mirror of
https://github.com/pkkid/python-plexapi
synced 2024-11-10 14:14:19 +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
|
syntax: glob
|
||||||
*.db
|
*.db
|
||||||
|
*.egg-info
|
||||||
*.log
|
*.log
|
||||||
*.pyc
|
*.pyc
|
||||||
*.swp
|
|
||||||
*.sublime-*
|
*.sublime-*
|
||||||
|
*.swp
|
||||||
*__pycache__*
|
*__pycache__*
|
||||||
dist
|
|
||||||
build
|
|
||||||
*.egg-info
|
|
||||||
.idea/
|
|
||||||
lib/
|
|
||||||
bin/
|
|
||||||
include/
|
|
||||||
.cache/
|
.cache/
|
||||||
|
.idea/
|
||||||
.Python
|
.Python
|
||||||
|
bin/
|
||||||
|
build
|
||||||
|
dist
|
||||||
|
docs/_build/
|
||||||
|
include/
|
||||||
|
lib/
|
||||||
pip-selfcheck.json
|
pip-selfcheck.json
|
||||||
|
|
||||||
pyvenv.cfg
|
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,
|
* Redistributions in binary form must reproduce the above copyright notice,
|
||||||
this list of conditions and the following disclaimer in the documentation
|
this list of conditions and the following disclaimer in the documentation
|
||||||
and/or other materials provided with the distribution.
|
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
|
may be used to endorse or promote products derived from this software without
|
||||||
specific prior written permission.
|
specific prior written permission.
|
||||||
|
|
||||||
|
|
|
@ -1,2 +1,2 @@
|
||||||
include README.md
|
include README.md
|
||||||
include requirements.pip
|
include requirements.txt
|
15
README.md
15
README.md
|
@ -1,4 +1,10 @@
|
||||||
## PlexAPI ##
|
## 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.
|
Python bindings for the Plex API.
|
||||||
|
|
||||||
* Navigate local or remote shared libraries.
|
* Navigate local or remote shared libraries.
|
||||||
|
@ -43,11 +49,10 @@ plex = PlexServer(baseurl, token)
|
||||||
#### Usage Examples ####
|
#### Usage Examples ####
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Example 1: List all unwatched content in library.
|
# Example 1: List all unwatched movies.
|
||||||
for section in plex.library.sections():
|
movies = plex.library.section('Movies')
|
||||||
print('Unwatched content in %s:' % section.title)
|
for video in movies.search(unwatched=True):
|
||||||
for video in section.unwatched():
|
print(video.title)
|
||||||
print(' %s' % video.title)
|
|
||||||
```
|
```
|
||||||
```python
|
```python
|
||||||
# Example 2: Mark all Conan episodes watched.
|
# 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 logging.handlers import RotatingFileHandler
|
||||||
from platform import uname
|
from platform import uname
|
||||||
from plexapi.config import PlexConfig, reset_base_headers
|
from plexapi.config import PlexConfig, reset_base_headers
|
||||||
|
from plexapi.utils import SecretsFilter
|
||||||
from uuid import getnode
|
from uuid import getnode
|
||||||
|
|
||||||
|
|
||||||
# Load User Defined Config
|
# 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)
|
CONFIG = PlexConfig(CONFIG_PATH)
|
||||||
|
|
||||||
# Core Settings
|
# Core Settings
|
||||||
|
@ -40,3 +41,6 @@ if logfile:
|
||||||
loghandler.setFormatter(logging.Formatter(logformat))
|
loghandler.setFormatter(logging.Formatter(logformat))
|
||||||
log.addHandler(loghandler)
|
log.addHandler(loghandler)
|
||||||
log.setLevel(loglevel)
|
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 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
from plexapi import media, utils
|
from plexapi import media, utils
|
||||||
from plexapi.utils import Playable, PlexPartialObject
|
from plexapi.utils import Playable, PlexPartialObject
|
||||||
|
|
||||||
|
@ -7,45 +6,37 @@ NA = utils.NA
|
||||||
|
|
||||||
|
|
||||||
class Audio(PlexPartialObject):
|
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:
|
Parameters:
|
||||||
addedAt (int): int from epoch, datetime.datetime
|
server (:class:`~plexapi.server.PlexServer`): PlexServer this client is connected to (optional)
|
||||||
index (sting): 1
|
data (ElementTree): Response from PlexServer used to build this object (optional).
|
||||||
key (str): Fx /library/metadata/102631
|
initpath (str): Relative path requested when retrieving specified `data` (optional).
|
||||||
lastViewedAt (datetime.datetime): parse int into datetime.datetime.
|
|
||||||
librarySectionID (int):
|
Attributes:
|
||||||
listType (str): audio
|
addedAt (datetime): Datetime this item was added to the library.
|
||||||
ratingKey (int): Unique key to identify this item
|
index (sting): Index Number (often the track number).
|
||||||
summary (str): Summery of the artist, track, album
|
key (str): API URL (/library/metadata/<ratingkey>).
|
||||||
thumb (str): Url to thumb image
|
lastViewedAt (datetime): Datetime item was last accessed.
|
||||||
title (str): Fx Aerosmith
|
librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID.
|
||||||
titleSort (str): Defaults title if None
|
listType (str): Hardcoded as 'audio' (useful for search filters).
|
||||||
TYPE (str): overwritten by subclass
|
ratingKey (int): Unique key identifying this item.
|
||||||
type (string, NA): Description
|
summary (str): Summary of the artist, track, or album.
|
||||||
updatedAt (datatime.datetime): parse int to datetime.datetime
|
thumb (str): URL to thumbnail image.
|
||||||
viewCount (int): How many time has this item been played
|
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
|
TYPE = None
|
||||||
|
|
||||||
def __init__(self, server, data, initpath):
|
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)
|
super(Audio, self).__init__(data, initpath, server)
|
||||||
|
|
||||||
def _loadData(self, data):
|
def _loadData(self, data):
|
||||||
"""Used to set the attributes.
|
""" Load attribute values from Plex XML response. """
|
||||||
|
|
||||||
Args:
|
|
||||||
data (Element): XML reponse from PMS as Element
|
|
||||||
normally built from server.query
|
|
||||||
"""
|
|
||||||
self.listType = 'audio'
|
self.listType = 'audio'
|
||||||
self.addedAt = utils.toDatetime(data.attrib.get('addedAt', NA))
|
self.addedAt = utils.toDatetime(data.attrib.get('addedAt', NA))
|
||||||
self.index = data.attrib.get('index', NA)
|
self.index = data.attrib.get('index', NA)
|
||||||
|
@ -63,125 +54,133 @@ class Audio(PlexPartialObject):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def thumbUrl(self):
|
def thumbUrl(self):
|
||||||
"""Return url to thumb image."""
|
""" Returns the URL to this items thumbnail image. """
|
||||||
if self.thumb:
|
if self.thumb:
|
||||||
return self.server.url(self.thumb)
|
return self.server.url(self.thumb)
|
||||||
|
|
||||||
def refresh(self):
|
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)
|
self.server.query('%s/refresh' % self.key, method=self.server.session.put)
|
||||||
|
|
||||||
def section(self):
|
def section(self):
|
||||||
"""Library section."""
|
""" Returns the :class:`~plexapi.library.LibrarySection` this item belongs to. """
|
||||||
return self.server.library.sectionByID(self.librarySectionID)
|
return self.server.library.sectionByID(self.librarySectionID)
|
||||||
|
|
||||||
|
|
||||||
@utils.register_libtype
|
@utils.register_libtype
|
||||||
class Artist(Audio):
|
class Artist(Audio):
|
||||||
"""Artist.
|
""" Represents a single audio artist.
|
||||||
|
|
||||||
Attributes:
|
Parameters:
|
||||||
art (str): /library/metadata/102631/art/1469310342
|
server (:class:`~plexapi.server.PlexServer`): PlexServer this client is connected to (optional)
|
||||||
countries (list): List of media.County fx [<Country:24200:United.States>]
|
data (ElementTree): Response from PlexServer used to build this object (optional).
|
||||||
genres (list): List of media.Genre fx [<Genre:25555:Classic.Rock>]
|
initpath (str): Relative path requested when retrieving specified `data` (optional).
|
||||||
guid (str): Fx guid com.plexapp.agents.plexmusic://gracenote/artist/05517B8701668D28?lang=en
|
|
||||||
key (str): Fx /library/metadata/102631
|
Attributes:
|
||||||
location (str): Filepath
|
art (str): Artist artwork (/library/metadata/<ratingkey>/art/<artid>)
|
||||||
similar (list): List of media.Similar fx [<Similar:25220:Guns.N'.Roses>]
|
countries (list): List of :class:`~plexapi.media.Country` objects this artist respresents.
|
||||||
TYPE (str): artist
|
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'
|
TYPE = 'artist'
|
||||||
|
|
||||||
def _loadData(self, data):
|
def _loadData(self, data):
|
||||||
"""Used to set the attributes.
|
""" Load attribute values from Plex XML response. """
|
||||||
|
|
||||||
Args:
|
|
||||||
data (Element): XML reponse from PMS as Element
|
|
||||||
normally built from server.query
|
|
||||||
"""
|
|
||||||
Audio._loadData(self, data)
|
Audio._loadData(self, data)
|
||||||
self.art = data.attrib.get('art', NA)
|
self.art = data.attrib.get('art', NA)
|
||||||
self.guid = data.attrib.get('guid', NA)
|
self.guid = data.attrib.get('guid', NA)
|
||||||
self.key = self.key.replace('/children', '') # FIX_BUG_50
|
self.key = self.key.replace('/children', '') # FIX_BUG_50
|
||||||
self.location = utils.findLocations(data, single=True)
|
self.location = utils.findLocations(data, single=True)
|
||||||
if self.isFullObject(): # check if this is needed
|
if self.isFullObject(): # check if this is needed
|
||||||
self.countries = [media.Country(self.server, e)
|
self.countries = [media.Country(self.server, e) for e in data if e.tag == media.Country.TYPE]
|
||||||
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.genres = [media.Genre(self.server, e)
|
self.similar = [media.Similar(self.server, e) for e in data if e.tag == media.Similar.TYPE]
|
||||||
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):
|
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
|
path = '%s/children' % self.key
|
||||||
return utils.listItems(self.server, path, Album.TYPE)
|
return utils.listItems(self.server, path, Album.TYPE)
|
||||||
|
|
||||||
def album(self, title):
|
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
|
path = '%s/children' % self.key
|
||||||
return utils.findItem(self.server, path, title)
|
return utils.findItem(self.server, path, title)
|
||||||
|
|
||||||
def tracks(self, watched=None):
|
def tracks(self):
|
||||||
"""Return all tracks to this artist.
|
""" Returns a list of :class:`~plexapi.audio.Track` objects by this artist. """
|
||||||
|
|
||||||
Args:
|
|
||||||
watched(None, False, True): Default to None.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List: of Track
|
|
||||||
"""
|
|
||||||
path = '%s/allLeaves' % self.key
|
path = '%s/allLeaves' % self.key
|
||||||
return utils.listItems(self.server, path, watched=watched)
|
return utils.listItems(self.server, path)
|
||||||
|
|
||||||
def track(self, title):
|
def track(self, title):
|
||||||
"""Return a Track that matches title.
|
""" Returns the :class:`~plexapi.audio.Track` that matches the specified title.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
title (str): Fx song name
|
title (str): Title of the track to return.
|
||||||
|
|
||||||
Returns:
|
|
||||||
Track:
|
|
||||||
"""
|
"""
|
||||||
path = '%s/allLeaves' % self.key
|
path = '%s/allLeaves' % self.key
|
||||||
return utils.findItem(self.server, path, title)
|
return utils.findItem(self.server, path, title)
|
||||||
|
|
||||||
def get(self, title):
|
def get(self, title):
|
||||||
"""Alias. See track."""
|
""" Alias of :func:`~plexapi.audio.Artist.track`. """
|
||||||
return self.track(title)
|
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
|
@utils.register_libtype
|
||||||
class Album(Audio):
|
class Album(Audio):
|
||||||
"""Album.
|
""" Represents a single audio album.
|
||||||
|
|
||||||
Attributes:
|
Parameters:
|
||||||
art (str): Fx /library/metadata/102631/art/1469310342
|
server (:class:`~plexapi.server.PlexServer`): PlexServer this client is connected to (optional)
|
||||||
genres (list): List of media.Genre
|
data (ElementTree): Response from PlexServer used to build this object (optional).
|
||||||
key (str): Fx /library/metadata/102632
|
initpath (str): Relative path requested when retrieving specified `data` (optional).
|
||||||
originallyAvailableAt (TYPE): Description
|
|
||||||
parentKey (str): /library/metadata/102631
|
Attributes:
|
||||||
parentRatingKey (int): Fx 1337
|
art (str): Album artwork (/library/metadata/<ratingkey>/art/<artid>)
|
||||||
parentThumb (TYPE): Relative url to parent thumb image
|
genres (list): List of :class:`~plexapi.media.Genre` objects this album respresents.
|
||||||
parentTitle (str): Aerosmith
|
key (str): API URL (/library/metadata/<ratingkey>).
|
||||||
studio (str):
|
originallyAvailableAt (datetime): Datetime this album was released.
|
||||||
TYPE (str): album
|
parentKey (str): API URL of this artist.
|
||||||
year (int): 1999
|
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'
|
TYPE = 'album'
|
||||||
|
|
||||||
def _loadData(self, data):
|
def _loadData(self, data):
|
||||||
"""Used to set the attributes.
|
""" Load attribute values from Plex XML response. """
|
||||||
|
|
||||||
Args:
|
|
||||||
data (Element): XML reponse from PMS as Element
|
|
||||||
normally built from server.query
|
|
||||||
"""
|
|
||||||
Audio._loadData(self, data)
|
Audio._loadData(self, data)
|
||||||
self.art = data.attrib.get('art', NA)
|
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.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt', NA), '%Y-%m-%d')
|
||||||
self.parentKey = data.attrib.get('parentKey', NA)
|
self.parentKey = data.attrib.get('parentKey', NA)
|
||||||
self.parentRatingKey = data.attrib.get('parentRatingKey', NA)
|
self.parentRatingKey = data.attrib.get('parentRatingKey', NA)
|
||||||
|
@ -192,88 +191,91 @@ class Album(Audio):
|
||||||
if self.isFullObject():
|
if self.isFullObject():
|
||||||
self.genres = [media.Genre(self.server, e) for e in data if e.tag == media.Genre.TYPE]
|
self.genres = [media.Genre(self.server, e) for e in data if e.tag == media.Genre.TYPE]
|
||||||
|
|
||||||
def tracks(self, watched=None):
|
def tracks(self):
|
||||||
"""Return all tracks to this album.
|
""" Returns a list of :class:`~plexapi.audio.Track` objects in this album. """
|
||||||
|
|
||||||
Args:
|
|
||||||
watched(None, False, True): Default to None.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List: of Track
|
|
||||||
"""
|
|
||||||
path = '%s/children' % self.key
|
path = '%s/children' % self.key
|
||||||
return utils.listItems(self.server, path, watched=watched)
|
return utils.listItems(self.server, path)
|
||||||
|
|
||||||
def track(self, title):
|
def track(self, title):
|
||||||
"""Return a Track that matches title.
|
""" Returns the :class:`~plexapi.audio.Track` that matches the specified title.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
title (str): Fx song name
|
title (str): Title of the track to return.
|
||||||
|
|
||||||
Returns:
|
|
||||||
Track:
|
|
||||||
"""
|
"""
|
||||||
path = '%s/children' % self.key
|
path = '%s/children' % self.key
|
||||||
return utils.findItem(self.server, path, title)
|
return utils.findItem(self.server, path, title)
|
||||||
|
|
||||||
def get(self, title):
|
def get(self, title):
|
||||||
"""Alias. See track."""
|
""" Alias of :func:`~plexapi.audio.Album.track`. """
|
||||||
return self.track(title)
|
return self.track(title)
|
||||||
|
|
||||||
def artist(self):
|
def artist(self):
|
||||||
"""Return Artist of this album."""
|
""" Return :func:`~plexapi.audio.Artist` of this album. """
|
||||||
return utils.listItems(self.server, self.parentKey)[0]
|
return utils.listItems(self.server, self.parentKey)[0]
|
||||||
|
|
||||||
def watched(self):
|
def download(self, savepath=None, keep_orginal_name=False, **kwargs):
|
||||||
"""Return Track that is lisson on."""
|
""" Downloads all tracks for this artist to the specified location.
|
||||||
return self.tracks(watched=True)
|
|
||||||
|
|
||||||
def unwatched(self):
|
Parameters:
|
||||||
"""Return Track that is not lisson on."""
|
savepath (str): Title of the track to return.
|
||||||
return self.tracks(watched=False)
|
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)
|
||||||
|
|
||||||
|
return downloaded
|
||||||
|
|
||||||
|
|
||||||
@utils.register_libtype
|
@utils.register_libtype
|
||||||
class Track(Audio, Playable):
|
class Track(Audio, Playable):
|
||||||
"""Track.
|
""" Represents a single audio track.
|
||||||
|
|
||||||
Attributes:
|
Parameters:
|
||||||
art (str): Relative path fx /library/metadata/102631/art/1469310342
|
server (:class:`~plexapi.server.PlexServer`): PlexServer this client is connected to (optional)
|
||||||
chapterSource (TYPE): Description
|
data (ElementTree): XML response from PlexServer used to build this object (optional).
|
||||||
duration (TYPE): Description
|
initpath (str): Relative path requested when retrieving specified `data` (optional).
|
||||||
grandparentArt (str): Relative path
|
|
||||||
grandparentKey (str): Relative path Fx /library/metadata/102631
|
Attributes:
|
||||||
grandparentRatingKey (TYPE): Description
|
art (str): Track artwork (/library/metadata/<ratingkey>/art/<artid>)
|
||||||
grandparentThumb (str): Relative path to Artist thumb img
|
chapterSource (TYPE): Unknown
|
||||||
grandparentTitle (str): Aerosmith
|
duration (int): Length of this album in seconds.
|
||||||
guid (TYPE): Description
|
grandparentArt (str): Artist artowrk.
|
||||||
media (list): List of media.Media
|
grandparentKey (str): Artist API URL.
|
||||||
moods (list): List of media.Moods
|
grandparentRatingKey (str): Unique key identifying artist.
|
||||||
originalTitle (str): Some track title
|
grandparentThumb (str): URL to artist thumbnail image.
|
||||||
parentIndex (int): 1
|
grandparentTitle (str): Name of the artist for this track.
|
||||||
parentKey (str): Relative path Fx /library/metadata/102632
|
guid (str): Unknown (unique ID).
|
||||||
parentRatingKey (int): 1337
|
media (list): List of :class:`~plexapi.media.Media` objects for this track.
|
||||||
parentThumb (str): Relative path to Album thumb
|
moods (list): List of :class:`~plexapi.media.Mood` objects for this track.
|
||||||
parentTitle (str): Album title
|
originalTitle (str): Original track title (if translated).
|
||||||
player (None): #TODO
|
parentIndex (int): Album index.
|
||||||
primaryExtraKey (TYPE): #TODO
|
parentKey (str): Album API URL.
|
||||||
ratingCount (int): 10
|
parentRatingKey (int): Unique key identifying album.
|
||||||
sessionKey (int): Description
|
parentThumb (str): URL to album thumbnail image.
|
||||||
transcodeSession (None):
|
parentTitle (str): Name of the album for this track.
|
||||||
TYPE (str): track
|
primaryExtraKey (str): Unknown
|
||||||
username (str): username@mail.com
|
ratingCount (int): Rating of this track (1-10?)
|
||||||
viewOffset (int): 100
|
viewOffset (int): Unknown
|
||||||
year (int): 1999
|
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'
|
TYPE = 'track'
|
||||||
|
|
||||||
def _loadData(self, data):
|
def _loadData(self, data):
|
||||||
"""Used to set the attributes
|
""" Load attribute values from Plex XML response. """
|
||||||
|
|
||||||
Args:
|
|
||||||
data (Element): Usually built from server.query
|
|
||||||
"""
|
|
||||||
Audio._loadData(self, data)
|
Audio._loadData(self, data)
|
||||||
Playable._loadData(self, data)
|
Playable._loadData(self, data)
|
||||||
self.art = data.attrib.get('art', NA)
|
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.ratingCount = utils.cast(int, data.attrib.get('ratingCount', NA))
|
||||||
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
|
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
|
||||||
self.year = utils.cast(int, data.attrib.get('year', NA))
|
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
|
if self.isFullObject(): # check me
|
||||||
self.moods = [media.Mood(self.server, e)
|
self.moods = [media.Mood(self.server, e) for e in data if e.tag == media.Mood.TYPE]
|
||||||
for e in data if e.tag == media.Mood.TYPE]
|
#self.media = [media.Media(self.server, e, self.initpath, self)
|
||||||
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]
|
|
||||||
# data for active sessions and history
|
# data for active sessions and history
|
||||||
self.sessionKey = utils.cast(int, data.attrib.get('sessionKey', NA))
|
self.sessionKey = utils.cast(int, data.attrib.get('sessionKey', NA))
|
||||||
self.username = utils.findUsername(data)
|
self.username = utils.findUsername(data)
|
||||||
|
@ -308,14 +312,18 @@ class Track(Audio, Playable):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def thumbUrl(self):
|
def thumbUrl(self):
|
||||||
"""Return url to thumb image."""
|
""" Returns the URL thumbnail image for this track's album. """
|
||||||
if self.parentThumb:
|
if self.parentThumb:
|
||||||
return self.server.url(self.parentThumb)
|
return self.server.url(self.parentThumb)
|
||||||
|
|
||||||
def album(self):
|
def album(self):
|
||||||
"""Return this track's Album."""
|
""" Return this track's :class:`~plexapi.audio.Album`. """
|
||||||
return utils.listItems(self.server, self.parentKey)[0]
|
return utils.listItems(self.server, self.parentKey)[0]
|
||||||
|
|
||||||
def artist(self):
|
def artist(self):
|
||||||
"""Return this track's Artist."""
|
""" Return this track's :class:`~plexapi.audio.Artist`. """
|
||||||
return utils.listItems(self.server, self.grandparentKey)[0]
|
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 -*-
|
# -*- 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
|
import requests
|
||||||
from requests.status_codes import _codes as codes
|
from requests.status_codes import _codes as codes
|
||||||
from plexapi import BASE_HEADERS, TIMEOUT, log, utils
|
from plexapi import BASE_HEADERS, TIMEOUT, log, utils
|
||||||
|
@ -13,65 +7,64 @@ from xml.etree import ElementTree
|
||||||
|
|
||||||
|
|
||||||
class PlexClient(object):
|
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:
|
Parameters:
|
||||||
baseurl (str): http adress for the client
|
baseurl (str): HTTP URL to connect dirrectly to this client.
|
||||||
device (None): Description
|
token (str): X-Plex-Token used for authenication (optional).
|
||||||
deviceClass (sting): pc, phone
|
session (:class:`~requests.Session`): requests.Session object if you want more control (optional).
|
||||||
machineIdentifier (str): uuid fx 5471D9EA-1467-4051-9BE7-FCBDF490ACE3
|
server (:class:`~plexapi.server.PlexServer`): PlexServer this client is connected to (optional)
|
||||||
model (TYPE): Description
|
data (ElementTree): Response from PlexServer used to build this object (optional).
|
||||||
platform (TYPE): Description
|
|
||||||
platformVersion (TYPE): Description
|
Attributes:
|
||||||
product (str): plex for ios
|
baseurl (str): HTTP address of the client
|
||||||
protocol (str): plex
|
device (str): Best guess on the type of device this is (PS, iPhone, Linux, etc).
|
||||||
protocolCapabilities (list): List of what client can do
|
deviceClass (str): Device class (pc, phone, etc).
|
||||||
protocolVersion (str): 1
|
machineIdentifier (str): Unique ID for this device.
|
||||||
server (plexapi.server.Plexserver): PMS your connected to
|
model (str): Unknown
|
||||||
session (None or requests.Session): Add your own session object to cache stuff
|
platform (str): Unknown
|
||||||
state (None): Description
|
platformVersion (str): Description
|
||||||
title (str): fx Johns Iphone
|
product (str): Client Product (Plex for iOS, etc).
|
||||||
token (str): X-Plex-Token, using for authenication with PMS
|
protocol (str): Always seems ot be 'plex'.
|
||||||
vendor (str): Description
|
protocolCapabilities (list<str>): List of client capabilities (navigation, playback,
|
||||||
version (str): fx. 4.6
|
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):
|
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.baseurl = baseurl.strip('/')
|
||||||
self.token = token
|
self.token = token
|
||||||
self.session = session or requests.Session()
|
|
||||||
self.server = server
|
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._loadData(data) if data is not None else self.connect()
|
||||||
self._proxyThroughServer = False
|
self._proxyThroughServer = False
|
||||||
self._commandId = 0
|
self._commandId = 0
|
||||||
|
|
||||||
def _loadData(self, data):
|
def _loadData(self, data):
|
||||||
"""Sets attrs to the class.
|
""" Load attribute values from Plex XML response. """
|
||||||
|
|
||||||
Args:
|
|
||||||
data (Element): XML response from PMS as a Element
|
|
||||||
"""
|
|
||||||
self.deviceClass = data.attrib.get('deviceClass')
|
self.deviceClass = data.attrib.get('deviceClass')
|
||||||
self.machineIdentifier = data.attrib.get('machineIdentifier')
|
self.machineIdentifier = data.attrib.get('machineIdentifier')
|
||||||
self.product = data.attrib.get('product')
|
self.product = data.attrib.get('product')
|
||||||
self.protocol = data.attrib.get('protocol')
|
self.protocol = data.attrib.get('protocol')
|
||||||
self.protocolCapabilities = data.attrib.get(
|
self.protocolCapabilities = data.attrib.get('protocolCapabilities', '').split(',')
|
||||||
'protocolCapabilities', '').split(',')
|
|
||||||
self.protocolVersion = data.attrib.get('protocolVersion')
|
self.protocolVersion = data.attrib.get('protocolVersion')
|
||||||
self.platform = data.attrib.get('platform')
|
self.platform = data.attrib.get('platform')
|
||||||
self.platformVersion = data.attrib.get('platformVersion')
|
self.platformVersion = data.attrib.get('platformVersion')
|
||||||
self.title = data.attrib.get('title') or data.attrib.get('name')
|
self.title = data.attrib.get('title') or data.attrib.get('name')
|
||||||
# active session details
|
# Active session details
|
||||||
self.device = data.attrib.get('device')
|
self.device = data.attrib.get('device')
|
||||||
self.model = data.attrib.get('model')
|
self.model = data.attrib.get('model')
|
||||||
self.state = data.attrib.get('state')
|
self.state = data.attrib.get('state')
|
||||||
|
@ -79,7 +72,11 @@ class PlexClient(object):
|
||||||
self.version = data.attrib.get('version')
|
self.version = data.attrib.get('version')
|
||||||
|
|
||||||
def connect(self):
|
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:
|
try:
|
||||||
data = self.query('/resources')[0]
|
data = self.query('/resources')[0]
|
||||||
self._loadData(data)
|
self._loadData(data)
|
||||||
|
@ -88,43 +85,38 @@ class PlexClient(object):
|
||||||
raise NotFound('No client found at: %s' % self.baseurl)
|
raise NotFound('No client found at: %s' % self.baseurl)
|
||||||
|
|
||||||
def headers(self):
|
def headers(self):
|
||||||
"""Default headers
|
""" Returns a dict of all default headers for Client requests. """
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: default headers
|
|
||||||
"""
|
|
||||||
headers = BASE_HEADERS
|
headers = BASE_HEADERS
|
||||||
if self.token:
|
if self.token:
|
||||||
headers['X-Plex-Token'] = self.token
|
headers['X-Plex-Token'] = self.token
|
||||||
return headers
|
return headers
|
||||||
|
|
||||||
def proxyThroughServer(self, value=True):
|
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:
|
Parameters:
|
||||||
value (bool, optional): Description
|
value (bool): Enable or disable proxying (optional, default True).
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
Unsupported: Cannot use client proxy with unknown server.
|
:class:`~plexapi.exceptions.Unsupported`: Cannot use client proxy with unknown server.
|
||||||
"""
|
"""
|
||||||
if value is True and not self.server:
|
if value is True and not self.server:
|
||||||
raise Unsupported('Cannot use client proxy with unknown server.')
|
raise Unsupported('Cannot use client proxy with unknown server.')
|
||||||
self._proxyThroughServer = value
|
self._proxyThroughServer = value
|
||||||
|
|
||||||
def query(self, path, method=None, headers=None, **kwargs):
|
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:
|
Parameters:
|
||||||
path (str): Relative path
|
path (str): Relative path to query.
|
||||||
method (None, optional): requests.post etc
|
method (func): `self.session.get` or `self.session.post`
|
||||||
headers (None, optional): Set headers manually
|
headers (dict): Additional headers to include or override in the request.
|
||||||
**kwargs (TYPE): Passord to the http request used for filter, sorting.
|
**kwargs (TYPE): Additional arguments to inclde in the request.<method> call.
|
||||||
|
|
||||||
Returns:
|
Raises:
|
||||||
Element
|
:class:`~plexapi.exceptions.BadRequest`: When the response is not in [200, 201]
|
||||||
|
|
||||||
Raises:
|
|
||||||
BadRequest: Http error and code
|
|
||||||
"""
|
"""
|
||||||
url = self.url(path)
|
url = self.url(path)
|
||||||
method = method or self.session.get
|
method = method or self.session.get
|
||||||
|
@ -138,24 +130,23 @@ class PlexClient(object):
|
||||||
return ElementTree.fromstring(data) if data else None
|
return ElementTree.fromstring(data) if data else None
|
||||||
|
|
||||||
def sendCommand(self, command, proxy=None, **params):
|
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:
|
Parameters:
|
||||||
command (str): See the commands listed below
|
command (str): Command to be sent in for format '<controller>/<command>'.
|
||||||
proxy (None, optional): Description
|
proxy (bool): Set True to proxy this command through the PlexServer.
|
||||||
**params (dict): Description
|
**params (dict): Additional GET parameters to include with the command.
|
||||||
|
|
||||||
Returns:
|
Raises:
|
||||||
Element
|
:class:`~plexapi.exceptions.Unsupported`: When we detect the client doesn't support this capability.
|
||||||
|
|
||||||
Raises:
|
|
||||||
Unsupported: Unsupported clients
|
|
||||||
"""
|
"""
|
||||||
command = command.strip('/')
|
command = command.strip('/')
|
||||||
controller = command.split('/')[0]
|
controller = command.split('/')[0]
|
||||||
if controller not in self.protocolCapabilities:
|
if controller not in self.protocolCapabilities:
|
||||||
raise Unsupported(
|
raise Unsupported('Client %s does not support the %s controller.' %
|
||||||
'Client %s does not support the %s controller.' % (self.title, controller))
|
(self.title, controller))
|
||||||
path = '/player/%s%s' % (command, utils.joinArgs(params))
|
path = '/player/%s%s' % (command, utils.joinArgs(params))
|
||||||
headers = {'X-Plex-Target-Client-Identifier': self.machineIdentifier}
|
headers = {'X-Plex-Target-Client-Identifier': self.machineIdentifier}
|
||||||
self._commandId += 1
|
self._commandId += 1
|
||||||
|
@ -167,82 +158,87 @@ class PlexClient(object):
|
||||||
return self.query(path, headers=headers)
|
return self.query(path, headers=headers)
|
||||||
|
|
||||||
def url(self, path):
|
def url(self, path):
|
||||||
"""Return a full url
|
""" Given a path, this retuns the full PlexClient the PlexServer URL to request.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
path (str): Relative path
|
path (str): Relative path to be converted.
|
||||||
|
|
||||||
Returns:
|
|
||||||
string: full path to PMS
|
|
||||||
"""
|
"""
|
||||||
if self.token:
|
if self.token:
|
||||||
delim = '&' if '?' in path else '?'
|
delim = '&' if '?' in path else '?'
|
||||||
return '%s%s%sX-Plex-Token=%s' % (self.baseurl, path, delim, self.token)
|
return '%s%s%sX-Plex-Token=%s' % (self.baseurl, path, delim, self.token)
|
||||||
return '%s%s' % (self.baseurl, path)
|
return '%s%s' % (self.baseurl, path)
|
||||||
|
|
||||||
|
#---------------------
|
||||||
# Navigation Commands
|
# Navigation Commands
|
||||||
# These commands navigate around the user-interface.
|
# These commands navigate around the user-interface.
|
||||||
def contextMenu(self):
|
def contextMenu(self):
|
||||||
"""Open the context menu on the client."""
|
""" Open the context menu on the client. """
|
||||||
self.sendCommand('navigation/contextMenu')
|
self.sendCommand('navigation/contextMenu')
|
||||||
|
|
||||||
def goBack(self):
|
def goBack(self):
|
||||||
"""One step back"""
|
""" Navigate back one position. """
|
||||||
self.sendCommand('navigation/back')
|
self.sendCommand('navigation/back')
|
||||||
|
|
||||||
def goToHome(self):
|
def goToHome(self):
|
||||||
"""Jump to home screen."""
|
""" Go directly to the home screen. """
|
||||||
self.sendCommand('navigation/home')
|
self.sendCommand('navigation/home')
|
||||||
|
|
||||||
def goToMusic(self):
|
def goToMusic(self):
|
||||||
"""Jump to music."""
|
""" Go directly to the playing music panel. """
|
||||||
self.sendCommand('navigation/music')
|
self.sendCommand('navigation/music')
|
||||||
|
|
||||||
def moveDown(self):
|
def moveDown(self):
|
||||||
"""One step down."""
|
""" Move selection down a position. """
|
||||||
self.sendCommand('navigation/moveDown')
|
self.sendCommand('navigation/moveDown')
|
||||||
|
|
||||||
def moveLeft(self):
|
def moveLeft(self):
|
||||||
|
""" Move selection left a position. """
|
||||||
self.sendCommand('navigation/moveLeft')
|
self.sendCommand('navigation/moveLeft')
|
||||||
|
|
||||||
def moveRight(self):
|
def moveRight(self):
|
||||||
|
""" Move selection right a position. """
|
||||||
self.sendCommand('navigation/moveRight')
|
self.sendCommand('navigation/moveRight')
|
||||||
|
|
||||||
def moveUp(self):
|
def moveUp(self):
|
||||||
|
""" Move selection up a position. """
|
||||||
self.sendCommand('navigation/moveUp')
|
self.sendCommand('navigation/moveUp')
|
||||||
|
|
||||||
def nextLetter(self):
|
def nextLetter(self):
|
||||||
"""Jump to the next letter in the alphabeth."""
|
""" Jump to next letter in the alphabet. """
|
||||||
self.sendCommand('navigation/nextLetter')
|
self.sendCommand('navigation/nextLetter')
|
||||||
|
|
||||||
def pageDown(self):
|
def pageDown(self):
|
||||||
|
""" Move selection down a full page. """
|
||||||
self.sendCommand('navigation/pageDown')
|
self.sendCommand('navigation/pageDown')
|
||||||
|
|
||||||
def pageUp(self):
|
def pageUp(self):
|
||||||
|
""" Move selection up a full page. """
|
||||||
self.sendCommand('navigation/pageUp')
|
self.sendCommand('navigation/pageUp')
|
||||||
|
|
||||||
def previousLetter(self):
|
def previousLetter(self):
|
||||||
|
""" Jump to previous letter in the alphabet. """
|
||||||
self.sendCommand('navigation/previousLetter')
|
self.sendCommand('navigation/previousLetter')
|
||||||
|
|
||||||
def select(self):
|
def select(self):
|
||||||
|
""" Select element at the current position. """
|
||||||
self.sendCommand('navigation/select')
|
self.sendCommand('navigation/select')
|
||||||
|
|
||||||
def toggleOSD(self):
|
def toggleOSD(self):
|
||||||
|
""" Toggle the on screen display during playback. """
|
||||||
self.sendCommand('navigation/toggleOSD')
|
self.sendCommand('navigation/toggleOSD')
|
||||||
|
|
||||||
def goToMedia(self, media, **params):
|
def goToMedia(self, media, **params):
|
||||||
"""Go to a media on the client.
|
""" Navigate directly to the specified media page.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
media (str): movie, music, photo
|
media (:class:`~plexapi.media.Media`): Media object to navigate to.
|
||||||
**params (TYPE): Description # todo
|
**params (dict): Additional GET parameters to include with the command.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
Unsupported: Description
|
:class:`~plexapi.exceptions.Unsupported`: When no PlexServer specified in this object.
|
||||||
"""
|
"""
|
||||||
if not self.server:
|
if not self.server:
|
||||||
raise Unsupported(
|
raise Unsupported('A server must be specified before using this command.')
|
||||||
'A server must be specified before using this command.')
|
|
||||||
server_url = media.server.baseurl.split(':')
|
server_url = media.server.baseurl.split(':')
|
||||||
self.sendCommand('mirror/details', **dict({
|
self.sendCommand('mirror/details', **dict({
|
||||||
'machineIdentifier': self.server.machineIdentifier,
|
'machineIdentifier': self.server.machineIdentifier,
|
||||||
|
@ -251,192 +247,184 @@ class PlexClient(object):
|
||||||
'key': media.key,
|
'key': media.key,
|
||||||
}, **params))
|
}, **params))
|
||||||
|
|
||||||
|
#-------------------
|
||||||
# Playback Commands
|
# Playback Commands
|
||||||
# Most of the playback commands take a mandatory mtype {'music','photo','video'} argument,
|
# 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
|
# 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
|
# is in case there are multiple things happening (e.g. music in the background, photo
|
||||||
# slideshow in the foreground).
|
# slideshow in the foreground).
|
||||||
|
|
||||||
def pause(self, mtype):
|
def pause(self, mtype):
|
||||||
"""Pause playback
|
""" Pause the currently playing media type.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
mtype (str): music, photo, video
|
mtype (str): Media type to take action against (music, photo, video).
|
||||||
"""
|
"""
|
||||||
self.sendCommand('playback/pause', type=mtype)
|
self.sendCommand('playback/pause', type=mtype)
|
||||||
|
|
||||||
def play(self, mtype):
|
def play(self, mtype):
|
||||||
"""Start playback
|
""" Start playback for the specified media type.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
mtype (str): music, photo, video
|
mtype (str): Media type to take action against (music, photo, video).
|
||||||
"""
|
"""
|
||||||
self.sendCommand('playback/play', type=mtype)
|
self.sendCommand('playback/play', type=mtype)
|
||||||
|
|
||||||
def refreshPlayQueue(self, playQueueID, mtype=None):
|
def refreshPlayQueue(self, playQueueID, mtype=None):
|
||||||
"""Summary
|
""" Refresh the specified Playqueue.
|
||||||
|
|
||||||
Args:
|
|
||||||
playQueueID (TYPE): Description
|
|
||||||
mtype (None, optional): photo, video, music
|
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
playQueueID (str): Playqueue ID.
|
||||||
|
mtype (str): Media type to take action against (music, photo, video).
|
||||||
"""
|
"""
|
||||||
self.sendCommand(
|
self.sendCommand(
|
||||||
'playback/refreshPlayQueue', playQueueID=playQueueID, type=mtype)
|
'playback/refreshPlayQueue', playQueueID=playQueueID, type=mtype)
|
||||||
|
|
||||||
def seekTo(self, offset, mtype=None):
|
def seekTo(self, offset, mtype=None):
|
||||||
"""Seek to a time in a plaback.
|
""" Seek to the specified offset (ms) during playback.
|
||||||
|
|
||||||
Args:
|
|
||||||
offset (int): in milliseconds
|
|
||||||
mtype (None, optional): photo, video, music
|
|
||||||
|
|
||||||
|
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)
|
self.sendCommand('playback/seekTo', offset=offset, type=mtype)
|
||||||
|
|
||||||
def skipNext(self, mtype=None):
|
def skipNext(self, mtype=None):
|
||||||
"""Skip to next
|
""" Skip to the next playback item.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
mtype (None, string, optional): photo, video, music
|
mtype (str): Media type to take action against (music, photo, video).
|
||||||
"""
|
"""
|
||||||
self.sendCommand('playback/skipNext', type=mtype)
|
self.sendCommand('playback/skipNext', type=mtype)
|
||||||
|
|
||||||
def skipPrevious(self, mtype=None):
|
def skipPrevious(self, mtype=None):
|
||||||
"""Skip to previous
|
""" Skip to previous playback item.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
mtype (None, optional): Description
|
mtype (str): Media type to take action against (music, photo, video).
|
||||||
"""
|
"""
|
||||||
self.sendCommand('playback/skipPrevious', type=mtype)
|
self.sendCommand('playback/skipPrevious', type=mtype)
|
||||||
|
|
||||||
def skipTo(self, key, mtype=None):
|
def skipTo(self, key, mtype=None):
|
||||||
"""Jump to
|
""" Skip to the playback item with the specified key.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
key (TYPE): # what is this
|
key (str): Key of the media item to skip to.
|
||||||
mtype (None, optional): photo, video, music
|
mtype (str): Media type to take action against (music, photo, video).
|
||||||
|
|
||||||
Returns:
|
|
||||||
TYPE: Description
|
|
||||||
"""
|
"""
|
||||||
# skips to item with matching key
|
|
||||||
self.sendCommand('playback/skipTo', key=key, type=mtype)
|
self.sendCommand('playback/skipTo', key=key, type=mtype)
|
||||||
|
|
||||||
def stepBack(self, mtype=None):
|
def stepBack(self, mtype=None):
|
||||||
"""
|
""" Step backward a chunk of time in the current playback item.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
mtype (None, optional): photo, video, music
|
mtype (str): Media type to take action against (music, photo, video).
|
||||||
"""
|
"""
|
||||||
self.sendCommand('playback/stepBack', type=mtype)
|
self.sendCommand('playback/stepBack', type=mtype)
|
||||||
|
|
||||||
def stepForward(self, mtype):
|
def stepForward(self, mtype):
|
||||||
"""Summary
|
""" Step forward a chunk of time in the current playback item.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
mtype (TYPE): Description
|
mtype (str): Media type to take action against (music, photo, video).
|
||||||
|
|
||||||
Returns:
|
|
||||||
TYPE: Description
|
|
||||||
"""
|
"""
|
||||||
self.sendCommand('playback/stepForward', type=mtype)
|
self.sendCommand('playback/stepForward', type=mtype)
|
||||||
|
|
||||||
def stop(self, mtype):
|
def stop(self, mtype):
|
||||||
"""Stop playback
|
""" Stop the currently playing item.
|
||||||
|
|
||||||
Args:
|
|
||||||
mtype (str): video, music, photo
|
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
mtype (str): Media type to take action against (music, photo, video).
|
||||||
"""
|
"""
|
||||||
self.sendCommand('playback/stop', type=mtype)
|
self.sendCommand('playback/stop', type=mtype)
|
||||||
|
|
||||||
def setRepeat(self, repeat, mtype):
|
def setRepeat(self, repeat, mtype):
|
||||||
"""Summary
|
""" Enable repeat for the specified playback items.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
repeat (int): 0=off, 1=repeatone, 2=repeatall
|
repeat (int): Repeat mode (0=off, 1=repeatone, 2=repeatall).
|
||||||
mtype (TYPE): video, music, photo
|
mtype (str): Media type to take action against (music, photo, video).
|
||||||
"""
|
"""
|
||||||
self.setParameters(repeat=repeat, mtype=mtype)
|
self.setParameters(repeat=repeat, mtype=mtype)
|
||||||
|
|
||||||
def setShuffle(self, shuffle, mtype):
|
def setShuffle(self, shuffle, mtype):
|
||||||
"""Set shuffle
|
""" Enable shuffle for the specified playback items.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
shuffle (int): 0=off, 1=on
|
shuffle (int): Shuffle mode (0=off, 1=on)
|
||||||
mtype (TYPE): Description
|
mtype (str): Media type to take action against (music, photo, video).
|
||||||
"""
|
"""
|
||||||
self.setParameters(shuffle=shuffle, mtype=mtype)
|
self.setParameters(shuffle=shuffle, mtype=mtype)
|
||||||
|
|
||||||
def setVolume(self, volume, mtype):
|
def setVolume(self, volume, mtype):
|
||||||
"""Change volume
|
""" Enable volume for the current playback item.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
volume (int): 0-100
|
volume (int): Volume level (0-100).
|
||||||
mtype (TYPE): Description
|
mtype (str): Media type to take action against (music, photo, video).
|
||||||
"""
|
"""
|
||||||
self.setParameters(volume=volume, mtype=mtype)
|
self.setParameters(volume=volume, mtype=mtype)
|
||||||
|
|
||||||
def setAudioStream(self, audioStreamID, mtype):
|
def setAudioStream(self, audioStreamID, mtype):
|
||||||
"""Select a audio stream
|
""" Select the audio stream for the current playback item (only video).
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
audioStreamID (TYPE): Description
|
audioStreamID (str): ID of the audio stream from the media object.
|
||||||
mtype (str): video, music, photo
|
mtype (str): Media type to take action against (music, photo, video).
|
||||||
"""
|
"""
|
||||||
self.setStreams(audioStreamID=audioStreamID, mtype=mtype)
|
self.setStreams(audioStreamID=audioStreamID, mtype=mtype)
|
||||||
|
|
||||||
def setSubtitleStream(self, subtitleStreamID, mtype):
|
def setSubtitleStream(self, subtitleStreamID, mtype):
|
||||||
"""Select a subtitle
|
""" Select the subtitle stream for the current playback item (only video).
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
subtitleStreamID (TYPE): Description
|
subtitleStreamID (str): ID of the subtitle stream from the media object.
|
||||||
mtype (str): video, music, photo
|
mtype (str): Media type to take action against (music, photo, video).
|
||||||
"""
|
"""
|
||||||
self.setStreams(subtitleStreamID=subtitleStreamID, mtype=mtype)
|
self.setStreams(subtitleStreamID=subtitleStreamID, mtype=mtype)
|
||||||
|
|
||||||
def setVideoStream(self, videoStreamID, mtype):
|
def setVideoStream(self, videoStreamID, mtype):
|
||||||
"""Summary
|
""" Select the video stream for the current playback item (only video).
|
||||||
|
|
||||||
Args:
|
|
||||||
videoStreamID (TYPE): Description
|
|
||||||
mtype (str): video, music, photo
|
|
||||||
|
|
||||||
|
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)
|
self.setStreams(videoStreamID=videoStreamID, mtype=mtype)
|
||||||
|
|
||||||
def playMedia(self, media, **params):
|
def playMedia(self, media, offset=0, **params):
|
||||||
"""Start playback on a media item.
|
""" Start playback of the specified media item. See also:
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
media (str): movie, music, photo
|
media (:class:`~plexapi.media.Media`): Media item to be played back (movie, music, photo).
|
||||||
**params (TYPE): Description
|
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
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
Unsupported: Description
|
:class:`~plexapi.exceptions.Unsupported`: When no PlexServer specified in this object.
|
||||||
"""
|
"""
|
||||||
if not self.server:
|
if not self.server:
|
||||||
raise Unsupported(
|
raise Unsupported('A server must be specified before using this command.')
|
||||||
'A server must be specified before using this command.')
|
|
||||||
server_url = media.server.baseurl.split(':')
|
server_url = media.server.baseurl.split(':')
|
||||||
playqueue = self.server.createPlayQueue(media)
|
playqueue = self.server.createPlayQueue(media)
|
||||||
self.sendCommand('playback/playMedia', **dict({
|
self.sendCommand('playback/playMedia', **dict({
|
||||||
'machineIdentifier': self.server.machineIdentifier,
|
'machineIdentifier': self.server.machineIdentifier,
|
||||||
'address': server_url[1].strip('/'),
|
'address': server_url[1].strip('/'),
|
||||||
'port': server_url[-1],
|
'port': server_url[-1],
|
||||||
|
'offset': offset,
|
||||||
'key': media.key,
|
'key': media.key,
|
||||||
'containerKey': '/playQueues/%s?window=100&own=1' % playqueue.playQueueID,
|
'containerKey': '/playQueues/%s?window=100&own=1' % playqueue.playQueueID,
|
||||||
}, **params))
|
}, **params))
|
||||||
|
|
||||||
def setParameters(self, volume=None, shuffle=None, repeat=None, mtype=None):
|
def setParameters(self, volume=None, shuffle=None, repeat=None, mtype=None):
|
||||||
"""Set params for the client
|
""" Set multiple playback parameters at once.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
volume (None, optional): 0-100
|
volume (int): Volume level (0-100; optional).
|
||||||
shuffle (None, optional): 0=off, 1=on
|
shuffle (int): Shuffle mode (0=off, 1=on; optional).
|
||||||
repeat (None, optional): 0=off, 1=repeatone, 2=repeatall
|
repeat (int): Repeat mode (0=off, 1=repeatone, 2=repeatall; optional).
|
||||||
mtype (None, optional): music,photo,video
|
mtype (str): Media type to take action against (optional music, photo, video).
|
||||||
"""
|
"""
|
||||||
params = {}
|
params = {}
|
||||||
if repeat is not None:
|
if repeat is not None:
|
||||||
|
@ -449,15 +437,14 @@ class PlexClient(object):
|
||||||
params['type'] = mtype
|
params['type'] = mtype
|
||||||
self.sendCommand('playback/setParameters', **params)
|
self.sendCommand('playback/setParameters', **params)
|
||||||
|
|
||||||
def setStreams(self, audioStreamID=None, subtitleStreamID=None,
|
def setStreams(self, audioStreamID=None, subtitleStreamID=None, videoStreamID=None, mtype=None):
|
||||||
videoStreamID=None, mtype=None):
|
""" Select multiple playback streams at once.
|
||||||
"""Select streams.
|
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
audioStreamID (None, optional): Description
|
audioStreamID (str): ID of the audio stream from the media object.
|
||||||
subtitleStreamID (None, optional): Description
|
subtitleStreamID (str): ID of the subtitle stream from the media object.
|
||||||
videoStreamID (None, optional): Description
|
videoStreamID (str): ID of the video stream from the media object.
|
||||||
mtype (None, optional): music,photo,video
|
mtype (str): Media type to take action against (optional music, photo, video).
|
||||||
"""
|
"""
|
||||||
params = {}
|
params = {}
|
||||||
if audioStreamID is not None:
|
if audioStreamID is not None:
|
||||||
|
@ -470,19 +457,18 @@ class PlexClient(object):
|
||||||
params['type'] = mtype
|
params['type'] = mtype
|
||||||
self.sendCommand('playback/setStreams', **params)
|
self.sendCommand('playback/setStreams', **params)
|
||||||
|
|
||||||
|
#-------------------
|
||||||
# Timeline Commands
|
# Timeline Commands
|
||||||
def timeline(self):
|
def timeline(self):
|
||||||
"""Timeline"""
|
""" Poll the current timeline and return the XML response. """
|
||||||
return self.sendCommand('timeline/poll', **{'wait': 1, 'commandID': 4})
|
return self.sendCommand('timeline/poll', **{'wait': 1, 'commandID': 4})
|
||||||
|
|
||||||
def isPlayingMedia(self, includePaused=False):
|
def isPlayingMedia(self, includePaused=False):
|
||||||
"""Check timeline if anything is playing
|
""" Returns True if any media is currently playing.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
includePaused (bool, optional): Should paused be included
|
includePaused (bool): Set True to treat currently paused items
|
||||||
|
as playing (optional; default True).
|
||||||
Returns:
|
|
||||||
bool
|
|
||||||
"""
|
"""
|
||||||
for mediatype in self.timeline():
|
for mediatype in self.timeline():
|
||||||
if mediatype.get('state') == 'playing':
|
if mediatype.get('state') == 'playing':
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- 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:
|
try:
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
@ -20,8 +22,13 @@ try:
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from urllib import unquote
|
from urllib import unquote
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from configparser import ConfigParser
|
from configparser import ConfigParser
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from ConfigParser import ConfigParser
|
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 -*-
|
# -*- 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
|
from collections import defaultdict
|
||||||
try:
|
from plexapi.compat import ConfigParser
|
||||||
from ConfigParser import ConfigParser # Python2
|
|
||||||
except ImportError:
|
|
||||||
from configparser import ConfigParser # Python3
|
|
||||||
|
|
||||||
|
|
||||||
class PlexConfig(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):
|
def __init__(self, path):
|
||||||
ConfigParser.__init__(self)
|
ConfigParser.__init__(self)
|
||||||
self.read(path)
|
self.read(path)
|
||||||
self.data = self._asDict()
|
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):
|
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:
|
try:
|
||||||
section, name = key.split('.')
|
section, name = key.split('.')
|
||||||
value = self.data.get(section.lower(), {}).get(name.lower(), default)
|
value = self.data.get(section.lower(), {}).get(name.lower(), default)
|
||||||
|
@ -27,6 +41,7 @@ class PlexConfig(ConfigParser):
|
||||||
return default
|
return default
|
||||||
|
|
||||||
def _asDict(self):
|
def _asDict(self):
|
||||||
|
""" Returns all configuration values as a dictionary. """
|
||||||
config = defaultdict(dict)
|
config = defaultdict(dict)
|
||||||
for section in self._sections:
|
for section in self._sections:
|
||||||
for name, value in self._sections[section].items():
|
for name, value in self._sections[section].items():
|
||||||
|
@ -36,6 +51,7 @@ class PlexConfig(ConfigParser):
|
||||||
|
|
||||||
|
|
||||||
def reset_base_headers():
|
def reset_base_headers():
|
||||||
|
""" Convenience function returns a dict of all base X-Plex-* headers for session requests. """
|
||||||
import plexapi
|
import plexapi
|
||||||
return {
|
return {
|
||||||
'X-Plex-Platform': plexapi.X_PLEX_PLATFORM,
|
'X-Plex-Platform': plexapi.X_PLEX_PLATFORM,
|
||||||
|
|
|
@ -1,23 +1,29 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
# flake8:noqa
|
|
||||||
"""
|
|
||||||
PlexAPI Exceptions
|
|
||||||
"""
|
|
||||||
|
|
||||||
class PlexApiException(Exception):
|
class PlexApiException(Exception):
|
||||||
|
""" Base class for all PlexAPI exceptions. """
|
||||||
pass
|
pass
|
||||||
|
|
||||||
class BadRequest(PlexApiException):
|
class BadRequest(PlexApiException):
|
||||||
|
""" An invalid request, generally a user error. """
|
||||||
pass
|
pass
|
||||||
|
|
||||||
class NotFound(PlexApiException):
|
class NotFound(PlexApiException):
|
||||||
|
""" Request media item or device is not found. """
|
||||||
pass
|
pass
|
||||||
|
|
||||||
class UnknownType(PlexApiException):
|
class UnknownType(PlexApiException):
|
||||||
|
""" Unknown library type. """
|
||||||
pass
|
pass
|
||||||
|
|
||||||
class Unsupported(PlexApiException):
|
class Unsupported(PlexApiException):
|
||||||
|
""" Unsupported client request. """
|
||||||
pass
|
pass
|
||||||
|
|
||||||
class Unauthorized(PlexApiException):
|
class Unauthorized(PlexApiException):
|
||||||
|
""" Invalid username or password. """
|
||||||
|
pass
|
||||||
|
|
||||||
|
class NotImplementedError(PlexApiException):
|
||||||
|
""" Feature is not yet implemented. """
|
||||||
pass
|
pass
|
||||||
|
|
|
@ -1,14 +1,24 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
import logging
|
|
||||||
|
|
||||||
from plexapi import log, utils
|
import logging
|
||||||
from plexapi import X_PLEX_CONTAINER_SIZE
|
from plexapi import X_PLEX_CONTAINER_SIZE, log, utils
|
||||||
from plexapi.compat import unquote
|
from plexapi.compat import unquote
|
||||||
from plexapi.media import MediaTag, Genre, Role, Director
|
from plexapi.media import MediaTag, Genre, Role, Director
|
||||||
from plexapi.exceptions import BadRequest, NotFound
|
from plexapi.exceptions import BadRequest, NotFound
|
||||||
|
from plexapi.media import MediaTag
|
||||||
|
|
||||||
|
|
||||||
class Library(object):
|
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):
|
def __init__(self, server, data):
|
||||||
self.identifier = data.attrib.get('identifier')
|
self.identifier = data.attrib.get('identifier')
|
||||||
|
@ -16,12 +26,16 @@ class Library(object):
|
||||||
self.server = server
|
self.server = server
|
||||||
self.title1 = data.attrib.get('title1')
|
self.title1 = data.attrib.get('title1')
|
||||||
self.title2 = data.attrib.get('title2')
|
self.title2 = data.attrib.get('title2')
|
||||||
self._sectionsByID = {} # cached section UUIDs
|
self._sectionsByID = {} # cached Section UUIDs
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return '<Library:%s>' % self.title1.encode('utf8')
|
return '<Library:%s>' % self.title1.encode('utf8')
|
||||||
|
|
||||||
def sections(self):
|
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 = []
|
items = []
|
||||||
SECTION_TYPES = {
|
SECTION_TYPES = {
|
||||||
MovieSection.TYPE: MovieSection,
|
MovieSection.TYPE: MovieSection,
|
||||||
|
@ -40,40 +54,69 @@ class Library(object):
|
||||||
return items
|
return items
|
||||||
|
|
||||||
def section(self, title=None):
|
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():
|
for item in self.sections():
|
||||||
if item.title == title:
|
if item.title == title:
|
||||||
return item
|
return item
|
||||||
raise NotFound('Invalid library section: %s' % title)
|
raise NotFound('Invalid library section: %s' % title)
|
||||||
|
|
||||||
def sectionByID(self, sectionID):
|
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()
|
self.sections()
|
||||||
return self._sectionsByID[sectionID]
|
return self._sectionsByID[sectionID]
|
||||||
|
|
||||||
def all(self):
|
def all(self):
|
||||||
return [item for section in self.sections()
|
""" Returns a list of all media from all library sections.
|
||||||
for item in section.all()]
|
This may be a very large dataset to retrieve.
|
||||||
|
"""
|
||||||
|
return [item for section in self.sections() for item in section.all()]
|
||||||
|
|
||||||
def onDeck(self):
|
def onDeck(self):
|
||||||
|
""" Returns a list of all media items on deck. """
|
||||||
return utils.listItems(self.server, '/library/onDeck')
|
return utils.listItems(self.server, '/library/onDeck')
|
||||||
|
|
||||||
def recentlyAdded(self):
|
def recentlyAdded(self):
|
||||||
|
""" Returns a list of all media items recently added. """
|
||||||
return utils.listItems(self.server, '/library/recentlyAdded')
|
return utils.listItems(self.server, '/library/recentlyAdded')
|
||||||
|
|
||||||
def get(self, title):
|
def get(self, title): # this should use hub search when its merged
|
||||||
return utils.findItem(self.server, '/library/all', title)
|
""" 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):
|
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)
|
return utils.findKey(self.server, key)
|
||||||
|
|
||||||
def search(self, title=None, libtype=None, **kwargs):
|
def search(self, title=None, libtype=None, **kwargs):
|
||||||
""" Searching within a library section is much more powerful. It seems certain attributes on the media
|
""" Searching within a library section is much more powerful. It seems certain
|
||||||
objects can be targeted to filter this search down a bit, but I havent found the documentation for
|
attributes on the media objects can be targeted to filter this search down
|
||||||
it.
|
a bit, but I havent found the documentation for it.
|
||||||
|
|
||||||
Example: "studio=Comedy%20Central" or "year=1999" "title=Kung Fu" all work. Other items
|
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.
|
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.
|
TLDR: This is untested but seems to work. Use library section search when you can.
|
||||||
"""
|
"""
|
||||||
args = {}
|
args = {}
|
||||||
if title:
|
if title:
|
||||||
|
@ -86,16 +129,30 @@ class Library(object):
|
||||||
return utils.listItems(self.server, query)
|
return utils.listItems(self.server, query)
|
||||||
|
|
||||||
def cleanBundles(self):
|
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')
|
self.server.query('/library/clean/bundles')
|
||||||
|
|
||||||
def emptyTrash(self):
|
def emptyTrash(self):
|
||||||
|
""" If a library has items in the Library Trash, use this option to empty the Trash. """
|
||||||
for section in self.sections():
|
for section in self.sections():
|
||||||
section.emptyTrash()
|
section.emptyTrash()
|
||||||
|
|
||||||
def optimize(self):
|
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')
|
self.server.query('/library/optimize')
|
||||||
|
|
||||||
def refresh(self):
|
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')
|
self.server.query('/library/sections/all/refresh')
|
||||||
|
|
||||||
def __len__(self):
|
def __len__(self):
|
||||||
|
@ -103,6 +160,33 @@ class Library(object):
|
||||||
|
|
||||||
|
|
||||||
class LibrarySection(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_FILTERS = ()
|
||||||
ALLOWED_SORT = ()
|
ALLOWED_SORT = ()
|
||||||
BOOLEAN_FILTERS = ('unwatched', 'duplicate')
|
BOOLEAN_FILTERS = ('unwatched', 'duplicate')
|
||||||
|
@ -118,7 +202,6 @@ class LibrarySection(object):
|
||||||
self.filters = data.attrib.get('filters')
|
self.filters = data.attrib.get('filters')
|
||||||
self.key = data.attrib.get('key')
|
self.key = data.attrib.get('key')
|
||||||
self.language = data.attrib.get('language')
|
self.language = data.attrib.get('language')
|
||||||
self.language = data.attrib.get('language')
|
|
||||||
self.locations = utils.findLocations(data)
|
self.locations = utils.findLocations(data)
|
||||||
self.refreshing = utils.cast(bool, data.attrib.get('refreshing'))
|
self.refreshing = utils.cast(bool, data.attrib.get('refreshing'))
|
||||||
self.scanner = data.attrib.get('scanner')
|
self.scanner = data.attrib.get('scanner')
|
||||||
|
@ -133,42 +216,66 @@ class LibrarySection(object):
|
||||||
return '<%s:%s>' % (self.__class__.__name__, title.encode('utf8'))
|
return '<%s:%s>' % (self.__class__.__name__, title.encode('utf8'))
|
||||||
|
|
||||||
def get(self, title):
|
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
|
path = '/library/sections/%s/all' % self.key
|
||||||
return utils.findItem(self.server, path, title)
|
return utils.findItem(self.server, path, title)
|
||||||
|
|
||||||
def all(self):
|
def all(self):
|
||||||
|
""" Returns a list of media from this library section. """
|
||||||
return utils.listItems(self.server, '/library/sections/%s/all' % self.key)
|
return utils.listItems(self.server, '/library/sections/%s/all' % self.key)
|
||||||
|
|
||||||
def onDeck(self):
|
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)
|
return utils.listItems(self.server, '/library/sections/%s/onDeck' % self.key)
|
||||||
|
|
||||||
def recentlyAdded(self, maxresults=50):
|
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)
|
return self.search(sort='addedAt:desc', maxresults=maxresults)
|
||||||
|
|
||||||
def analyze(self):
|
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):
|
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)
|
self.server.query('/library/sections/%s/emptyTrash' % self.key)
|
||||||
|
|
||||||
def refresh(self):
|
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)
|
self.server.query('/library/sections/%s/refresh' % self.key)
|
||||||
|
|
||||||
def listChoices(self, category, libtype=None, **kwargs):
|
def listChoices(self, category, libtype=None, **kwargs):
|
||||||
""" List choices for the specified filter category. kwargs can be any of the same
|
""" Returns a list of :class:`~plexapi.library.FilterChoice` objects for the
|
||||||
kwargs in self.search() to help narrow down the choices to only those that
|
specified category and libtype. kwargs can be any of the same kwargs in
|
||||||
matter in your current context.
|
: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:
|
if category in kwargs:
|
||||||
raise BadRequest(
|
raise BadRequest('Cannot include kwarg equal to specified category: %s' % category)
|
||||||
'Cannot include kwarg equal to specified category: %s' % category)
|
|
||||||
args = {}
|
args = {}
|
||||||
for subcategory, value in kwargs.items():
|
for subcategory, value in kwargs.items():
|
||||||
args[category] = self._cleanSearchFilter(subcategory, value)
|
args[category] = self._cleanSearchFilter(subcategory, value)
|
||||||
if libtype is not None:
|
if libtype is not None:
|
||||||
args['type'] = utils.searchType(libtype)
|
args['type'] = utils.searchType(libtype)
|
||||||
query = '/library/sections/%s/%s%s' % (
|
query = '/library/sections/%s/%s%s' % (self.key, category, utils.joinArgs(args))
|
||||||
self.key, category, utils.joinArgs(args))
|
|
||||||
return utils.listItems(self.server, query, bytag=True)
|
return utils.listItems(self.server, query, bytag=True)
|
||||||
|
|
||||||
def search(self, title=None, sort=None, maxresults=999999, libtype=None, **kwargs):
|
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
|
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.
|
doesn't iterate over all results on the server.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
title (string, optional): General string query to search for.
|
title (str): General string query to search for (optional).
|
||||||
sort (string): column:dir; column can be any of {addedAt, originallyAvailableAt, lastViewedAt,
|
sort (str): column:dir; column can be any of {addedAt, originallyAvailableAt, lastViewedAt,
|
||||||
titleSort, rating, mediaHeight, duration}. dir can be asc or desc.
|
titleSort, rating, mediaHeight, duration}. dir can be asc or desc (optional).
|
||||||
maxresults (int): Only return the specified number of results
|
maxresults (int): Only return the specified number of results (optional).
|
||||||
libtype (string): Filter results to a spcifiec libtype {movie, show, episode, artist, album, track}
|
libtype (str): Filter results to a spcifiec libtype (movie, show, episode, artist, album, track; optional).
|
||||||
kwargs: Any of the available filters for the current library section. Partial string
|
**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
|
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.
|
available options and a warning logged if the option does not appear valid.
|
||||||
|
|
||||||
'unwatched': Display or hide unwatched content (True, False). [all]
|
* unwatched: Display or hide unwatched content (True, False). [all]
|
||||||
'duplicate': Display or hide duplicate items (True, False). [movie]
|
* duplicate: Display or hide duplicate items (True, False). [movie]
|
||||||
'actor': List of actors to search ([actor_or_id, ...]). [movie]
|
* actor: List of actors to search ([actor_or_id, ...]). [movie]
|
||||||
'collection': List of collections to search within ([collection_or_id, ...]). [all]
|
* collection: List of collections to search within ([collection_or_id, ...]). [all]
|
||||||
'contentRating': List of content ratings to search within ([rating_or_key, ...]). [movie,tv]
|
* 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]
|
* country: List of countries to search within ([country_or_key, ...]). [movie,music]
|
||||||
'decade': List of decades to search within ([yyy0, ...]). [movie]
|
* decade: List of decades to search within ([yyy0, ...]). [movie]
|
||||||
'director': List of directors to search ([director_or_id, ...]). [movie]
|
* director: List of directors to search ([director_or_id, ...]). [movie]
|
||||||
'genre': List Genres to search within ([genere_or_id, ...]). [all]
|
* genre: List Genres to search within ([genere_or_id, ...]). [all]
|
||||||
'network': List of TV networks to search within ([resolution_or_key, ...]). [tv]
|
* network: List of TV networks to search within ([resolution_or_key, ...]). [tv]
|
||||||
'resolution': List of video resolutions to search within ([resolution_or_key, ...]). [movie]
|
* resolution: List of video resolutions to search within ([resolution_or_key, ...]). [movie]
|
||||||
'studio': List of studios to search within ([studio_or_key, ...]). [music]
|
* studio: List of studios to search within ([studio_or_key, ...]). [music]
|
||||||
'year': List of years to search within ([yyyy, ...]). [all]
|
* year: List of years to search within ([yyyy, ...]). [all]
|
||||||
"""
|
"""
|
||||||
# Cleanup the core arguments
|
# Cleanup the core arguments
|
||||||
args = {}
|
args = {}
|
||||||
|
@ -211,12 +318,10 @@ class LibrarySection(object):
|
||||||
args['sort'] = self._cleanSearchSort(sort)
|
args['sort'] = self._cleanSearchSort(sort)
|
||||||
if libtype is not None:
|
if libtype is not None:
|
||||||
args['type'] = utils.searchType(libtype)
|
args['type'] = utils.searchType(libtype)
|
||||||
|
|
||||||
# Iterate over the results
|
# Iterate over the results
|
||||||
results, subresults = [], '_init'
|
results, subresults = [], '_init'
|
||||||
args['X-Plex-Container-Start'] = 0
|
args['X-Plex-Container-Start'] = 0
|
||||||
args['X-Plex-Container-Size'] = min(X_PLEX_CONTAINER_SIZE, maxresults)
|
args['X-Plex-Container-Size'] = min(X_PLEX_CONTAINER_SIZE, maxresults)
|
||||||
|
|
||||||
while subresults and maxresults > len(results):
|
while subresults and maxresults > len(results):
|
||||||
query = '/library/sections/%s/all%s' % (
|
query = '/library/sections/%s/all%s' % (
|
||||||
self.key, utils.joinArgs(args))
|
self.key, utils.joinArgs(args))
|
||||||
|
@ -233,13 +338,11 @@ class LibrarySection(object):
|
||||||
return '1' if value else '0'
|
return '1' if value else '0'
|
||||||
if not isinstance(value, (list, tuple)):
|
if not isinstance(value, (list, tuple)):
|
||||||
value = [value]
|
value = [value]
|
||||||
|
|
||||||
# convert list of values to list of keys or ids
|
# convert list of values to list of keys or ids
|
||||||
result = set()
|
result = set()
|
||||||
choices = self.listChoices(category, libtype)
|
choices = self.listChoices(category, libtype)
|
||||||
lookup = {c.title.lower(): unquote(unquote(c.key)) for c in choices}
|
lookup = {c.title.lower(): unquote(unquote(c.key)) for c in choices}
|
||||||
allowed = set(c.key for c in choices)
|
allowed = set(c.key for c in choices)
|
||||||
|
|
||||||
for item in value:
|
for item in value:
|
||||||
item = str(item.id if isinstance(item, MediaTag) else item).lower()
|
item = str(item.id if isinstance(item, MediaTag) else item).lower()
|
||||||
# find most logical choice(s) to use in url
|
# find most logical choice(s) to use in url
|
||||||
|
@ -254,8 +357,7 @@ class LibrarySection(object):
|
||||||
map(result.add, matches)
|
map(result.add, matches)
|
||||||
continue
|
continue
|
||||||
# nothing matched; use raw item value
|
# nothing matched; use raw item value
|
||||||
log.warning(
|
log.warning('Filter value not listed, using raw item value: %s' % item)
|
||||||
'Filter value not listed, using raw item value: %s' % item)
|
|
||||||
result.add(item)
|
result.add(item)
|
||||||
return ','.join(result)
|
return ','.join(result)
|
||||||
|
|
||||||
|
@ -271,58 +373,108 @@ class LibrarySection(object):
|
||||||
|
|
||||||
|
|
||||||
class MovieSection(LibrarySection):
|
class MovieSection(LibrarySection):
|
||||||
ALLOWED_FILTERS = ('unwatched', 'duplicate', 'year', 'decade', 'genre', 'contentRating', 'collection',
|
""" Represents a :class:`~plexapi.library.LibrarySection` section containing movies.
|
||||||
'director', 'actor', 'country', 'studio', 'resolution')
|
|
||||||
|
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',
|
ALLOWED_SORT = ('addedAt', 'originallyAvailableAt', 'lastViewedAt', 'titleSort', 'rating',
|
||||||
'mediaHeight', 'duration')
|
'mediaHeight', 'duration')
|
||||||
TYPE = 'movie'
|
TYPE = 'movie'
|
||||||
|
|
||||||
|
|
||||||
class ShowSection(LibrarySection):
|
class ShowSection(LibrarySection):
|
||||||
ALLOWED_FILTERS = ('unwatched', 'year', 'genre',
|
""" Represents a :class:`~plexapi.library.LibrarySection` section containing tv shows.
|
||||||
'contentRating', 'network', 'collection')
|
|
||||||
ALLOWED_SORT = ('addedAt', 'lastViewedAt',
|
Attributes:
|
||||||
'originallyAvailableAt', 'titleSort', 'rating', 'unwatched')
|
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'
|
TYPE = 'show'
|
||||||
|
|
||||||
def searchShows(self, **kwargs):
|
def searchShows(self, **kwargs):
|
||||||
|
""" Search for a show. See :func:`~plexapi.library.LibrarySection.search()` for usage. """
|
||||||
return self.search(libtype='show', **kwargs)
|
return self.search(libtype='show', **kwargs)
|
||||||
|
|
||||||
def searchEpisodes(self, **kwargs):
|
def searchEpisodes(self, **kwargs):
|
||||||
|
""" Search for an episode. See :func:`~plexapi.library.LibrarySection.search()` for usage. """
|
||||||
return self.search(libtype='episode', **kwargs)
|
return self.search(libtype='episode', **kwargs)
|
||||||
|
|
||||||
def recentlyAdded(self, libtype='episode', maxresults=50):
|
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)
|
return self.search(sort='addedAt:desc', libtype=libtype, maxresults=maxresults)
|
||||||
|
|
||||||
|
|
||||||
class MusicSection(LibrarySection):
|
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_FILTERS = ('genre', 'country', 'collection')
|
||||||
ALLOWED_SORT = ('addedAt', 'lastViewedAt', 'viewCount', 'titleSort')
|
ALLOWED_SORT = ('addedAt', 'lastViewedAt', 'viewCount', 'titleSort')
|
||||||
TYPE = 'artist'
|
TYPE = 'artist'
|
||||||
|
|
||||||
def albums(self):
|
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)
|
return utils.listItems(self.server, '/library/sections/%s/albums' % self.key)
|
||||||
|
|
||||||
def searchArtists(self, **kwargs):
|
def searchArtists(self, **kwargs):
|
||||||
|
""" Search for an artist. See :func:`~plexapi.library.LibrarySection.search()` for usage. """
|
||||||
return self.search(libtype='artist', **kwargs)
|
return self.search(libtype='artist', **kwargs)
|
||||||
|
|
||||||
def searchAlbums(self, **kwargs):
|
def searchAlbums(self, **kwargs):
|
||||||
|
""" Search for an album. See :func:`~plexapi.library.LibrarySection.search()` for usage. """
|
||||||
return self.search(libtype='album', **kwargs)
|
return self.search(libtype='album', **kwargs)
|
||||||
|
|
||||||
def searchTracks(self, **kwargs):
|
def searchTracks(self, **kwargs):
|
||||||
|
""" Search for a track. See :func:`~plexapi.library.LibrarySection.search()` for usage. """
|
||||||
return self.search(libtype='track', **kwargs)
|
return self.search(libtype='track', **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class PhotoSection(LibrarySection):
|
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 = ()
|
ALLOWED_SORT = ()
|
||||||
TYPE = 'photo'
|
TYPE = 'photo'
|
||||||
|
|
||||||
def searchAlbums(self, **kwargs):
|
def searchAlbums(self, title, **kwargs): # lets use this for now.
|
||||||
return self.search(libtype='photo', **kwargs)
|
""" 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):
|
def searchPhotos(self, title, **kwargs):
|
||||||
return self.search(libtype='photo', **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
|
@utils.register_libtype
|
||||||
|
@ -363,6 +515,20 @@ class Hub(object):
|
||||||
|
|
||||||
@utils.register_libtype
|
@utils.register_libtype
|
||||||
class FilterChoice(object):
|
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'
|
TYPE = 'Directory'
|
||||||
|
|
||||||
def __init__(self, server, data, initpath):
|
def __init__(self, server, data, initpath):
|
||||||
|
|
|
@ -1,7 +1,4 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
|
||||||
PlexAPI Media
|
|
||||||
"""
|
|
||||||
from plexapi.utils import cast
|
from plexapi.utils import cast
|
||||||
|
|
||||||
|
|
||||||
|
@ -177,15 +174,49 @@ class MediaTag(object):
|
||||||
return '<%s:%s:%s>' % (self.__class__.__name__, self.id, tag)
|
return '<%s:%s:%s>' % (self.__class__.__name__, self.id, tag)
|
||||||
|
|
||||||
|
|
||||||
class Collection(MediaTag): TYPE = 'Collection'; FILTER = 'collection'
|
class Collection(MediaTag):
|
||||||
class Country(MediaTag): TYPE = 'Country'; FILTER = 'country'
|
TYPE = 'Collection'
|
||||||
class Director(MediaTag): TYPE = 'Director'; FILTER = 'director'
|
FILTER = 'collection'
|
||||||
class Genre(MediaTag): TYPE = 'Genre'; FILTER = 'genre'
|
|
||||||
class Mood(MediaTag): TYPE = 'Mood'; FILTER = 'mood'
|
|
||||||
class Producer(MediaTag): TYPE = 'Producer'; FILTER = 'producer'
|
class Country(MediaTag):
|
||||||
class Role(MediaTag): TYPE = 'Role'; FILTER = 'role'
|
TYPE = 'Country'
|
||||||
class Similar(MediaTag): TYPE = 'Similar'; FILTER = 'similar'
|
FILTER = 'country'
|
||||||
class Writer(MediaTag): TYPE = 'Writer'; FILTER = 'writer'
|
|
||||||
|
|
||||||
|
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):
|
class Field(object):
|
||||||
|
|
|
@ -1,69 +1,56 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
import sys
|
import plexapi, requests
|
||||||
|
from plexapi import TIMEOUT, log, logfilter, utils
|
||||||
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
|
|
||||||
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
|
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
|
||||||
from plexapi.client import PlexClient
|
from plexapi.client import PlexClient
|
||||||
|
from plexapi.compat import ElementTree
|
||||||
from plexapi.server import PlexServer
|
from plexapi.server import PlexServer
|
||||||
|
from requests.status_codes import _codes as codes
|
||||||
|
CONFIG = plexapi.CONFIG
|
||||||
|
|
||||||
|
|
||||||
class MyPlexAccount(object):
|
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:
|
Attributes:
|
||||||
authenticationToken (TYPE): Description
|
authenticationToken (str): <Unknown>
|
||||||
BASEURL (str): Description
|
certificateVersion (str): <Unknown>
|
||||||
certificateVersion (TYPE): Description
|
cloudSyncDevice (str):
|
||||||
cloudSyncDevice (TYPE): Description
|
email (str): Your current Plex email address.
|
||||||
email (TYPE): Description
|
entitlements (List<str>): List of devices your allowed to use with this account.
|
||||||
entitlements (TYPE): Description
|
guest (bool): <Unknown>
|
||||||
guest (TYPE): Description
|
home (bool): <Unknown>
|
||||||
home (TYPE): Description
|
homeSize (int): <Unknown>
|
||||||
homeSize (TYPE): Description
|
id (str): Your Plex account ID.
|
||||||
id (TYPE): Description
|
locale (str): Your Plex locale
|
||||||
locale (TYPE): Description
|
mailing_list_status (str): Your current mailing list status.
|
||||||
mailing_list_status (TYPE): Description
|
maxHomeSize (int): <Unknown>
|
||||||
maxHomeSize (TYPE): Description
|
queueEmail (str): Email address to add items to your `Watch Later` queue.
|
||||||
queueEmail (TYPE): Description
|
queueUid (str): <Unknown>
|
||||||
queueUid (TYPE): Description
|
restricted (bool): <Unknown>
|
||||||
restricted (TYPE): Description
|
roles: (List<str>) Lit of account roles. Plexpass membership listed here.
|
||||||
roles (TYPE): Description
|
scrobbleTypes (str): Description
|
||||||
scrobbleTypes (TYPE): Description
|
secure (bool): Description
|
||||||
secure (TYPE): Description
|
subscriptionActive (bool): True if your subsctiption is active.
|
||||||
SIGNIN (str): Description
|
subscriptionFeatures: (List<str>) List of features allowed on your subscription.
|
||||||
subscriptionActive (TYPE): Description
|
subscriptionPlan (str): Name of subscription plan.
|
||||||
subscriptionFeatures (TYPE): Description
|
subscriptionStatus (str): String representation of `subscriptionActive`.
|
||||||
subscriptionPlan (TYPE): Description
|
thumb (str): URL of your account thumbnail.
|
||||||
subscriptionStatus (TYPE): Description
|
title (str): <Unknown> - Looks like an alias for `username`.
|
||||||
thumb (TYPE): Description
|
username (str): Your account username.
|
||||||
title (TYPE): Description
|
uuid (str): <Unknown>
|
||||||
username (TYPE): Description
|
|
||||||
uuid (TYPE): Description
|
|
||||||
"""
|
"""
|
||||||
BASEURL = 'https://plex.tv/users/account'
|
BASEURL = 'https://plex.tv/users/account'
|
||||||
SIGNIN = 'https://my.plexapp.com/users/sign_in.xml'
|
SIGNIN = 'https://my.plexapp.com/users/sign_in.xml'
|
||||||
|
|
||||||
def __init__(self, data, initpath=None):
|
def __init__(self, data=None, initpath=None, session=None):
|
||||||
"""Sets the attrs.
|
self._session = session or requests.Session()
|
||||||
|
|
||||||
Args:
|
|
||||||
data (Element): XML response from PMS as a Element
|
|
||||||
initpath (string, optional): relative path.
|
|
||||||
"""
|
|
||||||
self.authenticationToken = data.attrib.get('authenticationToken')
|
self.authenticationToken = data.attrib.get('authenticationToken')
|
||||||
|
if self.authenticationToken:
|
||||||
|
logfilter.add_secret(self.authenticationToken)
|
||||||
self.certificateVersion = data.attrib.get('certificateVersion')
|
self.certificateVersion = data.attrib.get('certificateVersion')
|
||||||
self.cloudSyncDevice = data.attrib.get('cloudSyncDevice')
|
self.cloudSyncDevice = data.attrib.get('cloudSyncDevice')
|
||||||
self.email = data.attrib.get('email')
|
self.email = data.attrib.get('email')
|
||||||
|
@ -83,143 +70,114 @@ class MyPlexAccount(object):
|
||||||
self.title = data.attrib.get('title')
|
self.title = data.attrib.get('title')
|
||||||
self.username = data.attrib.get('username')
|
self.username = data.attrib.get('username')
|
||||||
self.uuid = data.attrib.get('uuid')
|
self.uuid = data.attrib.get('uuid')
|
||||||
|
# TODO: Fetch missing MyPlexAccount attributes
|
||||||
# TODO: Complete these items!
|
self.subscriptionActive = None # renamed on server
|
||||||
self.subscriptionActive = None # renamed on server
|
self.subscriptionStatus = None # renamed on server
|
||||||
self.subscriptionStatus = None # renamed on server
|
self.subscriptionPlan = None # renmaed on server
|
||||||
self.subscriptionPlan = None # renmaed on server
|
self.subscriptionFeatures = None # renamed on server
|
||||||
self.subscriptionFeatures = None # renamed on server
|
|
||||||
self.roles = None
|
self.roles = None
|
||||||
self.entitlements = None
|
self.entitlements = None
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
"""Pretty print."""
|
|
||||||
return '<%s:%s:%s>' % (self.__class__.__name__, self.id, self.username.encode('utf8'))
|
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):
|
def device(self, name):
|
||||||
"""Return a device wth a matching name.
|
""" Returns the :class:`~plexapi.myplex.MyPlexDevice` that matches the name specified.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
name (str): Name to match against.
|
name (str): Name to match against.
|
||||||
|
|
||||||
Returns:
|
|
||||||
class: MyPlexDevice
|
|
||||||
"""
|
"""
|
||||||
return _findItem(self.devices(), name)
|
return _findItem(self.devices(), name)
|
||||||
|
|
||||||
def resources(self):
|
def devices(self):
|
||||||
"""Resources.
|
""" Returns a list of all :class:`~plexapi.myplex.MyPlexDevice` objects connected to the server. """
|
||||||
|
return _listItems(MyPlexDevice.BASEURL, self.authenticationToken, MyPlexDevice)
|
||||||
|
|
||||||
Returns:
|
def resources(self):
|
||||||
List: of MyPlexResource
|
""" Returns a list of all :class:`~plexapi.myplex.MyPlexResource` objects connected to the server. """
|
||||||
"""
|
|
||||||
return _listItems(MyPlexResource.BASEURL, self.authenticationToken, MyPlexResource)
|
return _listItems(MyPlexResource.BASEURL, self.authenticationToken, MyPlexResource)
|
||||||
|
|
||||||
def resource(self, name):
|
def resource(self, name):
|
||||||
"""Find resource ny name.
|
""" Returns the :class:`~plexapi.myplex.MyPlexResource` that matches the name specified.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
name (str): to find
|
name (str): Name to match against.
|
||||||
|
|
||||||
Returns:
|
|
||||||
class: MyPlexResource
|
|
||||||
"""
|
"""
|
||||||
return _findItem(self.resources(), name)
|
return _findItem(self.resources(), name)
|
||||||
|
|
||||||
def users(self):
|
def users(self):
|
||||||
"""List of users.
|
""" Returns a list of all :class:`~plexapi.myplex.MyPlexUser` objects connected to your account. """
|
||||||
|
|
||||||
Returns:
|
|
||||||
List: of MyPlexuser
|
|
||||||
"""
|
|
||||||
return _listItems(MyPlexUser.BASEURL, self.authenticationToken, MyPlexUser)
|
return _listItems(MyPlexUser.BASEURL, self.authenticationToken, MyPlexUser)
|
||||||
|
|
||||||
def user(self, email):
|
def user(self, email):
|
||||||
"""Find a user by email.
|
""" Returns the :class:`~myplex.MyPlexUser` that matches the email or username specified.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
email (str): Username to match against.
|
email (str): Username or email to match against.
|
||||||
|
|
||||||
Returns:
|
|
||||||
class: User
|
|
||||||
"""
|
"""
|
||||||
return _findItem(self.users(), email, ['username', 'email'])
|
return _findItem(self.users(), email, ['username', 'email'])
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def signin(cls, username, password):
|
def signin(cls, username=None, password=None, session=None):
|
||||||
"""Summary
|
""" 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:
|
Parameters:
|
||||||
username (str): username
|
username (str): Your MyPlex.tv username. If not specified, it will check the config.ini file.
|
||||||
password (str): password
|
password (str): Your MyPlex.tv password. If not specified, it will check the config.ini file.
|
||||||
|
|
||||||
Returns:
|
Raises:
|
||||||
class: MyPlexAccount
|
: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.
|
||||||
Raises:
|
|
||||||
BadRequest: (HTTPCODE) http codename
|
|
||||||
Unauthorized: (HTTPCODE) http codename
|
|
||||||
"""
|
"""
|
||||||
if 'X-Plex-Token' in plexapi.BASE_HEADERS:
|
if 'X-Plex-Token' in plexapi.BASE_HEADERS:
|
||||||
del plexapi.BASE_HEADERS['X-Plex-Token']
|
del plexapi.BASE_HEADERS['X-Plex-Token']
|
||||||
|
username = username or CONFIG.get('authentication.username')
|
||||||
|
password = password or CONFIG.get('authentication.password')
|
||||||
auth = (username, password)
|
auth = (username, password)
|
||||||
log.info('POST %s', cls.SIGNIN)
|
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)
|
cls.SIGNIN, headers=plexapi.BASE_HEADERS, auth=auth, timeout=TIMEOUT)
|
||||||
if response.status_code != requests.codes.created:
|
if response.status_code != requests.codes.created:
|
||||||
codename = codes.get(response.status_code)[0]
|
codename = codes.get(response.status_code)[0]
|
||||||
if response.status_code == 401:
|
if response.status_code == 401:
|
||||||
raise Unauthorized('(%s) %s' %
|
raise Unauthorized('(%s) %s' % (response.status_code, codename))
|
||||||
(response.status_code, codename))
|
|
||||||
raise BadRequest('(%s) %s' % (response.status_code, codename))
|
raise BadRequest('(%s) %s' % (response.status_code, codename))
|
||||||
data = ElementTree.fromstring(response.text.encode('utf8'))
|
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 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:
|
Attributes:
|
||||||
allowCameraUpload (bool): True if this user can upload images
|
allowCameraUpload (bool): True if this user can upload images
|
||||||
allowChannels (bool): True if this user has access to channels
|
allowChannels (bool): True if this user has access to channels
|
||||||
allowSync (bool): True if this user can sync
|
allowSync (bool): True if this user can sync
|
||||||
BASEURL (str): Description
|
email (str): User's email address (user@gmail.com)
|
||||||
email (str): user@gmail.com
|
filterAll (str): Unknown
|
||||||
filterAll (str): Description
|
filterMovies (str): Unknown
|
||||||
filterMovies (str): Description
|
filterMusic (str): Unknown
|
||||||
filterMusic (str): Description
|
filterPhotos (str): Unknown
|
||||||
filterPhotos (str): Description
|
filterTelevision (str): Unknown
|
||||||
filterTelevision (str): Description
|
home (bool): Unknown
|
||||||
home (bool):
|
id (int): User's Plex account ID.
|
||||||
id (int): 1337
|
protected (False): Unknown (possibly SSL enabled?)
|
||||||
protected (False): Is this if ssl? check it
|
recommendationsPlaylistId (str): Unknown
|
||||||
recommendationsPlaylistId (str): Description
|
restricted (str): Unknown
|
||||||
restricted (str): fx 0
|
thumb (str): Link to the users avatar
|
||||||
thumb (str): Link to the users avatar
|
title (str): Seems to be an aliad for username
|
||||||
title (str): Hellowlol
|
username (str): User's username
|
||||||
username (str): Hellowlol
|
|
||||||
"""
|
"""
|
||||||
BASEURL = 'https://plex.tv/api/users/'
|
BASEURL = 'https://plex.tv/api/users/'
|
||||||
|
|
||||||
def __init__(self, data, initpath=None):
|
def __init__(self, data, initpath=None):
|
||||||
"""Summary
|
self.allowCameraUpload = utils.cast(bool, data.attrib.get('allowCameraUpload'))
|
||||||
|
|
||||||
Args:
|
|
||||||
data (Element): XML repsonse as Element
|
|
||||||
initpath (None, optional): Relative url str
|
|
||||||
"""
|
|
||||||
self.allowCameraUpload = utils.cast(
|
|
||||||
bool, data.attrib.get('allowCameraUpload'))
|
|
||||||
self.allowChannels = utils.cast(bool, data.attrib.get('allowChannels'))
|
self.allowChannels = utils.cast(bool, data.attrib.get('allowChannels'))
|
||||||
self.allowSync = utils.cast(bool, data.attrib.get('allowSync'))
|
self.allowSync = utils.cast(bool, data.attrib.get('allowSync'))
|
||||||
self.email = data.attrib.get('email')
|
self.email = data.attrib.get('email')
|
||||||
|
@ -231,50 +189,48 @@ class MyPlexUser(object):
|
||||||
self.home = utils.cast(bool, data.attrib.get('home'))
|
self.home = utils.cast(bool, data.attrib.get('home'))
|
||||||
self.id = utils.cast(int, data.attrib.get('id'))
|
self.id = utils.cast(int, data.attrib.get('id'))
|
||||||
self.protected = utils.cast(bool, data.attrib.get('protected'))
|
self.protected = utils.cast(bool, data.attrib.get('protected'))
|
||||||
self.recommendationsPlaylistId = data.attrib.get(
|
self.recommendationsPlaylistId = data.attrib.get('recommendationsPlaylistId')
|
||||||
'recommendationsPlaylistId')
|
|
||||||
self.restricted = data.attrib.get('restricted')
|
self.restricted = data.attrib.get('restricted')
|
||||||
self.thumb = data.attrib.get('thumb')
|
self.thumb = data.attrib.get('thumb')
|
||||||
self.title = data.attrib.get('title')
|
self.title = data.attrib.get('title')
|
||||||
self.username = data.attrib.get('username')
|
self.username = data.attrib.get('username')
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
"""Pretty repr."""
|
|
||||||
return '<%s:%s:%s>' % (self.__class__.__name__, self.id, self.username)
|
return '<%s:%s:%s>' % (self.__class__.__name__, self.id, self.username)
|
||||||
|
|
||||||
|
|
||||||
class MyPlexResource(object):
|
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:
|
Attributes:
|
||||||
accessToken (str): This resource accesstoken.
|
accessToken (str): This resources accesstoken.
|
||||||
BASEURL (TYPE): Description
|
clientIdentifier (str): Unique ID for this resource.
|
||||||
clientIdentifier (str): 1f2fe128794fd...
|
connections (list): List of :class:`~myplex.ResourceConnection` objects
|
||||||
connections (list): of ResourceConnection
|
for this resource.
|
||||||
createdAt (datetime): Description
|
createdAt (datetime): Timestamp this resource first connected to your server.
|
||||||
device (str): pc
|
device (str): Best guess on the type of device this is (PS, iPhone, Linux, etc).
|
||||||
home (None): Dunno wtf this can me
|
home (bool): Unknown
|
||||||
lastSeenAt (datetime): Description
|
lastSeenAt (datetime): Timestamp this resource last connected.
|
||||||
name (str): Pretty name fx S-PC
|
name (str): Descriptive name of this resource.
|
||||||
owned (bool): True if this is your own.
|
owned (bool): True if this resource is one of your own (you logged into it).
|
||||||
platform (str): Windows
|
platform (str): OS the resource is running (Linux, Windows, Chrome, etc.)
|
||||||
platformVersion (str): fx. 6.1 (Build 7601)
|
platformVersion (str): Version of the platform.
|
||||||
presence (bool): True if online
|
presence (bool): True if the resource is online
|
||||||
product (str): Plex Media Server
|
product (str): Plex product (Plex Media Server, Plex for iOS, Plex Web, etc.)
|
||||||
productVersion (str): 1.3.3.3148-b38628e
|
productVersion (str): Version of the product.
|
||||||
provides (str): fx server
|
provides (str): List of services this resource provides (client, server,
|
||||||
synced (bool): Description
|
player, pubsub-player, etc.)
|
||||||
|
synced (bool): Unknown (possibly True if the resource has synced content?)
|
||||||
"""
|
"""
|
||||||
BASEURL = 'https://plex.tv/api/resources?includeHttps=1'
|
BASEURL = 'https://plex.tv/api/resources?includeHttps=1'
|
||||||
|
|
||||||
def __init__(self, data):
|
def __init__(self, data):
|
||||||
"""Summary
|
|
||||||
|
|
||||||
Args:
|
|
||||||
data (Element): XML response as Element
|
|
||||||
"""
|
|
||||||
self.name = data.attrib.get('name')
|
self.name = data.attrib.get('name')
|
||||||
self.accessToken = data.attrib.get('accessToken')
|
self.accessToken = data.attrib.get('accessToken')
|
||||||
|
if self.accessToken:
|
||||||
|
logfilter.add_secret(self.accessToken)
|
||||||
self.product = data.attrib.get('product')
|
self.product = data.attrib.get('product')
|
||||||
self.productVersion = data.attrib.get('productVersion')
|
self.productVersion = data.attrib.get('productVersion')
|
||||||
self.platform = data.attrib.get('platform')
|
self.platform = data.attrib.get('platform')
|
||||||
|
@ -288,34 +244,36 @@ class MyPlexResource(object):
|
||||||
self.home = utils.cast(bool, data.attrib.get('home'))
|
self.home = utils.cast(bool, data.attrib.get('home'))
|
||||||
self.synced = utils.cast(bool, data.attrib.get('synced'))
|
self.synced = utils.cast(bool, data.attrib.get('synced'))
|
||||||
self.presence = utils.cast(bool, data.attrib.get('presence'))
|
self.presence = utils.cast(bool, data.attrib.get('presence'))
|
||||||
self.connections = [ResourceConnection(
|
self.connections = [ResourceConnection(elem) for elem in data if elem.tag == 'Connection']
|
||||||
elem) for elem in data if elem.tag == 'Connection']
|
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
"""Pretty repr."""
|
|
||||||
return '<%s:%s>' % (self.__class__.__name__, self.name.encode('utf8'))
|
return '<%s:%s>' % (self.__class__.__name__, self.name.encode('utf8'))
|
||||||
|
|
||||||
def connect(self, ssl=None):
|
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:
|
Parameters:
|
||||||
ssl (None, optional): Use ssl.
|
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:
|
Raises:
|
||||||
class: Plexserver
|
:class:`~plexapi.exceptions.NotFound`: When unable to connect to any addresses for this resource.
|
||||||
|
|
||||||
Raises:
|
|
||||||
NotFound: Unable to connect to resource: name
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Sort connections from (https, local) to (http, remote)
|
# Sort connections from (https, local) to (http, remote)
|
||||||
# Only check non-local connections unless we own the resource
|
# Only check non-local connections unless we own the resource
|
||||||
forcelocal = lambda c: self.owned or c.local
|
forcelocal = lambda c: self.owned or c.local
|
||||||
connections = sorted(
|
connections = sorted(self.connections, key=lambda c: c.local, reverse=True)
|
||||||
self.connections, key=lambda c: c.local, reverse=True)
|
|
||||||
https = [c.uri for c in self.connections if forcelocal(c)]
|
https = [c.uri for c in self.connections if forcelocal(c)]
|
||||||
http = [c.httpuri 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
|
# Try connecting to all known resource connections in parellel, but
|
||||||
# only return the first server (in order) that provides a response.
|
# only return the first server (in order) that provides a response.
|
||||||
listargs = [[c] for c in connections]
|
listargs = [[c] for c in connections]
|
||||||
|
@ -325,52 +283,33 @@ class MyPlexResource(object):
|
||||||
# established.
|
# established.
|
||||||
for url, token, result in results:
|
for url, token, result in results:
|
||||||
okerr = 'OK' if result else 'ERR'
|
okerr = 'OK' if result else 'ERR'
|
||||||
log.info(
|
log.info('Testing resource connection: %s?X-Plex-Token=%s %s', url, token, okerr)
|
||||||
'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]
|
||||||
|
|
||||||
results = [r[2] for r in results if r and r is not None]
|
|
||||||
if not results:
|
if not results:
|
||||||
raise NotFound('Unable to connect to resource: %s' % self.name)
|
raise NotFound('Unable to connect to resource: %s' % self.name)
|
||||||
log.info('Connecting to server: %s?X-Plex-Token=%s',
|
log.info('Connecting to server: %s?X-Plex-Token=%s', results[0].baseurl, results[0].token)
|
||||||
results[0].baseurl, results[0].token)
|
|
||||||
|
|
||||||
return results[0]
|
return results[0]
|
||||||
|
|
||||||
def _connect(self, url, results, i):
|
def _connect(self, url, results, i):
|
||||||
"""Connect.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
url (str): url to the resource
|
|
||||||
results (TYPE): Description
|
|
||||||
i (TYPE): Description
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
TYPE: Description
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
results[i] = (url, self.accessToken,
|
results[i] = (url, self.accessToken, PlexServer(url, self.accessToken))
|
||||||
PlexServer(url, self.accessToken))
|
|
||||||
except NotFound:
|
except NotFound:
|
||||||
results[i] = (url, self.accessToken, None)
|
results[i] = (url, self.accessToken, None)
|
||||||
|
|
||||||
|
|
||||||
class ResourceConnection(object):
|
class ResourceConnection(object):
|
||||||
"""ResourceConnection.
|
""" Represents a Resource Connection object found within the
|
||||||
|
:class:`~myplex.MyPlexResource` objects.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
address (str): Local ip adress
|
address (str): Local IP address
|
||||||
httpuri (str): Full local address
|
httpuri (str): Full local address
|
||||||
local (bool): True if local
|
local (bool): True if local
|
||||||
port (int): 32400
|
port (int): 32400
|
||||||
protocol (str): http or https
|
protocol (str): HTTP or HTTPS
|
||||||
uri (str): External adress
|
uri (str): External address
|
||||||
"""
|
"""
|
||||||
def __init__(self, data):
|
def __init__(self, data):
|
||||||
"""Set attrs.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
data (Element): XML response as Element from PMS.
|
|
||||||
"""
|
|
||||||
self.protocol = data.attrib.get('protocol')
|
self.protocol = data.attrib.get('protocol')
|
||||||
self.address = data.attrib.get('address')
|
self.address = data.attrib.get('address')
|
||||||
self.port = utils.cast(int, data.attrib.get('port'))
|
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)
|
self.httpuri = 'http://%s:%s' % (self.address, self.port)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
"""Pretty repr."""
|
|
||||||
return '<%s:%s>' % (self.__class__.__name__, self.uri.encode('utf8'))
|
return '<%s:%s>' % (self.__class__.__name__, self.uri.encode('utf8'))
|
||||||
|
|
||||||
|
|
||||||
class MyPlexDevice(object):
|
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:
|
Attributes:
|
||||||
BASEURL (str): Plex.tv XML device url
|
clientIdentifier (str): Unique ID for this resource.
|
||||||
clientIdentifier (str): 0x685d43d...
|
connections (list): List of connection URIs for the device.
|
||||||
connections (list):
|
device (str): Best guess on the type of device this is (Linux, iPad, AFTB, etc).
|
||||||
device (str): fx Windows
|
id (str): MyPlex ID of the device.
|
||||||
id (str): 123
|
model (str): Model of the device (bueller, Linux, x86_64, etc.)
|
||||||
model (str):
|
name (str): Hostname of the device.
|
||||||
name (str): fx Computername
|
platform (str): OS the resource is running (Linux, Windows, Chrome, etc.)
|
||||||
platform (str): Windows
|
platformVersion (str): Version of the platform.
|
||||||
platformVersion (str): Fx 8
|
product (str): Plex product (Plex Media Server, Plex for iOS, Plex Web, etc.)
|
||||||
product (str): Fx PlexAPI
|
productVersion (string): Version of the product.
|
||||||
productVersion (string): 2.0.2
|
provides (str): List of services this resource provides (client, controller,
|
||||||
provides (str): fx controller
|
sync-target, player, pubsub-player).
|
||||||
publicAddress (str): Public ip address
|
publicAddress (str): Public IP address.
|
||||||
screenDensity (str): Description
|
screenDensity (str): Unknown
|
||||||
screenResolution (str): Description
|
screenResolution (str): Screen resolution (750x1334, 1242x2208, etc.)
|
||||||
token (str): Auth token
|
token (str): Plex authentication token for the device.
|
||||||
vendor (str): Description
|
vendor (str): Device vendor (ubuntu, etc).
|
||||||
version (str): fx 2.0.2
|
version (str): Unknown (1, 2, 1.3.3.3148-b38628e, 1.3.15, etc.)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
BASEURL = 'https://plex.tv/devices.xml'
|
BASEURL = 'https://plex.tv/devices.xml'
|
||||||
|
|
||||||
def __init__(self, data):
|
def __init__(self, data):
|
||||||
"""Set attrs
|
|
||||||
|
|
||||||
Args:
|
|
||||||
data (Element): XML response as Element from PMS
|
|
||||||
"""
|
|
||||||
self.name = data.attrib.get('name')
|
self.name = data.attrib.get('name')
|
||||||
self.publicAddress = data.attrib.get('publicAddress')
|
self.publicAddress = data.attrib.get('publicAddress')
|
||||||
self.product = data.attrib.get('product')
|
self.product = data.attrib.get('product')
|
||||||
|
@ -429,26 +364,23 @@ class MyPlexDevice(object):
|
||||||
self.version = data.attrib.get('version')
|
self.version = data.attrib.get('version')
|
||||||
self.id = data.attrib.get('id')
|
self.id = data.attrib.get('id')
|
||||||
self.token = data.attrib.get('token')
|
self.token = data.attrib.get('token')
|
||||||
|
if self.token:
|
||||||
|
logfilter.add_secret(self.token)
|
||||||
self.screenResolution = data.attrib.get('screenResolution')
|
self.screenResolution = data.attrib.get('screenResolution')
|
||||||
self.screenDensity = data.attrib.get('screenDensity')
|
self.screenDensity = data.attrib.get('screenDensity')
|
||||||
self.connections = [connection.attrib.get(
|
self.connections = [connection.attrib.get('uri') for connection in data.iter('Connection')]
|
||||||
'uri') for connection in data.iter('Connection')]
|
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
"""Pretty repr."""
|
|
||||||
return '<%s:%s:%s>' % (self.__class__.__name__, self.name.encode('utf8'), self.product.encode('utf8'))
|
return '<%s:%s:%s>' % (self.__class__.__name__, self.name.encode('utf8'), self.product.encode('utf8'))
|
||||||
|
|
||||||
def connect(self, ssl=None):
|
def connect(self):
|
||||||
"""Connect to the first server.
|
""" 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:
|
Raises:
|
||||||
ssl (None, optional): Use SSL?
|
:class:`~plexapi.exceptions.NotFound`: When unable to connect to any addresses for this device.
|
||||||
|
|
||||||
Returns:
|
|
||||||
TYPE: Plexserver
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
NotFound: Unable to connect to resource: name
|
|
||||||
"""
|
"""
|
||||||
# Try connecting to all known resource connections in parellel, but
|
# Try connecting to all known resource connections in parellel, but
|
||||||
# only return the first server (in order) that provides a response.
|
# only return the first server (in order) that provides a response.
|
||||||
|
@ -459,46 +391,23 @@ class MyPlexDevice(object):
|
||||||
# established.
|
# established.
|
||||||
for url, token, result in results:
|
for url, token, result in results:
|
||||||
okerr = 'OK' if result else 'ERR'
|
okerr = 'OK' if result else 'ERR'
|
||||||
log.info('Testing device connection: %s?X-Plex-Token=%s %s',
|
log.info('Testing device connection: %s?X-Plex-Token=%s %s', url, token, okerr)
|
||||||
url, token, okerr)
|
|
||||||
results = [r[2] for r in results if r and r[2] is not None]
|
results = [r[2] for r in results if r and r[2] is not None]
|
||||||
if not results:
|
if not results:
|
||||||
raise NotFound('Unable to connect to resource: %s' % self.name)
|
raise NotFound('Unable to connect to resource: %s' % self.name)
|
||||||
log.info('Connecting to server: %s?X-Plex-Token=%s',
|
log.info('Connecting to server: %s?X-Plex-Token=%s', results[0].baseurl, results[0].token)
|
||||||
results[0].baseurl, results[0].token)
|
|
||||||
|
|
||||||
return results[0]
|
return results[0]
|
||||||
|
|
||||||
def _connect(self, url, results, i):
|
def _connect(self, url, results, i):
|
||||||
"""Summary
|
|
||||||
|
|
||||||
Args:
|
|
||||||
url (TYPE): Description
|
|
||||||
results (TYPE): Description
|
|
||||||
i (TYPE): Description
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
TYPE: Description
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
results[i] = (url, self.token, PlexClient(url, self.token))
|
results[i] = (url, self.token, PlexClient(url, self.token))
|
||||||
except NotFound as err:
|
except NotFound:
|
||||||
results[i] = (url, self.token, None)
|
results[i] = (url, self.token, None)
|
||||||
|
|
||||||
|
|
||||||
def _findItem(items, value, attrs=None):
|
def _findItem(items, value, attrs=None):
|
||||||
"""Simple helper to find something using attrs
|
""" This will return the first item in the list of items where value is
|
||||||
|
found in any of the specified attributes.
|
||||||
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
|
|
||||||
"""
|
"""
|
||||||
attrs = attrs or ['name']
|
attrs = attrs or ['name']
|
||||||
for item in items:
|
for item in items:
|
||||||
|
@ -509,16 +418,7 @@ def _findItem(items, value, attrs=None):
|
||||||
|
|
||||||
|
|
||||||
def _listItems(url, token, cls):
|
def _listItems(url, token, cls):
|
||||||
"""Helper that builds list of classes from a XML response.
|
""" Builds list of classes from a XML response. """
|
||||||
|
|
||||||
Args:
|
|
||||||
url (str): Description
|
|
||||||
token (str): Description
|
|
||||||
cls (class): Class to initate
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List: of classes
|
|
||||||
"""
|
|
||||||
headers = plexapi.BASE_HEADERS
|
headers = plexapi.BASE_HEADERS
|
||||||
headers['X-Plex-Token'] = token
|
headers['X-Plex-Token'] = token
|
||||||
log.info('GET %s?X-Plex-Token=%s', url, 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 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
|
||||||
PlexPhoto
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
NA (TYPE): Description
|
|
||||||
"""
|
|
||||||
from plexapi import media, utils
|
from plexapi import media, utils
|
||||||
from plexapi.utils import PlexPartialObject
|
from plexapi.utils import PlexPartialObject
|
||||||
NA = utils.NA
|
NA = utils.NA
|
||||||
|
@ -12,46 +6,36 @@ NA = utils.NA
|
||||||
|
|
||||||
@utils.register_libtype
|
@utils.register_libtype
|
||||||
class Photoalbum(PlexPartialObject):
|
class Photoalbum(PlexPartialObject):
|
||||||
"""Summary
|
""" Represents a photoalbum (collection of photos).
|
||||||
|
|
||||||
Attributes:
|
Parameters:
|
||||||
addedAt (TYPE): Description
|
server (:class:`~plexapi.server.PlexServer`): PlexServer this client is connected to (optional)
|
||||||
art (TYPE): Description
|
data (ElementTree): Response from PlexServer used to build this object (optional).
|
||||||
composite (TYPE): Description
|
initpath (str): Relative path requested when retrieving specified `data` (optional).
|
||||||
guid (TYPE): Description
|
|
||||||
index (TYPE): Description
|
Attributes:
|
||||||
key (TYPE): Description
|
addedAt (datetime): Datetime this item was added to the library.
|
||||||
librarySectionID (TYPE): Description
|
art (str): Photo art (/library/metadata/<ratingkey>/art/<artid>)
|
||||||
listType (str): Description
|
composite (str): Unknown
|
||||||
ratingKey (TYPE): Description
|
guid (str): Unknown (unique ID)
|
||||||
summary (TYPE): Description
|
index (sting): Index number of this album.
|
||||||
thumb (TYPE): Description
|
key (str): API URL (/library/metadata/<ratingkey>).
|
||||||
title (TYPE): Description
|
librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID.
|
||||||
TYPE (str): Description
|
listType (str): Hardcoded as 'photo' (useful for search filters).
|
||||||
type (TYPE): Description
|
ratingKey (int): Unique key identifying this item.
|
||||||
updatedAt (TYPE): Description
|
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'
|
TYPE = 'photoalbum'
|
||||||
|
|
||||||
def __init__(self, server, data, initpath):
|
def __init__(self, server, data, initpath):
|
||||||
"""Summary
|
|
||||||
|
|
||||||
Args:
|
|
||||||
server (TYPE): Description
|
|
||||||
data (TYPE): Description
|
|
||||||
initpath (TYPE): Description
|
|
||||||
"""
|
|
||||||
super(Photoalbum, self).__init__(data, initpath, server)
|
super(Photoalbum, self).__init__(data, initpath, server)
|
||||||
|
|
||||||
def _loadData(self, data):
|
def _loadData(self, data):
|
||||||
"""Summary
|
""" Load attribute values from Plex XML response. """
|
||||||
|
|
||||||
Args:
|
|
||||||
data (TYPE): Description
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
TYPE: Description
|
|
||||||
"""
|
|
||||||
self.listType = 'photo'
|
self.listType = 'photo'
|
||||||
self.addedAt = utils.toDatetime(data.attrib.get('addedAt', NA))
|
self.addedAt = utils.toDatetime(data.attrib.get('addedAt', NA))
|
||||||
self.art = data.attrib.get('art', NA)
|
self.art = data.attrib.get('art', NA)
|
||||||
|
@ -68,78 +52,53 @@ class Photoalbum(PlexPartialObject):
|
||||||
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt', NA))
|
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt', NA))
|
||||||
|
|
||||||
def photos(self):
|
def photos(self):
|
||||||
"""Summary
|
""" Returns a list of :class:`~plexapi.photo.Photo` objects in this album. """
|
||||||
|
|
||||||
Returns:
|
|
||||||
TYPE: Description
|
|
||||||
"""
|
|
||||||
path = '/library/metadata/%s/children' % self.ratingKey
|
path = '/library/metadata/%s/children' % self.ratingKey
|
||||||
return utils.listItems(self.server, path, Photo.TYPE)
|
return utils.listItems(self.server, path, Photo.TYPE)
|
||||||
|
|
||||||
def photo(self, title):
|
def photo(self, title):
|
||||||
"""Summary
|
""" Returns the :class:`~plexapi.photo.Photo` that matches the specified title. """
|
||||||
|
|
||||||
Args:
|
|
||||||
title (TYPE): Description
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
TYPE: Description
|
|
||||||
"""
|
|
||||||
path = '/library/metadata/%s/children' % self.ratingKey
|
path = '/library/metadata/%s/children' % self.ratingKey
|
||||||
return utils.findItem(self.server, path, title)
|
return utils.findItem(self.server, path, title)
|
||||||
|
|
||||||
def section(self):
|
def section(self):
|
||||||
"""Summary
|
""" Returns the :class:`~plexapi.library.LibrarySection` this item belongs to. """
|
||||||
|
|
||||||
Returns:
|
|
||||||
TYPE: Description
|
|
||||||
"""
|
|
||||||
return self.server.library.sectionByID(self.librarySectionID)
|
return self.server.library.sectionByID(self.librarySectionID)
|
||||||
|
|
||||||
|
|
||||||
@utils.register_libtype
|
@utils.register_libtype
|
||||||
class Photo(PlexPartialObject):
|
class Photo(PlexPartialObject):
|
||||||
"""Summary
|
""" Represents a single photo.
|
||||||
|
|
||||||
Attributes:
|
Parameters:
|
||||||
addedAt (TYPE): Description
|
server (:class:`~plexapi.server.PlexServer`): PlexServer this client is connected to (optional)
|
||||||
index (TYPE): Description
|
data (ElementTree): Response from PlexServer used to build this object (optional).
|
||||||
key (TYPE): Description
|
initpath (str): Relative path requested when retrieving specified `data` (optional).
|
||||||
listType (str): Description
|
|
||||||
media (TYPE): Description
|
Attributes:
|
||||||
originallyAvailableAt (TYPE): Description
|
addedAt (datetime): Datetime this item was added to the library.
|
||||||
parentKey (TYPE): Description
|
index (sting): Index number of this photo.
|
||||||
parentRatingKey (TYPE): Description
|
key (str): API URL (/library/metadata/<ratingkey>).
|
||||||
ratingKey (TYPE): Description
|
listType (str): Hardcoded as 'photo' (useful for search filters).
|
||||||
summary (TYPE): Description
|
media (TYPE): Unknown
|
||||||
thumb (TYPE): Description
|
originallyAvailableAt (datetime): Datetime this photo was added to Plex.
|
||||||
title (TYPE): Description
|
parentKey (str): Photoalbum API URL.
|
||||||
TYPE (str): Description
|
parentRatingKey (int): Unique key identifying the photoalbum.
|
||||||
type (TYPE): Description
|
ratingKey (int): Unique key identifying this item.
|
||||||
updatedAt (TYPE): Description
|
summary (str): Summary of the photo.
|
||||||
year (TYPE): Description
|
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'
|
TYPE = 'photo'
|
||||||
|
|
||||||
def __init__(self, server, data, initpath):
|
def __init__(self, server, data, initpath):
|
||||||
"""Summary
|
|
||||||
|
|
||||||
Args:
|
|
||||||
server (TYPE): Description
|
|
||||||
data (TYPE): Description
|
|
||||||
initpath (TYPE): Description
|
|
||||||
"""
|
|
||||||
super(Photo, self).__init__(data, initpath, server)
|
super(Photo, self).__init__(data, initpath, server)
|
||||||
|
|
||||||
def _loadData(self, data):
|
def _loadData(self, data):
|
||||||
"""Summary
|
""" Load attribute values from Plex XML response. """
|
||||||
|
|
||||||
Args:
|
|
||||||
data (TYPE): Description
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
TYPE: Description
|
|
||||||
"""
|
|
||||||
self.listType = 'photo'
|
self.listType = 'photo'
|
||||||
self.addedAt = utils.toDatetime(data.attrib.get('addedAt', NA))
|
self.addedAt = utils.toDatetime(data.attrib.get('addedAt', NA))
|
||||||
self.index = utils.cast(int, data.attrib.get('index', 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))
|
self.year = utils.cast(int, data.attrib.get('year', NA))
|
||||||
if self.isFullObject():
|
if self.isFullObject():
|
||||||
self.media = [media.Media(self.server, e, self.initpath, self)
|
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):
|
def photoalbum(self):
|
||||||
"""Summary
|
""" Return this photo's :class:`~plexapi.photo.Photoalbum`. """
|
||||||
|
|
||||||
Returns:
|
|
||||||
TYPE: Description
|
|
||||||
"""
|
|
||||||
return utils.listItems(self.server, self.parentKey)[0]
|
return utils.listItems(self.server, self.parentKey)[0]
|
||||||
|
|
||||||
def section(self):
|
def section(self):
|
||||||
"""Summary
|
""" Returns the :class:`~plexapi.library.LibrarySection` this item belongs to. """
|
||||||
|
|
||||||
Returns:
|
|
||||||
TYPE: Description
|
|
||||||
"""
|
|
||||||
return self.server.library.sectionByID(self.photoalbum().librarySectionID)
|
return self.server.library.sectionByID(self.photoalbum().librarySectionID)
|
||||||
|
|
|
@ -1,7 +1,4 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
|
||||||
PlexPlaylist
|
|
||||||
"""
|
|
||||||
from plexapi import utils
|
from plexapi import utils
|
||||||
from plexapi.exceptions import BadRequest
|
from plexapi.exceptions import BadRequest
|
||||||
from plexapi.utils import cast, toDatetime
|
from plexapi.utils import cast, toDatetime
|
||||||
|
@ -60,7 +57,7 @@ class Playlist(PlexPartialObject, Playable):
|
||||||
for item in items:
|
for item in items:
|
||||||
if item.listType != self.playlistType:
|
if item.listType != self.playlistType:
|
||||||
raise BadRequest('Can not mix media types when building a playlist: %s and %s' % (self.playlistType, item.listType))
|
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
|
uuid = items[0].section().uuid
|
||||||
ratingKeys = ','.join(ratingKeys)
|
ratingKeys = ','.join(ratingKeys)
|
||||||
path = '%s/items%s' % (self.key, utils.joinArgs({
|
path = '%s/items%s' % (self.key, utils.joinArgs({
|
||||||
|
|
|
@ -1,6 +1,4 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
|
||||||
import plexapi
|
import plexapi
|
||||||
import requests
|
import requests
|
||||||
from plexapi import utils
|
from plexapi import utils
|
||||||
|
@ -36,10 +34,8 @@ class PlayQueue(object):
|
||||||
self.mediaTagPrefix = data.attrib.get('mediaTagPrefix')
|
self.mediaTagPrefix = data.attrib.get('mediaTagPrefix')
|
||||||
self.mediaTagVersion = data.attrib.get('mediaTagVersion')
|
self.mediaTagVersion = data.attrib.get('mediaTagVersion')
|
||||||
self.playQueueID = data.attrib.get('playQueueID')
|
self.playQueueID = data.attrib.get('playQueueID')
|
||||||
self.playQueueSelectedItemID = data.attrib.get(
|
self.playQueueSelectedItemID = data.attrib.get('playQueueSelectedItemID')
|
||||||
'playQueueSelectedItemID')
|
self.playQueueSelectedItemOffset = data.attrib.get('playQueueSelectedItemOffset')
|
||||||
self.playQueueSelectedItemOffset = data.attrib.get(
|
|
||||||
'playQueueSelectedItemOffset')
|
|
||||||
self.playQueueTotalCount = data.attrib.get('playQueueTotalCount')
|
self.playQueueTotalCount = data.attrib.get('playQueueTotalCount')
|
||||||
self.playQueueVersion = data.attrib.get('playQueueVersion')
|
self.playQueueVersion = data.attrib.get('playQueueVersion')
|
||||||
self.items = [utils.buildItem(server, elem, initpath) for elem in data]
|
self.items = [utils.buildItem(server, elem, initpath) for elem in data]
|
||||||
|
|
|
@ -13,8 +13,8 @@ else:
|
||||||
import requests
|
import requests
|
||||||
from requests.status_codes import _codes as codes
|
from requests.status_codes import _codes as codes
|
||||||
|
|
||||||
from plexapi import BASE_HEADERS, TIMEOUT
|
from plexapi import BASE_HEADERS, CONFIG, TIMEOUT
|
||||||
from plexapi import log, utils
|
from plexapi import log, logfilter, utils
|
||||||
from plexapi import audio, video, photo, playlist # noqa; required # why is this needed?
|
from plexapi import audio, video, photo, playlist # noqa; required # why is this needed?
|
||||||
from plexapi.client import PlexClient
|
from plexapi.client import PlexClient
|
||||||
from plexapi.compat import quote, urlencode
|
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
|
session (requests.Session, optional): Use your own session object if you want
|
||||||
to cache the http responses from PMS
|
to cache the http responses from PMS
|
||||||
"""
|
"""
|
||||||
self.baseurl = baseurl
|
self.baseurl = baseurl or CONFIG.get('authentication.baseurl')
|
||||||
self.token = token
|
self.token = token or CONFIG.get('authentication.token')
|
||||||
|
if self.token:
|
||||||
|
logfilter.add_secret(self.token)
|
||||||
self.session = session or requests.Session()
|
self.session = session or requests.Session()
|
||||||
data = self._connect()
|
data = self._connect()
|
||||||
self.friendlyName = data.attrib.get('friendlyName')
|
self.friendlyName = data.attrib.get('friendlyName')
|
||||||
|
@ -113,8 +115,7 @@ class PlexServer(object):
|
||||||
"""
|
"""
|
||||||
items = []
|
items = []
|
||||||
for elem in self.query('/clients'):
|
for elem in self.query('/clients'):
|
||||||
baseurl = 'http://%s:%s' % (elem.attrib['address'],
|
baseurl = 'http://%s:%s' % (elem.attrib['host'], elem.attrib['port'])
|
||||||
elem.attrib['port'])
|
|
||||||
items.append(PlexClient(baseurl, server=self, data=elem))
|
items.append(PlexClient(baseurl, server=self, data=elem))
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
@ -133,8 +134,7 @@ class PlexServer(object):
|
||||||
"""
|
"""
|
||||||
for elem in self.query('/clients'):
|
for elem in self.query('/clients'):
|
||||||
if elem.attrib.get('name').lower() == name.lower():
|
if elem.attrib.get('name').lower() == name.lower():
|
||||||
baseurl = 'http://%s:%s' % (
|
baseurl = 'http://%s:%s' % (elem.attrib['host'], elem.attrib['port'])
|
||||||
elem.attrib['address'], elem.attrib['port'])
|
|
||||||
return PlexClient(baseurl, server=self, data=elem)
|
return PlexClient(baseurl, server=self, data=elem)
|
||||||
raise NotFound('Unknown client name: %s' % name)
|
raise NotFound('Unknown client name: %s' % name)
|
||||||
|
|
||||||
|
@ -204,9 +204,10 @@ class PlexServer(object):
|
||||||
if headers:
|
if headers:
|
||||||
h.update(headers)
|
h.update(headers)
|
||||||
response = method(url, headers=h, timeout=TIMEOUT, **kwargs)
|
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]
|
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')
|
data = response.text.encode('utf8')
|
||||||
return ElementTree.fromstring(data) if data else None
|
return ElementTree.fromstring(data) if data else None
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from plexapi import utils
|
from plexapi import utils
|
||||||
from plexapi.exceptions import NotFound
|
from plexapi.exceptions import NotFound
|
||||||
|
|
482
plexapi/utils.py
482
plexapi/utils.py
|
@ -1,192 +1,166 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
import logging, os, re, requests
|
||||||
import re
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from plexapi.compat import quote, urlencode
|
|
||||||
from plexapi.exceptions import NotFound, UnknownType, Unsupported
|
|
||||||
from threading import Thread
|
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.
|
# Search Types - Plex uses these to filter specific media types when searching.
|
||||||
SEARCHTYPES = {'movie': 1, 'show': 2, 'season': 3,
|
SEARCHTYPES = {'movie': 1, 'show': 2, 'season': 3, 'episode': 4,
|
||||||
'episode': 4, 'artist': 8, 'album': 9, 'track': 10}
|
'artist': 8, 'album': 9, 'track': 10, 'photo': 14}
|
||||||
|
|
||||||
LIBRARY_TYPES = {}
|
LIBRARY_TYPES = {}
|
||||||
|
|
||||||
|
|
||||||
def register_libtype(cls):
|
def register_libtype(cls):
|
||||||
"""Registry of library types we may come across when parsing XML.
|
""" Registry of library types we may come across when parsing XML. This allows us to
|
||||||
This allows us to define a few helper functions to dynamically convery
|
define a few helper functions to dynamically convery the XML into objects. See
|
||||||
the XML into objects. See buildItem() below for an example.
|
buildItem() below for an example.
|
||||||
"""
|
"""
|
||||||
LIBRARY_TYPES[cls.TYPE] = cls
|
LIBRARY_TYPES[cls.TYPE] = cls
|
||||||
return cls
|
return cls
|
||||||
|
|
||||||
|
|
||||||
class _NA(object):
|
class _NA(object):
|
||||||
"""This used to be a simple variable equal to '__NA__'.
|
""" This used to be a simple variable equal to '__NA__'. There has been need to
|
||||||
However, there has been need to compare NA against None in some use cases.
|
compare NA against None in some use cases. This object allows the internals
|
||||||
This object allows the internals of PlexAPI to distinguish between unfetched
|
of PlexAPI to distinguish between unfetched values and fetched, but non-existent
|
||||||
values and fetched, but non-existent values.
|
values. (NA == None results to True; NA is None results to False)
|
||||||
(NA == None results to True; NA is None results to False)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __bool__(self):
|
def __bool__(self):
|
||||||
"""Make sure Na always is False.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: False
|
|
||||||
"""
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def __eq__(self, other):
|
def __eq__(self, other):
|
||||||
"""Check eq.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
other (str): Description
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True is equal
|
|
||||||
"""
|
|
||||||
return isinstance(other, _NA) or other in [None, '__NA__']
|
return isinstance(other, _NA) or other in [None, '__NA__']
|
||||||
|
|
||||||
def __nonzero__(self):
|
def __nonzero__(self):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
"""Pretty print."""
|
|
||||||
return '__NA__'
|
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):
|
class PlexPartialObject(object):
|
||||||
"""Not all objects in the Plex listings return the complete list of elements
|
""" 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,
|
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
|
and if the specified value you request is None it will fetch the full object
|
||||||
automatically and update itself.
|
automatically and update itself.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
initpath (str): Relative url to PMS
|
data (ElementTree): Response from PlexServer used to build this object (optional).
|
||||||
server (): Description
|
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):
|
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.server = server
|
||||||
self.initpath = initpath
|
self.initpath = initpath
|
||||||
self._loadData(data)
|
self._loadData(data)
|
||||||
|
self._reloaded = False
|
||||||
|
|
||||||
def __eq__(self, other):
|
def __eq__(self, other):
|
||||||
"""Summary
|
|
||||||
|
|
||||||
Args:
|
|
||||||
other (TYPE): Description
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
TYPE: Description
|
|
||||||
"""
|
|
||||||
return other is not None and self.key == other.key
|
return other is not None and self.key == other.key
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
"""Pretty repr."""
|
|
||||||
clsname = self.__class__.__name__
|
clsname = self.__class__.__name__
|
||||||
key = self.key.replace('/library/metadata/', '') if self.key else 'NA'
|
key = self.key.replace('/library/metadata/', '') if self.key else 'NA'
|
||||||
title = self.title.replace(' ', '.')[0:20].encode('utf8')
|
title = self.title.replace(' ', '.')[0:20].encode('utf8')
|
||||||
return '<%s:%s:%s>' % (clsname, key, title)
|
return '<%s:%s:%s>' % (clsname, key, title)
|
||||||
|
|
||||||
def __getattr__(self, attr):
|
def __getattr__(self, attr):
|
||||||
"""Auto reload self, if the attribute is NA
|
# Auto reload self, from the full key (path) when needed.
|
||||||
|
|
||||||
Args:
|
|
||||||
attr (str): fx key
|
|
||||||
"""
|
|
||||||
if attr == 'key' or self.__dict__.get(attr) or self.isFullObject():
|
if attr == 'key' or self.__dict__.get(attr) or self.isFullObject():
|
||||||
return self.__dict__.get(attr, NA)
|
return self.__dict__.get(attr, NA)
|
||||||
|
print('reload because of %s' % attr)
|
||||||
self.reload()
|
self.reload()
|
||||||
return self.__dict__.get(attr, NA)
|
return self.__dict__.get(attr, NA)
|
||||||
|
|
||||||
def __setattr__(self, attr, value):
|
def __setattr__(self, attr, value):
|
||||||
"""Set attribute
|
|
||||||
|
|
||||||
Args:
|
|
||||||
attr (str): fx key
|
|
||||||
value (TYPE): Description
|
|
||||||
"""
|
|
||||||
if value != NA or self.isFullObject():
|
if value != NA or self.isFullObject():
|
||||||
self.__dict__[attr] = value
|
self.__dict__[attr] = value
|
||||||
|
|
||||||
def _loadData(self, data):
|
def _loadData(self, data):
|
||||||
"""Uses a element to set a attrs.
|
raise NotImplementedError('Abstract method not implemented.')
|
||||||
|
|
||||||
Args:
|
|
||||||
data (Element): Used by attrs
|
|
||||||
"""
|
|
||||||
raise Exception('Abstract method not implemented.')
|
|
||||||
|
|
||||||
def isFullObject(self):
|
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
|
return not self.key or self.key == self.initpath
|
||||||
|
|
||||||
def isPartialObject(self):
|
def isPartialObject(self):
|
||||||
|
""" Returns True if this is NOT a full object. """
|
||||||
return not self.isFullObject()
|
return not self.isFullObject()
|
||||||
|
|
||||||
def reload(self):
|
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)
|
data = self.server.query(self.key)
|
||||||
self.initpath = self.key
|
self.initpath = self.key
|
||||||
self._loadData(data[0])
|
self._loadData(data[0])
|
||||||
|
self._reloaded = True
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class Playable(object):
|
class Playable(object):
|
||||||
"""This is a general place to store functions specific to media that is 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,
|
Things were getting mixed up a bit when dealing with Shows, Season, Artists,
|
||||||
Artists, Albums which are all not playable.
|
Albums which are all not playable.
|
||||||
|
|
||||||
Attributes: # todo
|
Attributes:
|
||||||
player (Plexclient): Player
|
player (:class:`~plexapi.client.PlexClient`): Client object playing this item (for active sessions).
|
||||||
playlistItemID (int): Playlist item id
|
playlistItemID (int): Playlist item ID (only populated for :class:`~plexapi.playlist.Playlist` items).
|
||||||
sessionKey (int): 1223
|
sessionKey (int): Active session key.
|
||||||
transcodeSession (str): 12312312
|
transcodeSession (:class:`~plexapi.media.TranscodeSession`): Transcode Session object
|
||||||
username (str): Fx Hellowlol
|
if item is being transcoded (None otherwise).
|
||||||
viewedAt (datetime): viewed at.
|
username (str): Username of the person playing this item (for active sessions).
|
||||||
|
viewedAt (datetime): Datetime item was last viewed (history).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def _loadData(self, data):
|
def _loadData(self, data):
|
||||||
"""Set the class attributes
|
# Load data for active sessions (/status/sessions)
|
||||||
|
|
||||||
Args:
|
|
||||||
data (xml.etree.ElementTree.Element): usually from server.query
|
|
||||||
"""
|
|
||||||
# data for active sessions (/status/sessions)
|
|
||||||
self.sessionKey = cast(int, data.attrib.get('sessionKey', NA))
|
self.sessionKey = cast(int, data.attrib.get('sessionKey', NA))
|
||||||
self.username = findUsername(data)
|
self.username = findUsername(data)
|
||||||
self.player = findPlayer(self.server, data)
|
self.player = findPlayer(self.server, data)
|
||||||
self.transcodeSession = findTranscodeSession(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))
|
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))
|
self.playlistItemID = cast(int, data.attrib.get('playlistItemID', NA))
|
||||||
|
|
||||||
def getStreamURL(self, **params):
|
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:
|
Parameters:
|
||||||
**params (dict): Description
|
**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:
|
Raises:
|
||||||
string: ''
|
Unsupported: When the item doesn't support fetching a stream URL.
|
||||||
|
|
||||||
Raises:
|
|
||||||
Unsupported: Raises a error is the type is wrong.
|
|
||||||
"""
|
"""
|
||||||
if self.TYPE not in ('movie', 'episode', 'track'):
|
if self.TYPE not in ('movie', 'episode', 'track'):
|
||||||
raise Unsupported(
|
raise Unsupported('Fetching stream URL for %s is unsupported.' % self.TYPE)
|
||||||
'Fetching stream URL for %s is unsupported.' % self.TYPE)
|
|
||||||
mvb = params.get('maxVideoBitrate')
|
mvb = params.get('maxVideoBitrate')
|
||||||
vr = params.get('videoResolution', '')
|
vr = params.get('videoResolution', '')
|
||||||
params = {
|
params = {
|
||||||
|
@ -202,35 +176,68 @@ class Playable(object):
|
||||||
# remove None values
|
# remove None values
|
||||||
params = {k: v for k, v in params.items() if v is not None}
|
params = {k: v for k, v in params.items() if v is not None}
|
||||||
streamtype = 'audio' if self.TYPE in ('track', 'album') else 'video'
|
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):
|
def iterParts(self):
|
||||||
"""Yield parts."""
|
""" Iterates over the parts of this media item. """
|
||||||
for item in self.media:
|
for item in self.media:
|
||||||
for part in item.parts:
|
for part in item.parts:
|
||||||
yield part
|
yield part
|
||||||
|
|
||||||
def play(self, client):
|
def play(self, client):
|
||||||
"""Start playback on a client.
|
""" Start playback on the specified client.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
client (PlexClient): The client to start playing on.
|
client (:class:`~plexapi.client.PlexClient`): Client to start playing on.
|
||||||
"""
|
"""
|
||||||
client.playMedia(self)
|
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):
|
def buildItem(server, elem, initpath, bytag=False):
|
||||||
"""Build classes used by the plexapi.
|
""" Factory function to build the objects used within the PlexAPI.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
server (Plexserver): Your connected to.
|
server (:class:`~plexapi.server.PlexServer`): PlexServer object this is from.
|
||||||
elem (xml.etree.ElementTree.Element): xml from PMS
|
elem (ElementTree): XML data needed to build the object.
|
||||||
initpath (str): Relative path
|
initpath (str): Relative path requested when retrieving specified `data` (optional).
|
||||||
bytag (bool, optional): Description # figure out what this do
|
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
|
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
UnknownType: Unknown library type.
|
||||||
"""
|
"""
|
||||||
libtype = elem.tag if bytag else elem.attrib.get('type')
|
libtype = elem.tag if bytag else elem.attrib.get('type')
|
||||||
if libtype == 'photo' and elem.tag == 'Directory':
|
if libtype == 'photo' and elem.tag == 'Directory':
|
||||||
|
@ -242,19 +249,11 @@ def buildItem(server, elem, initpath, bytag=False):
|
||||||
|
|
||||||
|
|
||||||
def cast(func, value):
|
def cast(func, value):
|
||||||
"""Helper to change to the correct type.
|
""" Cast the specified value to the specified type (returned by func).
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
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:
|
if not value:
|
||||||
return
|
return
|
||||||
|
@ -272,14 +271,14 @@ def cast(func, value):
|
||||||
|
|
||||||
|
|
||||||
def findKey(server, key):
|
def findKey(server, key):
|
||||||
"""Finds and builds a object based on ratingKey.
|
""" Finds and builds a object based on ratingKey.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
server (Plexserver): PMS your connected to
|
server (:class:`~plexapi.server.PlexServer`): PlexServer object this is from.
|
||||||
key (int): key to look for
|
key (int): ratingKey to find and return.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
NotFound: Unable to find key. Key
|
NotFound: Unable to find key
|
||||||
"""
|
"""
|
||||||
path = '/library/metadata/{0}'.format(key)
|
path = '/library/metadata/{0}'.format(key)
|
||||||
try:
|
try:
|
||||||
|
@ -291,15 +290,15 @@ def findKey(server, key):
|
||||||
|
|
||||||
|
|
||||||
def findItem(server, path, title):
|
def findItem(server, path, title):
|
||||||
"""Finds and builds a object based on title.
|
""" Finds and builds a object based on title.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
server (Plexserver): Description
|
server (:class:`~plexapi.server.PlexServer`): PlexServer object this is from.
|
||||||
path (str): Relative path
|
path (str): API path that returns item to search title for.
|
||||||
title (str): Fx 16 blocks
|
title (str): Title of the item to find and return.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
NotFound: Unable to find item: title
|
NotFound: Unable to find item.
|
||||||
"""
|
"""
|
||||||
for elem in server.query(path):
|
for elem in server.query(path):
|
||||||
if elem.attrib.get('title').lower() == title.lower():
|
if elem.attrib.get('title').lower() == title.lower():
|
||||||
|
@ -308,14 +307,12 @@ def findItem(server, path, title):
|
||||||
|
|
||||||
|
|
||||||
def findLocations(data, single=False):
|
def findLocations(data, single=False):
|
||||||
"""Extract the path from a location tag
|
""" Returns a list of filepaths from a location tag.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
data (xml.etree.ElementTree.Element): xml from PMS as Element
|
data (ElementTree): XML object to search for locations in.
|
||||||
single (bool, optional): Only return one
|
single (bool): Set True to only return the first location found.
|
||||||
|
Return type will be a string if this is set to True.
|
||||||
Returns:
|
|
||||||
filepath string if single is True else list of filepaths
|
|
||||||
"""
|
"""
|
||||||
locations = []
|
locations = []
|
||||||
for elem in data:
|
for elem in data:
|
||||||
|
@ -327,33 +324,26 @@ def findLocations(data, single=False):
|
||||||
|
|
||||||
|
|
||||||
def findPlayer(server, data):
|
def findPlayer(server, data):
|
||||||
"""Find a player in a elementthee
|
""" Returns the :class:`~plexapi.client.PlexClient` object found in the specified data.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
server (Plexserver): PMS your connected to
|
server (:class:`~plexapi.server.PlexServer`): PlexServer object this is from.
|
||||||
data (xml.etree.ElementTree.Element): xml from pms as a element
|
data (ElementTree): XML data to find Player in.
|
||||||
|
|
||||||
Returns:
|
|
||||||
PlexClient or None
|
|
||||||
"""
|
"""
|
||||||
elem = data.find('Player')
|
elem = data.find('Player')
|
||||||
if elem is not None:
|
if elem is not None:
|
||||||
from plexapi.client import PlexClient
|
from plexapi.client import PlexClient
|
||||||
baseurl = 'http://%s:%s' % (elem.attrib.get('address'),
|
baseurl = 'http://%s:%s' % (elem.attrib.get('address'), elem.attrib.get('port'))
|
||||||
elem.attrib.get('port'))
|
|
||||||
return PlexClient(baseurl, server=server, data=elem)
|
return PlexClient(baseurl, server=server, data=elem)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def findStreams(media, streamtype):
|
def findStreams(media, streamtype):
|
||||||
"""Find streams.
|
""" Returns a list of streams (str) found in media that match the specified streamtype.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
media (Show, Movie, Episode): A item where find streams
|
media (:class:`~plexapi.utils.Playable`): Item to search for streams (show, movie, episode).
|
||||||
streamtype (str): Possible options [movie, show, episode] # is this correct?
|
streamtype (str): Streamtype to return (videostream, audiostream, subtitlestream).
|
||||||
|
|
||||||
Returns:
|
|
||||||
list: of streams
|
|
||||||
"""
|
"""
|
||||||
streams = []
|
streams = []
|
||||||
for mediaitem in media:
|
for mediaitem in media:
|
||||||
|
@ -365,14 +355,12 @@ def findStreams(media, streamtype):
|
||||||
|
|
||||||
|
|
||||||
def findTranscodeSession(server, data):
|
def findTranscodeSession(server, data):
|
||||||
"""Find transcode session.
|
""" Returns a :class:`~plexapi.media.TranscodeSession` object if found within the specified
|
||||||
|
XML data.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
server (Plexserver): PMS your connected to
|
server (:class:`~plexapi.server.PlexServer`): PlexServer object this is from.
|
||||||
data (xml.etree.ElementTree.Element): XML response from PMS as Element
|
data (ElementTree): XML data to find TranscodeSession in.
|
||||||
|
|
||||||
Returns:
|
|
||||||
media.TranscodeSession or None
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
elem = data.find('TranscodeSession')
|
elem = data.find('TranscodeSession')
|
||||||
|
@ -383,13 +371,10 @@ def findTranscodeSession(server, data):
|
||||||
|
|
||||||
|
|
||||||
def findUsername(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:
|
Parameters:
|
||||||
data (xml.etree.ElementTree.Element): XML from PMS as a Element
|
data (ElementTree): XML data to find username in.
|
||||||
|
|
||||||
Returns:
|
|
||||||
username or None
|
|
||||||
"""
|
"""
|
||||||
elem = data.find('User')
|
elem = data.find('User')
|
||||||
if elem is not None:
|
if elem is not None:
|
||||||
|
@ -398,7 +383,7 @@ def findUsername(data):
|
||||||
|
|
||||||
|
|
||||||
def isInt(str):
|
def isInt(str):
|
||||||
"""Check of a string is a int"""
|
""" Returns True if the specified string passes as an int. """
|
||||||
try:
|
try:
|
||||||
int(str)
|
int(str)
|
||||||
return True
|
return True
|
||||||
|
@ -407,14 +392,11 @@ def isInt(str):
|
||||||
|
|
||||||
|
|
||||||
def joinArgs(args):
|
def joinArgs(args):
|
||||||
"""Builds a query string where only
|
""" Returns a query string (uses for HTTP URLs) where only the value is URL encoded.
|
||||||
the value is quoted.
|
Example return value: '?genre=action&type=1337'.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
args (dict): ex {'genre': 'action', 'type': 1337}
|
args (dict): Arguments to include in query string.
|
||||||
|
|
||||||
Returns:
|
|
||||||
string: ?genre=action&type=1337
|
|
||||||
"""
|
"""
|
||||||
if not args:
|
if not args:
|
||||||
return ''
|
return ''
|
||||||
|
@ -426,30 +408,25 @@ def joinArgs(args):
|
||||||
|
|
||||||
|
|
||||||
def listChoices(server, path):
|
def listChoices(server, path):
|
||||||
"""ListChoices is by _cleanSort etc.
|
""" Returns a dict of {title:key} for all simple choices in a search filter.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
server (Plexserver): Server your connected to
|
server (:class:`~plexapi.server.PlexServer`): PlexServer object this is from.
|
||||||
path (str): Relative path to PMS
|
path (str): Relative path to request XML data from.
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: title:key
|
|
||||||
"""
|
"""
|
||||||
return {c.attrib['title']: c.attrib['key'] for c in server.query(path)}
|
return {c.attrib['title']: c.attrib['key'] for c in server.query(path)}
|
||||||
|
|
||||||
|
|
||||||
def listItems(server, path, libtype=None, watched=None, bytag=False):
|
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:
|
Parameters:
|
||||||
server (Plexserver): PMS your connected to.
|
server (:class:`~plexapi.server.PlexServer`): PlexServer object this is from.
|
||||||
path (str): Relative path to PMS
|
path (str): Relative path to request XML data from.
|
||||||
libtype (None or string, optional): [movie, show, episode, music] # check me
|
libtype (str): Optionally return only the specified library type.
|
||||||
watched (None, True, False, optional): Skip or include watched items
|
watched (bool): Optionally return only watched or unwatched items.
|
||||||
bytag (bool, optional): Dunno wtf this is used for # todo
|
bytag (bool): Set true if libtype is found in the XML tag (and not the 'type' attribute).
|
||||||
|
|
||||||
Returns:
|
|
||||||
list: of buildItem
|
|
||||||
"""
|
"""
|
||||||
items = []
|
items = []
|
||||||
for elem in server.query(path):
|
for elem in server.query(path):
|
||||||
|
@ -466,7 +443,18 @@ def listItems(server, path, libtype=None, watched=None, bytag=False):
|
||||||
return items
|
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:
|
try:
|
||||||
parts = attrstr.split(delim, 1)
|
parts = attrstr.split(delim, 1)
|
||||||
attr = parts[0]
|
attr = parts[0]
|
||||||
|
@ -487,19 +475,15 @@ def rget(obj, attrstr, default=None, delim='.'):
|
||||||
|
|
||||||
|
|
||||||
def searchType(libtype):
|
def searchType(libtype):
|
||||||
"""Map search type name to int using SEACHTYPES
|
""" Returns the integer value of the library string type.
|
||||||
Used when querying PMS.
|
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
libtype (str): Possible options see SEARCHTYPES
|
libtype (str): Library type to lookup (movie, show, season, episode,
|
||||||
|
artist, album, track)
|
||||||
|
|
||||||
Returns:
|
Raises:
|
||||||
int: fx 1
|
NotFound: Unknown libtype
|
||||||
|
|
||||||
Raises:
|
|
||||||
NotFound: Unknown libtype: libtype
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
libtype = str(libtype)
|
libtype = str(libtype)
|
||||||
if libtype in [str(v) for v in SEARCHTYPES.values()]:
|
if libtype in [str(v) for v in SEARCHTYPES.values()]:
|
||||||
return libtype
|
return libtype
|
||||||
|
@ -509,12 +493,12 @@ def searchType(libtype):
|
||||||
|
|
||||||
|
|
||||||
def threaded(callback, listargs):
|
def threaded(callback, listargs):
|
||||||
"""Run some function in threads.
|
""" Returns the result of <callback> for each set of \*args in listargs. Each call
|
||||||
|
to <callback. is called concurrently in their own separate threads.
|
||||||
Args:
|
|
||||||
callback (function): funcion to run in thread
|
|
||||||
listargs (list): args parssed to the callback
|
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
callback (func): Callback function to apply to each set of \*args.
|
||||||
|
listargs (list): List of lists; \*args to pass each thread.
|
||||||
"""
|
"""
|
||||||
threads, results = [], []
|
threads, results = [], []
|
||||||
for args in listargs:
|
for args in listargs:
|
||||||
|
@ -524,18 +508,16 @@ def threaded(callback, listargs):
|
||||||
threads[-1].start()
|
threads[-1].start()
|
||||||
for thread in threads:
|
for thread in threads:
|
||||||
thread.join()
|
thread.join()
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
def toDatetime(value, format=None):
|
def toDatetime(value, format=None):
|
||||||
"""Helper for datetime.
|
""" Returns a datetime object from the specified value.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
value (str): value to use to make datetime
|
value (str): value to return as a datetime
|
||||||
format (None, optional): string as strptime.
|
format (str): Format to pass strftime (optional; if value is a str).
|
||||||
|
|
||||||
Returns:
|
|
||||||
datetime
|
|
||||||
"""
|
"""
|
||||||
if value and value != NA:
|
if value and value != NA:
|
||||||
if format:
|
if format:
|
||||||
|
@ -543,3 +525,57 @@ def toDatetime(value, format=None):
|
||||||
else:
|
else:
|
||||||
value = datetime.fromtimestamp(int(value))
|
value = datetime.fromtimestamp(int(value))
|
||||||
return 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))
|
||||||
|
|
234
plexapi/video.py
234
plexapi/video.py
|
@ -1,6 +1,6 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
from plexapi import media, utils
|
from plexapi import media, utils
|
||||||
|
from plexapi.exceptions import NotFound
|
||||||
from plexapi.utils import Playable, PlexPartialObject
|
from plexapi.utils import Playable, PlexPartialObject
|
||||||
|
|
||||||
NA = utils.NA
|
NA = utils.NA
|
||||||
|
@ -29,8 +29,7 @@ class Video(PlexPartialObject):
|
||||||
self.listType = 'video'
|
self.listType = 'video'
|
||||||
self.addedAt = utils.toDatetime(data.attrib.get('addedAt', NA))
|
self.addedAt = utils.toDatetime(data.attrib.get('addedAt', NA))
|
||||||
self.key = data.attrib.get('key', NA)
|
self.key = data.attrib.get('key', NA)
|
||||||
self.lastViewedAt = utils.toDatetime(
|
self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt', NA))
|
||||||
data.attrib.get('lastViewedAt', NA))
|
|
||||||
self.librarySectionID = data.attrib.get('librarySectionID', NA)
|
self.librarySectionID = data.attrib.get('librarySectionID', NA)
|
||||||
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey', NA))
|
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey', NA))
|
||||||
self.summary = data.attrib.get('summary', 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,
|
that are useful to know–whether it's a video file,
|
||||||
a music track, or one of your photos.
|
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):
|
def markWatched(self):
|
||||||
"""Mark a items as watched."""
|
"""Mark a items as watched."""
|
||||||
|
@ -110,24 +109,15 @@ class Movie(Video, Playable):
|
||||||
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
|
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
|
||||||
self.year = utils.cast(int, data.attrib.get('year', NA))
|
self.year = utils.cast(int, data.attrib.get('year', NA))
|
||||||
if self.isFullObject(): # check this
|
if self.isFullObject(): # check this
|
||||||
self.collections = [media.Collection(
|
self.collections = [media.Collection(self.server, e) for e in data if e.tag == media.Collection.TYPE]
|
||||||
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.countries = [media.Country(self.server, e)
|
self.directors = [media.Director(self.server, e) for e in data if e.tag == media.Director.TYPE]
|
||||||
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.directors = [media.Director(
|
self.media = [media.Media(self.server, e, self.initpath, self) for e in data if e.tag == media.Media.TYPE]
|
||||||
self.server, e) for e in data if e.tag == media.Director.TYPE]
|
self.producers = [media.Producer(self.server, e) for e in data if e.tag == media.Producer.TYPE]
|
||||||
self.genres = [media.Genre(self.server, e)
|
self.roles = [media.Role(self.server, e) for e in data if e.tag == media.Role.TYPE]
|
||||||
for e in data if e.tag == media.Genre.TYPE]
|
self.writers = [media.Writer(self.server, e) for e in data if e.tag == media.Writer.TYPE]
|
||||||
self.media = [media.Media(self.server, e, self.initpath, self)
|
self.fields = [media.Field(e) for e in data if e.tag == media.Field.TYPE]
|
||||||
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.videoStreams = utils.findStreams(self.media, 'videostream')
|
||||||
self.audioStreams = utils.findStreams(self.media, 'audiostream')
|
self.audioStreams = utils.findStreams(self.media, 'audiostream')
|
||||||
self.subtitleStreams = utils.findStreams(
|
self.subtitleStreams = utils.findStreams(
|
||||||
|
@ -141,6 +131,35 @@ class Movie(Video, Playable):
|
||||||
def isWatched(self):
|
def isWatched(self):
|
||||||
return bool(self.viewCount > 0)
|
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
|
@utils.register_libtype
|
||||||
class Show(Video):
|
class Show(Video):
|
||||||
|
@ -153,6 +172,8 @@ class Show(Video):
|
||||||
data (Element): Usually built from server.query
|
data (Element): Usually built from server.query
|
||||||
"""
|
"""
|
||||||
Video._loadData(self, data)
|
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.art = data.attrib.get('art', NA)
|
||||||
self.banner = data.attrib.get('banner', NA)
|
self.banner = data.attrib.get('banner', NA)
|
||||||
self.childCount = utils.cast(int, data.attrib.get('childCount', 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.guid = data.attrib.get('guid', NA)
|
||||||
self.index = data.attrib.get('index', NA)
|
self.index = data.attrib.get('index', NA)
|
||||||
self.leafCount = utils.cast(int, data.attrib.get('leafCount', 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(
|
self.originallyAvailableAt = utils.toDatetime(
|
||||||
data.attrib.get('originallyAvailableAt', NA), '%Y-%m-%d')
|
data.attrib.get('originallyAvailableAt', NA), '%Y-%m-%d')
|
||||||
self.rating = utils.cast(float, data.attrib.get('rating', NA))
|
self.rating = utils.cast(float, data.attrib.get('rating', NA))
|
||||||
|
@ -170,11 +191,9 @@ class Show(Video):
|
||||||
self.viewedLeafCount = utils.cast(
|
self.viewedLeafCount = utils.cast(
|
||||||
int, data.attrib.get('viewedLeafCount', NA))
|
int, data.attrib.get('viewedLeafCount', NA))
|
||||||
self.year = utils.cast(int, data.attrib.get('year', NA))
|
self.year = utils.cast(int, data.attrib.get('year', NA))
|
||||||
#if self.isFullObject(): # will be fixed with docs.
|
if self.isFullObject(): # will be fixed with docs.
|
||||||
self.genres = [media.Genre(self.server, e)
|
self.genres = [media.Genre(self.server, e) for e in data if e.tag == media.Genre.TYPE]
|
||||||
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]
|
||||||
self.roles = [media.Role(self.server, e)
|
|
||||||
for e in data if e.tag == media.Role.TYPE]
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def actors(self):
|
def actors(self):
|
||||||
|
@ -189,12 +208,15 @@ class Show(Video):
|
||||||
path = '/library/metadata/%s/children' % self.ratingKey
|
path = '/library/metadata/%s/children' % self.ratingKey
|
||||||
return utils.listItems(self.server, path, Season.TYPE)
|
return utils.listItems(self.server, path, Season.TYPE)
|
||||||
|
|
||||||
def season(self, title):
|
def season(self, title=None):
|
||||||
"""Returns a Season
|
"""Returns a Season
|
||||||
|
|
||||||
Args:
|
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
|
path = '/library/metadata/%s/children' % self.ratingKey
|
||||||
return utils.findItem(self.server, path, title)
|
return utils.findItem(self.server, path, title)
|
||||||
|
|
||||||
|
@ -207,9 +229,45 @@ class Show(Video):
|
||||||
leavesKey = '/library/metadata/%s/allLeaves' % self.ratingKey
|
leavesKey = '/library/metadata/%s/allLeaves' % self.ratingKey
|
||||||
return utils.listItems(self.server, leavesKey, watched=watched)
|
return utils.listItems(self.server, leavesKey, watched=watched)
|
||||||
|
|
||||||
def episode(self, title):
|
def episode(self, title=None, season=None, episode=None):
|
||||||
path = '/library/metadata/%s/allLeaves' % self.ratingKey
|
"""Find a episode using a title or season and episode.
|
||||||
return utils.findItem(self.server, path, title)
|
|
||||||
|
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):
|
def watched(self):
|
||||||
"""Return a list of watched episodes"""
|
"""Return a list of watched episodes"""
|
||||||
|
@ -227,9 +285,21 @@ class Show(Video):
|
||||||
"""
|
"""
|
||||||
return self.episode(title)
|
return self.episode(title)
|
||||||
|
|
||||||
|
def analyze(self):
|
||||||
|
""" """
|
||||||
|
raise 'Cant analyse a show' # fix me
|
||||||
|
|
||||||
def refresh(self):
|
def refresh(self):
|
||||||
"""Refresh the metadata."""
|
"""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
|
@utils.register_libtype
|
||||||
|
@ -243,10 +313,12 @@ class Season(Video):
|
||||||
data (Element): Usually built from server.query
|
data (Element): Usually built from server.query
|
||||||
"""
|
"""
|
||||||
Video._loadData(self, data)
|
Video._loadData(self, data)
|
||||||
|
self.key = self.key.replace('/children', '')
|
||||||
self.leafCount = utils.cast(int, data.attrib.get('leafCount', NA))
|
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.parentKey = data.attrib.get('parentKey', NA)
|
||||||
self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey', NA))
|
self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey', NA))
|
||||||
|
self.parentTitle = data.attrib.get('parentTitle', NA)
|
||||||
self.viewedLeafCount = utils.cast(
|
self.viewedLeafCount = utils.cast(
|
||||||
int, data.attrib.get('viewedLeafCount', NA))
|
int, data.attrib.get('viewedLeafCount', NA))
|
||||||
|
|
||||||
|
@ -256,7 +328,7 @@ class Season(Video):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def seasonNumber(self):
|
def seasonNumber(self):
|
||||||
"""Reurns season number."""
|
"""Returns season number."""
|
||||||
return self.index
|
return self.index
|
||||||
|
|
||||||
def episodes(self, watched=None):
|
def episodes(self, watched=None):
|
||||||
|
@ -273,20 +345,43 @@ class Season(Video):
|
||||||
childrenKey = '/library/metadata/%s/children' % self.ratingKey
|
childrenKey = '/library/metadata/%s/children' % self.ratingKey
|
||||||
return utils.listItems(self.server, childrenKey, watched=watched)
|
return utils.listItems(self.server, childrenKey, watched=watched)
|
||||||
|
|
||||||
def episode(self, title):
|
def episode(self, title=None, episode=None):
|
||||||
"""Find a episode with a matching title.
|
"""Find a episode using a title or season and episode.
|
||||||
|
|
||||||
Args:
|
Note:
|
||||||
title (sting): Fx
|
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
|
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
|
if not title and not episode:
|
||||||
return utils.findItem(self.server, path, title)
|
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):
|
def get(self, title):
|
||||||
"""Get a episode witha matching title
|
"""Get a episode with a matching title.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
title (str): fx Secret santa
|
title (str): fx Secret santa
|
||||||
|
@ -308,6 +403,20 @@ class Season(Video):
|
||||||
"""Returns a list of unwatched Episode"""
|
"""Returns a list of unwatched Episode"""
|
||||||
return self.episodes(watched=False)
|
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
|
@utils.register_libtype
|
||||||
class Episode(Video, Playable):
|
class Episode(Video, Playable):
|
||||||
|
@ -332,9 +441,8 @@ class Episode(Video, Playable):
|
||||||
self.grandparentThumb = data.attrib.get('grandparentThumb', NA)
|
self.grandparentThumb = data.attrib.get('grandparentThumb', NA)
|
||||||
self.grandparentTitle = data.attrib.get('grandparentTitle', NA)
|
self.grandparentTitle = data.attrib.get('grandparentTitle', NA)
|
||||||
self.guid = data.attrib.get('guid', NA)
|
self.guid = data.attrib.get('guid', NA)
|
||||||
self.index = data.attrib.get('index', NA)
|
self.index = utils.cast(int, data.attrib.get('index', NA))
|
||||||
self.originallyAvailableAt = utils.toDatetime(
|
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt', NA), '%Y-%m-%d')
|
||||||
data.attrib.get('originallyAvailableAt', NA), '%Y-%m-%d')
|
|
||||||
self.parentIndex = data.attrib.get('parentIndex', NA)
|
self.parentIndex = data.attrib.get('parentIndex', NA)
|
||||||
self.parentKey = data.attrib.get('parentKey', NA)
|
self.parentKey = data.attrib.get('parentKey', NA)
|
||||||
self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey', 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.rating = utils.cast(float, data.attrib.get('rating', NA))
|
||||||
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
|
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
|
||||||
self.year = utils.cast(int, data.attrib.get('year', NA))
|
self.year = utils.cast(int, data.attrib.get('year', NA))
|
||||||
self.directors = [media.Director(self.server, e)
|
self.directors = [media.Director(self.server, e) for e in data if e.tag == media.Director.TYPE]
|
||||||
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.media = [media.Media(self.server, e, self.initpath, self)
|
self.writers = [media.Writer(self.server, e) for e in data if e.tag == media.Writer.TYPE]
|
||||||
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.videoStreams = utils.findStreams(self.media, 'videostream')
|
||||||
self.audioStreams = utils.findStreams(self.media, 'audiostream')
|
self.audioStreams = utils.findStreams(self.media, 'audiostream')
|
||||||
self.subtitleStreams = utils.findStreams(self.media, 'subtitlestream')
|
self.subtitleStreams = utils.findStreams(self.media, 'subtitlestream')
|
||||||
|
@ -359,6 +464,12 @@ class Episode(Video, Playable):
|
||||||
# Cached season number
|
# Cached season number
|
||||||
self._seasonNumber = None
|
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
|
@property
|
||||||
def isWatched(self):
|
def isWatched(self):
|
||||||
"""Returns True if watched, False if not."""
|
"""Returns True if watched, False if not."""
|
||||||
|
@ -369,7 +480,7 @@ class Episode(Video, Playable):
|
||||||
"""Return this episode seasonnumber."""
|
"""Return this episode seasonnumber."""
|
||||||
if self._seasonNumber is None:
|
if self._seasonNumber is None:
|
||||||
self._seasonNumber = self.parentIndex if self.parentIndex else self.season().seasonNumber
|
self._seasonNumber = self.parentIndex if self.parentIndex else self.season().seasonNumber
|
||||||
return self._seasonNumber
|
return utils.cast(int, self._seasonNumber)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def thumbUrl(self):
|
def thumbUrl(self):
|
||||||
|
@ -384,3 +495,18 @@ class Episode(Video, Playable):
|
||||||
def show(self):
|
def show(self):
|
||||||
"""Return this episodes Show"""
|
"""Return this episodes Show"""
|
||||||
return utils.listItems(self.server, self.grandparentKey)[0]
|
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
|
# PlexAPI Requirements
|
||||||
|
# pip install -r requirments.txt
|
||||||
#---------------------------------------------------------
|
#---------------------------------------------------------
|
||||||
requests
|
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
|
import re
|
||||||
from distutils.core import setup
|
from distutils.core import setup
|
||||||
from setuptools import find_packages
|
|
||||||
|
|
||||||
# Convert markdown readme to rst
|
# Convert markdown readme to rst
|
||||||
try:
|
try:
|
||||||
|
@ -29,7 +28,7 @@ setup(
|
||||||
author='Michael Shepanski',
|
author='Michael Shepanski',
|
||||||
author_email='mjs7231@gmail.com',
|
author_email='mjs7231@gmail.com',
|
||||||
url='https://github.com/mjs7231/plexapi',
|
url='https://github.com/mjs7231/plexapi',
|
||||||
packages=find_packages(),
|
packages=['plexapi'],
|
||||||
install_requires=['requests'],
|
install_requires=['requests'],
|
||||||
long_description=read_md('README.md'),
|
long_description=read_md('README.md'),
|
||||||
keywords=['plex', 'api'],
|
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