mirror of
https://github.com/pkkid/python-plexapi
synced 2024-09-20 13:51:57 +00:00
Merge branch 'master' into hub
This commit is contained in:
commit
f479b8453b
74 changed files with 5017 additions and 2028 deletions
9
.coveragerc
Normal file
9
.coveragerc
Normal file
|
@ -0,0 +1,9 @@
|
|||
[report]
|
||||
exclude_lines =
|
||||
pragma: no cover
|
||||
raise NotImplementedError
|
||||
raise Unsupported
|
||||
except ImportError
|
||||
def __repr__
|
||||
def __bool__
|
||||
if __name__ == .__main__.:
|
22
.gitignore
vendored
22
.gitignore
vendored
|
@ -1,20 +1,22 @@
|
|||
syntax: glob
|
||||
*.db
|
||||
*.egg-info
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
*.sublime-*
|
||||
*.swp
|
||||
*__pycache__*
|
||||
dist
|
||||
build
|
||||
*.egg-info
|
||||
.idea/
|
||||
lib/
|
||||
bin/
|
||||
include/
|
||||
.cache/
|
||||
.idea/
|
||||
.Python
|
||||
|
||||
bin/
|
||||
build
|
||||
dist
|
||||
docs/_build/
|
||||
include/
|
||||
lib/
|
||||
pip-selfcheck.json
|
||||
|
||||
pyvenv.cfg
|
||||
htmlcov
|
||||
.coverage
|
||||
*.orig
|
28
.travis.yml
Normal file
28
.travis.yml
Normal file
|
@ -0,0 +1,28 @@
|
|||
#---------------------------------------------------------
|
||||
# Travis CI Build Environment
|
||||
# https://docs.travis-ci.com/user/customizing-the-build
|
||||
#---------------------------------------------------------
|
||||
language:
|
||||
- python
|
||||
|
||||
python:
|
||||
- "2.7"
|
||||
- "3.3"
|
||||
- "3.4"
|
||||
- "3.5"
|
||||
- "3.6"
|
||||
|
||||
before_install:
|
||||
- pip install -U pytest pytest-cov coveralls
|
||||
|
||||
install:
|
||||
- pip install -r requirements_dev.txt
|
||||
|
||||
script:
|
||||
- py.test tests --cov-config .coveragerc --cov=plexapi --cov-report=html
|
||||
|
||||
after_success:
|
||||
- coveralls
|
||||
|
||||
matrix:
|
||||
fast_finish: true
|
|
@ -9,7 +9,7 @@ are permitted provided that the following conditions are met:
|
|||
* Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
* Neither the name conky-pkmeter nor the names of its contributors
|
||||
* Neither the name python-plexapi nor the names of its contributors
|
||||
may be used to endorse or promote products derived from this software without
|
||||
specific prior written permission.
|
||||
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
include README.md
|
||||
include requirements.pip
|
||||
include requirements.txt
|
15
README.md
15
README.md
|
@ -1,4 +1,10 @@
|
|||
## PlexAPI ##
|
||||
<a href="https://badge.fury.io/py/PlexAPI">
|
||||
<img align="right" src="https://badge.fury.io/py/PlexAPI.svg"/></a>
|
||||
<a href='https://coveralls.io/github/mjs7231/python-plexapi'>
|
||||
<img align="right" src='https://coveralls.io/repos/github/mjs7231/python-plexapi/badge.svg' alt='Coverage Status' /></a>
|
||||
<a href="https://travis-ci.org/mjs7231/python-plexapi">
|
||||
<img align="right" src="https://travis-ci.org/mjs7231/python-plexapi.svg?branch=master"/></a>
|
||||
Python bindings for the Plex API.
|
||||
|
||||
* Navigate local or remote shared libraries.
|
||||
|
@ -43,11 +49,10 @@ plex = PlexServer(baseurl, token)
|
|||
#### Usage Examples ####
|
||||
|
||||
```python
|
||||
# Example 1: List all unwatched content in library.
|
||||
for section in plex.library.sections():
|
||||
print('Unwatched content in %s:' % section.title)
|
||||
for video in section.unwatched():
|
||||
print(' %s' % video.title)
|
||||
# Example 1: List all unwatched movies.
|
||||
movies = plex.library.section('Movies')
|
||||
for video in movies.search(unwatched=True):
|
||||
print(video.title)
|
||||
```
|
||||
```python
|
||||
# Example 2: Mark all Conan episodes watched.
|
||||
|
|
225
docs/Makefile
Normal file
225
docs/Makefile
Normal file
|
@ -0,0 +1,225 @@
|
|||
# Makefile for Sphinx documentation
|
||||
#
|
||||
|
||||
# You can set these variables from the command line.
|
||||
SPHINXOPTS =
|
||||
SPHINXBUILD = sphinx-build
|
||||
PAPER =
|
||||
BUILDDIR = _build
|
||||
|
||||
# Internal variables.
|
||||
PAPEROPT_a4 = -D latex_paper_size=a4
|
||||
PAPEROPT_letter = -D latex_paper_size=letter
|
||||
ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
|
||||
# the i18n builder cannot share the environment and doctrees with the others
|
||||
I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
|
||||
|
||||
.PHONY: help
|
||||
help:
|
||||
@echo "Please use \`make <target>' where <target> is one of"
|
||||
@echo " html to make standalone HTML files"
|
||||
@echo " dirhtml to make HTML files named index.html in directories"
|
||||
@echo " singlehtml to make a single large HTML file"
|
||||
@echo " pickle to make pickle files"
|
||||
@echo " json to make JSON files"
|
||||
@echo " htmlhelp to make HTML files and a HTML help project"
|
||||
@echo " qthelp to make HTML files and a qthelp project"
|
||||
@echo " applehelp to make an Apple Help Book"
|
||||
@echo " devhelp to make HTML files and a Devhelp project"
|
||||
@echo " epub to make an epub"
|
||||
@echo " epub3 to make an epub3"
|
||||
@echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
|
||||
@echo " latexpdf to make LaTeX files and run them through pdflatex"
|
||||
@echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx"
|
||||
@echo " text to make text files"
|
||||
@echo " man to make manual pages"
|
||||
@echo " texinfo to make Texinfo files"
|
||||
@echo " info to make Texinfo files and run them through makeinfo"
|
||||
@echo " gettext to make PO message catalogs"
|
||||
@echo " changes to make an overview of all changed/added/deprecated items"
|
||||
@echo " xml to make Docutils-native XML files"
|
||||
@echo " pseudoxml to make pseudoxml-XML files for display purposes"
|
||||
@echo " linkcheck to check all external links for integrity"
|
||||
@echo " doctest to run all doctests embedded in the documentation (if enabled)"
|
||||
@echo " coverage to run coverage check of the documentation (if enabled)"
|
||||
@echo " dummy to check syntax errors of document sources"
|
||||
|
||||
.PHONY: clean
|
||||
clean:
|
||||
rm -rf $(BUILDDIR)/*
|
||||
|
||||
.PHONY: html
|
||||
html:
|
||||
$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
|
||||
@echo
|
||||
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
|
||||
|
||||
.PHONY: dirhtml
|
||||
dirhtml:
|
||||
$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
|
||||
@echo
|
||||
@echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
|
||||
|
||||
.PHONY: singlehtml
|
||||
singlehtml:
|
||||
$(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
|
||||
@echo
|
||||
@echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
|
||||
|
||||
.PHONY: pickle
|
||||
pickle:
|
||||
$(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
|
||||
@echo
|
||||
@echo "Build finished; now you can process the pickle files."
|
||||
|
||||
.PHONY: json
|
||||
json:
|
||||
$(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
|
||||
@echo
|
||||
@echo "Build finished; now you can process the JSON files."
|
||||
|
||||
.PHONY: htmlhelp
|
||||
htmlhelp:
|
||||
$(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
|
||||
@echo
|
||||
@echo "Build finished; now you can run HTML Help Workshop with the" \
|
||||
".hhp project file in $(BUILDDIR)/htmlhelp."
|
||||
|
||||
.PHONY: qthelp
|
||||
qthelp:
|
||||
$(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
|
||||
@echo
|
||||
@echo "Build finished; now you can run "qcollectiongenerator" with the" \
|
||||
".qhcp project file in $(BUILDDIR)/qthelp, like this:"
|
||||
@echo "# qcollectiongenerator $(BUILDDIR)/qthelp/PythonPlexAPI.qhcp"
|
||||
@echo "To view the help file:"
|
||||
@echo "# assistant -collectionFile $(BUILDDIR)/qthelp/PythonPlexAPI.qhc"
|
||||
|
||||
.PHONY: applehelp
|
||||
applehelp:
|
||||
$(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp
|
||||
@echo
|
||||
@echo "Build finished. The help book is in $(BUILDDIR)/applehelp."
|
||||
@echo "N.B. You won't be able to view it unless you put it in" \
|
||||
"~/Library/Documentation/Help or install it in your application" \
|
||||
"bundle."
|
||||
|
||||
.PHONY: devhelp
|
||||
devhelp:
|
||||
$(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
|
||||
@echo
|
||||
@echo "Build finished."
|
||||
@echo "To view the help file:"
|
||||
@echo "# mkdir -p $$HOME/.local/share/devhelp/PythonPlexAPI"
|
||||
@echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/PythonPlexAPI"
|
||||
@echo "# devhelp"
|
||||
|
||||
.PHONY: epub
|
||||
epub:
|
||||
$(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
|
||||
@echo
|
||||
@echo "Build finished. The epub file is in $(BUILDDIR)/epub."
|
||||
|
||||
.PHONY: epub3
|
||||
epub3:
|
||||
$(SPHINXBUILD) -b epub3 $(ALLSPHINXOPTS) $(BUILDDIR)/epub3
|
||||
@echo
|
||||
@echo "Build finished. The epub3 file is in $(BUILDDIR)/epub3."
|
||||
|
||||
.PHONY: latex
|
||||
latex:
|
||||
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
|
||||
@echo
|
||||
@echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
|
||||
@echo "Run \`make' in that directory to run these through (pdf)latex" \
|
||||
"(use \`make latexpdf' here to do that automatically)."
|
||||
|
||||
.PHONY: latexpdf
|
||||
latexpdf:
|
||||
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
|
||||
@echo "Running LaTeX files through pdflatex..."
|
||||
$(MAKE) -C $(BUILDDIR)/latex all-pdf
|
||||
@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
|
||||
|
||||
.PHONY: latexpdfja
|
||||
latexpdfja:
|
||||
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
|
||||
@echo "Running LaTeX files through platex and dvipdfmx..."
|
||||
$(MAKE) -C $(BUILDDIR)/latex all-pdf-ja
|
||||
@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
|
||||
|
||||
.PHONY: text
|
||||
text:
|
||||
$(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
|
||||
@echo
|
||||
@echo "Build finished. The text files are in $(BUILDDIR)/text."
|
||||
|
||||
.PHONY: man
|
||||
man:
|
||||
$(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
|
||||
@echo
|
||||
@echo "Build finished. The manual pages are in $(BUILDDIR)/man."
|
||||
|
||||
.PHONY: texinfo
|
||||
texinfo:
|
||||
$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
|
||||
@echo
|
||||
@echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
|
||||
@echo "Run \`make' in that directory to run these through makeinfo" \
|
||||
"(use \`make info' here to do that automatically)."
|
||||
|
||||
.PHONY: info
|
||||
info:
|
||||
$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
|
||||
@echo "Running Texinfo files through makeinfo..."
|
||||
make -C $(BUILDDIR)/texinfo info
|
||||
@echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
|
||||
|
||||
.PHONY: gettext
|
||||
gettext:
|
||||
$(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
|
||||
@echo
|
||||
@echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
|
||||
|
||||
.PHONY: changes
|
||||
changes:
|
||||
$(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
|
||||
@echo
|
||||
@echo "The overview file is in $(BUILDDIR)/changes."
|
||||
|
||||
.PHONY: linkcheck
|
||||
linkcheck:
|
||||
$(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
|
||||
@echo
|
||||
@echo "Link check complete; look for any errors in the above output " \
|
||||
"or in $(BUILDDIR)/linkcheck/output.txt."
|
||||
|
||||
.PHONY: doctest
|
||||
doctest:
|
||||
$(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
|
||||
@echo "Testing of doctests in the sources finished, look at the " \
|
||||
"results in $(BUILDDIR)/doctest/output.txt."
|
||||
|
||||
.PHONY: coverage
|
||||
coverage:
|
||||
$(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage
|
||||
@echo "Testing of coverage in the sources finished, look at the " \
|
||||
"results in $(BUILDDIR)/coverage/python.txt."
|
||||
|
||||
.PHONY: xml
|
||||
xml:
|
||||
$(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml
|
||||
@echo
|
||||
@echo "Build finished. The XML files are in $(BUILDDIR)/xml."
|
||||
|
||||
.PHONY: pseudoxml
|
||||
pseudoxml:
|
||||
$(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml
|
||||
@echo
|
||||
@echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml."
|
||||
|
||||
.PHONY: dummy
|
||||
dummy:
|
||||
$(SPHINXBUILD) -b dummy $(ALLSPHINXOPTS) $(BUILDDIR)/dummy
|
||||
@echo
|
||||
@echo "Build finished. Dummy builder generates no files."
|
314
docs/conf.py
Normal file
314
docs/conf.py
Normal file
|
@ -0,0 +1,314 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Python PlexAPI documentation build configuration file, created by
|
||||
# sphinx-quickstart on Sun Jan 8 23:50:18 2017.
|
||||
#
|
||||
# This file is execfile()d with the current directory set to its
|
||||
# containing dir. Note that not all possible configuration values are
|
||||
# present in this autogenerated file. All configuration values have a
|
||||
# default; values that are commented out serve to show the default.
|
||||
|
||||
# If extensions (or modules to document with autodoc) are in another directory,
|
||||
# add these directories to sys.path here. If the directory is relative to the
|
||||
# documentation root, use os.path.abspath to make it absolute, like shown here.
|
||||
import copy, sys
|
||||
import sphinx_rtd_theme
|
||||
from os.path import abspath, dirname, join
|
||||
from recommonmark.parser import CommonMarkParser
|
||||
sys.path.insert(0, join(dirname(abspath('.')), 'plexapi'))
|
||||
import plexapi
|
||||
|
||||
extensions = [
|
||||
'sphinx.ext.autodoc',
|
||||
'sphinx.ext.viewcode',
|
||||
'sphinx.ext.githubpages',
|
||||
'sphinxcontrib.napoleon',
|
||||
]
|
||||
|
||||
# -- Monkey-patch docstring to not auto-link :ivars ------------------------
|
||||
from sphinx.domains.python import PythonDomain
|
||||
print('Monkey-patching PythonDomain.resolve_xref()')
|
||||
old_resolve_xref = copy.deepcopy(PythonDomain.resolve_xref)
|
||||
def new_resolve_xref(*args):
|
||||
if '.' not in args[5]: # target
|
||||
return None
|
||||
return old_resolve_xref(*args)
|
||||
PythonDomain.resolve_xref = new_resolve_xref
|
||||
|
||||
# -- Napoleon Settings -----------------------------------------------------
|
||||
napoleon_google_docstring = True
|
||||
napoleon_numpy_docstring = False
|
||||
napoleon_include_init_with_doc = False
|
||||
napoleon_include_private_with_doc = False
|
||||
napoleon_include_special_with_doc = False
|
||||
napoleon_use_admonition_for_examples = False
|
||||
napoleon_use_admonition_for_notes = False
|
||||
napoleon_use_admonition_for_references = False
|
||||
napoleon_use_ivar = True
|
||||
napoleon_use_param = True
|
||||
napoleon_use_rtype = True
|
||||
napoleon_use_keyword = True
|
||||
autodoc_member_order = 'bysource'
|
||||
|
||||
# -- General Configuration ------------------------------------------------
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
templates_path = ['../']
|
||||
|
||||
# The suffix(es) of source filenames.
|
||||
# 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']
|
||||
|
||||
# The encoding of source files.
|
||||
# source_encoding = 'utf-8-sig'
|
||||
|
||||
# The master toctree document.
|
||||
master_doc = 'index'
|
||||
|
||||
# General information about the project.
|
||||
project = 'Python PlexAPI'
|
||||
copyright = '2017, M.Shepanski'
|
||||
author = 'M.Shepanski'
|
||||
|
||||
# The version info for the project you're documenting, acts as replacement for
|
||||
# |version| and |release|, also used in various other places throughout the
|
||||
# built documents.
|
||||
# The short X.Y version.
|
||||
version = plexapi.VERSION
|
||||
# The full version, including alpha/beta/rc tags.
|
||||
#release = '2.0.2'
|
||||
|
||||
# The language for content autogenerated by Sphinx. Refer to documentation
|
||||
# for a list of supported languages.
|
||||
# This is also used if you do content translation via gettext catalogs.
|
||||
# Usually you set "language" from the command line for these cases.
|
||||
language = None
|
||||
|
||||
# There are two options for replacing |today|: either, you set today to some
|
||||
# non-false value, then it is used:
|
||||
# today = ''
|
||||
#
|
||||
# Else, today_fmt is used as the format for a strftime call.
|
||||
# today_fmt = '%B %d, %Y'
|
||||
|
||||
# List of patterns, relative to source directory, that match files and
|
||||
# directories to ignore when looking for source files.
|
||||
# This patterns also effect to html_static_path and html_extra_path
|
||||
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
|
||||
|
||||
# The reST default role (used for this markup: `text`) to use for all
|
||||
# documents.
|
||||
# default_role = None
|
||||
|
||||
# If true, '()' will be appended to :func: etc. cross-reference text.
|
||||
# add_function_parentheses = True
|
||||
|
||||
# If true, the current module name will be prepended to all description
|
||||
# unit titles (such as .. function::).
|
||||
# add_module_names = True
|
||||
|
||||
# If true, sectionauthor and moduleauthor directives will be shown in the
|
||||
# output. They are ignored by default.
|
||||
# show_authors = False
|
||||
|
||||
# The name of the Pygments (syntax highlighting) style to use.
|
||||
pygments_style = 'sphinx'
|
||||
|
||||
# A list of ignored prefixes for module index sorting.
|
||||
# modindex_common_prefix = []
|
||||
|
||||
# If true, keep warnings as "system message" paragraphs in the built documents.
|
||||
# keep_warnings = False
|
||||
|
||||
# If true, `todo` and `todoList` produce output, else they produce nothing.
|
||||
todo_include_todos = False
|
||||
|
||||
|
||||
# -- Options for HTML output ----------------------------------------------
|
||||
# The theme to use for HTML and HTML Help pages. See the documentation for
|
||||
# a list of builtin themes.
|
||||
# html_theme = 'alabaster'
|
||||
html_theme = "sphinx_rtd_theme"
|
||||
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
|
||||
html_theme_options = {
|
||||
'collapse_navigation': False,
|
||||
'display_version': False,
|
||||
#'navigation_depth': 3,
|
||||
}
|
||||
|
||||
# Theme options are theme-specific and customize the look and feel of a theme
|
||||
# further. For a list of options available for each theme, see the
|
||||
# documentation.
|
||||
# html_theme_options = {}
|
||||
|
||||
# Add any paths that contain custom themes here, relative to this directory.
|
||||
# html_theme_path = []
|
||||
|
||||
# The name for this set of Sphinx documents.
|
||||
# "<project> v<release> documentation" by default.
|
||||
# html_title = 'Python PlexAPI v2'
|
||||
|
||||
# A shorter title for the navigation bar. Default is the same as html_title.
|
||||
# html_short_title = None
|
||||
|
||||
# The name of an image file (relative to this directory) to place at the top
|
||||
# of the sidebar.
|
||||
# html_logo = None
|
||||
|
||||
# The name of an image file (relative to this directory) to use as a favicon of
|
||||
# the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
|
||||
# pixels large.
|
||||
# html_favicon = None
|
||||
|
||||
# Add any paths that contain custom static files (such as style sheets) here,
|
||||
# relative to this directory. They are copied after the builtin static files,
|
||||
# so a file named "default.css" will overwrite the builtin "default.css".
|
||||
# html_static_path = ['static']
|
||||
|
||||
# Add any extra paths that contain custom files (such as robots.txt or
|
||||
# .htaccess) here, relative to this directory. These files are copied
|
||||
# directly to the root of the documentation.
|
||||
# html_extra_path = []
|
||||
|
||||
# If not None, a 'Last updated on:' timestamp is inserted at every page
|
||||
# bottom, using the given strftime format.
|
||||
# The empty string is equivalent to '%b %d, %Y'.
|
||||
# html_last_updated_fmt = None
|
||||
|
||||
# If true, SmartyPants will be used to convert quotes and dashes to
|
||||
# typographically correct entities.
|
||||
# html_use_smartypants = True
|
||||
|
||||
# Custom sidebar templates, maps document names to template names.
|
||||
# html_sidebars = {}
|
||||
|
||||
# Additional templates that should be rendered to pages, maps page names to
|
||||
# template names.
|
||||
# html_additional_pages = {}
|
||||
|
||||
# If false, no module index is generated.
|
||||
# html_domain_indices = True
|
||||
|
||||
# If false, no index is generated.
|
||||
# html_use_index = True
|
||||
|
||||
# If true, the index is split into individual pages for each letter.
|
||||
# html_split_index = False
|
||||
|
||||
# If true, links to the reST sources are added to the pages.
|
||||
# html_show_sourcelink = True
|
||||
|
||||
# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
|
||||
# html_show_sphinx = True
|
||||
|
||||
# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
|
||||
# html_show_copyright = True
|
||||
|
||||
# If true, an OpenSearch description file will be output, and all pages will
|
||||
# contain a <link> tag referring to it. The value of this option must be the
|
||||
# base URL from which the finished HTML is served.
|
||||
# html_use_opensearch = ''
|
||||
|
||||
# This is the file name suffix for HTML files (e.g. ".xhtml").
|
||||
# html_file_suffix = None
|
||||
|
||||
# Language to be used for generating the HTML full-text search index.
|
||||
# Sphinx supports the following languages:
|
||||
# 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja'
|
||||
# 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr', 'zh'
|
||||
# html_search_language = 'en'
|
||||
|
||||
# A dictionary with options for the search language support, empty by default.
|
||||
# 'ja' uses this config value.
|
||||
# 'zh' user can custom change `jieba` dictionary path.
|
||||
# html_search_options = {'type': 'default'}
|
||||
|
||||
# The name of a javascript file (relative to the configuration directory) that
|
||||
# implements a search results scorer. If empty, the default will be used.
|
||||
# html_search_scorer = 'scorer.js'
|
||||
|
||||
# Output file base name for HTML help builder.
|
||||
htmlhelp_basename = 'PythonPlexAPIdoc'
|
||||
|
||||
|
||||
# -- Options for LaTeX output ---------------------------------------------
|
||||
latex_elements = {
|
||||
# The paper size ('letterpaper' or 'a4paper').
|
||||
# 'papersize': 'letterpaper',
|
||||
|
||||
# The font size ('10pt', '11pt' or '12pt').
|
||||
# 'pointsize': '10pt',
|
||||
|
||||
# Additional stuff for the LaTeX preamble.
|
||||
# 'preamble': '',
|
||||
|
||||
# Latex figure (float) alignment
|
||||
# 'figure_align': 'htbp',
|
||||
}
|
||||
|
||||
# Grouping the document tree into LaTeX files. List of tuples
|
||||
# (source start file, target name, title,
|
||||
# author, documentclass [howto, manual, or own class]).
|
||||
latex_documents = [
|
||||
(master_doc, 'PythonPlexAPI.tex', 'Python PlexAPI Documentation',
|
||||
'M.Shepanski', 'manual'),
|
||||
]
|
||||
|
||||
# The name of an image file (relative to this directory) to place at the top of
|
||||
# the title page.
|
||||
# latex_logo = None
|
||||
|
||||
# For "manual" documents, if this is true, then toplevel headings are parts,
|
||||
# not chapters.
|
||||
# latex_use_parts = False
|
||||
|
||||
# If true, show page references after internal links.
|
||||
# latex_show_pagerefs = False
|
||||
|
||||
# If true, show URL addresses after external links.
|
||||
# latex_show_urls = False
|
||||
|
||||
# Documents to append as an appendix to all manuals.
|
||||
# latex_appendices = []
|
||||
|
||||
# If false, no module index is generated.
|
||||
# latex_domain_indices = True
|
||||
|
||||
|
||||
# -- Options for manual page output ---------------------------------------
|
||||
# One entry per manual page. List of tuples
|
||||
# (source start file, name, description, authors, manual section).
|
||||
man_pages = [
|
||||
(master_doc, 'pythonplexapi', 'Python PlexAPI Documentation',
|
||||
[author], 1)
|
||||
]
|
||||
|
||||
# If true, show URL addresses after external links.
|
||||
# man_show_urls = False
|
||||
|
||||
|
||||
# -- Options for Texinfo output -------------------------------------------
|
||||
|
||||
# Grouping the document tree into Texinfo files. List of tuples
|
||||
# (source start file, target name, title, author,
|
||||
# dir menu entry, description, category)
|
||||
texinfo_documents = [
|
||||
(master_doc, 'PythonPlexAPI', 'Python PlexAPI Documentation',
|
||||
author, 'PythonPlexAPI', 'One line description of project.',
|
||||
'Miscellaneous'),
|
||||
]
|
||||
|
||||
# Documents to append as an appendix to all manuals.
|
||||
# texinfo_appendices = []
|
||||
|
||||
# If false, no module index is generated.
|
||||
# texinfo_domain_indices = True
|
||||
|
||||
# How to display URL addresses: 'footnote', 'no', or 'inline'.
|
||||
# texinfo_show_urls = 'footnote'
|
||||
|
||||
# If true, do not generate a @detailmenu in the "Top" node's menu.
|
||||
# texinfo_no_detailmenu = False
|
4
docs/configuration.rst
Normal file
4
docs/configuration.rst
Normal file
|
@ -0,0 +1,4 @@
|
|||
Configuration
|
||||
=============
|
||||
|
||||
dasfasd
|
10
docs/index.rst
Normal file
10
docs/index.rst
Normal file
|
@ -0,0 +1,10 @@
|
|||
Python PlexAPI
|
||||
==============
|
||||
.. include:: toc.rst
|
||||
.. automodule:: myplex
|
||||
|
||||
Usage & Contributions
|
||||
---------------------
|
||||
* Source is available on the `Github Project Page <https://github.com/mjs7231/python-plexapi>`_.
|
||||
* Contributors to python-plexapi own their own contributions and may distribute that code under
|
||||
the `BSD license <https://github.com/mjs7231/python-plexapi/blob/master/LICENSE.txt>`_.
|
145
docs/introduction.rst
Normal file
145
docs/introduction.rst
Normal file
|
@ -0,0 +1,145 @@
|
|||
Getting Started
|
||||
===============
|
||||
|
||||
.. |br| raw:: html
|
||||
|
||||
<br />
|
||||
|
||||
Python bindings for the Plex API.
|
||||
|
||||
* Navigate local or remote shared libraries.
|
||||
* Mark shows watched or unwatched.
|
||||
* Request rescan, analyze, empty trash.
|
||||
* Play media on connected clients.
|
||||
* Get URL to stream stream h264/aac video (playable in VLC,MPV,etc).
|
||||
* Plex Sync Support.
|
||||
* Plex Audio Support.
|
||||
|
||||
Installation
|
||||
------------
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
pip install plexapi
|
||||
|
||||
|
||||
Getting a PlexServer Instance
|
||||
-----------------------------
|
||||
|
||||
There are two types of authentication. If you are running on a separate network
|
||||
or using Plex Users you can log into MyPlex to get a PlexServer instance. An
|
||||
example of this is below. NOTE: Servername below is the name of the server (not
|
||||
the hostname and port). If logged into Plex Web you can see the server name in
|
||||
the top left above your available libraries.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from plexapi.myplex import MyPlexAccount
|
||||
account = MyPlexAccount.signin('<USERNAME>', '<PASSWORD>')
|
||||
plex = account.resource('<SERVERNAME>').connect() # returns a PlexServer instance
|
||||
|
||||
If you want to avoid logging into MyPlex and you already know your auth token
|
||||
string, you can use the PlexServer object directly as above, but passing in
|
||||
the baseurl and auth token directly.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from plexapi.server import PlexServer
|
||||
baseurl = 'http://plexserver:32400'
|
||||
token = '2ffLuB84dqLswk9skLos'
|
||||
plex = PlexServer(baseurl, token)
|
||||
|
||||
|
||||
Usage Examples
|
||||
--------------
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Example 1: List all unwatched movies.
|
||||
movies = plex.library.section('Movies')
|
||||
for video in movies.search(unwatched=True):
|
||||
print(video.title)
|
||||
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Example 2: Mark all Conan episodes watched.
|
||||
plex.library.get('Conan (2010)').markWatched()
|
||||
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Example 3: List all clients connected to the Server.
|
||||
for client in plex.clients():
|
||||
print(client.title)
|
||||
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Example 4: Play the movie Avatar on another client.
|
||||
# Note: Client must be on same network as server.
|
||||
avatar = plex.library.section('Movies').get('Avatar')
|
||||
client = plex.client("Michael's iPhone")
|
||||
client.playMedia(avatar)
|
||||
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Example 5: List all content with the word 'Game' in the title.
|
||||
for video in plex.search('Game'):
|
||||
print('%s (%s)' % (video.title, video.TYPE))
|
||||
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Example 6: List all movies directed by the same person as Jurassic Park.
|
||||
movies = plex.library.section('Movies')
|
||||
jurassic_park = movies.get('Jurassic Park')
|
||||
director = jurassic_park.directors[0]
|
||||
for movie in movies.search(None, director=director):
|
||||
print(movie.title)
|
||||
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Example 7: List files for the latest episode of Friends.
|
||||
thelastone = plex.library.get('Friends').episodes()[-1]
|
||||
for part in thelastone.iterParts():
|
||||
print(part.file)
|
||||
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Example 8: Get a URL to stream a movie or show in another client
|
||||
jurassic_park = plex.library.section('Movies').get('Jurassic Park')
|
||||
print 'Run running the following command to play in VLC:'
|
||||
print 'vlc "%s"' % jurassic_park.getStreamUrl(videoResolution='800x600')
|
||||
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Example 9: Get audio/video/all playlists
|
||||
for playlist in self.plex.playlists():
|
||||
print(playlist.title)
|
||||
|
||||
|
||||
FAQs
|
||||
----
|
||||
|
||||
**Q. Why are you using camelCase and not following PEP8 guidelines?** |br|
|
||||
A. This API reads XML documents provided by MyPlex and the Plex Server.
|
||||
We decided to conform to their style so that the API variable names directly
|
||||
match with the provided XML documents.
|
||||
|
||||
|
||||
**Q. Why don't you offer feature XYZ?** |br|
|
||||
A. This library is meant to be a wrapper around the XML pages the Plex
|
||||
server provides. If we are not providing an API that is offerered in the
|
||||
XML pages, please let us know! -- Adding additional features beyond that
|
||||
should be done outside the scope of this library.
|
||||
|
||||
|
||||
**Q. What are some helpful links if trying to understand the raw Plex API?** |br|
|
||||
https://github.com/plexinc/plex-media-player/wiki/Remote-control-API |br|
|
||||
https://forums.plex.tv/discussion/104353/pms-web-api-documentation |br|
|
||||
https://github.com/Arcanemagus/plex-api/wiki |br|
|
5
docs/modules/audio.rst
Normal file
5
docs/modules/audio.rst
Normal file
|
@ -0,0 +1,5 @@
|
|||
Audio (plexapi.audio)
|
||||
---------------------
|
||||
.. automodule:: plexapi.audio
|
||||
:members:
|
||||
:show-inheritance:
|
5
docs/modules/client.rst
Normal file
5
docs/modules/client.rst
Normal file
|
@ -0,0 +1,5 @@
|
|||
Client (plexapi.client)
|
||||
-----------------------
|
||||
.. automodule:: plexapi.client
|
||||
:members:
|
||||
:show-inheritance:
|
5
docs/modules/config.rst
Normal file
5
docs/modules/config.rst
Normal file
|
@ -0,0 +1,5 @@
|
|||
Config (plexapi.config)
|
||||
-----------------------
|
||||
.. automodule:: plexapi.config
|
||||
:members:
|
||||
:show-inheritance:
|
5
docs/modules/exceptions.rst
Normal file
5
docs/modules/exceptions.rst
Normal file
|
@ -0,0 +1,5 @@
|
|||
Exceptions (plexapi.exceptions)
|
||||
-------------------------------
|
||||
.. automodule:: plexapi.exceptions
|
||||
:members:
|
||||
:show-inheritance:
|
5
docs/modules/library.rst
Normal file
5
docs/modules/library.rst
Normal file
|
@ -0,0 +1,5 @@
|
|||
Library (plexapi.library)
|
||||
-------------------------
|
||||
.. automodule:: plexapi.library
|
||||
:members:
|
||||
:show-inheritance:
|
5
docs/modules/media.rst
Normal file
5
docs/modules/media.rst
Normal file
|
@ -0,0 +1,5 @@
|
|||
Media (plexapi.media)
|
||||
---------------------
|
||||
.. automodule:: plexapi.media
|
||||
:members:
|
||||
:show-inheritance:
|
5
docs/modules/myplex.rst
Normal file
5
docs/modules/myplex.rst
Normal file
|
@ -0,0 +1,5 @@
|
|||
MyPlex (plexapi.myplex)
|
||||
-----------------------
|
||||
.. automodule:: plexapi.myplex
|
||||
:members:
|
||||
:show-inheritance:
|
5
docs/modules/photo.rst
Normal file
5
docs/modules/photo.rst
Normal file
|
@ -0,0 +1,5 @@
|
|||
Photo (plexapi.photo)
|
||||
---------------------
|
||||
.. automodule:: plexapi.photo
|
||||
:members:
|
||||
:show-inheritance:
|
5
docs/modules/playlist.rst
Normal file
5
docs/modules/playlist.rst
Normal file
|
@ -0,0 +1,5 @@
|
|||
Playlist (plexapi.playlist)
|
||||
---------------------------
|
||||
.. automodule:: plexapi.playlist
|
||||
:members:
|
||||
:show-inheritance:
|
5
docs/modules/playqueue.rst
Normal file
5
docs/modules/playqueue.rst
Normal file
|
@ -0,0 +1,5 @@
|
|||
Playqueue (plexapi.playqueue)
|
||||
-----------------------------
|
||||
.. automodule:: plexapi.playqueue
|
||||
:members:
|
||||
:show-inheritance:
|
5
docs/modules/server.rst
Normal file
5
docs/modules/server.rst
Normal file
|
@ -0,0 +1,5 @@
|
|||
Server (plexapi.server)
|
||||
-----------------------
|
||||
.. automodule:: plexapi.server
|
||||
:members:
|
||||
:show-inheritance:
|
5
docs/modules/sync.rst
Normal file
5
docs/modules/sync.rst
Normal file
|
@ -0,0 +1,5 @@
|
|||
Sync (plexapi.sync)
|
||||
-------------------
|
||||
.. automodule:: plexapi.sync
|
||||
:members:
|
||||
:show-inheritance:
|
5
docs/modules/utils.rst
Normal file
5
docs/modules/utils.rst
Normal file
|
@ -0,0 +1,5 @@
|
|||
Utils (plexapi.utils)
|
||||
---------------------
|
||||
.. automodule:: plexapi.utils
|
||||
:members:
|
||||
:show-inheritance:
|
5
docs/modules/video.rst
Normal file
5
docs/modules/video.rst
Normal file
|
@ -0,0 +1,5 @@
|
|||
Video (plexapi.video)
|
||||
-----------------------
|
||||
.. automodule:: plexapi.video
|
||||
:members:
|
||||
:show-inheritance:
|
27
docs/toc.rst
Normal file
27
docs/toc.rst
Normal file
|
@ -0,0 +1,27 @@
|
|||
.. toctree::
|
||||
:maxdepth: 1
|
||||
:caption: Table of Contents
|
||||
:titlesonly:
|
||||
|
||||
self
|
||||
introduction
|
||||
configuration
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
:caption: Modules
|
||||
|
||||
modules/audio
|
||||
modules/client
|
||||
modules/config
|
||||
modules/exceptions
|
||||
modules/library
|
||||
modules/media
|
||||
modules/myplex
|
||||
modules/photo
|
||||
modules/playlist
|
||||
modules/playqueue
|
||||
modules/server
|
||||
modules/sync
|
||||
modules/utils
|
||||
modules/video
|
|
@ -3,11 +3,12 @@ import logging, os
|
|||
from logging.handlers import RotatingFileHandler
|
||||
from platform import uname
|
||||
from plexapi.config import PlexConfig, reset_base_headers
|
||||
from plexapi.utils import SecretsFilter
|
||||
from uuid import getnode
|
||||
|
||||
|
||||
# Load User Defined Config
|
||||
CONFIG_PATH = os.path.expanduser('~/.config/plexapi/config.ini')
|
||||
DEFAULT_CONFIG_PATH = os.path.expanduser('~/.config/plexapi/config.ini')
|
||||
CONFIG_PATH = os.environ.get('PLEX_CONFIG_PATH', DEFAULT_CONFIG_PATH)
|
||||
CONFIG = PlexConfig(CONFIG_PATH)
|
||||
|
||||
# Core Settings
|
||||
|
@ -40,3 +41,6 @@ if logfile:
|
|||
loghandler.setFormatter(logging.Formatter(logformat))
|
||||
log.addHandler(loghandler)
|
||||
log.setLevel(loglevel)
|
||||
logfilter = SecretsFilter()
|
||||
if CONFIG.get('logging.show_secrets') != 'true':
|
||||
log.addFilter(logfilter)
|
||||
|
|
334
plexapi/audio.py
334
plexapi/audio.py
|
@ -1,5 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from plexapi import media, utils
|
||||
from plexapi.utils import Playable, PlexPartialObject
|
||||
|
||||
|
@ -7,45 +6,37 @@ NA = utils.NA
|
|||
|
||||
|
||||
class Audio(PlexPartialObject):
|
||||
"""Base class for audio.
|
||||
""" Base class for audio :class:`~plexapi.audio.Artist`, :class:`~plexapi.audio.Album`
|
||||
and :class:`~plexapi.audio.Track` objects.
|
||||
|
||||
Attributes:
|
||||
addedAt (int): int from epoch, datetime.datetime
|
||||
index (sting): 1
|
||||
key (str): Fx /library/metadata/102631
|
||||
lastViewedAt (datetime.datetime): parse int into datetime.datetime.
|
||||
librarySectionID (int):
|
||||
listType (str): audio
|
||||
ratingKey (int): Unique key to identify this item
|
||||
summary (str): Summery of the artist, track, album
|
||||
thumb (str): Url to thumb image
|
||||
title (str): Fx Aerosmith
|
||||
titleSort (str): Defaults title if None
|
||||
TYPE (str): overwritten by subclass
|
||||
type (string, NA): Description
|
||||
updatedAt (datatime.datetime): parse int to datetime.datetime
|
||||
viewCount (int): How many time has this item been played
|
||||
Parameters:
|
||||
server (:class:`~plexapi.server.PlexServer`): PlexServer this client is connected to (optional)
|
||||
data (ElementTree): Response from PlexServer used to build this object (optional).
|
||||
initpath (str): Relative path requested when retrieving specified `data` (optional).
|
||||
|
||||
Attributes:
|
||||
addedAt (datetime): Datetime this item was added to the library.
|
||||
index (sting): Index Number (often the track number).
|
||||
key (str): API URL (/library/metadata/<ratingkey>).
|
||||
lastViewedAt (datetime): Datetime item was last accessed.
|
||||
librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID.
|
||||
listType (str): Hardcoded as 'audio' (useful for search filters).
|
||||
ratingKey (int): Unique key identifying this item.
|
||||
summary (str): Summary of the artist, track, or album.
|
||||
thumb (str): URL to thumbnail image.
|
||||
title (str): Artist, Album or Track title. (Jason Mraz, We Sing, Lucky, etc.)
|
||||
titleSort (str): Title to use when sorting (defaults to title).
|
||||
type (str): 'artist', 'album', or 'track'.
|
||||
updatedAt (datatime): Datetime this item was updated.
|
||||
viewCount (int): Count of times this item was accessed.
|
||||
"""
|
||||
TYPE = None
|
||||
|
||||
def __init__(self, server, data, initpath):
|
||||
"""Used to set the attributes.
|
||||
|
||||
Args:
|
||||
server (Plexserver): PMS your connected to
|
||||
data (Element): XML reponse from PMS as Element
|
||||
normally built from server.query
|
||||
initpath (str): Fx /library/sections/7/all
|
||||
"""
|
||||
super(Audio, self).__init__(data, initpath, server)
|
||||
|
||||
def _loadData(self, data):
|
||||
"""Used to set the attributes.
|
||||
|
||||
Args:
|
||||
data (Element): XML reponse from PMS as Element
|
||||
normally built from server.query
|
||||
"""
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self.listType = 'audio'
|
||||
self.addedAt = utils.toDatetime(data.attrib.get('addedAt', NA))
|
||||
self.index = data.attrib.get('index', NA)
|
||||
|
@ -63,125 +54,133 @@ class Audio(PlexPartialObject):
|
|||
|
||||
@property
|
||||
def thumbUrl(self):
|
||||
"""Return url to thumb image."""
|
||||
""" Returns the URL to this items thumbnail image. """
|
||||
if self.thumb:
|
||||
return self.server.url(self.thumb)
|
||||
|
||||
def refresh(self):
|
||||
"""Refresh the metadata."""
|
||||
""" Tells Plex to refresh the metadata for this and all subitems. """
|
||||
self.server.query('%s/refresh' % self.key, method=self.server.session.put)
|
||||
|
||||
def section(self):
|
||||
"""Library section."""
|
||||
""" Returns the :class:`~plexapi.library.LibrarySection` this item belongs to. """
|
||||
return self.server.library.sectionByID(self.librarySectionID)
|
||||
|
||||
|
||||
@utils.register_libtype
|
||||
class Artist(Audio):
|
||||
"""Artist.
|
||||
""" Represents a single audio artist.
|
||||
|
||||
Attributes:
|
||||
art (str): /library/metadata/102631/art/1469310342
|
||||
countries (list): List of media.County fx [<Country:24200:United.States>]
|
||||
genres (list): List of media.Genre fx [<Genre:25555:Classic.Rock>]
|
||||
guid (str): Fx guid com.plexapp.agents.plexmusic://gracenote/artist/05517B8701668D28?lang=en
|
||||
key (str): Fx /library/metadata/102631
|
||||
location (str): Filepath
|
||||
similar (list): List of media.Similar fx [<Similar:25220:Guns.N'.Roses>]
|
||||
TYPE (str): artist
|
||||
Parameters:
|
||||
server (:class:`~plexapi.server.PlexServer`): PlexServer this client is connected to (optional)
|
||||
data (ElementTree): Response from PlexServer used to build this object (optional).
|
||||
initpath (str): Relative path requested when retrieving specified `data` (optional).
|
||||
|
||||
Attributes:
|
||||
art (str): Artist artwork (/library/metadata/<ratingkey>/art/<artid>)
|
||||
countries (list): List of :class:`~plexapi.media.Country` objects this artist respresents.
|
||||
genres (list): List of :class:`~plexapi.media.Genre` objects this artist respresents.
|
||||
guid (str): Unknown (unique ID; com.plexapp.agents.plexmusic://gracenote/artist/05517B8701668D28?lang=en)
|
||||
key (str): API URL (/library/metadata/<ratingkey>).
|
||||
location (str): Filepath this artist is found on disk.
|
||||
similar (list): List of :class:`~plexapi.media.Similar` artists.
|
||||
"""
|
||||
|
||||
TYPE = 'artist'
|
||||
|
||||
def _loadData(self, data):
|
||||
"""Used to set the attributes.
|
||||
|
||||
Args:
|
||||
data (Element): XML reponse from PMS as Element
|
||||
normally built from server.query
|
||||
"""
|
||||
""" Load attribute values from Plex XML response. """
|
||||
Audio._loadData(self, data)
|
||||
self.art = data.attrib.get('art', NA)
|
||||
self.guid = data.attrib.get('guid', NA)
|
||||
self.key = self.key.replace('/children', '') # FIX_BUG_50
|
||||
self.location = utils.findLocations(data, single=True)
|
||||
if self.isFullObject(): # check if this is needed
|
||||
self.countries = [media.Country(self.server, e)
|
||||
for e in data if e.tag == media.Country.TYPE]
|
||||
self.genres = [media.Genre(self.server, e)
|
||||
for e in data if e.tag == media.Genre.TYPE]
|
||||
self.similar = [media.Similar(self.server, e)
|
||||
for e in data if e.tag == media.Similar.TYPE]
|
||||
self.countries = [media.Country(self.server, e) for e in data if e.tag == media.Country.TYPE]
|
||||
self.genres = [media.Genre(self.server, e) for e in data if e.tag == media.Genre.TYPE]
|
||||
self.similar = [media.Similar(self.server, e) for e in data if e.tag == media.Similar.TYPE]
|
||||
|
||||
def albums(self):
|
||||
"""Return a list of Albums by thus artist."""
|
||||
""" Returns a list of :class:`~plexapi.audio.Album` objects by this artist. """
|
||||
path = '%s/children' % self.key
|
||||
return utils.listItems(self.server, path, Album.TYPE)
|
||||
|
||||
def album(self, title):
|
||||
"""Return a album from this artist that match title."""
|
||||
""" Returns the :class:`~plexapi.audio.Album` that matches the specified title.
|
||||
|
||||
Parameters:
|
||||
title (str): Title of the album to return.
|
||||
"""
|
||||
path = '%s/children' % self.key
|
||||
return utils.findItem(self.server, path, title)
|
||||
|
||||
def tracks(self, watched=None):
|
||||
"""Return all tracks to this artist.
|
||||
|
||||
Args:
|
||||
watched(None, False, True): Default to None.
|
||||
|
||||
Returns:
|
||||
List: of Track
|
||||
"""
|
||||
def tracks(self):
|
||||
""" Returns a list of :class:`~plexapi.audio.Track` objects by this artist. """
|
||||
path = '%s/allLeaves' % self.key
|
||||
return utils.listItems(self.server, path, watched=watched)
|
||||
return utils.listItems(self.server, path)
|
||||
|
||||
def track(self, title):
|
||||
"""Return a Track that matches title.
|
||||
""" Returns the :class:`~plexapi.audio.Track` that matches the specified title.
|
||||
|
||||
Args:
|
||||
title (str): Fx song name
|
||||
|
||||
Returns:
|
||||
Track:
|
||||
Parameters:
|
||||
title (str): Title of the track to return.
|
||||
"""
|
||||
path = '%s/allLeaves' % self.key
|
||||
return utils.findItem(self.server, path, title)
|
||||
|
||||
def get(self, title):
|
||||
"""Alias. See track."""
|
||||
""" Alias of :func:`~plexapi.audio.Artist.track`. """
|
||||
return self.track(title)
|
||||
|
||||
def download(self, savepath=None, keep_orginal_name=False, **kwargs):
|
||||
""" Downloads all tracks for this artist to the specified location.
|
||||
|
||||
Parameters:
|
||||
savepath (str): Title of the track to return.
|
||||
keep_orginal_name (bool): Set True to keep the original filename as stored in
|
||||
the Plex server. False will create a new filename with the format
|
||||
"<Atrist> - <Album> <Track>".
|
||||
kwargs (dict): If specified, a :func:`~plexapi.audio.Track.getStreamURL()` will
|
||||
be returned and the additional arguments passed in will be sent to that
|
||||
function. If kwargs is not specified, the media items will be downloaded
|
||||
and saved to disk.
|
||||
"""
|
||||
downloaded = []
|
||||
for album in self.albums():
|
||||
for track in album.tracks():
|
||||
dl = track.download(savepath=savepath, keep_orginal_name=keep_orginal_name, **kwargs)
|
||||
if dl:
|
||||
downloaded.extend(dl)
|
||||
return downloaded
|
||||
|
||||
|
||||
@utils.register_libtype
|
||||
class Album(Audio):
|
||||
"""Album.
|
||||
""" Represents a single audio album.
|
||||
|
||||
Attributes:
|
||||
art (str): Fx /library/metadata/102631/art/1469310342
|
||||
genres (list): List of media.Genre
|
||||
key (str): Fx /library/metadata/102632
|
||||
originallyAvailableAt (TYPE): Description
|
||||
parentKey (str): /library/metadata/102631
|
||||
parentRatingKey (int): Fx 1337
|
||||
parentThumb (TYPE): Relative url to parent thumb image
|
||||
parentTitle (str): Aerosmith
|
||||
studio (str):
|
||||
TYPE (str): album
|
||||
year (int): 1999
|
||||
Parameters:
|
||||
server (:class:`~plexapi.server.PlexServer`): PlexServer this client is connected to (optional)
|
||||
data (ElementTree): Response from PlexServer used to build this object (optional).
|
||||
initpath (str): Relative path requested when retrieving specified `data` (optional).
|
||||
|
||||
Attributes:
|
||||
art (str): Album artwork (/library/metadata/<ratingkey>/art/<artid>)
|
||||
genres (list): List of :class:`~plexapi.media.Genre` objects this album respresents.
|
||||
key (str): API URL (/library/metadata/<ratingkey>).
|
||||
originallyAvailableAt (datetime): Datetime this album was released.
|
||||
parentKey (str): API URL of this artist.
|
||||
parentRatingKey (int): Unique key identifying artist.
|
||||
parentThumb (str): URL to artist thumbnail image.
|
||||
parentTitle (str): Name of the artist for this album.
|
||||
studio (str): Studio that released this album.
|
||||
year (int): Year this album was released.
|
||||
"""
|
||||
|
||||
TYPE = 'album'
|
||||
|
||||
def _loadData(self, data):
|
||||
"""Used to set the attributes.
|
||||
|
||||
Args:
|
||||
data (Element): XML reponse from PMS as Element
|
||||
normally built from server.query
|
||||
"""
|
||||
""" Load attribute values from Plex XML response. """
|
||||
Audio._loadData(self, data)
|
||||
self.art = data.attrib.get('art', NA)
|
||||
self.key = self.key.replace('/children', '') # FIX_BUG_50
|
||||
self.key = self.key.replace('/children', '') # fixes bug #50
|
||||
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt', NA), '%Y-%m-%d')
|
||||
self.parentKey = data.attrib.get('parentKey', NA)
|
||||
self.parentRatingKey = data.attrib.get('parentRatingKey', NA)
|
||||
|
@ -192,88 +191,91 @@ class Album(Audio):
|
|||
if self.isFullObject():
|
||||
self.genres = [media.Genre(self.server, e) for e in data if e.tag == media.Genre.TYPE]
|
||||
|
||||
def tracks(self, watched=None):
|
||||
"""Return all tracks to this album.
|
||||
|
||||
Args:
|
||||
watched(None, False, True): Default to None.
|
||||
|
||||
Returns:
|
||||
List: of Track
|
||||
"""
|
||||
def tracks(self):
|
||||
""" Returns a list of :class:`~plexapi.audio.Track` objects in this album. """
|
||||
path = '%s/children' % self.key
|
||||
return utils.listItems(self.server, path, watched=watched)
|
||||
return utils.listItems(self.server, path)
|
||||
|
||||
def track(self, title):
|
||||
"""Return a Track that matches title.
|
||||
""" Returns the :class:`~plexapi.audio.Track` that matches the specified title.
|
||||
|
||||
Args:
|
||||
title (str): Fx song name
|
||||
|
||||
Returns:
|
||||
Track:
|
||||
Parameters:
|
||||
title (str): Title of the track to return.
|
||||
"""
|
||||
path = '%s/children' % self.key
|
||||
return utils.findItem(self.server, path, title)
|
||||
|
||||
def get(self, title):
|
||||
"""Alias. See track."""
|
||||
""" Alias of :func:`~plexapi.audio.Album.track`. """
|
||||
return self.track(title)
|
||||
|
||||
def artist(self):
|
||||
"""Return Artist of this album."""
|
||||
""" Return :func:`~plexapi.audio.Artist` of this album. """
|
||||
return utils.listItems(self.server, self.parentKey)[0]
|
||||
|
||||
def watched(self):
|
||||
"""Return Track that is lisson on."""
|
||||
return self.tracks(watched=True)
|
||||
def download(self, savepath=None, keep_orginal_name=False, **kwargs):
|
||||
""" Downloads all tracks for this artist to the specified location.
|
||||
|
||||
Parameters:
|
||||
savepath (str): Title of the track to return.
|
||||
keep_orginal_name (bool): Set True to keep the original filename as stored in
|
||||
the Plex server. False will create a new filename with the format
|
||||
"<Atrist> - <Album> <Track>".
|
||||
kwargs (dict): If specified, a :func:`~plexapi.audio.Track.getStreamURL()` will
|
||||
be returned and the additional arguments passed in will be sent to that
|
||||
function. If kwargs is not specified, the media items will be downloaded
|
||||
and saved to disk.
|
||||
"""
|
||||
downloaded = []
|
||||
for ep in self.tracks():
|
||||
dl = ep.download(savepath=savepath, keep_orginal_name=keep_orginal_name, **kwargs)
|
||||
if dl:
|
||||
downloaded.extend(dl)
|
||||
|
||||
def unwatched(self):
|
||||
"""Return Track that is not lisson on."""
|
||||
return self.tracks(watched=False)
|
||||
return downloaded
|
||||
|
||||
|
||||
@utils.register_libtype
|
||||
class Track(Audio, Playable):
|
||||
"""Track.
|
||||
""" Represents a single audio track.
|
||||
|
||||
Attributes:
|
||||
art (str): Relative path fx /library/metadata/102631/art/1469310342
|
||||
chapterSource (TYPE): Description
|
||||
duration (TYPE): Description
|
||||
grandparentArt (str): Relative path
|
||||
grandparentKey (str): Relative path Fx /library/metadata/102631
|
||||
grandparentRatingKey (TYPE): Description
|
||||
grandparentThumb (str): Relative path to Artist thumb img
|
||||
grandparentTitle (str): Aerosmith
|
||||
guid (TYPE): Description
|
||||
media (list): List of media.Media
|
||||
moods (list): List of media.Moods
|
||||
originalTitle (str): Some track title
|
||||
parentIndex (int): 1
|
||||
parentKey (str): Relative path Fx /library/metadata/102632
|
||||
parentRatingKey (int): 1337
|
||||
parentThumb (str): Relative path to Album thumb
|
||||
parentTitle (str): Album title
|
||||
player (None): #TODO
|
||||
primaryExtraKey (TYPE): #TODO
|
||||
ratingCount (int): 10
|
||||
sessionKey (int): Description
|
||||
transcodeSession (None):
|
||||
TYPE (str): track
|
||||
username (str): username@mail.com
|
||||
viewOffset (int): 100
|
||||
year (int): 1999
|
||||
Parameters:
|
||||
server (:class:`~plexapi.server.PlexServer`): PlexServer this client is connected to (optional)
|
||||
data (ElementTree): XML response from PlexServer used to build this object (optional).
|
||||
initpath (str): Relative path requested when retrieving specified `data` (optional).
|
||||
|
||||
Attributes:
|
||||
art (str): Track artwork (/library/metadata/<ratingkey>/art/<artid>)
|
||||
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.
|
||||
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).
|
||||
parentIndex (int): Album index.
|
||||
parentKey (str): Album API URL.
|
||||
parentRatingKey (int): Unique key identifying album.
|
||||
parentThumb (str): URL to album thumbnail image.
|
||||
parentTitle (str): Name of the album for this track.
|
||||
primaryExtraKey (str): Unknown
|
||||
ratingCount (int): Rating of this track (1-10?)
|
||||
viewOffset (int): Unknown
|
||||
year (int): Year this track was released.
|
||||
sessionKey (int): Session Key (active sessions only).
|
||||
username (str): Username of person playing this track (active sessions only).
|
||||
player (str): :class:`~plexapi.client.PlexClient` for playing track (active sessions only).
|
||||
transcodeSession (None): :class:`~plexapi.media.TranscodeSession` for playing
|
||||
track (active sessions only).
|
||||
"""
|
||||
|
||||
TYPE = 'track'
|
||||
|
||||
def _loadData(self, data):
|
||||
"""Used to set the attributes
|
||||
|
||||
Args:
|
||||
data (Element): Usually built from server.query
|
||||
"""
|
||||
""" Load attribute values from Plex XML response. """
|
||||
Audio._loadData(self, data)
|
||||
Playable._loadData(self, data)
|
||||
self.art = data.attrib.get('art', NA)
|
||||
|
@ -295,11 +297,13 @@ class Track(Audio, Playable):
|
|||
self.ratingCount = utils.cast(int, data.attrib.get('ratingCount', NA))
|
||||
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
|
||||
self.year = utils.cast(int, data.attrib.get('year', NA))
|
||||
# media is included in /children
|
||||
self.media = [media.Media(self.server, e, self.initpath, self)
|
||||
for e in data if e.tag == media.Media.TYPE]
|
||||
if self.isFullObject(): # check me
|
||||
self.moods = [media.Mood(self.server, e)
|
||||
for e in data if e.tag == media.Mood.TYPE]
|
||||
self.media = [media.Media(self.server, e, self.initpath, self)
|
||||
for e in data if e.tag == media.Media.TYPE]
|
||||
self.moods = [media.Mood(self.server, e) for e in data if e.tag == media.Mood.TYPE]
|
||||
#self.media = [media.Media(self.server, e, self.initpath, self)
|
||||
# for e in data if e.tag == media.Media.TYPE]
|
||||
# data for active sessions and history
|
||||
self.sessionKey = utils.cast(int, data.attrib.get('sessionKey', NA))
|
||||
self.username = utils.findUsername(data)
|
||||
|
@ -308,14 +312,18 @@ class Track(Audio, Playable):
|
|||
|
||||
@property
|
||||
def thumbUrl(self):
|
||||
"""Return url to thumb image."""
|
||||
""" Returns the URL thumbnail image for this track's album. """
|
||||
if self.parentThumb:
|
||||
return self.server.url(self.parentThumb)
|
||||
|
||||
def album(self):
|
||||
"""Return this track's Album."""
|
||||
""" Return this track's :class:`~plexapi.audio.Album`. """
|
||||
return utils.listItems(self.server, self.parentKey)[0]
|
||||
|
||||
def artist(self):
|
||||
"""Return this track's Artist."""
|
||||
""" Return this track's :class:`~plexapi.audio.Artist`. """
|
||||
return utils.listItems(self.server, self.grandparentKey)[0]
|
||||
|
||||
def _prettyfilename(self):
|
||||
""" Returns a filename for use in download. """
|
||||
return '%s - %s %s' % (self.grandparentTitle, self.parentTitle, self.title)
|
||||
|
|
|
@ -1,10 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
PlexAPI Client
|
||||
To understand how this works, read this page:
|
||||
https://github.com/plexinc/plex-media-player/wiki/Remote-control-API
|
||||
"""
|
||||
|
||||
import requests
|
||||
from requests.status_codes import _codes as codes
|
||||
from plexapi import BASE_HEADERS, TIMEOUT, log, utils
|
||||
|
@ -13,65 +7,64 @@ from xml.etree import ElementTree
|
|||
|
||||
|
||||
class PlexClient(object):
|
||||
"""Main class for interacting with a client.
|
||||
""" Main class for interacting with a Plex client. This class can connect
|
||||
directly to the client and control it or proxy commands through your
|
||||
Plex Server. To better understand the Plex client API's read this page:
|
||||
https://github.com/plexinc/plex-media-player/wiki/Remote-control-API
|
||||
|
||||
Attributes:
|
||||
baseurl (str): http adress for the client
|
||||
device (None): Description
|
||||
deviceClass (sting): pc, phone
|
||||
machineIdentifier (str): uuid fx 5471D9EA-1467-4051-9BE7-FCBDF490ACE3
|
||||
model (TYPE): Description
|
||||
platform (TYPE): Description
|
||||
platformVersion (TYPE): Description
|
||||
product (str): plex for ios
|
||||
protocol (str): plex
|
||||
protocolCapabilities (list): List of what client can do
|
||||
protocolVersion (str): 1
|
||||
server (plexapi.server.Plexserver): PMS your connected to
|
||||
session (None or requests.Session): Add your own session object to cache stuff
|
||||
state (None): Description
|
||||
title (str): fx Johns Iphone
|
||||
token (str): X-Plex-Token, using for authenication with PMS
|
||||
vendor (str): Description
|
||||
version (str): fx. 4.6
|
||||
Parameters:
|
||||
baseurl (str): HTTP URL to connect dirrectly to this client.
|
||||
token (str): X-Plex-Token used for authenication (optional).
|
||||
session (:class:`~requests.Session`): requests.Session object if you want more control (optional).
|
||||
server (:class:`~plexapi.server.PlexServer`): PlexServer this client is connected to (optional)
|
||||
data (ElementTree): Response from PlexServer used to build this object (optional).
|
||||
|
||||
Attributes:
|
||||
baseurl (str): HTTP address of the client
|
||||
device (str): Best guess on the type of device this is (PS, iPhone, Linux, etc).
|
||||
deviceClass (str): Device class (pc, phone, etc).
|
||||
machineIdentifier (str): Unique ID for this device.
|
||||
model (str): Unknown
|
||||
platform (str): Unknown
|
||||
platformVersion (str): Description
|
||||
product (str): Client Product (Plex for iOS, etc).
|
||||
protocol (str): Always seems ot be 'plex'.
|
||||
protocolCapabilities (list<str>): List of client capabilities (navigation, playback,
|
||||
timeline, mirror, playqueues).
|
||||
protocolVersion (str): Protocol version (1, future proofing?)
|
||||
server (:class:`~plexapi.server.PlexServer`): Server this client is connected to.
|
||||
session (:class:`~requests.Session`): Session object used for connection.
|
||||
state (str): Unknown
|
||||
title (str): Name of this client (Johns iPhone, etc).
|
||||
token (str): X-Plex-Token used for authenication
|
||||
vendor (str): Unknown
|
||||
version (str): Device version (4.6.1, etc).
|
||||
_proxyThroughServer (bool): Set to True after calling
|
||||
:func:`~plexapi.client.PlexClient.proxyThroughServer()` (default False).
|
||||
"""
|
||||
|
||||
def __init__(self, baseurl, token=None, session=None, server=None, data=None):
|
||||
"""Kick shit off.
|
||||
|
||||
Args:
|
||||
baseurl (sting): fx http://10.0.0.99:1111222
|
||||
token (None, optional): X-Plex-Token, using for authenication with PMS
|
||||
session (None, optional): requests.Session() or your own session
|
||||
server (None, optional): PlexServer
|
||||
data (None, optional): XML response from PMS as Element
|
||||
or uses connect to get it
|
||||
"""
|
||||
self.baseurl = baseurl.strip('/')
|
||||
self.token = token
|
||||
self.session = session or requests.Session()
|
||||
self.server = server
|
||||
# session > server.session > requests.Session
|
||||
_server_session = server.session if server else None
|
||||
self.session = session or _server_session or requests.Session()
|
||||
self._loadData(data) if data is not None else self.connect()
|
||||
self._proxyThroughServer = False
|
||||
self._commandId = 0
|
||||
|
||||
def _loadData(self, data):
|
||||
"""Sets attrs to the class.
|
||||
|
||||
Args:
|
||||
data (Element): XML response from PMS as a Element
|
||||
"""
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self.deviceClass = data.attrib.get('deviceClass')
|
||||
self.machineIdentifier = data.attrib.get('machineIdentifier')
|
||||
self.product = data.attrib.get('product')
|
||||
self.protocol = data.attrib.get('protocol')
|
||||
self.protocolCapabilities = data.attrib.get(
|
||||
'protocolCapabilities', '').split(',')
|
||||
self.protocolCapabilities = data.attrib.get('protocolCapabilities', '').split(',')
|
||||
self.protocolVersion = data.attrib.get('protocolVersion')
|
||||
self.platform = data.attrib.get('platform')
|
||||
self.platformVersion = data.attrib.get('platformVersion')
|
||||
self.title = data.attrib.get('title') or data.attrib.get('name')
|
||||
# active session details
|
||||
# Active session details
|
||||
self.device = data.attrib.get('device')
|
||||
self.model = data.attrib.get('model')
|
||||
self.state = data.attrib.get('state')
|
||||
|
@ -79,7 +72,11 @@ class PlexClient(object):
|
|||
self.version = data.attrib.get('version')
|
||||
|
||||
def connect(self):
|
||||
"""Connect"""
|
||||
""" Connects to the client and reloads all class attributes.
|
||||
|
||||
Raises:
|
||||
:class:`~plexapi.exceptions.NotFound`: No client found at the specified url.
|
||||
"""
|
||||
try:
|
||||
data = self.query('/resources')[0]
|
||||
self._loadData(data)
|
||||
|
@ -88,43 +85,38 @@ class PlexClient(object):
|
|||
raise NotFound('No client found at: %s' % self.baseurl)
|
||||
|
||||
def headers(self):
|
||||
"""Default headers
|
||||
|
||||
Returns:
|
||||
dict: default headers
|
||||
"""
|
||||
""" Returns a dict of all default headers for Client requests. """
|
||||
headers = BASE_HEADERS
|
||||
if self.token:
|
||||
headers['X-Plex-Token'] = self.token
|
||||
return headers
|
||||
|
||||
def proxyThroughServer(self, value=True):
|
||||
"""Connect to the client via the server.
|
||||
""" Tells this PlexClient instance to proxy all future commands through the PlexServer.
|
||||
Useful if you do not wish to connect directly to the Client device itself.
|
||||
|
||||
Args:
|
||||
value (bool, optional): Description
|
||||
Parameters:
|
||||
value (bool): Enable or disable proxying (optional, default True).
|
||||
|
||||
Raises:
|
||||
Unsupported: Cannot use client proxy with unknown server.
|
||||
Raises:
|
||||
:class:`~plexapi.exceptions.Unsupported`: Cannot use client proxy with unknown server.
|
||||
"""
|
||||
if value is True and not self.server:
|
||||
raise Unsupported('Cannot use client proxy with unknown server.')
|
||||
self._proxyThroughServer = value
|
||||
|
||||
def query(self, path, method=None, headers=None, **kwargs):
|
||||
"""Used to fetch relative paths to pms.
|
||||
""" Returns an ElementTree object containing the response
|
||||
from the specified request path.
|
||||
|
||||
Args:
|
||||
path (str): Relative path
|
||||
method (None, optional): requests.post etc
|
||||
headers (None, optional): Set headers manually
|
||||
**kwargs (TYPE): Passord to the http request used for filter, sorting.
|
||||
Parameters:
|
||||
path (str): Relative path to query.
|
||||
method (func): `self.session.get` or `self.session.post`
|
||||
headers (dict): Additional headers to include or override in the request.
|
||||
**kwargs (TYPE): Additional arguments to inclde in the request.<method> call.
|
||||
|
||||
Returns:
|
||||
Element
|
||||
|
||||
Raises:
|
||||
BadRequest: Http error and code
|
||||
Raises:
|
||||
:class:`~plexapi.exceptions.BadRequest`: When the response is not in [200, 201]
|
||||
"""
|
||||
url = self.url(path)
|
||||
method = method or self.session.get
|
||||
|
@ -138,24 +130,23 @@ class PlexClient(object):
|
|||
return ElementTree.fromstring(data) if data else None
|
||||
|
||||
def sendCommand(self, command, proxy=None, **params):
|
||||
"""Send a command to the client
|
||||
""" Convenience wrapper around :func:`~plexapi.client.PlexClient.query()` to more easily
|
||||
send simple commands to the client. Returns an ElementTree object containing
|
||||
the response.
|
||||
|
||||
Args:
|
||||
command (str): See the commands listed below
|
||||
proxy (None, optional): Description
|
||||
**params (dict): Description
|
||||
Parameters:
|
||||
command (str): Command to be sent in for format '<controller>/<command>'.
|
||||
proxy (bool): Set True to proxy this command through the PlexServer.
|
||||
**params (dict): Additional GET parameters to include with the command.
|
||||
|
||||
Returns:
|
||||
Element
|
||||
|
||||
Raises:
|
||||
Unsupported: Unsupported clients
|
||||
Raises:
|
||||
:class:`~plexapi.exceptions.Unsupported`: When we detect the client doesn't support this capability.
|
||||
"""
|
||||
command = command.strip('/')
|
||||
controller = command.split('/')[0]
|
||||
if controller not in self.protocolCapabilities:
|
||||
raise Unsupported(
|
||||
'Client %s does not support the %s controller.' % (self.title, controller))
|
||||
raise Unsupported('Client %s does not support the %s controller.' %
|
||||
(self.title, controller))
|
||||
path = '/player/%s%s' % (command, utils.joinArgs(params))
|
||||
headers = {'X-Plex-Target-Client-Identifier': self.machineIdentifier}
|
||||
self._commandId += 1
|
||||
|
@ -167,82 +158,87 @@ class PlexClient(object):
|
|||
return self.query(path, headers=headers)
|
||||
|
||||
def url(self, path):
|
||||
"""Return a full url
|
||||
""" Given a path, this retuns the full PlexClient the PlexServer URL to request.
|
||||
|
||||
Args:
|
||||
path (str): Relative path
|
||||
|
||||
Returns:
|
||||
string: full path to PMS
|
||||
Parameters:
|
||||
path (str): Relative path to be converted.
|
||||
"""
|
||||
if self.token:
|
||||
delim = '&' if '?' in path else '?'
|
||||
return '%s%s%sX-Plex-Token=%s' % (self.baseurl, path, delim, self.token)
|
||||
return '%s%s' % (self.baseurl, path)
|
||||
|
||||
#---------------------
|
||||
# Navigation Commands
|
||||
# These commands navigate around the user-interface.
|
||||
def contextMenu(self):
|
||||
"""Open the context menu on the client."""
|
||||
""" Open the context menu on the client. """
|
||||
self.sendCommand('navigation/contextMenu')
|
||||
|
||||
def goBack(self):
|
||||
"""One step back"""
|
||||
""" Navigate back one position. """
|
||||
self.sendCommand('navigation/back')
|
||||
|
||||
def goToHome(self):
|
||||
"""Jump to home screen."""
|
||||
""" Go directly to the home screen. """
|
||||
self.sendCommand('navigation/home')
|
||||
|
||||
def goToMusic(self):
|
||||
"""Jump to music."""
|
||||
""" Go directly to the playing music panel. """
|
||||
self.sendCommand('navigation/music')
|
||||
|
||||
def moveDown(self):
|
||||
"""One step down."""
|
||||
""" Move selection down a position. """
|
||||
self.sendCommand('navigation/moveDown')
|
||||
|
||||
def moveLeft(self):
|
||||
""" Move selection left a position. """
|
||||
self.sendCommand('navigation/moveLeft')
|
||||
|
||||
def moveRight(self):
|
||||
""" Move selection right a position. """
|
||||
self.sendCommand('navigation/moveRight')
|
||||
|
||||
def moveUp(self):
|
||||
""" Move selection up a position. """
|
||||
self.sendCommand('navigation/moveUp')
|
||||
|
||||
def nextLetter(self):
|
||||
"""Jump to the next letter in the alphabeth."""
|
||||
""" Jump to next letter in the alphabet. """
|
||||
self.sendCommand('navigation/nextLetter')
|
||||
|
||||
def pageDown(self):
|
||||
""" Move selection down a full page. """
|
||||
self.sendCommand('navigation/pageDown')
|
||||
|
||||
def pageUp(self):
|
||||
""" Move selection up a full page. """
|
||||
self.sendCommand('navigation/pageUp')
|
||||
|
||||
def previousLetter(self):
|
||||
""" Jump to previous letter in the alphabet. """
|
||||
self.sendCommand('navigation/previousLetter')
|
||||
|
||||
def select(self):
|
||||
""" Select element at the current position. """
|
||||
self.sendCommand('navigation/select')
|
||||
|
||||
def toggleOSD(self):
|
||||
""" Toggle the on screen display during playback. """
|
||||
self.sendCommand('navigation/toggleOSD')
|
||||
|
||||
def goToMedia(self, media, **params):
|
||||
"""Go to a media on the client.
|
||||
""" Navigate directly to the specified media page.
|
||||
|
||||
Args:
|
||||
media (str): movie, music, photo
|
||||
**params (TYPE): Description # todo
|
||||
Parameters:
|
||||
media (:class:`~plexapi.media.Media`): Media object to navigate to.
|
||||
**params (dict): Additional GET parameters to include with the command.
|
||||
|
||||
Raises:
|
||||
Unsupported: Description
|
||||
Raises:
|
||||
:class:`~plexapi.exceptions.Unsupported`: When no PlexServer specified in this object.
|
||||
"""
|
||||
if not self.server:
|
||||
raise Unsupported(
|
||||
'A server must be specified before using this command.')
|
||||
raise Unsupported('A server must be specified before using this command.')
|
||||
server_url = media.server.baseurl.split(':')
|
||||
self.sendCommand('mirror/details', **dict({
|
||||
'machineIdentifier': self.server.machineIdentifier,
|
||||
|
@ -251,192 +247,184 @@ class PlexClient(object):
|
|||
'key': media.key,
|
||||
}, **params))
|
||||
|
||||
#-------------------
|
||||
# Playback Commands
|
||||
# Most of the playback commands take a mandatory mtype {'music','photo','video'} argument,
|
||||
# to specify which media type to apply the command to, (except for playMedia). This
|
||||
# is in case there are multiple things happening (e.g. music in the background, photo
|
||||
# slideshow in the foreground).
|
||||
|
||||
def pause(self, mtype):
|
||||
"""Pause playback
|
||||
""" Pause the currently playing media type.
|
||||
|
||||
Args:
|
||||
mtype (str): music, photo, video
|
||||
Parameters:
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.sendCommand('playback/pause', type=mtype)
|
||||
|
||||
def play(self, mtype):
|
||||
"""Start playback
|
||||
""" Start playback for the specified media type.
|
||||
|
||||
Args:
|
||||
mtype (str): music, photo, video
|
||||
Parameters:
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.sendCommand('playback/play', type=mtype)
|
||||
|
||||
def refreshPlayQueue(self, playQueueID, mtype=None):
|
||||
"""Summary
|
||||
|
||||
Args:
|
||||
playQueueID (TYPE): Description
|
||||
mtype (None, optional): photo, video, music
|
||||
""" Refresh the specified Playqueue.
|
||||
|
||||
Parameters:
|
||||
playQueueID (str): Playqueue ID.
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.sendCommand(
|
||||
'playback/refreshPlayQueue', playQueueID=playQueueID, type=mtype)
|
||||
|
||||
def seekTo(self, offset, mtype=None):
|
||||
"""Seek to a time in a plaback.
|
||||
|
||||
Args:
|
||||
offset (int): in milliseconds
|
||||
mtype (None, optional): photo, video, music
|
||||
""" Seek to the specified offset (ms) during playback.
|
||||
|
||||
Parameters:
|
||||
offset (int): Position to seek to (milliseconds).
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.sendCommand('playback/seekTo', offset=offset, type=mtype)
|
||||
|
||||
def skipNext(self, mtype=None):
|
||||
"""Skip to next
|
||||
""" Skip to the next playback item.
|
||||
|
||||
Args:
|
||||
mtype (None, string, optional): photo, video, music
|
||||
Parameters:
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.sendCommand('playback/skipNext', type=mtype)
|
||||
|
||||
def skipPrevious(self, mtype=None):
|
||||
"""Skip to previous
|
||||
""" Skip to previous playback item.
|
||||
|
||||
Args:
|
||||
mtype (None, optional): Description
|
||||
Parameters:
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.sendCommand('playback/skipPrevious', type=mtype)
|
||||
|
||||
def skipTo(self, key, mtype=None):
|
||||
"""Jump to
|
||||
""" Skip to the playback item with the specified key.
|
||||
|
||||
Args:
|
||||
key (TYPE): # what is this
|
||||
mtype (None, optional): photo, video, music
|
||||
|
||||
Returns:
|
||||
TYPE: Description
|
||||
Parameters:
|
||||
key (str): Key of the media item to skip to.
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
# skips to item with matching key
|
||||
self.sendCommand('playback/skipTo', key=key, type=mtype)
|
||||
|
||||
def stepBack(self, mtype=None):
|
||||
"""
|
||||
""" Step backward a chunk of time in the current playback item.
|
||||
|
||||
Args:
|
||||
mtype (None, optional): photo, video, music
|
||||
Parameters:
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.sendCommand('playback/stepBack', type=mtype)
|
||||
|
||||
def stepForward(self, mtype):
|
||||
"""Summary
|
||||
""" Step forward a chunk of time in the current playback item.
|
||||
|
||||
Args:
|
||||
mtype (TYPE): Description
|
||||
|
||||
Returns:
|
||||
TYPE: Description
|
||||
Parameters:
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.sendCommand('playback/stepForward', type=mtype)
|
||||
|
||||
def stop(self, mtype):
|
||||
"""Stop playback
|
||||
|
||||
Args:
|
||||
mtype (str): video, music, photo
|
||||
""" Stop the currently playing item.
|
||||
|
||||
Parameters:
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.sendCommand('playback/stop', type=mtype)
|
||||
|
||||
def setRepeat(self, repeat, mtype):
|
||||
"""Summary
|
||||
""" Enable repeat for the specified playback items.
|
||||
|
||||
Args:
|
||||
repeat (int): 0=off, 1=repeatone, 2=repeatall
|
||||
mtype (TYPE): video, music, photo
|
||||
Parameters:
|
||||
repeat (int): Repeat mode (0=off, 1=repeatone, 2=repeatall).
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.setParameters(repeat=repeat, mtype=mtype)
|
||||
|
||||
def setShuffle(self, shuffle, mtype):
|
||||
"""Set shuffle
|
||||
""" Enable shuffle for the specified playback items.
|
||||
|
||||
Args:
|
||||
shuffle (int): 0=off, 1=on
|
||||
mtype (TYPE): Description
|
||||
Parameters:
|
||||
shuffle (int): Shuffle mode (0=off, 1=on)
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.setParameters(shuffle=shuffle, mtype=mtype)
|
||||
|
||||
def setVolume(self, volume, mtype):
|
||||
"""Change volume
|
||||
""" Enable volume for the current playback item.
|
||||
|
||||
Args:
|
||||
volume (int): 0-100
|
||||
mtype (TYPE): Description
|
||||
Parameters:
|
||||
volume (int): Volume level (0-100).
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.setParameters(volume=volume, mtype=mtype)
|
||||
|
||||
def setAudioStream(self, audioStreamID, mtype):
|
||||
"""Select a audio stream
|
||||
""" Select the audio stream for the current playback item (only video).
|
||||
|
||||
Args:
|
||||
audioStreamID (TYPE): Description
|
||||
mtype (str): video, music, photo
|
||||
Parameters:
|
||||
audioStreamID (str): ID of the audio stream from the media object.
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.setStreams(audioStreamID=audioStreamID, mtype=mtype)
|
||||
|
||||
def setSubtitleStream(self, subtitleStreamID, mtype):
|
||||
"""Select a subtitle
|
||||
""" Select the subtitle stream for the current playback item (only video).
|
||||
|
||||
Args:
|
||||
subtitleStreamID (TYPE): Description
|
||||
mtype (str): video, music, photo
|
||||
Parameters:
|
||||
subtitleStreamID (str): ID of the subtitle stream from the media object.
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.setStreams(subtitleStreamID=subtitleStreamID, mtype=mtype)
|
||||
|
||||
def setVideoStream(self, videoStreamID, mtype):
|
||||
"""Summary
|
||||
|
||||
Args:
|
||||
videoStreamID (TYPE): Description
|
||||
mtype (str): video, music, photo
|
||||
""" Select the video stream for the current playback item (only video).
|
||||
|
||||
Parameters:
|
||||
videoStreamID (str): ID of the video stream from the media object.
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.setStreams(videoStreamID=videoStreamID, mtype=mtype)
|
||||
|
||||
def playMedia(self, media, **params):
|
||||
"""Start playback on a media item.
|
||||
def playMedia(self, media, offset=0, **params):
|
||||
""" Start playback of the specified media item. See also:
|
||||
|
||||
Parameters:
|
||||
media (:class:`~plexapi.media.Media`): Media item to be played back (movie, music, photo).
|
||||
offset (int): Number of milliseconds at which to start playing with zero representing
|
||||
the beginning (default 0).
|
||||
**params (dict): Optional additional parameters to include in the playback request. See
|
||||
also: https://github.com/plexinc/plex-media-player/wiki/Remote-control-API#modified-commands
|
||||
|
||||
Args:
|
||||
media (str): movie, music, photo
|
||||
**params (TYPE): Description
|
||||
|
||||
Raises:
|
||||
Unsupported: Description
|
||||
Raises:
|
||||
:class:`~plexapi.exceptions.Unsupported`: When no PlexServer specified in this object.
|
||||
"""
|
||||
if not self.server:
|
||||
raise Unsupported(
|
||||
'A server must be specified before using this command.')
|
||||
raise Unsupported('A server must be specified before using this command.')
|
||||
server_url = media.server.baseurl.split(':')
|
||||
playqueue = self.server.createPlayQueue(media)
|
||||
self.sendCommand('playback/playMedia', **dict({
|
||||
'machineIdentifier': self.server.machineIdentifier,
|
||||
'address': server_url[1].strip('/'),
|
||||
'port': server_url[-1],
|
||||
'offset': offset,
|
||||
'key': media.key,
|
||||
'containerKey': '/playQueues/%s?window=100&own=1' % playqueue.playQueueID,
|
||||
}, **params))
|
||||
|
||||
def setParameters(self, volume=None, shuffle=None, repeat=None, mtype=None):
|
||||
"""Set params for the client
|
||||
""" Set multiple playback parameters at once.
|
||||
|
||||
Args:
|
||||
volume (None, optional): 0-100
|
||||
shuffle (None, optional): 0=off, 1=on
|
||||
repeat (None, optional): 0=off, 1=repeatone, 2=repeatall
|
||||
mtype (None, optional): music,photo,video
|
||||
Parameters:
|
||||
volume (int): Volume level (0-100; optional).
|
||||
shuffle (int): Shuffle mode (0=off, 1=on; optional).
|
||||
repeat (int): Repeat mode (0=off, 1=repeatone, 2=repeatall; optional).
|
||||
mtype (str): Media type to take action against (optional music, photo, video).
|
||||
"""
|
||||
params = {}
|
||||
if repeat is not None:
|
||||
|
@ -449,15 +437,14 @@ class PlexClient(object):
|
|||
params['type'] = mtype
|
||||
self.sendCommand('playback/setParameters', **params)
|
||||
|
||||
def setStreams(self, audioStreamID=None, subtitleStreamID=None,
|
||||
videoStreamID=None, mtype=None):
|
||||
"""Select streams.
|
||||
def setStreams(self, audioStreamID=None, subtitleStreamID=None, videoStreamID=None, mtype=None):
|
||||
""" Select multiple playback streams at once.
|
||||
|
||||
Args:
|
||||
audioStreamID (None, optional): Description
|
||||
subtitleStreamID (None, optional): Description
|
||||
videoStreamID (None, optional): Description
|
||||
mtype (None, optional): music,photo,video
|
||||
Parameters:
|
||||
audioStreamID (str): ID of the audio stream from the media object.
|
||||
subtitleStreamID (str): ID of the subtitle stream from the media object.
|
||||
videoStreamID (str): ID of the video stream from the media object.
|
||||
mtype (str): Media type to take action against (optional music, photo, video).
|
||||
"""
|
||||
params = {}
|
||||
if audioStreamID is not None:
|
||||
|
@ -470,19 +457,18 @@ class PlexClient(object):
|
|||
params['type'] = mtype
|
||||
self.sendCommand('playback/setStreams', **params)
|
||||
|
||||
#-------------------
|
||||
# Timeline Commands
|
||||
def timeline(self):
|
||||
"""Timeline"""
|
||||
""" Poll the current timeline and return the XML response. """
|
||||
return self.sendCommand('timeline/poll', **{'wait': 1, 'commandID': 4})
|
||||
|
||||
def isPlayingMedia(self, includePaused=False):
|
||||
"""Check timeline if anything is playing
|
||||
""" Returns True if any media is currently playing.
|
||||
|
||||
Args:
|
||||
includePaused (bool, optional): Should paused be included
|
||||
|
||||
Returns:
|
||||
bool
|
||||
Parameters:
|
||||
includePaused (bool): Set True to treat currently paused items
|
||||
as playing (optional; default True).
|
||||
"""
|
||||
for mediatype in self.timeline():
|
||||
if mediatype.get('state') == 'playing':
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# flake8:noqa
|
||||
"""
|
||||
Python 2/3 compatability
|
||||
Always try Py3 first
|
||||
"""
|
||||
# Python 2/3 compatability
|
||||
# Always try Py3 first
|
||||
|
||||
try:
|
||||
string_type = basestring
|
||||
except NameError:
|
||||
string_type = str
|
||||
|
||||
try:
|
||||
from urllib.parse import urlencode
|
||||
|
@ -20,8 +22,13 @@ try:
|
|||
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
|
||||
|
|
@ -1,24 +1,38 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
PlexConfig
|
||||
Settings are stored in an INI file and can be overridden after import
|
||||
plexapi by simply setting the value.
|
||||
"""
|
||||
from collections import defaultdict
|
||||
try:
|
||||
from ConfigParser import ConfigParser # Python2
|
||||
except ImportError:
|
||||
from configparser import ConfigParser # Python3
|
||||
from plexapi.compat import ConfigParser
|
||||
|
||||
|
||||
class PlexConfig(ConfigParser):
|
||||
""" PlexAPI configuration object. Settings are stored in an INI file within the
|
||||
user's home directory and can be overridden after importing plexapi by simply
|
||||
setting the value. See the documentation section 'Configuration' for more
|
||||
details on available options.
|
||||
|
||||
Parameters:
|
||||
path (str): Path of the configuration file to load.
|
||||
"""
|
||||
def __init__(self, path):
|
||||
ConfigParser.__init__(self)
|
||||
self.read(path)
|
||||
self.data = self._asDict()
|
||||
|
||||
def __getattr__(self, attr):
|
||||
if attr not in ('get', '_asDict', 'data'):
|
||||
for section in self._sections:
|
||||
for name, value in self._sections[section].items():
|
||||
if name == attr:
|
||||
return value
|
||||
raise Exception('Config attr not found: %s' % attr)
|
||||
|
||||
def get(self, key, default=None, cast=None):
|
||||
""" Returns the specified configuration value or <default> if not found.
|
||||
|
||||
Parameters:
|
||||
key (str): Configuration variable to load in the format '<section>.<variable>'.
|
||||
default: Default value to use if key not found.
|
||||
cast (func): Cast the value to the specified type before returning.
|
||||
"""
|
||||
try:
|
||||
section, name = key.split('.')
|
||||
value = self.data.get(section.lower(), {}).get(name.lower(), default)
|
||||
|
@ -27,6 +41,7 @@ class PlexConfig(ConfigParser):
|
|||
return default
|
||||
|
||||
def _asDict(self):
|
||||
""" Returns all configuration values as a dictionary. """
|
||||
config = defaultdict(dict)
|
||||
for section in self._sections:
|
||||
for name, value in self._sections[section].items():
|
||||
|
@ -36,6 +51,7 @@ class PlexConfig(ConfigParser):
|
|||
|
||||
|
||||
def reset_base_headers():
|
||||
""" Convenience function returns a dict of all base X-Plex-* headers for session requests. """
|
||||
import plexapi
|
||||
return {
|
||||
'X-Plex-Platform': plexapi.X_PLEX_PLATFORM,
|
||||
|
|
|
@ -1,23 +1,29 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# flake8:noqa
|
||||
"""
|
||||
PlexAPI Exceptions
|
||||
"""
|
||||
|
||||
class PlexApiException(Exception):
|
||||
""" Base class for all PlexAPI exceptions. """
|
||||
pass
|
||||
|
||||
class BadRequest(PlexApiException):
|
||||
""" An invalid request, generally a user error. """
|
||||
pass
|
||||
|
||||
class NotFound(PlexApiException):
|
||||
""" Request media item or device is not found. """
|
||||
pass
|
||||
|
||||
class UnknownType(PlexApiException):
|
||||
""" Unknown library type. """
|
||||
pass
|
||||
|
||||
class Unsupported(PlexApiException):
|
||||
""" Unsupported client request. """
|
||||
pass
|
||||
|
||||
class Unauthorized(PlexApiException):
|
||||
""" Invalid username or password. """
|
||||
pass
|
||||
|
||||
class NotImplementedError(PlexApiException):
|
||||
""" Feature is not yet implemented. """
|
||||
pass
|
||||
|
|
|
@ -1,14 +1,24 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
|
||||
from plexapi import log, utils
|
||||
from plexapi import X_PLEX_CONTAINER_SIZE
|
||||
import logging
|
||||
from plexapi import X_PLEX_CONTAINER_SIZE, log, utils
|
||||
from plexapi.compat import unquote
|
||||
from plexapi.media import MediaTag, Genre, Role, Director
|
||||
from plexapi.exceptions import BadRequest, NotFound
|
||||
from plexapi.media import MediaTag
|
||||
|
||||
|
||||
class Library(object):
|
||||
""" Represents a PlexServer library. This contains all sections of media defined
|
||||
in your Plex server including video, shows and audio.
|
||||
|
||||
Attributes:
|
||||
identifier (str): Unknown ('com.plexapp.plugins.library').
|
||||
mediaTagVersion (str): Unknown (/system/bundle/media/flags/)
|
||||
server (:class:`~plexapi.server.PlexServer`): PlexServer this client is connected to.
|
||||
title1 (str): 'Plex Library' (not sure how useful this is).
|
||||
title2 (str): Second title (this is blank on my setup).
|
||||
"""
|
||||
|
||||
def __init__(self, server, data):
|
||||
self.identifier = data.attrib.get('identifier')
|
||||
|
@ -16,12 +26,16 @@ class Library(object):
|
|||
self.server = server
|
||||
self.title1 = data.attrib.get('title1')
|
||||
self.title2 = data.attrib.get('title2')
|
||||
self._sectionsByID = {} # cached section UUIDs
|
||||
self._sectionsByID = {} # cached Section UUIDs
|
||||
|
||||
def __repr__(self):
|
||||
return '<Library:%s>' % self.title1.encode('utf8')
|
||||
|
||||
def sections(self):
|
||||
""" Returns a list of all media sections in this library. Library sections may be any of
|
||||
:class:`~plexapi.library.MovieSection`, :class:`~plexapi.library.ShowSection`,
|
||||
:class:`~plexapi.library.MusicSection`, :class:`~plexapi.library.PhotoSection`.
|
||||
"""
|
||||
items = []
|
||||
SECTION_TYPES = {
|
||||
MovieSection.TYPE: MovieSection,
|
||||
|
@ -40,40 +54,69 @@ class Library(object):
|
|||
return items
|
||||
|
||||
def section(self, title=None):
|
||||
""" Returns the :class:`~plexapi.library.LibrarySection` that matches the specified title.
|
||||
|
||||
Parameters:
|
||||
title (str): Title of the section to return.
|
||||
|
||||
Raises:
|
||||
:class:`~plexapi.exceptions.NotFound`: Invalid library section title.
|
||||
"""
|
||||
for item in self.sections():
|
||||
if item.title == title:
|
||||
return item
|
||||
raise NotFound('Invalid library section: %s' % title)
|
||||
|
||||
def sectionByID(self, sectionID):
|
||||
if not self._sectionsByID:
|
||||
""" Returns the :class:`~plexapi.library.LibrarySection` that matches the specified sectionID.
|
||||
|
||||
Parameters:
|
||||
sectionID (int): ID of the section to return.
|
||||
"""
|
||||
if not self._sectionsByID or sectionID not in self._sectionsByID:
|
||||
self.sections()
|
||||
return self._sectionsByID[sectionID]
|
||||
|
||||
def all(self):
|
||||
return [item for section in self.sections()
|
||||
for item in section.all()]
|
||||
""" Returns a list of all media from all library sections.
|
||||
This may be a very large dataset to retrieve.
|
||||
"""
|
||||
return [item for section in self.sections() for item in section.all()]
|
||||
|
||||
def onDeck(self):
|
||||
""" Returns a list of all media items on deck. """
|
||||
return utils.listItems(self.server, '/library/onDeck')
|
||||
|
||||
def recentlyAdded(self):
|
||||
""" Returns a list of all media items recently added. """
|
||||
return utils.listItems(self.server, '/library/recentlyAdded')
|
||||
|
||||
def get(self, title):
|
||||
return utils.findItem(self.server, '/library/all', title)
|
||||
def get(self, title): # this should use hub search when its merged
|
||||
""" Return the first item from all items with the specified title.
|
||||
|
||||
Parameters:
|
||||
title (str): Title of the item to return.
|
||||
"""
|
||||
for i in self.all():
|
||||
if i.title.lower() == title.lower():
|
||||
return i
|
||||
|
||||
def getByKey(self, key):
|
||||
""" Return the first item from all items with the specified key.
|
||||
|
||||
Parameters:
|
||||
key (str): Key of the item to return.
|
||||
"""
|
||||
return utils.findKey(self.server, key)
|
||||
|
||||
def search(self, title=None, libtype=None, **kwargs):
|
||||
""" Searching within a library section is much more powerful. It seems certain attributes on the media
|
||||
objects can be targeted to filter this search down a bit, but I havent found the documentation for
|
||||
it.
|
||||
""" Searching within a library section is much more powerful. It seems certain
|
||||
attributes on the media objects can be targeted to filter this search down
|
||||
a bit, but I havent found the documentation for it.
|
||||
|
||||
Example: "studio=Comedy%20Central" or "year=1999" "title=Kung Fu" all work. Other items
|
||||
such as actor=<id> seem to work, but require you already know the id of the actor.
|
||||
TLDR: This is untested but seems to work. Use library section search when you can.
|
||||
such as actor=<id> seem to work, but require you already know the id of the actor.
|
||||
TLDR: This is untested but seems to work. Use library section search when you can.
|
||||
"""
|
||||
args = {}
|
||||
if title:
|
||||
|
@ -86,16 +129,30 @@ class Library(object):
|
|||
return utils.listItems(self.server, query)
|
||||
|
||||
def cleanBundles(self):
|
||||
""" Poster images and other metadata for items in your library are kept in "bundle"
|
||||
packages. When you remove items from your library, these bundles aren't immediately
|
||||
removed. Removing these old bundles can reduce the size of your install. By default, your
|
||||
server will automatically clean up old bundles once a week as part of Scheduled Tasks.
|
||||
"""
|
||||
# TODO: Should this check the response for success or the correct mediaprefix?
|
||||
self.server.query('/library/clean/bundles')
|
||||
|
||||
def emptyTrash(self):
|
||||
""" If a library has items in the Library Trash, use this option to empty the Trash. """
|
||||
for section in self.sections():
|
||||
section.emptyTrash()
|
||||
|
||||
def optimize(self):
|
||||
""" The Optimize option cleans up the server database from unused or fragmented data.
|
||||
For example, if you have deleted or added an entire library or many items in a
|
||||
library, you may like to optimize the database.
|
||||
"""
|
||||
self.server.query('/library/optimize')
|
||||
|
||||
def refresh(self):
|
||||
""" Refresh the metadata for the entire library. This will fetch fresh metadata for
|
||||
all contents in the library, including items that already have metadata.
|
||||
"""
|
||||
self.server.query('/library/sections/all/refresh')
|
||||
|
||||
def __len__(self):
|
||||
|
@ -103,6 +160,33 @@ class Library(object):
|
|||
|
||||
|
||||
class LibrarySection(object):
|
||||
""" Base class for a single library section.
|
||||
|
||||
Parameters:
|
||||
server (:class:`~plexapi.server.PlexServer`): PlexServer object this library section is from.
|
||||
data (ElementTree): Response from PlexServer used to build this object (optional).
|
||||
initpath (str): Relative path requested when retrieving specified `data` (optional).
|
||||
|
||||
Attributes:
|
||||
server (:class:`~plexapi.server.PlexServer`): Server this client is connected to.
|
||||
initpath (str): Path requested when building this object.
|
||||
agent (str): Unknown (com.plexapp.agents.imdb, etc)
|
||||
allowSync (bool): True if you allow syncing content from this section.
|
||||
art (str): Wallpaper artwork used to respresent this section.
|
||||
composite (str): Composit image used to represent this section.
|
||||
createdAt (datetime): Datetime this library section was created.
|
||||
filters (str): Unknown
|
||||
key (str): Key (or ID) of this library section.
|
||||
language (str): Language represented in this section (en, xn, etc).
|
||||
locations (str): Paths on disk where section content is stored.
|
||||
refreshing (str): True if this section is currently being refreshed.
|
||||
scanner (str): Internal scanner used to find media (Plex Movie Scanner, Plex Premium Music Scanner, etc.)
|
||||
thumb (str): Thumbnail image used to represent this section.
|
||||
title (str): Title of this section.
|
||||
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)
|
||||
"""
|
||||
ALLOWED_FILTERS = ()
|
||||
ALLOWED_SORT = ()
|
||||
BOOLEAN_FILTERS = ('unwatched', 'duplicate')
|
||||
|
@ -118,7 +202,6 @@ class LibrarySection(object):
|
|||
self.filters = data.attrib.get('filters')
|
||||
self.key = data.attrib.get('key')
|
||||
self.language = data.attrib.get('language')
|
||||
self.language = data.attrib.get('language')
|
||||
self.locations = utils.findLocations(data)
|
||||
self.refreshing = utils.cast(bool, data.attrib.get('refreshing'))
|
||||
self.scanner = data.attrib.get('scanner')
|
||||
|
@ -133,42 +216,66 @@ class LibrarySection(object):
|
|||
return '<%s:%s>' % (self.__class__.__name__, title.encode('utf8'))
|
||||
|
||||
def get(self, title):
|
||||
""" Returns the media item with the specified title.
|
||||
|
||||
Parameters:
|
||||
title (str): Title of the item to return.
|
||||
"""
|
||||
path = '/library/sections/%s/all' % self.key
|
||||
return utils.findItem(self.server, path, title)
|
||||
|
||||
def all(self):
|
||||
""" Returns a list of media from this library section. """
|
||||
return utils.listItems(self.server, '/library/sections/%s/all' % self.key)
|
||||
|
||||
def onDeck(self):
|
||||
""" Returns a list of media items on deck from this library section. """
|
||||
return utils.listItems(self.server, '/library/sections/%s/onDeck' % self.key)
|
||||
|
||||
def recentlyAdded(self, maxresults=50):
|
||||
""" Returns a list of media items recently added from this library section.
|
||||
|
||||
Parameters:
|
||||
maxresults (int): Max number of items to return (default 50).
|
||||
"""
|
||||
return self.search(sort='addedAt:desc', maxresults=maxresults)
|
||||
|
||||
def analyze(self):
|
||||
self.server.query('/library/sections/%s/analyze' % self.key)
|
||||
""" Run an analysis on all of the items in this library section. """
|
||||
self.server.query('/library/sections/%s/analyze' % self.key, method=self.server.session.put)
|
||||
|
||||
def emptyTrash(self):
|
||||
""" If a section has items in the Trash, use this option to empty the Trash. """
|
||||
self.server.query('/library/sections/%s/emptyTrash' % self.key)
|
||||
|
||||
def refresh(self):
|
||||
""" Refresh the metadata for this library section. This will fetch fresh metadata for
|
||||
all contents in the section, including items that already have metadata.
|
||||
"""
|
||||
self.server.query('/library/sections/%s/refresh' % self.key)
|
||||
|
||||
def listChoices(self, category, libtype=None, **kwargs):
|
||||
""" List choices for the specified filter category. kwargs can be any of the same
|
||||
kwargs in self.search() to help narrow down the choices to only those that
|
||||
matter in your current context.
|
||||
""" Returns a list of :class:`~plexapi.library.FilterChoice` objects for the
|
||||
specified category and libtype. kwargs can be any of the same kwargs in
|
||||
:func:`plexapi.library.LibraySection.search()` to help narrow down the choices
|
||||
to only those that matter in your current context.
|
||||
|
||||
Parameters:
|
||||
category (str): Category to list choices for (genre, contentRating, etc).
|
||||
libtype (int): Library type of item filter.
|
||||
**kwargs (dict): Additional kwargs to narrow down the choices.
|
||||
|
||||
Raises:
|
||||
:class:`~plexapi.exceptions.BadRequest`: Cannot include kwarg equal to specified category.
|
||||
"""
|
||||
if category in kwargs:
|
||||
raise BadRequest(
|
||||
'Cannot include kwarg equal to specified category: %s' % category)
|
||||
raise BadRequest('Cannot include kwarg equal to specified category: %s' % category)
|
||||
args = {}
|
||||
for subcategory, value in kwargs.items():
|
||||
args[category] = self._cleanSearchFilter(subcategory, value)
|
||||
if libtype is not None:
|
||||
args['type'] = utils.searchType(libtype)
|
||||
query = '/library/sections/%s/%s%s' % (
|
||||
self.key, category, utils.joinArgs(args))
|
||||
query = '/library/sections/%s/%s%s' % (self.key, category, utils.joinArgs(args))
|
||||
return utils.listItems(self.server, query, bytag=True)
|
||||
|
||||
def search(self, title=None, sort=None, maxresults=999999, libtype=None, **kwargs):
|
||||
|
@ -177,29 +284,29 @@ class LibrarySection(object):
|
|||
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.
|
||||
|
||||
Args:
|
||||
title (string, optional): General string query to search for.
|
||||
sort (string): column:dir; column can be any of {addedAt, originallyAvailableAt, lastViewedAt,
|
||||
titleSort, rating, mediaHeight, duration}. dir can be asc or desc.
|
||||
maxresults (int): Only return the specified number of results
|
||||
libtype (string): Filter results to a spcifiec libtype {movie, show, episode, artist, album, track}
|
||||
kwargs: Any of the available filters for the current library section. Partial string
|
||||
Parameters:
|
||||
title (str): General string query to search for (optional).
|
||||
sort (str): column:dir; column can be any of {addedAt, originallyAvailableAt, lastViewedAt,
|
||||
titleSort, rating, mediaHeight, duration}. dir can be asc or desc (optional).
|
||||
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).
|
||||
**kwargs (dict): Any of the available filters for the current library section. Partial string
|
||||
matches allowed. Multiple matches OR together. All inputs will be compared with the
|
||||
available options and a warning logged if the option does not appear valid.
|
||||
|
||||
'unwatched': Display or hide unwatched content (True, False). [all]
|
||||
'duplicate': Display or hide duplicate items (True, False). [movie]
|
||||
'actor': List of actors to search ([actor_or_id, ...]). [movie]
|
||||
'collection': List of collections to search within ([collection_or_id, ...]). [all]
|
||||
'contentRating': List of content ratings to search within ([rating_or_key, ...]). [movie,tv]
|
||||
'country': List of countries to search within ([country_or_key, ...]). [movie,music]
|
||||
'decade': List of decades to search within ([yyy0, ...]). [movie]
|
||||
'director': List of directors to search ([director_or_id, ...]). [movie]
|
||||
'genre': List Genres to search within ([genere_or_id, ...]). [all]
|
||||
'network': List of TV networks to search within ([resolution_or_key, ...]). [tv]
|
||||
'resolution': List of video resolutions to search within ([resolution_or_key, ...]). [movie]
|
||||
'studio': List of studios to search within ([studio_or_key, ...]). [music]
|
||||
'year': List of years to search within ([yyyy, ...]). [all]
|
||||
* unwatched: Display or hide unwatched content (True, False). [all]
|
||||
* duplicate: Display or hide duplicate items (True, False). [movie]
|
||||
* actor: List of actors to search ([actor_or_id, ...]). [movie]
|
||||
* collection: List of collections to search within ([collection_or_id, ...]). [all]
|
||||
* contentRating: List of content ratings to search within ([rating_or_key, ...]). [movie,tv]
|
||||
* country: List of countries to search within ([country_or_key, ...]). [movie,music]
|
||||
* decade: List of decades to search within ([yyy0, ...]). [movie]
|
||||
* director: List of directors to search ([director_or_id, ...]). [movie]
|
||||
* genre: List Genres to search within ([genere_or_id, ...]). [all]
|
||||
* network: List of TV networks to search within ([resolution_or_key, ...]). [tv]
|
||||
* resolution: List of video resolutions to search within ([resolution_or_key, ...]). [movie]
|
||||
* studio: List of studios to search within ([studio_or_key, ...]). [music]
|
||||
* year: List of years to search within ([yyyy, ...]). [all]
|
||||
"""
|
||||
# Cleanup the core arguments
|
||||
args = {}
|
||||
|
@ -211,12 +318,10 @@ class LibrarySection(object):
|
|||
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):
|
||||
query = '/library/sections/%s/all%s' % (
|
||||
self.key, utils.joinArgs(args))
|
||||
|
@ -233,13 +338,11 @@ class LibrarySection(object):
|
|||
return '1' if value else '0'
|
||||
if not isinstance(value, (list, tuple)):
|
||||
value = [value]
|
||||
|
||||
# convert list of values to list of keys or ids
|
||||
result = set()
|
||||
choices = self.listChoices(category, libtype)
|
||||
lookup = {c.title.lower(): unquote(unquote(c.key)) for c in choices}
|
||||
allowed = set(c.key for c in choices)
|
||||
|
||||
for item in value:
|
||||
item = str(item.id if isinstance(item, MediaTag) else item).lower()
|
||||
# find most logical choice(s) to use in url
|
||||
|
@ -254,8 +357,7 @@ class LibrarySection(object):
|
|||
map(result.add, matches)
|
||||
continue
|
||||
# nothing matched; use raw item value
|
||||
log.warning(
|
||||
'Filter value not listed, using raw item value: %s' % item)
|
||||
log.warning('Filter value not listed, using raw item value: %s' % item)
|
||||
result.add(item)
|
||||
return ','.join(result)
|
||||
|
||||
|
@ -271,58 +373,108 @@ class LibrarySection(object):
|
|||
|
||||
|
||||
class MovieSection(LibrarySection):
|
||||
ALLOWED_FILTERS = ('unwatched', 'duplicate', 'year', 'decade', 'genre', 'contentRating', 'collection',
|
||||
'director', 'actor', 'country', 'studio', 'resolution')
|
||||
""" Represents a :class:`~plexapi.library.LibrarySection` section containing movies.
|
||||
|
||||
Attributes:
|
||||
ALLOWED_FILTERS (list<str>): List of allowed search filters. ('unwatched',
|
||||
'duplicate', 'year', 'decade', 'genre', 'contentRating', 'collection',
|
||||
'director', 'actor', 'country', 'studio', 'resolution')
|
||||
ALLOWED_SORT (list<str>): List of allowed sorting keys. ('addedAt',
|
||||
'originallyAvailableAt', 'lastViewedAt', 'titleSort', 'rating',
|
||||
'mediaHeight', 'duration')
|
||||
TYPE (str): 'movie'
|
||||
"""
|
||||
ALLOWED_FILTERS = ('unwatched', 'duplicate', 'year', 'decade', 'genre', 'contentRating',
|
||||
'collection', 'director', 'actor', 'country', 'studio', 'resolution')
|
||||
ALLOWED_SORT = ('addedAt', 'originallyAvailableAt', 'lastViewedAt', 'titleSort', 'rating',
|
||||
'mediaHeight', 'duration')
|
||||
'mediaHeight', 'duration')
|
||||
TYPE = 'movie'
|
||||
|
||||
|
||||
class ShowSection(LibrarySection):
|
||||
ALLOWED_FILTERS = ('unwatched', 'year', 'genre',
|
||||
'contentRating', 'network', 'collection')
|
||||
ALLOWED_SORT = ('addedAt', 'lastViewedAt',
|
||||
'originallyAvailableAt', 'titleSort', 'rating', 'unwatched')
|
||||
""" Represents a :class:`~plexapi.library.LibrarySection` section containing tv shows.
|
||||
|
||||
Attributes:
|
||||
ALLOWED_FILTERS (list<str>): List of allowed search filters. ('unwatched',
|
||||
'year', 'genre', 'contentRating', 'network', 'collection')
|
||||
ALLOWED_SORT (list<str>): List of allowed sorting keys. ('addedAt', 'lastViewedAt',
|
||||
'originallyAvailableAt', 'titleSort', 'rating', 'unwatched')
|
||||
TYPE (str): 'show'
|
||||
"""
|
||||
ALLOWED_FILTERS = ('unwatched', 'year', 'genre', 'contentRating', 'network', 'collection')
|
||||
ALLOWED_SORT = ('addedAt', 'lastViewedAt', 'originallyAvailableAt', 'titleSort',
|
||||
'rating', 'unwatched')
|
||||
TYPE = 'show'
|
||||
|
||||
def searchShows(self, **kwargs):
|
||||
""" Search for a show. See :func:`~plexapi.library.LibrarySection.search()` for usage. """
|
||||
return self.search(libtype='show', **kwargs)
|
||||
|
||||
def searchEpisodes(self, **kwargs):
|
||||
""" Search for an episode. See :func:`~plexapi.library.LibrarySection.search()` for usage. """
|
||||
return self.search(libtype='episode', **kwargs)
|
||||
|
||||
def recentlyAdded(self, libtype='episode', maxresults=50):
|
||||
""" Returns a list of recently added episodes from this library section.
|
||||
|
||||
Parameters:
|
||||
maxresults (int): Max number of items to return (default 50).
|
||||
"""
|
||||
return self.search(sort='addedAt:desc', libtype=libtype, maxresults=maxresults)
|
||||
|
||||
|
||||
class MusicSection(LibrarySection):
|
||||
""" Represents a :class:`~plexapi.library.LibrarySection` section containing music artists.
|
||||
|
||||
Attributes:
|
||||
ALLOWED_FILTERS (list<str>): List of allowed search filters. ('genre',
|
||||
'country', 'collection')
|
||||
ALLOWED_SORT (list<str>): List of allowed sorting keys. ('addedAt',
|
||||
'lastViewedAt', 'viewCount', 'titleSort')
|
||||
TYPE (str): 'artist'
|
||||
"""
|
||||
ALLOWED_FILTERS = ('genre', 'country', 'collection')
|
||||
ALLOWED_SORT = ('addedAt', 'lastViewedAt', 'viewCount', 'titleSort')
|
||||
TYPE = 'artist'
|
||||
|
||||
def albums(self):
|
||||
""" Returns a list of :class:`~plexapi.audio.Album` objects in this section. """
|
||||
return utils.listItems(self.server, '/library/sections/%s/albums' % self.key)
|
||||
|
||||
def searchArtists(self, **kwargs):
|
||||
""" Search for an artist. See :func:`~plexapi.library.LibrarySection.search()` for usage. """
|
||||
return self.search(libtype='artist', **kwargs)
|
||||
|
||||
def searchAlbums(self, **kwargs):
|
||||
""" Search for an album. See :func:`~plexapi.library.LibrarySection.search()` for usage. """
|
||||
return self.search(libtype='album', **kwargs)
|
||||
|
||||
def searchTracks(self, **kwargs):
|
||||
""" Search for a track. See :func:`~plexapi.library.LibrarySection.search()` for usage. """
|
||||
return self.search(libtype='track', **kwargs)
|
||||
|
||||
|
||||
class PhotoSection(LibrarySection):
|
||||
ALLOWED_FILTERS = ()
|
||||
""" Represents a :class:`~plexapi.library.LibrarySection` section containing photos.
|
||||
|
||||
Attributes:
|
||||
ALLOWED_FILTERS (list<str>): List of allowed search filters. <NONE>
|
||||
ALLOWED_SORT (list<str>): List of allowed sorting keys. <NONE>
|
||||
TYPE (str): 'photo'
|
||||
"""
|
||||
ALLOWED_FILTERS = ('all', 'iso', 'make', 'lens', 'aperture', 'exposure')
|
||||
ALLOWED_SORT = ()
|
||||
TYPE = 'photo'
|
||||
|
||||
def searchAlbums(self, **kwargs):
|
||||
return self.search(libtype='photo', **kwargs)
|
||||
def searchAlbums(self, title, **kwargs): # lets use this for now.
|
||||
""" Search for an album. See :func:`~plexapi.library.LibrarySection.search()` for usage. """
|
||||
albums = utils.listItems(self.server, '/library/sections/%s/all?type=14' % self.key)
|
||||
return [i for i in albums if i.title.lower() == title.lower()]
|
||||
|
||||
def searchPhotos(self, **kwargs):
|
||||
return self.search(libtype='photo', **kwargs)
|
||||
def searchPhotos(self, title, **kwargs):
|
||||
""" Search for a photo. See :func:`~plexapi.library.LibrarySection.search()` for usage. """
|
||||
photos = utils.listItems(self.server, '/library/sections/%s/all?type=13' % self.key)
|
||||
return [i for i in photos if i.title.lower() == title.lower()]
|
||||
|
||||
|
||||
@utils.register_libtype
|
||||
|
@ -363,6 +515,20 @@ class Hub(object):
|
|||
|
||||
@utils.register_libtype
|
||||
class FilterChoice(object):
|
||||
""" Represents a single filter choice. These objects are gathered when using filters
|
||||
while searching for library items and is the object returned in the result set of
|
||||
:func:`~plexapi.library.LibrarySection.listChoices()`.
|
||||
|
||||
Attributes:
|
||||
server (:class:`~plexapi.server.PlexServer`): PlexServer this client is connected to.
|
||||
initpath (str): Relative path requested when retrieving specified `data` (optional).
|
||||
fastKey (str): API path to quickly list all items in this filter
|
||||
(/library/sections/<section>/all?genre=<key>)
|
||||
key (str): Short key (id) of this filter option (used ad <key> in fastKey above).
|
||||
thumb (str): Thumbnail used to represent this filter option.
|
||||
title (str): Human readable name for this filter option.
|
||||
type (str): Filter type (genre, contentRating, etc).
|
||||
"""
|
||||
TYPE = 'Directory'
|
||||
|
||||
def __init__(self, server, data, initpath):
|
||||
|
|
|
@ -1,7 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
PlexAPI Media
|
||||
"""
|
||||
from plexapi.utils import cast
|
||||
|
||||
|
||||
|
@ -177,15 +174,49 @@ class MediaTag(object):
|
|||
return '<%s:%s:%s>' % (self.__class__.__name__, self.id, tag)
|
||||
|
||||
|
||||
class Collection(MediaTag): TYPE = 'Collection'; FILTER = 'collection'
|
||||
class Country(MediaTag): TYPE = 'Country'; FILTER = 'country'
|
||||
class Director(MediaTag): TYPE = 'Director'; FILTER = 'director'
|
||||
class Genre(MediaTag): TYPE = 'Genre'; FILTER = 'genre'
|
||||
class Mood(MediaTag): TYPE = 'Mood'; FILTER = 'mood'
|
||||
class Producer(MediaTag): TYPE = 'Producer'; FILTER = 'producer'
|
||||
class Role(MediaTag): TYPE = 'Role'; FILTER = 'role'
|
||||
class Similar(MediaTag): TYPE = 'Similar'; FILTER = 'similar'
|
||||
class Writer(MediaTag): TYPE = 'Writer'; FILTER = 'writer'
|
||||
class Collection(MediaTag):
|
||||
TYPE = 'Collection'
|
||||
FILTER = 'collection'
|
||||
|
||||
|
||||
class Country(MediaTag):
|
||||
TYPE = 'Country'
|
||||
FILTER = 'country'
|
||||
|
||||
|
||||
class Director(MediaTag):
|
||||
TYPE = 'Director'
|
||||
FILTER = 'director'
|
||||
|
||||
|
||||
class Genre(MediaTag):
|
||||
TYPE = 'Genre'
|
||||
FILTER = 'genre'
|
||||
|
||||
|
||||
class Mood(MediaTag):
|
||||
TYPE = 'Mood'
|
||||
FILTER = 'mood'
|
||||
|
||||
|
||||
class Producer(MediaTag):
|
||||
TYPE = 'Producer'
|
||||
FILTER = 'producer'
|
||||
|
||||
|
||||
class Role(MediaTag):
|
||||
TYPE = 'Role'
|
||||
FILTER = 'role'
|
||||
|
||||
|
||||
class Similar(MediaTag):
|
||||
TYPE = 'Similar'
|
||||
FILTER = 'similar'
|
||||
|
||||
|
||||
class Writer(MediaTag):
|
||||
TYPE = 'Writer'
|
||||
FILTER = 'writer'
|
||||
|
||||
|
||||
class Field(object):
|
||||
|
|
|
@ -1,69 +1,56 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import sys
|
||||
|
||||
if sys.version_info <= (3, 3):
|
||||
try:
|
||||
from xml.etree import cElementTree as ElementTree
|
||||
except ImportError:
|
||||
from xml.etree import ElementTree
|
||||
else:
|
||||
# py 3.3 and above selects the fastest automatically
|
||||
from xml.etree import ElementTree
|
||||
|
||||
from requests.status_codes import _codes as codes
|
||||
|
||||
import plexapi
|
||||
import requests
|
||||
from plexapi import TIMEOUT, log, utils
|
||||
import plexapi, requests
|
||||
from plexapi import TIMEOUT, log, logfilter, utils
|
||||
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
|
||||
from plexapi.client import PlexClient
|
||||
from plexapi.compat import ElementTree
|
||||
from plexapi.server import PlexServer
|
||||
from requests.status_codes import _codes as codes
|
||||
CONFIG = plexapi.CONFIG
|
||||
|
||||
|
||||
class MyPlexAccount(object):
|
||||
"""Your personal MyPlex account and profile information
|
||||
""" MyPlex account and profile information. The easiest way to build
|
||||
this object is by calling the staticmethod :func:`~plexapi.myplex.MyPlexAccount.signin`
|
||||
with your username and password. This object represents the data found Account on
|
||||
the myplex.tv servers at the url https://plex.tv/users/account.
|
||||
|
||||
Attributes:
|
||||
authenticationToken (TYPE): Description
|
||||
BASEURL (str): Description
|
||||
certificateVersion (TYPE): Description
|
||||
cloudSyncDevice (TYPE): Description
|
||||
email (TYPE): Description
|
||||
entitlements (TYPE): Description
|
||||
guest (TYPE): Description
|
||||
home (TYPE): Description
|
||||
homeSize (TYPE): Description
|
||||
id (TYPE): Description
|
||||
locale (TYPE): Description
|
||||
mailing_list_status (TYPE): Description
|
||||
maxHomeSize (TYPE): Description
|
||||
queueEmail (TYPE): Description
|
||||
queueUid (TYPE): Description
|
||||
restricted (TYPE): Description
|
||||
roles (TYPE): Description
|
||||
scrobbleTypes (TYPE): Description
|
||||
secure (TYPE): Description
|
||||
SIGNIN (str): Description
|
||||
subscriptionActive (TYPE): Description
|
||||
subscriptionFeatures (TYPE): Description
|
||||
subscriptionPlan (TYPE): Description
|
||||
subscriptionStatus (TYPE): Description
|
||||
thumb (TYPE): Description
|
||||
title (TYPE): Description
|
||||
username (TYPE): Description
|
||||
uuid (TYPE): Description
|
||||
Attributes:
|
||||
authenticationToken (str): <Unknown>
|
||||
certificateVersion (str): <Unknown>
|
||||
cloudSyncDevice (str):
|
||||
email (str): Your current Plex email address.
|
||||
entitlements (List<str>): List of devices your allowed to use with this account.
|
||||
guest (bool): <Unknown>
|
||||
home (bool): <Unknown>
|
||||
homeSize (int): <Unknown>
|
||||
id (str): Your Plex account ID.
|
||||
locale (str): Your Plex locale
|
||||
mailing_list_status (str): Your current mailing list status.
|
||||
maxHomeSize (int): <Unknown>
|
||||
queueEmail (str): Email address to add items to your `Watch Later` queue.
|
||||
queueUid (str): <Unknown>
|
||||
restricted (bool): <Unknown>
|
||||
roles: (List<str>) Lit of account roles. Plexpass membership listed here.
|
||||
scrobbleTypes (str): Description
|
||||
secure (bool): Description
|
||||
subscriptionActive (bool): True if your subsctiption is active.
|
||||
subscriptionFeatures: (List<str>) List of features allowed on your subscription.
|
||||
subscriptionPlan (str): Name of subscription plan.
|
||||
subscriptionStatus (str): String representation of `subscriptionActive`.
|
||||
thumb (str): URL of your account thumbnail.
|
||||
title (str): <Unknown> - Looks like an alias for `username`.
|
||||
username (str): Your account username.
|
||||
uuid (str): <Unknown>
|
||||
"""
|
||||
BASEURL = 'https://plex.tv/users/account'
|
||||
SIGNIN = 'https://my.plexapp.com/users/sign_in.xml'
|
||||
|
||||
def __init__(self, data, initpath=None):
|
||||
"""Sets the attrs.
|
||||
|
||||
Args:
|
||||
data (Element): XML response from PMS as a Element
|
||||
initpath (string, optional): relative path.
|
||||
"""
|
||||
def __init__(self, data=None, initpath=None, session=None):
|
||||
self._session = session or requests.Session()
|
||||
self.authenticationToken = data.attrib.get('authenticationToken')
|
||||
if self.authenticationToken:
|
||||
logfilter.add_secret(self.authenticationToken)
|
||||
self.certificateVersion = data.attrib.get('certificateVersion')
|
||||
self.cloudSyncDevice = data.attrib.get('cloudSyncDevice')
|
||||
self.email = data.attrib.get('email')
|
||||
|
@ -83,143 +70,114 @@ class MyPlexAccount(object):
|
|||
self.title = data.attrib.get('title')
|
||||
self.username = data.attrib.get('username')
|
||||
self.uuid = data.attrib.get('uuid')
|
||||
|
||||
# TODO: Complete these items!
|
||||
self.subscriptionActive = None # renamed on server
|
||||
self.subscriptionStatus = None # renamed on server
|
||||
self.subscriptionPlan = None # renmaed on server
|
||||
self.subscriptionFeatures = None # renamed on server
|
||||
# TODO: Fetch missing MyPlexAccount attributes
|
||||
self.subscriptionActive = None # renamed on server
|
||||
self.subscriptionStatus = None # renamed on server
|
||||
self.subscriptionPlan = None # renmaed on server
|
||||
self.subscriptionFeatures = None # renamed on server
|
||||
self.roles = None
|
||||
self.entitlements = None
|
||||
|
||||
def __repr__(self):
|
||||
"""Pretty print."""
|
||||
return '<%s:%s:%s>' % (self.__class__.__name__, self.id, self.username.encode('utf8'))
|
||||
|
||||
def devices(self):
|
||||
"""Return a all devices connected to the plex account.
|
||||
|
||||
Returns:
|
||||
list: of MyPlexDevice
|
||||
"""
|
||||
return _listItems(MyPlexDevice.BASEURL, self.authenticationToken, MyPlexDevice)
|
||||
|
||||
def device(self, name):
|
||||
"""Return a device wth a matching name.
|
||||
""" Returns the :class:`~plexapi.myplex.MyPlexDevice` that matches the name specified.
|
||||
|
||||
Args:
|
||||
name (str): Name to match against.
|
||||
|
||||
Returns:
|
||||
class: MyPlexDevice
|
||||
Parameters:
|
||||
name (str): Name to match against.
|
||||
"""
|
||||
return _findItem(self.devices(), name)
|
||||
|
||||
def resources(self):
|
||||
"""Resources.
|
||||
def devices(self):
|
||||
""" Returns a list of all :class:`~plexapi.myplex.MyPlexDevice` objects connected to the server. """
|
||||
return _listItems(MyPlexDevice.BASEURL, self.authenticationToken, MyPlexDevice)
|
||||
|
||||
Returns:
|
||||
List: of MyPlexResource
|
||||
"""
|
||||
def resources(self):
|
||||
""" Returns a list of all :class:`~plexapi.myplex.MyPlexResource` objects connected to the server. """
|
||||
return _listItems(MyPlexResource.BASEURL, self.authenticationToken, MyPlexResource)
|
||||
|
||||
def resource(self, name):
|
||||
"""Find resource ny name.
|
||||
""" Returns the :class:`~plexapi.myplex.MyPlexResource` that matches the name specified.
|
||||
|
||||
Args:
|
||||
name (str): to find
|
||||
|
||||
Returns:
|
||||
class: MyPlexResource
|
||||
Parameters:
|
||||
name (str): Name to match against.
|
||||
"""
|
||||
return _findItem(self.resources(), name)
|
||||
|
||||
def users(self):
|
||||
"""List of users.
|
||||
|
||||
Returns:
|
||||
List: of MyPlexuser
|
||||
"""
|
||||
""" Returns a list of all :class:`~plexapi.myplex.MyPlexUser` objects connected to your account. """
|
||||
return _listItems(MyPlexUser.BASEURL, self.authenticationToken, MyPlexUser)
|
||||
|
||||
def user(self, email):
|
||||
"""Find a user by email.
|
||||
""" Returns the :class:`~myplex.MyPlexUser` that matches the email or username specified.
|
||||
|
||||
Args:
|
||||
email (str): Username to match against.
|
||||
|
||||
Returns:
|
||||
class: User
|
||||
Parameters:
|
||||
email (str): Username or email to match against.
|
||||
"""
|
||||
return _findItem(self.users(), email, ['username', 'email'])
|
||||
|
||||
@classmethod
|
||||
def signin(cls, username, password):
|
||||
"""Summary
|
||||
def signin(cls, username=None, password=None, session=None):
|
||||
""" Returns a new :class:`~myplex.MyPlexAccount` object by connecting to MyPlex with the
|
||||
specified username and password. This is essentially logging into MyPlex and often
|
||||
the very first entry point to using this API.
|
||||
|
||||
Args:
|
||||
username (str): username
|
||||
password (str): password
|
||||
Parameters:
|
||||
username (str): Your MyPlex.tv username. If not specified, it will check the config.ini file.
|
||||
password (str): Your MyPlex.tv password. If not specified, it will check the config.ini file.
|
||||
|
||||
Returns:
|
||||
class: MyPlexAccount
|
||||
|
||||
Raises:
|
||||
BadRequest: (HTTPCODE) http codename
|
||||
Unauthorized: (HTTPCODE) http codename
|
||||
Raises:
|
||||
:class:`~plexapi.exceptions.Unauthorized`: (401) If the username or password are invalid.
|
||||
:class:`~plexapi.exceptions.BadRequest`: If any other errors occured not allowing us to log into MyPlex.tv.
|
||||
"""
|
||||
if 'X-Plex-Token' in plexapi.BASE_HEADERS:
|
||||
del plexapi.BASE_HEADERS['X-Plex-Token']
|
||||
username = username or CONFIG.get('authentication.username')
|
||||
password = password or CONFIG.get('authentication.password')
|
||||
auth = (username, password)
|
||||
log.info('POST %s', cls.SIGNIN)
|
||||
response = requests.post(
|
||||
sess = session or requests.Session()
|
||||
response = sess.post(
|
||||
cls.SIGNIN, headers=plexapi.BASE_HEADERS, auth=auth, timeout=TIMEOUT)
|
||||
if response.status_code != requests.codes.created:
|
||||
codename = codes.get(response.status_code)[0]
|
||||
if response.status_code == 401:
|
||||
raise Unauthorized('(%s) %s' %
|
||||
(response.status_code, codename))
|
||||
raise Unauthorized('(%s) %s' % (response.status_code, codename))
|
||||
raise BadRequest('(%s) %s' % (response.status_code, codename))
|
||||
data = ElementTree.fromstring(response.text.encode('utf8'))
|
||||
return cls(data, cls.SIGNIN)
|
||||
return MyPlexAccount(data, cls.SIGNIN, session=sess)
|
||||
|
||||
|
||||
# Not to be confused with the MyPlexAccount, this represents
|
||||
# non-signed in users such as friends and linked accounts.
|
||||
class MyPlexUser(object):
|
||||
"""Class to other users.
|
||||
""" This object represents non-signed in users such as friends and linked
|
||||
accounts. NOTE: This should not be confused with the :class:`~myplex.MyPlexAccount`
|
||||
which is your specific account. The raw xml for the data presented here
|
||||
can be found at: https://plex.tv/api/users/
|
||||
|
||||
Attributes:
|
||||
allowCameraUpload (bool): True if this user can upload images
|
||||
allowChannels (bool): True if this user has access to channels
|
||||
allowSync (bool): True if this user can sync
|
||||
BASEURL (str): Description
|
||||
email (str): user@gmail.com
|
||||
filterAll (str): Description
|
||||
filterMovies (str): Description
|
||||
filterMusic (str): Description
|
||||
filterPhotos (str): Description
|
||||
filterTelevision (str): Description
|
||||
home (bool):
|
||||
id (int): 1337
|
||||
protected (False): Is this if ssl? check it
|
||||
recommendationsPlaylistId (str): Description
|
||||
restricted (str): fx 0
|
||||
thumb (str): Link to the users avatar
|
||||
title (str): Hellowlol
|
||||
username (str): Hellowlol
|
||||
Attributes:
|
||||
allowCameraUpload (bool): True if this user can upload images
|
||||
allowChannels (bool): True if this user has access to channels
|
||||
allowSync (bool): True if this user can sync
|
||||
email (str): User's email address (user@gmail.com)
|
||||
filterAll (str): Unknown
|
||||
filterMovies (str): Unknown
|
||||
filterMusic (str): Unknown
|
||||
filterPhotos (str): Unknown
|
||||
filterTelevision (str): Unknown
|
||||
home (bool): Unknown
|
||||
id (int): User's Plex account ID.
|
||||
protected (False): Unknown (possibly SSL enabled?)
|
||||
recommendationsPlaylistId (str): Unknown
|
||||
restricted (str): Unknown
|
||||
thumb (str): Link to the users avatar
|
||||
title (str): Seems to be an aliad for username
|
||||
username (str): User's username
|
||||
"""
|
||||
BASEURL = 'https://plex.tv/api/users/'
|
||||
|
||||
def __init__(self, data, initpath=None):
|
||||
"""Summary
|
||||
|
||||
Args:
|
||||
data (Element): XML repsonse as Element
|
||||
initpath (None, optional): Relative url str
|
||||
"""
|
||||
self.allowCameraUpload = utils.cast(
|
||||
bool, data.attrib.get('allowCameraUpload'))
|
||||
self.allowCameraUpload = utils.cast(bool, data.attrib.get('allowCameraUpload'))
|
||||
self.allowChannels = utils.cast(bool, data.attrib.get('allowChannels'))
|
||||
self.allowSync = utils.cast(bool, data.attrib.get('allowSync'))
|
||||
self.email = data.attrib.get('email')
|
||||
|
@ -231,50 +189,48 @@ class MyPlexUser(object):
|
|||
self.home = utils.cast(bool, data.attrib.get('home'))
|
||||
self.id = utils.cast(int, data.attrib.get('id'))
|
||||
self.protected = utils.cast(bool, data.attrib.get('protected'))
|
||||
self.recommendationsPlaylistId = data.attrib.get(
|
||||
'recommendationsPlaylistId')
|
||||
self.recommendationsPlaylistId = data.attrib.get('recommendationsPlaylistId')
|
||||
self.restricted = data.attrib.get('restricted')
|
||||
self.thumb = data.attrib.get('thumb')
|
||||
self.title = data.attrib.get('title')
|
||||
self.username = data.attrib.get('username')
|
||||
|
||||
def __repr__(self):
|
||||
"""Pretty repr."""
|
||||
return '<%s:%s:%s>' % (self.__class__.__name__, self.id, self.username)
|
||||
|
||||
|
||||
class MyPlexResource(object):
|
||||
"""Summary
|
||||
""" This object represents resources connected to your Plex server that can provide
|
||||
content such as Plex Media Servers, iPhone or Android clients, etc. The raw xml
|
||||
for the data presented here can be found at: https://plex.tv/api/resources?includeHttps=1
|
||||
|
||||
Attributes:
|
||||
accessToken (str): This resource accesstoken.
|
||||
BASEURL (TYPE): Description
|
||||
clientIdentifier (str): 1f2fe128794fd...
|
||||
connections (list): of ResourceConnection
|
||||
createdAt (datetime): Description
|
||||
device (str): pc
|
||||
home (None): Dunno wtf this can me
|
||||
lastSeenAt (datetime): Description
|
||||
name (str): Pretty name fx S-PC
|
||||
owned (bool): True if this is your own.
|
||||
platform (str): Windows
|
||||
platformVersion (str): fx. 6.1 (Build 7601)
|
||||
presence (bool): True if online
|
||||
product (str): Plex Media Server
|
||||
productVersion (str): 1.3.3.3148-b38628e
|
||||
provides (str): fx server
|
||||
synced (bool): Description
|
||||
Attributes:
|
||||
accessToken (str): This resources accesstoken.
|
||||
clientIdentifier (str): Unique ID for this resource.
|
||||
connections (list): List of :class:`~myplex.ResourceConnection` objects
|
||||
for this resource.
|
||||
createdAt (datetime): Timestamp this resource first connected to your server.
|
||||
device (str): Best guess on the type of device this is (PS, iPhone, Linux, etc).
|
||||
home (bool): Unknown
|
||||
lastSeenAt (datetime): Timestamp this resource last connected.
|
||||
name (str): Descriptive name of this resource.
|
||||
owned (bool): True if this resource is one of your own (you logged into it).
|
||||
platform (str): OS the resource is running (Linux, Windows, Chrome, etc.)
|
||||
platformVersion (str): Version of the platform.
|
||||
presence (bool): True if the resource is online
|
||||
product (str): Plex product (Plex Media Server, Plex for iOS, Plex Web, etc.)
|
||||
productVersion (str): Version of the product.
|
||||
provides (str): List of services this resource provides (client, server,
|
||||
player, pubsub-player, etc.)
|
||||
synced (bool): Unknown (possibly True if the resource has synced content?)
|
||||
"""
|
||||
BASEURL = 'https://plex.tv/api/resources?includeHttps=1'
|
||||
|
||||
def __init__(self, data):
|
||||
"""Summary
|
||||
|
||||
Args:
|
||||
data (Element): XML response as Element
|
||||
"""
|
||||
self.name = data.attrib.get('name')
|
||||
self.accessToken = data.attrib.get('accessToken')
|
||||
if self.accessToken:
|
||||
logfilter.add_secret(self.accessToken)
|
||||
self.product = data.attrib.get('product')
|
||||
self.productVersion = data.attrib.get('productVersion')
|
||||
self.platform = data.attrib.get('platform')
|
||||
|
@ -288,34 +244,36 @@ class MyPlexResource(object):
|
|||
self.home = utils.cast(bool, data.attrib.get('home'))
|
||||
self.synced = utils.cast(bool, data.attrib.get('synced'))
|
||||
self.presence = utils.cast(bool, data.attrib.get('presence'))
|
||||
self.connections = [ResourceConnection(
|
||||
elem) for elem in data if elem.tag == 'Connection']
|
||||
self.connections = [ResourceConnection(elem) for elem in data if elem.tag == 'Connection']
|
||||
|
||||
def __repr__(self):
|
||||
"""Pretty repr."""
|
||||
return '<%s:%s>' % (self.__class__.__name__, self.name.encode('utf8'))
|
||||
|
||||
def connect(self, ssl=None):
|
||||
"""Connect.
|
||||
""" Returns a new :class:`~server.PlexServer` object. Often times there is more than
|
||||
one address specified for a server or client. This function will prioritize local
|
||||
connections before remote and HTTPS before HTTP. After trying to connect to all
|
||||
available addresses for this resource and assuming at least one connection was
|
||||
successful, the PlexServer object is built and returned.
|
||||
|
||||
Args:
|
||||
ssl (None, optional): Use ssl.
|
||||
Parameters:
|
||||
ssl (optional): Set True to only connect to HTTPS connections. Set False to
|
||||
only connect to HTTP connections. Set None (default) to connect to any
|
||||
HTTP or HTTPS connection.
|
||||
|
||||
Returns:
|
||||
class: Plexserver
|
||||
|
||||
Raises:
|
||||
NotFound: Unable to connect to resource: name
|
||||
Raises:
|
||||
:class:`~plexapi.exceptions.NotFound`: When unable to connect to any addresses for this resource.
|
||||
"""
|
||||
|
||||
# Sort connections from (https, local) to (http, remote)
|
||||
# Only check non-local connections unless we own the resource
|
||||
forcelocal = lambda c: self.owned or c.local
|
||||
connections = sorted(
|
||||
self.connections, key=lambda c: c.local, reverse=True)
|
||||
connections = sorted(self.connections, key=lambda c: c.local, reverse=True)
|
||||
https = [c.uri for c in self.connections if forcelocal(c)]
|
||||
http = [c.httpuri for c in self.connections if forcelocal(c)]
|
||||
connections = https + http
|
||||
# Force ssl, no ssl, or any (default)
|
||||
if ssl is True: connections = https
|
||||
elif ssl is False: connections = http
|
||||
else: connections = https + http
|
||||
# Try connecting to all known resource connections in parellel, but
|
||||
# only return the first server (in order) that provides a response.
|
||||
listargs = [[c] for c in connections]
|
||||
|
@ -325,52 +283,33 @@ class MyPlexResource(object):
|
|||
# established.
|
||||
for url, token, result in results:
|
||||
okerr = 'OK' if result else 'ERR'
|
||||
log.info(
|
||||
'Testing resource connection: %s?X-Plex-Token=%s %s', url, token, okerr)
|
||||
|
||||
results = [r[2] for r in results if r and r is not None]
|
||||
log.info('Testing resource connection: %s?X-Plex-Token=%s %s', url, token, okerr)
|
||||
results = [r[2] for r in results if r and r[2] is not None]
|
||||
if not results:
|
||||
raise NotFound('Unable to connect to resource: %s' % self.name)
|
||||
log.info('Connecting to server: %s?X-Plex-Token=%s',
|
||||
results[0].baseurl, results[0].token)
|
||||
|
||||
log.info('Connecting to server: %s?X-Plex-Token=%s', results[0].baseurl, results[0].token)
|
||||
return results[0]
|
||||
|
||||
def _connect(self, url, results, i):
|
||||
"""Connect.
|
||||
|
||||
Args:
|
||||
url (str): url to the resource
|
||||
results (TYPE): Description
|
||||
i (TYPE): Description
|
||||
|
||||
Returns:
|
||||
TYPE: Description
|
||||
"""
|
||||
try:
|
||||
results[i] = (url, self.accessToken,
|
||||
PlexServer(url, self.accessToken))
|
||||
results[i] = (url, self.accessToken, PlexServer(url, self.accessToken))
|
||||
except NotFound:
|
||||
results[i] = (url, self.accessToken, None)
|
||||
|
||||
|
||||
class ResourceConnection(object):
|
||||
"""ResourceConnection.
|
||||
""" Represents a Resource Connection object found within the
|
||||
:class:`~myplex.MyPlexResource` objects.
|
||||
|
||||
Attributes:
|
||||
address (str): Local ip adress
|
||||
httpuri (str): Full local address
|
||||
local (bool): True if local
|
||||
port (int): 32400
|
||||
protocol (str): http or https
|
||||
uri (str): External adress
|
||||
Attributes:
|
||||
address (str): Local IP address
|
||||
httpuri (str): Full local address
|
||||
local (bool): True if local
|
||||
port (int): 32400
|
||||
protocol (str): HTTP or HTTPS
|
||||
uri (str): External address
|
||||
"""
|
||||
def __init__(self, data):
|
||||
"""Set attrs.
|
||||
|
||||
Args:
|
||||
data (Element): XML response as Element from PMS.
|
||||
"""
|
||||
self.protocol = data.attrib.get('protocol')
|
||||
self.address = data.attrib.get('address')
|
||||
self.port = utils.cast(int, data.attrib.get('port'))
|
||||
|
@ -379,42 +318,38 @@ class ResourceConnection(object):
|
|||
self.httpuri = 'http://%s:%s' % (self.address, self.port)
|
||||
|
||||
def __repr__(self):
|
||||
"""Pretty repr."""
|
||||
return '<%s:%s>' % (self.__class__.__name__, self.uri.encode('utf8'))
|
||||
|
||||
|
||||
class MyPlexDevice(object):
|
||||
"""Device connected.
|
||||
""" This object represents resources connected to your Plex server that provide
|
||||
playback ability from your Plex Server, iPhone or Android clients, Plex Web,
|
||||
this API, etc. The raw xml for the data presented here can be found at:
|
||||
https://plex.tv/devices.xml
|
||||
|
||||
Attributes:
|
||||
BASEURL (str): Plex.tv XML device url
|
||||
clientIdentifier (str): 0x685d43d...
|
||||
connections (list):
|
||||
device (str): fx Windows
|
||||
id (str): 123
|
||||
model (str):
|
||||
name (str): fx Computername
|
||||
platform (str): Windows
|
||||
platformVersion (str): Fx 8
|
||||
product (str): Fx PlexAPI
|
||||
productVersion (string): 2.0.2
|
||||
provides (str): fx controller
|
||||
publicAddress (str): Public ip address
|
||||
screenDensity (str): Description
|
||||
screenResolution (str): Description
|
||||
token (str): Auth token
|
||||
vendor (str): Description
|
||||
version (str): fx 2.0.2
|
||||
Attributes:
|
||||
clientIdentifier (str): Unique ID for this resource.
|
||||
connections (list): List of connection URIs for the device.
|
||||
device (str): Best guess on the type of device this is (Linux, iPad, AFTB, etc).
|
||||
id (str): MyPlex ID of the device.
|
||||
model (str): Model of the device (bueller, Linux, x86_64, etc.)
|
||||
name (str): Hostname of the device.
|
||||
platform (str): OS the resource is running (Linux, Windows, Chrome, etc.)
|
||||
platformVersion (str): Version of the platform.
|
||||
product (str): Plex product (Plex Media Server, Plex for iOS, Plex Web, etc.)
|
||||
productVersion (string): Version of the product.
|
||||
provides (str): List of services this resource provides (client, controller,
|
||||
sync-target, player, pubsub-player).
|
||||
publicAddress (str): Public IP address.
|
||||
screenDensity (str): Unknown
|
||||
screenResolution (str): Screen resolution (750x1334, 1242x2208, etc.)
|
||||
token (str): Plex authentication token for the device.
|
||||
vendor (str): Device vendor (ubuntu, etc).
|
||||
version (str): Unknown (1, 2, 1.3.3.3148-b38628e, 1.3.15, etc.)
|
||||
"""
|
||||
|
||||
BASEURL = 'https://plex.tv/devices.xml'
|
||||
|
||||
def __init__(self, data):
|
||||
"""Set attrs
|
||||
|
||||
Args:
|
||||
data (Element): XML response as Element from PMS
|
||||
"""
|
||||
self.name = data.attrib.get('name')
|
||||
self.publicAddress = data.attrib.get('publicAddress')
|
||||
self.product = data.attrib.get('product')
|
||||
|
@ -429,26 +364,23 @@ class MyPlexDevice(object):
|
|||
self.version = data.attrib.get('version')
|
||||
self.id = data.attrib.get('id')
|
||||
self.token = data.attrib.get('token')
|
||||
if self.token:
|
||||
logfilter.add_secret(self.token)
|
||||
self.screenResolution = data.attrib.get('screenResolution')
|
||||
self.screenDensity = data.attrib.get('screenDensity')
|
||||
self.connections = [connection.attrib.get(
|
||||
'uri') for connection in data.iter('Connection')]
|
||||
self.connections = [connection.attrib.get('uri') for connection in data.iter('Connection')]
|
||||
|
||||
def __repr__(self):
|
||||
"""Pretty repr."""
|
||||
return '<%s:%s:%s>' % (self.__class__.__name__, self.name.encode('utf8'), self.product.encode('utf8'))
|
||||
|
||||
def connect(self, ssl=None):
|
||||
"""Connect to the first server.
|
||||
def connect(self):
|
||||
""" Returns a new :class:`~plexapi.client.PlexClient` object. Sometimes there is more than
|
||||
one address specified for a server or client. After trying to connect to all
|
||||
available addresses for this resource and assuming at least one connection was
|
||||
successful, the PlexClient object is built and returned.
|
||||
|
||||
Args:
|
||||
ssl (None, optional): Use SSL?
|
||||
|
||||
Returns:
|
||||
TYPE: Plexserver
|
||||
|
||||
Raises:
|
||||
NotFound: Unable to connect to resource: name
|
||||
Raises:
|
||||
:class:`~plexapi.exceptions.NotFound`: When unable to connect to any addresses for this device.
|
||||
"""
|
||||
# Try connecting to all known resource connections in parellel, but
|
||||
# only return the first server (in order) that provides a response.
|
||||
|
@ -459,46 +391,23 @@ class MyPlexDevice(object):
|
|||
# established.
|
||||
for url, token, result in results:
|
||||
okerr = 'OK' if result else 'ERR'
|
||||
log.info('Testing device connection: %s?X-Plex-Token=%s %s',
|
||||
url, token, okerr)
|
||||
log.info('Testing device connection: %s?X-Plex-Token=%s %s', url, token, okerr)
|
||||
results = [r[2] for r in results if r and r[2] is not None]
|
||||
if not results:
|
||||
raise NotFound('Unable to connect to resource: %s' % self.name)
|
||||
log.info('Connecting to server: %s?X-Plex-Token=%s',
|
||||
results[0].baseurl, results[0].token)
|
||||
|
||||
log.info('Connecting to server: %s?X-Plex-Token=%s', results[0].baseurl, results[0].token)
|
||||
return results[0]
|
||||
|
||||
def _connect(self, url, results, i):
|
||||
"""Summary
|
||||
|
||||
Args:
|
||||
url (TYPE): Description
|
||||
results (TYPE): Description
|
||||
i (TYPE): Description
|
||||
|
||||
Returns:
|
||||
TYPE: Description
|
||||
"""
|
||||
try:
|
||||
results[i] = (url, self.token, PlexClient(url, self.token))
|
||||
except NotFound as err:
|
||||
except NotFound:
|
||||
results[i] = (url, self.token, None)
|
||||
|
||||
|
||||
def _findItem(items, value, attrs=None):
|
||||
"""Simple helper to find something using attrs
|
||||
|
||||
Args:
|
||||
items (cls): list of Object to get the attrs from
|
||||
value (str): value to match against
|
||||
attrs (None, optional): attr to match against value.
|
||||
|
||||
Returns:
|
||||
TYPE: Description
|
||||
|
||||
Raises:
|
||||
NotFound: Description
|
||||
""" This will return the first item in the list of items where value is
|
||||
found in any of the specified attributes.
|
||||
"""
|
||||
attrs = attrs or ['name']
|
||||
for item in items:
|
||||
|
@ -509,16 +418,7 @@ def _findItem(items, value, attrs=None):
|
|||
|
||||
|
||||
def _listItems(url, token, cls):
|
||||
"""Helper that builds list of classes from a XML response.
|
||||
|
||||
Args:
|
||||
url (str): Description
|
||||
token (str): Description
|
||||
cls (class): Class to initate
|
||||
|
||||
Returns:
|
||||
List: of classes
|
||||
"""
|
||||
""" Builds list of classes from a XML response. """
|
||||
headers = plexapi.BASE_HEADERS
|
||||
headers['X-Plex-Token'] = token
|
||||
log.info('GET %s?X-Plex-Token=%s', url, token)
|
||||
|
|
151
plexapi/photo.py
151
plexapi/photo.py
|
@ -1,10 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
PlexPhoto
|
||||
|
||||
Attributes:
|
||||
NA (TYPE): Description
|
||||
"""
|
||||
from plexapi import media, utils
|
||||
from plexapi.utils import PlexPartialObject
|
||||
NA = utils.NA
|
||||
|
@ -12,46 +6,36 @@ NA = utils.NA
|
|||
|
||||
@utils.register_libtype
|
||||
class Photoalbum(PlexPartialObject):
|
||||
"""Summary
|
||||
""" Represents a photoalbum (collection of photos).
|
||||
|
||||
Attributes:
|
||||
addedAt (TYPE): Description
|
||||
art (TYPE): Description
|
||||
composite (TYPE): Description
|
||||
guid (TYPE): Description
|
||||
index (TYPE): Description
|
||||
key (TYPE): Description
|
||||
librarySectionID (TYPE): Description
|
||||
listType (str): Description
|
||||
ratingKey (TYPE): Description
|
||||
summary (TYPE): Description
|
||||
thumb (TYPE): Description
|
||||
title (TYPE): Description
|
||||
TYPE (str): Description
|
||||
type (TYPE): Description
|
||||
updatedAt (TYPE): Description
|
||||
Parameters:
|
||||
server (:class:`~plexapi.server.PlexServer`): PlexServer this client is connected to (optional)
|
||||
data (ElementTree): Response from PlexServer used to build this object (optional).
|
||||
initpath (str): Relative path requested when retrieving specified `data` (optional).
|
||||
|
||||
Attributes:
|
||||
addedAt (datetime): Datetime this item was added to the library.
|
||||
art (str): Photo art (/library/metadata/<ratingkey>/art/<artid>)
|
||||
composite (str): Unknown
|
||||
guid (str): Unknown (unique ID)
|
||||
index (sting): Index number of this album.
|
||||
key (str): API URL (/library/metadata/<ratingkey>).
|
||||
librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID.
|
||||
listType (str): Hardcoded as 'photo' (useful for search filters).
|
||||
ratingKey (int): Unique key identifying this item.
|
||||
summary (str): Summary of the photoalbum.
|
||||
thumb (str): URL to thumbnail image.
|
||||
title (str): Photoalbum title. (Trip to Disney World)
|
||||
type (str): Unknown
|
||||
updatedAt (datatime): Datetime this item was updated.
|
||||
"""
|
||||
TYPE = 'photoalbum'
|
||||
|
||||
def __init__(self, server, data, initpath):
|
||||
"""Summary
|
||||
|
||||
Args:
|
||||
server (TYPE): Description
|
||||
data (TYPE): Description
|
||||
initpath (TYPE): Description
|
||||
"""
|
||||
super(Photoalbum, self).__init__(data, initpath, server)
|
||||
|
||||
def _loadData(self, data):
|
||||
"""Summary
|
||||
|
||||
Args:
|
||||
data (TYPE): Description
|
||||
|
||||
Returns:
|
||||
TYPE: Description
|
||||
"""
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self.listType = 'photo'
|
||||
self.addedAt = utils.toDatetime(data.attrib.get('addedAt', NA))
|
||||
self.art = data.attrib.get('art', NA)
|
||||
|
@ -68,78 +52,53 @@ class Photoalbum(PlexPartialObject):
|
|||
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt', NA))
|
||||
|
||||
def photos(self):
|
||||
"""Summary
|
||||
|
||||
Returns:
|
||||
TYPE: Description
|
||||
"""
|
||||
""" Returns a list of :class:`~plexapi.photo.Photo` objects in this album. """
|
||||
path = '/library/metadata/%s/children' % self.ratingKey
|
||||
return utils.listItems(self.server, path, Photo.TYPE)
|
||||
|
||||
def photo(self, title):
|
||||
"""Summary
|
||||
|
||||
Args:
|
||||
title (TYPE): Description
|
||||
|
||||
Returns:
|
||||
TYPE: Description
|
||||
"""
|
||||
""" Returns the :class:`~plexapi.photo.Photo` that matches the specified title. """
|
||||
path = '/library/metadata/%s/children' % self.ratingKey
|
||||
return utils.findItem(self.server, path, title)
|
||||
|
||||
def section(self):
|
||||
"""Summary
|
||||
|
||||
Returns:
|
||||
TYPE: Description
|
||||
"""
|
||||
""" Returns the :class:`~plexapi.library.LibrarySection` this item belongs to. """
|
||||
return self.server.library.sectionByID(self.librarySectionID)
|
||||
|
||||
|
||||
@utils.register_libtype
|
||||
class Photo(PlexPartialObject):
|
||||
"""Summary
|
||||
""" Represents a single photo.
|
||||
|
||||
Attributes:
|
||||
addedAt (TYPE): Description
|
||||
index (TYPE): Description
|
||||
key (TYPE): Description
|
||||
listType (str): Description
|
||||
media (TYPE): Description
|
||||
originallyAvailableAt (TYPE): Description
|
||||
parentKey (TYPE): Description
|
||||
parentRatingKey (TYPE): Description
|
||||
ratingKey (TYPE): Description
|
||||
summary (TYPE): Description
|
||||
thumb (TYPE): Description
|
||||
title (TYPE): Description
|
||||
TYPE (str): Description
|
||||
type (TYPE): Description
|
||||
updatedAt (TYPE): Description
|
||||
year (TYPE): Description
|
||||
Parameters:
|
||||
server (:class:`~plexapi.server.PlexServer`): PlexServer this client is connected to (optional)
|
||||
data (ElementTree): Response from PlexServer used to build this object (optional).
|
||||
initpath (str): Relative path requested when retrieving specified `data` (optional).
|
||||
|
||||
Attributes:
|
||||
addedAt (datetime): Datetime this item was added to the library.
|
||||
index (sting): Index number of this photo.
|
||||
key (str): API URL (/library/metadata/<ratingkey>).
|
||||
listType (str): Hardcoded as 'photo' (useful for search filters).
|
||||
media (TYPE): Unknown
|
||||
originallyAvailableAt (datetime): Datetime this photo was added to Plex.
|
||||
parentKey (str): Photoalbum API URL.
|
||||
parentRatingKey (int): Unique key identifying the photoalbum.
|
||||
ratingKey (int): Unique key identifying this item.
|
||||
summary (str): Summary of the photo.
|
||||
thumb (str): URL to thumbnail image.
|
||||
title (str): Photo title.
|
||||
type (str): Unknown
|
||||
updatedAt (datatime): Datetime this item was updated.
|
||||
year (int): Year this photo was taken.
|
||||
"""
|
||||
TYPE = 'photo'
|
||||
|
||||
def __init__(self, server, data, initpath):
|
||||
"""Summary
|
||||
|
||||
Args:
|
||||
server (TYPE): Description
|
||||
data (TYPE): Description
|
||||
initpath (TYPE): Description
|
||||
"""
|
||||
super(Photo, self).__init__(data, initpath, server)
|
||||
|
||||
def _loadData(self, data):
|
||||
"""Summary
|
||||
|
||||
Args:
|
||||
data (TYPE): Description
|
||||
|
||||
Returns:
|
||||
TYPE: Description
|
||||
"""
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self.listType = 'photo'
|
||||
self.addedAt = utils.toDatetime(data.attrib.get('addedAt', NA))
|
||||
self.index = utils.cast(int, data.attrib.get('index', NA))
|
||||
|
@ -157,20 +116,12 @@ class Photo(PlexPartialObject):
|
|||
self.year = utils.cast(int, data.attrib.get('year', NA))
|
||||
if self.isFullObject():
|
||||
self.media = [media.Media(self.server, e, self.initpath, self)
|
||||
for e in data if e.tag == media.Media.TYPE]
|
||||
for e in data if e.tag == media.Media.TYPE]
|
||||
|
||||
def photoalbum(self):
|
||||
"""Summary
|
||||
|
||||
Returns:
|
||||
TYPE: Description
|
||||
"""
|
||||
""" Return this photo's :class:`~plexapi.photo.Photoalbum`. """
|
||||
return utils.listItems(self.server, self.parentKey)[0]
|
||||
|
||||
def section(self):
|
||||
"""Summary
|
||||
|
||||
Returns:
|
||||
TYPE: Description
|
||||
"""
|
||||
""" Returns the :class:`~plexapi.library.LibrarySection` this item belongs to. """
|
||||
return self.server.library.sectionByID(self.photoalbum().librarySectionID)
|
||||
|
|
|
@ -1,7 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
PlexPlaylist
|
||||
"""
|
||||
from plexapi import utils
|
||||
from plexapi.exceptions import BadRequest
|
||||
from plexapi.utils import cast, toDatetime
|
||||
|
@ -60,7 +57,7 @@ class Playlist(PlexPartialObject, Playable):
|
|||
for item in items:
|
||||
if item.listType != self.playlistType:
|
||||
raise BadRequest('Can not mix media types when building a playlist: %s and %s' % (self.playlistType, item.listType))
|
||||
ratingKeys.append(item.ratingKey)
|
||||
ratingKeys.append(str(item.ratingKey))
|
||||
uuid = items[0].section().uuid
|
||||
ratingKeys = ','.join(ratingKeys)
|
||||
path = '%s/items%s' % (self.key, utils.joinArgs({
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
|
||||
import plexapi
|
||||
import requests
|
||||
from plexapi import utils
|
||||
|
@ -36,10 +34,8 @@ class PlayQueue(object):
|
|||
self.mediaTagPrefix = data.attrib.get('mediaTagPrefix')
|
||||
self.mediaTagVersion = data.attrib.get('mediaTagVersion')
|
||||
self.playQueueID = data.attrib.get('playQueueID')
|
||||
self.playQueueSelectedItemID = data.attrib.get(
|
||||
'playQueueSelectedItemID')
|
||||
self.playQueueSelectedItemOffset = data.attrib.get(
|
||||
'playQueueSelectedItemOffset')
|
||||
self.playQueueSelectedItemID = data.attrib.get('playQueueSelectedItemID')
|
||||
self.playQueueSelectedItemOffset = data.attrib.get('playQueueSelectedItemOffset')
|
||||
self.playQueueTotalCount = data.attrib.get('playQueueTotalCount')
|
||||
self.playQueueVersion = data.attrib.get('playQueueVersion')
|
||||
self.items = [utils.buildItem(server, elem, initpath) for elem in data]
|
||||
|
|
|
@ -13,8 +13,8 @@ else:
|
|||
import requests
|
||||
from requests.status_codes import _codes as codes
|
||||
|
||||
from plexapi import BASE_HEADERS, TIMEOUT
|
||||
from plexapi import log, utils
|
||||
from plexapi import BASE_HEADERS, CONFIG, TIMEOUT
|
||||
from plexapi import log, logfilter, utils
|
||||
from plexapi import audio, video, photo, playlist # noqa; required # why is this needed?
|
||||
from plexapi.client import PlexClient
|
||||
from plexapi.compat import quote, urlencode
|
||||
|
@ -62,8 +62,10 @@ class PlexServer(object):
|
|||
session (requests.Session, optional): Use your own session object if you want
|
||||
to cache the http responses from PMS
|
||||
"""
|
||||
self.baseurl = baseurl
|
||||
self.token = token
|
||||
self.baseurl = baseurl or CONFIG.get('authentication.baseurl')
|
||||
self.token = token or CONFIG.get('authentication.token')
|
||||
if self.token:
|
||||
logfilter.add_secret(self.token)
|
||||
self.session = session or requests.Session()
|
||||
data = self._connect()
|
||||
self.friendlyName = data.attrib.get('friendlyName')
|
||||
|
@ -113,8 +115,7 @@ class PlexServer(object):
|
|||
"""
|
||||
items = []
|
||||
for elem in self.query('/clients'):
|
||||
baseurl = 'http://%s:%s' % (elem.attrib['address'],
|
||||
elem.attrib['port'])
|
||||
baseurl = 'http://%s:%s' % (elem.attrib['host'], elem.attrib['port'])
|
||||
items.append(PlexClient(baseurl, server=self, data=elem))
|
||||
return items
|
||||
|
||||
|
@ -133,8 +134,7 @@ class PlexServer(object):
|
|||
"""
|
||||
for elem in self.query('/clients'):
|
||||
if elem.attrib.get('name').lower() == name.lower():
|
||||
baseurl = 'http://%s:%s' % (
|
||||
elem.attrib['address'], elem.attrib['port'])
|
||||
baseurl = 'http://%s:%s' % (elem.attrib['host'], elem.attrib['port'])
|
||||
return PlexClient(baseurl, server=self, data=elem)
|
||||
raise NotFound('Unknown client name: %s' % name)
|
||||
|
||||
|
@ -204,9 +204,10 @@ class PlexServer(object):
|
|||
if headers:
|
||||
h.update(headers)
|
||||
response = method(url, headers=h, timeout=TIMEOUT, **kwargs)
|
||||
if response.status_code not in [200, 201]:
|
||||
#print(response.url)
|
||||
if response.status_code not in [200, 201]: # pragma: no cover
|
||||
codename = codes.get(response.status_code)[0]
|
||||
raise BadRequest('(%s) %s' % (response.status_code, codename))
|
||||
raise BadRequest('(%s) %s %s' % (response.status_code, codename, response.url))
|
||||
data = response.text.encode('utf8')
|
||||
return ElementTree.fromstring(data) if data else None
|
||||
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
import requests
|
||||
from plexapi import utils
|
||||
from plexapi.exceptions import NotFound
|
||||
|
|
482
plexapi/utils.py
482
plexapi/utils.py
|
@ -1,192 +1,166 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
import re
|
||||
import logging, os, re, requests
|
||||
from datetime import datetime
|
||||
from plexapi.compat import quote, urlencode
|
||||
from plexapi.exceptions import NotFound, UnknownType, Unsupported
|
||||
from threading import Thread
|
||||
from plexapi import log
|
||||
from plexapi.compat import quote, string_type, urlencode
|
||||
from plexapi.exceptions import NotFound, NotImplementedError, UnknownType, Unsupported
|
||||
|
||||
# Search Types - Plex uses these to filter specific media types when searching.
|
||||
SEARCHTYPES = {'movie': 1, 'show': 2, 'season': 3,
|
||||
'episode': 4, 'artist': 8, 'album': 9, 'track': 10}
|
||||
|
||||
SEARCHTYPES = {'movie': 1, 'show': 2, 'season': 3, 'episode': 4,
|
||||
'artist': 8, 'album': 9, 'track': 10, 'photo': 14}
|
||||
LIBRARY_TYPES = {}
|
||||
|
||||
|
||||
def register_libtype(cls):
|
||||
"""Registry of library types we may come across when parsing XML.
|
||||
This allows us to define a few helper functions to dynamically convery
|
||||
the XML into objects. See buildItem() below for an example.
|
||||
""" Registry of library types we may come across when parsing XML. This allows us to
|
||||
define a few helper functions to dynamically convery the XML into objects. See
|
||||
buildItem() below for an example.
|
||||
"""
|
||||
LIBRARY_TYPES[cls.TYPE] = cls
|
||||
return cls
|
||||
|
||||
|
||||
class _NA(object):
|
||||
"""This used to be a simple variable equal to '__NA__'.
|
||||
However, there has been need to compare NA against None in some use cases.
|
||||
This object allows the internals of PlexAPI to distinguish between unfetched
|
||||
values and fetched, but non-existent values.
|
||||
(NA == None results to True; NA is None results to False)
|
||||
""" This used to be a simple variable equal to '__NA__'. There has been need to
|
||||
compare NA against None in some use cases. This object allows the internals
|
||||
of PlexAPI to distinguish between unfetched values and fetched, but non-existent
|
||||
values. (NA == None results to True; NA is None results to False)
|
||||
"""
|
||||
|
||||
def __bool__(self):
|
||||
"""Make sure Na always is False.
|
||||
|
||||
Returns:
|
||||
bool: False
|
||||
"""
|
||||
return False
|
||||
|
||||
def __eq__(self, other):
|
||||
"""Check eq.
|
||||
|
||||
Args:
|
||||
other (str): Description
|
||||
|
||||
Returns:
|
||||
bool: True is equal
|
||||
"""
|
||||
return isinstance(other, _NA) or other in [None, '__NA__']
|
||||
|
||||
def __nonzero__(self):
|
||||
return False
|
||||
|
||||
def __repr__(self):
|
||||
"""Pretty print."""
|
||||
return '__NA__'
|
||||
|
||||
NA = _NA()
|
||||
NA = _NA() # Keep this for now.
|
||||
|
||||
|
||||
class SecretsFilter(logging.Filter):
|
||||
""" Logging filter to hide secrets. """
|
||||
def __init__(self, secrets=None):
|
||||
self.secrets = secrets or set()
|
||||
|
||||
def add_secret(self, secret):
|
||||
self.secrets.add(secret)
|
||||
|
||||
def filter(self, record):
|
||||
cleanargs = list(record.args)
|
||||
for i in range(len(cleanargs)):
|
||||
if isinstance(cleanargs[i], string_type):
|
||||
for secret in self.secrets:
|
||||
cleanargs[i] = cleanargs[i].replace(secret, '<hidden>')
|
||||
record.args = tuple(cleanargs)
|
||||
return True
|
||||
|
||||
|
||||
class PlexPartialObject(object):
|
||||
"""Not all objects in the Plex listings return the complete list of elements
|
||||
for the object.This object will allow you to assume each object is complete,
|
||||
and if the specified value you request is None it will fetch the full object
|
||||
automatically and update itself.
|
||||
""" Not all objects in the Plex listings return the complete list of elements
|
||||
for the object. This object will allow you to assume each object is complete,
|
||||
and if the specified value you request is None it will fetch the full object
|
||||
automatically and update itself.
|
||||
|
||||
Attributes:
|
||||
initpath (str): Relative url to PMS
|
||||
server (): Description
|
||||
Attributes:
|
||||
data (ElementTree): Response from PlexServer used to build this object (optional).
|
||||
initpath (str): Relative path requested when retrieving specified `data` (optional).
|
||||
server (:class:`~plexapi.server.PlexServer`): PlexServer object this is from.
|
||||
"""
|
||||
|
||||
def __init__(self, data, initpath, server=None):
|
||||
"""
|
||||
Args:
|
||||
data (xml.etree.ElementTree.Element): passed from server.query
|
||||
initpath (str): Relative path
|
||||
server (None or Plexserver, optional): PMS class your connected to
|
||||
"""
|
||||
self.server = server
|
||||
self.initpath = initpath
|
||||
self._loadData(data)
|
||||
self._reloaded = False
|
||||
|
||||
def __eq__(self, other):
|
||||
"""Summary
|
||||
|
||||
Args:
|
||||
other (TYPE): Description
|
||||
|
||||
Returns:
|
||||
TYPE: Description
|
||||
"""
|
||||
return other is not None and self.key == other.key
|
||||
|
||||
def __repr__(self):
|
||||
"""Pretty repr."""
|
||||
clsname = self.__class__.__name__
|
||||
key = self.key.replace('/library/metadata/', '') if self.key else 'NA'
|
||||
title = self.title.replace(' ', '.')[0:20].encode('utf8')
|
||||
return '<%s:%s:%s>' % (clsname, key, title)
|
||||
|
||||
def __getattr__(self, attr):
|
||||
"""Auto reload self, if the attribute is NA
|
||||
|
||||
Args:
|
||||
attr (str): fx key
|
||||
"""
|
||||
# Auto reload self, from the full key (path) when needed.
|
||||
if attr == 'key' or self.__dict__.get(attr) or self.isFullObject():
|
||||
return self.__dict__.get(attr, NA)
|
||||
print('reload because of %s' % attr)
|
||||
self.reload()
|
||||
return self.__dict__.get(attr, NA)
|
||||
|
||||
def __setattr__(self, attr, value):
|
||||
"""Set attribute
|
||||
|
||||
Args:
|
||||
attr (str): fx key
|
||||
value (TYPE): Description
|
||||
"""
|
||||
if value != NA or self.isFullObject():
|
||||
self.__dict__[attr] = value
|
||||
|
||||
def _loadData(self, data):
|
||||
"""Uses a element to set a attrs.
|
||||
|
||||
Args:
|
||||
data (Element): Used by attrs
|
||||
"""
|
||||
raise Exception('Abstract method not implemented.')
|
||||
raise NotImplementedError('Abstract method not implemented.')
|
||||
|
||||
def isFullObject(self):
|
||||
""" Retruns True if this is already a full object. A full object means all attributes
|
||||
were populated from the api path representing only this item. For example, the
|
||||
search result for a movie often only contain a portion of the attributes a full
|
||||
object (main url) for that movie contain.
|
||||
"""
|
||||
return not self.key or self.key == self.initpath
|
||||
|
||||
def isPartialObject(self):
|
||||
""" Returns True if this is NOT a full object. """
|
||||
return not self.isFullObject()
|
||||
|
||||
def reload(self):
|
||||
"""Reload the data for this object from PlexServer XML."""
|
||||
""" Reload the data for this object from PlexServer XML. """
|
||||
data = self.server.query(self.key)
|
||||
self.initpath = self.key
|
||||
self._loadData(data[0])
|
||||
self._reloaded = True
|
||||
return self
|
||||
|
||||
|
||||
class Playable(object):
|
||||
"""This is a general place to store functions specific to media that is Playable.
|
||||
Things were getting mixed up a bit when dealing with Shows, Season,
|
||||
Artists, Albums which are all not playable.
|
||||
""" This is a general place to store functions specific to media that is Playable.
|
||||
Things were getting mixed up a bit when dealing with Shows, Season, Artists,
|
||||
Albums which are all not playable.
|
||||
|
||||
Attributes: # todo
|
||||
player (Plexclient): Player
|
||||
playlistItemID (int): Playlist item id
|
||||
sessionKey (int): 1223
|
||||
transcodeSession (str): 12312312
|
||||
username (str): Fx Hellowlol
|
||||
viewedAt (datetime): viewed at.
|
||||
Attributes:
|
||||
player (:class:`~plexapi.client.PlexClient`): Client object playing this item (for active sessions).
|
||||
playlistItemID (int): Playlist item ID (only populated for :class:`~plexapi.playlist.Playlist` items).
|
||||
sessionKey (int): Active session key.
|
||||
transcodeSession (:class:`~plexapi.media.TranscodeSession`): Transcode Session object
|
||||
if item is being transcoded (None otherwise).
|
||||
username (str): Username of the person playing this item (for active sessions).
|
||||
viewedAt (datetime): Datetime item was last viewed (history).
|
||||
"""
|
||||
|
||||
def _loadData(self, data):
|
||||
"""Set the class attributes
|
||||
|
||||
Args:
|
||||
data (xml.etree.ElementTree.Element): usually from server.query
|
||||
"""
|
||||
# data for active sessions (/status/sessions)
|
||||
# Load data for active sessions (/status/sessions)
|
||||
self.sessionKey = cast(int, data.attrib.get('sessionKey', NA))
|
||||
self.username = findUsername(data)
|
||||
self.player = findPlayer(self.server, data)
|
||||
self.transcodeSession = findTranscodeSession(self.server, data)
|
||||
# data for history details (/status/sessions/history/all)
|
||||
# Load data for history details (/status/sessions/history/all)
|
||||
self.viewedAt = toDatetime(data.attrib.get('viewedAt', NA))
|
||||
# data for playlist items
|
||||
# Load data for playlist items
|
||||
self.playlistItemID = cast(int, data.attrib.get('playlistItemID', NA))
|
||||
|
||||
def getStreamURL(self, **params):
|
||||
"""Make a stream url that can be used by vlc.
|
||||
""" Returns a stream url that may be used by external applications such as VLC.
|
||||
|
||||
Args:
|
||||
**params (dict): Description
|
||||
Parameters:
|
||||
**params (dict): optional parameters to manipulate the playback when accessing
|
||||
the stream. A few known parameters include: maxVideoBitrate, videoResolution
|
||||
offset, copyts, protocol, mediaIndex, platform.
|
||||
|
||||
Returns:
|
||||
string: ''
|
||||
|
||||
Raises:
|
||||
Unsupported: Raises a error is the type is wrong.
|
||||
Raises:
|
||||
Unsupported: When the item doesn't support fetching a stream URL.
|
||||
"""
|
||||
if self.TYPE not in ('movie', 'episode', 'track'):
|
||||
raise Unsupported(
|
||||
'Fetching stream URL for %s is unsupported.' % self.TYPE)
|
||||
raise Unsupported('Fetching stream URL for %s is unsupported.' % self.TYPE)
|
||||
mvb = params.get('maxVideoBitrate')
|
||||
vr = params.get('videoResolution', '')
|
||||
params = {
|
||||
|
@ -202,35 +176,68 @@ class Playable(object):
|
|||
# remove None values
|
||||
params = {k: v for k, v in params.items() if v is not None}
|
||||
streamtype = 'audio' if self.TYPE in ('track', 'album') else 'video'
|
||||
return self.server.url('/%s/:/transcode/universal/start.m3u8?%s' % (streamtype, urlencode(params)))
|
||||
# sort the keys since the randomness fucks with my tests..
|
||||
sorted_params = sorted(params.items(), key=lambda val: val[0])
|
||||
return self.server.url('/%s/:/transcode/universal/start.m3u8?%s' % (streamtype, urlencode(sorted_params)))
|
||||
|
||||
def iterParts(self):
|
||||
"""Yield parts."""
|
||||
""" Iterates over the parts of this media item. """
|
||||
for item in self.media:
|
||||
for part in item.parts:
|
||||
yield part
|
||||
|
||||
def play(self, client):
|
||||
"""Start playback on a client.
|
||||
""" Start playback on the specified client.
|
||||
|
||||
Args:
|
||||
client (PlexClient): The client to start playing on.
|
||||
Parameters:
|
||||
client (:class:`~plexapi.client.PlexClient`): Client to start playing on.
|
||||
"""
|
||||
client.playMedia(self)
|
||||
|
||||
def download(self, savepath=None, keep_orginal_name=False, **kwargs):
|
||||
""" Downloads this items media to the specified location. Returns a list of
|
||||
filepaths that have been saved to disk.
|
||||
|
||||
Parameters:
|
||||
savepath (str): Title of the track to return.
|
||||
keep_orginal_name (bool): Set True to keep the original filename as stored in
|
||||
the Plex server. False will create a new filename with the format
|
||||
"<Atrist> - <Album> <Track>".
|
||||
kwargs (dict): If specified, a :func:`~plexapi.audio.Track.getStreamURL()` will
|
||||
be returned and the additional arguments passed in will be sent to that
|
||||
function. If kwargs is not specified, the media items will be downloaded
|
||||
and saved to disk.
|
||||
"""
|
||||
filepaths = []
|
||||
locations = [i for i in self.iterParts() if i]
|
||||
for location in locations:
|
||||
filename = location.file
|
||||
if keep_orginal_name is False:
|
||||
filename = '%s.%s' % (self._prettyfilename(), location.container)
|
||||
# So this seems to be a alot slower but allows transcode.
|
||||
if kwargs:
|
||||
download_url = self.getStreamURL(**kwargs)
|
||||
else:
|
||||
download_url = self.server.url('%s?download=1' % location.key)
|
||||
filepath = download(download_url, filename=filename, savepath=savepath,
|
||||
session=self.server.session)
|
||||
if filepath:
|
||||
filepaths.append(filepath)
|
||||
return filepaths
|
||||
|
||||
|
||||
def buildItem(server, elem, initpath, bytag=False):
|
||||
"""Build classes used by the plexapi.
|
||||
""" Factory function to build the objects used within the PlexAPI.
|
||||
|
||||
Args:
|
||||
server (Plexserver): Your connected to.
|
||||
elem (xml.etree.ElementTree.Element): xml from PMS
|
||||
initpath (str): Relative path
|
||||
bytag (bool, optional): Description # figure out what this do
|
||||
|
||||
Raises:
|
||||
UnknownType: Unknown library type libtype
|
||||
Parameters:
|
||||
server (:class:`~plexapi.server.PlexServer`): PlexServer object this is from.
|
||||
elem (ElementTree): XML data needed to build the object.
|
||||
initpath (str): Relative path requested when retrieving specified `data` (optional).
|
||||
bytag (bool): Creates the object from the name specified by the tag instead of the
|
||||
default which builds the object specified by the type attribute. <tag type='foo' />
|
||||
|
||||
Raises:
|
||||
UnknownType: Unknown library type.
|
||||
"""
|
||||
libtype = elem.tag if bytag else elem.attrib.get('type')
|
||||
if libtype == 'photo' and elem.tag == 'Directory':
|
||||
|
@ -242,19 +249,11 @@ def buildItem(server, elem, initpath, bytag=False):
|
|||
|
||||
|
||||
def cast(func, value):
|
||||
"""Helper to change to the correct type.
|
||||
|
||||
Args:
|
||||
func (function): function to used [int, bool float]
|
||||
value (string, int, float): value to cast
|
||||
|
||||
Returns:
|
||||
None, nan, int, bool, or float
|
||||
|
||||
Raises:
|
||||
TypeError: cast only allows int, float and bool
|
||||
as func argument
|
||||
""" Cast the specified value to the specified type (returned by func).
|
||||
|
||||
Parameters:
|
||||
func (func): Calback function to used cast to type (int, bool, float, etc).
|
||||
value (any): value to be cast and returned.
|
||||
"""
|
||||
if not value:
|
||||
return
|
||||
|
@ -272,14 +271,14 @@ def cast(func, value):
|
|||
|
||||
|
||||
def findKey(server, key):
|
||||
"""Finds and builds a object based on ratingKey.
|
||||
""" Finds and builds a object based on ratingKey.
|
||||
|
||||
Args:
|
||||
server (Plexserver): PMS your connected to
|
||||
key (int): key to look for
|
||||
Parameters:
|
||||
server (:class:`~plexapi.server.PlexServer`): PlexServer object this is from.
|
||||
key (int): ratingKey to find and return.
|
||||
|
||||
Raises:
|
||||
NotFound: Unable to find key. Key
|
||||
Raises:
|
||||
NotFound: Unable to find key
|
||||
"""
|
||||
path = '/library/metadata/{0}'.format(key)
|
||||
try:
|
||||
|
@ -291,15 +290,15 @@ def findKey(server, key):
|
|||
|
||||
|
||||
def findItem(server, path, title):
|
||||
"""Finds and builds a object based on title.
|
||||
""" Finds and builds a object based on title.
|
||||
|
||||
Args:
|
||||
server (Plexserver): Description
|
||||
path (str): Relative path
|
||||
title (str): Fx 16 blocks
|
||||
Parameters:
|
||||
server (:class:`~plexapi.server.PlexServer`): PlexServer object this is from.
|
||||
path (str): API path that returns item to search title for.
|
||||
title (str): Title of the item to find and return.
|
||||
|
||||
Raises:
|
||||
NotFound: Unable to find item: title
|
||||
Raises:
|
||||
NotFound: Unable to find item.
|
||||
"""
|
||||
for elem in server.query(path):
|
||||
if elem.attrib.get('title').lower() == title.lower():
|
||||
|
@ -308,14 +307,12 @@ def findItem(server, path, title):
|
|||
|
||||
|
||||
def findLocations(data, single=False):
|
||||
"""Extract the path from a location tag
|
||||
""" Returns a list of filepaths from a location tag.
|
||||
|
||||
Args:
|
||||
data (xml.etree.ElementTree.Element): xml from PMS as Element
|
||||
single (bool, optional): Only return one
|
||||
|
||||
Returns:
|
||||
filepath string if single is True else list of filepaths
|
||||
Parameters:
|
||||
data (ElementTree): XML object to search for locations in.
|
||||
single (bool): Set True to only return the first location found.
|
||||
Return type will be a string if this is set to True.
|
||||
"""
|
||||
locations = []
|
||||
for elem in data:
|
||||
|
@ -327,33 +324,26 @@ def findLocations(data, single=False):
|
|||
|
||||
|
||||
def findPlayer(server, data):
|
||||
"""Find a player in a elementthee
|
||||
""" Returns the :class:`~plexapi.client.PlexClient` object found in the specified data.
|
||||
|
||||
Args:
|
||||
server (Plexserver): PMS your connected to
|
||||
data (xml.etree.ElementTree.Element): xml from pms as a element
|
||||
|
||||
Returns:
|
||||
PlexClient or None
|
||||
Parameters:
|
||||
server (:class:`~plexapi.server.PlexServer`): PlexServer object this is from.
|
||||
data (ElementTree): XML data to find Player in.
|
||||
"""
|
||||
elem = data.find('Player')
|
||||
if elem is not None:
|
||||
from plexapi.client import PlexClient
|
||||
baseurl = 'http://%s:%s' % (elem.attrib.get('address'),
|
||||
elem.attrib.get('port'))
|
||||
baseurl = 'http://%s:%s' % (elem.attrib.get('address'), elem.attrib.get('port'))
|
||||
return PlexClient(baseurl, server=server, data=elem)
|
||||
return None
|
||||
|
||||
|
||||
def findStreams(media, streamtype):
|
||||
"""Find streams.
|
||||
""" Returns a list of streams (str) found in media that match the specified streamtype.
|
||||
|
||||
Args:
|
||||
media (Show, Movie, Episode): A item where find streams
|
||||
streamtype (str): Possible options [movie, show, episode] # is this correct?
|
||||
|
||||
Returns:
|
||||
list: of streams
|
||||
Parameters:
|
||||
media (:class:`~plexapi.utils.Playable`): Item to search for streams (show, movie, episode).
|
||||
streamtype (str): Streamtype to return (videostream, audiostream, subtitlestream).
|
||||
"""
|
||||
streams = []
|
||||
for mediaitem in media:
|
||||
|
@ -365,14 +355,12 @@ def findStreams(media, streamtype):
|
|||
|
||||
|
||||
def findTranscodeSession(server, data):
|
||||
"""Find transcode session.
|
||||
""" Returns a :class:`~plexapi.media.TranscodeSession` object if found within the specified
|
||||
XML data.
|
||||
|
||||
Args:
|
||||
server (Plexserver): PMS your connected to
|
||||
data (xml.etree.ElementTree.Element): XML response from PMS as Element
|
||||
|
||||
Returns:
|
||||
media.TranscodeSession or None
|
||||
Parameters:
|
||||
server (:class:`~plexapi.server.PlexServer`): PlexServer object this is from.
|
||||
data (ElementTree): XML data to find TranscodeSession in.
|
||||
"""
|
||||
|
||||
elem = data.find('TranscodeSession')
|
||||
|
@ -383,13 +371,10 @@ def findTranscodeSession(server, data):
|
|||
|
||||
|
||||
def findUsername(data):
|
||||
"""Find a username in a Element
|
||||
""" Returns the username if found in the specified XML data. Returns None if not found.
|
||||
|
||||
Args:
|
||||
data (xml.etree.ElementTree.Element): XML from PMS as a Element
|
||||
|
||||
Returns:
|
||||
username or None
|
||||
Parameters:
|
||||
data (ElementTree): XML data to find username in.
|
||||
"""
|
||||
elem = data.find('User')
|
||||
if elem is not None:
|
||||
|
@ -398,7 +383,7 @@ def findUsername(data):
|
|||
|
||||
|
||||
def isInt(str):
|
||||
"""Check of a string is a int"""
|
||||
""" Returns True if the specified string passes as an int. """
|
||||
try:
|
||||
int(str)
|
||||
return True
|
||||
|
@ -407,14 +392,11 @@ def isInt(str):
|
|||
|
||||
|
||||
def joinArgs(args):
|
||||
"""Builds a query string where only
|
||||
the value is quoted.
|
||||
""" Returns a query string (uses for HTTP URLs) where only the value is URL encoded.
|
||||
Example return value: '?genre=action&type=1337'.
|
||||
|
||||
Args:
|
||||
args (dict): ex {'genre': 'action', 'type': 1337}
|
||||
|
||||
Returns:
|
||||
string: ?genre=action&type=1337
|
||||
Parameters:
|
||||
args (dict): Arguments to include in query string.
|
||||
"""
|
||||
if not args:
|
||||
return ''
|
||||
|
@ -426,30 +408,25 @@ def joinArgs(args):
|
|||
|
||||
|
||||
def listChoices(server, path):
|
||||
"""ListChoices is by _cleanSort etc.
|
||||
""" Returns a dict of {title:key} for all simple choices in a search filter.
|
||||
|
||||
Args:
|
||||
server (Plexserver): Server your connected to
|
||||
path (str): Relative path to PMS
|
||||
|
||||
Returns:
|
||||
dict: title:key
|
||||
Parameters:
|
||||
server (:class:`~plexapi.server.PlexServer`): PlexServer object this is from.
|
||||
path (str): Relative path to request XML data from.
|
||||
"""
|
||||
return {c.attrib['title']: c.attrib['key'] for c in server.query(path)}
|
||||
|
||||
|
||||
def listItems(server, path, libtype=None, watched=None, bytag=False):
|
||||
"""Return a list buildItem. See buildItem doc.
|
||||
""" Returns a list of object built from :func:`~plexapi.utils.buildItem()` found
|
||||
within the specified path.
|
||||
|
||||
Args:
|
||||
server (Plexserver): PMS your connected to.
|
||||
path (str): Relative path to PMS
|
||||
libtype (None or string, optional): [movie, show, episode, music] # check me
|
||||
watched (None, True, False, optional): Skip or include watched items
|
||||
bytag (bool, optional): Dunno wtf this is used for # todo
|
||||
|
||||
Returns:
|
||||
list: of buildItem
|
||||
Parameters:
|
||||
server (:class:`~plexapi.server.PlexServer`): PlexServer object this is from.
|
||||
path (str): Relative path to request XML data from.
|
||||
libtype (str): Optionally return only the specified library type.
|
||||
watched (bool): Optionally return only watched or unwatched items.
|
||||
bytag (bool): Set true if libtype is found in the XML tag (and not the 'type' attribute).
|
||||
"""
|
||||
items = []
|
||||
for elem in server.query(path):
|
||||
|
@ -466,7 +443,18 @@ def listItems(server, path, libtype=None, watched=None, bytag=False):
|
|||
return items
|
||||
|
||||
|
||||
def rget(obj, attrstr, default=None, delim='.'):
|
||||
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
|
||||
for each key in attrstr (split by by the delimiter) This function is heavily
|
||||
influenced by the lookups used in Django templates.
|
||||
|
||||
Parameters:
|
||||
obj (any): Object to start the lookup in (dict, obj, list, tuple, etc).
|
||||
attrstr (str): String to lookup (ex: 'foo.bar.baz.value')
|
||||
default (any): Default value to return if not found.
|
||||
delim (str): Delimiter separating keys in attrstr.
|
||||
"""
|
||||
try:
|
||||
parts = attrstr.split(delim, 1)
|
||||
attr = parts[0]
|
||||
|
@ -487,19 +475,15 @@ def rget(obj, attrstr, default=None, delim='.'):
|
|||
|
||||
|
||||
def searchType(libtype):
|
||||
"""Map search type name to int using SEACHTYPES
|
||||
Used when querying PMS.
|
||||
""" Returns the integer value of the library string type.
|
||||
|
||||
Args:
|
||||
libtype (str): Possible options see SEARCHTYPES
|
||||
Parameters:
|
||||
libtype (str): Library type to lookup (movie, show, season, episode,
|
||||
artist, album, track)
|
||||
|
||||
Returns:
|
||||
int: fx 1
|
||||
|
||||
Raises:
|
||||
NotFound: Unknown libtype: libtype
|
||||
Raises:
|
||||
NotFound: Unknown libtype
|
||||
"""
|
||||
|
||||
libtype = str(libtype)
|
||||
if libtype in [str(v) for v in SEARCHTYPES.values()]:
|
||||
return libtype
|
||||
|
@ -509,12 +493,12 @@ def searchType(libtype):
|
|||
|
||||
|
||||
def threaded(callback, listargs):
|
||||
"""Run some function in threads.
|
||||
|
||||
Args:
|
||||
callback (function): funcion to run in thread
|
||||
listargs (list): args parssed to the callback
|
||||
""" Returns the result of <callback> for each set of \*args in listargs. Each call
|
||||
to <callback. is called concurrently in their own separate threads.
|
||||
|
||||
Parameters:
|
||||
callback (func): Callback function to apply to each set of \*args.
|
||||
listargs (list): List of lists; \*args to pass each thread.
|
||||
"""
|
||||
threads, results = [], []
|
||||
for args in listargs:
|
||||
|
@ -524,18 +508,16 @@ def threaded(callback, listargs):
|
|||
threads[-1].start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def toDatetime(value, format=None):
|
||||
"""Helper for datetime.
|
||||
""" Returns a datetime object from the specified value.
|
||||
|
||||
Args:
|
||||
value (str): value to use to make datetime
|
||||
format (None, optional): string as strptime.
|
||||
|
||||
Returns:
|
||||
datetime
|
||||
Parameters:
|
||||
value (str): value to return as a datetime
|
||||
format (str): Format to pass strftime (optional; if value is a str).
|
||||
"""
|
||||
if value and value != NA:
|
||||
if format:
|
||||
|
@ -543,3 +525,57 @@ def toDatetime(value, format=None):
|
|||
else:
|
||||
value = datetime.fromtimestamp(int(value))
|
||||
return value
|
||||
|
||||
|
||||
def download(url, filename=None, savepath=None, session=None, chunksize=4024, mocked=False):
|
||||
""" Helper to download a thumb, videofile or other media item. Returns the local
|
||||
path to the downloaded file.
|
||||
|
||||
Parameters:
|
||||
url (str): URL where the content be reached.
|
||||
filename (str): Filename of the downloaded file, default None.
|
||||
savepath (str): Defaults to current working dir.
|
||||
chunksize (int): What chunksize read/write at the time.
|
||||
mocked (bool): Helper to do evertything except write the file.
|
||||
|
||||
Example:
|
||||
>>> download(a_episode.getStreamURL(), a_episode.location)
|
||||
/path/to/file
|
||||
"""
|
||||
session = session or requests.Session()
|
||||
print('Mocked download %s' % mocked)
|
||||
if savepath is None:
|
||||
savepath = os.getcwd()
|
||||
else:
|
||||
# Make sure the user supplied path exists
|
||||
try:
|
||||
os.makedirs(savepath)
|
||||
except OSError:
|
||||
if not os.path.isdir(savepath): # pragma: no cover
|
||||
raise
|
||||
filename = os.path.basename(filename)
|
||||
fullpath = os.path.join(savepath, filename)
|
||||
try:
|
||||
response = session.get(url, stream=True)
|
||||
# images dont have a extention so we try
|
||||
# to guess it from content-type
|
||||
ext = os.path.splitext(fullpath)[-1]
|
||||
if ext:
|
||||
ext = ''
|
||||
else:
|
||||
cp = response.headers.get('content-type')
|
||||
if cp:
|
||||
if 'image' in cp:
|
||||
ext = '.%s' % cp.split('/')[1]
|
||||
fullpath = '%s%s' % (fullpath, ext)
|
||||
if mocked:
|
||||
return fullpath
|
||||
with open(fullpath, 'wb') as f:
|
||||
for chunk in response.iter_content(chunk_size=chunksize):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
#log.debug('Downloaded %s to %s from %s' % (filename, fullpath, url))
|
||||
return fullpath
|
||||
except Exception as err: # pragma: no cover
|
||||
print('Error downloading file: %s' % err)
|
||||
#log.exception('Failed to download %s to %s %s' % (url, fullpath, e))
|
||||
|
|
236
plexapi/video.py
236
plexapi/video.py
|
@ -1,6 +1,6 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from plexapi import media, utils
|
||||
from plexapi.exceptions import NotFound
|
||||
from plexapi.utils import Playable, PlexPartialObject
|
||||
|
||||
NA = utils.NA
|
||||
|
@ -29,8 +29,7 @@ class Video(PlexPartialObject):
|
|||
self.listType = 'video'
|
||||
self.addedAt = utils.toDatetime(data.attrib.get('addedAt', NA))
|
||||
self.key = data.attrib.get('key', NA)
|
||||
self.lastViewedAt = utils.toDatetime(
|
||||
data.attrib.get('lastViewedAt', NA))
|
||||
self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt', NA))
|
||||
self.librarySectionID = data.attrib.get('librarySectionID', NA)
|
||||
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey', NA))
|
||||
self.summary = data.attrib.get('summary', NA)
|
||||
|
@ -53,7 +52,7 @@ class Video(PlexPartialObject):
|
|||
that are useful to know–whether it's a video file,
|
||||
a music track, or one of your photos.
|
||||
"""
|
||||
self.server.query('/%s/analyze' % self.key)
|
||||
self.server.query('/%s/analyze' % self.key.lstrip('/'), method=self.server.session.put)
|
||||
|
||||
def markWatched(self):
|
||||
"""Mark a items as watched."""
|
||||
|
@ -110,24 +109,15 @@ class Movie(Video, Playable):
|
|||
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
|
||||
self.year = utils.cast(int, data.attrib.get('year', NA))
|
||||
if self.isFullObject(): # check this
|
||||
self.collections = [media.Collection(
|
||||
self.server, e) for e in data if e.tag == media.Collection.TYPE]
|
||||
self.countries = [media.Country(self.server, e)
|
||||
for e in data if e.tag == media.Country.TYPE]
|
||||
self.directors = [media.Director(
|
||||
self.server, e) for e in data if e.tag == media.Director.TYPE]
|
||||
self.genres = [media.Genre(self.server, e)
|
||||
for e in data if e.tag == media.Genre.TYPE]
|
||||
self.media = [media.Media(self.server, e, self.initpath, self)
|
||||
for e in data if e.tag == media.Media.TYPE]
|
||||
self.producers = [media.Producer(
|
||||
self.server, e) for e in data if e.tag == media.Producer.TYPE]
|
||||
self.roles = [media.Role(self.server, e)
|
||||
for e in data if e.tag == media.Role.TYPE]
|
||||
self.writers = [media.Writer(self.server, e)
|
||||
for e in data if e.tag == media.Writer.TYPE]
|
||||
self.fields = [media.Field(e)
|
||||
for e in data if e.tag == media.Field.TYPE]
|
||||
self.collections = [media.Collection(self.server, e) for e in data if e.tag == media.Collection.TYPE]
|
||||
self.countries = [media.Country(self.server, e) for e in data if e.tag == media.Country.TYPE]
|
||||
self.directors = [media.Director(self.server, e) for e in data if e.tag == media.Director.TYPE]
|
||||
self.genres = [media.Genre(self.server, e) for e in data if e.tag == media.Genre.TYPE]
|
||||
self.media = [media.Media(self.server, e, self.initpath, self) for e in data if e.tag == media.Media.TYPE]
|
||||
self.producers = [media.Producer(self.server, e) for e in data if e.tag == media.Producer.TYPE]
|
||||
self.roles = [media.Role(self.server, e) for e in data if e.tag == media.Role.TYPE]
|
||||
self.writers = [media.Writer(self.server, e) for e in data if e.tag == media.Writer.TYPE]
|
||||
self.fields = [media.Field(e) for e in data if e.tag == media.Field.TYPE]
|
||||
self.videoStreams = utils.findStreams(self.media, 'videostream')
|
||||
self.audioStreams = utils.findStreams(self.media, 'audiostream')
|
||||
self.subtitleStreams = utils.findStreams(
|
||||
|
@ -141,6 +131,35 @@ class Movie(Video, Playable):
|
|||
def isWatched(self):
|
||||
return bool(self.viewCount > 0)
|
||||
|
||||
@property
|
||||
def location(self):
|
||||
""" This does not exist in plex xml response but is added to have a common
|
||||
interface to get the location of the Movie/Show/Episode
|
||||
"""
|
||||
files = [i.file for i in self.iterParts() if i]
|
||||
if len(files) == 1:
|
||||
files = files[0]
|
||||
|
||||
return files
|
||||
|
||||
def download(self, savepath=None, keep_orginal_name=False, **kwargs):
|
||||
downloaded = []
|
||||
locs = [i for i in self.iterParts() if i]
|
||||
for loc in locs:
|
||||
if keep_orginal_name is False:
|
||||
name = '%s.%s' % (self.title.replace(' ', '.'), loc.container)
|
||||
else:
|
||||
name = loc.file
|
||||
# So this seems to be a alot slower but allows transcode.
|
||||
if kwargs:
|
||||
download_url = self.getStreamURL(**kwargs)
|
||||
else:
|
||||
download_url = self.server.url('%s?download=1' % loc.key)
|
||||
dl = utils.download(download_url, filename=name, savepath=savepath, session=self.server.session)
|
||||
if dl:
|
||||
downloaded.append(dl)
|
||||
return downloaded
|
||||
|
||||
|
||||
@utils.register_libtype
|
||||
class Show(Video):
|
||||
|
@ -153,6 +172,8 @@ class Show(Video):
|
|||
data (Element): Usually built from server.query
|
||||
"""
|
||||
Video._loadData(self, data)
|
||||
# Incase this was loaded from search etc
|
||||
self.key = self.key.replace('/children', '')
|
||||
self.art = data.attrib.get('art', NA)
|
||||
self.banner = data.attrib.get('banner', NA)
|
||||
self.childCount = utils.cast(int, data.attrib.get('childCount', NA))
|
||||
|
@ -161,7 +182,7 @@ class Show(Video):
|
|||
self.guid = data.attrib.get('guid', NA)
|
||||
self.index = data.attrib.get('index', NA)
|
||||
self.leafCount = utils.cast(int, data.attrib.get('leafCount', NA))
|
||||
self.location = utils.findLocations(data, single=True)
|
||||
self.location = utils.findLocations(data, single=True) or NA
|
||||
self.originallyAvailableAt = utils.toDatetime(
|
||||
data.attrib.get('originallyAvailableAt', NA), '%Y-%m-%d')
|
||||
self.rating = utils.cast(float, data.attrib.get('rating', NA))
|
||||
|
@ -170,11 +191,9 @@ class Show(Video):
|
|||
self.viewedLeafCount = utils.cast(
|
||||
int, data.attrib.get('viewedLeafCount', NA))
|
||||
self.year = utils.cast(int, data.attrib.get('year', NA))
|
||||
#if self.isFullObject(): # will be fixed with docs.
|
||||
self.genres = [media.Genre(self.server, e)
|
||||
for e in data if e.tag == media.Genre.TYPE]
|
||||
self.roles = [media.Role(self.server, e)
|
||||
for e in data if e.tag == media.Role.TYPE]
|
||||
if self.isFullObject(): # will be fixed with docs.
|
||||
self.genres = [media.Genre(self.server, e) for e in data if e.tag == media.Genre.TYPE]
|
||||
self.roles = [media.Role(self.server, e) for e in data if e.tag == media.Role.TYPE]
|
||||
|
||||
@property
|
||||
def actors(self):
|
||||
|
@ -189,12 +208,15 @@ class Show(Video):
|
|||
path = '/library/metadata/%s/children' % self.ratingKey
|
||||
return utils.listItems(self.server, path, Season.TYPE)
|
||||
|
||||
def season(self, title):
|
||||
def season(self, title=None):
|
||||
"""Returns a Season
|
||||
|
||||
Args:
|
||||
title (str): fx Season1
|
||||
title (str, int): fx Season 1
|
||||
"""
|
||||
if isinstance(title, int):
|
||||
title = 'Season %s' % title
|
||||
|
||||
path = '/library/metadata/%s/children' % self.ratingKey
|
||||
return utils.findItem(self.server, path, title)
|
||||
|
||||
|
@ -207,9 +229,45 @@ class Show(Video):
|
|||
leavesKey = '/library/metadata/%s/allLeaves' % self.ratingKey
|
||||
return utils.listItems(self.server, leavesKey, watched=watched)
|
||||
|
||||
def episode(self, title):
|
||||
path = '/library/metadata/%s/allLeaves' % self.ratingKey
|
||||
return utils.findItem(self.server, path, title)
|
||||
def episode(self, title=None, season=None, episode=None):
|
||||
"""Find a episode using a title or season and episode.
|
||||
|
||||
Note:
|
||||
Both season and episode is required if title is missing.
|
||||
|
||||
Args:
|
||||
title (str): Default None
|
||||
season (int): Season number, default None
|
||||
episode (int): Episode number, default None
|
||||
|
||||
Raises:
|
||||
ValueError: If season and episode is missing.
|
||||
NotFound: If the episode is missing.
|
||||
|
||||
Returns:
|
||||
Episode
|
||||
|
||||
Examples:
|
||||
>>> plex.search('The blacklist')[0].episode(season=1, episode=1)
|
||||
<Episode:116263:The.Freelancer>
|
||||
>>> plex.search('The blacklist')[0].episode('The Freelancer')
|
||||
<Episode:116263:The.Freelancer>
|
||||
|
||||
"""
|
||||
if not title and (not season or not episode):
|
||||
raise TypeError('Missing argument: title or season and episode are required')
|
||||
|
||||
if title:
|
||||
path = '/library/metadata/%s/allLeaves' % self.ratingKey
|
||||
return utils.findItem(self.server, path, title)
|
||||
|
||||
elif season and episode:
|
||||
results = [i for i in self.episodes()
|
||||
if i.seasonNumber == season and i.index == episode]
|
||||
if results:
|
||||
return results[0]
|
||||
else:
|
||||
raise NotFound('Couldnt find %s S%s E%s' % (self.title, season, episode))
|
||||
|
||||
def watched(self):
|
||||
"""Return a list of watched episodes"""
|
||||
|
@ -227,9 +285,21 @@ class Show(Video):
|
|||
"""
|
||||
return self.episode(title)
|
||||
|
||||
def analyze(self):
|
||||
""" """
|
||||
raise 'Cant analyse a show' # fix me
|
||||
|
||||
def refresh(self):
|
||||
"""Refresh the metadata."""
|
||||
self.server.query('/library/metadata/%s/refresh' % self.ratingKey)
|
||||
self.server.query('/library/metadata/%s/refresh' % self.ratingKey, method=self.server.session.put)
|
||||
|
||||
def download(self, savepath=None, keep_orginal_name=False, **kwargs):
|
||||
downloaded = []
|
||||
for ep in self.episodes():
|
||||
dl = ep.download(savepath=savepath, keep_orginal_name=keep_orginal_name, **kwargs)
|
||||
if dl:
|
||||
downloaded.extend(dl)
|
||||
return downloaded
|
||||
|
||||
|
||||
@utils.register_libtype
|
||||
|
@ -243,10 +313,12 @@ class Season(Video):
|
|||
data (Element): Usually built from server.query
|
||||
"""
|
||||
Video._loadData(self, data)
|
||||
self.key = self.key.replace('/children', '')
|
||||
self.leafCount = utils.cast(int, data.attrib.get('leafCount', NA))
|
||||
self.index = data.attrib.get('index', NA)
|
||||
self.index = utils.cast(int, data.attrib.get('index', NA))
|
||||
self.parentKey = data.attrib.get('parentKey', NA)
|
||||
self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey', NA))
|
||||
self.parentTitle = data.attrib.get('parentTitle', NA)
|
||||
self.viewedLeafCount = utils.cast(
|
||||
int, data.attrib.get('viewedLeafCount', NA))
|
||||
|
||||
|
@ -256,7 +328,7 @@ class Season(Video):
|
|||
|
||||
@property
|
||||
def seasonNumber(self):
|
||||
"""Reurns season number."""
|
||||
"""Returns season number."""
|
||||
return self.index
|
||||
|
||||
def episodes(self, watched=None):
|
||||
|
@ -268,25 +340,48 @@ class Season(Video):
|
|||
Returns:
|
||||
list: of Episode
|
||||
|
||||
|
||||
|
||||
"""
|
||||
childrenKey = '/library/metadata/%s/children' % self.ratingKey
|
||||
return utils.listItems(self.server, childrenKey, watched=watched)
|
||||
|
||||
def episode(self, title):
|
||||
"""Find a episode with a matching title.
|
||||
def episode(self, title=None, episode=None):
|
||||
"""Find a episode using a title or season and episode.
|
||||
|
||||
Args:
|
||||
title (sting): Fx
|
||||
Note:
|
||||
episode is required if title is missing.
|
||||
|
||||
Returns:
|
||||
Args:
|
||||
title (str): Default None
|
||||
episode (int): Episode number, default None
|
||||
|
||||
Raises:
|
||||
TypeError: If title and episode is missing.
|
||||
NotFound: If that episode cant be found.
|
||||
|
||||
Returns:
|
||||
Episode
|
||||
|
||||
Examples:
|
||||
>>> plex.search('The blacklist').season(1).episode(episode=1)
|
||||
<Episode:116263:The.Freelancer>
|
||||
>>> plex.search('The blacklist').season(1).episode('The Freelancer')
|
||||
<Episode:116263:The.Freelancer>
|
||||
|
||||
"""
|
||||
path = '/library/metadata/%s/children' % self.ratingKey
|
||||
return utils.findItem(self.server, path, title)
|
||||
if not title and not episode:
|
||||
raise TypeError('Missing argument, you need to use title or episode.')
|
||||
if title:
|
||||
path = '/library/metadata/%s/children' % self.ratingKey
|
||||
return utils.findItem(self.server, path, title)
|
||||
elif episode:
|
||||
results = [i for i in self.episodes() if i.seasonNumber == self.index and i.index == episode]
|
||||
if results:
|
||||
return results[0]
|
||||
raise NotFound('Couldnt find %s.Season %s Episode %s.' % (self.grandparentTitle, self.index. episode))
|
||||
|
||||
def get(self, title):
|
||||
"""Get a episode witha matching title
|
||||
"""Get a episode with a matching title.
|
||||
|
||||
Args:
|
||||
title (str): fx Secret santa
|
||||
|
@ -308,6 +403,20 @@ class Season(Video):
|
|||
"""Returns a list of unwatched Episode"""
|
||||
return self.episodes(watched=False)
|
||||
|
||||
def __repr__(self):
|
||||
clsname = self.__class__.__name__
|
||||
key = self.key.replace('/library/metadata/', '').replace('/children', '') if self.key else 'NA'
|
||||
title = self.title.replace(' ', '.')[0:20].encode('utf8')
|
||||
return '<%s:%s:%s:%s>' % (clsname, key, self.parentTitle, title)
|
||||
|
||||
def download(self, savepath=None, keep_orginal_name=False, **kwargs):
|
||||
downloaded = []
|
||||
for ep in self.episodes():
|
||||
dl = ep.download(savepath=savepath, keep_orginal_name=keep_orginal_name, **kwargs)
|
||||
if dl:
|
||||
downloaded.extend(dl)
|
||||
return downloaded
|
||||
|
||||
|
||||
@utils.register_libtype
|
||||
class Episode(Video, Playable):
|
||||
|
@ -332,9 +441,8 @@ class Episode(Video, Playable):
|
|||
self.grandparentThumb = data.attrib.get('grandparentThumb', NA)
|
||||
self.grandparentTitle = data.attrib.get('grandparentTitle', NA)
|
||||
self.guid = data.attrib.get('guid', NA)
|
||||
self.index = data.attrib.get('index', NA)
|
||||
self.originallyAvailableAt = utils.toDatetime(
|
||||
data.attrib.get('originallyAvailableAt', NA), '%Y-%m-%d')
|
||||
self.index = utils.cast(int, data.attrib.get('index', NA))
|
||||
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt', NA), '%Y-%m-%d')
|
||||
self.parentIndex = data.attrib.get('parentIndex', NA)
|
||||
self.parentKey = data.attrib.get('parentKey', NA)
|
||||
self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey', NA))
|
||||
|
@ -342,12 +450,9 @@ class Episode(Video, Playable):
|
|||
self.rating = utils.cast(float, data.attrib.get('rating', NA))
|
||||
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
|
||||
self.year = utils.cast(int, data.attrib.get('year', NA))
|
||||
self.directors = [media.Director(self.server, e)
|
||||
for e in data if e.tag == media.Director.TYPE]
|
||||
self.media = [media.Media(self.server, e, self.initpath, self)
|
||||
for e in data if e.tag == media.Media.TYPE]
|
||||
self.writers = [media.Writer(self.server, e)
|
||||
for e in data if e.tag == media.Writer.TYPE]
|
||||
self.directors = [media.Director(self.server, e) for e in data if e.tag == media.Director.TYPE]
|
||||
self.media = [media.Media(self.server, e, self.initpath, self) for e in data if e.tag == media.Media.TYPE]
|
||||
self.writers = [media.Writer(self.server, e) for e in data if e.tag == media.Writer.TYPE]
|
||||
self.videoStreams = utils.findStreams(self.media, 'videostream')
|
||||
self.audioStreams = utils.findStreams(self.media, 'audiostream')
|
||||
self.subtitleStreams = utils.findStreams(self.media, 'subtitlestream')
|
||||
|
@ -359,6 +464,12 @@ class Episode(Video, Playable):
|
|||
# Cached season number
|
||||
self._seasonNumber = None
|
||||
|
||||
def __repr__(self):
|
||||
clsname = self.__class__.__name__
|
||||
key = self.key.replace('/library/metadata/', '').replace('/children', '') if self.key else 'NA'
|
||||
title = self.title.replace(' ', '.')[0:20].encode('utf8')
|
||||
return '<%s:%s:%s:S%s:E%s:%s>' % (clsname, key, self.grandparentTitle, self.seasonNumber, self.index, title)
|
||||
|
||||
@property
|
||||
def isWatched(self):
|
||||
"""Returns True if watched, False if not."""
|
||||
|
@ -369,7 +480,7 @@ class Episode(Video, Playable):
|
|||
"""Return this episode seasonnumber."""
|
||||
if self._seasonNumber is None:
|
||||
self._seasonNumber = self.parentIndex if self.parentIndex else self.season().seasonNumber
|
||||
return self._seasonNumber
|
||||
return utils.cast(int, self._seasonNumber)
|
||||
|
||||
@property
|
||||
def thumbUrl(self):
|
||||
|
@ -384,3 +495,18 @@ class Episode(Video, Playable):
|
|||
def show(self):
|
||||
"""Return this episodes Show"""
|
||||
return utils.listItems(self.server, self.grandparentKey)[0]
|
||||
|
||||
@property
|
||||
def location(self):
|
||||
""" This does not exist in plex xml response but is added to have a common
|
||||
interface to get the location of the Movie/Show
|
||||
"""
|
||||
# Note this should probably belong to some parent.
|
||||
files = [i.file for i in self.iterParts() if i]
|
||||
if len(files) == 1:
|
||||
files = files[0]
|
||||
return files
|
||||
|
||||
def _prettyfilename(self):
|
||||
return '%s.S%sE%s' % (self.grandparentTitle.replace(' ', '.'),
|
||||
str(self.seasonNumber).zfill(2), str(self.index).zfill(2))
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
#---------------------------------------------------------
|
||||
# PlexAPI Requirements
|
||||
# pip install -r requirments.txt
|
||||
#---------------------------------------------------------
|
||||
requests
|
7
requirements_dev.txt
Normal file
7
requirements_dev.txt
Normal file
|
@ -0,0 +1,7 @@
|
|||
requests
|
||||
pytest
|
||||
pytest-cov
|
||||
betamax
|
||||
betamax_serializers
|
||||
pillow
|
||||
coveralls
|
3
setup.py
3
setup.py
|
@ -5,7 +5,6 @@ Install PlexAPI
|
|||
"""
|
||||
import re
|
||||
from distutils.core import setup
|
||||
from setuptools import find_packages
|
||||
|
||||
# Convert markdown readme to rst
|
||||
try:
|
||||
|
@ -29,7 +28,7 @@ setup(
|
|||
author='Michael Shepanski',
|
||||
author_email='mjs7231@gmail.com',
|
||||
url='https://github.com/mjs7231/plexapi',
|
||||
packages=find_packages(),
|
||||
packages=['plexapi'],
|
||||
install_requires=['requests'],
|
||||
long_description=read_md('README.md'),
|
||||
keywords=['plex', 'api'],
|
||||
|
|
0
tests-old/__init__.py
Normal file
0
tests-old/__init__.py
Normal file
76
tests-old/runtests.py
Executable file
76
tests-old/runtests.py
Executable file
|
@ -0,0 +1,76 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
You can run this test suite with the following command:
|
||||
>> python tests.py -u <USERNAME> -p <PASSWORD> -s <SERVERNAME>
|
||||
"""
|
||||
import argparse, pkgutil, sys, time, traceback
|
||||
from os.path import dirname, abspath
|
||||
sys.path.append(dirname(dirname(abspath(__file__))))
|
||||
from plexapi import CONFIG
|
||||
from plexapi.server import PlexServer
|
||||
from plexapi.myplex import MyPlexAccount
|
||||
from utils import log, itertests
|
||||
|
||||
|
||||
def runtests(args):
|
||||
# Get username and password from environment
|
||||
username = args.username or CONFIG.get('authentication.username')
|
||||
password = args.password or CONFIG.get('authentication.password')
|
||||
resource = args.resource or CONFIG.get('authentication.resource')
|
||||
# Register known tests
|
||||
for loader, name, ispkg in pkgutil.iter_modules([dirname(abspath(__file__))]):
|
||||
if name.startswith('test_'):
|
||||
log(0, 'Registering tests from %s.py' % name)
|
||||
loader.find_module(name).load_module(name)
|
||||
# Create Account and Plex objects
|
||||
log(0, 'Logging into MyPlex as %s' % username)
|
||||
account = MyPlexAccount.signin(username, password)
|
||||
log(0, 'Signed into MyPlex as %s (%s)' % (account.username, account.email))
|
||||
if resource:
|
||||
plex = account.resource(resource).connect()
|
||||
log(0, 'Connected to PlexServer resource %s' % plex.friendlyName)
|
||||
else:
|
||||
plex = PlexServer(args.baseurl, args.token)
|
||||
log(0, 'Connected to PlexServer %s' % plex.friendlyName)
|
||||
log(0, '')
|
||||
# Run all specified tests
|
||||
tests = {'passed':0, 'failed':0}
|
||||
for test in itertests(args.query):
|
||||
starttime = time.time()
|
||||
log(0, test['name'], 'green')
|
||||
try:
|
||||
test['func'](account, plex)
|
||||
runtime = time.time() - starttime
|
||||
log(2, 'PASS! (runtime: %.3fs)' % runtime, 'blue')
|
||||
tests['passed'] += 1
|
||||
except Exception as err:
|
||||
errstr = str(err)
|
||||
errstr += '\n%s' % traceback.format_exc() if args.verbose else ''
|
||||
log(2, 'FAIL: %s' % errstr, 'red')
|
||||
tests['failed'] += 1
|
||||
log(0, '')
|
||||
# Log a final report
|
||||
log(0, 'Tests Run: %s' % sum(tests.values()))
|
||||
log(0, 'Tests Passed: %s' % tests['passed'])
|
||||
if tests['failed']:
|
||||
log(0, 'Tests Failed: %s' % tests['failed'], 'red')
|
||||
if not tests['failed']:
|
||||
log(0, '')
|
||||
log(0, 'EVERYTHING OK!! :)')
|
||||
raise SystemExit(tests['failed'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description='Run PlexAPI tests.')
|
||||
# Auth Method 1: Pass --username, --password, and optionally --resource.
|
||||
parser.add_argument('-u', '--username', help='Username for your MyPlex account.')
|
||||
parser.add_argument('-p', '--password', help='Password for your MyPlex account.')
|
||||
parser.add_argument('-r', '--resource', help='Name of the Plex resource (requires user/pass).')
|
||||
# Auth Method 2: Pass --baseurl and --token.
|
||||
parser.add_argument('-b', '--baseurl', help='Baseurl needed for auth token authentication')
|
||||
parser.add_argument('-t', '--token', help='Auth token (instead of user/pass)')
|
||||
# Misc options
|
||||
parser.add_argument('-q', '--query', help='Only run the specified tests.')
|
||||
parser.add_argument('-v', '--verbose', default=False, action='store_true', help='Print verbose logging.')
|
||||
runtests(parser.parse_args())
|
29
tests-old/test_actions.py
Normal file
29
tests-old/test_actions.py
Normal file
|
@ -0,0 +1,29 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from utils import log, register
|
||||
from plexapi import CONFIG
|
||||
|
||||
|
||||
@register()
|
||||
def test_mark_movie_watched(account, plex):
|
||||
movie = plex.library.section(CONFIG.movie_section).get(CONFIG.movie_title)
|
||||
movie.markUnwatched()
|
||||
log(2, 'Marking movie watched: %s' % movie)
|
||||
log(2, 'View count: %s' % movie.viewCount)
|
||||
movie.markWatched()
|
||||
log(2, 'View count: %s' % movie.viewCount)
|
||||
assert movie.viewCount == 1, 'View count 0 after watched.'
|
||||
movie.markUnwatched()
|
||||
log(2, 'View count: %s' % movie.viewCount)
|
||||
assert movie.viewCount == 0, 'View count 1 after unwatched.'
|
||||
|
||||
|
||||
@register()
|
||||
def test_refresh_section(account, plex):
|
||||
shows = plex.library.section(CONFIG.movie_section)
|
||||
shows.refresh()
|
||||
|
||||
|
||||
@register()
|
||||
def test_refresh_video(account, plex):
|
||||
result = plex.search(CONFIG.movie_title)
|
||||
result[0].refresh()
|
132
tests-old/test_client.py
Normal file
132
tests-old/test_client.py
Normal file
|
@ -0,0 +1,132 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import time
|
||||
from utils import log, register, getclient
|
||||
from plexapi import CONFIG
|
||||
|
||||
|
||||
@register()
|
||||
def test_list_clients(account, plex):
|
||||
clients = [c.title for c in plex.clients()]
|
||||
log(2, 'Clients: %s' % ', '.join(clients or []))
|
||||
assert clients, 'Server is not listing any clients.'
|
||||
|
||||
|
||||
@register()
|
||||
def test_client_navigation(account, plex):
|
||||
client = getclient(CONFIG.client, CONFIG.client_baseurl, plex)
|
||||
_navigate(plex, client)
|
||||
|
||||
|
||||
@register()
|
||||
def test_client_navigation_via_proxy(account, plex):
|
||||
client = getclient(CONFIG.client, CONFIG.client_baseurl, plex)
|
||||
client.proxyThroughServer()
|
||||
_navigate(plex, client)
|
||||
|
||||
|
||||
def _navigate(plex, client):
|
||||
episode = plex.library.section(CONFIG.show_section).get(CONFIG.show_title).get(CONFIG.show_episode)
|
||||
artist = plex.library.section(CONFIG.audio_section).get(CONFIG.audio_artist)
|
||||
log(2, 'Client: %s (%s)' % (client.title, client.product))
|
||||
log(2, 'Capabilities: %s' % client.protocolCapabilities)
|
||||
# Move around a bit
|
||||
log(2, 'Browsing around..')
|
||||
client.moveDown(); time.sleep(0.5)
|
||||
client.moveDown(); time.sleep(0.5)
|
||||
client.moveDown(); time.sleep(0.5)
|
||||
client.select(); time.sleep(3)
|
||||
client.moveRight(); time.sleep(0.5)
|
||||
client.moveRight(); time.sleep(0.5)
|
||||
client.moveLeft(); time.sleep(0.5)
|
||||
client.select(); time.sleep(3)
|
||||
client.goBack(); time.sleep(1)
|
||||
client.goBack(); time.sleep(3)
|
||||
# Go directly to media
|
||||
log(2, 'Navigating to %s..' % episode.title)
|
||||
client.goToMedia(episode); time.sleep(5)
|
||||
log(2, 'Navigating to %s..' % artist.title)
|
||||
client.goToMedia(artist); time.sleep(5)
|
||||
log(2, 'Navigating home..')
|
||||
client.goToHome(); time.sleep(5)
|
||||
client.moveUp(); time.sleep(0.5)
|
||||
client.moveUp(); time.sleep(0.5)
|
||||
client.moveUp(); time.sleep(0.5)
|
||||
# Show context menu
|
||||
client.contextMenu(); time.sleep(3)
|
||||
client.goBack(); time.sleep(5)
|
||||
|
||||
|
||||
@register()
|
||||
def test_video_playback(account, plex):
|
||||
client = getclient(CONFIG.client, CONFIG.client_baseurl, plex)
|
||||
_video_playback(plex, client)
|
||||
|
||||
|
||||
@register()
|
||||
def test_video_playback_via_proxy(account, plex):
|
||||
client = getclient(CONFIG.client, CONFIG.client_baseurl, plex)
|
||||
client.proxyThroughServer()
|
||||
_video_playback(plex, client)
|
||||
|
||||
|
||||
def _video_playback(plex, client):
|
||||
try:
|
||||
mtype = 'video'
|
||||
movie = plex.library.section(CONFIG.movie_section).get(CONFIG.movie_title)
|
||||
subs = [s for s in movie.subtitleStreams if s.language == 'English']
|
||||
log(2, 'Client: %s (%s)' % (client.title, client.product))
|
||||
log(2, 'Capabilities: %s' % client.protocolCapabilities)
|
||||
log(2, 'Playing to %s..' % movie.title)
|
||||
client.playMedia(movie); time.sleep(5)
|
||||
log(2, 'Pause..')
|
||||
client.pause(mtype); time.sleep(2)
|
||||
log(2, 'Step Forward..')
|
||||
client.stepForward(mtype); time.sleep(5)
|
||||
log(2, 'Play..')
|
||||
client.play(mtype); time.sleep(3)
|
||||
log(2, 'Seek to 10m..')
|
||||
client.seekTo(10*60*1000); time.sleep(5)
|
||||
log(2, 'Disable Subtitles..')
|
||||
client.setSubtitleStream(0, mtype); time.sleep(10)
|
||||
log(2, 'Load English Subtitles %s..' % subs[0].id)
|
||||
client.setSubtitleStream(subs[0].id, mtype); time.sleep(10)
|
||||
log(2, 'Stop..')
|
||||
client.stop(mtype); time.sleep(1)
|
||||
finally:
|
||||
log(2, 'Cleanup: Marking %s watched.' % movie.title)
|
||||
movie.markWatched()
|
||||
|
||||
|
||||
@register()
|
||||
def test_client_timeline(account, plex):
|
||||
client = getclient(CONFIG.client, CONFIG.client_baseurl, plex)
|
||||
_test_timeline(plex, client)
|
||||
|
||||
|
||||
@register()
|
||||
def test_client_timeline_via_proxy(account, plex):
|
||||
client = getclient(CONFIG.client, CONFIG.client_baseurl, plex)
|
||||
client.proxyThroughServer()
|
||||
_test_timeline(plex, client)
|
||||
|
||||
|
||||
def _test_timeline(plex, client):
|
||||
try:
|
||||
mtype = 'video'
|
||||
client = getclient(CONFIG.client, CONFIG.client_baseurl, plex)
|
||||
movie = plex.library.section(CONFIG.movie_section).get(CONFIG.movie_title)
|
||||
time.sleep(30) # previous test may have played media..
|
||||
playing = client.isPlayingMedia()
|
||||
log(2, 'Playing Media: %s' % playing)
|
||||
assert playing is False, 'isPlayingMedia() should have returned False.'
|
||||
client.playMedia(movie); time.sleep(30)
|
||||
playing = client.isPlayingMedia()
|
||||
log(2, 'Playing Media: %s' % playing)
|
||||
assert playing is True, 'isPlayingMedia() should have returned True.'
|
||||
client.stop(mtype); time.sleep(30)
|
||||
playing = client.isPlayingMedia()
|
||||
log(2, 'Playing Media: %s' % playing)
|
||||
assert playing is False, 'isPlayingMedia() should have returned False.'
|
||||
finally:
|
||||
log(2, 'Cleanup: Marking %s watched.' % movie.title)
|
||||
movie.markWatched()
|
52
tests-old/test_core.py
Normal file
52
tests-old/test_core.py
Normal file
|
@ -0,0 +1,52 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import time
|
||||
from utils import log, register, getclient
|
||||
from plexapi import CONFIG
|
||||
|
||||
|
||||
@register()
|
||||
def test_server(account, plex):
|
||||
log(2, 'Username: %s' % plex.myPlexUsername)
|
||||
log(2, 'Platform: %s' % plex.platform)
|
||||
log(2, 'Version: %s' % plex.version)
|
||||
assert plex.myPlexUsername is not None, 'Unknown username.'
|
||||
assert plex.platform is not None, 'Unknown platform.'
|
||||
assert plex.version is not None, 'Unknown version.'
|
||||
|
||||
|
||||
@register()
|
||||
def test_list_sections(account, plex):
|
||||
sections = [s.title for s in plex.library.sections()]
|
||||
log(2, 'Sections: %s' % sections)
|
||||
assert CONFIG.show_section in sections, '%s not a library section.' % CONFIG.show_section
|
||||
assert CONFIG.movie_section in sections, '%s not a library section.' % CONFIG.movie_section
|
||||
plex.library.section(CONFIG.show_section)
|
||||
plex.library.section(CONFIG.movie_section)
|
||||
|
||||
|
||||
@register()
|
||||
def test_history(account, plex):
|
||||
history = plex.history()
|
||||
for item in history[:20]:
|
||||
log(2, "%s: %s played %s '%s'" % (item.viewedAt, item.username, item.TYPE, item.title))
|
||||
assert len(history), 'No history items have been found.'
|
||||
|
||||
|
||||
@register()
|
||||
def test_sessions(account, plex):
|
||||
client, movie = None, None
|
||||
try:
|
||||
mtype = 'video'
|
||||
movie = plex.library.section(CONFIG.movie_section).get(CONFIG.movie_title)
|
||||
client = getclient(CONFIG.client, CONFIG.client_baseurl, plex)
|
||||
log(2, 'Playing %s..' % movie.title)
|
||||
client.playMedia(movie); time.sleep(5)
|
||||
sessions = plex.sessions()
|
||||
for item in sessions[:20]:
|
||||
log(2, "%s is playing %s '%s' on %s" % (item.username, item.TYPE, item.title, item.player.platform))
|
||||
assert len(sessions), 'No session items have been found.'
|
||||
finally:
|
||||
log(2, 'Stop..')
|
||||
if client: client.stop(mtype); time.sleep(1)
|
||||
log(2, 'Cleanup: Marking %s watched.' % movie.title)
|
||||
if movie: movie.markWatched()
|
144
tests-old/test_metadata.py
Normal file
144
tests-old/test_metadata.py
Normal file
|
@ -0,0 +1,144 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from os.path import basename
|
||||
from utils import log, register
|
||||
from plexapi import CONFIG
|
||||
|
||||
|
||||
@register()
|
||||
def test_partial_video(account, plex):
|
||||
result = plex.search(CONFIG.movie_foreign)
|
||||
log(2, 'Title: %s' % result[0].title)
|
||||
log(2, 'Original Title: %s' % result[0].originalTitle)
|
||||
assert(result[0].originalTitle != None)
|
||||
|
||||
|
||||
@register()
|
||||
def test_list_media_files(account, plex):
|
||||
# Fetch file names from the tv show
|
||||
episode_files = []
|
||||
episode = plex.library.section(CONFIG.show_section).get(CONFIG.show_title).episodes()[-1]
|
||||
log(2, 'Episode Files: %s' % episode)
|
||||
for media in episode.media:
|
||||
for part in media.parts:
|
||||
log(4, part.file)
|
||||
episode_files.append(part.file)
|
||||
assert filter(None, episode_files), 'No show files have been listed.'
|
||||
# Fetch file names from the movie
|
||||
movie_files = []
|
||||
movie = plex.library.section(CONFIG.movie_section).get(CONFIG.movie_title)
|
||||
log(2, 'Movie Files: %s' % movie)
|
||||
for media in movie.media:
|
||||
for part in media.parts:
|
||||
log(4, part.file)
|
||||
movie_files.append(part.file)
|
||||
assert filter(None, movie_files), 'No movie files have been listed.'
|
||||
|
||||
|
||||
@register()
|
||||
def test_list_video_tags(account, plex):
|
||||
movies = plex.library.section(CONFIG.movie_section)
|
||||
movie = movies.get(CONFIG.movie_title)
|
||||
log(2, 'Countries: %s' % movie.countries[0:3])
|
||||
log(2, 'Directors: %s' % movie.directors[0:3])
|
||||
log(2, 'Genres: %s' % movie.genres[0:3])
|
||||
log(2, 'Producers: %s' % movie.producers[0:3])
|
||||
log(2, 'Actors: %s' % movie.actors[0:3])
|
||||
log(2, 'Writers: %s' % movie.writers[0:3])
|
||||
assert filter(None, movie.countries), 'No countries listed for movie.'
|
||||
assert filter(None, movie.directors), 'No directors listed for movie.'
|
||||
assert filter(None, movie.genres), 'No genres listed for movie.'
|
||||
assert filter(None, movie.producers), 'No producers listed for movie.'
|
||||
assert filter(None, movie.actors), 'No actors listed for movie.'
|
||||
assert filter(None, movie.writers), 'No writers listed for movie.'
|
||||
log(2, 'List movies with same director: %s' % movie.directors[0])
|
||||
related = movies.search(None, director=movie.directors[0])
|
||||
log(4, related[0:3])
|
||||
assert movie in related, 'Movie was not found in related directors search.'
|
||||
|
||||
|
||||
@register()
|
||||
def test_list_video_streams(account, plex):
|
||||
movie = plex.library.section(CONFIG.movie_section).get('John Wick')
|
||||
videostreams = [s.language for s in movie.videoStreams]
|
||||
audiostreams = [s.language for s in movie.audioStreams]
|
||||
subtitlestreams = [s.language for s in movie.subtitleStreams]
|
||||
log(2, 'Video Streams: %s' % ', '.join(videostreams[0:5]))
|
||||
log(2, 'Audio Streams: %s' % ', '.join(audiostreams[0:5]))
|
||||
log(2, 'Subtitle Streams: %s' % ', '.join(subtitlestreams[0:5]))
|
||||
assert filter(None, videostreams), 'No video streams listed for movie.'
|
||||
assert filter(None, audiostreams), 'No audio streams listed for movie.'
|
||||
assert filter(None, subtitlestreams), 'No subtitle streams listed for movie.'
|
||||
|
||||
|
||||
@register()
|
||||
def test_list_audio_tags(account, plex):
|
||||
section = plex.library.section(CONFIG.audio_section)
|
||||
artist = section.get(CONFIG.audio_artist)
|
||||
track = artist.get(CONFIG.audio_track)
|
||||
log(2, 'Countries: %s' % artist.countries[0:3])
|
||||
log(2, 'Genres: %s' % artist.genres[0:3])
|
||||
log(2, 'Similar: %s' % artist.similar[0:3])
|
||||
log(2, 'Album Genres: %s' % track.album().genres[0:3])
|
||||
log(2, 'Moods: %s' % track.moods[0:3])
|
||||
log(2, 'Media: %s' % track.media[0:3])
|
||||
assert filter(None, artist.countries), 'No countries listed for artist.'
|
||||
assert filter(None, artist.genres), 'No genres listed for artist.'
|
||||
assert filter(None, artist.similar), 'No similar artists listed.'
|
||||
assert filter(None, track.album().genres), 'No genres listed for album.'
|
||||
assert filter(None, track.moods), 'No moods listed for track.'
|
||||
assert filter(None, track.media), 'No media listed for track.'
|
||||
|
||||
|
||||
@register()
|
||||
def test_is_watched(account, plex):
|
||||
show = plex.library.section(CONFIG.show_section).get(CONFIG.show_title)
|
||||
episode = show.get(CONFIG.show_episode)
|
||||
log(2, '%s isWatched: %s' % (episode.title, episode.isWatched))
|
||||
movie = plex.library.section(CONFIG.movie_section).get(CONFIG.movie_title)
|
||||
log(2, '%s isWatched: %s' % (movie.title, movie.isWatched))
|
||||
|
||||
|
||||
@register()
|
||||
def test_fetch_details_not_in_search_result(account, plex):
|
||||
# Search results only contain 3 actors per movie. This text checks there
|
||||
# are more than 3 results in the actor list (meaning it fetched the detailed
|
||||
# information behind the scenes).
|
||||
result = plex.search(CONFIG.movie_title)[0]
|
||||
actors = result.actors
|
||||
assert len(actors) >= 4, 'Unable to fetch detailed movie information'
|
||||
log(2, '%s actors found.' % len(actors))
|
||||
|
||||
|
||||
@register()
|
||||
def test_stream_url(account, plex):
|
||||
movie = plex.library.section(CONFIG.movie_section).get(CONFIG.movie_title)
|
||||
episode = plex.library.section(CONFIG.show_section).get(CONFIG.show_title).episodes()[-1]
|
||||
track = plex.library.section(CONFIG.audio_section).get(CONFIG.audio_artist).get(CONFIG.audio_track)
|
||||
log(2, 'Movie: vlc "%s"' % movie.getStreamURL())
|
||||
log(2, 'Episode: vlc "%s"' % episode.getStreamURL())
|
||||
log(2, 'Track: cvlc "%s"' % track.getStreamURL())
|
||||
|
||||
|
||||
@register()
|
||||
def test_list_audioalbums(account, plex):
|
||||
music = plex.library.section(CONFIG.audio_section)
|
||||
albums = music.albums()
|
||||
for album in albums[:10]:
|
||||
log(2, '%s - %s [%s]' % (album.artist().title, album.title, album.year))
|
||||
|
||||
|
||||
@register()
|
||||
def test_list_photoalbums(account, plex):
|
||||
photosection = plex.library.section(CONFIG.photo_section)
|
||||
photoalbums = photosection.all()
|
||||
log(2, 'Listing albums..')
|
||||
for album in photoalbums[:10]:
|
||||
log(4, '%s' % album.title)
|
||||
assert len(photoalbums), 'No photoalbums found.'
|
||||
album = photosection.get(CONFIG.photo_album)
|
||||
photos = album.photos()
|
||||
for photo in photos[:10]:
|
||||
filename = basename(photo.media[0].parts[0].file)
|
||||
width, height = photo.media[0].width, photo.media[0].height
|
||||
log(4, '%s (%sx%s)' % (filename, width, height))
|
||||
assert len(photoalbums), 'No photos found.'
|
72
tests-old/test_myplex.py
Normal file
72
tests-old/test_myplex.py
Normal file
|
@ -0,0 +1,72 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from utils import log, register
|
||||
from plexapi import CONFIG
|
||||
|
||||
|
||||
@register()
|
||||
def test_myplex_accounts(account, plex):
|
||||
assert account, 'Must specify username, password & resource to run this test.'
|
||||
log(2, 'MyPlexAccount:')
|
||||
log(4, 'username: %s' % account.username)
|
||||
log(4, 'authenticationToken: %s' % account.authenticationToken)
|
||||
log(4, 'email: %s' % account.email)
|
||||
log(4, 'home: %s' % account.home)
|
||||
log(4, '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()
|
||||
log(2, 'Local PlexServer.account():')
|
||||
log(4, 'username: %s' % account.username)
|
||||
log(4, 'authToken: %s' % account.authToken)
|
||||
log(4, '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'
|
||||
|
||||
|
||||
@register()
|
||||
def test_myplex_resources(account, plex):
|
||||
assert account, 'Must specify username, password & resource to run this test.'
|
||||
resources = account.resources()
|
||||
for resource in resources:
|
||||
name = resource.name or 'Unknown'
|
||||
connections = [c.uri for c in resource.connections]
|
||||
connections = ', '.join(connections) if connections else 'None'
|
||||
log(2, '%s (%s): %s' % (name, resource.product, connections))
|
||||
assert resources, 'No resources found for account: %s' % account.name
|
||||
|
||||
|
||||
@register()
|
||||
def test_myplex_devices(account, plex):
|
||||
assert account, 'Must specify username, password & resource to run this test.'
|
||||
devices = account.devices()
|
||||
for device in devices:
|
||||
name = device.name or 'Unknown'
|
||||
connections = ', '.join(device.connections) if device.connections else 'None'
|
||||
log(2, '%s (%s): %s' % (name, device.product, connections))
|
||||
assert devices, 'No devices found for account: %s' % account.name
|
||||
|
||||
|
||||
@register()
|
||||
def test_myplex_users(account, plex):
|
||||
users = account.users()
|
||||
assert users, 'Found no users on account: %s' % account.name
|
||||
log(2, 'Found %s users.' % len(users))
|
||||
user = account.user('sdfsdfplex')
|
||||
log(2, 'Found user: %s' % user)
|
||||
assert users, 'Could not find user sdfsdfplex'
|
||||
|
||||
|
||||
@register()
|
||||
def test_myplex_connect_to_device(account, plex):
|
||||
assert account, 'Must specify username, password & resource to run this test.'
|
||||
devices = account.devices()
|
||||
for device in devices:
|
||||
if device.name == CONFIG.client and len(device.connections):
|
||||
break
|
||||
client = device.connect()
|
||||
log(2, 'Connected to client: %s (%s)' % (client.title, client.product))
|
||||
assert client, 'Unable to connect to device'
|
70
tests-old/test_navigation.py
Normal file
70
tests-old/test_navigation.py
Normal file
|
@ -0,0 +1,70 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from utils import log, register
|
||||
from plexapi import CONFIG
|
||||
|
||||
|
||||
# TODO: test_navigation/test_navigate_to_movie
|
||||
# FAIL: (500) internal_server_error
|
||||
# @register()
|
||||
def test_navigate_to_movie(account, plex):
|
||||
result_library = plex.library.get(CONFIG.movie_title)
|
||||
result_movies = plex.library.section(CONFIG.movie_section).get(CONFIG.movie_title)
|
||||
log(2, 'Navigating to: %s' % CONFIG.movie_title)
|
||||
log(2, 'Result Library: %s' % result_library)
|
||||
log(2, 'Result Movies: %s' % result_movies)
|
||||
assert result_movies, 'Movie navigation not working.'
|
||||
assert result_library == result_movies, 'Movie navigation not consistent.'
|
||||
|
||||
|
||||
@register()
|
||||
def test_navigate_to_show(account, plex):
|
||||
result_shows = plex.library.section(CONFIG.show_section).get(CONFIG.show_title)
|
||||
log(2, 'Navigating to: %s' % CONFIG.show_title)
|
||||
log(2, 'Result Shows: %s' % result_shows)
|
||||
assert result_shows, 'Show navigation not working.'
|
||||
|
||||
|
||||
# TODO: Fix test_navigation/test_navigate_around_show
|
||||
# FAIL: Unable to list season: Season 1
|
||||
# @register()
|
||||
def test_navigate_around_show(account, plex):
|
||||
show = plex.library.section(CONFIG.show_section).get(CONFIG.show_title)
|
||||
seasons = show.seasons()
|
||||
season = show.season(CONFIG.show_season)
|
||||
episodes = show.episodes()
|
||||
episode = show.episode(CONFIG.show_episode)
|
||||
log(2, 'Navigating around show: %s' % show)
|
||||
log(2, 'Seasons: %s...' % seasons[:3])
|
||||
log(2, 'Season: %s' % season)
|
||||
log(2, 'Episodes: %s...' % episodes[:3])
|
||||
log(2, 'Episode: %s' % episode)
|
||||
assert CONFIG.show_season in [s.title for s in seasons], 'Unable to list season: %s' % CONFIG.show_season
|
||||
assert CONFIG.show_episode in [e.title for e in episodes], 'Unable to list episode: %s' % CONFIG.show_episode
|
||||
assert show.season(CONFIG.show_season) == season, 'Unable to get show season: %s' % CONFIG.show_season
|
||||
assert show.episode(CONFIG.show_episode) == episode, 'Unable to get show episode: %s' % CONFIG.show_episode
|
||||
assert season.episode(CONFIG.show_episode) == episode, 'Unable to get season episode: %s' % CONFIG.show_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.'
|
||||
|
||||
|
||||
@register()
|
||||
def test_navigate_around_artist(account, plex):
|
||||
artist = plex.library.section(CONFIG.audio_section).get(CONFIG.audio_artist)
|
||||
albums = artist.albums()
|
||||
album = artist.album(CONFIG.audio_album)
|
||||
tracks = artist.tracks()
|
||||
track = artist.track(CONFIG.audio_track)
|
||||
log(2, 'Navigating around artist: %s' % artist)
|
||||
log(2, 'Albums: %s...' % albums[:3])
|
||||
log(2, 'Album: %s' % album)
|
||||
log(2, 'Tracks: %s...' % tracks[:3])
|
||||
log(2, 'Track: %s' % track)
|
||||
assert CONFIG.audio_album in [a.title for a in albums], 'Unable to list album: %s' % CONFIG.audio_album
|
||||
assert CONFIG.audio_track in [e.title for e in tracks], 'Unable to list track: %s' % CONFIG.audio_track
|
||||
assert artist.album(CONFIG.audio_album) == album, 'Unable to get artist album: %s' % CONFIG.audio_album
|
||||
assert artist.track(CONFIG.audio_track) == track, 'Unable to get artist track: %s' % CONFIG.audio_track
|
||||
assert album.track(CONFIG.audio_track) == track, 'Unable to get album track: %s' % CONFIG.audio_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.'
|
119
tests-old/test_playlists.py
Normal file
119
tests-old/test_playlists.py
Normal file
|
@ -0,0 +1,119 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import time
|
||||
from utils import log, register, getclient
|
||||
from plexapi import CONFIG
|
||||
|
||||
|
||||
@register()
|
||||
def test_list_playlists(account, plex):
|
||||
playlists = plex.playlists()
|
||||
for playlist in playlists:
|
||||
log(2, playlist.title)
|
||||
|
||||
|
||||
# TODO: Fix test_playlists/test_create_playlist
|
||||
# FAIL: (500) internal_server_error
|
||||
# @register()
|
||||
def test_create_playlist(account, plex):
|
||||
# create the playlist
|
||||
title = 'test_create_playlist'
|
||||
log(2, 'Creating playlist %s..' % title)
|
||||
episodes = plex.library.section(CONFIG.show_section).get(CONFIG.show_title).episodes()
|
||||
playlist = plex.createPlaylist(title, episodes[:3])
|
||||
try:
|
||||
items = playlist.items()
|
||||
log(4, 'Title: %s' % playlist.title)
|
||||
log(4, 'Items: %s' % items)
|
||||
log(4, 'Duration: %s min' % int(playlist.duration / 60000.0))
|
||||
assert playlist.title == title, 'Playlist not created successfully.'
|
||||
assert len(items) == 3, 'Playlist does not contain 3 items.'
|
||||
assert items[0].ratingKey == episodes[0].ratingKey, 'Items not in proper order [0a].'
|
||||
assert items[1].ratingKey == episodes[1].ratingKey, 'Items not in proper order [1a].'
|
||||
assert items[2].ratingKey == episodes[2].ratingKey, 'Items not in proper order [2a].'
|
||||
# move items around (b)
|
||||
log(2, 'Testing move items..')
|
||||
playlist.moveItem(items[1])
|
||||
items = playlist.items()
|
||||
assert items[0].ratingKey == episodes[1].ratingKey, 'Items not in proper order [0b].'
|
||||
assert items[1].ratingKey == episodes[0].ratingKey, 'Items not in proper order [1b].'
|
||||
assert items[2].ratingKey == episodes[2].ratingKey, 'Items not in proper order [2b].'
|
||||
# move items around (c)
|
||||
playlist.moveItem(items[0], items[1])
|
||||
items = playlist.items()
|
||||
assert items[0].ratingKey == episodes[0].ratingKey, 'Items not in proper order [0c].'
|
||||
assert items[1].ratingKey == episodes[1].ratingKey, 'Items not in proper order [1c].'
|
||||
assert items[2].ratingKey == episodes[2].ratingKey, 'Items not in proper order [2c].'
|
||||
# add an item
|
||||
log(2, 'Testing add item: %s' % episodes[3])
|
||||
playlist.addItems(episodes[3])
|
||||
items = playlist.items()
|
||||
log(4, '4th Item: %s' % items[3])
|
||||
assert items[3].ratingKey == episodes[3].ratingKey, 'Missing added item: %s' % episodes[3]
|
||||
# add two items
|
||||
log(2, 'Testing add item: %s' % episodes[4:6])
|
||||
playlist.addItems(episodes[4:6])
|
||||
items = playlist.items()
|
||||
log(4, '5th+ Items: %s' % items[4:])
|
||||
assert items[4].ratingKey == episodes[4].ratingKey, 'Missing added item: %s' % episodes[4]
|
||||
assert items[5].ratingKey == episodes[5].ratingKey, 'Missing added item: %s' % episodes[5]
|
||||
assert len(items) == 6, 'Playlist should have 6 items, %s found' % len(items)
|
||||
# remove item
|
||||
toremove = items[3]
|
||||
log(2, 'Testing remove item: %s' % toremove)
|
||||
playlist.removeItem(toremove)
|
||||
items = playlist.items()
|
||||
assert toremove not in items, 'Removed item still in playlist: %s' % items[3]
|
||||
assert len(items) == 5, 'Playlist should have 5 items, %s found' % len(items)
|
||||
finally:
|
||||
playlist.delete()
|
||||
|
||||
|
||||
@register()
|
||||
def test_playlist(account, plex):
|
||||
client = getclient(CONFIG.client, CONFIG.client_baseurl, plex)
|
||||
artist = plex.library.section(CONFIG.audio_section).get(CONFIG.audio_artist)
|
||||
album = artist.album(CONFIG.audio_album)
|
||||
playlist = plex.createPlaylist('test_play_playlist', album)
|
||||
try:
|
||||
log(2, 'Playing playlist: %s' % playlist)
|
||||
client.playMedia(playlist); time.sleep(5)
|
||||
log(2, 'stop..')
|
||||
client.stop('music'); time.sleep(1)
|
||||
finally:
|
||||
playlist.delete()
|
||||
|
||||
|
||||
@register()
|
||||
def test_playlist_photos(account, plex):
|
||||
client = getclient('iphone-mike', CONFIG.client_baseurl, plex)
|
||||
photosection = plex.library.section(CONFIG.photo_section)
|
||||
album = photosection.get(CONFIG.photo_album)
|
||||
photos = album.photos()
|
||||
playlist = plex.createPlaylist('test_play_playlist2', photos)
|
||||
try:
|
||||
client.playMedia(playlist)
|
||||
for i in range(3):
|
||||
time.sleep(2)
|
||||
client.skipNext(mtype='photo')
|
||||
finally:
|
||||
playlist.delete()
|
||||
|
||||
|
||||
@register()
|
||||
def test_play_photos(account, plex):
|
||||
client = getclient('iphone-mike', CONFIG.client_baseurl, plex)
|
||||
photosection = plex.library.section(CONFIG.photo_section)
|
||||
album = photosection.get(CONFIG.photo_album)
|
||||
photos = album.photos()
|
||||
for photo in photos[:4]:
|
||||
client.playMedia(photo)
|
||||
time.sleep(2)
|
||||
|
||||
|
||||
@register()
|
||||
def test_play_queues(account, plex):
|
||||
episode = plex.library.section(CONFIG.show_section).get(CONFIG.show_title).get(CONFIG.show_episode)
|
||||
playqueue = plex.createPlayQueue(episode)
|
||||
assert len(playqueue.items) == 1, 'No items in play queue.'
|
||||
assert playqueue.items[0].title == CONFIG.show_episode, 'Wrong show queued.'
|
||||
assert playqueue.playQueueID, 'Play queue ID not set.'
|
95
tests-old/test_search.py
Normal file
95
tests-old/test_search.py
Normal file
|
@ -0,0 +1,95 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from utils import log, register
|
||||
from plexapi import CONFIG
|
||||
|
||||
|
||||
@register()
|
||||
def test_search_show(account, plex):
|
||||
result_server = plex.search(CONFIG.show_title)
|
||||
result_shows = plex.library.section(CONFIG.show_section).search(CONFIG.show_title)
|
||||
result_movies = plex.library.section(CONFIG.movie_section).search(CONFIG.show_title)
|
||||
log(2, 'Searching for: %s' % CONFIG.show_title)
|
||||
log(4, 'Result Server: %s' % result_server)
|
||||
log(4, 'Result Shows: %s' % result_shows)
|
||||
log(4, 'Result Movies: %s' % result_movies)
|
||||
assert result_server, 'Show not found.'
|
||||
assert result_server == result_shows, 'Show searches not consistent.'
|
||||
assert not result_movies, 'Movie search returned show title.'
|
||||
|
||||
|
||||
@register()
|
||||
def test_search_with_apostrophe(account, plex):
|
||||
show_title = "Marvel's Daredevil" # Test ' in show title
|
||||
result_server = plex.search(show_title)
|
||||
result_shows = plex.library.section(CONFIG.show_section).search(show_title)
|
||||
log(2, 'Searching for: %s' % CONFIG.show_title)
|
||||
log(4, 'Result Server: %s' % result_server)
|
||||
log(4, 'Result Shows: %s' % result_shows)
|
||||
assert result_server, 'Show not found.'
|
||||
assert result_server == result_shows, 'Show searches not consistent.'
|
||||
|
||||
|
||||
@register()
|
||||
def test_search_movie(account, plex):
|
||||
result_server = plex.search(CONFIG.movie_title)
|
||||
result_library = plex.library.search(CONFIG.movie_title)
|
||||
result_shows = plex.library.section(CONFIG.show_section).search(CONFIG.movie_title)
|
||||
result_movies = plex.library.section(CONFIG.movie_section).search(CONFIG.movie_title)
|
||||
log(2, 'Searching for: %s' % CONFIG.movie_title)
|
||||
log(4, 'Result Server: %s' % result_server)
|
||||
log(4, 'Result Library: %s' % result_library)
|
||||
log(4, 'Result Shows: %s' % result_shows)
|
||||
log(4, 'Result Movies: %s' % result_movies)
|
||||
assert result_server, 'Movie not found.'
|
||||
assert result_server == result_library == result_movies, 'Movie searches not consistent.'
|
||||
assert not result_shows, 'Show search returned show title.'
|
||||
|
||||
|
||||
@register()
|
||||
def test_search_audio(account, plex):
|
||||
result_server = plex.search(CONFIG.audio_artist)
|
||||
result_library = plex.library.search(CONFIG.audio_artist)
|
||||
result_music = plex.library.section(CONFIG.audio_section).search(CONFIG.audio_artist)
|
||||
log(2, 'Searching for: %s' % CONFIG.audio_artist)
|
||||
log(4, 'Result Server: %s' % result_server)
|
||||
log(4, 'Result Library: %s' % result_library)
|
||||
log(4, 'Result Music: %s' % result_music)
|
||||
assert result_server, 'Artist not found.'
|
||||
assert result_server == result_library == result_music, 'Audio searches not consistent.'
|
||||
|
||||
|
||||
@register()
|
||||
def test_search_related(account, plex):
|
||||
movies = plex.library.section(CONFIG.movie_section)
|
||||
movie = movies.get(CONFIG.movie_title)
|
||||
related_by_actors = movies.search(actor=movie.actors, maxresults=3)
|
||||
log(2, u'Actors: %s..' % movie.actors)
|
||||
log(2, u'Related by Actors: %s..' % related_by_actors)
|
||||
assert related_by_actors, 'No related movies found by actor.'
|
||||
related_by_genre = movies.search(genre=movie.genres, maxresults=3)
|
||||
log(2, u'Genres: %s..' % movie.genres)
|
||||
log(2, u'Related by Genre: %s..' % related_by_genre)
|
||||
assert related_by_genre, 'No related movies found by genre.'
|
||||
related_by_director = movies.search(director=movie.directors, maxresults=3)
|
||||
log(2, 'Directors: %s..' % movie.directors)
|
||||
log(2, 'Related by Director: %s..' % related_by_director)
|
||||
assert related_by_director, 'No related movies found by director.'
|
||||
|
||||
|
||||
# TODO: Fix test_search/test_crazy_search
|
||||
# FAIL: Unable to search movie by director.
|
||||
# @register()
|
||||
def test_crazy_search(account, plex):
|
||||
movies = plex.library.section(CONFIG.movie_section)
|
||||
movie = movies.get('Jurassic World')
|
||||
log(2, u'Search by Actor: "Chris Pratt"')
|
||||
assert movie in movies.search(actor='Chris Pratt'), 'Unable to search movie by actor.'
|
||||
log(2, u'Search by Director: ["Trevorrow"]')
|
||||
assert movie in movies.search(director=['Trevorrow']), 'Unable to search movie by director.'
|
||||
log(2, u'Search by Year: ["2014", "2015"]')
|
||||
assert movie in movies.search(year=['2014', '2015']), 'Unable to search movie by year.'
|
||||
log(2, u'Filter by Year: 2014')
|
||||
assert movie not in movies.search(year=2014), 'Unable to filter movie by year.'
|
||||
judy = [a for a in movie.actors if 'Judy' in a.tag][0]
|
||||
log(2, u'Search by Unpopular Actor: %s' % judy)
|
||||
assert movie in movies.search(actor=judy.id), 'Unable to filter movie by year.'
|
19
tests-old/test_sync.py
Normal file
19
tests-old/test_sync.py
Normal file
|
@ -0,0 +1,19 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from utils import log, register
|
||||
|
||||
|
||||
# TODO: Fix test_sync/test_sync_items
|
||||
# I don't know if this ever worked. It was contributed by the guy that added sync support.
|
||||
# @register()
|
||||
def test_sync_items(account, plex):
|
||||
device = account.getDevice('device-uuid')
|
||||
# fetch the sync items via the device sync list
|
||||
for item in device.sync_items():
|
||||
# fetch the media object associated with the sync item
|
||||
for video in item.get_media():
|
||||
# fetch the media parts (actual video/audio streams) associated with the media
|
||||
for part in video.iterParts():
|
||||
log(2, 'Found media to download!')
|
||||
# make the relevant sync id (media part) as downloaded
|
||||
# this tells the server that this device has successfully downloaded this media part of this sync item
|
||||
item.mark_as_done(part.sync_id)
|
42
tests-old/utils.py
Normal file
42
tests-old/utils.py
Normal file
|
@ -0,0 +1,42 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Test Library Functions
|
||||
"""
|
||||
import datetime, sys
|
||||
from plexapi.client import PlexClient
|
||||
from plexapi.exceptions import NotFound
|
||||
|
||||
REGISTERED = []
|
||||
COLORS = {'blue':'\033[94m', 'green':'\033[92m', 'red':'\033[91m', 'yellow':'\033[93m', 'end':'\033[0m'}
|
||||
|
||||
|
||||
def register():
|
||||
def wrapper(func):
|
||||
name = '%s/%s' % (func.__module__, func.__name__)
|
||||
REGISTERED.append({'func':func, 'name':name})
|
||||
return lambda plex, account: func(plex, account)
|
||||
return wrapper
|
||||
|
||||
|
||||
def itertests(query):
|
||||
for test in REGISTERED:
|
||||
if not query:
|
||||
yield test
|
||||
elif query in test['name']:
|
||||
yield test
|
||||
|
||||
|
||||
def log(indent, message, color=None):
|
||||
dt = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]
|
||||
if color:
|
||||
return sys.stdout.write('%s: %s%s%s%s\n' % (dt, ' '*indent, COLORS[color], message, COLORS['end']))
|
||||
return sys.stdout.write('%s: %s%s\n' % (dt, ' '*indent, message))
|
||||
|
||||
|
||||
def getclient(name, baseurl, server):
|
||||
try:
|
||||
return server.client(name)
|
||||
except NotFound as err:
|
||||
log(2, 'Warning: %s' % err)
|
||||
return PlexClient(baseurl, server=server)
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import sys
|
||||
from os.path import dirname, abspath
|
||||
|
||||
# Make sure plexapi is in the systempath
|
||||
sys.path.insert(0, dirname(dirname(abspath(__file__))))
|
148
tests/conftest.py
Normal file
148
tests/conftest.py
Normal file
|
@ -0,0 +1,148 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import betamax, os, plexapi
|
||||
import pytest, requests
|
||||
from betamax_serializers import pretty_json
|
||||
from functools import partial
|
||||
|
||||
token = os.environ.get('PLEX_TOKEN')
|
||||
test_token = os.environ.get('PLEX_TEST_TOKEN')
|
||||
test_username = os.environ.get('PLEX_TEST_USERNAME')
|
||||
test_password = os.environ.get('PLEX_TEST_PASSWORD')
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def pms(request):
|
||||
from plexapi.server import PlexServer
|
||||
sess = requests.Session()
|
||||
# CASSETTE_LIBRARY_DIR = 'response/'
|
||||
# betamax.Betamax.register_serializer(pretty_json.PrettyJSONSerializer)
|
||||
# config = betamax.Betamax.configure()
|
||||
# config.define_cassette_placeholder('MASKED', token)
|
||||
# config.define_cassette_placeholder('MASKED', test_token)
|
||||
# recorder = betamax.Betamax(sess, cassette_library_dir=CASSETTE_LIBRARY_DIR)
|
||||
# recorder.use_cassette('http_responses', serialize_with='prettyjson') # record='new_episodes'
|
||||
# recorder.start()
|
||||
url = 'http://138.68.157.5:32400'
|
||||
assert test_token
|
||||
assert url
|
||||
pms = PlexServer(url, test_token, session=sess)
|
||||
#request.addfinalizer(recorder.stop)
|
||||
return pms
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def freshpms():
|
||||
from plexapi.server import PlexServer
|
||||
sess = requests.Session()
|
||||
url = 'http://138.68.157.5:32400'
|
||||
assert test_token
|
||||
assert url
|
||||
pms = PlexServer(url, test_token, session=sess)
|
||||
return pms
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
parser.addoption("--req_client", action="store_true",
|
||||
help="Run tests that interact with a client")
|
||||
|
||||
|
||||
def pytest_runtest_setup(item):
|
||||
if 'req_client' in item.keywords and not item.config.getvalue("req_client"):
|
||||
pytest.skip("need --req_client option to run")
|
||||
else:
|
||||
item.config.getvalue("req_client")
|
||||
|
||||
@pytest.fixture()
|
||||
def plex_account():
|
||||
from plexapi.myplex import MyPlexAccount
|
||||
username = test_username
|
||||
password = test_password
|
||||
assert username and password
|
||||
account = MyPlexAccount.signin(username, password)
|
||||
assert account
|
||||
return account
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def a_movie(pms):
|
||||
m = pms.library.search('16 blocks')
|
||||
assert m
|
||||
return m[0]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def a_tv_section(pms):
|
||||
sec = pms.library.section('TV Shows')
|
||||
assert sec
|
||||
return sec
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def a_movie_section(pms):
|
||||
sec = pms.library.section('Movies')
|
||||
assert sec
|
||||
return sec
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def a_music_section(pms):
|
||||
sec = pms.library.section('Music')
|
||||
assert sec
|
||||
return sec
|
||||
|
||||
@pytest.fixture()
|
||||
def a_photo_section(pms):
|
||||
sec = pms.library.section('Photos')
|
||||
assert sec
|
||||
return sec
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def a_artist(a_music_section):
|
||||
sec = a_music_section.get('Infinite State')
|
||||
assert sec
|
||||
return sec
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def a_music_album(a_music_section):
|
||||
sec = a_music_section.get('Infinite State').album('Unmastered Impulses')
|
||||
assert sec
|
||||
return sec
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def a_track(a_music_album):
|
||||
track = a_music_album.track('Holy Moment')
|
||||
assert track
|
||||
return track
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def a_show(a_tv_section):
|
||||
sec = a_tv_section.get('The 100')
|
||||
assert sec
|
||||
return sec
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def a_episode(a_show):
|
||||
ep = a_show.get('Pilot')
|
||||
assert ep
|
||||
return ep
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def a_photo_album(pms):
|
||||
sec = pms.library.section('Photos')
|
||||
assert sec
|
||||
album = sec.get('photo_album1')
|
||||
assert album
|
||||
return album
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def monkeydownload(request, monkeypatch):
|
||||
monkeypatch.setattr('plexapi.utils.download', partial(plexapi.utils.download, mocked=True))
|
||||
yield
|
||||
monkeypatch.undo()
|
22
tests/test_actions.py
Normal file
22
tests/test_actions.py
Normal file
|
@ -0,0 +1,22 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
def test_mark_movie_watched(a_movie):
|
||||
a_movie.markUnwatched()
|
||||
print('Marking movie watched: %s' % a_movie)
|
||||
print('View count: %s' % a_movie.viewCount)
|
||||
a_movie.markWatched()
|
||||
print('View count: %s' % a_movie.viewCount)
|
||||
assert a_movie.viewCount == 1, 'View count 0 after watched.'
|
||||
a_movie.markUnwatched()
|
||||
print('View count: %s' % a_movie.viewCount)
|
||||
assert a_movie.viewCount == 0, 'View count 1 after unwatched.'
|
||||
|
||||
|
||||
def test_refresh_section(pms):
|
||||
shows = pms.library.section('TV Shows')
|
||||
#shows.refresh()
|
||||
|
||||
|
||||
def test_refresh_video(pms):
|
||||
result = pms.search('16 blocks')
|
||||
#result[0].refresh()
|
307
tests/test_audio.py
Normal file
307
tests/test_audio.py
Normal file
|
@ -0,0 +1,307 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
def test_audio_Artist_attr(a_artist):
|
||||
m = a_artist
|
||||
m.reload()
|
||||
assert str(m.addedAt.date()) == '2017-01-17'
|
||||
assert m.countries == []
|
||||
assert [i.tag for i in m.genres] == ['Electronic']
|
||||
assert m.guid == 'com.plexapp.agents.lastfm://Infinite%20State?lang=en'
|
||||
assert m.index == '1'
|
||||
assert m.initpath == '/library/metadata/20'
|
||||
assert m.key == '/library/metadata/20'
|
||||
assert m.librarySectionID == '3'
|
||||
assert m.listType == 'audio'
|
||||
assert m.location == '/media/music/unmastered_impulses'
|
||||
assert m.ratingKey == 20
|
||||
assert m.server.baseurl == 'http://138.68.157.5:32400'
|
||||
assert m.similar == []
|
||||
assert m.summary == ""
|
||||
assert m.title == 'Infinite State'
|
||||
assert m.titleSort == 'Infinite State'
|
||||
assert m.type == 'artist'
|
||||
assert str(m.updatedAt.date()) == '2017-02-02'
|
||||
assert m.viewCount == 0
|
||||
|
||||
|
||||
def test_audio_Artist_get(a_artist, a_music_section):
|
||||
a_artist == a_music_section.searchArtists(**{'title': 'Infinite State'})[0]
|
||||
a_artist.title == 'Infinite State'
|
||||
|
||||
|
||||
def test_audio_Artist_track(a_artist):
|
||||
track = a_artist.track('Holy Moment')
|
||||
assert track.title == 'Holy Moment'
|
||||
|
||||
|
||||
def test_audio_Artist_tracks(a_artist):
|
||||
tracks = a_artist.tracks()
|
||||
assert len(tracks) == 14
|
||||
|
||||
|
||||
def test_audio_Artist_album(a_artist):
|
||||
album = a_artist.album('Unmastered Impulses')
|
||||
assert album.title == 'Unmastered Impulses'
|
||||
|
||||
|
||||
def test_audio_Artist_albums(a_artist):
|
||||
albums = a_artist.albums()
|
||||
assert len(albums) == 1 and albums[0].title == 'Unmastered Impulses'
|
||||
|
||||
|
||||
def test_audio_Album_attrs(a_music_album):
|
||||
m = a_music_album
|
||||
assert str(m.addedAt.date()) == '2017-01-17'
|
||||
assert [i.tag for i in m.genres] == ['Electronic']
|
||||
assert m.index == '1'
|
||||
assert m.initpath == '/library/metadata/21'
|
||||
assert m.key == '/library/metadata/21'
|
||||
assert m.librarySectionID == '3'
|
||||
assert m.listType == 'audio'
|
||||
assert str(m.originallyAvailableAt.date()) == '2016-01-01'
|
||||
assert m.parentKey == '/library/metadata/20'
|
||||
assert m.parentRatingKey == '20'
|
||||
assert str(m.parentThumb) == '__NA__'
|
||||
assert m.parentTitle == 'Infinite State'
|
||||
assert m.ratingKey == 21
|
||||
assert m.server.baseurl == 'http://138.68.157.5:32400'
|
||||
assert str(m.studio) == '__NA__'
|
||||
assert m.summary == ''
|
||||
assert m.thumb == '/library/metadata/21/thumb/1484693407'
|
||||
assert m.title == 'Unmastered Impulses'
|
||||
assert m.titleSort == 'Unmastered Impulses'
|
||||
assert m.type == 'album'
|
||||
assert str(m.updatedAt.date()) == '2017-01-17'
|
||||
assert m.viewCount == 0
|
||||
assert m.year == 2016
|
||||
|
||||
|
||||
def test_audio_Album_tracks(a_music_album):
|
||||
tracks = a_music_album.tracks()
|
||||
assert len(tracks) == 14
|
||||
assert tracks[0].grandparentKey == '/library/metadata/20'
|
||||
assert tracks[0].grandparentRatingKey == '20'
|
||||
assert tracks[0].grandparentTitle == 'Infinite State'
|
||||
assert tracks[0].index == '1'
|
||||
assert tracks[0].initpath == '/library/metadata/21/children'
|
||||
assert tracks[0].key == '/library/metadata/22'
|
||||
assert tracks[0].listType == 'audio'
|
||||
assert tracks[0].originalTitle == 'Kenneth Reitz'
|
||||
assert tracks[0].parentIndex == '1'
|
||||
assert tracks[0].parentKey == '/library/metadata/21'
|
||||
assert tracks[0].parentRatingKey == '21'
|
||||
assert tracks[0].parentThumb == '/library/metadata/21/thumb/1484693407'
|
||||
assert tracks[0].parentTitle == 'Unmastered Impulses'
|
||||
assert tracks[0].player is None
|
||||
assert tracks[0].ratingCount == 9
|
||||
assert tracks[0].ratingKey == 22
|
||||
assert tracks[0].server.baseurl == 'http://138.68.157.5:32400'
|
||||
assert tracks[0].summary == ""
|
||||
assert tracks[0].thumb == '/library/metadata/21/thumb/1484693407'
|
||||
assert tracks[0].title == 'Holy Moment'
|
||||
assert tracks[0].titleSort == 'Holy Moment'
|
||||
assert tracks[0].transcodeSession is None
|
||||
assert tracks[0].type == 'track'
|
||||
assert str(tracks[0].updatedAt.date()) == '2017-01-17'
|
||||
assert tracks[0].username is None
|
||||
assert tracks[0].viewCount == 0
|
||||
assert tracks[0].viewOffset == 0
|
||||
|
||||
|
||||
def test_audio_Album_track(a_music_album):
|
||||
# this is not reloaded. its not that much info missing.
|
||||
track = a_music_album.track('Holy Moment')
|
||||
assert str(track.addedAt.date()) == '2017-01-17'
|
||||
assert track.duration == 298606
|
||||
assert track.grandparentKey == '/library/metadata/20'
|
||||
assert track.grandparentRatingKey == '20'
|
||||
assert track.grandparentTitle == 'Infinite State'
|
||||
assert track.index == '1'
|
||||
assert track.initpath == '/library/metadata/21/children'
|
||||
assert track.key == '/library/metadata/22'
|
||||
assert track.listType == 'audio'
|
||||
# Assign 0 track.media
|
||||
med0 = track.media[0]
|
||||
assert track.originalTitle == 'Kenneth Reitz'
|
||||
assert track.parentIndex == '1'
|
||||
assert track.parentKey == '/library/metadata/21'
|
||||
assert track.parentRatingKey == '21'
|
||||
assert track.parentThumb == '/library/metadata/21/thumb/1484693407'
|
||||
assert track.parentTitle == 'Unmastered Impulses'
|
||||
assert track.player is None
|
||||
assert track.ratingCount == 9
|
||||
assert track.ratingKey == 22
|
||||
assert track.server.baseurl == 'http://138.68.157.5:32400'
|
||||
assert track.summary == ''
|
||||
assert track.thumb == '/library/metadata/21/thumb/1484693407'
|
||||
assert track.title == 'Holy Moment'
|
||||
assert track.titleSort == 'Holy Moment'
|
||||
assert track.transcodeSession is None
|
||||
assert track.type == 'track'
|
||||
assert str(track.updatedAt.date()) == '2017-01-17'
|
||||
assert track.username is None
|
||||
assert track.viewCount == 0
|
||||
assert track.viewOffset == 0
|
||||
assert med0.aspectRatio is None
|
||||
assert med0.audioChannels == 2
|
||||
assert med0.audioCodec == 'mp3'
|
||||
assert med0.bitrate == 385
|
||||
assert med0.container == 'mp3'
|
||||
assert med0.duration == 298606
|
||||
assert med0.height is None
|
||||
assert med0.id == 22
|
||||
assert med0.initpath == '/library/metadata/21/children'
|
||||
assert med0.optimizedForStreaming is None
|
||||
# Assign 0 med0.parts
|
||||
par0 = med0.parts[0]
|
||||
assert med0.server.baseurl == 'http://138.68.157.5:32400'
|
||||
assert med0.videoCodec is None
|
||||
assert med0.videoFrameRate is None
|
||||
assert med0.videoResolution is None
|
||||
assert med0.width is None
|
||||
assert par0.container == 'mp3'
|
||||
assert par0.duration == 298606
|
||||
assert par0.file == '/media/music/unmastered_impulses/01-Holy_Moment.mp3'
|
||||
assert par0.id == 22
|
||||
assert par0.initpath == '/library/metadata/21/children'
|
||||
assert par0.key == '/library/parts/22/1484693136/file.mp3'
|
||||
assert par0.server.baseurl == 'http://138.68.157.5:32400'
|
||||
assert par0.size == 14360402
|
||||
|
||||
|
||||
def test_audio_Album_get():
|
||||
""" Just a alias for track(); skip it. """
|
||||
pass
|
||||
|
||||
|
||||
def test_audio_Album_artist(a_music_album):
|
||||
artist = a_music_album.artist()
|
||||
artist.title == 'Infinite State'
|
||||
|
||||
|
||||
def test_audio_Track_attrs(a_music_album):
|
||||
track = a_music_album.get('Holy Moment')
|
||||
track.reload()
|
||||
assert str(track.addedAt.date()) == '2017-01-17'
|
||||
assert str(track.art) == '__NA__'
|
||||
assert str(track.chapterSource) == '__NA__'
|
||||
assert track.duration == 298606
|
||||
assert str(track.grandparentArt) == '__NA__'
|
||||
assert track.grandparentKey == '/library/metadata/20'
|
||||
assert track.grandparentRatingKey == '20'
|
||||
assert str(track.grandparentThumb) == '__NA__'
|
||||
assert track.grandparentTitle == 'Infinite State'
|
||||
assert track.guid == 'local://22'
|
||||
assert track.index == '1'
|
||||
assert track.initpath == '/library/metadata/22'
|
||||
assert track.key == '/library/metadata/22'
|
||||
assert str(track.lastViewedAt) == '__NA__'
|
||||
assert track.librarySectionID == '3'
|
||||
assert track.listType == 'audio'
|
||||
# Assign 0 track.media
|
||||
med0 = track.media[0]
|
||||
assert track.moods == []
|
||||
assert track.originalTitle == 'Kenneth Reitz'
|
||||
assert track.parentIndex == '1'
|
||||
assert track.parentKey == '/library/metadata/21'
|
||||
assert track.parentRatingKey == '21'
|
||||
assert track.parentThumb == '/library/metadata/21/thumb/1484693407'
|
||||
assert track.parentTitle == 'Unmastered Impulses'
|
||||
assert track.player is None
|
||||
assert str(track.playlistItemID) == '__NA__'
|
||||
assert str(track.primaryExtraKey) == '__NA__'
|
||||
assert track.ratingCount == 9
|
||||
assert track.ratingKey == 22
|
||||
assert track.server.baseurl == 'http://138.68.157.5:32400'
|
||||
assert str(track.sessionKey) == '__NA__'
|
||||
assert track.summary == ''
|
||||
assert track.thumb == '/library/metadata/21/thumb/1484693407'
|
||||
assert track.title == 'Holy Moment'
|
||||
assert track.titleSort == 'Holy Moment'
|
||||
assert track.transcodeSession is None
|
||||
assert track.type == 'track'
|
||||
assert str(track.updatedAt.date()) == '2017-01-17'
|
||||
assert track.username is None
|
||||
assert track.viewCount == 0
|
||||
assert track.viewOffset == 0
|
||||
assert str(track.viewedAt) == '__NA__'
|
||||
assert str(track.year) == '__NA__'
|
||||
assert med0.aspectRatio is None
|
||||
assert med0.audioChannels == 2
|
||||
assert med0.audioCodec == 'mp3'
|
||||
assert med0.bitrate == 385
|
||||
assert med0.container == 'mp3'
|
||||
assert med0.duration == 298606
|
||||
assert med0.height is None
|
||||
assert med0.id == 22
|
||||
assert med0.initpath == '/library/metadata/22'
|
||||
assert med0.optimizedForStreaming is None
|
||||
# Assign 0 med0.parts
|
||||
par0 = med0.parts[0]
|
||||
assert med0.server.baseurl == 'http://138.68.157.5:32400'
|
||||
assert med0.videoCodec is None
|
||||
assert med0.videoFrameRate is None
|
||||
assert med0.videoResolution is None
|
||||
assert med0.width is None
|
||||
assert par0.container == 'mp3'
|
||||
assert par0.duration == 298606
|
||||
assert par0.file == '/media/music/unmastered_impulses/01-Holy_Moment.mp3'
|
||||
assert par0.id == 22
|
||||
assert par0.initpath == '/library/metadata/22'
|
||||
assert par0.key == '/library/parts/22/1484693136/file.mp3'
|
||||
#assert par0.media == <Media:Holy.Moment>
|
||||
assert par0.server.baseurl == 'http://138.68.157.5:32400'
|
||||
assert par0.size == 14360402
|
||||
# Assign 0 par0.streams
|
||||
str0 = par0.streams[0]
|
||||
assert str0.audioChannelLayout == 'stereo'
|
||||
assert str0.bitDepth is None
|
||||
assert str0.bitrate == 320
|
||||
assert str0.bitrateMode is None
|
||||
assert str0.channels == 2
|
||||
assert str0.codec == 'mp3'
|
||||
assert str0.codecID is None
|
||||
assert str0.dialogNorm is None
|
||||
assert str0.duration is None
|
||||
assert str0.id == 44
|
||||
assert str0.index == 0
|
||||
assert str0.initpath == '/library/metadata/22'
|
||||
assert str0.language is None
|
||||
assert str0.languageCode is None
|
||||
#assert str0.part == <MediaPart:22>
|
||||
assert str0.samplingRate == 44100
|
||||
assert str0.selected is True
|
||||
assert str0.server.baseurl == 'http://138.68.157.5:32400'
|
||||
assert str0.streamType == 2
|
||||
assert str0.title is None
|
||||
assert str0.type == 2
|
||||
|
||||
|
||||
def test_audio_Track_album(a_music_album):
|
||||
assert a_music_album.tracks()[0].album() == a_music_album
|
||||
|
||||
|
||||
def test_audio_Track_artist(a_music_album, a_artist):
|
||||
assert a_music_album.tracks()[0].artist() == a_artist
|
||||
|
||||
|
||||
def test_audio_Audio_section(a_artist, a_music_album, a_track):
|
||||
assert a_artist.section()
|
||||
assert a_music_album.section()
|
||||
assert a_track.section()
|
||||
assert a_track.section() == a_music_album.section() == a_artist.section()
|
||||
|
||||
|
||||
def test_audio_Track_download(monkeydownload, tmpdir, a_track):
|
||||
f = a_track.download(savepath=str(tmpdir))
|
||||
assert f
|
||||
|
||||
|
||||
def test_audio_album_download(monkeydownload, a_music_album, tmpdir):
|
||||
f = a_music_album.download(savepath=str(tmpdir))
|
||||
assert len(f) == 14
|
||||
|
||||
|
||||
def test_audio_Artist_download(monkeydownload, a_artist, tmpdir):
|
||||
f = a_artist.download(savepath=str(tmpdir))
|
||||
assert len(f) == 14
|
217
tests/test_client.py
Normal file
217
tests/test_client.py
Normal file
|
@ -0,0 +1,217 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def _test_client_PlexClient__loadData(pms):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def _test_client_PlexClient_connect(pms):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def _test_client_PlexClient_contextMenu(pms):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def _test_client_PlexClient_goBack(pms):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def _test_client_PlexClient_goToHome(pms):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def _test_client_PlexClient_goToMedia(pms):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def _test_client_PlexClient_goToMusic(pms):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def _test_client_PlexClient_headers(pms):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def _test_client_PlexClient_isPlayingMedia(pms):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def _test_client_PlexClient_moveDown(pms):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def _test_client_PlexClient_moveLeft(pms):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def _test_client_PlexClient_moveRight(pms):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def _test_client_PlexClient_moveUp(pms):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def _test_client_PlexClient_nextLetter(pms):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def _test_client_PlexClient_pageDown(pms):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def _test_client_PlexClient_pageUp(pms):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def _test_client_PlexClient_pause(pms):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def _test_client_PlexClient_play(pms):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def _test_client_PlexClient_playMedia(pms):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def _test_client_PlexClient_previousLetter(pms):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def _test_client_PlexClient_proxyThroughServer(pms):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def _test_client_PlexClient_query(pms):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def _test_client_PlexClient_refreshPlayQueue(pms):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def _test_client_PlexClient_seekTo(pms):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def _test_client_PlexClient_select(pms):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def _test_client_PlexClient_sendCommand(pms):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def _test_client_PlexClient_setAudioStream(pms):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def _test_client_PlexClient_setParameters(pms):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def _test_client_PlexClient_setRepeat(pms):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def _test_client_PlexClient_setShuffle(pms):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def _test_client_PlexClient_setStreams(pms):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def _test_client_PlexClient_setSubtitleStream(pms):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def _test_client_PlexClient_setVideoStream(pms):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def _test_client_PlexClient_setVolume(pms):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def _test_client_PlexClient_skipNext(pms):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def _test_client_PlexClient_skipPrevious(pms):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def _test_client_PlexClient_skipTo(pms):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def _test_client_PlexClient_stepBack(pms):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def _test_client_PlexClient_stepForward(pms):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def _test_client_PlexClient_stop(pms):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def _test_client_PlexClient_timeline(pms):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def _test_client_PlexClient_toggleOSD(pms):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def _test_client_PlexClient_url(pms):
|
||||
pass
|
161
tests/test_library.py
Normal file
161
tests/test_library.py
Normal file
|
@ -0,0 +1,161 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import pytest
|
||||
from plexapi.exceptions import NotFound
|
||||
|
||||
|
||||
def test_library_Library_section(pms):
|
||||
sections = pms.library.sections()
|
||||
assert len(sections) == 4
|
||||
lfs = 'TV Shows'
|
||||
section_name = pms.library.section(lfs)
|
||||
assert section_name.title == lfs
|
||||
with pytest.raises(NotFound):
|
||||
assert pms.library.section('gfdsas')
|
||||
|
||||
|
||||
def test_library_Library_sectionByID_is_equal_section(pms, freshpms):
|
||||
# test that sctionmyID refreshes the section if the key is missing
|
||||
# this is needed if there isnt any cached sections
|
||||
assert freshpms.library.sectionByID('1')
|
||||
assert pms.library.sectionByID('1').uuid == pms.library.section('Movies').uuid
|
||||
|
||||
|
||||
def test_library_sectionByID_with_attrs(pms):
|
||||
m = pms.library.sectionByID('1')
|
||||
assert m.agent == 'com.plexapp.agents.imdb'
|
||||
assert m.allowSync is False
|
||||
assert m.art == '/:/resources/movie-fanart.jpg'
|
||||
assert m.composite == '/library/sections/1/composite/1484690696'
|
||||
assert str(m.createdAt.date()) == '2017-01-17'
|
||||
assert m.filters == '1'
|
||||
assert m.initpath == '/library/sections'
|
||||
assert m.key == '1'
|
||||
assert m.language == 'en'
|
||||
assert m.locations == ['/media/movies']
|
||||
assert m.refreshing is False
|
||||
assert m.scanner == 'Plex Movie Scanner'
|
||||
assert m.server.baseurl == 'http://138.68.157.5:32400'
|
||||
assert m.thumb == '/:/resources/movie.png'
|
||||
assert m.title == 'Movies'
|
||||
assert m.type == 'movie'
|
||||
assert str(m.updatedAt.date()) == '2017-01-17'
|
||||
assert m.uuid == '2b72d593-3881-43f4-a8b8-db541bd3535a'
|
||||
|
||||
|
||||
def test_library_section_get_movie(pms): # fix me
|
||||
m = pms.library.section('Movies').get('16 blocks')
|
||||
assert m
|
||||
|
||||
|
||||
def test_library_getByKey(pms):
|
||||
m = pms.library.getByKey('1')
|
||||
assert m.title == '16 Blocks'
|
||||
|
||||
|
||||
def test_library_onDeck(pms):
|
||||
assert len(list(pms.library.onDeck()))
|
||||
|
||||
|
||||
def test_library_recentlyAdded(pms):
|
||||
assert len(list(pms.library.recentlyAdded()))
|
||||
|
||||
|
||||
def test_library_get(pms):
|
||||
m = pms.library.get('16 blocks')
|
||||
assert m.title == '16 Blocks'
|
||||
|
||||
|
||||
def test_library_Library_cleanBundle(pms):
|
||||
pms.library.cleanBundles()
|
||||
|
||||
|
||||
def test_library_Library_optimize(pms):
|
||||
pms.library.optimize()
|
||||
|
||||
|
||||
def test_library_Library_emptyTrash(pms):
|
||||
pms.library.emptyTrash()
|
||||
|
||||
|
||||
def _test_library_Library_refresh(pms):
|
||||
pms.library.refresh() # fix mangle and proof the sections attrs
|
||||
|
||||
|
||||
def _test_library_MovieSection_refresh(a_movie_section):
|
||||
a_movie_section.refresh()
|
||||
|
||||
|
||||
def test_library_MovieSection_onDeck(a_movie_section):
|
||||
assert len(a_movie_section.onDeck())
|
||||
|
||||
|
||||
def test_library_MovieSection_recentlyAdded(a_movie_section):
|
||||
assert len(a_movie_section.recentlyAdded())
|
||||
|
||||
|
||||
def test_library_MovieSection_analyze(a_movie_section):
|
||||
a_movie_section.analyze()
|
||||
|
||||
|
||||
def test_library_ShowSection_searchShows(a_tv_section):
|
||||
s = a_tv_section.searchShows(**{'title': 'The 100'})
|
||||
assert s
|
||||
|
||||
|
||||
def test_library_ShowSection_searchEpisodes(a_tv_section):
|
||||
s = a_tv_section.searchEpisodes(**{'title': 'Pilot'})
|
||||
assert s
|
||||
|
||||
|
||||
def test_library_ShowSection_recentlyAdded(a_tv_section):
|
||||
assert len(a_tv_section.recentlyAdded())
|
||||
|
||||
|
||||
def test_library_MusicSection_albums(a_music_section):
|
||||
assert len(a_music_section.albums())
|
||||
|
||||
|
||||
def test_library_MusicSection_searchTracks(a_music_section):
|
||||
assert len(a_music_section.searchTracks(**{'title': 'Holy Moment'}))
|
||||
|
||||
|
||||
def test_library_MusicSection_searchAlbums(a_music_section):
|
||||
assert len(a_music_section.searchAlbums(**{'title': 'Unmastered Impulses'}))
|
||||
|
||||
|
||||
def test_library_PhotoSection_searchAlbums(a_photo_section):
|
||||
albums = a_photo_section.searchAlbums('photo_album1')
|
||||
assert len(albums)
|
||||
print([i.TYPE for i in albums])
|
||||
|
||||
|
||||
def test_library_PhotoSection_searchPhotos(a_photo_section):
|
||||
assert len(a_photo_section.searchPhotos('lolcat2'))
|
||||
|
||||
|
||||
# Start on library search
|
||||
def test_library_and_section_search_for_movie(pms):
|
||||
find = '16 blocks'
|
||||
l_search = pms.library.search(find)
|
||||
s_search = pms.library.section('Movies').search(find)
|
||||
assert l_search == s_search
|
||||
|
||||
|
||||
def test_search_with_apostrophe(pms):
|
||||
show_title = "Marvel's Daredevil" # Test ' in show title
|
||||
result_server = pms.search(show_title)
|
||||
result_shows = pms.library.section('TV Shows').search(show_title)
|
||||
assert result_server
|
||||
assert result_shows
|
||||
assert result_server == result_shows
|
||||
|
||||
|
||||
def test_crazy_search(pms, a_movie):
|
||||
movie = a_movie
|
||||
movies = pms.library.section('Movies')
|
||||
assert movie in pms.library.search(genre=29, libtype='movie')
|
||||
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].id)
|
75
tests/test_myplex.py
Normal file
75
tests/test_myplex.py
Normal file
|
@ -0,0 +1,75 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
def test_myplex_accounts(plex_account, pms):
|
||||
account = plex_account
|
||||
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 = pms.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'
|
||||
|
||||
|
||||
def test_myplex_resources(plex_account):
|
||||
account = plex_account
|
||||
assert account, 'Must specify username, password & resource to run this test.'
|
||||
resources = account.resources()
|
||||
for resource in resources:
|
||||
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
|
||||
|
||||
|
||||
def test_myplex_connect_to_resource(plex_account):
|
||||
for resource in plex_account.resources():
|
||||
if resource.name == 'PMS_API_TEST_SERVER':
|
||||
break
|
||||
server = resource.connect()
|
||||
assert 'Ohno' in server.url('Ohno')
|
||||
assert server
|
||||
|
||||
|
||||
def test_myplex_devices(plex_account):
|
||||
account = plex_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
|
||||
|
||||
|
||||
#@pytest.mark.req_client # this need to be recorded?
|
||||
def _test_myplex_connect_to_device(plex_account):
|
||||
account = plex_account
|
||||
devices = account.devices()
|
||||
for device in devices:
|
||||
if device.name == 'some client name' and len(device.connections):
|
||||
break
|
||||
client = device.connect()
|
||||
assert client, 'Unable to connect to device'
|
||||
|
||||
|
||||
def test_myplex_users(plex_account):
|
||||
account = plex_account
|
||||
users = account.users()
|
||||
assert users, 'Found no users on account: %s' % account.name
|
||||
print('Found %s users.' % len(users))
|
||||
user = account.user('Hellowlol')
|
||||
print('Found user: %s' % user)
|
||||
assert user, 'Could not find user Hellowlol'
|
37
tests/test_navigation.py
Normal file
37
tests/test_navigation.py
Normal file
|
@ -0,0 +1,37 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
def test_navigate_around_show(plex_account, pms):
|
||||
show = pms.library.section('TV Shows').get('The 100')
|
||||
seasons = show.seasons()
|
||||
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:'
|
||||
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.'
|
||||
|
||||
|
||||
def test_navigate_around_artist(plex_account, pms):
|
||||
artist = pms.library.section('Music').get('Infinite State')
|
||||
albums = artist.albums()
|
||||
album = artist.album('Unmastered Impulses')
|
||||
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.'
|
102
tests/test_playlist.py
Normal file
102
tests/test_playlist.py
Normal file
|
@ -0,0 +1,102 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import pytest, time
|
||||
|
||||
|
||||
def test_create_playlist(pms, a_show):
|
||||
# create the playlist
|
||||
title = 'test_create_playlist_a_show'
|
||||
#print('Creating playlist %s..' % title)
|
||||
episodes = a_show.episodes()
|
||||
playlist = pms.createPlaylist(title, episodes[:3])
|
||||
try:
|
||||
items = playlist.items()
|
||||
#log(4, 'Title: %s' % playlist.title)
|
||||
#log(4, 'Items: %s' % items)
|
||||
#log(4, 'Duration: %s min' % int(playlist.duration / 60000.0))
|
||||
assert playlist.title == title, 'Playlist not created successfully.'
|
||||
assert len(items) == 3, 'Playlist does not contain 3 items.'
|
||||
assert items[0].ratingKey == episodes[0].ratingKey, 'Items not in proper order [0a].'
|
||||
assert items[1].ratingKey == episodes[1].ratingKey, 'Items not in proper order [1a].'
|
||||
assert items[2].ratingKey == episodes[2].ratingKey, 'Items not in proper order [2a].'
|
||||
# move items around (b)
|
||||
#print('Testing move items..')
|
||||
playlist.moveItem(items[1])
|
||||
items = playlist.items()
|
||||
assert items[0].ratingKey == episodes[1].ratingKey, 'Items not in proper order [0b].'
|
||||
assert items[1].ratingKey == episodes[0].ratingKey, 'Items not in proper order [1b].'
|
||||
assert items[2].ratingKey == episodes[2].ratingKey, 'Items not in proper order [2b].'
|
||||
# move items around (c)
|
||||
playlist.moveItem(items[0], items[1])
|
||||
items = playlist.items()
|
||||
assert items[0].ratingKey == episodes[0].ratingKey, 'Items not in proper order [0c].'
|
||||
assert items[1].ratingKey == episodes[1].ratingKey, 'Items not in proper order [1c].'
|
||||
assert items[2].ratingKey == episodes[2].ratingKey, 'Items not in proper order [2c].'
|
||||
# add an item
|
||||
#print('Testing add item: %s' % episodes[3])
|
||||
playlist.addItems(episodes[3])
|
||||
items = playlist.items()
|
||||
#log(4, '4th Item: %s' % items[3])
|
||||
assert items[3].ratingKey == episodes[3].ratingKey, 'Missing added item: %s' % episodes[3]
|
||||
# add two items
|
||||
#print('Testing add item: %s' % episodes[4:6])
|
||||
playlist.addItems(episodes[4:6])
|
||||
items = playlist.items()
|
||||
#log(4, '5th+ Items: %s' % items[4:])
|
||||
assert items[4].ratingKey == episodes[4].ratingKey, 'Missing added item: %s' % episodes[4]
|
||||
assert items[5].ratingKey == episodes[5].ratingKey, 'Missing added item: %s' % episodes[5]
|
||||
assert len(items) == 6, 'Playlist should have 6 items, %s found' % len(items)
|
||||
# remove item
|
||||
toremove = items[3]
|
||||
#print('Testing remove item: %s' % toremove)
|
||||
playlist.removeItem(toremove)
|
||||
items = playlist.items()
|
||||
assert toremove not in items, 'Removed item still in playlist: %s' % items[3]
|
||||
assert len(items) == 5, 'Playlist should have 5 items, %s found' % len(items)
|
||||
finally:
|
||||
playlist.delete()
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def test_playlist_play(pms):
|
||||
client = getclient(CONFIG.client, CONFIG.client_baseurl, plex)
|
||||
artist = plex.library.section(CONFIG.audio_section).get(CONFIG.audio_artist)
|
||||
album = artist.album(CONFIG.audio_album)
|
||||
pl_name = 'test_play_playlist'
|
||||
playlist = plex.createPlaylist(pl_name, album)
|
||||
try:
|
||||
client.playMedia(playlist); time.sleep(5)
|
||||
client.stop('music'); time.sleep(1)
|
||||
finally:
|
||||
playlist.delete()
|
||||
assert pl_name not in [i.title for i in pms.playlists()]
|
||||
|
||||
|
||||
def test_playlist_photos(pms, a_photo_album):
|
||||
album = a_photo_album
|
||||
photos = album.photos()
|
||||
pl_name = 'test_playlist_photos'
|
||||
playlist = pms.createPlaylist(pl_name, photos)
|
||||
try:
|
||||
assert len(playlist.items()) == 4
|
||||
finally:
|
||||
playlist.delete()
|
||||
assert pl_name not in [i.title for i in pms.playlists()]
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def _test_play_photos(account, plex):
|
||||
client = getclient('iphone-mike', CONFIG.client_baseurl, plex)
|
||||
photosection = plex.library.section(CONFIG.photo_section)
|
||||
album = photosection.get(CONFIG.photo_album)
|
||||
photos = album.photos()
|
||||
for photo in photos[:4]:
|
||||
client.playMedia(photo)
|
||||
time.sleep(2)
|
||||
|
||||
|
||||
def test_play_queues(pms):
|
||||
episode = pms.library.section('TV Shows').get('the 100').get('Pilot')
|
||||
playqueue = pms.createPlayQueue(episode)
|
||||
assert len(playqueue.items) == 1, 'No items in play queue.'
|
||||
assert playqueue.items[0].title == episode.title, 'Wrong show queued.'
|
||||
assert playqueue.playQueueID, 'Play queue ID not set.'
|
2
tests/test_search.py
Normal file
2
tests/test_search.py
Normal file
|
@ -0,0 +1,2 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# TODO: Many more tests is for search later.
|
196
tests/test_server.py
Normal file
196
tests/test_server.py
Normal file
|
@ -0,0 +1,196 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import os, pytest
|
||||
from plexapi.exceptions import BadRequest, NotFound
|
||||
from plexapi.utils import download
|
||||
|
||||
|
||||
def test_server_attr(pms):
|
||||
assert pms.baseurl == 'http://138.68.157.5:32400'
|
||||
assert pms.friendlyName == 'PMS_API_TEST_SERVER'
|
||||
assert pms.machineIdentifier == 'e42470b5c527c7e5ebbdc017b5a32c8c683f6f8b'
|
||||
assert pms.myPlex is True
|
||||
assert pms.myPlexMappingState == 'mapped'
|
||||
assert pms.myPlexSigninState == 'ok'
|
||||
assert pms.myPlexSubscription == '0'
|
||||
assert pms.myPlexUsername == 'testplexapi@gmail.com'
|
||||
assert pms.platform == 'Linux'
|
||||
assert pms.platformVersion == '4.4.0-59-generic (#80-Ubuntu SMP Fri Jan 6 17:47:47 UTC 2017)'
|
||||
#assert pms.session == <requests.sessions.Session object at 0x029A5E10>
|
||||
assert pms.token == os.environ.get('PLEX_TEST_TOKEN')
|
||||
assert pms.transcoderActiveVideoSessions == 0
|
||||
assert pms.updatedAt == 1484943666
|
||||
assert pms.version == '1.3.3.3148-b38628e'
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def test_server_session():
|
||||
pass
|
||||
|
||||
|
||||
def test_server_library(pms):
|
||||
assert pms.library
|
||||
|
||||
|
||||
def test_server_url(pms):
|
||||
assert 'ohno' in pms.url('ohno')
|
||||
|
||||
|
||||
def test_server_transcodeImage(tmpdir, pms, a_show):
|
||||
# Ideally we should also test the black white but this has to do for now.
|
||||
from PIL import Image
|
||||
width, height = 500, 500
|
||||
img_url_resize = pms.transcodeImage(a_show.banner, height, width)
|
||||
gray = img_url_resize = pms.transcodeImage(a_show.banner, height, width, saturation=0)
|
||||
resized_image = download(img_url_resize, savepath=str(tmpdir), filename='resize_image')
|
||||
org_image = download(a_show.server.url(a_show.banner), savepath=str(tmpdir), filename='org_image')
|
||||
gray_image = download(gray, savepath=str(tmpdir), filename='gray_image')
|
||||
with Image.open(resized_image) as im:
|
||||
assert width, height == im.size
|
||||
with Image.open(org_image) as im:
|
||||
assert width, height != im.size
|
||||
assert _detect_color_image(gray_image, thumb_size=150) == 'grayscale'
|
||||
|
||||
|
||||
def _detect_color_image(file, thumb_size=150, MSE_cutoff=22, adjust_color_bias=True):
|
||||
# from http://stackoverflow.com/questions/20068945/detect-if-image-is-color-grayscale-or-black-and-white-with-python-pil
|
||||
from PIL import Image, ImageStat
|
||||
pil_img = Image.open(file)
|
||||
bands = pil_img.getbands()
|
||||
if bands == ('R', 'G', 'B') or bands == ('R', 'G', 'B', 'A'):
|
||||
thumb = pil_img.resize((thumb_size, thumb_size))
|
||||
SSE, bias = 0, [0, 0, 0]
|
||||
if adjust_color_bias:
|
||||
bias = ImageStat.Stat(thumb).mean[:3]
|
||||
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])
|
||||
MSE = float(SSE) / (thumb_size * thumb_size)
|
||||
if MSE <= MSE_cutoff:
|
||||
return 'grayscale'
|
||||
else:
|
||||
return 'color'
|
||||
elif len(bands) == 1:
|
||||
return 'blackandwhite'
|
||||
|
||||
|
||||
def test_server_search(pms):
|
||||
# basic search. see test_search.py
|
||||
assert pms.search('16 Blocks')
|
||||
assert pms.search('16 blocks', mediatype='movie')
|
||||
|
||||
|
||||
def test_server_playlist(pms):
|
||||
pl = pms.playlist('some_playlist')
|
||||
assert pl.title == 'some_playlist'
|
||||
with pytest.raises(NotFound):
|
||||
pms.playlist('124xxx11y')
|
||||
|
||||
|
||||
def test_server_playlists(pms):
|
||||
playlists = pms.playlists()
|
||||
assert len(playlists)
|
||||
|
||||
|
||||
def test_server_history(pms):
|
||||
history = pms.history()
|
||||
assert len(history)
|
||||
|
||||
|
||||
def test_server_Server_query(pms):
|
||||
assert pms.query('/')
|
||||
from plexapi.server import PlexServer
|
||||
with pytest.raises(BadRequest):
|
||||
assert pms.query('/asdasdsada/12123127/aaaa', headers={'random_headers': '1337'})
|
||||
with pytest.raises(NotFound):
|
||||
# This is really requests.exceptions.HTTPError:
|
||||
# 401 Client Error: Unauthorized for url:
|
||||
PlexServer('http://138.68.157.5:32400', '1234')
|
||||
|
||||
|
||||
def test_server_Server_session():
|
||||
from requests import Session
|
||||
from plexapi.server import PlexServer
|
||||
|
||||
class MySession(Session):
|
||||
def __init__(self):
|
||||
super(self.__class__, self).__init__()
|
||||
self.plexapi_session_test = True
|
||||
|
||||
plex = PlexServer('http://138.68.157.5:32400',
|
||||
os.environ.get('PLEX_TEST_TOKEN'), session=MySession())
|
||||
assert hasattr(plex.session, 'plexapi_session_test')
|
||||
pl = plex.playlists()
|
||||
assert hasattr(pl[0].server.session, 'plexapi_session_test')
|
||||
# TODO: Check client in test_server_Server_session.
|
||||
# TODO: Check myplex in test_server_Server_session.
|
||||
|
||||
|
||||
def test_server_token_in_headers(pms):
|
||||
h = pms.headers()
|
||||
assert 'X-Plex-Token' in h and len(h['X-Plex-Token'])
|
||||
|
||||
|
||||
def _test_server_createPlayQueue():
|
||||
# see test_playlists.py
|
||||
pass
|
||||
|
||||
|
||||
def _test_server_createPlaylist():
|
||||
# see test_playlists.py
|
||||
pass
|
||||
|
||||
|
||||
def test_server_client_not_found(pms):
|
||||
with pytest.raises(NotFound):
|
||||
pms.client('<This-client-should-not-be-found>')
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def test_server_client(pms):
|
||||
assert pms.client('Plex Web (Chrome)')
|
||||
|
||||
|
||||
def test_server_Server_sessions(pms):
|
||||
assert len(pms.sessions()) == 0
|
||||
|
||||
|
||||
@pytest.mark.req_client
|
||||
def test_server_clients(pms):
|
||||
assert len(pms.clients())
|
||||
m = pms.clients()[0]
|
||||
assert m.baseurl == 'http://127.0.0.1:32400'
|
||||
assert m.device is None
|
||||
assert m.deviceClass == 'pc'
|
||||
assert m.machineIdentifier == '89hgkrbqxaxmf45o1q2949ru'
|
||||
assert m.model is None
|
||||
assert m.platform is None
|
||||
assert m.platformVersion is None
|
||||
assert m.product == 'Plex Web'
|
||||
assert m.protocol == 'plex'
|
||||
assert m.protocolCapabilities == ['timeline', 'playback', 'navigation', 'mirror', 'playqueues']
|
||||
assert m.protocolVersion == '1'
|
||||
assert m.server.baseurl == 'http://138.68.157.5:32400'
|
||||
assert m.state is None
|
||||
assert m.title == 'Plex Web (Chrome)'
|
||||
assert m.token is None
|
||||
assert m.vendor is None
|
||||
assert m.version == '2.12.5'
|
||||
|
||||
|
||||
def test_server_account(pms):
|
||||
acc = pms.account()
|
||||
assert acc.authToken
|
||||
# TODO: Figure out why this is missing from time to time.
|
||||
#assert acc.mappingError == 'publisherror'
|
||||
assert acc.mappingErrorMessage is None
|
||||
assert acc.mappingState == 'mapped'
|
||||
assert acc.privateAddress == '138.68.157.5'
|
||||
assert acc.privatePort == '32400'
|
||||
assert acc.publicAddress == '138.68.157.5'
|
||||
assert acc.publicPort == '32400'
|
||||
assert acc.signInState == 'ok'
|
||||
assert acc.subscriptionActive == '0'
|
||||
assert acc.subscriptionFeatures is None
|
||||
assert acc.subscriptionState == 'Unknown'
|
||||
assert acc.username == 'testplexapi@gmail.com'
|
107
tests/test_utils.py
Normal file
107
tests/test_utils.py
Normal file
|
@ -0,0 +1,107 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import pytest
|
||||
import plexapi.utils as utils
|
||||
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] == '1970-01-01'
|
||||
# should this handle args as '0' # no need element attrs are strings.
|
||||
|
||||
|
||||
def _test_utils_threaded():
|
||||
# TODO: Implement test_utils_threaded
|
||||
pass
|
||||
|
||||
|
||||
def test_utils_searchType():
|
||||
st = utils.searchType('movie')
|
||||
assert st == 1
|
||||
movie = utils.searchType(1)
|
||||
assert movie == '1'
|
||||
with pytest.raises(NotFound):
|
||||
utils.searchType('kekekekeke')
|
||||
|
||||
|
||||
def _test_utils_listItems():
|
||||
# TODO: Implement test_utils_listItems
|
||||
pass
|
||||
|
||||
|
||||
def _test_utils_listChoices(pms):
|
||||
# TODO: Implement test_utils_listChoices
|
||||
pass
|
||||
|
||||
|
||||
def test_utils_joinArgs():
|
||||
test_dict = {'genre': 'action', 'type': 1337}
|
||||
assert utils.joinArgs(test_dict) == '?genre=action&type=1337'
|
||||
|
||||
|
||||
def test_utils_isInt():
|
||||
assert utils.isInt(1) is True
|
||||
assert utils.isInt('got_you') is False
|
||||
assert utils.isInt('1337') is True
|
||||
|
||||
|
||||
def _test_utils_findUsername():
|
||||
# TODO: Implement test_utils_findUsername
|
||||
pass
|
||||
|
||||
|
||||
def _test_utils_findStreams():
|
||||
# TODO: Implement test_utils_findStreams
|
||||
pass
|
||||
|
||||
|
||||
def _test_utils_findPlayer():
|
||||
# TODO: Implement test_utils_findPlayer
|
||||
pass
|
||||
|
||||
|
||||
def _test_utils_findLocations():
|
||||
# TODO: Implement test_utils_findLocations
|
||||
pass
|
||||
|
||||
|
||||
def _test_utils_findItem():
|
||||
# TODO: Implement test_utils_findItem
|
||||
pass
|
||||
|
||||
|
||||
def test_utils_findKey(pms):
|
||||
with pytest.raises(NotFound):
|
||||
assert utils.findKey(pms, '9999999')
|
||||
assert utils.findKey(pms, '1')
|
||||
|
||||
|
||||
def test_utils_cast():
|
||||
t_int_int = utils.cast(int, 1)
|
||||
t_int_str_int = utils.cast(int, '1')
|
||||
t_bool_str_int = utils.cast(bool, '1')
|
||||
t_bool_int = utils.cast(bool, 1)
|
||||
t_float_int = utils.cast(float, 1)
|
||||
t_float_float = utils.cast(float, 1)
|
||||
t_float_str = utils.cast(float, 'kek')
|
||||
assert t_int_int == 1 and isinstance(t_int_int, int)
|
||||
assert t_int_str_int == 1 and isinstance(t_int_str_int, int)
|
||||
assert t_bool_str_int is True
|
||||
assert t_bool_int is True
|
||||
assert t_float_float == 1.0 and isinstance(t_float_float, float)
|
||||
assert t_float_str != t_float_str # nan is never equal
|
||||
with pytest.raises(ValueError):
|
||||
t_bool_str = utils.cast(bool, 'kek') # should we catch this in cast?
|
||||
|
||||
|
||||
def test_utils_download(a_episode):
|
||||
# this files is really getting downloaded..
|
||||
without_session = utils.download(a_episode.getStreamURL(),
|
||||
filename=a_episode.location, mocked=True)
|
||||
assert without_session
|
||||
with_session = utils.download(a_episode.getStreamURL(),
|
||||
filename=a_episode.location, session=a_episode.server.session,
|
||||
mocked=True)
|
||||
assert with_session
|
||||
img = utils.download(a_episode.thumbUrl, filename=a_episode.title, mocked=True)
|
||||
assert img
|
543
tests/test_video.py
Normal file
543
tests/test_video.py
Normal file
|
@ -0,0 +1,543 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import os, pytest
|
||||
from plexapi.exceptions import NotFound
|
||||
|
||||
|
||||
def test_video_Movie(a_movie_section):
|
||||
m = a_movie_section.get('Cars')
|
||||
assert m.title == 'Cars'
|
||||
|
||||
|
||||
def test_video_Movie_getStreamURL(a_movie):
|
||||
assert a_movie.getStreamURL() == "http://138.68.157.5:32400/video/:/transcode/universal/start.m3u8?X-Plex-Platform=Chrome©ts=1&mediaIndex=0&offset=0&path=%2Flibrary%2Fmetadata%2F1&X-Plex-Token={0}".format(os.environ.get('PLEX_TEST_TOKEN'))
|
||||
assert a_movie.getStreamURL(videoResolution='800x600') == "http://138.68.157.5:32400/video/:/transcode/universal/start.m3u8?X-Plex-Platform=Chrome©ts=1&mediaIndex=0&offset=0&path=%2Flibrary%2Fmetadata%2F1&videoResolution=800x600&X-Plex-Token={0}".format(os.environ.get('PLEX_TEST_TOKEN'))
|
||||
|
||||
|
||||
def test_video_Movie_isFullObject_and_reload(pms):
|
||||
movie = pms.library.section('Movies').get('16 Blocks')
|
||||
assert movie.isFullObject() is False
|
||||
movie.reload()
|
||||
assert movie.isFullObject() is True
|
||||
movie_via_search = pms.library.search('16 Blocks')[0]
|
||||
assert movie_via_search.isFullObject() is False
|
||||
movie_via_search.reload()
|
||||
assert movie_via_search.isFullObject() is True
|
||||
movie_via_section_search = pms.library.section('Movies').search('16 Blocks')[0]
|
||||
assert movie_via_section_search.isFullObject() is False
|
||||
movie_via_section_search.reload()
|
||||
assert movie_via_section_search.isFullObject() is True
|
||||
# If the verify that the object has been reloaded. xml from search only returns 3 actors.
|
||||
assert len(movie_via_section_search.roles) > 3
|
||||
|
||||
|
||||
def test_video_Movie_isPartialObject(a_movie):
|
||||
assert a_movie.isPartialObject()
|
||||
|
||||
|
||||
def test_video_Movie_iterParts(a_movie):
|
||||
assert len(list(a_movie.iterParts())) == 1
|
||||
|
||||
|
||||
def test_video_Movie_download(monkeydownload, tmpdir, a_movie):
|
||||
downloaded_movie = a_movie.download(savepath=str(tmpdir))
|
||||
assert len(downloaded_movie) == 1
|
||||
downloaded_movie2 = a_movie.download(savepath=str(tmpdir), **{'videoResolution': '500x300'})
|
||||
assert len(downloaded_movie2) == 1
|
||||
|
||||
|
||||
def test_video_Movie_attrs_as_much_as_possible(a_movie_section):
|
||||
m = a_movie_section.get('Cars')
|
||||
assert m.location == '/media/movies/cars/cars.mp4'
|
||||
assert str(m.addedAt.date()) == '2017-01-17'
|
||||
assert m.art == '/library/metadata/2/art/1484690715'
|
||||
assert m.audienceRating == 7.9
|
||||
assert m.audienceRatingImage == 'rottentomatoes://image.rating.upright'
|
||||
# Assign 0 m.audioStreams
|
||||
aud0 = m.audioStreams[0]
|
||||
assert m.chapterSource == 'agent'
|
||||
assert m.collections == []
|
||||
assert m.contentRating == 'G'
|
||||
#assert m.countries == [<Country:35:USA>]
|
||||
assert [i.tag for i in m.directors] == ['John Lasseter', 'Joe Ranft']
|
||||
assert m.duration == 170859
|
||||
assert m.fields == []
|
||||
assert [i.tag for i in m.genres] == ['Animation', 'Family', 'Comedy', 'Sport', 'Adventure']
|
||||
assert m.guid == 'com.plexapp.agents.imdb://tt0317219?lang=en'
|
||||
assert m.initpath == '/library/metadata/2'
|
||||
assert m.key == '/library/metadata/2'
|
||||
assert str(m.lastViewedAt) == '2017-01-30 22:19:38' # fix me
|
||||
assert m.librarySectionID == '1'
|
||||
assert m.listType == 'video'
|
||||
# Assign 0 m.media
|
||||
med0 = m.media[0]
|
||||
assert str(m.originalTitle) == '__NA__'
|
||||
assert str(m.originallyAvailableAt.date()) == '2006-06-09'
|
||||
assert m.player is None
|
||||
assert str(m.playlistItemID) == '__NA__'
|
||||
assert str(m.primaryExtraKey) == '__NA__'
|
||||
#assert m.producers == [<Producer:130:Darla.K..Anderson>]
|
||||
assert m.rating == '7.4'
|
||||
assert m.ratingImage == 'rottentomatoes://image.rating.certified'
|
||||
assert m.ratingKey == 2
|
||||
assert [i.tag for i in m.roles] == ['Owen Wilson', 'Paul Newman', 'Bonnie Hunt', 'Larry the Cable Guy', 'Cheech Marin', 'Tony Shalhoub', 'Guido Quaroni', 'Jenifer Lewis', 'Paul Dooley', 'Michael Wallis', 'George Carlin', 'Katherine Helmond', 'John Ratzenberger', 'Michael Keaton', 'Joe Ranft', 'Richard Petty', 'Jeremy Piven', 'Bob Costas', 'Darrell Waltrip', 'Richard Kind', 'Edie McClurg', 'Humpy Wheeler', 'Tom Magliozzi', 'Ray Magliozzi', 'Lynda Petty', 'Andrew Stanton', 'Dale Earnhardt Jr.', 'Michael Schumacher', 'Jay Leno', 'Sarah Clark', 'Mike Nelson', 'Joe Ranft', 'Jonas Rivera', 'Lou Romano', 'Adrian Ochoa', 'E.J. Holowicki', 'Elissa Knight', 'Lindsey Collins', 'Larry Benton', 'Douglas Keever', 'Tom Hanks', 'Tim Allen', 'John Ratzenberger', 'Billy Crystal', 'John Goodman', 'John Ratzenberger', 'Dave Foley', 'John Ratzenberger', 'Vanness Wu']
|
||||
assert m.server.baseurl == 'http://138.68.157.5:32400'
|
||||
assert str(m.sessionKey) == '__NA__'
|
||||
assert m.studio == 'Walt Disney Pictures'
|
||||
assert m.summary == u"Lightning McQueen, a hotshot rookie race car driven to succeed, discovers that life is about the journey, not the finish line, when he finds himself unexpectedly detoured in the sleepy Route 66 town of Radiator Springs. On route across the country to the big Piston Cup Championship in California to compete against two seasoned pros, McQueen gets to know the town's offbeat characters."
|
||||
assert m.tagline == "Ahhh... it's got that new movie smell."
|
||||
assert m.thumb == '/library/metadata/2/thumb/1484690715'
|
||||
assert m.title == 'Cars'
|
||||
assert m.titleSort == 'Cars'
|
||||
assert m.transcodeSession is None
|
||||
assert m.type == 'movie'
|
||||
assert str(m.updatedAt.date()) == '2017-01-17'
|
||||
assert str(m.userRating) == '__NA__'
|
||||
assert m.username is None
|
||||
# Assign 0 m.videoStreams
|
||||
vid0 = m.videoStreams[0]
|
||||
assert m.viewCount == 0
|
||||
assert m.viewOffset == 88870
|
||||
assert str(m.viewedAt) == '__NA__'
|
||||
assert [i.tag for i in m.writers] == ['Dan Fogelman', 'Joe Ranft', 'John Lasseter', 'Kiel Murray', 'Phil Lorin', 'Jorgen Klubien']
|
||||
assert m.year == 2006
|
||||
assert aud0.audioChannelLayout == '5.1'
|
||||
assert aud0.bitDepth is None
|
||||
assert aud0.bitrate == 388
|
||||
assert aud0.bitrateMode is None
|
||||
assert aud0.channels == 6
|
||||
assert aud0.codec == 'aac'
|
||||
assert aud0.codecID is None
|
||||
assert aud0.dialogNorm is None
|
||||
assert aud0.duration is None
|
||||
assert aud0.id == 10
|
||||
assert aud0.index == 1
|
||||
assert aud0.initpath == '/library/metadata/2'
|
||||
assert aud0.language is None
|
||||
assert aud0.languageCode is None
|
||||
#assert aud0.part == <MediaPart:2>
|
||||
assert aud0.samplingRate == 48000
|
||||
assert aud0.selected is True
|
||||
assert aud0.server.baseurl == 'http://138.68.157.5:32400'
|
||||
assert aud0.streamType == 2
|
||||
assert aud0.title is None
|
||||
assert aud0.type == 2
|
||||
assert med0.aspectRatio == 1.78
|
||||
assert med0.audioChannels == 6
|
||||
assert med0.audioCodec == 'aac'
|
||||
assert med0.bitrate == 1474
|
||||
assert med0.container == 'mp4'
|
||||
assert med0.duration == 170859
|
||||
assert med0.height == 720
|
||||
assert med0.id == 2
|
||||
assert med0.initpath == '/library/metadata/2'
|
||||
assert med0.optimizedForStreaming is False
|
||||
# Assign 0 med0.parts
|
||||
par0 = med0.parts[0]
|
||||
assert med0.server.baseurl == 'http://138.68.157.5:32400'
|
||||
assert med0.video == m
|
||||
assert med0.videoCodec == 'h264'
|
||||
assert med0.videoFrameRate == 'PAL'
|
||||
assert med0.videoResolution == '720'
|
||||
assert med0.width == 1280
|
||||
assert vid0.bitDepth == 8
|
||||
assert vid0.bitrate == 1086
|
||||
assert vid0.cabac is None
|
||||
assert vid0.chromaSubsampling == '4:2:0'
|
||||
assert vid0.codec == 'h264'
|
||||
assert vid0.codecID is None
|
||||
assert vid0.colorSpace is None
|
||||
assert vid0.duration is None
|
||||
assert vid0.frameRate == 25.0
|
||||
assert vid0.frameRateMode is None
|
||||
assert vid0.hasScallingMatrix is None
|
||||
assert vid0.height == 720
|
||||
assert vid0.id == 9
|
||||
assert vid0.index == 0
|
||||
assert vid0.initpath == '/library/metadata/2'
|
||||
assert vid0.language is None
|
||||
assert vid0.languageCode is None
|
||||
assert vid0.level == 31
|
||||
#assert vid0.part == <MediaPart:2>
|
||||
assert vid0.profile == 'main'
|
||||
assert vid0.refFrames == 1
|
||||
assert vid0.scanType is None
|
||||
assert vid0.selected is False
|
||||
assert vid0.server.baseurl == 'http://138.68.157.5:32400'
|
||||
assert vid0.streamType == 1
|
||||
assert vid0.title is None
|
||||
assert vid0.type == 1
|
||||
assert vid0.width == 1280
|
||||
assert par0.container == 'mp4'
|
||||
assert par0.duration == 170859
|
||||
assert par0.file == '/media/movies/cars/cars.mp4'
|
||||
assert par0.id == 2
|
||||
assert par0.initpath == '/library/metadata/2'
|
||||
assert par0.key == '/library/parts/2/1484679008/file.mp4'
|
||||
#assert par0.media == <Media:Cars>
|
||||
assert par0.server.baseurl == 'http://138.68.157.5:32400'
|
||||
assert par0.size == 31491130
|
||||
# Assign 0 par0.streams
|
||||
str0 = par0.streams[0]
|
||||
# Assign 1 par0.streams
|
||||
str1 = par0.streams[1]
|
||||
assert str0.bitDepth == 8
|
||||
assert str0.bitrate == 1086
|
||||
assert str0.cabac is None
|
||||
assert str0.chromaSubsampling == '4:2:0'
|
||||
assert str0.codec == 'h264'
|
||||
assert str0.codecID is None
|
||||
assert str0.colorSpace is None
|
||||
assert str0.duration is None
|
||||
assert str0.frameRate == 25.0
|
||||
assert str0.frameRateMode is None
|
||||
assert str0.hasScallingMatrix is None
|
||||
assert str0.height == 720
|
||||
assert str0.id == 9
|
||||
assert str0.index == 0
|
||||
assert str0.initpath == '/library/metadata/2'
|
||||
assert str0.language is None
|
||||
assert str0.languageCode is None
|
||||
assert str0.level == 31
|
||||
#assert str0.part == <MediaPart:2>
|
||||
assert str0.profile == 'main'
|
||||
assert str0.refFrames == 1
|
||||
assert str0.scanType is None
|
||||
assert str0.selected is False
|
||||
assert str0.server.baseurl == 'http://138.68.157.5:32400'
|
||||
assert str0.streamType == 1
|
||||
assert str0.title is None
|
||||
assert str0.type == 1
|
||||
assert str0.width == 1280
|
||||
assert str1.audioChannelLayout == '5.1'
|
||||
assert str1.bitDepth is None
|
||||
assert str1.bitrate == 388
|
||||
assert str1.bitrateMode is None
|
||||
assert str1.channels == 6
|
||||
assert str1.codec == 'aac'
|
||||
assert str1.codecID is None
|
||||
assert str1.dialogNorm is None
|
||||
assert str1.duration is None
|
||||
assert str1.id == 10
|
||||
assert str1.index == 1
|
||||
assert str1.initpath == '/library/metadata/2'
|
||||
assert str1.language is None
|
||||
assert str1.languageCode is None
|
||||
#assert str1.part == <MediaPart:2>
|
||||
assert str1.samplingRate == 48000
|
||||
assert str1.selected is True
|
||||
assert str1.server.baseurl == 'http://138.68.157.5:32400'
|
||||
assert str1.streamType == 2
|
||||
assert str1.title is None
|
||||
assert str1.type == 2
|
||||
|
||||
|
||||
def test_video_Show(a_show):
|
||||
assert a_show.title == 'The 100'
|
||||
|
||||
|
||||
def test_video_Show_attrs(a_show):
|
||||
m = a_show
|
||||
assert str(m.addedAt.date()) == '2017-01-17'
|
||||
assert '/library/metadata/12/art/' in m.art
|
||||
assert '/library/metadata/12/banner/' in m.banner
|
||||
assert m.childCount == 2
|
||||
assert m.contentRating == 'TV-14'
|
||||
assert m.duration == 2700000
|
||||
assert m.initpath == '/library/sections/2/all'
|
||||
# Since we access m.genres the show is forced to reload
|
||||
assert [i.tag for i in m.genres] == ['Drama', 'Science-Fiction', 'Suspense', 'Thriller']
|
||||
# So the initkey should have changed because of the reload
|
||||
assert m.initpath == '/library/metadata/12'
|
||||
assert m.index == '1'
|
||||
assert m.key == '/library/metadata/12'
|
||||
assert str(m.lastViewedAt.date()) == '2017-01-22'
|
||||
assert m.leafCount == 9
|
||||
assert m.listType == 'video'
|
||||
assert m.location == '/media/tvshows/the 100'
|
||||
assert str(m.originallyAvailableAt.date()) == '2014-03-19'
|
||||
assert m.rating == 8.1
|
||||
assert m.ratingKey == 12
|
||||
assert [i.tag for i in m.roles][:3] == ['Richard Harmon', 'Alycia Debnam-Carey', 'Lindsey Morgan']
|
||||
assert [i.tag for i in m.actors][:3] == ['Richard Harmon', 'Alycia Debnam-Carey', 'Lindsey Morgan']
|
||||
assert m.server.baseurl == 'http://138.68.157.5:32400'
|
||||
assert m.studio == 'The CW'
|
||||
assert m.summary == u"When nuclear Armageddon destroys civilization on Earth, the only survivors are those on the 12 international space stations in orbit at the time. Three generations later, the 4,000 survivors living on a space ark of linked stations see their resources dwindle and face draconian measures established to ensure humanity's future. Desperately looking for a solution, the ark's leaders send 100 juvenile prisoners back to the planet to test its habitability. Having always lived in space, the exiles find the planet fascinating and terrifying, but with the fate of the human race in their hands, they must forge a path into the unknown."
|
||||
assert '/library/metadata/12/theme/' in m.theme
|
||||
assert '/library/metadata/12/thumb/' in m.thumb
|
||||
assert m.title == 'The 100'
|
||||
assert m.titleSort == '100'
|
||||
assert m.type == 'show'
|
||||
assert str(m.updatedAt.date()) == '2017-01-22'
|
||||
assert m.viewCount == 1
|
||||
assert m.viewedLeafCount == 1
|
||||
assert m.year == 2014
|
||||
|
||||
|
||||
def test_video_Show_watched(a_show):
|
||||
watched = a_show.watched()
|
||||
assert len(watched) == 1 and watched[0].title == 'Pilot'
|
||||
|
||||
|
||||
def test_video_Show_unwatched(a_show):
|
||||
assert len(a_show.unwatched()) == 8
|
||||
|
||||
|
||||
def test_video_Show_location(pms):
|
||||
# This should be a part of test test_video_Show_attrs
|
||||
# But is excluded because of https://github.com/mjs7231/python-plexapi/issues/97
|
||||
s = pms.library.section('TV Shows').get('The 100')
|
||||
# This will require a reload since the xml from http://138.68.157.5:32400/library/sections/2/all
|
||||
# Does not contain a location
|
||||
assert s.location == '/media/tvshows/the 100'
|
||||
|
||||
def test_video_Show_reload(pms):
|
||||
s = pms.library.section('TV Shows').get('Game of Thrones')
|
||||
assert s.initpath == '/library/sections/2/all'
|
||||
s.reload()
|
||||
assert s.initpath == '/library/metadata/6'
|
||||
assert len(s.roles) > 3
|
||||
|
||||
|
||||
def test_video_Show_episodes(a_show):
|
||||
inc_watched = a_show.episodes()
|
||||
ex_watched = a_show.episodes(watched=False)
|
||||
assert len(inc_watched) == 9
|
||||
assert len(ex_watched) == 8
|
||||
|
||||
|
||||
def test_video_Show_download(monkeydownload, tmpdir, a_show):
|
||||
f = a_show.download(savepath=str(tmpdir))
|
||||
assert len(f) == 9
|
||||
|
||||
|
||||
def test_video_Season_download(monkeydownload, tmpdir, a_show):
|
||||
sn = a_show.season('Season 1')
|
||||
f = sn.download(savepath=str(tmpdir))
|
||||
assert len(f) == 8
|
||||
|
||||
|
||||
def test_video_Episode_download(monkeydownload, tmpdir, a_episode):
|
||||
f = a_episode.download(savepath=str(tmpdir))
|
||||
assert len(f) == 1
|
||||
with_sceen_size = a_episode.download(savepath=str(tmpdir), **{'videoResolution': '500x300'})
|
||||
assert len(with_sceen_size) == 1
|
||||
|
||||
|
||||
def test_video_Show_thumbUrl(a_show):
|
||||
assert 'http://138.68.157.5:32400/library/metadata/12/thumb/' in a_show.thumbUrl
|
||||
|
||||
|
||||
@pytest.mark.xfail
|
||||
def test_video_Show_analyze(a_show):
|
||||
show = a_show.analyze() # this isnt possble.. should it even be available?
|
||||
|
||||
|
||||
def test_video_Show_markWatched(a_tv_section):
|
||||
show = a_tv_section.get("Marvel's Daredevil")
|
||||
show.markWatched()
|
||||
assert a_tv_section.get("Marvel's Daredevil").isWatched
|
||||
|
||||
|
||||
def test_video_Show_markUnwatched(a_tv_section):
|
||||
show = a_tv_section.get("Marvel's Daredevil")
|
||||
show.markUnwatched()
|
||||
assert not a_tv_section.get("Marvel's Daredevil").isWatched
|
||||
|
||||
|
||||
def test_video_Show_refresh(a_tv_section):
|
||||
show = a_tv_section.get("Marvel's Daredevil")
|
||||
show.refresh()
|
||||
|
||||
|
||||
def test_video_Show_get(a_show):
|
||||
assert a_show.get('Pilot').title == 'Pilot'
|
||||
|
||||
|
||||
def test_video_Show_isWatched(a_show):
|
||||
assert not a_show.isWatched
|
||||
|
||||
|
||||
@pytest.mark.xfail
|
||||
def test_video_Show_section(a_show): # BROKEN!
|
||||
show = a_show.section()
|
||||
|
||||
|
||||
def test_video_Episode(a_show):
|
||||
pilot = a_show.episode('Pilot')
|
||||
assert pilot == a_show.episode(season=1, episode=1)
|
||||
with pytest.raises(TypeError):
|
||||
a_show.episode()
|
||||
with pytest.raises(NotFound):
|
||||
a_show.episode(season=1337, episode=1337)
|
||||
|
||||
|
||||
def test_video_Episode_analyze(a_tv_section):
|
||||
ep = a_tv_section.get("Marvel's Daredevil").episode(season=1, episode=1)
|
||||
ep.analyze()
|
||||
|
||||
|
||||
def test_video_Episode_attrs(a_episode):
|
||||
ep = a_episode
|
||||
assert str(ep.addedAt.date()) == '2017-01-17'
|
||||
assert ep.contentRating == 'TV-14'
|
||||
assert [i.tag for i in ep.directors] == ['Bharat Nalluri']
|
||||
assert ep.duration == 170859
|
||||
assert ep.grandparentTitle == 'The 100'
|
||||
assert ep.index == 1
|
||||
assert ep.initpath == '/library/metadata/12/allLeaves'
|
||||
assert ep.key == '/library/metadata/14'
|
||||
assert ep.listType == 'video'
|
||||
# Assign 0 ep.media
|
||||
med0 = ep.media[0]
|
||||
assert str(ep.originallyAvailableAt.date()) == '2014-03-19'
|
||||
assert ep.parentIndex == '1'
|
||||
assert ep.parentKey == '/library/metadata/13'
|
||||
assert ep.parentRatingKey == 13
|
||||
assert '/library/metadata/13/thumb/' in ep.parentThumb
|
||||
#assert ep.parentThumb == '/library/metadata/13/thumb/1485096623'
|
||||
assert ep.player is None
|
||||
assert ep.rating == 7.4
|
||||
assert ep.ratingKey == 14
|
||||
assert ep.server.baseurl == 'http://138.68.157.5:32400'
|
||||
assert ep.summary == u'Ninety-seven years ago, nuclear Armageddon decimated planet Earth, destroying civilization. The only survivors were the 400 inhabitants of 12 international space stations that were in orbit at the time. Three generations have been born in space, the survivors now number 4,000, and resources are running out on their dying "Ark." Among the 100 young exiles are Clarke, the bright teenage daughter of the Ark’s chief medical officer; the daredevil Finn; the brother/sister duo of Bellamy and Octavia, whose illegal sibling status has always led them to flaunt the rules, the lighthearted Jasper and the resourceful Monty. Technologically blind to what’s happening on the planet below them, the Ark’s leaders — Clarke’s widowed mother, Abby; Chancellor Jaha; and his shadowy second in command, Kane — are faced with difficult decisions about life, death and the continued existence of the human race.'
|
||||
assert ep.thumb == '/library/metadata/14/thumb/1485115318'
|
||||
assert ep.title == 'Pilot'
|
||||
assert ep.titleSort == 'Pilot'
|
||||
assert ep.transcodeSession is None
|
||||
assert ep.type == 'episode'
|
||||
assert str(ep.updatedAt.date()) == '2017-01-22'
|
||||
assert ep.username is None
|
||||
assert ep.viewCount == 1
|
||||
assert ep.viewOffset == 0
|
||||
assert [i.tag for i in ep.writers] == ['Jason Rothenberg']
|
||||
assert ep.year == 2014
|
||||
assert med0.aspectRatio == 1.78
|
||||
assert med0.audioChannels == 6
|
||||
assert med0.audioCodec == 'aac'
|
||||
assert med0.bitrate == 1474
|
||||
assert med0.container == 'mp4'
|
||||
assert med0.duration == 170859
|
||||
assert med0.height == 720
|
||||
assert med0.id == 12
|
||||
assert med0.initpath == '/library/metadata/12/allLeaves'
|
||||
assert med0.optimizedForStreaming is False
|
||||
# Assign 0 med0.parts
|
||||
par0 = med0.parts[0]
|
||||
assert med0.server.baseurl == 'http://138.68.157.5:32400'
|
||||
#assert med0.video == <Episode:14:The 100:S1:E1:Pilot>
|
||||
assert med0.videoCodec == 'h264'
|
||||
assert med0.videoFrameRate == 'PAL'
|
||||
assert med0.videoResolution == '720'
|
||||
assert med0.width == 1280
|
||||
assert par0.container == 'mp4'
|
||||
assert par0.duration == 170859
|
||||
assert par0.file == '/media/tvshows/the 100/season 1/the.100.s01e01.mp4'
|
||||
assert par0.id == 12
|
||||
assert par0.initpath == '/library/metadata/12/allLeaves'
|
||||
assert par0.key == '/library/parts/12/1484679008/file.mp4'
|
||||
#assert par0.media == <Media:Pilot>
|
||||
assert par0.server.baseurl == 'http://138.68.157.5:32400'
|
||||
assert par0.size == 31491130
|
||||
assert ep.isWatched is True
|
||||
|
||||
|
||||
def test_video_Season(a_show):
|
||||
seasons = a_show.seasons()
|
||||
assert len(seasons) == 2
|
||||
assert ['Season 1', 'Season 2'] == [s.title for s in seasons]
|
||||
assert a_show.season('Season 1') == seasons[0]
|
||||
|
||||
|
||||
def test_video_Season_attrs(a_show):
|
||||
m = a_show.season('Season 1')
|
||||
assert str(m.addedAt.date()) == '2017-01-17'
|
||||
assert m.index == 1
|
||||
assert m.initpath == '/library/metadata/12/children'
|
||||
assert m.key == '/library/metadata/13'
|
||||
assert str(m.lastViewedAt.date()) == '2017-01-22'
|
||||
assert m.leafCount == 8
|
||||
assert m.listType == 'video'
|
||||
assert m.parentKey == '/library/metadata/12'
|
||||
assert m.parentRatingKey == 12
|
||||
assert m.parentTitle == 'The 100'
|
||||
assert m.ratingKey == 13
|
||||
assert m.server.baseurl == 'http://138.68.157.5:32400'
|
||||
assert m.summary == ''
|
||||
assert '/library/metadata/13/thumb/' in m.thumb
|
||||
#assert m.thumb == '/library/metadata/13/thumb/1485096623'
|
||||
assert m.title == 'Season 1'
|
||||
assert m.titleSort == 'Season 1'
|
||||
assert m.type == 'season'
|
||||
assert str(m.updatedAt.date()) == '2017-01-22'
|
||||
assert m.viewCount == 1
|
||||
assert m.viewedLeafCount == 1
|
||||
assert m.seasonNumber == 1
|
||||
|
||||
|
||||
def test_video_Season_show(a_show):
|
||||
sn = a_show.seasons()[0]
|
||||
season_by_name = a_show.season('Season 1')
|
||||
assert a_show.ratingKey == sn.parentRatingKey and season_by_name.parentRatingKey
|
||||
assert sn.ratingKey == season_by_name.ratingKey
|
||||
|
||||
|
||||
def test_video_Season_watched(a_tv_section):
|
||||
show = a_tv_section.get("Marvel's Daredevil")
|
||||
sn = show.season(1)
|
||||
sne = show.season('Season 1')
|
||||
assert sn == sne
|
||||
sn.markWatched()
|
||||
assert sn.isWatched
|
||||
|
||||
|
||||
def test_video_Season_unwatched(a_tv_section):
|
||||
sn = a_tv_section.get("Marvel's Daredevil").season(1)
|
||||
sn.markUnwatched()
|
||||
assert not sn.isWatched
|
||||
|
||||
|
||||
def test_video_Season_get(a_show):
|
||||
ep = a_show.season(1).get('Pilot')
|
||||
assert ep.title == 'Pilot'
|
||||
|
||||
|
||||
def test_video_Season_episode(a_show):
|
||||
ep = a_show.season(1).get('Pilot')
|
||||
assert ep.title == 'Pilot'
|
||||
|
||||
|
||||
def test_video_Season_episodes(a_show):
|
||||
sn_eps = a_show.season(2).episodes()
|
||||
assert len(sn_eps) == 1
|
||||
|
||||
|
||||
def test_that_reload_return_the_same_object(pms):
|
||||
# we want to check this that all the urls are correct
|
||||
movie_library_search = pms.library.section('Movies').search('16 Blocks')[0]
|
||||
movie_search = pms.search('16 Blocks')[0]
|
||||
movie_section_get = pms.library.section('Movies').get('16 Blocks')
|
||||
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
|
||||
tvshow_library_search = pms.library.section('TV Shows').search('The 100')[0]
|
||||
tvshow_search = pms.search('The 100')[0]
|
||||
tvshow_section_get = pms.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
|
||||
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
|
||||
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
|
772
tests/tests.py
772
tests/tests.py
|
@ -1,772 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Test Library Functions
|
||||
As of Plex version 0.9.11 I noticed that you must be logged in
|
||||
to browse even the plex server locatewd at localhost. You can
|
||||
run this test suite with the following command:
|
||||
|
||||
>> python tests.py -u <USERNAME> -p <PASSWORD> -s <SERVERNAME>
|
||||
"""
|
||||
import argparse, sys, time
|
||||
from os.path import basename, dirname, abspath
|
||||
sys.path.append(dirname(dirname(abspath(__file__))))
|
||||
from utils import log, register, safe_client, run_tests
|
||||
from plexapi.utils import NA
|
||||
|
||||
SHOW_SECTION = 'TV Shows'
|
||||
SHOW_TITLE = 'Game of Thrones'
|
||||
SHOW_SEASON = 'Season 1'
|
||||
SHOW_EPISODE = 'Winter Is Coming'
|
||||
MOVIE_SECTION = 'Movies'
|
||||
MOVIE_TITLE = 'Jurassic World'
|
||||
AUDIO_SECTION = 'Music'
|
||||
AUDIO_ARTIST = 'Beastie Boys'
|
||||
AUDIO_ALBUM = 'Licensed To Ill'
|
||||
AUDIO_TRACK = 'Brass Monkey'
|
||||
PHOTO_SECTION = 'Photos'
|
||||
PHOTO_ALBUM = '2015-12-12 - Family Photo for Christmas card'
|
||||
CLIENT = 'pkkid-home'
|
||||
CLIENT_BASEURL = 'http://192.168.1.131:3005'
|
||||
|
||||
|
||||
#-----------------------
|
||||
# Core
|
||||
#-----------------------
|
||||
|
||||
@register('core,server')
|
||||
def test_server(plex, account=None):
|
||||
log(2, 'Username: %s' % plex.myPlexUsername)
|
||||
log(2, 'Platform: %s' % plex.platform)
|
||||
log(2, 'Version: %s' % plex.version)
|
||||
assert plex.myPlexUsername is not None, 'Unknown username.'
|
||||
assert plex.platform is not None, 'Unknown platform.'
|
||||
assert plex.version is not None, 'Unknown version.'
|
||||
|
||||
|
||||
@register('core')
|
||||
def test_list_sections(plex, account=None):
|
||||
sections = [s.title for s in plex.library.sections()]
|
||||
log(2, 'Sections: %s' % sections)
|
||||
assert SHOW_SECTION in sections, '%s not a library section.' % SHOW_SECTION
|
||||
assert MOVIE_SECTION in sections, '%s not a library section.' % MOVIE_SECTION
|
||||
plex.library.section(SHOW_SECTION)
|
||||
plex.library.section(MOVIE_SECTION)
|
||||
|
||||
|
||||
@register('core')
|
||||
def test_history(plex, account=None):
|
||||
history = plex.history()
|
||||
for item in history[:20]:
|
||||
log(2, "%s: %s played %s '%s'" % (item.viewedAt, item.username, item.TYPE, item.title))
|
||||
assert len(history), 'No history items have been found.'
|
||||
|
||||
|
||||
@register('core')
|
||||
def test_sessions(plex, account=None):
|
||||
try:
|
||||
mtype = 'video'
|
||||
movie = plex.library.section(MOVIE_SECTION).get(MOVIE_TITLE)
|
||||
client = safe_client(CLIENT, CLIENT_BASEURL, plex)
|
||||
log(2, 'Playing %s..' % movie.title)
|
||||
client.playMedia(movie); time.sleep(5)
|
||||
sessions = plex.sessions()
|
||||
for item in sessions[:20]:
|
||||
log(2, "%s is playing %s '%s' on %s" % (item.username, item.TYPE, item.title, item.player.platform))
|
||||
assert len(sessions), 'No session items have been found.'
|
||||
finally:
|
||||
log(2, 'Stop..')
|
||||
client.stop(mtype); time.sleep(1)
|
||||
log(2, 'Cleanup: Marking %s watched.' % movie.title)
|
||||
movie.markWatched()
|
||||
|
||||
|
||||
#-----------------------
|
||||
# Search
|
||||
#-----------------------
|
||||
|
||||
@register('search,show')
|
||||
def test_search_show(plex, account=None):
|
||||
result_server = plex.search(SHOW_TITLE)
|
||||
result_shows = plex.library.section(SHOW_SECTION).search(SHOW_TITLE)
|
||||
result_movies = plex.library.section(MOVIE_SECTION).search(SHOW_TITLE)
|
||||
log(2, 'Searching for: %s' % SHOW_TITLE)
|
||||
log(4, 'Result Server: %s' % result_server)
|
||||
log(4, 'Result Shows: %s' % result_shows)
|
||||
log(4, 'Result Movies: %s' % result_movies)
|
||||
assert result_server, 'Show not found.'
|
||||
assert result_server == result_shows, 'Show searches not consistent.'
|
||||
assert not result_movies, 'Movie search returned show title.'
|
||||
|
||||
|
||||
@register('search,show')
|
||||
def test_search_with_apostrophe(plex, account=None):
|
||||
show_title = "Marvel's Daredevil" # Test ' in show title
|
||||
result_server = plex.search(show_title)
|
||||
result_shows = plex.library.section(SHOW_SECTION).search(show_title)
|
||||
log(2, 'Searching for: %s' % SHOW_TITLE)
|
||||
log(4, 'Result Server: %s' % result_server)
|
||||
log(4, 'Result Shows: %s' % result_shows)
|
||||
assert result_server, 'Show not found.'
|
||||
assert result_server == result_shows, 'Show searches not consistent.'
|
||||
|
||||
|
||||
@register('search,movie')
|
||||
def test_search_movie(plex, account=None):
|
||||
result_server = plex.search(MOVIE_TITLE)
|
||||
result_library = plex.library.search(MOVIE_TITLE)
|
||||
result_shows = plex.library.section(SHOW_SECTION).search(MOVIE_TITLE)
|
||||
result_movies = plex.library.section(MOVIE_SECTION).search(MOVIE_TITLE)
|
||||
log(2, 'Searching for: %s' % MOVIE_TITLE)
|
||||
log(4, 'Result Server: %s' % result_server)
|
||||
log(4, 'Result Library: %s' % result_library)
|
||||
log(4, 'Result Shows: %s' % result_shows)
|
||||
log(4, 'Result Movies: %s' % result_movies)
|
||||
assert result_server, 'Movie not found.'
|
||||
assert result_server == result_library == result_movies, 'Movie searches not consistent.'
|
||||
assert not result_shows, 'Show search returned show title.'
|
||||
|
||||
|
||||
@register('search,audio')
|
||||
def test_search_audio(plex, account=None):
|
||||
result_server = plex.search(AUDIO_ARTIST)
|
||||
result_library = plex.library.search(AUDIO_ARTIST)
|
||||
result_music = plex.library.section(AUDIO_SECTION).search(AUDIO_ARTIST)
|
||||
log(2, 'Searching for: %s' % AUDIO_ARTIST)
|
||||
log(4, 'Result Server: %s' % result_server)
|
||||
log(4, 'Result Library: %s' % result_library)
|
||||
log(4, 'Result Music: %s' % result_music)
|
||||
assert result_server, 'Artist not found.'
|
||||
assert result_server == result_library == result_music, 'Audio searches not consistent.'
|
||||
|
||||
|
||||
@register('search,audio')
|
||||
def test_search_related(plex, account=None):
|
||||
movies = plex.library.section(MOVIE_SECTION)
|
||||
movie = movies.get(MOVIE_TITLE)
|
||||
related_by_actors = movies.search(actor=movie.actors, maxresults=3)
|
||||
log(2, u'Actors: %s..' % movie.actors)
|
||||
log(2, u'Related by Actors: %s..' % related_by_actors)
|
||||
assert related_by_actors, 'No related movies found by actor.'
|
||||
related_by_genre = movies.search(genre=movie.genres, maxresults=3)
|
||||
log(2, u'Genres: %s..' % movie.genres)
|
||||
log(2, u'Related by Genre: %s..' % related_by_genre)
|
||||
assert related_by_genre, 'No related movies found by genre.'
|
||||
related_by_director = movies.search(director=movie.directors, maxresults=3)
|
||||
log(2, 'Directors: %s..' % movie.directors)
|
||||
log(2, 'Related by Director: %s..' % related_by_director)
|
||||
assert related_by_director, 'No related movies found by director.'
|
||||
|
||||
|
||||
@register('search,show')
|
||||
def test_crazy_search(plex, account=None):
|
||||
movies = plex.library.section(MOVIE_SECTION)
|
||||
movie = movies.get('Jurassic World')
|
||||
log(2, u'Search by Actor: "Chris Pratt"')
|
||||
assert movie in movies.search(actor='Chris Pratt'), 'Unable to search movie by actor.'
|
||||
log(2, u'Search by Director: ["Trevorrow"]')
|
||||
assert movie in movies.search(director=['Trevorrow']), 'Unable to search movie by director.'
|
||||
log(2, u'Search by Year: ["2014", "2015"]')
|
||||
assert movie in movies.search(year=['2014', '2015']), 'Unable to search movie by year.'
|
||||
log(2, u'Filter by Year: 2014')
|
||||
assert movie not in movies.search(year=2014), 'Unable to filter movie by year.'
|
||||
judy = [a for a in movie.actors if 'Judy' in a.tag][0]
|
||||
log(2, u'Search by Unpopular Actor: %s' % judy)
|
||||
assert movie in movies.search(actor=judy.id), 'Unable to filter movie by year.'
|
||||
|
||||
|
||||
#-----------------------
|
||||
# Library Navigation
|
||||
#-----------------------
|
||||
|
||||
@register('navigate,movie,show')
|
||||
def test_navigate_to_movie(plex, account=None):
|
||||
result_library = plex.library.get(MOVIE_TITLE)
|
||||
result_movies = plex.library.section(MOVIE_SECTION).get(MOVIE_TITLE)
|
||||
log(2, 'Navigating to: %s' % MOVIE_TITLE)
|
||||
log(2, 'Result Library: %s' % result_library)
|
||||
log(2, 'Result Movies: %s' % result_movies)
|
||||
assert result_movies, 'Movie navigation not working.'
|
||||
assert result_library == result_movies, 'Movie navigation not consistent.'
|
||||
|
||||
|
||||
@register('navigate,movie,show')
|
||||
def test_navigate_to_show(plex, account=None):
|
||||
result_shows = plex.library.section(SHOW_SECTION).get(SHOW_TITLE)
|
||||
log(2, 'Navigating to: %s' % SHOW_TITLE)
|
||||
log(2, 'Result Shows: %s' % result_shows)
|
||||
assert result_shows, 'Show navigation not working.'
|
||||
|
||||
|
||||
@register('navigate,show')
|
||||
def test_navigate_around_show(plex, account=None):
|
||||
show = plex.library.section(SHOW_SECTION).get(SHOW_TITLE)
|
||||
seasons = show.seasons()
|
||||
season = show.season(SHOW_SEASON)
|
||||
episodes = show.episodes()
|
||||
episode = show.episode(SHOW_EPISODE)
|
||||
log(2, 'Navigating around show: %s' % show)
|
||||
log(2, 'Seasons: %s...' % seasons[:3])
|
||||
log(2, 'Season: %s' % season)
|
||||
log(2, 'Episodes: %s...' % episodes[:3])
|
||||
log(2, 'Episode: %s' % episode)
|
||||
assert SHOW_SEASON in [s.title for s in seasons], 'Unable to list season: %s' % SHOW_SEASON
|
||||
assert SHOW_EPISODE in [e.title for e in episodes], 'Unable to list episode: %s' % SHOW_EPISODE
|
||||
assert show.season(SHOW_SEASON) == season, 'Unable to get show season: %s' % SHOW_SEASON
|
||||
assert show.episode(SHOW_EPISODE) == episode, 'Unable to get show episode: %s' % SHOW_EPISODE
|
||||
assert season.episode(SHOW_EPISODE) == episode, 'Unable to get season episode: %s' % SHOW_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.'
|
||||
|
||||
|
||||
@register('navigate,audio')
|
||||
def test_navigate_around_artist(plex, account=None):
|
||||
artist = plex.library.section(AUDIO_SECTION).get(AUDIO_ARTIST)
|
||||
albums = artist.albums()
|
||||
album = artist.album(AUDIO_ALBUM)
|
||||
tracks = artist.tracks()
|
||||
track = artist.track(AUDIO_TRACK)
|
||||
log(2, 'Navigating around artist: %s' % artist)
|
||||
log(2, 'Albums: %s...' % albums[:3])
|
||||
log(2, 'Album: %s' % album)
|
||||
log(2, 'Tracks: %s...' % tracks[:3])
|
||||
log(2, 'Track: %s' % track)
|
||||
assert AUDIO_ALBUM in [a.title for a in albums], 'Unable to list album: %s' % AUDIO_ALBUM
|
||||
assert AUDIO_TRACK in [e.title for e in tracks], 'Unable to list track: %s' % AUDIO_TRACK
|
||||
assert artist.album(AUDIO_ALBUM) == album, 'Unable to get artist album: %s' % AUDIO_ALBUM
|
||||
assert artist.track(AUDIO_TRACK) == track, 'Unable to get artist track: %s' % AUDIO_TRACK
|
||||
assert album.track(AUDIO_TRACK) == track, 'Unable to get album track: %s' % AUDIO_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.'
|
||||
|
||||
|
||||
#-----------------------
|
||||
# Library Actions
|
||||
#-----------------------
|
||||
|
||||
@register('action,movie')
|
||||
def test_mark_movie_watched(plex, account=None):
|
||||
movie = plex.library.section(MOVIE_SECTION).get(MOVIE_TITLE)
|
||||
movie.markUnwatched()
|
||||
log(2, 'Marking movie watched: %s' % movie)
|
||||
log(2, 'View count: %s' % movie.viewCount)
|
||||
movie.markWatched()
|
||||
log(2, 'View count: %s' % movie.viewCount)
|
||||
assert movie.viewCount == 1, 'View count 0 after watched.'
|
||||
movie.markUnwatched()
|
||||
log(2, 'View count: %s' % movie.viewCount)
|
||||
assert movie.viewCount == 0, 'View count 1 after unwatched.'
|
||||
|
||||
|
||||
@register('action')
|
||||
def test_refresh_section(plex, account=None):
|
||||
shows = plex.library.section(MOVIE_SECTION)
|
||||
shows.refresh()
|
||||
|
||||
|
||||
@register('action,movie')
|
||||
def test_refresh_video(plex, account=None):
|
||||
result = plex.search(MOVIE_TITLE)
|
||||
result[0].refresh()
|
||||
|
||||
|
||||
#-----------------------
|
||||
# Playlists
|
||||
#-----------------------
|
||||
|
||||
@register('playlist')
|
||||
def test_list_playlists(plex, account=None):
|
||||
playlists = plex.playlists()
|
||||
for playlist in playlists:
|
||||
log(2, playlist.title)
|
||||
|
||||
|
||||
@register('playlist')
|
||||
def test_create_playlist(plex, account=None):
|
||||
# create the playlist
|
||||
title = 'test_create_playlist'
|
||||
log(2, 'Creating playlist %s..' % title)
|
||||
episodes = plex.library.section(SHOW_SECTION).get(SHOW_TITLE).episodes()
|
||||
playlist = plex.createPlaylist(title, episodes[:3])
|
||||
try:
|
||||
items = playlist.items()
|
||||
log(4, 'Title: %s' % playlist.title)
|
||||
log(4, 'Items: %s' % items)
|
||||
log(4, 'Duration: %s min' % int(playlist.duration / 60000.0))
|
||||
assert playlist.title == title, 'Playlist not created successfully.'
|
||||
assert len(items) == 3, 'Playlist does not contain 3 items.'
|
||||
assert items[0].ratingKey == episodes[0].ratingKey, 'Items not in proper order [0a].'
|
||||
assert items[1].ratingKey == episodes[1].ratingKey, 'Items not in proper order [1a].'
|
||||
assert items[2].ratingKey == episodes[2].ratingKey, 'Items not in proper order [2a].'
|
||||
# move items around (b)
|
||||
log(2, 'Testing move items..')
|
||||
playlist.moveItem(items[1])
|
||||
items = playlist.items()
|
||||
assert items[0].ratingKey == episodes[1].ratingKey, 'Items not in proper order [0b].'
|
||||
assert items[1].ratingKey == episodes[0].ratingKey, 'Items not in proper order [1b].'
|
||||
assert items[2].ratingKey == episodes[2].ratingKey, 'Items not in proper order [2b].'
|
||||
# move items around (c)
|
||||
playlist.moveItem(items[0], items[1])
|
||||
items = playlist.items()
|
||||
assert items[0].ratingKey == episodes[0].ratingKey, 'Items not in proper order [0c].'
|
||||
assert items[1].ratingKey == episodes[1].ratingKey, 'Items not in proper order [1c].'
|
||||
assert items[2].ratingKey == episodes[2].ratingKey, 'Items not in proper order [2c].'
|
||||
# add an item
|
||||
log(2, 'Testing add item: %s' % episodes[3])
|
||||
playlist.addItems(episodes[3])
|
||||
items = playlist.items()
|
||||
log(4, '4th Item: %s' % items[3])
|
||||
assert items[3].ratingKey == episodes[3].ratingKey, 'Missing added item: %s' % episodes[3]
|
||||
# add two items
|
||||
log(2, 'Testing add item: %s' % episodes[4:6])
|
||||
playlist.addItems(episodes[4:6])
|
||||
items = playlist.items()
|
||||
log(4, '5th+ Items: %s' % items[4:])
|
||||
assert items[4].ratingKey == episodes[4].ratingKey, 'Missing added item: %s' % episodes[4]
|
||||
assert items[5].ratingKey == episodes[5].ratingKey, 'Missing added item: %s' % episodes[5]
|
||||
assert len(items) == 6, 'Playlist should have 6 items, %s found' % len(items)
|
||||
# remove item
|
||||
toremove = items[3]
|
||||
log(2, 'Testing remove item: %s' % toremove)
|
||||
playlist.removeItem(toremove)
|
||||
items = playlist.items()
|
||||
assert toremove not in items, 'Removed item still in playlist: %s' % items[3]
|
||||
assert len(items) == 5, 'Playlist should have 5 items, %s found' % len(items)
|
||||
finally:
|
||||
playlist.delete()
|
||||
|
||||
|
||||
@register('playlist,client')
|
||||
def test_playlist(plex, account=None):
|
||||
client = safe_client(CLIENT, CLIENT_BASEURL, plex)
|
||||
artist = plex.library.section(AUDIO_SECTION).get(AUDIO_ARTIST)
|
||||
album = artist.album(AUDIO_ALBUM)
|
||||
playlist = plex.createPlaylist('test_play_playlist', album)
|
||||
try:
|
||||
log(2, 'Playing playlist: %s' % playlist)
|
||||
client.playMedia(playlist); time.sleep(5)
|
||||
log(2, 'stop..')
|
||||
client.stop('music'); time.sleep(1)
|
||||
finally:
|
||||
playlist.delete()
|
||||
|
||||
|
||||
@register('playlist,photos')
|
||||
def test_playlist_photos(plex, account=None):
|
||||
client = safe_client('iphone-mike', CLIENT_BASEURL, plex)
|
||||
photosection = plex.library.section(PHOTO_SECTION)
|
||||
album = photosection.get(PHOTO_ALBUM)
|
||||
photos = album.photos()
|
||||
playlist = plex.createPlaylist('test_play_playlist2', photos)
|
||||
try:
|
||||
client.playMedia(playlist)
|
||||
for i in range(3):
|
||||
time.sleep(2)
|
||||
client.skipNext(mtype='photo')
|
||||
finally:
|
||||
playlist.delete()
|
||||
|
||||
|
||||
@register('playlist,photos')
|
||||
def test_play_photos(plex, account=None):
|
||||
client = safe_client('iphone-mike', CLIENT_BASEURL, plex)
|
||||
photosection = plex.library.section(PHOTO_SECTION)
|
||||
album = photosection.get(PHOTO_ALBUM)
|
||||
photos = album.photos()
|
||||
for photo in photos[:4]:
|
||||
client.playMedia(photo)
|
||||
time.sleep(2)
|
||||
|
||||
#-----------------------
|
||||
# Metadata
|
||||
#-----------------------
|
||||
|
||||
@register('meta,movie')
|
||||
def test_partial_video(plex, account=None):
|
||||
movie_title = 'Bedside Detective'
|
||||
result = plex.search(movie_title)
|
||||
log(2, 'Title: %s' % result[0].title)
|
||||
log(2, 'Original Title: %s' % result[0].originalTitle)
|
||||
assert(result[0].originalTitle != NA)
|
||||
|
||||
|
||||
@register('meta,movie,show')
|
||||
def test_list_media_files(plex, account=None):
|
||||
# Fetch file names from the tv show
|
||||
episode_files = []
|
||||
episode = plex.library.section(SHOW_SECTION).get(SHOW_TITLE).episodes()[-1]
|
||||
log(2, 'Episode Files: %s' % episode)
|
||||
for media in episode.media:
|
||||
for part in media.parts:
|
||||
log(4, part.file)
|
||||
episode_files.append(part.file)
|
||||
assert filter(None, episode_files), 'No show files have been listed.'
|
||||
# Fetch file names from the movie
|
||||
movie_files = []
|
||||
movie = plex.library.section(MOVIE_SECTION).get(MOVIE_TITLE)
|
||||
log(2, 'Movie Files: %s' % movie)
|
||||
for media in movie.media:
|
||||
for part in media.parts:
|
||||
log(4, part.file)
|
||||
movie_files.append(part.file)
|
||||
assert filter(None, movie_files), 'No movie files have been listed.'
|
||||
|
||||
|
||||
@register('meta,movie')
|
||||
def test_list_video_tags(plex, account=None):
|
||||
movies = plex.library.section(MOVIE_SECTION)
|
||||
movie = movies.get(MOVIE_TITLE)
|
||||
log(2, 'Countries: %s' % movie.countries[0:3])
|
||||
log(2, 'Directors: %s' % movie.directors[0:3])
|
||||
log(2, 'Genres: %s' % movie.genres[0:3])
|
||||
log(2, 'Producers: %s' % movie.producers[0:3])
|
||||
log(2, 'Actors: %s' % movie.actors[0:3])
|
||||
log(2, 'Writers: %s' % movie.writers[0:3])
|
||||
assert filter(None, movie.countries), 'No countries listed for movie.'
|
||||
assert filter(None, movie.directors), 'No directors listed for movie.'
|
||||
assert filter(None, movie.genres), 'No genres listed for movie.'
|
||||
assert filter(None, movie.producers), 'No producers listed for movie.'
|
||||
assert filter(None, movie.actors), 'No actors listed for movie.'
|
||||
assert filter(None, movie.writers), 'No writers listed for movie.'
|
||||
log(2, 'List movies with same director: %s' % movie.directors[0])
|
||||
related = movies.search(None, director=movie.directors[0])
|
||||
log(4, related[0:3])
|
||||
assert movie in related, 'Movie was not found in related directors search.'
|
||||
|
||||
|
||||
@register('client')
|
||||
def test_list_video_streams(plex, account=None):
|
||||
movie = plex.library.section(MOVIE_SECTION).get('John Wick')
|
||||
videostreams = [s.language for s in movie.videoStreams]
|
||||
audiostreams = [s.language for s in movie.audioStreams]
|
||||
subtitlestreams = [s.language for s in movie.subtitleStreams]
|
||||
log(2, 'Video Streams: %s' % ', '.join(videostreams[0:5]))
|
||||
log(2, 'Audio Streams: %s' % ', '.join(audiostreams[0:5]))
|
||||
log(2, 'Subtitle Streams: %s' % ', '.join(subtitlestreams[0:5]))
|
||||
assert filter(None, videostreams), 'No video streams listed for movie.'
|
||||
assert filter(None, audiostreams), 'No audio streams listed for movie.'
|
||||
assert filter(None, subtitlestreams), 'No subtitle streams listed for movie.'
|
||||
|
||||
|
||||
@register('meta,audio')
|
||||
def test_list_audio_tags(plex, account=None):
|
||||
section = plex.library.section(AUDIO_SECTION)
|
||||
artist = section.get(AUDIO_ARTIST)
|
||||
track = artist.get(AUDIO_TRACK)
|
||||
log(2, 'Countries: %s' % artist.countries[0:3])
|
||||
log(2, 'Genres: %s' % artist.genres[0:3])
|
||||
log(2, 'Similar: %s' % artist.similar[0:3])
|
||||
log(2, 'Album Genres: %s' % track.album().genres[0:3])
|
||||
log(2, 'Moods: %s' % track.moods[0:3])
|
||||
log(2, 'Media: %s' % track.media[0:3])
|
||||
assert filter(None, artist.countries), 'No countries listed for artist.'
|
||||
assert filter(None, artist.genres), 'No genres listed for artist.'
|
||||
assert filter(None, artist.similar), 'No similar artists listed.'
|
||||
assert filter(None, track.album().genres), 'No genres listed for album.'
|
||||
assert filter(None, track.moods), 'No moods listed for track.'
|
||||
assert filter(None, track.media), 'No media listed for track.'
|
||||
|
||||
|
||||
@register('meta,show')
|
||||
def test_is_watched(plex, account=None):
|
||||
show = plex.library.section(SHOW_SECTION).get(SHOW_TITLE)
|
||||
episode = show.get(SHOW_EPISODE)
|
||||
log(2, '%s isWatched: %s' % (episode.title, episode.isWatched))
|
||||
movie = plex.library.section(MOVIE_SECTION).get(MOVIE_TITLE)
|
||||
log(2, '%s isWatched: %s' % (movie.title, movie.isWatched))
|
||||
|
||||
|
||||
@register('meta,movie')
|
||||
def test_fetch_details_not_in_search_result(plex, account=None):
|
||||
# Search results only contain 3 actors per movie. This text checks there
|
||||
# are more than 3 results in the actor list (meaning it fetched the detailed
|
||||
# information behind the scenes).
|
||||
result = plex.search(MOVIE_TITLE)[0]
|
||||
actors = result.actors
|
||||
assert len(actors) >= 4, 'Unable to fetch detailed movie information'
|
||||
log(2, '%s actors found.' % len(actors))
|
||||
|
||||
|
||||
@register('movie,audio')
|
||||
def test_stream_url(plex, account=None):
|
||||
movie = plex.library.section(MOVIE_SECTION).get(MOVIE_TITLE)
|
||||
episode = plex.library.section(SHOW_SECTION).get(SHOW_TITLE).episodes()[-1]
|
||||
track = plex.library.section(AUDIO_SECTION).get(AUDIO_ARTIST).get(AUDIO_TRACK)
|
||||
log(2, 'Movie: vlc "%s"' % movie.getStreamURL())
|
||||
log(2, 'Episode: vlc "%s"' % episode.getStreamURL())
|
||||
log(2, 'Track: cvlc "%s"' % track.getStreamURL())
|
||||
|
||||
|
||||
@register('audio')
|
||||
def test_list_audioalbums(plex, account=None):
|
||||
music = plex.library.section(AUDIO_SECTION)
|
||||
albums = music.albums()
|
||||
for album in albums[:10]:
|
||||
log(2, '%s - %s [%s]' % (album.artist().title, album.title, album.year))
|
||||
|
||||
|
||||
@register('photo')
|
||||
def test_list_photoalbums(plex, account=None):
|
||||
photosection = plex.library.section(PHOTO_SECTION)
|
||||
photoalbums = photosection.all()
|
||||
log(2, 'Listing albums..')
|
||||
for album in photoalbums[:10]:
|
||||
log(4, '%s' % album.title)
|
||||
assert len(photoalbums), 'No photoalbums found.'
|
||||
album = photosection.get(PHOTO_ALBUM)
|
||||
photos = album.photos()
|
||||
for photo in photos[:10]:
|
||||
log(4, '%s (%sx%s)' % (basename(photo.media[0].parts[0].file), photo.media[0].width, photo.media[0].height))
|
||||
assert len(photoalbums), 'No photos found.'
|
||||
|
||||
|
||||
#-----------------------
|
||||
# Play Queue
|
||||
#-----------------------
|
||||
|
||||
@register('queue')
|
||||
def test_play_queues(plex, account=None):
|
||||
episode = plex.library.section(SHOW_SECTION).get(SHOW_TITLE).get(SHOW_EPISODE)
|
||||
playqueue = plex.createPlayQueue(episode)
|
||||
assert len(playqueue.items) == 1, 'No items in play queue.'
|
||||
assert playqueue.items[0].title == SHOW_EPISODE, 'Wrong show queued.'
|
||||
assert playqueue.playQueueID, 'Play queue ID not set.'
|
||||
|
||||
|
||||
#-----------------------
|
||||
# Client
|
||||
#-----------------------
|
||||
|
||||
@register('client')
|
||||
def test_list_clients(plex, account=None):
|
||||
clients = [c.title for c in plex.clients()]
|
||||
log(2, 'Clients: %s' % ', '.join(clients or []))
|
||||
assert clients, 'Server is not listing any clients.'
|
||||
|
||||
|
||||
@register('client')
|
||||
def test_client_navigation(plex, account=None):
|
||||
client = safe_client(CLIENT, CLIENT_BASEURL, plex)
|
||||
_navigate(plex, client)
|
||||
|
||||
|
||||
@register('client,proxy')
|
||||
def test_client_navigation_via_proxy(plex, account=None):
|
||||
client = safe_client(CLIENT, CLIENT_BASEURL, plex)
|
||||
client.proxyThroughServer()
|
||||
_navigate(plex, client)
|
||||
|
||||
|
||||
def _navigate(plex, client):
|
||||
episode = plex.library.section(SHOW_SECTION).get(SHOW_TITLE).get(SHOW_EPISODE)
|
||||
artist = plex.library.section(AUDIO_SECTION).get(AUDIO_ARTIST)
|
||||
log(2, 'Client: %s (%s)' % (client.title, client.product))
|
||||
log(2, 'Capabilities: %s' % client.protocolCapabilities)
|
||||
# Move around a bit
|
||||
log(2, 'Browsing around..')
|
||||
client.moveDown(); time.sleep(0.5)
|
||||
client.moveDown(); time.sleep(0.5)
|
||||
client.moveDown(); time.sleep(0.5)
|
||||
client.select(); time.sleep(3)
|
||||
client.moveRight(); time.sleep(0.5)
|
||||
client.moveRight(); time.sleep(0.5)
|
||||
client.moveLeft(); time.sleep(0.5)
|
||||
client.select(); time.sleep(3)
|
||||
client.goBack(); time.sleep(1)
|
||||
client.goBack(); time.sleep(3)
|
||||
# Go directly to media
|
||||
log(2, 'Navigating to %s..' % episode.title)
|
||||
client.goToMedia(episode); time.sleep(5)
|
||||
log(2, 'Navigating to %s..' % artist.title)
|
||||
client.goToMedia(artist); time.sleep(5)
|
||||
log(2, 'Navigating home..')
|
||||
client.goToHome(); time.sleep(5)
|
||||
client.moveUp(); time.sleep(0.5)
|
||||
client.moveUp(); time.sleep(0.5)
|
||||
client.moveUp(); time.sleep(0.5)
|
||||
# Show context menu
|
||||
client.contextMenu(); time.sleep(3)
|
||||
client.goBack(); time.sleep(5)
|
||||
|
||||
|
||||
@register('client')
|
||||
def test_video_playback(plex, account=None):
|
||||
client = safe_client(CLIENT, CLIENT_BASEURL, plex)
|
||||
_video_playback(plex, client)
|
||||
|
||||
|
||||
@register('client,proxy')
|
||||
def test_video_playback_via_proxy(plex, account=None):
|
||||
client = safe_client(CLIENT, CLIENT_BASEURL, plex)
|
||||
client.proxyThroughServer()
|
||||
_video_playback(plex, client)
|
||||
|
||||
|
||||
def _video_playback(plex, client):
|
||||
try:
|
||||
mtype = 'video'
|
||||
movie = plex.library.section(MOVIE_SECTION).get(MOVIE_TITLE)
|
||||
subs = [s for s in movie.subtitleStreams if s.language == 'English']
|
||||
log(2, 'Client: %s (%s)' % (client.title, client.product))
|
||||
log(2, 'Capabilities: %s' % client.protocolCapabilities)
|
||||
log(2, 'Playing to %s..' % movie.title)
|
||||
client.playMedia(movie); time.sleep(5)
|
||||
log(2, 'Pause..')
|
||||
client.pause(mtype); time.sleep(2)
|
||||
log(2, 'Step Forward..')
|
||||
client.stepForward(mtype); time.sleep(5)
|
||||
log(2, 'Play..')
|
||||
client.play(mtype); time.sleep(3)
|
||||
log(2, 'Seek to 10m..')
|
||||
client.seekTo(10*60*1000); time.sleep(5)
|
||||
log(2, 'Disable Subtitles..')
|
||||
client.setSubtitleStream(0, mtype); time.sleep(10)
|
||||
log(2, 'Load English Subtitles %s..' % subs[0].id)
|
||||
client.setSubtitleStream(subs[0].id, mtype); time.sleep(10)
|
||||
log(2, 'Stop..')
|
||||
client.stop(mtype); time.sleep(1)
|
||||
finally:
|
||||
log(2, 'Cleanup: Marking %s watched.' % movie.title)
|
||||
movie.markWatched()
|
||||
|
||||
|
||||
@register('client')
|
||||
def test_client_timeline(plex, account=None):
|
||||
client = safe_client(CLIENT, CLIENT_BASEURL, plex)
|
||||
_test_timeline(plex, client)
|
||||
|
||||
|
||||
@register('client,proxy')
|
||||
def test_client_timeline_via_proxy(plex, account=None):
|
||||
client = safe_client(CLIENT, CLIENT_BASEURL, plex)
|
||||
client.proxyThroughServer()
|
||||
_test_timeline(plex, client)
|
||||
|
||||
|
||||
def _test_timeline(plex, client):
|
||||
try:
|
||||
mtype = 'video'
|
||||
client = safe_client(CLIENT, CLIENT_BASEURL, plex)
|
||||
movie = plex.library.section(MOVIE_SECTION).get(MOVIE_TITLE)
|
||||
time.sleep(30) # previous test may have played media..
|
||||
playing = client.isPlayingMedia()
|
||||
log(2, 'Playing Media: %s' % playing)
|
||||
assert playing is False, 'isPlayingMedia() should have returned False.'
|
||||
client.playMedia(movie); time.sleep(30)
|
||||
playing = client.isPlayingMedia()
|
||||
log(2, 'Playing Media: %s' % playing)
|
||||
assert playing is True, 'isPlayingMedia() should have returned True.'
|
||||
client.stop(mtype); time.sleep(30)
|
||||
playing = client.isPlayingMedia()
|
||||
log(2, 'Playing Media: %s' % playing)
|
||||
assert playing is False, 'isPlayingMedia() should have returned False.'
|
||||
finally:
|
||||
log(2, 'Cleanup: Marking %s watched.' % movie.title)
|
||||
movie.markWatched()
|
||||
|
||||
|
||||
# TODO: MAKE THIS WORK..
|
||||
# @register('client')
|
||||
def test_sync_items(plex, account=None):
|
||||
device = account.getDevice('device-uuid')
|
||||
# fetch the sync items via the device sync list
|
||||
for item in device.sync_items():
|
||||
# fetch the media object associated with the sync item
|
||||
for video in item.get_media():
|
||||
# fetch the media parts (actual video/audio streams) associated with the media
|
||||
for part in video.iterParts():
|
||||
print('Found media to download!')
|
||||
# make the relevant sync id (media part) as downloaded
|
||||
# this tells the server that this device has successfully downloaded this media part of this sync item
|
||||
item.mark_as_done(part.sync_id)
|
||||
|
||||
|
||||
#-----------------------
|
||||
# MyPlex Resources
|
||||
#-----------------------
|
||||
|
||||
@register('myplex')
|
||||
def test_myplex_accounts(plex, account=None):
|
||||
assert account, 'Must specify username, password & resource to run this test.'
|
||||
log(2, 'MyPlexAccount:')
|
||||
log(4, 'username: %s' % account.username)
|
||||
log(4, 'authenticationToken: %s' % account.authenticationToken)
|
||||
log(4, 'email: %s' % account.email)
|
||||
log(4, 'home: %s' % account.home)
|
||||
log(4, '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()
|
||||
log(2, 'Local PlexServer.account():')
|
||||
log(4, 'username: %s' % account.username)
|
||||
log(4, 'authToken: %s' % account.authToken)
|
||||
log(4, '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'
|
||||
|
||||
|
||||
@register('myplex,resource')
|
||||
def test_myplex_resources(plex, account=None):
|
||||
assert account, 'Must specify username, password & resource to run this test.'
|
||||
resources = account.resources()
|
||||
for resource in resources:
|
||||
name = resource.name or 'Unknown'
|
||||
connections = [c.uri for c in resource.connections]
|
||||
connections = ', '.join(connections) if connections else 'None'
|
||||
log(2, '%s (%s): %s' % (name, resource.product, connections))
|
||||
assert resources, 'No resources found for account: %s' % account.name
|
||||
|
||||
|
||||
@register('myplex,devices')
|
||||
def test_myplex_devices(plex, account=None):
|
||||
assert account, 'Must specify username, password & resource to run this test.'
|
||||
devices = account.devices()
|
||||
for device in devices:
|
||||
name = device.name or 'Unknown'
|
||||
connections = ', '.join(device.connections) if device.connections else 'None'
|
||||
log(2, '%s (%s): %s' % (name, device.product, connections))
|
||||
assert devices, 'No devices found for account: %s' % account.name
|
||||
|
||||
|
||||
@register('myplex')
|
||||
def test_myplex_users(plex, account=None):
|
||||
users = account.users()
|
||||
assert users, 'Found no users on account: %s' % account.name
|
||||
log(2, 'Found %s users.' % len(users))
|
||||
user = account.user('sdfsdfplex')
|
||||
log(2, 'Found user: %s' % user)
|
||||
assert users, 'Could not find user sdfsdfplex'
|
||||
|
||||
|
||||
@register('myplex,devices')
|
||||
def test_myplex_connect_to_device(plex, account=None):
|
||||
assert account, 'Must specify username, password & resource to run this test.'
|
||||
devices = account.devices()
|
||||
for device in devices:
|
||||
if device.name == CLIENT and len(device.connections):
|
||||
break
|
||||
client = device.connect()
|
||||
log(2, 'Connected to client: %s (%s)' % (client.title, client.product))
|
||||
assert client, 'Unable to connect to device'
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# There are three ways to authenticate:
|
||||
# 1. If the server is running on localhost, just run without any auth.
|
||||
# 2. Pass in --username, --password, and --resource.
|
||||
# 3. Pass in --baseurl, --token
|
||||
parser = argparse.ArgumentParser(description='Run PlexAPI tests.')
|
||||
parser.add_argument('-u', '--username', help='Username for your MyPlex account.')
|
||||
parser.add_argument('-p', '--password', help='Password for your MyPlex account.')
|
||||
parser.add_argument('-r', '--resource', help='Name of the Plex resource (requires user/pass).')
|
||||
parser.add_argument('-b', '--baseurl', help='Baseurl needed for auth token authentication')
|
||||
parser.add_argument('-t', '--token', help='Auth token (instead of user/pass)')
|
||||
parser.add_argument('-q', '--query', help='Only run the specified tests.')
|
||||
parser.add_argument('-v', '--verbose', default=False, action='store_true', help='Print verbose logging.')
|
||||
args = parser.parse_args()
|
||||
run_tests(__name__, args)
|
|
@ -1,88 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Test Library Functions
|
||||
"""
|
||||
import sys, traceback
|
||||
import datetime, time
|
||||
from plexapi import server
|
||||
from plexapi.client import PlexClient
|
||||
from plexapi.exceptions import NotFound
|
||||
from plexapi.myplex import MyPlexAccount
|
||||
|
||||
COLORS = {'blue':'\033[94m', 'green':'\033[92m', 'red':'\033[91m', 'yellow':'\033[93m', 'end':'\033[0m'}
|
||||
|
||||
|
||||
registered = []
|
||||
def register(tags=''):
|
||||
def wrap2(func):
|
||||
registered.append({'name':func.__name__, 'tags':tags.split(','), 'func':func})
|
||||
def wrap1(*args, **kwargs): # noqa
|
||||
func(*args, **kwargs)
|
||||
return wrap1
|
||||
return wrap2
|
||||
|
||||
|
||||
def log(indent, message, color=None):
|
||||
dt = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]
|
||||
if color:
|
||||
return sys.stdout.write('%s: %s%s%s%s\n' % (dt, ' '*indent, COLORS[color], message, COLORS['end']))
|
||||
return sys.stdout.write('%s: %s%s\n' % (dt, ' '*indent, message))
|
||||
|
||||
|
||||
def fetch_server(args):
|
||||
if args.resource and args.username and args.password:
|
||||
log(0, 'Signing in as MyPlex account %s..' % args.username)
|
||||
account = MyPlexAccount.signin(args.username, args.password)
|
||||
log(0, 'Connecting to Plex server %s..' % args.resource)
|
||||
return account.resource(args.resource).connect(), account
|
||||
elif args.baseurl and args.token:
|
||||
log(0, 'Connecting to Plex server %s..' % args.baseurl)
|
||||
return server.PlexServer(args.baseurl, args.token), None
|
||||
return server.PlexServer(), None
|
||||
|
||||
|
||||
def safe_client(name, baseurl, server):
|
||||
try:
|
||||
return server.client(name)
|
||||
except NotFound as err:
|
||||
log(2, 'Warning: %s' % err)
|
||||
return PlexClient(baseurl, server=server)
|
||||
|
||||
|
||||
def iter_tests(query):
|
||||
tags = query[5:].split(',') if query and query.startswith('tags:') else ''
|
||||
for test in registered:
|
||||
if not query:
|
||||
yield test
|
||||
elif tags:
|
||||
matching_tags = [t for t in tags if t in test['tags']]
|
||||
if matching_tags: yield test
|
||||
elif query == test['name']:
|
||||
yield test
|
||||
|
||||
|
||||
def run_tests(module, args):
|
||||
plex, account = fetch_server(args)
|
||||
tests = {'passed':0, 'failed':0}
|
||||
for test in iter_tests(args.query):
|
||||
starttime = time.time()
|
||||
log(0, '%s (%s)' % (test['name'], ','.join(test['tags'])))
|
||||
try:
|
||||
test['func'](plex, account)
|
||||
runtime = time.time() - starttime
|
||||
log(2, 'PASS! (runtime: %.3fs)' % runtime, 'blue')
|
||||
tests['passed'] += 1
|
||||
except Exception as err:
|
||||
errstr = str(err)
|
||||
errstr += '\n%s' % traceback.format_exc() if args.verbose else ''
|
||||
log(2, 'FAIL!: %s' % errstr, 'red')
|
||||
tests['failed'] += 1
|
||||
log(0, '')
|
||||
log(0, 'Tests Run: %s' % sum(tests.values()))
|
||||
log(0, 'Tests Passed: %s' % tests['passed'])
|
||||
if tests['failed']:
|
||||
log(0, 'Tests Failed: %s' % tests['failed'], 'red')
|
||||
if not tests['failed']:
|
||||
log(0, '')
|
||||
log(0, 'EVERYTHING OK!! :)')
|
||||
raise SystemExit(tests['failed'])
|
Loading…
Reference in a new issue