pull/2835/head
v1.5.2-beta.9
parent
2fc8f10a94
commit
fe7b224916
@ -0,0 +1 @@
|
||||
# coding=utf-8
|
@ -0,0 +1,27 @@
|
||||
# coding=utf-8
|
||||
from datetime import datetime
|
||||
from app.config import settings
|
||||
from plexapi.server import PlexServer
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def plex_set_added_date_now(movie_metadata):
|
||||
try:
|
||||
if settings.plex.ssl:
|
||||
protocol_plex = "https://"
|
||||
else:
|
||||
protocol_plex = "http://"
|
||||
|
||||
baseurl = f'{protocol_plex}{settings.plex.ip}:{settings.plex.port}'
|
||||
token = settings.plex.apikey
|
||||
plex = PlexServer(baseurl, token)
|
||||
library = plex.library.section(settings.plex.movie_library)
|
||||
video = library.getGuid(guid=movie_metadata.imdbId)
|
||||
# Get the current date and time in the desired format
|
||||
current_date = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
updates = {"addedAt.value": current_date}
|
||||
video.edit(**updates)
|
||||
except Exception as e:
|
||||
logger.error(f"A Plex error occurred: {e}")
|
@ -0,0 +1,46 @@
|
||||
import { FunctionComponent } from "react";
|
||||
import {
|
||||
Check,
|
||||
CollapseBox,
|
||||
Layout,
|
||||
Message,
|
||||
Number,
|
||||
Section,
|
||||
Text,
|
||||
} from "@/pages/Settings/components";
|
||||
import { plexEnabledKey } from "@/pages/Settings/keys";
|
||||
|
||||
const SettingsPlexView: FunctionComponent = () => {
|
||||
return (
|
||||
<Layout name="Interface">
|
||||
<Section header="Use Plex integration">
|
||||
<Check label="Enabled" settingKey={plexEnabledKey}></Check>
|
||||
</Section>
|
||||
<CollapseBox settingKey={plexEnabledKey}>
|
||||
<Section header="Host">
|
||||
<Text label="Address" settingKey="settings-plex-ip"></Text>
|
||||
<Number
|
||||
label="Port"
|
||||
settingKey="settings-plex-port"
|
||||
defaultValue={32400}
|
||||
></Number>
|
||||
<Message>Hostname or IPv4 Address</Message>
|
||||
<Text label="API Token" settingKey="settings-plex-apikey"></Text>
|
||||
<Check label="SSL" settingKey="settings-plex-ssl"></Check>
|
||||
</Section>
|
||||
<Section header="Movie editing">
|
||||
<Text
|
||||
label="Name of the library"
|
||||
settingKey="settings-plex-movie_library"
|
||||
></Text>
|
||||
<Check
|
||||
label="Set the movie as recently added after downloading the subtitles"
|
||||
settingKey="settings-plex-set_added"
|
||||
></Check>
|
||||
</Section>
|
||||
</CollapseBox>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsPlexView;
|
@ -0,0 +1,7 @@
|
||||
Authors and Contributors:
|
||||
* Michael Shepanski (Primary Author)
|
||||
* Hellowlol (Major Contributor)
|
||||
* Nate Mara (Timeline)
|
||||
* Goni Zahavy (Sync, Media Parts)
|
||||
* Simon W. Jackson (Stream URL)
|
||||
* Håvard Gulldahl (Plex Audio)
|
@ -0,0 +1 @@
|
||||
pip
|
@ -0,0 +1,25 @@
|
||||
Copyright (c) 2010, Michael Shepanski
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification,
|
||||
are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright notice,
|
||||
this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
* Neither the name python-plexapi nor the names of its contributors
|
||||
may be used to endorse or promote products derived from this software without
|
||||
specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
|
||||
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
@ -0,0 +1,282 @@
|
||||
Metadata-Version: 2.1
|
||||
Name: PlexAPI
|
||||
Version: 4.16.1
|
||||
Summary: Python bindings for the Plex API.
|
||||
Author-email: Michael Shepanski <michael.shepanski@gmail.com>
|
||||
License: BSD-3-Clause
|
||||
Project-URL: Homepage, https://github.com/pkkid/python-plexapi
|
||||
Project-URL: Documentation, https://python-plexapi.readthedocs.io
|
||||
Keywords: plex,api
|
||||
Classifier: Operating System :: OS Independent
|
||||
Classifier: Programming Language :: Python :: 3
|
||||
Classifier: License :: OSI Approved :: BSD License
|
||||
Requires-Python: >=3.9
|
||||
Description-Content-Type: text/x-rst
|
||||
License-File: LICENSE.txt
|
||||
License-File: AUTHORS.txt
|
||||
Requires-Dist: requests
|
||||
Provides-Extra: alert
|
||||
Requires-Dist: websocket-client (>=1.3.3) ; extra == 'alert'
|
||||
|
||||
Python-PlexAPI
|
||||
==============
|
||||
.. image:: https://github.com/pkkid/python-plexapi/workflows/CI/badge.svg
|
||||
:target: https://github.com/pkkid/python-plexapi/actions?query=workflow%3ACI
|
||||
.. image:: https://readthedocs.org/projects/python-plexapi/badge/?version=latest
|
||||
:target: http://python-plexapi.readthedocs.io/en/latest/?badge=latest
|
||||
.. image:: https://codecov.io/gh/pkkid/python-plexapi/branch/master/graph/badge.svg?token=fOECznuMtw
|
||||
:target: https://codecov.io/gh/pkkid/python-plexapi
|
||||
.. image:: https://img.shields.io/github/tag/pkkid/python-plexapi.svg?label=github+release
|
||||
:target: https://github.com/pkkid/python-plexapi/releases
|
||||
.. image:: https://badge.fury.io/py/PlexAPI.svg
|
||||
:target: https://badge.fury.io/py/PlexAPI
|
||||
.. image:: https://img.shields.io/github/last-commit/pkkid/python-plexapi.svg
|
||||
:target: https://img.shields.io/github/last-commit/pkkid/python-plexapi.svg
|
||||
|
||||
|
||||
Overview
|
||||
--------
|
||||
Unofficial Python bindings for the Plex API. Our goal is to match all capabilities of the official
|
||||
Plex Web Client. A few of the many features we currently support are:
|
||||
|
||||
* Navigate local or remote shared libraries.
|
||||
* Perform library actions such as scan, analyze, empty trash.
|
||||
* Remote control and play media on connected clients, including `Controlling Sonos speakers`_
|
||||
* Listen in on all Plex Server notifications.
|
||||
|
||||
|
||||
Installation & Documentation
|
||||
----------------------------
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
pip install plexapi
|
||||
|
||||
*Install extra features:*
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
pip install plexapi[alert] # Install with dependencies required for plexapi.alert
|
||||
|
||||
Documentation_ can be found at Read the Docs.
|
||||
|
||||
.. _Documentation: http://python-plexapi.readthedocs.io/en/latest/
|
||||
|
||||
Join our Discord_ for support and discussion.
|
||||
|
||||
.. _Discord: https://discord.gg/GtAnnZAkuw
|
||||
|
||||
|
||||
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('<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, by 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 Game of Thrones episodes as played.
|
||||
plex.library.section('TV Shows').get('Game of Thrones').markPlayed()
|
||||
|
||||
|
||||
.. 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 Cars on another client.
|
||||
# Note: Client must be on same network as server.
|
||||
cars = plex.library.section('Movies').get('Cars')
|
||||
client = plex.client("Michael's iPhone")
|
||||
client.playMedia(cars)
|
||||
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Example 5: List all content with the word 'Game' in the title.
|
||||
for video in plex.search('Game'):
|
||||
print(f'{video.title} ({video.TYPE})')
|
||||
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Example 6: List all movies directed by the same person as Elephants Dream.
|
||||
movies = plex.library.section('Movies')
|
||||
elephants_dream = movies.get('Elephants Dream')
|
||||
director = elephants_dream.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 The 100.
|
||||
last_episode = plex.library.section('TV Shows').get('The 100').episodes()[-1]
|
||||
for part in last_episode.iterParts():
|
||||
print(part.file)
|
||||
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Example 8: Get audio/video/all playlists
|
||||
for playlist in plex.playlists():
|
||||
print(playlist.title)
|
||||
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Example 9: Rate the 100 four stars.
|
||||
plex.library.section('TV Shows').get('The 100').rate(8.0)
|
||||
|
||||
|
||||
Controlling Sonos speakers
|
||||
--------------------------
|
||||
|
||||
To control Sonos speakers directly using Plex APIs, the following requirements must be met:
|
||||
|
||||
1. Active Plex Pass subscription
|
||||
2. Sonos account linked to Plex account
|
||||
3. Plex remote access enabled
|
||||
|
||||
Due to the design of Sonos music services, the API calls to control Sonos speakers route through https://sonos.plex.tv
|
||||
and back via the Plex server's remote access. Actual media playback is local unless networking restrictions prevent the
|
||||
Sonos speakers from connecting to the Plex server directly.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from plexapi.myplex import MyPlexAccount
|
||||
from plexapi.server import PlexServer
|
||||
|
||||
baseurl = 'http://plexserver:32400'
|
||||
token = '2ffLuB84dqLswk9skLos'
|
||||
|
||||
account = MyPlexAccount(token)
|
||||
server = PlexServer(baseurl, token)
|
||||
|
||||
# List available speakers/groups
|
||||
for speaker in account.sonos_speakers():
|
||||
print(speaker.title)
|
||||
|
||||
# Obtain PlexSonosPlayer instance
|
||||
speaker = account.sonos_speaker("Kitchen")
|
||||
|
||||
album = server.library.section('Music').get('Stevie Wonder').album('Innervisions')
|
||||
|
||||
# Speaker control examples
|
||||
speaker.playMedia(album)
|
||||
speaker.pause()
|
||||
speaker.setVolume(10)
|
||||
speaker.skipNext()
|
||||
|
||||
|
||||
Running tests over PlexAPI
|
||||
--------------------------
|
||||
|
||||
Use:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
tools/plex-boostraptest.py
|
||||
|
||||
with appropriate
|
||||
arguments and add this new server to a shared user which username is defined in environment variable `SHARED_USERNAME`.
|
||||
It uses `official docker image`_ to create a proper instance.
|
||||
|
||||
For skipping the docker and reuse a existing server use
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
python plex-bootstraptest.py --no-docker --username USERNAME --password PASSWORD --server-name NAME-OF-YOUR-SEVER
|
||||
|
||||
Also in order to run most of the tests you have to provide some environment variables:
|
||||
|
||||
* `PLEXAPI_AUTH_SERVER_BASEURL` containing an URL to your Plex instance, e.g. `http://127.0.0.1:32400` (without trailing
|
||||
slash)
|
||||
* `PLEXAPI_AUTH_MYPLEX_USERNAME` and `PLEXAPI_AUTH_MYPLEX_PASSWORD` with your MyPlex username and password accordingly
|
||||
|
||||
After this step you can run tests with following command:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
py.test tests -rxXs --ignore=tests/test_sync.py
|
||||
|
||||
Some of the tests in main test-suite require a shared user in your account (e.g. `test_myplex_users`,
|
||||
`test_myplex_updateFriend`, etc.), you need to provide a valid shared user's username to get them running you need to
|
||||
provide the username of the shared user as an environment variable `SHARED_USERNAME`. You can enable a Guest account and
|
||||
simply pass `Guest` as `SHARED_USERNAME` (or just create a user like `plexapitest` and play with it).
|
||||
|
||||
To be able to run tests over Mobile Sync api you have to some some more environment variables, to following values
|
||||
exactly:
|
||||
|
||||
* PLEXAPI_HEADER_PROVIDES='controller,sync-target'
|
||||
* PLEXAPI_HEADER_PLATFORM=iOS
|
||||
* PLEXAPI_HEADER_PLATFORM_VERSION=11.4.1
|
||||
* PLEXAPI_HEADER_DEVICE=iPhone
|
||||
|
||||
And finally run the sync-related tests:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
py.test tests/test_sync.py -rxXs
|
||||
|
||||
.. _official docker image: https://hub.docker.com/r/plexinc/pms-docker/
|
||||
|
||||
Common Questions
|
||||
----------------
|
||||
|
||||
**Why are you using camelCase and not following PEP8 guidelines?**
|
||||
|
||||
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.
|
||||
|
||||
|
||||
**Why don't you offer feature XYZ?**
|
||||
|
||||
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 offered in the
|
||||
XML pages, please let us know! -- Adding additional features beyond that
|
||||
should be done outside the scope of this library.
|
||||
|
||||
|
||||
**What are some helpful links if trying to understand the raw Plex API?**
|
||||
|
||||
* https://github.com/plexinc/plex-media-player/wiki/Remote-control-API
|
||||
* https://forums.plex.tv/discussion/104353/pms-web-api-documentation
|
||||
* https://github.com/Arcanemagus/plex-api/wiki
|
@ -0,0 +1,31 @@
|
||||
PlexAPI-4.16.1.dist-info/AUTHORS.txt,sha256=iEonabCDE0G6AnfT0tCcppsJ0AaTJZGhRjIM4lIIAck,228
|
||||
PlexAPI-4.16.1.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
|
||||
PlexAPI-4.16.1.dist-info/LICENSE.txt,sha256=ZmoFInlwd6lOpMMQCWIbjLLtu4pwhwWArg_dnYS3X5A,1515
|
||||
PlexAPI-4.16.1.dist-info/METADATA,sha256=Wqd-vI8B0Geygwyrt4NqBcSsuUZxoDxqBHbLtKjz6Wc,9284
|
||||
PlexAPI-4.16.1.dist-info/RECORD,,
|
||||
PlexAPI-4.16.1.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
PlexAPI-4.16.1.dist-info/WHEEL,sha256=2wepM1nk4DS4eFpYrW1TTqPcoGNfHhhO_i5m4cOimbo,92
|
||||
PlexAPI-4.16.1.dist-info/top_level.txt,sha256=PTwXHiZDiXtrZnSI7lpZkRz1oJs5DyYpiiu_FuhuSlk,8
|
||||
plexapi/__init__.py,sha256=rsy6uvdxBP64y4v5lC4yLTP3l5VY3S-Rsk8rE_gDPIM,2144
|
||||
plexapi/alert.py,sha256=pSAIwtzsOnY2b97137dG_8YZOpSBxmKJ_kRz0oZw5jA,4065
|
||||
plexapi/audio.py,sha256=A6hI88X3nP2fTiXMMu7Y_-iRGOtr6K_iRJtw2yzuX6g,29505
|
||||
plexapi/base.py,sha256=aeCngmI8GHicvzIurfGoPFtAfFJXbMJzZ8b8Fi0d3yo,49100
|
||||
plexapi/client.py,sha256=IMbtTVes6_XFO6KBOtMqX4DDvY-iC-94lzb4wdIzYS8,27906
|
||||
plexapi/collection.py,sha256=Cv4xQQMY0YzfrD4FJirUwepPbq28CkJOiCPauNueaQQ,26267
|
||||
plexapi/config.py,sha256=1kiGaq-DooB9zy5KvMLls6_FyIyPIXwddh0zjcmpD-U,2696
|
||||
plexapi/const.py,sha256=0tyh_Wsx9JgzwWD0mCyTM6cLnFtukiMEhUqn9ibDSIU,239
|
||||
plexapi/exceptions.py,sha256=yQYnQk07EQcwvFGJ44rXPt9Q3L415BYqyxxOCj2R8CI,683
|
||||
plexapi/gdm.py,sha256=SVi6uZu5pCuLNUAPIm8WeIJy1J55NTtN-bsBnTvB6Ec,5066
|
||||
plexapi/library.py,sha256=ceJryNLApNus7MCmQ8nm4HuO2_UKBgTdD1EGVI-u6yA,143847
|
||||
plexapi/media.py,sha256=Gsx8IqSUF71Qg4fCVQiEdPcepc3-i0jlgowEVgZiAj0,56451
|
||||
plexapi/mixins.py,sha256=hICrNwbVznjPDibsQHiHXMQ2T4fDooszlR7U47wJ3QM,49090
|
||||
plexapi/myplex.py,sha256=AQR2ZHM045-OAF8JN-f4yKMwFFO0B1r_eQPBsgVW0ps,99769
|
||||
plexapi/photo.py,sha256=eOyn_0wbXLQ7r0zADWbRTfbuRv70_NNN1DViwG2nW24,15702
|
||||
plexapi/playlist.py,sha256=SABCcXfDs3fLE_N0rUwqAkKTbduscQ6cDGpGoutGsrU,24436
|
||||
plexapi/playqueue.py,sha256=MU8fZMyTNTZOIJuPkNSGXijDAGeAuAVAiurtGzVFxG0,12937
|
||||
plexapi/server.py,sha256=tojLUl4sJdu2qnCwu0f_kac5_LKVfEI9SN5qJ553tms,64062
|
||||
plexapi/settings.py,sha256=3suRjHsJUBeRG61WXLpjmNxoTiRFLJMcuZZbzkaDK_Q,7149
|
||||
plexapi/sonos.py,sha256=tIr216CC-o2Vk8GLxsNPkXeyq4JYs9pgz244wbbFfgA,5099
|
||||
plexapi/sync.py,sha256=1NK-oeUKVvNnLFIVAq8d8vy2jG8Nu4gkQB295Qx2xYE,13728
|
||||
plexapi/utils.py,sha256=BvcUNCm_lPnDo5ny4aRlLtVT6KobVG4EqwPjN4w3kAc,24246
|
||||
plexapi/video.py,sha256=9DUhtyA1KCwVN8IoJUfa_kUlZEbplWFCli6-D0nr__k,62939
|
@ -0,0 +1,5 @@
|
||||
Wheel-Version: 1.0
|
||||
Generator: bdist_wheel (0.38.4)
|
||||
Root-Is-Purelib: true
|
||||
Tag: py3-none-any
|
||||
|
@ -0,0 +1 @@
|
||||
plexapi
|
@ -0,0 +1,53 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
import os
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from platform import uname
|
||||
from uuid import getnode
|
||||
|
||||
from plexapi.config import PlexConfig, reset_base_headers
|
||||
import plexapi.const as const
|
||||
from plexapi.utils import SecretsFilter
|
||||
|
||||
# Load User Defined Config
|
||||
DEFAULT_CONFIG_PATH = os.path.expanduser('~/.config/plexapi/config.ini')
|
||||
CONFIG_PATH = os.environ.get('PLEXAPI_CONFIG_PATH', DEFAULT_CONFIG_PATH)
|
||||
CONFIG = PlexConfig(CONFIG_PATH)
|
||||
|
||||
# PlexAPI Settings
|
||||
PROJECT = 'PlexAPI'
|
||||
VERSION = __version__ = const.__version__
|
||||
TIMEOUT = CONFIG.get('plexapi.timeout', 30, int)
|
||||
X_PLEX_CONTAINER_SIZE = CONFIG.get('plexapi.container_size', 100, int)
|
||||
X_PLEX_ENABLE_FAST_CONNECT = CONFIG.get('plexapi.enable_fast_connect', False, bool)
|
||||
|
||||
# Plex Header Configuration
|
||||
X_PLEX_PROVIDES = CONFIG.get('header.provides', 'controller')
|
||||
X_PLEX_PLATFORM = CONFIG.get('header.platform', uname()[0])
|
||||
X_PLEX_PLATFORM_VERSION = CONFIG.get('header.platform_version', uname()[2])
|
||||
X_PLEX_PRODUCT = CONFIG.get('header.product', PROJECT)
|
||||
X_PLEX_VERSION = CONFIG.get('header.version', VERSION)
|
||||
X_PLEX_DEVICE = CONFIG.get('header.device', X_PLEX_PLATFORM)
|
||||
X_PLEX_DEVICE_NAME = CONFIG.get('header.device_name', uname()[1])
|
||||
X_PLEX_IDENTIFIER = CONFIG.get('header.identifier', str(hex(getnode())))
|
||||
X_PLEX_LANGUAGE = CONFIG.get('header.language', 'en')
|
||||
BASE_HEADERS = reset_base_headers()
|
||||
|
||||
# Logging Configuration
|
||||
log = logging.getLogger('plexapi')
|
||||
logfile = CONFIG.get('log.path')
|
||||
logformat = CONFIG.get('log.format', '%(asctime)s %(module)12s:%(lineno)-4s %(levelname)-9s %(message)s')
|
||||
loglevel = CONFIG.get('log.level', 'INFO').upper()
|
||||
loghandler = logging.NullHandler()
|
||||
|
||||
if logfile: # pragma: no cover
|
||||
logbackups = CONFIG.get('log.backup_count', 3, int)
|
||||
logbytes = CONFIG.get('log.rotate_bytes', 512000, int)
|
||||
loghandler = RotatingFileHandler(os.path.expanduser(logfile), 'a', logbytes, logbackups)
|
||||
|
||||
loghandler.setFormatter(logging.Formatter(logformat))
|
||||
log.addHandler(loghandler)
|
||||
log.setLevel(loglevel)
|
||||
logfilter = SecretsFilter()
|
||||
if CONFIG.get('log.show_secrets', '').lower() != 'true':
|
||||
log.addFilter(logfilter)
|
@ -0,0 +1,97 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import json
|
||||
import socket
|
||||
from typing import Callable
|
||||
import threading
|
||||
|
||||
from plexapi import log
|
||||
|
||||
|
||||
class AlertListener(threading.Thread):
|
||||
""" Creates a websocket connection to the PlexServer to optionally receive alert notifications.
|
||||
These often include messages from Plex about media scans as well as updates to currently running
|
||||
Transcode Sessions. This class implements threading.Thread, therefore to start monitoring
|
||||
alerts you must call .start() on the object once it's created. When calling
|
||||
`PlexServer.startAlertListener()`, the thread will be started for you.
|
||||
|
||||
Known `state`-values for timeline entries, with identifier=`com.plexapp.plugins.library`:
|
||||
|
||||
:0: The item was created
|
||||
:1: Reporting progress on item processing
|
||||
:2: Matching the item
|
||||
:3: Downloading the metadata
|
||||
:4: Processing downloaded metadata
|
||||
:5: The item processed
|
||||
:9: The item deleted
|
||||
|
||||
When metadata agent is not set for the library processing ends with state=1.
|
||||
|
||||
Parameters:
|
||||
server (:class:`~plexapi.server.PlexServer`): PlexServer this listener is connected to.
|
||||
callback (func): Callback function to call on received messages. The callback function
|
||||
will be sent a single argument 'data' which will contain a dictionary of data
|
||||
received from the server. :samp:`def my_callback(data): ...`
|
||||
callbackError (func): Callback function to call on errors. The callback function
|
||||
will be sent a single argument 'error' which will contain the Error object.
|
||||
:samp:`def my_callback(error): ...`
|
||||
ws_socket (socket): Socket to use for the connection. If not specified, a new socket will be created.
|
||||
"""
|
||||
key = '/:/websockets/notifications'
|
||||
|
||||
def __init__(self, server, callback: Callable = None, callbackError: Callable = None, ws_socket: socket = None):
|
||||
super(AlertListener, self).__init__()
|
||||
self.daemon = True
|
||||
self._server = server
|
||||
self._callback = callback
|
||||
self._callbackError = callbackError
|
||||
self._socket = ws_socket
|
||||
self._ws = None
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
import websocket
|
||||
except ImportError:
|
||||
log.warning("Can't use the AlertListener without websocket")
|
||||
return
|
||||
# create the websocket connection
|
||||
url = self._server.url(self.key, includeToken=True).replace('http', 'ws')
|
||||
log.info('Starting AlertListener: %s', url)
|
||||
|
||||
self._ws = websocket.WebSocketApp(url, on_message=self._onMessage, on_error=self._onError, socket=self._socket)
|
||||
|
||||
self._ws.run_forever()
|
||||
|
||||
def stop(self):
|
||||
""" Stop the AlertListener thread. Once the notifier is stopped, it cannot be directly
|
||||
started again. You must call :func:`~plexapi.server.PlexServer.startAlertListener`
|
||||
from a PlexServer instance.
|
||||
"""
|
||||
log.info('Stopping AlertListener.')
|
||||
self._ws.close()
|
||||
|
||||
def _onMessage(self, *args):
|
||||
""" Called when websocket message is received.
|
||||
|
||||
We are assuming the last argument in the tuple is the message.
|
||||
"""
|
||||
message = args[-1]
|
||||
try:
|
||||
data = json.loads(message)['NotificationContainer']
|
||||
log.debug('Alert: %s %s %s', *data)
|
||||
if self._callback:
|
||||
self._callback(data)
|
||||
except Exception as err: # pragma: no cover
|
||||
log.error('AlertListener Msg Error: %s', err)
|
||||
|
||||
def _onError(self, *args): # pragma: no cover
|
||||
""" Called when websocket error is received.
|
||||
|
||||
We are assuming the last argument in the tuple is the message.
|
||||
"""
|
||||
err = args[-1]
|
||||
try:
|
||||
log.error('AlertListener Error: %s', err)
|
||||
if self._callbackError:
|
||||
self._callbackError(err)
|
||||
except Exception as err: # pragma: no cover
|
||||
log.error('AlertListener Error: Error: %s', err)
|
@ -0,0 +1,609 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from typing import Any, Dict, List, Optional, TypeVar
|
||||
|
||||
from plexapi import media, utils
|
||||
from plexapi.base import Playable, PlexPartialObject, PlexHistory, PlexSession
|
||||
from plexapi.exceptions import BadRequest
|
||||
from plexapi.mixins import (
|
||||
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, PlayedUnplayedMixin, RatingMixin,
|
||||
ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin, ThemeMixin, ThemeUrlMixin,
|
||||
ArtistEditMixins, AlbumEditMixins, TrackEditMixins
|
||||
)
|
||||
from plexapi.playlist import Playlist
|
||||
|
||||
|
||||
TAudio = TypeVar("TAudio", bound="Audio")
|
||||
TTrack = TypeVar("TTrack", bound="Track")
|
||||
|
||||
|
||||
class Audio(PlexPartialObject, PlayedUnplayedMixin):
|
||||
""" Base class for all audio objects including :class:`~plexapi.audio.Artist`,
|
||||
:class:`~plexapi.audio.Album`, and :class:`~plexapi.audio.Track`.
|
||||
|
||||
Attributes:
|
||||
addedAt (datetime): Datetime the item was added to the library.
|
||||
art (str): URL to artwork image (/library/metadata/<ratingKey>/art/<artid>).
|
||||
artBlurHash (str): BlurHash string for artwork image.
|
||||
distance (float): Sonic Distance of the item from the seed item.
|
||||
fields (List<:class:`~plexapi.media.Field`>): List of field objects.
|
||||
guid (str): Plex GUID for the artist, album, or track (plex://artist/5d07bcb0403c64029053ac4c).
|
||||
images (List<:class:`~plexapi.media.Image`>): List of image objects.
|
||||
index (int): Plex index number (often the track number).
|
||||
key (str): API URL (/library/metadata/<ratingkey>).
|
||||
lastRatedAt (datetime): Datetime the item was last rated.
|
||||
lastViewedAt (datetime): Datetime the item was last played.
|
||||
librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID.
|
||||
librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key.
|
||||
librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title.
|
||||
listType (str): Hardcoded as 'audio' (useful for search filters).
|
||||
moods (List<:class:`~plexapi.media.Mood`>): List of mood objects.
|
||||
musicAnalysisVersion (int): The Plex music analysis version for the item.
|
||||
ratingKey (int): Unique key identifying the item.
|
||||
summary (str): Summary of the artist, album, or track.
|
||||
thumb (str): URL to thumbnail image (/library/metadata/<ratingKey>/thumb/<thumbid>).
|
||||
thumbBlurHash (str): BlurHash string for thumbnail image.
|
||||
title (str): Name of the artist, album, or track (Jason Mraz, We Sing, Lucky, etc.).
|
||||
titleSort (str): Title to use when sorting (defaults to title).
|
||||
type (str): 'artist', 'album', or 'track'.
|
||||
updatedAt (datetime): Datetime the item was updated.
|
||||
userRating (float): Rating of the item (0.0 - 10.0) equaling (0 stars - 5 stars).
|
||||
viewCount (int): Count of times the item was played.
|
||||
"""
|
||||
METADATA_TYPE = 'track'
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self._data = data
|
||||
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
|
||||
self.art = data.attrib.get('art')
|
||||
self.artBlurHash = data.attrib.get('artBlurHash')
|
||||
self.distance = utils.cast(float, data.attrib.get('distance'))
|
||||
self.fields = self.findItems(data, media.Field)
|
||||
self.guid = data.attrib.get('guid')
|
||||
self.images = self.findItems(data, media.Image)
|
||||
self.index = utils.cast(int, data.attrib.get('index'))
|
||||
self.key = data.attrib.get('key', '')
|
||||
self.lastRatedAt = utils.toDatetime(data.attrib.get('lastRatedAt'))
|
||||
self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt'))
|
||||
self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID'))
|
||||
self.librarySectionKey = data.attrib.get('librarySectionKey')
|
||||
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
|
||||
self.listType = 'audio'
|
||||
self.moods = self.findItems(data, media.Mood)
|
||||
self.musicAnalysisVersion = utils.cast(int, data.attrib.get('musicAnalysisVersion'))
|
||||
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
|
||||
self.summary = data.attrib.get('summary')
|
||||
self.thumb = data.attrib.get('thumb')
|
||||
self.thumbBlurHash = data.attrib.get('thumbBlurHash')
|
||||
self.title = data.attrib.get('title')
|
||||
self.titleSort = data.attrib.get('titleSort', self.title)
|
||||
self.type = data.attrib.get('type')
|
||||
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
|
||||
self.userRating = utils.cast(float, data.attrib.get('userRating'))
|
||||
self.viewCount = utils.cast(int, data.attrib.get('viewCount', 0))
|
||||
|
||||
def url(self, part):
|
||||
""" Returns the full URL for the audio item. Typically used for getting a specific track. """
|
||||
return self._server.url(part, includeToken=True) if part else None
|
||||
|
||||
def _defaultSyncTitle(self):
|
||||
""" Returns str, default title for a new syncItem. """
|
||||
return self.title
|
||||
|
||||
@property
|
||||
def hasSonicAnalysis(self):
|
||||
""" Returns True if the audio has been sonically analyzed. """
|
||||
return self.musicAnalysisVersion == 1
|
||||
|
||||
def sync(self, bitrate, client=None, clientId=None, limit=None, title=None):
|
||||
""" Add current audio (artist, album or track) as sync item for specified device.
|
||||
See :func:`~plexapi.myplex.MyPlexAccount.sync` for possible exceptions.
|
||||
|
||||
Parameters:
|
||||
bitrate (int): maximum bitrate for synchronized music, better use one of MUSIC_BITRATE_* values from the
|
||||
module :mod:`~plexapi.sync`.
|
||||
client (:class:`~plexapi.myplex.MyPlexDevice`): sync destination, see
|
||||
:func:`~plexapi.myplex.MyPlexAccount.sync`.
|
||||
clientId (str): sync destination, see :func:`~plexapi.myplex.MyPlexAccount.sync`.
|
||||
limit (int): maximum count of items to sync, unlimited if `None`.
|
||||
title (str): descriptive title for the new :class:`~plexapi.sync.SyncItem`, if empty the value would be
|
||||
generated from metadata of current media.
|
||||
|
||||
Returns:
|
||||
:class:`~plexapi.sync.SyncItem`: an instance of created syncItem.
|
||||
"""
|
||||
|
||||
from plexapi.sync import SyncItem, Policy, MediaSettings
|
||||
|
||||
myplex = self._server.myPlexAccount()
|
||||
sync_item = SyncItem(self._server, None)
|
||||
sync_item.title = title if title else self._defaultSyncTitle()
|
||||
sync_item.rootTitle = self.title
|
||||
sync_item.contentType = self.listType
|
||||
sync_item.metadataType = self.METADATA_TYPE
|
||||
sync_item.machineIdentifier = self._server.machineIdentifier
|
||||
|
||||
section = self._server.library.sectionByID(self.librarySectionID)
|
||||
|
||||
sync_item.location = f'library://{section.uuid}/item/{quote_plus(self.key)}'
|
||||
sync_item.policy = Policy.create(limit)
|
||||
sync_item.mediaSettings = MediaSettings.createMusic(bitrate)
|
||||
|
||||
return myplex.sync(sync_item, client=client, clientId=clientId)
|
||||
|
||||
def sonicallySimilar(
|
||||
self: TAudio,
|
||||
limit: Optional[int] = None,
|
||||
maxDistance: Optional[float] = None,
|
||||
**kwargs,
|
||||
) -> List[TAudio]:
|
||||
"""Returns a list of sonically similar audio items.
|
||||
|
||||
Parameters:
|
||||
limit (int): Maximum count of items to return. Default 50 (server default)
|
||||
maxDistance (float): Maximum distance between tracks, 0.0 - 1.0. Default 0.25 (server default).
|
||||
**kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.fetchItems`.
|
||||
|
||||
Returns:
|
||||
List[:class:`~plexapi.audio.Audio`]: list of sonically similar audio items.
|
||||
"""
|
||||
|
||||
key = f"{self.key}/nearest"
|
||||
params: Dict[str, Any] = {}
|
||||
if limit is not None:
|
||||
params['limit'] = limit
|
||||
if maxDistance is not None:
|
||||
params['maxDistance'] = maxDistance
|
||||
key += utils.joinArgs(params)
|
||||
|
||||
return self.fetchItems(
|
||||
key,
|
||||
cls=type(self),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Artist(
|
||||
Audio,
|
||||
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin,
|
||||
ArtMixin, PosterMixin, ThemeMixin,
|
||||
ArtistEditMixins
|
||||
):
|
||||
""" Represents a single Artist.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Directory'
|
||||
TYPE (str): 'artist'
|
||||
albumSort (int): Setting that indicates how albums are sorted for the artist
|
||||
(-1 = Library default, 0 = Newest first, 1 = Oldest first, 2 = By name).
|
||||
audienceRating (float): Audience rating.
|
||||
collections (List<:class:`~plexapi.media.Collection`>): List of collection objects.
|
||||
countries (List<:class:`~plexapi.media.Country`>): List country objects.
|
||||
genres (List<:class:`~plexapi.media.Genre`>): List of genre objects.
|
||||
guids (List<:class:`~plexapi.media.Guid`>): List of guid objects.
|
||||
key (str): API URL (/library/metadata/<ratingkey>).
|
||||
labels (List<:class:`~plexapi.media.Label`>): List of label objects.
|
||||
locations (List<str>): List of folder paths where the artist is found on disk.
|
||||
rating (float): Artist rating (7.9; 9.8; 8.1).
|
||||
similar (List<:class:`~plexapi.media.Similar`>): List of similar objects.
|
||||
styles (List<:class:`~plexapi.media.Style`>): List of style objects.
|
||||
theme (str): URL to theme resource (/library/metadata/<ratingkey>/theme/<themeid>).
|
||||
ultraBlurColors (:class:`~plexapi.media.UltraBlurColors`): Ultra blur color object.
|
||||
"""
|
||||
TAG = 'Directory'
|
||||
TYPE = 'artist'
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
Audio._loadData(self, data)
|
||||
self.albumSort = utils.cast(int, data.attrib.get('albumSort', '-1'))
|
||||
self.audienceRating = utils.cast(float, data.attrib.get('audienceRating'))
|
||||
self.collections = self.findItems(data, media.Collection)
|
||||
self.countries = self.findItems(data, media.Country)
|
||||
self.genres = self.findItems(data, media.Genre)
|
||||
self.guids = self.findItems(data, media.Guid)
|
||||
self.key = self.key.replace('/children', '') # FIX_BUG_50
|
||||
self.labels = self.findItems(data, media.Label)
|
||||
self.locations = self.listAttrs(data, 'path', etag='Location')
|
||||
self.rating = utils.cast(float, data.attrib.get('rating'))
|
||||
self.similar = self.findItems(data, media.Similar)
|
||||
self.styles = self.findItems(data, media.Style)
|
||||
self.theme = data.attrib.get('theme')
|
||||
self.ultraBlurColors = self.findItem(data, media.UltraBlurColors)
|
||||
|
||||
def __iter__(self):
|
||||
for album in self.albums():
|
||||
yield album
|
||||
|
||||
def album(self, title):
|
||||
""" Returns the :class:`~plexapi.audio.Album` that matches the specified title.
|
||||
|
||||
Parameters:
|
||||
title (str): Title of the album to return.
|
||||
"""
|
||||
return self.section().get(
|
||||
title=title,
|
||||
libtype='album',
|
||||
filters={'artist.id': self.ratingKey}
|
||||
)
|
||||
|
||||
def albums(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.audio.Album` objects by the artist. """
|
||||
return self.section().search(
|
||||
libtype='album',
|
||||
filters={**kwargs.pop('filters', {}), 'artist.id': self.ratingKey},
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def track(self, title=None, album=None, track=None):
|
||||
""" Returns the :class:`~plexapi.audio.Track` that matches the specified title.
|
||||
|
||||
Parameters:
|
||||
title (str): Title of the track to return.
|
||||
album (str): Album name (default: None; required if title not specified).
|
||||
track (int): Track number (default: None; required if title not specified).
|
||||
|
||||
Raises:
|
||||
:exc:`~plexapi.exceptions.BadRequest`: If title or album and track parameters are missing.
|
||||
"""
|
||||
key = f'{self.key}/allLeaves'
|
||||
if title is not None:
|
||||
return self.fetchItem(key, Track, title__iexact=title)
|
||||
elif album is not None and track is not None:
|
||||
return self.fetchItem(key, Track, parentTitle__iexact=album, index=track)
|
||||
raise BadRequest('Missing argument: title or album and track are required')
|
||||
|
||||
def tracks(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.audio.Track` objects by the artist. """
|
||||
key = f'{self.key}/allLeaves'
|
||||
return self.fetchItems(key, Track, **kwargs)
|
||||
|
||||
def get(self, title=None, album=None, track=None):
|
||||
""" Alias of :func:`~plexapi.audio.Artist.track`. """
|
||||
return self.track(title, album, track)
|
||||
|
||||
def download(self, savepath=None, keep_original_name=False, subfolders=False, **kwargs):
|
||||
""" Download all tracks from the artist. See :func:`~plexapi.base.Playable.download` for details.
|
||||
|
||||
Parameters:
|
||||
savepath (str): Defaults to current working dir.
|
||||
keep_original_name (bool): True to keep the original filename otherwise
|
||||
a friendlier filename is generated.
|
||||
subfolders (bool): True to separate tracks in to album folders.
|
||||
**kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.getStreamURL`.
|
||||
"""
|
||||
filepaths = []
|
||||
for track in self.tracks():
|
||||
_savepath = os.path.join(savepath, track.parentTitle) if subfolders else savepath
|
||||
filepaths += track.download(_savepath, keep_original_name, **kwargs)
|
||||
return filepaths
|
||||
|
||||
def popularTracks(self):
|
||||
""" Returns a list of :class:`~plexapi.audio.Track` popular tracks by the artist. """
|
||||
filters = {
|
||||
'album.subformat!': 'Compilation,Live',
|
||||
'artist.id': self.ratingKey,
|
||||
'group': 'title',
|
||||
'ratingCount>>': 0,
|
||||
}
|
||||
return self.section().search(
|
||||
libtype='track',
|
||||
filters=filters,
|
||||
sort='ratingCount:desc',
|
||||
limit=100
|
||||
)
|
||||
|
||||
def station(self):
|
||||
""" Returns a :class:`~plexapi.playlist.Playlist` artist radio station or `None`. """
|
||||
key = f'{self.key}?includeStations=1'
|
||||
return next(iter(self.fetchItems(key, cls=Playlist, rtag="Stations")), None)
|
||||
|
||||
@property
|
||||
def metadataDirectory(self):
|
||||
""" Returns the Plex Media Server data directory where the metadata is stored. """
|
||||
guid_hash = utils.sha1hash(self.guid)
|
||||
return str(Path('Metadata') / 'Artists' / guid_hash[0] / f'{guid_hash[1:]}.bundle')
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Album(
|
||||
Audio,
|
||||
SplitMergeMixin, UnmatchMatchMixin, RatingMixin,
|
||||
ArtMixin, PosterMixin, ThemeUrlMixin,
|
||||
AlbumEditMixins
|
||||
):
|
||||
""" Represents a single Album.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Directory'
|
||||
TYPE (str): 'album'
|
||||
audienceRating (float): Audience rating.
|
||||
collections (List<:class:`~plexapi.media.Collection`>): List of collection objects.
|
||||
formats (List<:class:`~plexapi.media.Format`>): List of format objects.
|
||||
genres (List<:class:`~plexapi.media.Genre`>): List of genre objects.
|
||||
guids (List<:class:`~plexapi.media.Guid`>): List of guid objects.
|
||||
key (str): API URL (/library/metadata/<ratingkey>).
|
||||
labels (List<:class:`~plexapi.media.Label`>): List of label objects.
|
||||
leafCount (int): Number of items in the album view.
|
||||
loudnessAnalysisVersion (int): The Plex loudness analysis version level.
|
||||
originallyAvailableAt (datetime): Datetime the album was released.
|
||||
parentGuid (str): Plex GUID for the album artist (plex://artist/5d07bcb0403c64029053ac4c).
|
||||
parentKey (str): API URL of the album artist (/library/metadata/<parentRatingKey>).
|
||||
parentRatingKey (int): Unique key identifying the album artist.
|
||||
parentTheme (str): URL to artist theme resource (/library/metadata/<parentRatingkey>/theme/<themeid>).
|
||||
parentThumb (str): URL to album artist thumbnail image (/library/metadata/<parentRatingKey>/thumb/<thumbid>).
|
||||
parentTitle (str): Name of the album artist.
|
||||
rating (float): Album rating (7.9; 9.8; 8.1).
|
||||
studio (str): Studio that released the album.
|
||||
styles (List<:class:`~plexapi.media.Style`>): List of style objects.
|
||||
subformats (List<:class:`~plexapi.media.Subformat`>): List of subformat objects.
|
||||
ultraBlurColors (:class:`~plexapi.media.UltraBlurColors`): Ultra blur color object.
|
||||
viewedLeafCount (int): Number of items marked as played in the album view.
|
||||
year (int): Year the album was released.
|
||||
"""
|
||||
TAG = 'Directory'
|
||||
TYPE = 'album'
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
Audio._loadData(self, data)
|
||||
self.audienceRating = utils.cast(float, data.attrib.get('audienceRating'))
|
||||
self.collections = self.findItems(data, media.Collection)
|
||||
self.formats = self.findItems(data, media.Format)
|
||||
self.genres = self.findItems(data, media.Genre)
|
||||
self.guids = self.findItems(data, media.Guid)
|
||||
self.key = self.key.replace('/children', '') # FIX_BUG_50
|
||||
self.labels = self.findItems(data, media.Label)
|
||||
self.leafCount = utils.cast(int, data.attrib.get('leafCount'))
|
||||
self.loudnessAnalysisVersion = utils.cast(int, data.attrib.get('loudnessAnalysisVersion'))
|
||||
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
|
||||
self.parentGuid = data.attrib.get('parentGuid')
|
||||
self.parentKey = data.attrib.get('parentKey')
|
||||
self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey'))
|
||||
self.parentTheme = data.attrib.get('parentTheme')
|
||||
self.parentThumb = data.attrib.get('parentThumb')
|
||||
self.parentTitle = data.attrib.get('parentTitle')
|
||||
self.rating = utils.cast(float, data.attrib.get('rating'))
|
||||
self.studio = data.attrib.get('studio')
|
||||
self.styles = self.findItems(data, media.Style)
|
||||
self.subformats = self.findItems(data, media.Subformat)
|
||||
self.ultraBlurColors = self.findItem(data, media.UltraBlurColors)
|
||||
self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount'))
|
||||
self.year = utils.cast(int, data.attrib.get('year'))
|
||||
|
||||
def __iter__(self):
|
||||
for track in self.tracks():
|
||||
yield track
|
||||
|
||||
def track(self, title=None, track=None):
|
||||
""" Returns the :class:`~plexapi.audio.Track` that matches the specified title.
|
||||
|
||||
Parameters:
|
||||
title (str): Title of the track to return.
|
||||
track (int): Track number (default: None; required if title not specified).
|
||||
|
||||
Raises:
|
||||
:exc:`~plexapi.exceptions.BadRequest`: If title or track parameter is missing.
|
||||
"""
|
||||
key = f'{self.key}/children'
|
||||
if title is not None and not isinstance(title, int):
|
||||
return self.fetchItem(key, Track, title__iexact=title)
|
||||
elif track is not None or isinstance(title, int):
|
||||
if isinstance(title, int):
|
||||
index = title
|
||||
else:
|
||||
index = track
|
||||
return self.fetchItem(key, Track, parentTitle__iexact=self.title, index=index)
|
||||
raise BadRequest('Missing argument: title or track is required')
|
||||
|
||||
def tracks(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.audio.Track` objects in the album. """
|
||||
key = f'{self.key}/children'
|
||||
return self.fetchItems(key, Track, **kwargs)
|
||||
|
||||
def get(self, title=None, track=None):
|
||||
""" Alias of :func:`~plexapi.audio.Album.track`. """
|
||||
return self.track(title, track)
|
||||
|
||||
def artist(self):
|
||||
""" Return the album's :class:`~plexapi.audio.Artist`. """
|
||||
return self.fetchItem(self.parentKey)
|
||||
|
||||
def download(self, savepath=None, keep_original_name=False, **kwargs):
|
||||
""" Download all tracks from the album. See :func:`~plexapi.base.Playable.download` for details.
|
||||
|
||||
Parameters:
|
||||
savepath (str): Defaults to current working dir.
|
||||
keep_original_name (bool): True to keep the original filename otherwise
|
||||
a friendlier filename is generated.
|
||||
**kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.getStreamURL`.
|
||||
"""
|
||||
filepaths = []
|
||||
for track in self.tracks():
|
||||
filepaths += track.download(savepath, keep_original_name, **kwargs)
|
||||
return filepaths
|
||||
|
||||
def _defaultSyncTitle(self):
|
||||
""" Returns str, default title for a new syncItem. """
|
||||
return f'{self.parentTitle} - {self.title}'
|
||||
|
||||
@property
|
||||
def metadataDirectory(self):
|
||||
""" Returns the Plex Media Server data directory where the metadata is stored. """
|
||||
guid_hash = utils.sha1hash(self.guid)
|
||||
return str(Path('Metadata') / 'Albums' / guid_hash[0] / f'{guid_hash[1:]}.bundle')
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Track(
|
||||
Audio, Playable,
|
||||
ExtrasMixin, RatingMixin,
|
||||
ArtUrlMixin, PosterUrlMixin, ThemeUrlMixin,
|
||||
TrackEditMixins
|
||||
):
|
||||
""" Represents a single Track.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Directory'
|
||||
TYPE (str): 'track'
|
||||
audienceRating (float): Audience rating.
|
||||
chapters (List<:class:`~plexapi.media.Chapter`>): List of Chapter objects.
|
||||
chapterSource (str): Unknown
|
||||
collections (List<:class:`~plexapi.media.Collection`>): List of collection objects.
|
||||
duration (int): Length of the track in milliseconds.
|
||||
genres (List<:class:`~plexapi.media.Genre`>): List of genre objects.
|
||||
grandparentArt (str): URL to album artist artwork (/library/metadata/<grandparentRatingKey>/art/<artid>).
|
||||
grandparentGuid (str): Plex GUID for the album artist (plex://artist/5d07bcb0403c64029053ac4c).
|
||||
grandparentKey (str): API URL of the album artist (/library/metadata/<grandparentRatingKey>).
|
||||
grandparentRatingKey (int): Unique key identifying the album artist.
|
||||
grandparentTheme (str): URL to artist theme resource (/library/metadata/<grandparentRatingkey>/theme/<themeid>).
|
||||
(/library/metadata/<grandparentRatingkey>/theme/<themeid>).
|
||||
grandparentThumb (str): URL to album artist thumbnail image
|
||||
(/library/metadata/<grandparentRatingKey>/thumb/<thumbid>).
|
||||
grandparentTitle (str): Name of the album artist for the track.
|
||||
guids (List<:class:`~plexapi.media.Guid`>): List of guid objects.
|
||||
labels (List<:class:`~plexapi.media.Label`>): List of label objects.
|
||||
media (List<:class:`~plexapi.media.Media`>): List of media objects.
|
||||
originalTitle (str): The artist for the track.
|
||||
parentGuid (str): Plex GUID for the album (plex://album/5d07cd8e403c640290f180f9).
|
||||
parentIndex (int): Disc number of the track.
|
||||
parentKey (str): API URL of the album (/library/metadata/<parentRatingKey>).
|
||||
parentRatingKey (int): Unique key identifying the album.
|
||||
parentThumb (str): URL to album thumbnail image (/library/metadata/<parentRatingKey>/thumb/<thumbid>).
|
||||
parentTitle (str): Name of the album for the track.
|
||||
primaryExtraKey (str) API URL for the primary extra for the track.
|
||||
rating (float): Track rating (7.9; 9.8; 8.1).
|
||||
ratingCount (int): Number of listeners who have scrobbled this track, as reported by Last.fm.
|
||||
skipCount (int): Number of times the track has been skipped.
|
||||
sourceURI (str): Remote server URI (server://<machineIdentifier>/com.plexapp.plugins.library)
|
||||
(remote playlist item only).
|
||||
viewOffset (int): View offset in milliseconds.
|
||||
year (int): Year the track was released.
|
||||
"""
|
||||
TAG = 'Track'
|
||||
TYPE = 'track'
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
Audio._loadData(self, data)
|
||||
Playable._loadData(self, data)
|
||||
self.audienceRating = utils.cast(float, data.attrib.get('audienceRating'))
|
||||
self.chapters = self.findItems(data, media.Chapter)
|
||||
self.chapterSource = data.attrib.get('chapterSource')
|
||||
self.collections = self.findItems(data, media.Collection)
|
||||
self.duration = utils.cast(int, data.attrib.get('duration'))
|
||||
self.genres = self.findItems(data, media.Genre)
|
||||
self.grandparentArt = data.attrib.get('grandparentArt')
|
||||
self.grandparentGuid = data.attrib.get('grandparentGuid')
|
||||
self.grandparentKey = data.attrib.get('grandparentKey')
|
||||
self.grandparentRatingKey = utils.cast(int, data.attrib.get('grandparentRatingKey'))
|
||||
self.grandparentTheme = data.attrib.get('grandparentTheme')
|
||||
self.grandparentThumb = data.attrib.get('grandparentThumb')
|
||||
self.grandparentTitle = data.attrib.get('grandparentTitle')
|
||||
self.guids = self.findItems(data, media.Guid)
|
||||
self.labels = self.findItems(data, media.Label)
|
||||
self.media = self.findItems(data, media.Media)
|
||||
self.originalTitle = data.attrib.get('originalTitle')
|
||||
self.parentGuid = data.attrib.get('parentGuid')
|
||||
self.parentIndex = utils.cast(int, data.attrib.get('parentIndex'))
|
||||
self.parentKey = data.attrib.get('parentKey')
|
||||
self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey'))
|
||||
self.parentThumb = data.attrib.get('parentThumb')
|
||||
self.parentTitle = data.attrib.get('parentTitle')
|
||||
self.primaryExtraKey = data.attrib.get('primaryExtraKey')
|
||||
self.rating = utils.cast(float, data.attrib.get('rating'))
|
||||
self.ratingCount = utils.cast(int, data.attrib.get('ratingCount'))
|
||||
self.skipCount = utils.cast(int, data.attrib.get('skipCount'))
|
||||
self.sourceURI = data.attrib.get('source') # remote playlist item
|
||||
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
|
||||
self.year = utils.cast(int, data.attrib.get('year'))
|
||||
|
||||
@property
|
||||
def locations(self):
|
||||
""" This does not exist in plex xml response but is added to have a common
|
||||
interface to get the locations of the track.
|
||||
|
||||
Returns:
|
||||
List<str> of file paths where the track is found on disk.
|
||||
"""
|
||||
return [part.file for part in self.iterParts() if part]
|
||||
|
||||
@property
|
||||
def trackNumber(self):
|
||||
""" Returns the track number. """
|
||||
return self.index
|
||||
|
||||
def _prettyfilename(self):
|
||||
""" Returns a filename for use in download. """
|
||||
return f'{self.grandparentTitle} - {self.parentTitle} - {str(self.trackNumber).zfill(2)} - {self.title}'
|
||||
|
||||
def album(self):
|
||||
""" Return the track's :class:`~plexapi.audio.Album`. """
|
||||
return self.fetchItem(self.parentKey)
|
||||
|
||||
def artist(self):
|
||||
""" Return the track's :class:`~plexapi.audio.Artist`. """
|
||||
return self.fetchItem(self.grandparentKey)
|
||||
|
||||
def _defaultSyncTitle(self):
|
||||
""" Returns str, default title for a new syncItem. """
|
||||
return f'{self.grandparentTitle} - {self.parentTitle} - {self.title}'
|
||||
|
||||
def _getWebURL(self, base=None):
|
||||
""" Get the Plex Web URL with the correct parameters. """
|
||||
return self._server._buildWebURL(base=base, endpoint='details', key=self.parentKey)
|
||||
|
||||
@property
|
||||
def metadataDirectory(self):
|
||||
""" Returns the Plex Media Server data directory where the metadata is stored. """
|
||||
guid_hash = utils.sha1hash(self.parentGuid)
|
||||
return str(Path('Metadata') / 'Albums' / guid_hash[0] / f'{guid_hash[1:]}.bundle')
|
||||
|
||||
def sonicAdventure(
|
||||
self: TTrack,
|
||||
to: TTrack,
|
||||
**kwargs: Any,
|
||||
) -> list[TTrack]:
|
||||
"""Returns a sonic adventure from the current track to the specified track.
|
||||
|
||||
Parameters:
|
||||
to (:class:`~plexapi.audio.Track`): The target track for the sonic adventure.
|
||||
**kwargs: Additional options passed into :func:`~plexapi.library.MusicSection.sonicAdventure`.
|
||||
|
||||
Returns:
|
||||
List[:class:`~plexapi.audio.Track`]: list of tracks in the sonic adventure.
|
||||
"""
|
||||
return self.section().sonicAdventure(self, to, **kwargs)
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class TrackSession(PlexSession, Track):
|
||||
""" Represents a single Track session
|
||||
loaded from :func:`~plexapi.server.PlexServer.sessions`.
|
||||
"""
|
||||
_SESSIONTYPE = True
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
Track._loadData(self, data)
|
||||
PlexSession._loadData(self, data)
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class TrackHistory(PlexHistory, Track):
|
||||
""" Represents a single Track history entry
|
||||
loaded from :func:`~plexapi.server.PlexServer.history`.
|
||||
"""
|
||||
_HISTORYTYPE = True
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
Track._loadData(self, data)
|
||||
PlexHistory._loadData(self, data)
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,638 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import time
|
||||
import weakref
|
||||
from xml.etree import ElementTree
|
||||
|
||||
import requests
|
||||
|
||||
from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, log, logfilter, utils
|
||||
from plexapi.base import PlexObject
|
||||
from plexapi.exceptions import BadRequest, NotFound, Unauthorized, Unsupported
|
||||
from plexapi.playqueue import PlayQueue
|
||||
from requests.status_codes import _codes as codes
|
||||
|
||||
DEFAULT_MTYPE = 'video'
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class PlexClient(PlexObject):
|
||||
""" 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
|
||||
|
||||
Parameters:
|
||||
server (:class:`~plexapi.server.PlexServer`): PlexServer this client is connected to (optional).
|
||||
data (ElementTree): Response from PlexServer used to build this object (optional).
|
||||
initpath (str): Path used to generate data.
|
||||
baseurl (str): HTTP URL to connect directly to this client.
|
||||
identifier (str): The resource/machine identifier for the desired client.
|
||||
May be necessary when connecting to a specific proxied client (optional).
|
||||
token (str): X-Plex-Token used for authentication (optional).
|
||||
session (:class:`~requests.Session`): requests.Session object if you want more control (optional).
|
||||
timeout (int): timeout in seconds on initial connect to client (default config.TIMEOUT).
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Player'
|
||||
key (str): '/resources'
|
||||
device (str): Best guess on the type of device this is (PS, iPhone, Linux, etc).
|
||||
deviceClass (str): Device class (pc, phone, etc).
|
||||
machineIdentifier (str): Unique ID for this device.
|
||||
model (str): Unknown
|
||||
platform (str): Unknown
|
||||
platformVersion (str): Description
|
||||
product (str): Client Product (Plex for iOS, etc).
|
||||
protocol (str): Always seems ot be 'plex'.
|
||||
protocolCapabilities (list<str>): List of client capabilities (navigation, playback,
|
||||
timeline, mirror, playqueues).
|
||||
protocolVersion (str): Protocol version (1, future proofing?)
|
||||
server (:class:`~plexapi.server.PlexServer`): Server this client is connected to.
|
||||
session (:class:`~requests.Session`): Session object used for connection.
|
||||
state (str): Unknown
|
||||
title (str): Name of this client (Johns iPhone, etc).
|
||||
token (str): X-Plex-Token used for authentication
|
||||
vendor (str): Unknown
|
||||
version (str): Device version (4.6.1, etc).
|
||||
_baseurl (str): HTTP address of the client.
|
||||
_token (str): Token used to access this client.
|
||||
_session (obj): Requests session object used to access this client.
|
||||
_proxyThroughServer (bool): Set to True after calling
|
||||
:func:`~plexapi.client.PlexClient.proxyThroughServer` (default False).
|
||||
"""
|
||||
TAG = 'Player'
|
||||
key = '/resources'
|
||||
|
||||
def __init__(self, server=None, data=None, initpath=None, baseurl=None,
|
||||
identifier=None, token=None, connect=True, session=None, timeout=None,
|
||||
parent=None):
|
||||
super(PlexClient, self).__init__(server, data, initpath)
|
||||
self._baseurl = baseurl.strip('/') if baseurl else None
|
||||
self._clientIdentifier = identifier
|
||||
self._token = logfilter.add_secret(token)
|
||||
self._showSecrets = CONFIG.get('log.show_secrets', '').lower() == 'true'
|
||||
server_session = server._session if server else None
|
||||
self._session = session or server_session or requests.Session()
|
||||
self._timeout = timeout or TIMEOUT
|
||||
self._proxyThroughServer = False
|
||||
self._commandId = 0
|
||||
self._last_call = 0
|
||||
self._timeline_cache = []
|
||||
self._timeline_cache_timestamp = 0
|
||||
self._parent = weakref.ref(parent) if parent is not None else None
|
||||
if not any([data is not None, initpath, baseurl, token]):
|
||||
self._baseurl = CONFIG.get('auth.client_baseurl', 'http://localhost:32433')
|
||||
self._token = logfilter.add_secret(CONFIG.get('auth.client_token'))
|
||||
if connect and self._baseurl:
|
||||
self.connect(timeout=timeout)
|
||||
|
||||
def _nextCommandId(self):
|
||||
self._commandId += 1
|
||||
return self._commandId
|
||||
|
||||
def connect(self, timeout=None):
|
||||
""" Alias of reload as any subsequent requests to this client will be
|
||||
made directly to the device even if the object attributes were initially
|
||||
populated from a PlexServer.
|
||||
"""
|
||||
if not self.key:
|
||||
raise Unsupported('Cannot reload an object not built from a URL.')
|
||||
self._initpath = self.key
|
||||
data = self.query(self.key, timeout=timeout)
|
||||
if data is None:
|
||||
raise NotFound(f"Client not found at {self._baseurl}")
|
||||
if self._clientIdentifier:
|
||||
client = next(
|
||||
(
|
||||
x
|
||||
for x in data
|
||||
if x.attrib.get("machineIdentifier") == self._clientIdentifier
|
||||
),
|
||||
None,
|
||||
)
|
||||
if client is None:
|
||||
raise NotFound(
|
||||
f"Client with identifier {self._clientIdentifier} not found at {self._baseurl}"
|
||||
)
|
||||
else:
|
||||
client = data[0]
|
||||
self._loadData(client)
|
||||
return self
|
||||
|
||||
def reload(self):
|
||||
""" Alias to self.connect(). """
|
||||
return self.connect()
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self._data = data
|
||||
self.deviceClass = data.attrib.get('deviceClass')
|
||||
self.machineIdentifier = data.attrib.get('machineIdentifier')
|
||||
self.product = data.attrib.get('product')
|
||||
self.protocol = data.attrib.get('protocol')
|
||||
self.protocolCapabilities = data.attrib.get('protocolCapabilities', '').split(',')
|
||||
self.protocolVersion = data.attrib.get('protocolVersion')
|
||||
self.platform = data.attrib.get('platform')
|
||||
self.platformVersion = data.attrib.get('platformVersion')
|
||||
self.title = data.attrib.get('title') or data.attrib.get('name')
|
||||
# Active session details
|
||||
# Since protocolCapabilities is missing from /sessions we can't really control this player without
|
||||
# creating a client manually.
|
||||
# Add this in next breaking release.
|
||||
# if self._initpath == 'status/sessions':
|
||||
self.device = data.attrib.get('device') # session
|
||||
self.profile = data.attrib.get('profile') # session
|
||||
self.model = data.attrib.get('model') # session
|
||||
self.state = data.attrib.get('state') # session
|
||||
self.vendor = data.attrib.get('vendor') # session
|
||||
self.version = data.attrib.get('version') # session
|
||||
self.local = utils.cast(bool, data.attrib.get('local', 0)) # session
|
||||
self.relayed = utils.cast(bool, data.attrib.get('relayed', 0)) # session
|
||||
self.secure = utils.cast(bool, data.attrib.get('secure', 0)) # session
|
||||
self.address = data.attrib.get('address') # session
|
||||
self.remotePublicAddress = data.attrib.get('remotePublicAddress')
|
||||
self.userID = data.attrib.get('userID')
|
||||
|
||||
def _headers(self, **kwargs):
|
||||
""" Returns a dict of all default headers for Client requests. """
|
||||
headers = BASE_HEADERS
|
||||
if self._token:
|
||||
headers['X-Plex-Token'] = self._token
|
||||
headers.update(kwargs)
|
||||
return headers
|
||||
|
||||
def proxyThroughServer(self, value=True, server=None):
|
||||
""" 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.
|
||||
|
||||
Parameters:
|
||||
value (bool): Enable or disable proxying (optional, default True).
|
||||
|
||||
Raises:
|
||||
:exc:`~plexapi.exceptions.Unsupported`: Cannot use client proxy with unknown server.
|
||||
"""
|
||||
if server:
|
||||
self._server = server
|
||||
if value is True and not self._server:
|
||||
raise Unsupported('Cannot use client proxy with unknown server.')
|
||||
self._proxyThroughServer = value
|
||||
|
||||
def query(self, path, method=None, headers=None, timeout=None, **kwargs):
|
||||
""" Main method used to handle HTTPS requests to the Plex client. This method helps
|
||||
by encoding the response to utf-8 and parsing the returned XML into and
|
||||
ElementTree object. Returns None if no data exists in the response.
|
||||
"""
|
||||
url = self.url(path)
|
||||
method = method or self._session.get
|
||||
timeout = timeout or self._timeout
|
||||
log.debug('%s %s', method.__name__.upper(), url)
|
||||
headers = self._headers(**headers or {})
|
||||
response = method(url, headers=headers, timeout=timeout, **kwargs)
|
||||
if response.status_code not in (200, 201, 204):
|
||||
codename = codes.get(response.status_code)[0]
|
||||
errtext = response.text.replace('\n', ' ')
|
||||
message = f'({response.status_code}) {codename}; {response.url} {errtext}'
|
||||
if response.status_code == 401:
|
||||
raise Unauthorized(message)
|
||||
elif response.status_code == 404:
|
||||
raise NotFound(message)
|
||||
else:
|
||||
raise BadRequest(message)
|
||||
data = utils.cleanXMLString(response.text).encode('utf8')
|
||||
return ElementTree.fromstring(data) if data.strip() else None
|
||||
|
||||
def sendCommand(self, command, proxy=None, **params):
|
||||
""" Convenience wrapper around :func:`~plexapi.client.PlexClient.query` to more easily
|
||||
send simple commands to the client. Returns an ElementTree object containing
|
||||
the response.
|
||||
|
||||
Parameters:
|
||||
command (str): Command to be sent in for format '<controller>/<command>'.
|
||||
proxy (bool): Set True to proxy this command through the PlexServer.
|
||||
**params (dict): Additional GET parameters to include with the command.
|
||||
|
||||
Raises:
|
||||
:exc:`~plexapi.exceptions.Unsupported`: When we detect the client doesn't support this capability.
|
||||
"""
|
||||
command = command.strip('/')
|
||||
controller = command.split('/')[0]
|
||||
headers = {'X-Plex-Target-Client-Identifier': self.machineIdentifier}
|
||||
if controller not in self.protocolCapabilities:
|
||||
log.debug("Client %s doesn't support %s controller. What your trying might not work", self.title, controller)
|
||||
|
||||
proxy = self._proxyThroughServer if proxy is None else proxy
|
||||
query = self._server.query if proxy else self.query
|
||||
|
||||
# Workaround for ptp. See https://github.com/pkkid/python-plexapi/issues/244
|
||||
t = time.time()
|
||||
if command == 'timeline/poll':
|
||||
self._last_call = t
|
||||
elif t - self._last_call >= 80 and self.product in ('ptp', 'Plex Media Player'):
|
||||
self._last_call = t
|
||||
self.sendCommand(ClientTimeline.key, wait=0)
|
||||
|
||||
params['commandID'] = self._nextCommandId()
|
||||
key = f'/player/{command}{utils.joinArgs(params)}'
|
||||
|
||||
try:
|
||||
return query(key, headers=headers)
|
||||
except ElementTree.ParseError:
|
||||
# Workaround for players which don't return valid XML on successful commands
|
||||
# - Plexamp, Plex for Android: `b'OK'`
|
||||
# - Plex for Samsung: `b'<?xml version="1.0"?><Response code="200" status="OK">'`
|
||||
if self.product in (
|
||||
'Plexamp',
|
||||
'Plex for Android (TV)',
|
||||
'Plex for Android (Mobile)',
|
||||
'Plex for Samsung',
|
||||
):
|
||||
return
|
||||
raise
|
||||
|
||||
def url(self, key, includeToken=False):
|
||||
""" Build a URL string with proper token argument. Token will be appended to the URL
|
||||
if either includeToken is True or CONFIG.log.show_secrets is 'true'.
|
||||
"""
|
||||
if not self._baseurl:
|
||||
raise BadRequest('PlexClient object missing baseurl.')
|
||||
if self._token and (includeToken or self._showSecrets):
|
||||
delim = '&' if '?' in key else '?'
|
||||
return f'{self._baseurl}{key}{delim}X-Plex-Token={self._token}'
|
||||
return f'{self._baseurl}{key}'
|
||||
|
||||
# ---------------------
|
||||
# Navigation Commands
|
||||
# These commands navigate around the user-interface.
|
||||
def contextMenu(self):
|
||||
""" Open the context menu on the client. """
|
||||
self.sendCommand('navigation/contextMenu')
|
||||
|
||||
def goBack(self):
|
||||
""" Navigate back one position. """
|
||||
self.sendCommand('navigation/back')
|
||||
|
||||
def goToHome(self):
|
||||
""" Go directly to the home screen. """
|
||||
self.sendCommand('navigation/home')
|
||||
|
||||
def goToMusic(self):
|
||||
""" Go directly to the playing music panel. """
|
||||
self.sendCommand('navigation/music')
|
||||
|
||||
def moveDown(self):
|
||||
""" Move selection down a position. """
|
||||
self.sendCommand('navigation/moveDown')
|
||||
|
||||
def moveLeft(self):
|
||||
""" Move selection left a position. """
|
||||
self.sendCommand('navigation/moveLeft')
|
||||
|
||||
def moveRight(self):
|
||||
""" Move selection right a position. """
|
||||
self.sendCommand('navigation/moveRight')
|
||||
|
||||
def moveUp(self):
|
||||
""" Move selection up a position. """
|
||||
self.sendCommand('navigation/moveUp')
|
||||
|
||||
def nextLetter(self):
|
||||
""" Jump to next letter in the alphabet. """
|
||||
self.sendCommand('navigation/nextLetter')
|
||||
|
||||
def pageDown(self):
|
||||
""" Move selection down a full page. """
|
||||
self.sendCommand('navigation/pageDown')
|
||||
|
||||
def pageUp(self):
|
||||
""" Move selection up a full page. """
|
||||
self.sendCommand('navigation/pageUp')
|
||||
|
||||
def previousLetter(self):
|
||||
""" Jump to previous letter in the alphabet. """
|
||||
self.sendCommand('navigation/previousLetter')
|
||||
|
||||
def select(self):
|
||||
""" Select element at the current position. """
|
||||
self.sendCommand('navigation/select')
|
||||
|
||||
def toggleOSD(self):
|
||||
""" Toggle the on screen display during playback. """
|
||||
self.sendCommand('navigation/toggleOSD')
|
||||
|
||||
def goToMedia(self, media, **params):
|
||||
""" Navigate directly to the specified media page.
|
||||
|
||||
Parameters:
|
||||
media (:class:`~plexapi.media.Media`): Media object to navigate to.
|
||||
**params (dict): Additional GET parameters to include with the command.
|
||||
"""
|
||||
server_url = media._server._baseurl.split(':')
|
||||
command = {
|
||||
'machineIdentifier': media._server.machineIdentifier,
|
||||
'address': server_url[1].strip('/'),
|
||||
'port': server_url[-1],
|
||||
'key': media.key,
|
||||
'protocol': server_url[0],
|
||||
**params,
|
||||
}
|
||||
token = media._server.createToken()
|
||||
if token:
|
||||
command["token"] = token
|
||||
|
||||
self.sendCommand("mirror/details", **command)
|
||||
|
||||
# -------------------
|
||||
# Playback Commands
|
||||
# Most of the playback commands take a mandatory mtype {'music','photo','video'} argument,
|
||||
# to specify which media type to apply the command to, (except for playMedia). This
|
||||
# is in case there are multiple things happening (e.g. music in the background, photo
|
||||
# slideshow in the foreground).
|
||||
def pause(self, mtype=DEFAULT_MTYPE):
|
||||
""" Pause the currently playing media type.
|
||||
|
||||
Parameters:
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.sendCommand('playback/pause', type=mtype)
|
||||
|
||||
def play(self, mtype=DEFAULT_MTYPE):
|
||||
""" Start playback for the specified media type.
|
||||
|
||||
Parameters:
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.sendCommand('playback/play', type=mtype)
|
||||
|
||||
def refreshPlayQueue(self, playQueueID, mtype=DEFAULT_MTYPE):
|
||||
""" Refresh the specified Playqueue.
|
||||
|
||||
Parameters:
|
||||
playQueueID (str): Playqueue ID.
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.sendCommand(
|
||||
'playback/refreshPlayQueue', playQueueID=playQueueID, type=mtype)
|
||||
|
||||
def seekTo(self, offset, mtype=DEFAULT_MTYPE):
|
||||
""" Seek to the specified offset (ms) during playback.
|
||||
|
||||
Parameters:
|
||||
offset (int): Position to seek to (milliseconds).
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.sendCommand('playback/seekTo', offset=offset, type=mtype)
|
||||
|
||||
def skipNext(self, mtype=DEFAULT_MTYPE):
|
||||
""" Skip to the next playback item.
|
||||
|
||||
Parameters:
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.sendCommand('playback/skipNext', type=mtype)
|
||||
|
||||
def skipPrevious(self, mtype=DEFAULT_MTYPE):
|
||||
""" Skip to previous playback item.
|
||||
|
||||
Parameters:
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.sendCommand('playback/skipPrevious', type=mtype)
|
||||
|
||||
def skipTo(self, key, mtype=DEFAULT_MTYPE):
|
||||
""" Skip to the playback item with the specified key.
|
||||
|
||||
Parameters:
|
||||
key (str): Key of the media item to skip to.
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.sendCommand('playback/skipTo', key=key, type=mtype)
|
||||
|
||||
def stepBack(self, mtype=DEFAULT_MTYPE):
|
||||
""" Step backward a chunk of time in the current playback item.
|
||||
|
||||
Parameters:
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.sendCommand('playback/stepBack', type=mtype)
|
||||
|
||||
def stepForward(self, mtype=DEFAULT_MTYPE):
|
||||
""" Step forward a chunk of time in the current playback item.
|
||||
|
||||
Parameters:
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.sendCommand('playback/stepForward', type=mtype)
|
||||
|
||||
def stop(self, mtype=DEFAULT_MTYPE):
|
||||
""" Stop the currently playing item.
|
||||
|
||||
Parameters:
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.sendCommand('playback/stop', type=mtype)
|
||||
|
||||
def setRepeat(self, repeat, mtype=DEFAULT_MTYPE):
|
||||
""" Enable repeat for the specified playback items.
|
||||
|
||||
Parameters:
|
||||
repeat (int): Repeat mode (0=off, 1=repeatone, 2=repeatall).
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.setParameters(repeat=repeat, mtype=mtype)
|
||||
|
||||
def setShuffle(self, shuffle, mtype=DEFAULT_MTYPE):
|
||||
""" Enable shuffle for the specified playback items.
|
||||
|
||||
Parameters:
|
||||
shuffle (int): Shuffle mode (0=off, 1=on)
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.setParameters(shuffle=shuffle, mtype=mtype)
|
||||
|
||||
def setVolume(self, volume, mtype=DEFAULT_MTYPE):
|
||||
""" Enable volume for the current playback item.
|
||||
|
||||
Parameters:
|
||||
volume (int): Volume level (0-100).
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.setParameters(volume=volume, mtype=mtype)
|
||||
|
||||
def setAudioStream(self, audioStreamID, mtype=DEFAULT_MTYPE):
|
||||
""" Select the audio stream for the current playback item (only video).
|
||||
|
||||
Parameters:
|
||||
audioStreamID (str): ID of the audio stream from the media object.
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.setStreams(audioStreamID=audioStreamID, mtype=mtype)
|
||||
|
||||
def setSubtitleStream(self, subtitleStreamID, mtype=DEFAULT_MTYPE):
|
||||
""" Select the subtitle stream for the current playback item (only video).
|
||||
|
||||
Parameters:
|
||||
subtitleStreamID (str): ID of the subtitle stream from the media object.
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.setStreams(subtitleStreamID=subtitleStreamID, mtype=mtype)
|
||||
|
||||
def setVideoStream(self, videoStreamID, mtype=DEFAULT_MTYPE):
|
||||
""" Select the video stream for the current playback item (only video).
|
||||
|
||||
Parameters:
|
||||
videoStreamID (str): ID of the video stream from the media object.
|
||||
mtype (str): Media type to take action against (music, photo, video).
|
||||
"""
|
||||
self.setStreams(videoStreamID=videoStreamID, mtype=mtype)
|
||||
|
||||
def playMedia(self, media, offset=0, **params):
|
||||
""" Start playback of the specified media item. See also:
|
||||
|
||||
Parameters:
|
||||
media (:class:`~plexapi.media.Media`): Media item to be played back
|
||||
(movie, music, photo, playlist, playqueue).
|
||||
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
|
||||
"""
|
||||
server_url = media._server._baseurl.split(':')
|
||||
server_port = server_url[-1].strip('/')
|
||||
|
||||
if hasattr(media, "playlistType"):
|
||||
mediatype = media.playlistType
|
||||
else:
|
||||
if isinstance(media, PlayQueue):
|
||||
mediatype = media.items[0].listType
|
||||
else:
|
||||
mediatype = media.listType
|
||||
|
||||
# mediatype must be in ["video", "music", "photo"]
|
||||
if mediatype == "audio":
|
||||
mediatype = "music"
|
||||
|
||||
playqueue = media if isinstance(media, PlayQueue) else media._server.createPlayQueue(media)
|
||||
command = {
|
||||
'providerIdentifier': 'com.plexapp.plugins.library',
|
||||
'machineIdentifier': media._server.machineIdentifier,
|
||||
'protocol': server_url[0],
|
||||
'address': server_url[1].strip('/'),
|
||||
'port': server_port,
|
||||
'offset': offset,
|
||||
'key': media.key or playqueue.selectedItem.key,
|
||||
'type': mediatype,
|
||||
'containerKey': f'/playQueues/{playqueue.playQueueID}?window=100&own=1',
|
||||
**params,
|
||||
}
|
||||
token = media._server.createToken()
|
||||
if token:
|
||||
command["token"] = token
|
||||
|
||||
self.sendCommand("playback/playMedia", **command)
|
||||
|
||||
def setParameters(self, volume=None, shuffle=None, repeat=None, mtype=DEFAULT_MTYPE):
|
||||
""" Set multiple playback parameters at once.
|
||||
|
||||
Parameters:
|
||||
volume (int): Volume level (0-100; optional).
|
||||
shuffle (int): Shuffle mode (0=off, 1=on; optional).
|
||||
repeat (int): Repeat mode (0=off, 1=repeatone, 2=repeatall; optional).
|
||||
mtype (str): Media type to take action against (optional music, photo, video).
|
||||
"""
|
||||
params = {}
|
||||
if repeat is not None:
|
||||
params['repeat'] = repeat
|
||||
if shuffle is not None:
|
||||
params['shuffle'] = shuffle
|
||||
if volume is not None:
|
||||
params['volume'] = volume
|
||||
if mtype is not None:
|
||||
params['type'] = mtype
|
||||
self.sendCommand('playback/setParameters', **params)
|
||||
|
||||
def setStreams(self, audioStreamID=None, subtitleStreamID=None, videoStreamID=None, mtype=DEFAULT_MTYPE):
|
||||
""" Select multiple playback streams at once.
|
||||
|
||||
Parameters:
|
||||
audioStreamID (str): ID of the audio stream from the media object.
|
||||
subtitleStreamID (str): ID of the subtitle stream from the media object.
|
||||
videoStreamID (str): ID of the video stream from the media object.
|
||||
mtype (str): Media type to take action against (optional music, photo, video).
|
||||
"""
|
||||
params = {}
|
||||
if audioStreamID is not None:
|
||||
params['audioStreamID'] = audioStreamID
|
||||
if subtitleStreamID is not None:
|
||||
params['subtitleStreamID'] = subtitleStreamID
|
||||
if videoStreamID is not None:
|
||||
params['videoStreamID'] = videoStreamID
|
||||
if mtype is not None:
|
||||
params['type'] = mtype
|
||||
self.sendCommand('playback/setStreams', **params)
|
||||
|
||||
# -------------------
|
||||
# Timeline Commands
|
||||
def timelines(self, wait=0):
|
||||
"""Poll the client's timelines, create, and return timeline objects.
|
||||
Some clients may not always respond to timeline requests, believe this
|
||||
to be a Plex bug.
|
||||
"""
|
||||
t = time.time()
|
||||
if t - self._timeline_cache_timestamp > 1:
|
||||
self._timeline_cache_timestamp = t
|
||||
timelines = self.sendCommand(ClientTimeline.key, wait=wait) or []
|
||||
self._timeline_cache = [ClientTimeline(self, data) for data in timelines]
|
||||
|
||||
return self._timeline_cache
|
||||
|
||||
@property
|
||||
def timeline(self):
|
||||
"""Returns the active timeline object."""
|
||||
return next((x for x in self.timelines() if x.state != 'stopped'), None)
|
||||
|
||||
def isPlayingMedia(self, includePaused=True):
|
||||
"""Returns True if any media is currently playing.
|
||||
|
||||
Parameters:
|
||||
includePaused (bool): Set True to treat currently paused items
|
||||
as playing (optional; default True).
|
||||
"""
|
||||
state = getattr(self.timeline, "state", None)
|
||||
return bool(state == 'playing' or (includePaused and state == 'paused'))
|
||||
|
||||
|
||||
class ClientTimeline(PlexObject):
|
||||
"""Get the timeline's attributes."""
|
||||
|
||||
key = 'timeline/poll'
|
||||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
self.address = data.attrib.get('address')
|
||||
self.audioStreamId = utils.cast(int, data.attrib.get('audioStreamId'))
|
||||
self.autoPlay = utils.cast(bool, data.attrib.get('autoPlay'))
|
||||
self.containerKey = data.attrib.get('containerKey')
|
||||
self.controllable = data.attrib.get('controllable')
|
||||
self.duration = utils.cast(int, data.attrib.get('duration'))
|
||||
self.itemType = data.attrib.get('itemType')
|
||||
self.key = data.attrib.get('key')
|
||||
self.location = data.attrib.get('location')
|
||||
self.machineIdentifier = data.attrib.get('machineIdentifier')
|
||||
self.partCount = utils.cast(int, data.attrib.get('partCount'))
|
||||
self.partIndex = utils.cast(int, data.attrib.get('partIndex'))
|
||||
self.playQueueID = utils.cast(int, data.attrib.get('playQueueID'))
|
||||
self.playQueueItemID = utils.cast(int, data.attrib.get('playQueueItemID'))
|
||||
self.playQueueVersion = utils.cast(int, data.attrib.get('playQueueVersion'))
|
||||
self.port = utils.cast(int, data.attrib.get('port'))
|
||||
self.protocol = data.attrib.get('protocol')
|
||||
self.providerIdentifier = data.attrib.get('providerIdentifier')
|
||||
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
|
||||
self.repeat = utils.cast(int, data.attrib.get('repeat'))
|
||||
self.seekRange = data.attrib.get('seekRange')
|
||||
self.shuffle = utils.cast(bool, data.attrib.get('shuffle'))
|
||||
self.state = data.attrib.get('state')
|
||||
self.subtitleColor = data.attrib.get('subtitleColor')
|
||||
self.subtitlePosition = data.attrib.get('subtitlePosition')
|
||||
self.subtitleSize = utils.cast(int, data.attrib.get('subtitleSize'))
|
||||
self.time = utils.cast(int, data.attrib.get('time'))
|
||||
self.type = data.attrib.get('type')
|
||||
self.volume = utils.cast(int, data.attrib.get('volume'))
|
@ -0,0 +1,579 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from plexapi import media, utils
|
||||
from plexapi.base import PlexPartialObject
|
||||
from plexapi.exceptions import BadRequest, NotFound, Unsupported
|
||||
from plexapi.library import LibrarySection, ManagedHub
|
||||
from plexapi.mixins import (
|
||||
AdvancedSettingsMixin, SmartFilterMixin, HubsMixin, RatingMixin,
|
||||
ArtMixin, PosterMixin, ThemeMixin,
|
||||
CollectionEditMixins
|
||||
)
|
||||
from plexapi.utils import deprecated
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Collection(
|
||||
PlexPartialObject,
|
||||
AdvancedSettingsMixin, SmartFilterMixin, HubsMixin, RatingMixin,
|
||||
ArtMixin, PosterMixin, ThemeMixin,
|
||||
CollectionEditMixins
|
||||
):
|
||||
""" Represents a single Collection.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Directory'
|
||||
TYPE (str): 'collection'
|
||||
addedAt (datetime): Datetime the collection was added to the library.
|
||||
art (str): URL to artwork image (/library/metadata/<ratingKey>/art/<artid>).
|
||||
artBlurHash (str): BlurHash string for artwork image.
|
||||
audienceRating (float): Audience rating.
|
||||
childCount (int): Number of items in the collection.
|
||||
collectionFilterBasedOnUser (int): Which user's activity is used for the collection filtering.
|
||||
collectionMode (int): How the items in the collection are displayed.
|
||||
collectionPublished (bool): True if the collection is published to the Plex homepage.
|
||||
collectionSort (int): How to sort the items in the collection.
|
||||
content (str): The filter URI string for smart collections.
|
||||
contentRating (str) Content rating (PG-13; NR; TV-G).
|
||||
fields (List<:class:`~plexapi.media.Field`>): List of field objects.
|
||||
guid (str): Plex GUID for the collection (collection://XXXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXX).
|
||||
images (List<:class:`~plexapi.media.Image`>): List of image objects.
|
||||
index (int): Plex index number for the collection.
|
||||
key (str): API URL (/library/metadata/<ratingkey>).
|
||||
labels (List<:class:`~plexapi.media.Label`>): List of label objects.
|
||||
lastRatedAt (datetime): Datetime the collection was last rated.
|
||||
librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID.
|
||||
librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key.
|
||||
librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title.
|
||||
maxYear (int): Maximum year for the items in the collection.
|
||||
minYear (int): Minimum year for the items in the collection.
|
||||
rating (float): Collection rating (7.9; 9.8; 8.1).
|
||||
ratingCount (int): The number of ratings.
|
||||
ratingKey (int): Unique key identifying the collection.
|
||||
smart (bool): True if the collection is a smart collection.
|
||||
subtype (str): Media type of the items in the collection (movie, show, artist, or album).
|
||||
summary (str): Summary of the collection.
|
||||
theme (str): URL to theme resource (/library/metadata/<ratingkey>/theme/<themeid>).
|
||||
thumb (str): URL to thumbnail image (/library/metadata/<ratingKey>/thumb/<thumbid>).
|
||||
thumbBlurHash (str): BlurHash string for thumbnail image.
|
||||
title (str): Name of the collection.
|
||||
titleSort (str): Title to use when sorting (defaults to title).
|
||||
type (str): 'collection'
|
||||
ultraBlurColors (:class:`~plexapi.media.UltraBlurColors`): Ultra blur color object.
|
||||
updatedAt (datetime): Datetime the collection was updated.
|
||||
userRating (float): Rating of the collection (0.0 - 10.0) equaling (0 stars - 5 stars).
|
||||
"""
|
||||
TAG = 'Directory'
|
||||
TYPE = 'collection'
|
||||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
|
||||
self.art = data.attrib.get('art')
|
||||
self.artBlurHash = data.attrib.get('artBlurHash')
|
||||
self.audienceRating = utils.cast(float, data.attrib.get('audienceRating'))
|
||||
self.childCount = utils.cast(int, data.attrib.get('childCount'))
|
||||
self.collectionFilterBasedOnUser = utils.cast(int, data.attrib.get('collectionFilterBasedOnUser', '0'))
|
||||
self.collectionMode = utils.cast(int, data.attrib.get('collectionMode', '-1'))
|
||||
self.collectionPublished = utils.cast(bool, data.attrib.get('collectionPublished', '0'))
|
||||
self.collectionSort = utils.cast(int, data.attrib.get('collectionSort', '0'))
|
||||
self.content = data.attrib.get('content')
|
||||
self.contentRating = data.attrib.get('contentRating')
|
||||
self.fields = self.findItems(data, media.Field)
|
||||
self.guid = data.attrib.get('guid')
|
||||
self.images = self.findItems(data, media.Image)
|
||||
self.index = utils.cast(int, data.attrib.get('index'))
|
||||
self.key = data.attrib.get('key', '').replace('/children', '') # FIX_BUG_50
|
||||
self.labels = self.findItems(data, media.Label)
|
||||
self.lastRatedAt = utils.toDatetime(data.attrib.get('lastRatedAt'))
|
||||
self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID'))
|
||||
self.librarySectionKey = data.attrib.get('librarySectionKey')
|
||||
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
|
||||
self.maxYear = utils.cast(int, data.attrib.get('maxYear'))
|
||||
self.minYear = utils.cast(int, data.attrib.get('minYear'))
|
||||
self.rating = utils.cast(float, data.attrib.get('rating'))
|
||||
self.ratingCount = utils.cast(int, data.attrib.get('ratingCount'))
|
||||
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
|
||||
self.smart = utils.cast(bool, data.attrib.get('smart', '0'))
|
||||
self.subtype = data.attrib.get('subtype')
|
||||
self.summary = data.attrib.get('summary')
|
||||
self.theme = data.attrib.get('theme')
|
||||
self.thumb = data.attrib.get('thumb')
|
||||
self.thumbBlurHash = data.attrib.get('thumbBlurHash')
|
||||
self.title = data.attrib.get('title')
|
||||
self.titleSort = data.attrib.get('titleSort', self.title)
|
||||
self.type = data.attrib.get('type')
|
||||
self.ultraBlurColors = self.findItem(data, media.UltraBlurColors)
|
||||
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
|
||||
self.userRating = utils.cast(float, data.attrib.get('userRating'))
|
||||
self._items = None # cache for self.items
|
||||
self._section = None # cache for self.section
|
||||
self._filters = None # cache for self.filters
|
||||
|
||||
def __len__(self): # pragma: no cover
|
||||
return len(self.items())
|
||||
|
||||
def __iter__(self): # pragma: no cover
|
||||
for item in self.items():
|
||||
yield item
|
||||
|
||||
def __contains__(self, other): # pragma: no cover
|
||||
return any(i.key == other.key for i in self.items())
|
||||
|
||||
def __getitem__(self, key): # pragma: no cover
|
||||
return self.items()[key]
|
||||
|
||||
@property
|
||||
def listType(self):
|
||||
""" Returns the listType for the collection. """
|
||||
if self.isVideo:
|
||||
return 'video'
|
||||
elif self.isAudio:
|
||||
return 'audio'
|
||||
elif self.isPhoto:
|
||||
return 'photo'
|
||||
else:
|
||||
raise Unsupported('Unexpected collection type')
|
||||
|
||||
@property
|
||||
def metadataType(self):
|
||||
""" Returns the type of metadata in the collection. """
|
||||
return self.subtype
|
||||
|
||||
@property
|
||||
def isVideo(self):
|
||||
""" Returns True if this is a video collection. """
|
||||
return self.subtype in {'movie', 'show', 'season', 'episode'}
|
||||
|
||||
@property
|
||||
def isAudio(self):
|
||||
""" Returns True if this is an audio collection. """
|
||||
return self.subtype in {'artist', 'album', 'track'}
|
||||
|
||||
@property
|
||||
def isPhoto(self):
|
||||
""" Returns True if this is a photo collection. """
|
||||
return self.subtype in {'photoalbum', 'photo'}
|
||||
|
||||
@property
|
||||
@deprecated('use "items" instead', stacklevel=3)
|
||||
def children(self):
|
||||
return self.items()
|
||||
|
||||
def filters(self):
|
||||
""" Returns the search filter dict for smart collection.
|
||||
The filter dict be passed back into :func:`~plexapi.library.LibrarySection.search`
|
||||
to get the list of items.
|
||||
"""
|
||||
if self.smart and self._filters is None:
|
||||
self._filters = self._parseFilters(self.content)
|
||||
return self._filters
|
||||
|
||||
def section(self):
|
||||
""" Returns the :class:`~plexapi.library.LibrarySection` this collection belongs to.
|
||||
"""
|
||||
if self._section is None:
|
||||
self._section = super(Collection, self).section()
|
||||
return self._section
|
||||
|
||||
def item(self, title):
|
||||
""" Returns the item in the collection that matches the specified title.
|
||||
|
||||
Parameters:
|
||||
title (str): Title of the item to return.
|
||||
|
||||
Raises:
|
||||
:class:`plexapi.exceptions.NotFound`: When the item is not found in the collection.
|
||||
"""
|
||||
for item in self.items():
|
||||
if item.title.lower() == title.lower():
|
||||
return item
|
||||
raise NotFound(f'Item with title "{title}" not found in the collection')
|
||||
|
||||
def items(self):
|
||||
""" Returns a list of all items in the collection. """
|
||||
if self._items is None:
|
||||
key = f'{self.key}/children'
|
||||
items = self.fetchItems(key)
|
||||
self._items = items
|
||||
return self._items
|
||||
|
||||
def visibility(self):
|
||||
""" Returns the :class:`~plexapi.library.ManagedHub` for this collection. """
|
||||
key = f'/hubs/sections/{self.librarySectionID}/manage?metadataItemId={self.ratingKey}'
|
||||
data = self._server.query(key)
|
||||
hub = self.findItem(data, cls=ManagedHub)
|
||||
if hub is None:
|
||||
hub = ManagedHub(self._server, data, parent=self)
|
||||
hub.identifier = f'custom.collection.{self.librarySectionID}.{self.ratingKey}'
|
||||
hub.title = self.title
|
||||
hub._promoted = False
|
||||
return hub
|
||||
|
||||
def get(self, title):
|
||||
""" Alias to :func:`~plexapi.library.Collection.item`. """
|
||||
return self.item(title)
|
||||
|
||||
def filterUserUpdate(self, user=None):
|
||||
""" Update the collection filtering user advanced setting.
|
||||
|
||||
Parameters:
|
||||
user (str): One of the following values:
|
||||
"admin" (Always the server admin user),
|
||||
"user" (User currently viewing the content)
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
collection.updateMode(user="user")
|
||||
|
||||
"""
|
||||
if not self.smart:
|
||||
raise BadRequest('Cannot change collection filtering user for a non-smart collection.')
|
||||
|
||||
user_dict = {
|
||||
'admin': 0,
|
||||
'user': 1
|
||||
}
|
||||
key = user_dict.get(user)
|
||||
if key is None:
|
||||
raise BadRequest(f'Unknown collection filtering user: {user}. Options {list(user_dict)}')
|
||||
return self.editAdvanced(collectionFilterBasedOnUser=key)
|
||||
|
||||
def modeUpdate(self, mode=None):
|
||||
""" Update the collection mode advanced setting.
|
||||
|
||||
Parameters:
|
||||
mode (str): One of the following values:
|
||||
"default" (Library default),
|
||||
"hide" (Hide Collection),
|
||||
"hideItems" (Hide Items in this Collection),
|
||||
"showItems" (Show this Collection and its Items)
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
collection.updateMode(mode="hide")
|
||||
|
||||
"""
|
||||
mode_dict = {
|
||||
'default': -1,
|
||||
'hide': 0,
|
||||
'hideItems': 1,
|
||||
'showItems': 2
|
||||
}
|
||||
key = mode_dict.get(mode)
|
||||
if key is None:
|
||||
raise BadRequest(f'Unknown collection mode: {mode}. Options {list(mode_dict)}')
|
||||
return self.editAdvanced(collectionMode=key)
|
||||
|
||||
def sortUpdate(self, sort=None):
|
||||
""" Update the collection order advanced setting.
|
||||
|
||||
Parameters:
|
||||
sort (str): One of the following values:
|
||||
"release" (Order Collection by release dates),
|
||||
"alpha" (Order Collection alphabetically),
|
||||
"custom" (Custom collection order)
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
collection.sortUpdate(sort="alpha")
|
||||
|
||||
"""
|
||||
if self.smart:
|
||||
raise BadRequest('Cannot change collection order for a smart collection.')
|
||||
|
||||
sort_dict = {
|
||||
'release': 0,
|
||||
'alpha': 1,
|
||||
'custom': 2
|
||||
}
|
||||
key = sort_dict.get(sort)
|
||||
if key is None:
|
||||
raise BadRequest(f'Unknown sort dir: {sort}. Options: {list(sort_dict)}')
|
||||
return self.editAdvanced(collectionSort=key)
|
||||
|
||||
def addItems(self, items):
|
||||
""" Add items to the collection.
|
||||
|
||||
Parameters:
|
||||
items (List): List of :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`,
|
||||
or :class:`~plexapi.photo.Photo` objects to be added to the collection.
|
||||
|
||||
Raises:
|
||||
:class:`plexapi.exceptions.BadRequest`: When trying to add items to a smart collection.
|
||||
"""
|
||||
if self.smart:
|
||||
raise BadRequest('Cannot add items to a smart collection.')
|
||||
|
||||
if items and not isinstance(items, (list, tuple)):
|
||||
items = [items]
|
||||
|
||||
ratingKeys = []
|
||||
for item in items:
|
||||
if item.type != self.subtype: # pragma: no cover
|
||||
raise BadRequest(f'Can not mix media types when building a collection: {self.subtype} and {item.type}')
|
||||
ratingKeys.append(str(item.ratingKey))
|
||||
|
||||
ratingKeys = ','.join(ratingKeys)
|
||||
uri = f'{self._server._uriRoot()}/library/metadata/{ratingKeys}'
|
||||
|
||||
args = {'uri': uri}
|
||||
key = f"{self.key}/items{utils.joinArgs(args)}"
|
||||
self._server.query(key, method=self._server._session.put)
|
||||
return self
|
||||
|
||||
def removeItems(self, items):
|
||||
""" Remove items from the collection.
|
||||
|
||||
Parameters:
|
||||
items (List): List of :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`,
|
||||
or :class:`~plexapi.photo.Photo` objects to be removed from the collection.
|
||||
|
||||
Raises:
|
||||
:class:`plexapi.exceptions.BadRequest`: When trying to remove items from a smart collection.
|
||||
"""
|
||||
if self.smart:
|
||||
raise BadRequest('Cannot remove items from a smart collection.')
|
||||
|
||||
if items and not isinstance(items, (list, tuple)):
|
||||
items = [items]
|
||||
|
||||
for item in items:
|
||||
key = f'{self.key}/items/{item.ratingKey}'
|
||||
self._server.query(key, method=self._server._session.delete)
|
||||
return self
|
||||
|
||||
def moveItem(self, item, after=None):
|
||||
""" Move an item to a new position in the collection.
|
||||
|
||||
Parameters:
|
||||
item (obj): :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`,
|
||||
or :class:`~plexapi.photo.Photo` object to be moved in the collection.
|
||||
after (obj): :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`,
|
||||
or :class:`~plexapi.photo.Photo` object to move the item after in the collection.
|
||||
|
||||
Raises:
|
||||
:class:`plexapi.exceptions.BadRequest`: When trying to move items in a smart collection.
|
||||
"""
|
||||
if self.smart:
|
||||
raise BadRequest('Cannot move items in a smart collection.')
|
||||
|
||||
key = f'{self.key}/items/{item.ratingKey}/move'
|
||||
|
||||
if after:
|
||||
key += f'?after={after.ratingKey}'
|
||||
|
||||
self._server.query(key, method=self._server._session.put)
|
||||
return self
|
||||
|
||||
def updateFilters(self, libtype=None, limit=None, sort=None, filters=None, **kwargs):
|
||||
""" Update the filters for a smart collection.
|
||||
|
||||
Parameters:
|
||||
libtype (str): The specific type of content to filter
|
||||
(movie, show, season, episode, artist, album, track, photoalbum, photo, collection).
|
||||
limit (int): Limit the number of items in the collection.
|
||||
sort (str or list, optional): A string of comma separated sort fields
|
||||
or a list of sort fields in the format ``column:dir``.
|
||||
See :func:`~plexapi.library.LibrarySection.search` for more info.
|
||||
filters (dict): A dictionary of advanced filters.
|
||||
See :func:`~plexapi.library.LibrarySection.search` for more info.
|
||||
**kwargs (dict): Additional custom filters to apply to the search results.
|
||||
See :func:`~plexapi.library.LibrarySection.search` for more info.
|
||||
|
||||
Raises:
|
||||
:class:`plexapi.exceptions.BadRequest`: When trying update filters for a regular collection.
|
||||
"""
|
||||
if not self.smart:
|
||||
raise BadRequest('Cannot update filters for a regular collection.')
|
||||
|
||||
section = self.section()
|
||||
searchKey = section._buildSearchKey(
|
||||
sort=sort, libtype=libtype, limit=limit, filters=filters, **kwargs)
|
||||
uri = f'{self._server._uriRoot()}{searchKey}'
|
||||
|
||||
args = {'uri': uri}
|
||||
key = f"{self.key}/items{utils.joinArgs(args)}"
|
||||
self._server.query(key, method=self._server._session.put)
|
||||
return self
|
||||
|
||||
@deprecated('use editTitle, editSortTitle, editContentRating, and editSummary instead')
|
||||
def edit(self, title=None, titleSort=None, contentRating=None, summary=None, **kwargs):
|
||||
""" Edit the collection.
|
||||
|
||||
Parameters:
|
||||
title (str, optional): The title of the collection.
|
||||
titleSort (str, optional): The sort title of the collection.
|
||||
contentRating (str, optional): The summary of the collection.
|
||||
summary (str, optional): The summary of the collection.
|
||||
"""
|
||||
args = {}
|
||||
if title is not None:
|
||||
args['title.value'] = title
|
||||
args['title.locked'] = 1
|
||||
if titleSort is not None:
|
||||
args['titleSort.value'] = titleSort
|
||||
args['titleSort.locked'] = 1
|
||||
if contentRating is not None:
|
||||
args['contentRating.value'] = contentRating
|
||||
args['contentRating.locked'] = 1
|
||||
if summary is not None:
|
||||
args['summary.value'] = summary
|
||||
args['summary.locked'] = 1
|
||||
|
||||
args.update(kwargs)
|
||||
self._edit(**args)
|
||||
|
||||
def delete(self):
|
||||
""" Delete the collection. """
|
||||
super(Collection, self).delete()
|
||||
|
||||
@classmethod
|
||||
def _create(cls, server, title, section, items):
|
||||
""" Create a regular collection. """
|
||||
if not items:
|
||||
raise BadRequest('Must include items to add when creating new collection.')
|
||||
|
||||
if not isinstance(section, LibrarySection):
|
||||
section = server.library.section(section)
|
||||
|
||||
if items and not isinstance(items, (list, tuple)):
|
||||
items = [items]
|
||||
|
||||
itemType = items[0].type
|
||||
ratingKeys = []
|
||||
for item in items:
|
||||
if item.type != itemType: # pragma: no cover
|
||||
raise BadRequest('Can not mix media types when building a collection.')
|
||||
ratingKeys.append(str(item.ratingKey))
|
||||
|
||||
ratingKeys = ','.join(ratingKeys)
|
||||
uri = f'{server._uriRoot()}/library/metadata/{ratingKeys}'
|
||||
|
||||
args = {'uri': uri, 'type': utils.searchType(itemType), 'title': title, 'smart': 0, 'sectionId': section.key}
|
||||
key = f"/library/collections{utils.joinArgs(args)}"
|
||||
data = server.query(key, method=server._session.post)[0]
|
||||
return cls(server, data, initpath=key)
|
||||
|
||||
@classmethod
|
||||
def _createSmart(cls, server, title, section, limit=None, libtype=None, sort=None, filters=None, **kwargs):
|
||||
""" Create a smart collection. """
|
||||
if not isinstance(section, LibrarySection):
|
||||
section = server.library.section(section)
|
||||
|
||||
libtype = libtype or section.TYPE
|
||||
|
||||
searchKey = section._buildSearchKey(
|
||||
sort=sort, libtype=libtype, limit=limit, filters=filters, **kwargs)
|
||||
uri = f'{server._uriRoot()}{searchKey}'
|
||||
|
||||
args = {'uri': uri, 'type': utils.searchType(libtype), 'title': title, 'smart': 1, 'sectionId': section.key}
|
||||
key = f"/library/collections{utils.joinArgs(args)}"
|
||||
data = server.query(key, method=server._session.post)[0]
|
||||
return cls(server, data, initpath=key)
|
||||
|
||||
@classmethod
|
||||
def create(cls, server, title, section, items=None, smart=False, limit=None,
|
||||
libtype=None, sort=None, filters=None, **kwargs):
|
||||
""" Create a collection.
|
||||
|
||||
Parameters:
|
||||
server (:class:`~plexapi.server.PlexServer`): Server to create the collection on.
|
||||
title (str): Title of the collection.
|
||||
section (:class:`~plexapi.library.LibrarySection`, str): The library section to create the collection in.
|
||||
items (List): Regular collections only, list of :class:`~plexapi.audio.Audio`,
|
||||
:class:`~plexapi.video.Video`, or :class:`~plexapi.photo.Photo` objects to be added to the collection.
|
||||
smart (bool): True to create a smart collection. Default False.
|
||||
limit (int): Smart collections only, limit the number of items in the collection.
|
||||
libtype (str): Smart collections only, the specific type of content to filter
|
||||
(movie, show, season, episode, artist, album, track, photoalbum, photo).
|
||||
sort (str or list, optional): Smart collections only, a string of comma separated sort fields
|
||||
or a list of sort fields in the format ``column:dir``.
|
||||
See :func:`~plexapi.library.LibrarySection.search` for more info.
|
||||
filters (dict): Smart collections only, a dictionary of advanced filters.
|
||||
See :func:`~plexapi.library.LibrarySection.search` for more info.
|
||||
**kwargs (dict): Smart collections only, additional custom filters to apply to the
|
||||
search results. See :func:`~plexapi.library.LibrarySection.search` for more info.
|
||||
|
||||
Raises:
|
||||
:class:`plexapi.exceptions.BadRequest`: When no items are included to create the collection.
|
||||
:class:`plexapi.exceptions.BadRequest`: When mixing media types in the collection.
|
||||
|
||||
Returns:
|
||||
:class:`~plexapi.collection.Collection`: A new instance of the created Collection.
|
||||
"""
|
||||
if smart:
|
||||
if items:
|
||||
raise BadRequest('Cannot create a smart collection with items.')
|
||||
return cls._createSmart(server, title, section, limit, libtype, sort, filters, **kwargs)
|
||||
else:
|
||||
return cls._create(server, title, section, items)
|
||||
|
||||
def sync(self, videoQuality=None, photoResolution=None, audioBitrate=None, client=None, clientId=None, limit=None,
|
||||
unwatched=False, title=None):
|
||||
""" Add the collection as sync item for the specified device.
|
||||
See :func:`~plexapi.myplex.MyPlexAccount.sync` for possible exceptions.
|
||||
|
||||
Parameters:
|
||||
videoQuality (int): idx of quality of the video, one of VIDEO_QUALITY_* values defined in
|
||||
:mod:`~plexapi.sync` module. Used only when collection contains video.
|
||||
photoResolution (str): maximum allowed resolution for synchronized photos, see PHOTO_QUALITY_* values in
|
||||
the module :mod:`~plexapi.sync`. Used only when collection contains photos.
|
||||
audioBitrate (int): maximum bitrate for synchronized music, better use one of MUSIC_BITRATE_* values
|
||||
from the module :mod:`~plexapi.sync`. Used only when collection contains audio.
|
||||
client (:class:`~plexapi.myplex.MyPlexDevice`): sync destination, see
|
||||
:func:`~plexapi.myplex.MyPlexAccount.sync`.
|
||||
clientId (str): sync destination, see :func:`~plexapi.myplex.MyPlexAccount.sync`.
|
||||
limit (int): maximum count of items to sync, unlimited if `None`.
|
||||
unwatched (bool): if `True` watched videos wouldn't be synced.
|
||||
title (str): descriptive title for the new :class:`~plexapi.sync.SyncItem`, if empty the value would be
|
||||
generated from metadata of current photo.
|
||||
|
||||
Raises:
|
||||
:exc:`~plexapi.exceptions.BadRequest`: When collection is not allowed to sync.
|
||||
:exc:`~plexapi.exceptions.Unsupported`: When collection content is unsupported.
|
||||
|
||||
Returns:
|
||||
:class:`~plexapi.sync.SyncItem`: A new instance of the created sync item.
|
||||
"""
|
||||
if not self.section().allowSync:
|
||||
raise BadRequest('The collection is not allowed to sync')
|
||||
|
||||
from plexapi.sync import SyncItem, Policy, MediaSettings
|
||||
|
||||
myplex = self._server.myPlexAccount()
|
||||
sync_item = SyncItem(self._server, None)
|
||||
sync_item.title = title if title else self.title
|
||||
sync_item.rootTitle = self.title
|
||||
sync_item.contentType = self.listType
|
||||
sync_item.metadataType = self.metadataType
|
||||
sync_item.machineIdentifier = self._server.machineIdentifier
|
||||
|
||||
key = quote_plus(f'{self.key}/children?excludeAllLeaves=1')
|
||||
sync_item.location = f'library:///directory/{key}'
|
||||
sync_item.policy = Policy.create(limit, unwatched)
|
||||
|
||||
if self.isVideo:
|
||||
sync_item.mediaSettings = MediaSettings.createVideo(videoQuality)
|
||||
elif self.isAudio:
|
||||
sync_item.mediaSettings = MediaSettings.createMusic(audioBitrate)
|
||||
elif self.isPhoto:
|
||||
sync_item.mediaSettings = MediaSettings.createPhoto(photoResolution)
|
||||
else:
|
||||
raise Unsupported('Unsupported collection content')
|
||||
|
||||
return myplex.sync(sync_item, client=client, clientId=clientId)
|
||||
|
||||
@property
|
||||
def metadataDirectory(self):
|
||||
""" Returns the Plex Media Server data directory where the metadata is stored. """
|
||||
guid_hash = utils.sha1hash(self.guid)
|
||||
return str(Path('Metadata') / 'Collections' / guid_hash[0] / f'{guid_hash[1:]}.bundle')
|
@ -0,0 +1,69 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from configparser import ConfigParser
|
||||
|
||||
from plexapi import utils
|
||||
|
||||
|
||||
class PlexConfig(ConfigParser):
|
||||
""" PlexAPI configuration object. Settings are stored in an INI file within the
|
||||
user's home directory and can be overridden after importing plexapi by simply
|
||||
setting the value. See the documentation section 'Configuration' for more
|
||||
details on available options.
|
||||
|
||||
Parameters:
|
||||
path (str): Path of the configuration file to load.
|
||||
"""
|
||||
|
||||
def __init__(self, path):
|
||||
ConfigParser.__init__(self)
|
||||
self.read(path)
|
||||
self.data = self._asDict()
|
||||
|
||||
def 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:
|
||||
# First: check environment variable is set
|
||||
envkey = f"PLEXAPI_{key.upper().replace('.', '_')}"
|
||||
value = os.environ.get(envkey)
|
||||
if value is None:
|
||||
# Second: check the config file has attr
|
||||
section, name = key.lower().split('.')
|
||||
value = self.data.get(section, {}).get(name, default)
|
||||
return utils.cast(cast, value) if cast else value
|
||||
except: # noqa: E722
|
||||
return default
|
||||
|
||||
def _asDict(self):
|
||||
""" Returns all configuration values as a dictionary. """
|
||||
config = defaultdict(dict)
|
||||
for section in self._sections:
|
||||
for name, value in self._sections[section].items():
|
||||
if name != '__name__':
|
||||
config[section.lower()][name.lower()] = value
|
||||
return dict(config)
|
||||
|
||||
|
||||
def reset_base_headers():
|
||||
""" Convenience function returns a dict of all base X-Plex-* headers for session requests. """
|
||||
import plexapi
|
||||
return {
|
||||
'X-Plex-Platform': plexapi.X_PLEX_PLATFORM,
|
||||
'X-Plex-Platform-Version': plexapi.X_PLEX_PLATFORM_VERSION,
|
||||
'X-Plex-Provides': plexapi.X_PLEX_PROVIDES,
|
||||
'X-Plex-Product': plexapi.X_PLEX_PRODUCT,
|
||||
'X-Plex-Version': plexapi.X_PLEX_VERSION,
|
||||
'X-Plex-Device': plexapi.X_PLEX_DEVICE,
|
||||
'X-Plex-Device-Name': plexapi.X_PLEX_DEVICE_NAME,
|
||||
'X-Plex-Client-Identifier': plexapi.X_PLEX_IDENTIFIER,
|
||||
'X-Plex-Language': plexapi.X_PLEX_LANGUAGE,
|
||||
'X-Plex-Sync-Version': '2',
|
||||
'X-Plex-Features': 'external-media',
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Constants used by plexapi."""
|
||||
|
||||
# Library version
|
||||
MAJOR_VERSION = 4
|
||||
MINOR_VERSION = 16
|
||||
PATCH_VERSION = 1
|
||||
__short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
||||
__version__ = f"{__short_version__}.{PATCH_VERSION}"
|
@ -0,0 +1,36 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
|
||||
class PlexApiException(Exception):
|
||||
""" Base class for all PlexAPI exceptions. """
|
||||
pass
|
||||
|
||||
|
||||
class BadRequest(PlexApiException):
|
||||
""" An invalid request, generally a user error. """
|
||||
pass
|
||||
|
||||
|
||||
class NotFound(PlexApiException):
|
||||
""" Request media item or device is not found. """
|
||||
pass
|
||||
|
||||
|
||||
class UnknownType(PlexApiException):
|
||||
""" Unknown library type. """
|
||||
pass
|
||||
|
||||
|
||||
class Unsupported(PlexApiException):
|
||||
""" Unsupported client request. """
|
||||
pass
|
||||
|
||||
|
||||
class Unauthorized(BadRequest):
|
||||
""" Invalid username/password or token. """
|
||||
pass
|
||||
|
||||
|
||||
class TwoFactorRequired(Unauthorized):
|
||||
""" Two factor authentication required. """
|
||||
pass
|
@ -0,0 +1,151 @@
|
||||
"""
|
||||
Support for discovery using GDM (Good Day Mate), multicast protocol by Plex.
|
||||
|
||||
# Licensed Apache 2.0
|
||||
# From https://github.com/home-assistant/netdisco/netdisco/gdm.py
|
||||
|
||||
Inspired by:
|
||||
hippojay's plexGDM: https://github.com/hippojay/script.plexbmc.helper/resources/lib/plexgdm.py
|
||||
iBaa's PlexConnect: https://github.com/iBaa/PlexConnect/PlexAPI.py
|
||||
"""
|
||||
import socket
|
||||
import struct
|
||||
|
||||
|
||||
class GDM:
|
||||
"""Base class to discover GDM services.
|
||||
|
||||
Attributes:
|
||||
entries (List<dict>): List of server and/or client data discovered.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.entries = []
|
||||
|
||||
def scan(self, scan_for_clients=False):
|
||||
"""Scan the network."""
|
||||
self.update(scan_for_clients)
|
||||
|
||||
def all(self, scan_for_clients=False):
|
||||
"""Return all found entries.
|
||||
|
||||
Will scan for entries if not scanned recently.
|
||||
"""
|
||||
self.scan(scan_for_clients)
|
||||
return list(self.entries)
|
||||
|
||||
def find_by_content_type(self, value):
|
||||
"""Return a list of entries that match the content_type."""
|
||||
self.scan()
|
||||
return [entry for entry in self.entries
|
||||
if value in entry['data']['Content-Type']]
|
||||
|
||||
def find_by_data(self, values):
|
||||
"""Return a list of entries that match the search parameters."""
|
||||
self.scan()
|
||||
return [entry for entry in self.entries
|
||||
if all(item in entry['data'].items()
|
||||
for item in values.items())]
|
||||
|
||||
def update(self, scan_for_clients):
|
||||
"""Scan for new GDM services.
|
||||
|
||||
Examples of the dict list assigned to self.entries by this function:
|
||||
|
||||
Server:
|
||||
|
||||
[{'data': {
|
||||
'Content-Type': 'plex/media-server',
|
||||
'Host': '53f4b5b6023d41182fe88a99b0e714ba.plex.direct',
|
||||
'Name': 'myfirstplexserver',
|
||||
'Port': '32400',
|
||||
'Resource-Identifier': '646ab0aa8a01c543e94ba975f6fd6efadc36b7',
|
||||
'Updated-At': '1585769946',
|
||||
'Version': '1.18.8.2527-740d4c206',
|
||||
},
|
||||
'from': ('10.10.10.100', 32414)}]
|
||||
|
||||
Clients:
|
||||
|
||||
[{'data': {'Content-Type': 'plex/media-player',
|
||||
'Device-Class': 'stb',
|
||||
'Name': 'plexamp',
|
||||
'Port': '36000',
|
||||
'Product': 'Plexamp',
|
||||
'Protocol': 'plex',
|
||||
'Protocol-Capabilities': 'timeline,playback,playqueues,playqueues-creation',
|
||||
'Protocol-Version': '1',
|
||||
'Resource-Identifier': 'b6e57a3f-e0f8-494f-8884-f4b58501467e',
|
||||
'Version': '1.1.0',
|
||||
},
|
||||
'from': ('10.10.10.101', 32412)}]
|
||||
"""
|
||||
|
||||
gdm_msg = 'M-SEARCH * HTTP/1.0'.encode('ascii')
|
||||
gdm_timeout = 1
|
||||
|
||||
self.entries = []
|
||||
known_responses = []
|
||||
|
||||
# setup socket for discovery -> multicast message
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.settimeout(gdm_timeout)
|
||||
|
||||
# Set the time-to-live for messages for local network
|
||||
sock.setsockopt(socket.IPPROTO_IP,
|
||||
socket.IP_MULTICAST_TTL,
|
||||
struct.pack("B", gdm_timeout))
|
||||
|
||||
if scan_for_clients:
|
||||
# setup socket for broadcast to Plex clients
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||
gdm_ip = '255.255.255.255'
|
||||
gdm_port = 32412
|
||||
else:
|
||||
# setup socket for multicast to Plex server(s)
|
||||
gdm_ip = '239.0.0.250'
|
||||
gdm_port = 32414
|
||||
|
||||
try:
|
||||
# Send data to the multicast group
|
||||
sock.sendto(gdm_msg, (gdm_ip, gdm_port))
|
||||
|
||||
# Look for responses from all recipients
|
||||
while True:
|
||||
try:
|
||||
bdata, host = sock.recvfrom(1024)
|
||||
data = bdata.decode('utf-8')
|
||||
if '200 OK' in data.splitlines()[0]:
|
||||
ddata = {k: v.strip() for (k, v) in (
|
||||
line.split(':') for line in
|
||||
data.splitlines() if ':' in line)}
|
||||
identifier = ddata.get('Resource-Identifier')
|
||||
if identifier and identifier in known_responses:
|
||||
continue
|
||||
known_responses.append(identifier)
|
||||
self.entries.append({'data': ddata,
|
||||
'from': host})
|
||||
except socket.timeout:
|
||||
break
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
def main():
|
||||
"""Test GDM discovery."""
|
||||
from pprint import pprint
|
||||
|
||||
gdm = GDM()
|
||||
|
||||
pprint("Scanning GDM for servers...")
|
||||
gdm.scan()
|
||||
pprint(gdm.entries)
|
||||
|
||||
pprint("Scanning GDM for clients...")
|
||||
gdm.scan(scan_for_clients=True)
|
||||
pprint(gdm.entries)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,324 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from plexapi import media, utils, video
|
||||
from plexapi.base import Playable, PlexPartialObject, PlexSession
|
||||
from plexapi.exceptions import BadRequest
|
||||
from plexapi.mixins import (
|
||||
RatingMixin,
|
||||
ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin,
|
||||
PhotoalbumEditMixins, PhotoEditMixins
|
||||
)
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Photoalbum(
|
||||
PlexPartialObject,
|
||||
RatingMixin,
|
||||
ArtMixin, PosterMixin,
|
||||
PhotoalbumEditMixins
|
||||
):
|
||||
""" Represents a single Photoalbum (collection of photos).
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Directory'
|
||||
TYPE (str): 'photo'
|
||||
addedAt (datetime): Datetime the photo album was added to the library.
|
||||
art (str): URL to artwork image (/library/metadata/<ratingKey>/art/<artid>).
|
||||
composite (str): URL to composite image (/library/metadata/<ratingKey>/composite/<compositeid>)
|
||||
fields (List<:class:`~plexapi.media.Field`>): List of field objects.
|
||||
guid (str): Plex GUID for the photo album (local://229674).
|
||||
images (List<:class:`~plexapi.media.Image`>): List of image objects.
|
||||
index (sting): Plex index number for the photo album.
|
||||
key (str): API URL (/library/metadata/<ratingkey>).
|
||||
lastRatedAt (datetime): Datetime the photo album was last rated.
|
||||
librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID.
|
||||
librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key.
|
||||
librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title.
|
||||
listType (str): Hardcoded as 'photo' (useful for search filters).
|
||||
ratingKey (int): Unique key identifying the photo album.
|
||||
summary (str): Summary of the photoalbum.
|
||||
thumb (str): URL to thumbnail image (/library/metadata/<ratingKey>/thumb/<thumbid>).
|
||||
title (str): Name of the photo album. (Trip to Disney World)
|
||||
titleSort (str): Title to use when sorting (defaults to title).
|
||||
type (str): 'photo'
|
||||
updatedAt (datetime): Datetime the photo album was updated.
|
||||
userRating (float): Rating of the photo album (0.0 - 10.0) equaling (0 stars - 5 stars).
|
||||
"""
|
||||
TAG = 'Directory'
|
||||
TYPE = 'photo'
|
||||
_searchType = 'photoalbum'
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
|
||||
self.art = data.attrib.get('art')
|
||||
self.composite = data.attrib.get('composite')
|
||||
self.fields = self.findItems(data, media.Field)
|
||||
self.guid = data.attrib.get('guid')
|
||||
self.images = self.findItems(data, media.Image)
|
||||
self.index = utils.cast(int, data.attrib.get('index'))
|
||||
self.key = data.attrib.get('key', '').replace('/children', '') # FIX_BUG_50
|
||||
self.lastRatedAt = utils.toDatetime(data.attrib.get('lastRatedAt'))
|
||||
self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID'))
|
||||
self.librarySectionKey = data.attrib.get('librarySectionKey')
|
||||
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
|
||||
self.listType = 'photo'
|
||||
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
|
||||
self.summary = data.attrib.get('summary')
|
||||
self.thumb = data.attrib.get('thumb')
|
||||
self.title = data.attrib.get('title')
|
||||
self.titleSort = data.attrib.get('titleSort', self.title)
|
||||
self.type = data.attrib.get('type')
|
||||
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
|
||||
self.userRating = utils.cast(float, data.attrib.get('userRating'))
|
||||
|
||||
def album(self, title):
|
||||
""" Returns the :class:`~plexapi.photo.Photoalbum` that matches the specified title.
|
||||
|
||||
Parameters:
|
||||
title (str): Title of the photo album to return.
|
||||
"""
|
||||
key = f'{self.key}/children'
|
||||
return self.fetchItem(key, Photoalbum, title__iexact=title)
|
||||
|
||||
def albums(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.photo.Photoalbum` objects in the album. """
|
||||
key = f'{self.key}/children'
|
||||
return self.fetchItems(key, Photoalbum, **kwargs)
|
||||
|
||||
def photo(self, title):
|
||||
""" Returns the :class:`~plexapi.photo.Photo` that matches the specified title.
|
||||
|
||||
Parameters:
|
||||
title (str): Title of the photo to return.
|
||||
"""
|
||||
key = f'{self.key}/children'
|
||||
return self.fetchItem(key, Photo, title__iexact=title)
|
||||
|
||||
def photos(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.photo.Photo` objects in the album. """
|
||||
key = f'{self.key}/children'
|
||||
return self.fetchItems(key, Photo, **kwargs)
|
||||
|
||||
def clip(self, title):
|
||||
""" Returns the :class:`~plexapi.video.Clip` that matches the specified title.
|
||||
|
||||
Parameters:
|
||||
title (str): Title of the clip to return.
|
||||
"""
|
||||
key = f'{self.key}/children'
|
||||
return self.fetchItem(key, video.Clip, title__iexact=title)
|
||||
|
||||
def clips(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.video.Clip` objects in the album. """
|
||||
key = f'{self.key}/children'
|
||||
return self.fetchItems(key, video.Clip, **kwargs)
|
||||
|
||||
def get(self, title):
|
||||
""" Alias to :func:`~plexapi.photo.Photoalbum.photo`. """
|
||||
return self.episode(title)
|
||||
|
||||
def download(self, savepath=None, keep_original_name=False, subfolders=False):
|
||||
""" Download all photos and clips from the photo album. See :func:`~plexapi.base.Playable.download` for details.
|
||||
|
||||
Parameters:
|
||||
savepath (str): Defaults to current working dir.
|
||||
keep_original_name (bool): True to keep the original filename otherwise
|
||||
a friendlier filename is generated.
|
||||
subfolders (bool): True to separate photos/clips in to photo album folders.
|
||||
"""
|
||||
filepaths = []
|
||||
for album in self.albums():
|
||||
_savepath = os.path.join(savepath, album.title) if subfolders else savepath
|
||||
filepaths += album.download(_savepath, keep_original_name)
|
||||
for photo in self.photos() + self.clips():
|
||||
filepaths += photo.download(savepath, keep_original_name)
|
||||
return filepaths
|
||||
|
||||
def _getWebURL(self, base=None):
|
||||
""" Get the Plex Web URL with the correct parameters. """
|
||||
return self._server._buildWebURL(base=base, endpoint='details', key=self.key, legacy=1)
|
||||
|
||||
@property
|
||||
def metadataDirectory(self):
|
||||
""" Returns the Plex Media Server data directory where the metadata is stored. """
|
||||
guid_hash = utils.sha1hash(self.guid)
|
||||
return str(Path('Metadata') / 'Photos' / guid_hash[0] / f'{guid_hash[1:]}.bundle')
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Photo(
|
||||
PlexPartialObject, Playable,
|
||||
RatingMixin,
|
||||
ArtUrlMixin, PosterUrlMixin,
|
||||
PhotoEditMixins
|
||||
):
|
||||
""" Represents a single Photo.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Photo'
|
||||
TYPE (str): 'photo'
|
||||
addedAt (datetime): Datetime the photo was added to the library.
|
||||
createdAtAccuracy (str): Unknown (local).
|
||||
createdAtTZOffset (int): Unknown (-25200).
|
||||
fields (List<:class:`~plexapi.media.Field`>): List of field objects.
|
||||
guid (str): Plex GUID for the photo (com.plexapp.agents.none://231714?lang=xn).
|
||||
images (List<:class:`~plexapi.media.Image`>): List of image objects.
|
||||
index (sting): Plex index number for the photo.
|
||||
key (str): API URL (/library/metadata/<ratingkey>).
|
||||
lastRatedAt (datetime): Datetime the photo was last rated.
|
||||
librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID.
|
||||
librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key.
|
||||
librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title.
|
||||
listType (str): Hardcoded as 'photo' (useful for search filters).
|
||||
media (List<:class:`~plexapi.media.Media`>): List of media objects.
|
||||
originallyAvailableAt (datetime): Datetime the photo was added to Plex.
|
||||
parentGuid (str): Plex GUID for the photo album (local://229674).
|
||||
parentIndex (int): Plex index number for the photo album.
|
||||
parentKey (str): API URL of the photo album (/library/metadata/<parentRatingKey>).
|
||||
parentRatingKey (int): Unique key identifying the photo album.
|
||||
parentThumb (str): URL to photo album thumbnail image (/library/metadata/<parentRatingKey>/thumb/<thumbid>).
|
||||
parentTitle (str): Name of the photo album for the photo.
|
||||
ratingKey (int): Unique key identifying the photo.
|
||||
sourceURI (str): Remote server URI (server://<machineIdentifier>/com.plexapp.plugins.library)
|
||||
(remote playlist item only).
|
||||
summary (str): Summary of the photo.
|
||||
tags (List<:class:`~plexapi.media.Tag`>): List of tag objects.
|
||||
thumb (str): URL to thumbnail image (/library/metadata/<ratingKey>/thumb/<thumbid>).
|
||||
title (str): Name of the photo.
|
||||
titleSort (str): Title to use when sorting (defaults to title).
|
||||
type (str): 'photo'
|
||||
updatedAt (datetime): Datetime the photo was updated.
|
||||
userRating (float): Rating of the photo (0.0 - 10.0) equaling (0 stars - 5 stars).
|
||||
year (int): Year the photo was taken.
|
||||
"""
|
||||
TAG = 'Photo'
|
||||
TYPE = 'photo'
|
||||
METADATA_TYPE = 'photo'
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
Playable._loadData(self, data)
|
||||
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
|
||||
self.createdAtAccuracy = data.attrib.get('createdAtAccuracy')
|
||||
self.createdAtTZOffset = utils.cast(int, data.attrib.get('createdAtTZOffset'))
|
||||
self.fields = self.findItems(data, media.Field)
|
||||
self.guid = data.attrib.get('guid')
|
||||
self.images = self.findItems(data, media.Image)
|
||||
self.index = utils.cast(int, data.attrib.get('index'))
|
||||
self.key = data.attrib.get('key', '')
|
||||
self.lastRatedAt = utils.toDatetime(data.attrib.get('lastRatedAt'))
|
||||
self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID'))
|
||||
self.librarySectionKey = data.attrib.get('librarySectionKey')
|
||||
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
|
||||
self.listType = 'photo'
|
||||
self.media = self.findItems(data, media.Media)
|
||||
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
|
||||
self.parentGuid = data.attrib.get('parentGuid')
|
||||
self.parentIndex = utils.cast(int, data.attrib.get('parentIndex'))
|
||||
self.parentKey = data.attrib.get('parentKey')
|
||||
self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey'))
|
||||
self.parentThumb = data.attrib.get('parentThumb')
|
||||
self.parentTitle = data.attrib.get('parentTitle')
|
||||
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
|
||||
self.sourceURI = data.attrib.get('source') # remote playlist item
|
||||
self.summary = data.attrib.get('summary')
|
||||
self.tags = self.findItems(data, media.Tag)
|
||||
self.thumb = data.attrib.get('thumb')
|
||||
self.title = data.attrib.get('title')
|
||||
self.titleSort = data.attrib.get('titleSort', self.title)
|
||||
self.type = data.attrib.get('type')
|
||||
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
|
||||
self.userRating = utils.cast(float, data.attrib.get('userRating'))
|
||||
self.year = utils.cast(int, data.attrib.get('year'))
|
||||
|
||||
def _prettyfilename(self):
|
||||
""" Returns a filename for use in download. """
|
||||
if self.parentTitle:
|
||||
return f'{self.parentTitle} - {self.title}'
|
||||
return self.title
|
||||
|
||||
def photoalbum(self):
|
||||
""" Return the photo's :class:`~plexapi.photo.Photoalbum`. """
|
||||
return self.fetchItem(self.parentKey)
|
||||
|
||||
def section(self):
|
||||
""" Returns the :class:`~plexapi.library.LibrarySection` the item belongs to. """
|
||||
if hasattr(self, 'librarySectionID'):
|
||||
return self._server.library.sectionByID(self.librarySectionID)
|
||||
elif self.parentKey:
|
||||
return self._server.library.sectionByID(self.photoalbum().librarySectionID)
|
||||
else:
|
||||
raise BadRequest("Unable to get section for photo, can't find librarySectionID")
|
||||
|
||||
@property
|
||||
def locations(self):
|
||||
""" This does not exist in plex xml response but is added to have a common
|
||||
interface to get the locations of the photo.
|
||||
|
||||
Returns:
|
||||
List<str> of file paths where the photo is found on disk.
|
||||
"""
|
||||
return [part.file for item in self.media for part in item.parts if part]
|
||||
|
||||
def sync(self, resolution, client=None, clientId=None, limit=None, title=None):
|
||||
""" Add current photo as sync item for specified device.
|
||||
See :func:`~plexapi.myplex.MyPlexAccount.sync` for possible exceptions.
|
||||
|
||||
Parameters:
|
||||
resolution (str): maximum allowed resolution for synchronized photos, see PHOTO_QUALITY_* values in the
|
||||
module :mod:`~plexapi.sync`.
|
||||
client (:class:`~plexapi.myplex.MyPlexDevice`): sync destination, see
|
||||
:func:`~plexapi.myplex.MyPlexAccount.sync`.
|
||||
clientId (str): sync destination, see :func:`~plexapi.myplex.MyPlexAccount.sync`.
|
||||
limit (int): maximum count of items to sync, unlimited if `None`.
|
||||
title (str): descriptive title for the new :class:`~plexapi.sync.SyncItem`, if empty the value would be
|
||||
generated from metadata of current photo.
|
||||
|
||||
Returns:
|
||||
:class:`~plexapi.sync.SyncItem`: an instance of created syncItem.
|
||||
"""
|
||||
|
||||
from plexapi.sync import SyncItem, Policy, MediaSettings
|
||||
|
||||
myplex = self._server.myPlexAccount()
|
||||
sync_item = SyncItem(self._server, None)
|
||||
sync_item.title = title if title else self.title
|
||||
sync_item.rootTitle = self.title
|
||||
sync_item.contentType = self.listType
|
||||
sync_item.metadataType = self.METADATA_TYPE
|
||||
sync_item.machineIdentifier = self._server.machineIdentifier
|
||||
|
||||
section = self.section()
|
||||
|
||||
sync_item.location = f'library://{section.uuid}/item/{quote_plus(self.key)}'
|
||||
sync_item.policy = Policy.create(limit)
|
||||
sync_item.mediaSettings = MediaSettings.createPhoto(resolution)
|
||||
|
||||
return myplex.sync(sync_item, client=client, clientId=clientId)
|
||||
|
||||
def _getWebURL(self, base=None):
|
||||
""" Get the Plex Web URL with the correct parameters. """
|
||||
return self._server._buildWebURL(base=base, endpoint='details', key=self.parentKey, legacy=1)
|
||||
|
||||
@property
|
||||
def metadataDirectory(self):
|
||||
""" Returns the Plex Media Server data directory where the metadata is stored. """
|
||||
guid_hash = utils.sha1hash(self.parentGuid)
|
||||
return str(Path('Metadata') / 'Photos' / guid_hash[0] / f'{guid_hash[1:]}.bundle')
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class PhotoSession(PlexSession, Photo):
|
||||
""" Represents a single Photo session
|
||||
loaded from :func:`~plexapi.server.PlexServer.sessions`.
|
||||
"""
|
||||
_SESSIONTYPE = True
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
Photo._loadData(self, data)
|
||||
PlexSession._loadData(self, data)
|
@ -0,0 +1,534 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
from itertools import groupby
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote_plus, unquote
|
||||
|
||||
from plexapi import media, utils
|
||||
from plexapi.base import Playable, PlexPartialObject
|
||||
from plexapi.exceptions import BadRequest, NotFound, Unsupported
|
||||
from plexapi.library import LibrarySection, MusicSection
|
||||
from plexapi.mixins import SmartFilterMixin, ArtMixin, PosterMixin, PlaylistEditMixins
|
||||
from plexapi.utils import deprecated
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Playlist(
|
||||
PlexPartialObject, Playable,
|
||||
SmartFilterMixin,
|
||||
ArtMixin, PosterMixin,
|
||||
PlaylistEditMixins
|
||||
):
|
||||
""" Represents a single Playlist.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Playlist'
|
||||
TYPE (str): 'playlist'
|
||||
addedAt (datetime): Datetime the playlist was added to the server.
|
||||
allowSync (bool): True if you allow syncing playlists.
|
||||
composite (str): URL to composite image (/playlist/<ratingKey>/composite/<compositeid>)
|
||||
content (str): The filter URI string for smart playlists.
|
||||
duration (int): Duration of the playlist in milliseconds.
|
||||
durationInSeconds (int): Duration of the playlist in seconds.
|
||||
fields (List<:class:`~plexapi.media.Field`>): List of field objects.
|
||||
guid (str): Plex GUID for the playlist (com.plexapp.agents.none://XXXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXX).
|
||||
icon (str): Icon URI string for smart playlists.
|
||||
key (str): API URL (/playlist/<ratingkey>).
|
||||
leafCount (int): Number of items in the playlist view.
|
||||
librarySectionID (int): Library section identifier (radio only)
|
||||
librarySectionKey (str): Library section key (radio only)
|
||||
librarySectionTitle (str): Library section title (radio only)
|
||||
playlistType (str): 'audio', 'video', or 'photo'
|
||||
radio (bool): If this playlist represents a radio station
|
||||
ratingKey (int): Unique key identifying the playlist.
|
||||
smart (bool): True if the playlist is a smart playlist.
|
||||
summary (str): Summary of the playlist.
|
||||
title (str): Name of the playlist.
|
||||
titleSort (str): Title to use when sorting (defaults to title).
|
||||
type (str): 'playlist'
|
||||
updatedAt (datetime): Datetime the playlist was updated.
|
||||
"""
|
||||
TAG = 'Playlist'
|
||||
TYPE = 'playlist'
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
Playable._loadData(self, data)
|
||||
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
|
||||
self.allowSync = utils.cast(bool, data.attrib.get('allowSync'))
|
||||
self.composite = data.attrib.get('composite') # url to thumbnail
|
||||
self.content = data.attrib.get('content')
|
||||
self.duration = utils.cast(int, data.attrib.get('duration'))
|
||||
self.durationInSeconds = utils.cast(int, data.attrib.get('durationInSeconds'))
|
||||
self.fields = self.findItems(data, media.Field)
|
||||
self.guid = data.attrib.get('guid')
|
||||
self.icon = data.attrib.get('icon')
|
||||
self.key = data.attrib.get('key', '').replace('/items', '') # FIX_BUG_50
|
||||
self.leafCount = utils.cast(int, data.attrib.get('leafCount'))
|
||||
self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID'))
|
||||
self.librarySectionKey = data.attrib.get('librarySectionKey')
|
||||
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
|
||||
self.playlistType = data.attrib.get('playlistType')
|
||||
self.radio = utils.cast(bool, data.attrib.get('radio', 0))
|
||||
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
|
||||
self.smart = utils.cast(bool, data.attrib.get('smart'))
|
||||
self.summary = data.attrib.get('summary')
|
||||
self.title = data.attrib.get('title')
|
||||
self.titleSort = data.attrib.get('titleSort', self.title)
|
||||
self.type = data.attrib.get('type')
|
||||
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
|
||||
self._items = None # cache for self.items
|
||||
self._section = None # cache for self.section
|
||||
self._filters = None # cache for self.filters
|
||||
|
||||
def __len__(self): # pragma: no cover
|
||||
return len(self.items())
|
||||
|
||||
def __iter__(self): # pragma: no cover
|
||||
for item in self.items():
|
||||
yield item
|
||||
|
||||
def __contains__(self, other): # pragma: no cover
|
||||
return any(i.key == other.key for i in self.items())
|
||||
|
||||
def __getitem__(self, key): # pragma: no cover
|
||||
return self.items()[key]
|
||||
|
||||
@property
|
||||
def thumb(self):
|
||||
""" Alias to self.composite. """
|
||||
return self.composite
|
||||
|
||||
@property
|
||||
def metadataType(self):
|
||||
""" Returns the type of metadata in the playlist (movie, track, or photo). """
|
||||
if self.isVideo:
|
||||
return 'movie'
|
||||
elif self.isAudio:
|
||||
return 'track'
|
||||
elif self.isPhoto:
|
||||
return 'photo'
|
||||
else:
|
||||
raise Unsupported('Unexpected playlist type')
|
||||
|
||||
@property
|
||||
def isVideo(self):
|
||||
""" Returns True if this is a video playlist. """
|
||||
return self.playlistType == 'video'
|
||||
|
||||
@property
|
||||
def isAudio(self):
|
||||
""" Returns True if this is an audio playlist. """
|
||||
return self.playlistType == 'audio'
|
||||
|
||||
@property
|
||||
def isPhoto(self):
|
||||
""" Returns True if this is a photo playlist. """
|
||||
return self.playlistType == 'photo'
|
||||
|
||||
def _getPlaylistItemID(self, item):
|
||||
""" Match an item to a playlist item and return the item playlistItemID. """
|
||||
for _item in self.items():
|
||||
if _item.ratingKey == item.ratingKey:
|
||||
return _item.playlistItemID
|
||||
raise NotFound(f'Item with title "{item.title}" not found in the playlist')
|
||||
|
||||
def filters(self):
|
||||
""" Returns the search filter dict for smart playlist.
|
||||
The filter dict be passed back into :func:`~plexapi.library.LibrarySection.search`
|
||||
to get the list of items.
|
||||
"""
|
||||
if self.smart and self._filters is None:
|
||||
self._filters = self._parseFilters(self.content)
|
||||
return self._filters
|
||||
|
||||
def section(self):
|
||||
""" Returns the :class:`~plexapi.library.LibrarySection` this smart playlist belongs to.
|
||||
|
||||
Raises:
|
||||
:class:`plexapi.exceptions.BadRequest`: When trying to get the section for a regular playlist.
|
||||
:class:`plexapi.exceptions.Unsupported`: When unable to determine the library section.
|
||||
"""
|
||||
if not self.smart:
|
||||
raise BadRequest('Regular playlists are not associated with a library.')
|
||||
|
||||
if self._section is None:
|
||||
# Try to parse the library section from the content URI string
|
||||
match = re.search(r'/library/sections/(\d+)/all', unquote(self.content or ''))
|
||||
if match:
|
||||
sectionKey = int(match.group(1))
|
||||
self._section = self._server.library.sectionByID(sectionKey)
|
||||
return self._section
|
||||
|
||||
# Try to get the library section from the first item in the playlist
|
||||
if self.items():
|
||||
self._section = self.items()[0].section()
|
||||
return self._section
|
||||
|
||||
raise Unsupported('Unable to determine the library section')
|
||||
|
||||
return self._section
|
||||
|
||||
def item(self, title):
|
||||
""" Returns the item in the playlist that matches the specified title.
|
||||
|
||||
Parameters:
|
||||
title (str): Title of the item to return.
|
||||
|
||||
Raises:
|
||||
:class:`plexapi.exceptions.NotFound`: When the item is not found in the playlist.
|
||||
"""
|
||||
for item in self.items():
|
||||
if item.title.lower() == title.lower():
|
||||
return item
|
||||
raise NotFound(f'Item with title "{title}" not found in the playlist')
|
||||
|
||||
def items(self):
|
||||
""" Returns a list of all items in the playlist. """
|
||||
if self.radio:
|
||||
return []
|
||||
if self._items is None:
|
||||
key = f'{self.key}/items'
|
||||
items = self.fetchItems(key)
|
||||
|
||||
# Cache server connections to avoid reconnecting for each item
|
||||
_servers = {}
|
||||
for item in items:
|
||||
if item.sourceURI:
|
||||
serverID = item.sourceURI.split('/')[2]
|
||||
if serverID not in _servers:
|
||||
try:
|
||||
_servers[serverID] = self._server.myPlexAccount().resource(serverID).connect()
|
||||
except NotFound:
|
||||
# Override the server connection with None if the server is not found
|
||||
_servers[serverID] = None
|
||||
item._server = _servers[serverID]
|
||||
|
||||
self._items = items
|
||||
return self._items
|
||||
|
||||
def get(self, title):
|
||||
""" Alias to :func:`~plexapi.playlist.Playlist.item`. """
|
||||
return self.item(title)
|
||||
|
||||
def addItems(self, items):
|
||||
""" Add items to the playlist.
|
||||
|
||||
Parameters:
|
||||
items (List): List of :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`,
|
||||
or :class:`~plexapi.photo.Photo` objects to be added to the playlist.
|
||||
|
||||
Raises:
|
||||
:class:`plexapi.exceptions.BadRequest`: When trying to add items to a smart playlist.
|
||||
"""
|
||||
if self.smart:
|
||||
raise BadRequest('Cannot add items to a smart playlist.')
|
||||
|
||||
if items and not isinstance(items, (list, tuple)):
|
||||
items = [items]
|
||||
|
||||
# Group items by server to maintain order when adding items from multiple servers
|
||||
for server, _items in groupby(items, key=lambda item: item._server):
|
||||
|
||||
ratingKeys = []
|
||||
for item in _items:
|
||||
if item.listType != self.playlistType: # pragma: no cover
|
||||
raise BadRequest(f'Can not mix media types when building a playlist: '
|
||||
f'{self.playlistType} and {item.listType}')
|
||||
ratingKeys.append(str(item.ratingKey))
|
||||
|
||||
ratingKeys = ','.join(ratingKeys)
|
||||
uri = f'{server._uriRoot()}/library/metadata/{ratingKeys}'
|
||||
|
||||
args = {'uri': uri}
|
||||
key = f"{self.key}/items{utils.joinArgs(args)}"
|
||||
self._server.query(key, method=self._server._session.put)
|
||||
|
||||
return self
|
||||
|
||||
@deprecated('use "removeItems" instead')
|
||||
def removeItem(self, item):
|
||||
self.removeItems(item)
|
||||
|
||||
def removeItems(self, items):
|
||||
""" Remove items from the playlist.
|
||||
|
||||
Parameters:
|
||||
items (List): List of :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`,
|
||||
or :class:`~plexapi.photo.Photo` objects to be removed from the playlist.
|
||||
|
||||
Raises:
|
||||
:class:`plexapi.exceptions.BadRequest`: When trying to remove items from a smart playlist.
|
||||
:class:`plexapi.exceptions.NotFound`: When the item does not exist in the playlist.
|
||||
"""
|
||||
if self.smart:
|
||||
raise BadRequest('Cannot remove items from a smart playlist.')
|
||||
|
||||
if items and not isinstance(items, (list, tuple)):
|
||||
items = [items]
|
||||
|
||||
for item in items:
|
||||
playlistItemID = self._getPlaylistItemID(item)
|
||||
key = f'{self.key}/items/{playlistItemID}'
|
||||
self._server.query(key, method=self._server._session.delete)
|
||||
return self
|
||||
|
||||
def moveItem(self, item, after=None):
|
||||
""" Move an item to a new position in the playlist.
|
||||
|
||||
Parameters:
|
||||
items (obj): :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`,
|
||||
or :class:`~plexapi.photo.Photo` objects to be moved in the playlist.
|
||||
after (obj): :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`,
|
||||
or :class:`~plexapi.photo.Photo` objects to move the item after in the playlist.
|
||||
|
||||
Raises:
|
||||
:class:`plexapi.exceptions.BadRequest`: When trying to move items in a smart playlist.
|
||||
:class:`plexapi.exceptions.NotFound`: When the item or item after does not exist in the playlist.
|
||||
"""
|
||||
if self.smart:
|
||||
raise BadRequest('Cannot move items in a smart playlist.')
|
||||
|
||||
playlistItemID = self._getPlaylistItemID(item)
|
||||
key = f'{self.key}/items/{playlistItemID}/move'
|
||||
|
||||
if after:
|
||||
afterPlaylistItemID = self._getPlaylistItemID(after)
|
||||
key += f'?after={afterPlaylistItemID}'
|
||||
|
||||
self._server.query(key, method=self._server._session.put)
|
||||
return self
|
||||
|
||||
def updateFilters(self, limit=None, sort=None, filters=None, **kwargs):
|
||||
""" Update the filters for a smart playlist.
|
||||
|
||||
Parameters:
|
||||
limit (int): Limit the number of items in the playlist.
|
||||
sort (str or list, optional): A string of comma separated sort fields
|
||||
or a list of sort fields in the format ``column:dir``.
|
||||
See :func:`~plexapi.library.LibrarySection.search` for more info.
|
||||
filters (dict): A dictionary of advanced filters.
|
||||
See :func:`~plexapi.library.LibrarySection.search` for more info.
|
||||
**kwargs (dict): Additional custom filters to apply to the search results.
|
||||
See :func:`~plexapi.library.LibrarySection.search` for more info.
|
||||
|
||||
Raises:
|
||||
:class:`plexapi.exceptions.BadRequest`: When trying update filters for a regular playlist.
|
||||
"""
|
||||
if not self.smart:
|
||||
raise BadRequest('Cannot update filters for a regular playlist.')
|
||||
|
||||
section = self.section()
|
||||
searchKey = section._buildSearchKey(
|
||||
sort=sort, libtype=section.METADATA_TYPE, limit=limit, filters=filters, **kwargs)
|
||||
uri = f'{self._server._uriRoot()}{searchKey}'
|
||||
|
||||
args = {'uri': uri}
|
||||
key = f"{self.key}/items{utils.joinArgs(args)}"
|
||||
self._server.query(key, method=self._server._session.put)
|
||||
return self
|
||||
|
||||
def _edit(self, **kwargs):
|
||||
""" Actually edit the playlist. """
|
||||
if isinstance(self._edits, dict):
|
||||
self._edits.update(kwargs)
|
||||
return self
|
||||
|
||||
key = f'{self.key}{utils.joinArgs(kwargs)}'
|
||||
self._server.query(key, method=self._server._session.put)
|
||||
return self
|
||||
|
||||
@deprecated('use "editTitle" and "editSummary" instead')
|
||||
def edit(self, title=None, summary=None):
|
||||
""" Edit the playlist.
|
||||
|
||||
Parameters:
|
||||
title (str, optional): The title of the playlist.
|
||||
summary (str, optional): The summary of the playlist.
|
||||
"""
|
||||
args = {}
|
||||
if title:
|
||||
args['title'] = title
|
||||
if summary:
|
||||
args['summary'] = summary
|
||||
return self._edit(**args)
|
||||
|
||||
def delete(self):
|
||||
""" Delete the playlist. """
|
||||
self._server.query(self.key, method=self._server._session.delete)
|
||||
|
||||
@classmethod
|
||||
def _create(cls, server, title, items):
|
||||
""" Create a regular playlist. """
|
||||
if not items:
|
||||
raise BadRequest('Must include items to add when creating new playlist.')
|
||||
|
||||
if items and not isinstance(items, (list, tuple)):
|
||||
items = [items]
|
||||
|
||||
listType = items[0].listType
|
||||
ratingKeys = []
|
||||
for item in items:
|
||||
if item.listType != listType: # pragma: no cover
|
||||
raise BadRequest('Can not mix media types when building a playlist.')
|
||||
ratingKeys.append(str(item.ratingKey))
|
||||
|
||||
ratingKeys = ','.join(ratingKeys)
|
||||
uri = f'{server._uriRoot()}/library/metadata/{ratingKeys}'
|
||||
|
||||
args = {'uri': uri, 'type': listType, 'title': title, 'smart': 0}
|
||||
key = f"/playlists{utils.joinArgs(args)}"
|
||||
data = server.query(key, method=server._session.post)[0]
|
||||
return cls(server, data, initpath=key)
|
||||
|
||||
@classmethod
|
||||
def _createSmart(cls, server, title, section, limit=None, libtype=None, sort=None, filters=None, **kwargs):
|
||||
""" Create a smart playlist. """
|
||||
if not isinstance(section, LibrarySection):
|
||||
section = server.library.section(section)
|
||||
|
||||
libtype = libtype or section.METADATA_TYPE
|
||||
|
||||
searchKey = section._buildSearchKey(
|
||||
sort=sort, libtype=libtype, limit=limit, filters=filters, **kwargs)
|
||||
uri = f'{server._uriRoot()}{searchKey}'
|
||||
|
||||
args = {'uri': uri, 'type': section.CONTENT_TYPE, 'title': title, 'smart': 1}
|
||||
key = f"/playlists{utils.joinArgs(args)}"
|
||||
data = server.query(key, method=server._session.post)[0]
|
||||
return cls(server, data, initpath=key)
|
||||
|
||||
@classmethod
|
||||
def _createFromM3U(cls, server, title, section, m3ufilepath):
|
||||
""" Create a playlist from uploading an m3u file. """
|
||||
if not isinstance(section, LibrarySection):
|
||||
section = server.library.section(section)
|
||||
|
||||
if not isinstance(section, MusicSection):
|
||||
raise BadRequest('Can only create playlists from m3u files in a music library.')
|
||||
|
||||
args = {'sectionID': section.key, 'path': m3ufilepath}
|
||||
key = f"/playlists/upload{utils.joinArgs(args)}"
|
||||
server.query(key, method=server._session.post)
|
||||
try:
|
||||
return server.playlists(sectionId=section.key, guid__endswith=m3ufilepath)[0].editTitle(title).reload()
|
||||
except IndexError:
|
||||
raise BadRequest('Failed to create playlist from m3u file.') from None
|
||||
|
||||
@classmethod
|
||||
def create(cls, server, title, section=None, items=None, smart=False, limit=None,
|
||||
libtype=None, sort=None, filters=None, m3ufilepath=None, **kwargs):
|
||||
""" Create a playlist.
|
||||
|
||||
Parameters:
|
||||
server (:class:`~plexapi.server.PlexServer`): Server to create the playlist on.
|
||||
title (str): Title of the playlist.
|
||||
section (:class:`~plexapi.library.LibrarySection`, str): Smart playlists and m3u import only,
|
||||
the library section to create the playlist in.
|
||||
items (List): Regular playlists only, list of :class:`~plexapi.audio.Audio`,
|
||||
:class:`~plexapi.video.Video`, or :class:`~plexapi.photo.Photo` objects to be added to the playlist.
|
||||
smart (bool): True to create a smart playlist. Default False.
|
||||
limit (int): Smart playlists only, limit the number of items in the playlist.
|
||||
libtype (str): Smart playlists only, the specific type of content to filter
|
||||
(movie, show, season, episode, artist, album, track, photoalbum, photo).
|
||||
sort (str or list, optional): Smart playlists only, a string of comma separated sort fields
|
||||
or a list of sort fields in the format ``column:dir``.
|
||||
See :func:`~plexapi.library.LibrarySection.search` for more info.
|
||||
filters (dict): Smart playlists only, a dictionary of advanced filters.
|
||||
See :func:`~plexapi.library.LibrarySection.search` for more info.
|
||||
m3ufilepath (str): Music playlists only, the full file path to an m3u file to import.
|
||||
Note: This will overwrite any playlist previously created from the same m3u file.
|
||||
**kwargs (dict): Smart playlists only, additional custom filters to apply to the
|
||||
search results. See :func:`~plexapi.library.LibrarySection.search` for more info.
|
||||
|
||||
Raises:
|
||||
:class:`plexapi.exceptions.BadRequest`: When no items are included to create the playlist.
|
||||
:class:`plexapi.exceptions.BadRequest`: When mixing media types in the playlist.
|
||||
:class:`plexapi.exceptions.BadRequest`: When attempting to import m3u file into non-music library.
|
||||
:class:`plexapi.exceptions.BadRequest`: When failed to import m3u file.
|
||||
|
||||
Returns:
|
||||
:class:`~plexapi.playlist.Playlist`: A new instance of the created Playlist.
|
||||
"""
|
||||
if m3ufilepath:
|
||||
return cls._createFromM3U(server, title, section, m3ufilepath)
|
||||
elif smart:
|
||||
if items:
|
||||
raise BadRequest('Cannot create a smart playlist with items.')
|
||||
return cls._createSmart(server, title, section, limit, libtype, sort, filters, **kwargs)
|
||||
else:
|
||||
return cls._create(server, title, items)
|
||||
|
||||
def copyToUser(self, user):
|
||||
""" Copy playlist to another user account.
|
||||
|
||||
Parameters:
|
||||
user (:class:`~plexapi.myplex.MyPlexUser` or str): `MyPlexUser` object, username,
|
||||
email, or user id of the user to copy the playlist to.
|
||||
"""
|
||||
userServer = self._server.switchUser(user)
|
||||
return self.create(server=userServer, title=self.title, items=self.items())
|
||||
|
||||
def sync(self, videoQuality=None, photoResolution=None, audioBitrate=None, client=None, clientId=None, limit=None,
|
||||
unwatched=False, title=None):
|
||||
""" Add the playlist as a sync item for the specified device.
|
||||
See :func:`~plexapi.myplex.MyPlexAccount.sync` for possible exceptions.
|
||||
|
||||
Parameters:
|
||||
videoQuality (int): idx of quality of the video, one of VIDEO_QUALITY_* values defined in
|
||||
:mod:`~plexapi.sync` module. Used only when playlist contains video.
|
||||
photoResolution (str): maximum allowed resolution for synchronized photos, see PHOTO_QUALITY_* values in
|
||||
the module :mod:`~plexapi.sync`. Used only when playlist contains photos.
|
||||
audioBitrate (int): maximum bitrate for synchronized music, better use one of MUSIC_BITRATE_* values
|
||||
from the module :mod:`~plexapi.sync`. Used only when playlist contains audio.
|
||||
client (:class:`~plexapi.myplex.MyPlexDevice`): sync destination, see
|
||||
:func:`~plexapi.myplex.MyPlexAccount.sync`.
|
||||
clientId (str): sync destination, see :func:`~plexapi.myplex.MyPlexAccount.sync`.
|
||||
limit (int): maximum count of items to sync, unlimited if `None`.
|
||||
unwatched (bool): if `True` watched videos wouldn't be synced.
|
||||
title (str): descriptive title for the new :class:`~plexapi.sync.SyncItem`, if empty the value would be
|
||||
generated from metadata of current photo.
|
||||
|
||||
Raises:
|
||||
:exc:`~plexapi.exceptions.BadRequest`: When playlist is not allowed to sync.
|
||||
:exc:`~plexapi.exceptions.Unsupported`: When playlist content is unsupported.
|
||||
|
||||
Returns:
|
||||
:class:`~plexapi.sync.SyncItem`: A new instance of the created sync item.
|
||||
"""
|
||||
if not self.allowSync:
|
||||
raise BadRequest('The playlist is not allowed to sync')
|
||||
|
||||
from plexapi.sync import SyncItem, Policy, MediaSettings
|
||||
|
||||
myplex = self._server.myPlexAccount()
|
||||
sync_item = SyncItem(self._server, None)
|
||||
sync_item.title = title if title else self.title
|
||||
sync_item.rootTitle = self.title
|
||||
sync_item.contentType = self.playlistType
|
||||
sync_item.metadataType = self.metadataType
|
||||
sync_item.machineIdentifier = self._server.machineIdentifier
|
||||
|
||||
sync_item.location = f'playlist:///{quote_plus(self.guid)}'
|
||||
sync_item.policy = Policy.create(limit, unwatched)
|
||||
|
||||
if self.isVideo:
|
||||
sync_item.mediaSettings = MediaSettings.createVideo(videoQuality)
|
||||
elif self.isAudio:
|
||||
sync_item.mediaSettings = MediaSettings.createMusic(audioBitrate)
|
||||
elif self.isPhoto:
|
||||
sync_item.mediaSettings = MediaSettings.createPhoto(photoResolution)
|
||||
else:
|
||||
raise Unsupported('Unsupported playlist content')
|
||||
|
||||
return myplex.sync(sync_item, client=client, clientId=clientId)
|
||||
|
||||
def _getWebURL(self, base=None):
|
||||
""" Get the Plex Web URL with the correct parameters. """
|
||||
return self._server._buildWebURL(base=base, endpoint='playlist', key=self.key)
|
||||
|
||||
@property
|
||||
def metadataDirectory(self):
|
||||
""" Returns the Plex Media Server data directory where the metadata is stored. """
|
||||
guid_hash = utils.sha1hash(self.guid)
|
||||
return str(Path('Metadata') / 'Playlists' / guid_hash[0] / f'{guid_hash[1:]}.bundle')
|
@ -0,0 +1,319 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from plexapi import utils
|
||||
from plexapi.base import PlexObject
|
||||
from plexapi.exceptions import BadRequest
|
||||
|
||||
|
||||
class PlayQueue(PlexObject):
|
||||
"""Control a PlayQueue.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'PlayQueue'
|
||||
TYPE (str): 'playqueue'
|
||||
identifier (str): com.plexapp.plugins.library
|
||||
items (list): List of :class:`~plexapi.base.Playable` or :class:`~plexapi.playlist.Playlist`
|
||||
mediaTagPrefix (str): Fx /system/bundle/media/flags/
|
||||
mediaTagVersion (int): Fx 1485957738
|
||||
playQueueID (int): ID of the PlayQueue.
|
||||
playQueueLastAddedItemID (int):
|
||||
Defines where the "Up Next" region starts. Empty unless PlayQueue is modified after creation.
|
||||
playQueueSelectedItemID (int): The queue item ID of the currently selected item.
|
||||
playQueueSelectedItemOffset (int):
|
||||
The offset of the selected item in the PlayQueue, from the beginning of the queue.
|
||||
playQueueSelectedMetadataItemID (int): ID of the currently selected item, matches ratingKey.
|
||||
playQueueShuffled (bool): True if shuffled.
|
||||
playQueueSourceURI (str): Original URI used to create the PlayQueue.
|
||||
playQueueTotalCount (int): How many items in the PlayQueue.
|
||||
playQueueVersion (int): Version of the PlayQueue. Increments every time a change is made to the PlayQueue.
|
||||
selectedItem (:class:`~plexapi.base.Playable`): Media object for the currently selected item.
|
||||
_server (:class:`~plexapi.server.PlexServer`): PlexServer associated with the PlayQueue.
|
||||
size (int): Alias for playQueueTotalCount.
|
||||
"""
|
||||
|
||||
TAG = "PlayQueue"
|
||||
TYPE = "playqueue"
|
||||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
self.identifier = data.attrib.get("identifier")
|
||||
self.mediaTagPrefix = data.attrib.get("mediaTagPrefix")
|
||||
self.mediaTagVersion = utils.cast(int, data.attrib.get("mediaTagVersion"))
|
||||
self.playQueueID = utils.cast(int, data.attrib.get("playQueueID"))
|
||||
self.playQueueLastAddedItemID = utils.cast(
|
||||
int, data.attrib.get("playQueueLastAddedItemID")
|
||||
)
|
||||
self.playQueueSelectedItemID = utils.cast(
|
||||
int, data.attrib.get("playQueueSelectedItemID")
|
||||
)
|
||||
self.playQueueSelectedItemOffset = utils.cast(
|
||||
int, data.attrib.get("playQueueSelectedItemOffset")
|
||||
)
|
||||
self.playQueueSelectedMetadataItemID = utils.cast(
|
||||
int, data.attrib.get("playQueueSelectedMetadataItemID")
|
||||
)
|
||||
self.playQueueShuffled = utils.cast(
|
||||
bool, data.attrib.get("playQueueShuffled", 0)
|
||||
)
|
||||
self.playQueueSourceURI = data.attrib.get("playQueueSourceURI")
|
||||
self.playQueueTotalCount = utils.cast(
|
||||
int, data.attrib.get("playQueueTotalCount")
|
||||
)
|
||||
self.playQueueVersion = utils.cast(int, data.attrib.get("playQueueVersion"))
|
||||
self.size = utils.cast(int, data.attrib.get("size", 0))
|
||||
self.items = self.findItems(data)
|
||||
self.selectedItem = self[self.playQueueSelectedItemOffset]
|
||||
|
||||
def __getitem__(self, key):
|
||||
if not self.items:
|
||||
return None
|
||||
return self.items[key]
|
||||
|
||||
def __len__(self):
|
||||
return self.playQueueTotalCount
|
||||
|
||||
def __iter__(self):
|
||||
yield from self.items
|
||||
|
||||
def __contains__(self, media):
|
||||
"""Returns True if the PlayQueue contains the provided media item."""
|
||||
return any(x.playQueueItemID == media.playQueueItemID for x in self.items)
|
||||
|
||||
def getQueueItem(self, item):
|
||||
"""
|
||||
Accepts a media item and returns a similar object from this PlayQueue.
|
||||
Useful for looking up playQueueItemIDs using items obtained from the Library.
|
||||
"""
|
||||
matches = [x for x in self.items if x == item]
|
||||
if len(matches) == 1:
|
||||
return matches[0]
|
||||
elif len(matches) > 1:
|
||||
raise BadRequest(
|
||||
f"{item} occurs multiple times in this PlayQueue, provide exact item"
|
||||
)
|
||||
else:
|
||||
raise BadRequest(f"{item} not valid for this PlayQueue")
|
||||
|
||||
@classmethod
|
||||
def get(
|
||||
cls,
|
||||
server,
|
||||
playQueueID,
|
||||
own=False,
|
||||
center=None,
|
||||
window=50,
|
||||
includeBefore=True,
|
||||
includeAfter=True,
|
||||
):
|
||||
"""Retrieve an existing :class:`~plexapi.playqueue.PlayQueue` by identifier.
|
||||
|
||||
Parameters:
|
||||
server (:class:`~plexapi.server.PlexServer`): Server you are connected to.
|
||||
playQueueID (int): Identifier of an existing PlayQueue.
|
||||
own (bool, optional): If server should transfer ownership.
|
||||
center (int, optional): The playQueueItemID of the center of the window. Does not change selectedItem.
|
||||
window (int, optional): Number of items to return from each side of the center item.
|
||||
includeBefore (bool, optional):
|
||||
Include items before the center, defaults True. Does not include center if False.
|
||||
includeAfter (bool, optional):
|
||||
Include items after the center, defaults True. Does not include center if False.
|
||||
"""
|
||||
args = {
|
||||
"own": utils.cast(int, own),
|
||||
"window": window,
|
||||
"includeBefore": utils.cast(int, includeBefore),
|
||||
"includeAfter": utils.cast(int, includeAfter),
|
||||
}
|
||||
if center:
|
||||
args["center"] = center
|
||||
|
||||
path = f"/playQueues/{playQueueID}{utils.joinArgs(args)}"
|
||||
data = server.query(path, method=server._session.get)
|
||||
c = cls(server, data, initpath=path)
|
||||
c._server = server
|
||||
return c
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
server,
|
||||
items,
|
||||
startItem=None,
|
||||
shuffle=0,
|
||||
repeat=0,
|
||||
includeChapters=1,
|
||||
includeRelated=1,
|
||||
continuous=0,
|
||||
):
|
||||
"""Create and return a new :class:`~plexapi.playqueue.PlayQueue`.
|
||||
|
||||
Parameters:
|
||||
server (:class:`~plexapi.server.PlexServer`): Server you are connected to.
|
||||
items (:class:`~plexapi.base.PlexPartialObject`):
|
||||
A media item or a list of media items.
|
||||
startItem (:class:`~plexapi.base.Playable`, optional):
|
||||
Media item in the PlayQueue where playback should begin.
|
||||
shuffle (int, optional): Start the playqueue shuffled.
|
||||
repeat (int, optional): Start the playqueue shuffled.
|
||||
includeChapters (int, optional): include Chapters.
|
||||
includeRelated (int, optional): include Related.
|
||||
continuous (int, optional): include additional items after the initial item.
|
||||
For a show this would be the next episodes, for a movie it does nothing.
|
||||
"""
|
||||
args = {
|
||||
"includeChapters": includeChapters,
|
||||
"includeRelated": includeRelated,
|
||||
"repeat": repeat,
|
||||
"shuffle": shuffle,
|
||||
"continuous": continuous,
|
||||
}
|
||||
|
||||
if isinstance(items, list):
|
||||
item_keys = ",".join(str(x.ratingKey) for x in items)
|
||||
uri_args = quote_plus(f"/library/metadata/{item_keys}")
|
||||
args["uri"] = f"library:///directory/{uri_args}"
|
||||
args["type"] = items[0].listType
|
||||
else:
|
||||
if items.type == "playlist":
|
||||
args["type"] = items.playlistType
|
||||
args["playlistID"] = items.ratingKey
|
||||
else:
|
||||
args["type"] = items.listType
|
||||
args["uri"] = f"server://{server.machineIdentifier}/{server.library.identifier}{items.key}"
|
||||
|
||||
if startItem:
|
||||
args["key"] = startItem.key
|
||||
|
||||
path = f"/playQueues{utils.joinArgs(args)}"
|
||||
data = server.query(path, method=server._session.post)
|
||||
c = cls(server, data, initpath=path)
|
||||
c._server = server
|
||||
return c
|
||||
|
||||
@classmethod
|
||||
def fromStationKey(cls, server, key):
|
||||
"""Create and return a new :class:`~plexapi.playqueue.PlayQueue`.
|
||||
|
||||
This is a convenience method to create a `PlayQueue` for
|
||||
radio stations when only the `key` string is available.
|
||||
|
||||
Parameters:
|
||||
server (:class:`~plexapi.server.PlexServer`): Server you are connected to.
|
||||
key (str): A station key as provided by :func:`~plexapi.library.LibrarySection.hubs()`
|
||||
or :func:`~plexapi.audio.Artist.station()`
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from plexapi.playqueue import PlayQueue
|
||||
music = server.library.section("Music")
|
||||
artist = music.get("Artist Name")
|
||||
station = artist.station()
|
||||
key = station.key # "/library/metadata/12855/station/8bd39616-dbdb-459e-b8da-f46d0b170af4?type=10"
|
||||
pq = PlayQueue.fromStationKey(server, key)
|
||||
client = server.clients()[0]
|
||||
client.playMedia(pq)
|
||||
"""
|
||||
args = {
|
||||
"type": "audio",
|
||||
"uri": f"server://{server.machineIdentifier}/{server.library.identifier}{key}"
|
||||
}
|
||||
path = f"/playQueues{utils.joinArgs(args)}"
|
||||
data = server.query(path, method=server._session.post)
|
||||
c = cls(server, data, initpath=path)
|
||||
c._server = server
|
||||
return c
|
||||
|
||||
def addItem(self, item, playNext=False, refresh=True):
|
||||
"""
|
||||
Append the provided item to the "Up Next" section of the PlayQueue.
|
||||
Items can only be added to the section immediately following the current playing item.
|
||||
|
||||
Parameters:
|
||||
item (:class:`~plexapi.base.Playable` or :class:`~plexapi.playlist.Playlist`): Single media item or Playlist.
|
||||
playNext (bool, optional): If True, add this item to the front of the "Up Next" section.
|
||||
If False, the item will be appended to the end of the "Up Next" section.
|
||||
Only has an effect if an item has already been added to the "Up Next" section.
|
||||
See https://support.plex.tv/articles/202188298-play-queues/ for more details.
|
||||
refresh (bool, optional): Refresh the PlayQueue from the server before updating.
|
||||
"""
|
||||
if refresh:
|
||||
self.refresh()
|
||||
|
||||
args = {}
|
||||
if item.type == "playlist":
|
||||
args["playlistID"] = item.ratingKey
|
||||
else:
|
||||
uuid = item.section().uuid
|
||||
args["uri"] = f"library://{uuid}/item{item.key}"
|
||||
|
||||
if playNext:
|
||||
args["next"] = 1
|
||||
|
||||
path = f"/playQueues/{self.playQueueID}{utils.joinArgs(args)}"
|
||||
data = self._server.query(path, method=self._server._session.put)
|
||||
self._loadData(data)
|
||||
return self
|
||||
|
||||
def moveItem(self, item, after=None, refresh=True):
|
||||
"""
|
||||
Moves an item to the beginning of the PlayQueue. If `after` is provided,
|
||||
the item will be placed immediately after the specified item.
|
||||
|
||||
Parameters:
|
||||
item (:class:`~plexapi.base.Playable`): An existing item in the PlayQueue to move.
|
||||
afterItemID (:class:`~plexapi.base.Playable`, optional): A different item in the PlayQueue.
|
||||
If provided, `item` will be placed in the PlayQueue after this item.
|
||||
refresh (bool, optional): Refresh the PlayQueue from the server before updating.
|
||||
"""
|
||||
args = {}
|
||||
|
||||
if refresh:
|
||||
self.refresh()
|
||||
|
||||
if item not in self:
|
||||
item = self.getQueueItem(item)
|
||||
|
||||
if after:
|
||||
if after not in self:
|
||||
after = self.getQueueItem(after)
|
||||
args["after"] = after.playQueueItemID
|
||||
|
||||
path = f"/playQueues/{self.playQueueID}/items/{item.playQueueItemID}/move{utils.joinArgs(args)}"
|
||||
data = self._server.query(path, method=self._server._session.put)
|
||||
self._loadData(data)
|
||||
return self
|
||||
|
||||
def removeItem(self, item, refresh=True):
|
||||
"""Remove an item from the PlayQueue.
|
||||
|
||||
Parameters:
|
||||
item (:class:`~plexapi.base.Playable`): An existing item in the PlayQueue to move.
|
||||
refresh (bool, optional): Refresh the PlayQueue from the server before updating.
|
||||
"""
|
||||
if refresh:
|
||||
self.refresh()
|
||||
|
||||
if item not in self:
|
||||
item = self.getQueueItem(item)
|
||||
|
||||
path = f"/playQueues/{self.playQueueID}/items/{item.playQueueItemID}"
|
||||
data = self._server.query(path, method=self._server._session.delete)
|
||||
self._loadData(data)
|
||||
return self
|
||||
|
||||
def clear(self):
|
||||
"""Remove all items from the PlayQueue."""
|
||||
path = f"/playQueues/{self.playQueueID}/items"
|
||||
data = self._server.query(path, method=self._server._session.delete)
|
||||
self._loadData(data)
|
||||
return self
|
||||
|
||||
def refresh(self):
|
||||
"""Refresh the PlayQueue from the Plex server."""
|
||||
path = f"/playQueues/{self.playQueueID}"
|
||||
data = self._server.query(path, method=self._server._session.get)
|
||||
self._loadData(data)
|
||||
return self
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,186 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from collections import defaultdict
|
||||
from urllib.parse import quote
|
||||
|
||||
from plexapi import log, utils
|
||||
from plexapi.base import PlexObject
|
||||
from plexapi.exceptions import BadRequest, NotFound
|
||||
|
||||
|
||||
class Settings(PlexObject):
|
||||
""" Container class for all settings. Allows getting and setting PlexServer settings.
|
||||
|
||||
Attributes:
|
||||
key (str): '/:/prefs'
|
||||
"""
|
||||
key = '/:/prefs'
|
||||
|
||||
def __init__(self, server, data, initpath=None):
|
||||
self._settings = {}
|
||||
super(Settings, self).__init__(server, data, initpath)
|
||||
|
||||
def __getattr__(self, attr):
|
||||
if attr.startswith('_'):
|
||||
try:
|
||||
return self.__dict__[attr]
|
||||
except KeyError:
|
||||
raise AttributeError
|
||||
return self.get(attr).value
|
||||
|
||||
def __setattr__(self, attr, value):
|
||||
if not attr.startswith('_'):
|
||||
return self.get(attr).set(value)
|
||||
self.__dict__[attr] = value
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self._data = data
|
||||
for elem in data:
|
||||
id = utils.lowerFirst(elem.attrib['id'])
|
||||
if id in self._settings:
|
||||
self._settings[id]._loadData(elem)
|
||||
continue
|
||||
self._settings[id] = Setting(self._server, elem, self._initpath)
|
||||
|
||||
def all(self):
|
||||
""" Returns a list of all :class:`~plexapi.settings.Setting` objects available. """
|
||||
return [v for id, v in sorted(self._settings.items())]
|
||||
|
||||
def get(self, id):
|
||||
""" Return the :class:`~plexapi.settings.Setting` object with the specified id. """
|
||||
id = utils.lowerFirst(id)
|
||||
if id in self._settings:
|
||||
return self._settings[id]
|
||||
raise NotFound(f'Invalid setting id: {id}')
|
||||
|
||||
def groups(self):
|
||||
""" Returns a dict of lists for all :class:`~plexapi.settings.Setting`
|
||||
objects grouped by setting group.
|
||||
"""
|
||||
groups = defaultdict(list)
|
||||
for setting in self.all():
|
||||
groups[setting.group].append(setting)
|
||||
return dict(groups)
|
||||
|
||||
def group(self, group):
|
||||
""" Return a list of all :class:`~plexapi.settings.Setting` objects in the specified group.
|
||||
|
||||
Parameters:
|
||||
group (str): Group to return all settings.
|
||||
"""
|
||||
return self.groups().get(group, [])
|
||||
|
||||
def save(self):
|
||||
""" Save any outstanding setting changes to the :class:`~plexapi.server.PlexServer`. This
|
||||
performs a full reload() of Settings after complete.
|
||||
"""
|
||||
params = {}
|
||||
for setting in self.all():
|
||||
if setting._setValue:
|
||||
log.info('Saving PlexServer setting %s = %s', setting.id, setting._setValue)
|
||||
params[setting.id] = quote(setting._setValue)
|
||||
if not params:
|
||||
raise BadRequest('No setting have been modified.')
|
||||
querystr = '&'.join(f'{k}={v}' for k, v in params.items())
|
||||
url = f'{self.key}?{querystr}'
|
||||
self._server.query(url, self._server._session.put)
|
||||
self.reload()
|
||||
|
||||
|
||||
class Setting(PlexObject):
|
||||
""" Represents a single Plex setting.
|
||||
|
||||
Attributes:
|
||||
id (str): Setting id (or name).
|
||||
label (str): Short description of what this setting is.
|
||||
summary (str): Long description of what this setting is.
|
||||
type (str): Setting type (text, int, double, bool).
|
||||
default (str): Default value for this setting.
|
||||
value (str,bool,int,float): Current value for this setting.
|
||||
hidden (bool): True if this is a hidden setting.
|
||||
advanced (bool): True if this is an advanced setting.
|
||||
group (str): Group name this setting is categorized as.
|
||||
enumValues (list,dict): List or dictionary of valid values for this setting.
|
||||
"""
|
||||
_bool_cast = lambda x: bool(x == 'true' or x == '1')
|
||||
_bool_str = lambda x: str(x).lower()
|
||||
TYPES = {
|
||||
'bool': {'type': bool, 'cast': _bool_cast, 'tostr': _bool_str},
|
||||
'double': {'type': float, 'cast': float, 'tostr': str},
|
||||
'int': {'type': int, 'cast': int, 'tostr': str},
|
||||
'text': {'type': str, 'cast': str, 'tostr': str},
|
||||
}
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self.type = data.attrib.get('type')
|
||||
self.advanced = utils.cast(bool, data.attrib.get('advanced'))
|
||||
self.default = self._cast(data.attrib.get('default'))
|
||||
self.enumValues = self._getEnumValues(data)
|
||||
self.group = data.attrib.get('group')
|
||||
self.hidden = utils.cast(bool, data.attrib.get('hidden'))
|
||||
self.id = data.attrib.get('id')
|
||||
self.label = data.attrib.get('label')
|
||||
self.option = data.attrib.get('option')
|
||||
self.secure = utils.cast(bool, data.attrib.get('secure'))
|
||||
self.summary = data.attrib.get('summary')
|
||||
self.value = self._cast(data.attrib.get('value'))
|
||||
self._setValue = None
|
||||
|
||||
def _cast(self, value):
|
||||
""" Cast the specific value to the type of this setting. """
|
||||
if self.type != 'enum':
|
||||
value = utils.cast(self.TYPES.get(self.type)['cast'], value)
|
||||
return value
|
||||
|
||||
def _getEnumValues(self, data):
|
||||
""" Returns a list or dictionary of values for this setting. """
|
||||
enumstr = data.attrib.get('enumValues') or data.attrib.get('values')
|
||||
if not enumstr:
|
||||
return None
|
||||
if ':' in enumstr:
|
||||
d = {}
|
||||
for kv in enumstr.split('|'):
|
||||
try:
|
||||
k, v = kv.split(':')
|
||||
d[self._cast(k)] = v
|
||||
except ValueError:
|
||||
d[self._cast(kv)] = kv
|
||||
return d
|
||||
return enumstr.split('|')
|
||||
|
||||
def set(self, value):
|
||||
""" Set a new value for this setting. NOTE: You must call plex.settings.save() for before
|
||||
any changes to setting values are persisted to the :class:`~plexapi.server.PlexServer`.
|
||||
"""
|
||||
# check a few things up front
|
||||
if not isinstance(value, self.TYPES[self.type]['type']):
|
||||
badtype = type(value).__name__
|
||||
raise BadRequest(f'Invalid value for {self.id}: a {self.type} is required, not {badtype}')
|
||||
if self.enumValues and value not in self.enumValues:
|
||||
raise BadRequest(f'Invalid value for {self.id}: {value} not in {list(self.enumValues)}')
|
||||
# store value off to the side until we call settings.save()
|
||||
tostr = self.TYPES[self.type]['tostr']
|
||||
self._setValue = tostr(value)
|
||||
|
||||
def toUrl(self):
|
||||
"""Helper for urls"""
|
||||
return f'{self.id}={self._value or self.value}'
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Preferences(Setting):
|
||||
""" Represents a single Preferences.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Setting'
|
||||
FILTER (str): 'preferences'
|
||||
"""
|
||||
TAG = 'Setting'
|
||||
FILTER = 'preferences'
|
||||
|
||||
def _default(self):
|
||||
""" Set the default value for this setting."""
|
||||
key = f'{self._initpath}/prefs?'
|
||||
url = key + f'{self.id}={self.default}'
|
||||
self._server.query(url, method=self._server._session.put)
|
@ -0,0 +1,116 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import requests
|
||||
|
||||
from plexapi import CONFIG, X_PLEX_IDENTIFIER, TIMEOUT
|
||||
from plexapi.client import PlexClient
|
||||
from plexapi.exceptions import BadRequest
|
||||
from plexapi.playqueue import PlayQueue
|
||||
|
||||
|
||||
class PlexSonosClient(PlexClient):
|
||||
""" Class for interacting with a Sonos speaker via the Plex API. This class
|
||||
makes requests to an external Plex API which then forwards the
|
||||
Sonos-specific commands back to your Plex server & Sonos speakers. Use
|
||||
of this feature requires an active Plex Pass subscription and Sonos
|
||||
speakers linked to your Plex account. It also requires remote access to
|
||||
be working properly.
|
||||
|
||||
More details on the Sonos integration are available here:
|
||||
https://support.plex.tv/articles/218237558-requirements-for-using-plex-for-sonos/
|
||||
|
||||
The Sonos API emulates the Plex player control API closely:
|
||||
https://github.com/plexinc/plex-media-player/wiki/Remote-control-API
|
||||
|
||||
Parameters:
|
||||
account (:class:`~plexapi.myplex.PlexAccount`): PlexAccount instance this
|
||||
Sonos speaker is associated with.
|
||||
data (ElementTree): Response from Plex Sonos API used to build this client.
|
||||
|
||||
Attributes:
|
||||
deviceClass (str): "speaker"
|
||||
lanIP (str): Local IP address of speaker.
|
||||
machineIdentifier (str): Unique ID for this device.
|
||||
platform (str): "Sonos"
|
||||
platformVersion (str): Build version of Sonos speaker firmware.
|
||||
product (str): "Sonos"
|
||||
protocol (str): "plex"
|
||||
protocolCapabilities (list<str>): List of client capabilities (timeline, playback,
|
||||
playqueues, provider-playback)
|
||||
server (:class:`~plexapi.server.PlexServer`): Server this client is connected to.
|
||||
session (:class:`~requests.Session`): Session object used for connection.
|
||||
title (str): Name of this Sonos speaker.
|
||||
token (str): X-Plex-Token used for authentication
|
||||
_baseurl (str): Address of public Plex Sonos API endpoint.
|
||||
_commandId (int): Counter for commands sent to Plex API.
|
||||
_token (str): Token associated with linked Plex account.
|
||||
_session (obj): Requests session object used to access this client.
|
||||
"""
|
||||
|
||||
def __init__(self, account, data, timeout=None):
|
||||
self._data = data
|
||||
self.deviceClass = data.attrib.get("deviceClass")
|
||||
self.machineIdentifier = data.attrib.get("machineIdentifier")
|
||||
self.product = data.attrib.get("product")
|
||||
self.platform = data.attrib.get("platform")
|
||||
self.platformVersion = data.attrib.get("platformVersion")
|
||||
self.protocol = data.attrib.get("protocol")
|
||||
self.protocolCapabilities = data.attrib.get("protocolCapabilities")
|
||||
self.lanIP = data.attrib.get("lanIP")
|
||||
self.title = data.attrib.get("title")
|
||||
self._baseurl = "https://sonos.plex.tv"
|
||||
self._commandId = 0
|
||||
self._token = account._token
|
||||
self._session = account._session or requests.Session()
|
||||
|
||||
# Dummy values for PlexClient inheritance
|
||||
self._last_call = 0
|
||||
self._proxyThroughServer = False
|
||||
self._showSecrets = CONFIG.get("log.show_secrets", "").lower() == "true"
|
||||
self._timeout = timeout or TIMEOUT
|
||||
|
||||
def playMedia(self, media, offset=0, **params):
|
||||
|
||||
if hasattr(media, "playlistType"):
|
||||
mediatype = media.playlistType
|
||||
else:
|
||||
if isinstance(media, PlayQueue):
|
||||
mediatype = media.items[0].listType
|
||||
else:
|
||||
mediatype = media.listType
|
||||
|
||||
if mediatype == "audio":
|
||||
mediatype = "music"
|
||||
else:
|
||||
raise BadRequest("Sonos currently only supports music for playback")
|
||||
|
||||
server_protocol, server_address, server_port = media._server._baseurl.split(":")
|
||||
server_address = server_address.strip("/")
|
||||
server_port = server_port.strip("/")
|
||||
|
||||
playqueue = (
|
||||
media
|
||||
if isinstance(media, PlayQueue)
|
||||
else media._server.createPlayQueue(media)
|
||||
)
|
||||
self.sendCommand(
|
||||
"playback/playMedia",
|
||||
**dict(
|
||||
{
|
||||
"type": "music",
|
||||
"providerIdentifier": "com.plexapp.plugins.library",
|
||||
"containerKey": f"/playQueues/{playqueue.playQueueID}?own=1",
|
||||
"key": media.key,
|
||||
"offset": offset,
|
||||
"machineIdentifier": media._server.machineIdentifier,
|
||||
"protocol": server_protocol,
|
||||
"address": server_address,
|
||||
"port": server_port,
|
||||
"token": media._server.createToken(),
|
||||
"commandID": self._nextCommandId(),
|
||||
"X-Plex-Client-Identifier": X_PLEX_IDENTIFIER,
|
||||
"X-Plex-Token": media._server._token,
|
||||
"X-Plex-Target-Client-Identifier": self.machineIdentifier,
|
||||
},
|
||||
**params
|
||||
)
|
||||
)
|
@ -0,0 +1,312 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
You can work with Mobile Sync on other devices straight away, but if you'd like to use your app as a `sync-target` (when
|
||||
you can set items to be synced to your app) you need to init some variables.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
def init_sync():
|
||||
import plexapi
|
||||
plexapi.X_PLEX_PROVIDES = 'sync-target'
|
||||
plexapi.BASE_HEADERS['X-Plex-Sync-Version'] = '2'
|
||||
plexapi.BASE_HEADERS['X-Plex-Provides'] = plexapi.X_PLEX_PROVIDES
|
||||
|
||||
# mimic iPhone SE
|
||||
plexapi.X_PLEX_PLATFORM = 'iOS'
|
||||
plexapi.X_PLEX_PLATFORM_VERSION = '11.4.1'
|
||||
plexapi.X_PLEX_DEVICE = 'iPhone'
|
||||
|
||||
plexapi.BASE_HEADERS['X-Plex-Platform'] = plexapi.X_PLEX_PLATFORM
|
||||
plexapi.BASE_HEADERS['X-Plex-Platform-Version'] = plexapi.X_PLEX_PLATFORM_VERSION
|
||||
plexapi.BASE_HEADERS['X-Plex-Device'] = plexapi.X_PLEX_DEVICE
|
||||
|
||||
You have to fake platform/device/model because transcoding profiles are hardcoded in Plex, and you obviously have
|
||||
to explicitly specify that your app supports `sync-target`.
|
||||
"""
|
||||
import requests
|
||||
|
||||
import plexapi
|
||||
from plexapi.base import PlexObject
|
||||
from plexapi.exceptions import NotFound, BadRequest
|
||||
|
||||
|
||||
class SyncItem(PlexObject):
|
||||
"""
|
||||
Represents single sync item, for specified server and client. When you saying in the UI to sync "this" to "that"
|
||||
you're basically creating a sync item.
|
||||
|
||||
Attributes:
|
||||
id (int): unique id of the item.
|
||||
clientIdentifier (str): an identifier of Plex Client device, to which the item is belongs.
|
||||
machineIdentifier (str): the id of server which holds all this content.
|
||||
version (int): current version of the item. Each time you modify the item (e.g. by changing amount if media to
|
||||
sync) the new version is created.
|
||||
rootTitle (str): the title of library/media from which the sync item was created. E.g.:
|
||||
|
||||
* when you create an item for an episode 3 of season 3 of show Example, the value would be `Title of
|
||||
Episode 3`
|
||||
* when you create an item for a season 3 of show Example, the value would be `Season 3`
|
||||
* when you set to sync all your movies in library named "My Movies" to value would be `My Movies`.
|
||||
|
||||
title (str): the title which you've set when created the sync item.
|
||||
metadataType (str): the type of media which hides inside, can be `episode`, `movie`, etc.
|
||||
contentType (str): basic type of the content: `video` or `audio`.
|
||||
status (:class:`~plexapi.sync.Status`): current status of the sync.
|
||||
mediaSettings (:class:`~plexapi.sync.MediaSettings`): media transcoding settings used for the item.
|
||||
policy (:class:`~plexapi.sync.Policy`): the policy of which media to sync.
|
||||
location (str): plex-style library url with all required filters / sorting.
|
||||
"""
|
||||
TAG = 'SyncItem'
|
||||
|
||||
def __init__(self, server, data, initpath=None, clientIdentifier=None):
|
||||
super(SyncItem, self).__init__(server, data, initpath)
|
||||
self.clientIdentifier = clientIdentifier
|
||||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
self.id = plexapi.utils.cast(int, data.attrib.get('id'))
|
||||
self.version = plexapi.utils.cast(int, data.attrib.get('version'))
|
||||
self.rootTitle = data.attrib.get('rootTitle')
|
||||
self.title = data.attrib.get('title')
|
||||
self.metadataType = data.attrib.get('metadataType')
|
||||
self.contentType = data.attrib.get('contentType')
|
||||
self.machineIdentifier = data.find('Server').get('machineIdentifier')
|
||||
self.status = Status(**data.find('Status').attrib)
|
||||
self.mediaSettings = MediaSettings(**data.find('MediaSettings').attrib)
|
||||
self.policy = Policy(**data.find('Policy').attrib)
|
||||
self.location = data.find('Location').attrib.get('uri', '')
|
||||
|
||||
def server(self):
|
||||
""" Returns :class:`~plexapi.myplex.MyPlexResource` with server of current item. """
|
||||
server = [s for s in self._server.resources() if s.clientIdentifier == self.machineIdentifier]
|
||||
if len(server) == 0:
|
||||
raise NotFound(f'Unable to find server with uuid {self.machineIdentifier}')
|
||||
return server[0]
|
||||
|
||||
def getMedia(self):
|
||||
""" Returns list of :class:`~plexapi.base.Playable` which belong to this sync item. """
|
||||
server = self.server().connect()
|
||||
key = f'/sync/items/{self.id}'
|
||||
return server.fetchItems(key)
|
||||
|
||||
def markDownloaded(self, media):
|
||||
""" Mark the file as downloaded (by the nature of Plex it will be marked as downloaded within
|
||||
any SyncItem where it presented).
|
||||
|
||||
Parameters:
|
||||
media (base.Playable): the media to be marked as downloaded.
|
||||
"""
|
||||
url = f'/sync/{self.clientIdentifier}/item/{media.ratingKey}/downloaded'
|
||||
media._server.query(url, method=requests.put)
|
||||
|
||||
def delete(self):
|
||||
""" Removes current SyncItem """
|
||||
url = SyncList.key.format(clientId=self.clientIdentifier)
|
||||
url += '/' + str(self.id)
|
||||
self._server.query(url, self._server._session.delete)
|
||||
|
||||
|
||||
class SyncList(PlexObject):
|
||||
""" Represents a Mobile Sync state, specific for single client, within one SyncList may be presented
|
||||
items from different servers.
|
||||
|
||||
Attributes:
|
||||
clientId (str): an identifier of the client.
|
||||
items (List<:class:`~plexapi.sync.SyncItem`>): list of registered items to sync.
|
||||
"""
|
||||
key = 'https://plex.tv/devices/{clientId}/sync_items'
|
||||
TAG = 'SyncList'
|
||||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
self.clientId = data.attrib.get('clientIdentifier')
|
||||
self.items = []
|
||||
|
||||
syncItems = data.find('SyncItems')
|
||||
if syncItems:
|
||||
for sync_item in syncItems.iter('SyncItem'):
|
||||
item = SyncItem(self._server, sync_item, clientIdentifier=self.clientId)
|
||||
self.items.append(item)
|
||||
|
||||
|
||||
class Status:
|
||||
""" Represents a current status of specific :class:`~plexapi.sync.SyncItem`.
|
||||
|
||||
Attributes:
|
||||
failureCode: unknown, never got one yet.
|
||||
failure: unknown.
|
||||
state (str): server-side status of the item, can be `completed`, `pending`, empty, and probably something
|
||||
else.
|
||||
itemsCount (int): total items count.
|
||||
itemsCompleteCount (int): count of transcoded and/or downloaded items.
|
||||
itemsDownloadedCount (int): count of downloaded items.
|
||||
itemsReadyCount (int): count of transcoded items, which can be downloaded.
|
||||
totalSize (int): total size in bytes of complete items.
|
||||
itemsSuccessfulCount (int): unknown, in my experience it always was equal to `itemsCompleteCount`.
|
||||
"""
|
||||
|
||||
def __init__(self, itemsCount, itemsCompleteCount, state, totalSize, itemsDownloadedCount, itemsReadyCount,
|
||||
itemsSuccessfulCount, failureCode, failure):
|
||||
self.itemsDownloadedCount = plexapi.utils.cast(int, itemsDownloadedCount)
|
||||
self.totalSize = plexapi.utils.cast(int, totalSize)
|
||||
self.itemsReadyCount = plexapi.utils.cast(int, itemsReadyCount)
|
||||
self.failureCode = failureCode
|
||||
self.failure = failure
|
||||
self.itemsSuccessfulCount = plexapi.utils.cast(int, itemsSuccessfulCount)
|
||||
self.state = state
|
||||
self.itemsCompleteCount = plexapi.utils.cast(int, itemsCompleteCount)
|
||||
self.itemsCount = plexapi.utils.cast(int, itemsCount)
|
||||
|
||||
def __repr__(self):
|
||||
d = dict(
|
||||
itemsCount=self.itemsCount,
|
||||
itemsCompleteCount=self.itemsCompleteCount,
|
||||
itemsDownloadedCount=self.itemsDownloadedCount,
|
||||
itemsReadyCount=self.itemsReadyCount,
|
||||
itemsSuccessfulCount=self.itemsSuccessfulCount
|
||||
)
|
||||
return f'<{self.__class__.__name__}>:{d}'
|
||||
|
||||
|
||||
class MediaSettings:
|
||||
""" Transcoding settings used for all media within :class:`~plexapi.sync.SyncItem`.
|
||||
|
||||
Attributes:
|
||||
audioBoost (int): unknown.
|
||||
maxVideoBitrate (int|str): maximum bitrate for video, may be empty string.
|
||||
musicBitrate (int|str): maximum bitrate for music, may be an empty string.
|
||||
photoQuality (int): photo quality on scale 0 to 100.
|
||||
photoResolution (str): maximum photo resolution, formatted as WxH (e.g. `1920x1080`).
|
||||
videoResolution (str): maximum video resolution, formatted as WxH (e.g. `1280x720`, may be empty).
|
||||
subtitleSize (int): subtitle size on scale 0 to 100.
|
||||
videoQuality (int): video quality on scale 0 to 100.
|
||||
"""
|
||||
|
||||
def __init__(self, maxVideoBitrate=4000, videoQuality=100, videoResolution='1280x720', audioBoost=100,
|
||||
musicBitrate=192, photoQuality=74, photoResolution='1920x1080', subtitleSize=100):
|
||||
self.audioBoost = plexapi.utils.cast(int, audioBoost)
|
||||
self.maxVideoBitrate = plexapi.utils.cast(int, maxVideoBitrate) if maxVideoBitrate != '' else ''
|
||||
self.musicBitrate = plexapi.utils.cast(int, musicBitrate) if musicBitrate != '' else ''
|
||||
self.photoQuality = plexapi.utils.cast(int, photoQuality) if photoQuality != '' else ''
|
||||
self.photoResolution = photoResolution
|
||||
self.videoResolution = videoResolution
|
||||
self.subtitleSize = plexapi.utils.cast(int, subtitleSize) if subtitleSize != '' else ''
|
||||
self.videoQuality = plexapi.utils.cast(int, videoQuality) if videoQuality != '' else ''
|
||||
|
||||
@staticmethod
|
||||
def createVideo(videoQuality):
|
||||
""" Returns a :class:`~plexapi.sync.MediaSettings` object, based on provided video quality value.
|
||||
|
||||
Parameters:
|
||||
videoQuality (int): idx of quality of the video, one of VIDEO_QUALITY_* values defined in this module.
|
||||
|
||||
Raises:
|
||||
:exc:`~plexapi.exceptions.BadRequest`: When provided unknown video quality.
|
||||
"""
|
||||
if videoQuality == VIDEO_QUALITY_ORIGINAL:
|
||||
return MediaSettings('', '', '')
|
||||
elif videoQuality < len(VIDEO_QUALITIES['bitrate']):
|
||||
return MediaSettings(VIDEO_QUALITIES['bitrate'][videoQuality],
|
||||
VIDEO_QUALITIES['videoQuality'][videoQuality],
|
||||
VIDEO_QUALITIES['videoResolution'][videoQuality])
|
||||
else:
|
||||
raise BadRequest('Unexpected video quality')
|
||||
|
||||
@staticmethod
|
||||
def createMusic(bitrate):
|
||||
""" Returns a :class:`~plexapi.sync.MediaSettings` object, based on provided music quality value
|
||||
|
||||
Parameters:
|
||||
bitrate (int): maximum bitrate for synchronized music, better use one of MUSIC_BITRATE_* values from the
|
||||
module
|
||||
"""
|
||||
return MediaSettings(musicBitrate=bitrate)
|
||||
|
||||
@staticmethod
|
||||
def createPhoto(resolution):
|
||||
""" Returns a :class:`~plexapi.sync.MediaSettings` object, based on provided photo quality value.
|
||||
|
||||
Parameters:
|
||||
resolution (str): maximum allowed resolution for synchronized photos, see PHOTO_QUALITY_* values in the
|
||||
module.
|
||||
|
||||
Raises:
|
||||
:exc:`~plexapi.exceptions.BadRequest`: When provided unknown video quality.
|
||||
"""
|
||||
if resolution in PHOTO_QUALITIES:
|
||||
return MediaSettings(photoQuality=PHOTO_QUALITIES[resolution], photoResolution=resolution)
|
||||
else:
|
||||
raise BadRequest('Unexpected photo quality')
|
||||
|
||||
|
||||
class Policy:
|
||||
""" Policy of syncing the media (how many items to sync and process watched media or not).
|
||||
|
||||
Attributes:
|
||||
scope (str): type of limitation policy, can be `count` or `all`.
|
||||
value (int): amount of media to sync, valid only when `scope=count`.
|
||||
unwatched (bool): True means disallow to sync watched media.
|
||||
"""
|
||||
|
||||
def __init__(self, scope, unwatched, value=0):
|
||||
self.scope = scope
|
||||
self.unwatched = plexapi.utils.cast(bool, unwatched)
|
||||
self.value = plexapi.utils.cast(int, value)
|
||||
|
||||
@staticmethod
|
||||
def create(limit=None, unwatched=False):
|
||||
""" Creates a :class:`~plexapi.sync.Policy` object for provided options and automatically sets proper `scope`
|
||||
value.
|
||||
|
||||
Parameters:
|
||||
limit (int): limit items by count.
|
||||
unwatched (bool): if True then watched items wouldn't be synced.
|
||||
|
||||
Returns:
|
||||
:class:`~plexapi.sync.Policy`.
|
||||
"""
|
||||
scope = 'all'
|
||||
if limit is None:
|
||||
limit = 0
|
||||
else:
|
||||
scope = 'count'
|
||||
|
||||
return Policy(scope, unwatched, limit)
|
||||
|
||||
|
||||
VIDEO_QUALITIES = {
|
||||
'bitrate': [64, 96, 208, 320, 720, 1500, 2e3, 3e3, 4e3, 8e3, 1e4, 12e3, 2e4],
|
||||
'videoResolution': ['220x128', '220x128', '284x160', '420x240', '576x320', '720x480', '1280x720', '1280x720',
|
||||
'1280x720', '1920x1080', '1920x1080', '1920x1080', '1920x1080'],
|
||||
'videoQuality': [10, 20, 30, 30, 40, 60, 60, 75, 100, 60, 75, 90, 100],
|
||||
}
|
||||
|
||||
VIDEO_QUALITY_0_2_MBPS = 2
|
||||
VIDEO_QUALITY_0_3_MBPS = 3
|
||||
VIDEO_QUALITY_0_7_MBPS = 4
|
||||
VIDEO_QUALITY_1_5_MBPS_480p = 5
|
||||
VIDEO_QUALITY_2_MBPS_720p = 6
|
||||
VIDEO_QUALITY_3_MBPS_720p = 7
|
||||
VIDEO_QUALITY_4_MBPS_720p = 8
|
||||
VIDEO_QUALITY_8_MBPS_1080p = 9
|
||||
VIDEO_QUALITY_10_MBPS_1080p = 10
|
||||
VIDEO_QUALITY_12_MBPS_1080p = 11
|
||||
VIDEO_QUALITY_20_MBPS_1080p = 12
|
||||
VIDEO_QUALITY_ORIGINAL = -1
|
||||
|
||||
AUDIO_BITRATE_96_KBPS = 96
|
||||
AUDIO_BITRATE_128_KBPS = 128
|
||||
AUDIO_BITRATE_192_KBPS = 192
|
||||
AUDIO_BITRATE_320_KBPS = 320
|
||||
|
||||
PHOTO_QUALITIES = {
|
||||
'720x480': 24,
|
||||
'1280x720': 49,
|
||||
'1920x1080': 74,
|
||||
'3840x2160': 99,
|
||||
}
|
||||
|
||||
PHOTO_QUALITY_HIGHEST = PHOTO_QUALITY_2160p = '3840x2160'
|
||||
PHOTO_QUALITY_HIGH = PHOTO_QUALITY_1080p = '1920x1080'
|
||||
PHOTO_QUALITY_MEDIUM = PHOTO_QUALITY_720p = '1280x720'
|
||||
PHOTO_QUALITY_LOW = PHOTO_QUALITY_480p = '720x480'
|
@ -0,0 +1,720 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import base64
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import string
|
||||
import sys
|
||||
import time
|
||||
import unicodedata
|
||||
import warnings
|
||||
import zipfile
|
||||
from collections import deque
|
||||
from datetime import datetime, timedelta
|
||||
from getpass import getpass
|
||||
from hashlib import sha1
|
||||
from threading import Event, Thread
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
from requests.status_codes import _codes as codes
|
||||
|
||||
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
|
||||
|
||||
try:
|
||||
from tqdm import tqdm
|
||||
except ImportError:
|
||||
tqdm = None
|
||||
|
||||
log = logging.getLogger('plexapi')
|
||||
|
||||
# Search Types - Plex uses these to filter specific media types when searching.
|
||||
SEARCHTYPES = {
|
||||
'movie': 1,
|
||||
'show': 2,
|
||||
'season': 3,
|
||||
'episode': 4,
|
||||
'trailer': 5,
|
||||
'comic': 6,
|
||||
'person': 7,
|
||||
'artist': 8,
|
||||
'album': 9,
|
||||
'track': 10,
|
||||
'picture': 11,
|
||||
'clip': 12,
|
||||
'photo': 13,
|
||||
'photoalbum': 14,
|
||||
'playlist': 15,
|
||||
'playlistFolder': 16,
|
||||
'collection': 18,
|
||||
'optimizedVersion': 42,
|
||||
'userPlaylistItem': 1001,
|
||||
}
|
||||
REVERSESEARCHTYPES = {v: k for k, v in SEARCHTYPES.items()}
|
||||
|
||||
# Tag Types - Plex uses these to filter specific tags when searching.
|
||||
TAGTYPES = {
|
||||
'tag': 0,
|
||||
'genre': 1,
|
||||
'collection': 2,
|
||||
'director': 4,
|
||||
'writer': 5,
|
||||
'role': 6,
|
||||
'producer': 7,
|
||||
'country': 8,
|
||||
'chapter': 9,
|
||||
'review': 10,
|
||||
'label': 11,
|
||||
'marker': 12,
|
||||
'mediaProcessingTarget': 42,
|
||||
'make': 200,
|
||||
'model': 201,
|
||||
'aperture': 202,
|
||||
'exposure': 203,
|
||||
'iso': 204,
|
||||
'lens': 205,
|
||||
'device': 206,
|
||||
'autotag': 207,
|
||||
'mood': 300,
|
||||
'style': 301,
|
||||
'format': 302,
|
||||
'similar': 305,
|
||||
'concert': 306,
|
||||
'banner': 311,
|
||||
'poster': 312,
|
||||
'art': 313,
|
||||
'guid': 314,
|
||||
'ratingImage': 316,
|
||||
'theme': 317,
|
||||
'studio': 318,
|
||||
'network': 319,
|
||||
'showOrdering': 322,
|
||||
'clearLogo': 323,
|
||||
'place': 400,
|
||||
}
|
||||
REVERSETAGTYPES = {v: k for k, v in TAGTYPES.items()}
|
||||
|
||||
# Plex Objects - Populated at runtime
|
||||
PLEXOBJECTS = {}
|
||||
|
||||
|
||||
class SecretsFilter(logging.Filter):
|
||||
""" Logging filter to hide secrets. """
|
||||
|
||||
def __init__(self, secrets=None):
|
||||
self.secrets = secrets or set()
|
||||
|
||||
def add_secret(self, secret):
|
||||
if secret is not None and secret != '':
|
||||
self.secrets.add(secret)
|
||||
return secret
|
||||
|
||||
def filter(self, record):
|
||||
cleanargs = list(record.args)
|
||||
for i in range(len(cleanargs)):
|
||||
if isinstance(cleanargs[i], str):
|
||||
for secret in self.secrets:
|
||||
cleanargs[i] = cleanargs[i].replace(secret, '<hidden>')
|
||||
record.args = tuple(cleanargs)
|
||||
return True
|
||||
|
||||
|
||||
def registerPlexObject(cls):
|
||||
""" Registry of library types we may come across when parsing XML. This allows us to
|
||||
define a few helper functions to dynamically convert the XML into objects. See
|
||||
buildItem() below for an example.
|
||||
"""
|
||||
etype = getattr(cls, 'STREAMTYPE', getattr(cls, 'TAGTYPE', cls.TYPE))
|
||||
ehash = f'{cls.TAG}.{etype}' if etype else cls.TAG
|
||||
if getattr(cls, '_SESSIONTYPE', None):
|
||||
ehash = f"{ehash}.session"
|
||||
elif getattr(cls, '_HISTORYTYPE', None):
|
||||
ehash = f"{ehash}.history"
|
||||
if ehash in PLEXOBJECTS:
|
||||
raise Exception(f'Ambiguous PlexObject definition {cls.__name__}(tag={cls.TAG}, type={etype}) '
|
||||
f'with {PLEXOBJECTS[ehash].__name__}')
|
||||
PLEXOBJECTS[ehash] = cls
|
||||
return cls
|
||||
|
||||
|
||||
def getPlexObject(ehash, default):
|
||||
""" Return the PlexObject class for the specified ehash. This recursively looks up the class
|
||||
with the highest specificity, falling back to the default class if not found.
|
||||
"""
|
||||
cls = PLEXOBJECTS.get(ehash)
|
||||
if cls is not None:
|
||||
return cls
|
||||
if '.' in ehash:
|
||||
ehash = ehash.rsplit('.', 1)[0]
|
||||
return getPlexObject(ehash, default=default)
|
||||
return PLEXOBJECTS.get(default)
|
||||
|
||||
|
||||
def cast(func, value):
|
||||
""" Cast the specified value to the specified type (returned by func). Currently this
|
||||
only support str, int, float, bool. Should be extended if needed.
|
||||
|
||||
Parameters:
|
||||
func (func): Callback function to used cast to type (int, bool, float).
|
||||
value (any): value to be cast and returned.
|
||||
"""
|
||||
if value is None:
|
||||
return value
|
||||
if func == bool:
|
||||
if value in (1, True, "1", "true"):
|
||||
return True
|
||||
if value in (0, False, "0", "false"):
|
||||
return False
|
||||
raise ValueError(value)
|
||||
|
||||
if func in (int, float):
|
||||
try:
|
||||
return func(value)
|
||||
except ValueError:
|
||||
return float('nan')
|
||||
return func(value)
|
||||
|
||||
|
||||
def joinArgs(args):
|
||||
""" Returns a query string (uses for HTTP URLs) where only the value is URL encoded.
|
||||
Example return value: '?genre=action&type=1337'.
|
||||
|
||||
Parameters:
|
||||
args (dict): Arguments to include in query string.
|
||||
"""
|
||||
if not args:
|
||||
return ''
|
||||
arglist = []
|
||||
for key in sorted(args, key=lambda x: x.lower()):
|
||||
value = str(args[key])
|
||||
arglist.append(f"{key}={quote(value, safe='')}")
|
||||
return f"?{'&'.join(arglist)}"
|
||||
|
||||
|
||||
def lowerFirst(s):
|
||||
return s[0].lower() + s[1:]
|
||||
|
||||
|
||||
def rget(obj, attrstr, default=None, delim='.'): # pragma: no cover
|
||||
""" Returns the value at the specified attrstr location within a nested tree of
|
||||
dicts, lists, tuples, functions, classes, etc. The lookup is done recursively
|
||||
for each key in attrstr (split by by the delimiter) This function is heavily
|
||||
influenced by the lookups used in Django templates.
|
||||
|
||||
Parameters:
|
||||
obj (any): Object to start the lookup in (dict, obj, list, tuple, etc).
|
||||
attrstr (str): String to lookup (ex: 'foo.bar.baz.value')
|
||||
default (any): Default value to return if not found.
|
||||
delim (str): Delimiter separating keys in attrstr.
|
||||
"""
|
||||
try:
|
||||
parts = attrstr.split(delim, 1)
|
||||
attr = parts[0]
|
||||
attrstr = parts[1] if len(parts) == 2 else None
|
||||
if isinstance(obj, dict):
|
||||
value = obj[attr]
|
||||
elif isinstance(obj, list):
|
||||
value = obj[int(attr)]
|
||||
elif isinstance(obj, tuple):
|
||||
value = obj[int(attr)]
|
||||
elif isinstance(obj, object):
|
||||
value = getattr(obj, attr)
|
||||
if attrstr:
|
||||
return rget(value, attrstr, default, delim)
|
||||
return value
|
||||
except: # noqa: E722
|
||||
return default
|
||||
|
||||
|
||||
def searchType(libtype):
|
||||
""" Returns the integer value of the library string type.
|
||||
|
||||
Parameters:
|
||||
libtype (str): LibType to lookup (See :data:`~plexapi.utils.SEARCHTYPES`)
|
||||
|
||||
Raises:
|
||||
:exc:`~plexapi.exceptions.NotFound`: Unknown libtype
|
||||
"""
|
||||
libtype = str(libtype)
|
||||
try:
|
||||
return SEARCHTYPES[libtype]
|
||||
except KeyError:
|
||||
if libtype in [str(k) for k in REVERSESEARCHTYPES]:
|
||||
return libtype
|
||||
raise NotFound(f'Unknown libtype: {libtype}') from None
|
||||
|
||||
|
||||
def reverseSearchType(libtype):
|
||||
""" Returns the string value of the library type.
|
||||
|
||||
Parameters:
|
||||
libtype (int): Integer value of the library type.
|
||||
|
||||
Raises:
|
||||
:exc:`~plexapi.exceptions.NotFound`: Unknown libtype
|
||||
"""
|
||||
try:
|
||||
return REVERSESEARCHTYPES[int(libtype)]
|
||||
except (KeyError, ValueError):
|
||||
if libtype in SEARCHTYPES:
|
||||
return libtype
|
||||
raise NotFound(f'Unknown libtype: {libtype}') from None
|
||||
|
||||
|
||||
def tagType(tag):
|
||||
""" Returns the integer value of the library tag type.
|
||||
|
||||
Parameters:
|
||||
tag (str): Tag to lookup (See :data:`~plexapi.utils.TAGTYPES`)
|
||||
|
||||
Raises:
|
||||
:exc:`~plexapi.exceptions.NotFound`: Unknown tag
|
||||
"""
|
||||
tag = str(tag)
|
||||
try:
|
||||
return TAGTYPES[tag]
|
||||
except KeyError:
|
||||
if tag in [str(k) for k in REVERSETAGTYPES]:
|
||||
return tag
|
||||
raise NotFound(f'Unknown tag: {tag}') from None
|
||||
|
||||
|
||||
def reverseTagType(tag):
|
||||
""" Returns the string value of the library tag type.
|
||||
|
||||
Parameters:
|
||||
tag (int): Integer value of the library tag type.
|
||||
|
||||
Raises:
|
||||
:exc:`~plexapi.exceptions.NotFound`: Unknown tag
|
||||
"""
|
||||
try:
|
||||
return REVERSETAGTYPES[int(tag)]
|
||||
except (KeyError, ValueError):
|
||||
if tag in TAGTYPES:
|
||||
return tag
|
||||
raise NotFound(f'Unknown tag: {tag}') from None
|
||||
|
||||
|
||||
def threaded(callback, listargs):
|
||||
""" Returns the result of <callback> for each set of `*args` in listargs. Each call
|
||||
to <callback> is called concurrently in their own separate threads.
|
||||
|
||||
Parameters:
|
||||
callback (func): Callback function to apply to each set of `*args`.
|
||||
listargs (list): List of lists; `*args` to pass each thread.
|
||||
"""
|
||||
threads, results = [], []
|
||||
job_is_done_event = Event()
|
||||
for args in listargs:
|
||||
args += [results, len(results)]
|
||||
results.append(None)
|
||||
threads.append(Thread(target=callback, args=args, kwargs=dict(job_is_done_event=job_is_done_event)))
|
||||
threads[-1].daemon = True
|
||||
threads[-1].start()
|
||||
while not job_is_done_event.is_set():
|
||||
if all(not t.is_alive() for t in threads):
|
||||
break
|
||||
time.sleep(0.05)
|
||||
|
||||
return [r for r in results if r is not None]
|
||||
|
||||
|
||||
def toDatetime(value, format=None):
|
||||
""" Returns a datetime object from the specified value.
|
||||
|
||||
Parameters:
|
||||
value (str): value to return as a datetime
|
||||
format (str): Format to pass strftime (optional; if value is a str).
|
||||
"""
|
||||
if value is not None:
|
||||
if format:
|
||||
try:
|
||||
return datetime.strptime(value, format)
|
||||
except ValueError:
|
||||
log.info('Failed to parse "%s" to datetime as format "%s", defaulting to None', value, format)
|
||||
return None
|
||||
else:
|
||||
try:
|
||||
value = int(value)
|
||||
except ValueError:
|
||||
log.info('Failed to parse "%s" to datetime as timestamp, defaulting to None', value)
|
||||
return None
|
||||
try:
|
||||
return datetime.fromtimestamp(value)
|
||||
except (OSError, OverflowError, ValueError):
|
||||
try:
|
||||
return datetime.fromtimestamp(0) + timedelta(seconds=value)
|
||||
except OverflowError:
|
||||
log.info('Failed to parse "%s" to datetime as timestamp (out-of-bounds), defaulting to None', value)
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def millisecondToHumanstr(milliseconds):
|
||||
""" Returns human readable time duration [D day[s], ]HH:MM:SS.UUU from milliseconds.
|
||||
|
||||
Parameters:
|
||||
milliseconds (str, int): time duration in milliseconds.
|
||||
"""
|
||||
milliseconds = int(milliseconds)
|
||||
if milliseconds < 0:
|
||||
return '-' + millisecondToHumanstr(abs(milliseconds))
|
||||
secs, ms = divmod(milliseconds, 1000)
|
||||
mins, secs = divmod(secs, 60)
|
||||
hours, mins = divmod(mins, 60)
|
||||
days, hours = divmod(hours, 24)
|
||||
return ('' if days == 0 else f'{days} day{"s" if days > 1 else ""}, ') + f'{hours:02d}:{mins:02d}:{secs:02d}.{ms:03d}'
|
||||
|
||||
|
||||
def toList(value, itemcast=None, delim=','):
|
||||
""" Returns a list of strings from the specified value.
|
||||
|
||||
Parameters:
|
||||
value (str): comma delimited string to convert to list.
|
||||
itemcast (func): Function to cast each list item to (default str).
|
||||
delim (str): string delimiter (optional; default ',').
|
||||
"""
|
||||
value = value or ''
|
||||
itemcast = itemcast or str
|
||||
return [itemcast(item) for item in value.split(delim) if item != '']
|
||||
|
||||
|
||||
def cleanFilename(filename, replace='_'):
|
||||
whitelist = f"-_.()[] {string.ascii_letters}{string.digits}"
|
||||
cleaned_filename = unicodedata.normalize('NFKD', filename).encode('ASCII', 'ignore').decode()
|
||||
cleaned_filename = ''.join(c if c in whitelist else replace for c in cleaned_filename)
|
||||
return cleaned_filename
|
||||
|
||||
|
||||
def downloadSessionImages(server, filename=None, height=150, width=150,
|
||||
opacity=100, saturation=100): # pragma: no cover
|
||||
""" Helper to download a bif image or thumb.url from plex.server.sessions.
|
||||
|
||||
Parameters:
|
||||
filename (str): default to None,
|
||||
height (int): Height of the image.
|
||||
width (int): width of the image.
|
||||
opacity (int): Opacity of the resulting image (possibly deprecated).
|
||||
saturation (int): Saturating of the resulting image.
|
||||
|
||||
Returns:
|
||||
{'hellowlol': {'filepath': '<filepath>', 'url': 'http://<url>'},
|
||||
{'<username>': {filepath, url}}, ...
|
||||
"""
|
||||
info = {}
|
||||
for media in server.sessions():
|
||||
url = None
|
||||
for part in media.iterParts():
|
||||
if media.thumb:
|
||||
url = media.thumb
|
||||
if part.indexes: # always use bif images if available.
|
||||
url = f'/library/parts/{part.id}/indexes/{part.indexes.lower()}/{media.viewOffset}'
|
||||
if url:
|
||||
if filename is None:
|
||||
prettyname = media._prettyfilename()
|
||||
filename = f'session_transcode_{media.usernames[0]}_{prettyname}_{int(time.time())}'
|
||||
url = server.transcodeImage(url, height, width, opacity, saturation)
|
||||
filepath = download(url, server._token, filename=filename)
|
||||
info['username'] = {'filepath': filepath, 'url': url}
|
||||
return info
|
||||
|
||||
|
||||
def download(url, token, filename=None, savepath=None, session=None, chunksize=4096, # noqa: C901
|
||||
unpack=False, mocked=False, showstatus=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.
|
||||
token (str): Plex auth token to include in headers.
|
||||
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 everything except write the file.
|
||||
unpack (bool): Unpack the zip file.
|
||||
showstatus(bool): Display a progressbar.
|
||||
|
||||
Example:
|
||||
>>> download(a_episode.getStreamURL(), a_episode.location)
|
||||
/path/to/file
|
||||
"""
|
||||
# fetch the data to be saved
|
||||
session = session or requests.Session()
|
||||
headers = {'X-Plex-Token': token}
|
||||
response = session.get(url, headers=headers, stream=True)
|
||||
if response.status_code not in (200, 201, 204):
|
||||
codename = codes.get(response.status_code)[0]
|
||||
errtext = response.text.replace('\n', ' ')
|
||||
message = f'({response.status_code}) {codename}; {response.url} {errtext}'
|
||||
if response.status_code == 401:
|
||||
raise Unauthorized(message)
|
||||
elif response.status_code == 404:
|
||||
raise NotFound(message)
|
||||
else:
|
||||
raise BadRequest(message)
|
||||
|
||||
# make sure the savepath directory exists
|
||||
savepath = savepath or os.getcwd()
|
||||
os.makedirs(savepath, exist_ok=True)
|
||||
|
||||
# try getting filename from header if not specified in arguments (used for logs, db)
|
||||
if not filename and response.headers.get('Content-Disposition'):
|
||||
filename = re.findall(r'filename=\"(.+)\"', response.headers.get('Content-Disposition'))
|
||||
filename = filename[0] if filename[0] else None
|
||||
|
||||
filename = os.path.basename(filename)
|
||||
fullpath = os.path.join(savepath, filename)
|
||||
# append file.ext from content-type if not already there
|
||||
extension = os.path.splitext(fullpath)[-1]
|
||||
if not extension:
|
||||
contenttype = response.headers.get('content-type')
|
||||
if contenttype and 'image' in contenttype:
|
||||
fullpath += contenttype.split('/')[1]
|
||||
|
||||
# check this is a mocked download (testing)
|
||||
if mocked:
|
||||
log.debug('Mocked download %s', fullpath)
|
||||
return fullpath
|
||||
|
||||
# save the file to disk
|
||||
log.info('Downloading: %s', fullpath)
|
||||
if showstatus and tqdm: # pragma: no cover
|
||||
total = int(response.headers.get('content-length', 0))
|
||||
bar = tqdm(unit='B', unit_scale=True, total=total, desc=filename)
|
||||
|
||||
with open(fullpath, 'wb') as handle:
|
||||
for chunk in response.iter_content(chunk_size=chunksize):
|
||||
handle.write(chunk)
|
||||
if showstatus and tqdm:
|
||||
bar.update(len(chunk))
|
||||
|
||||
if showstatus and tqdm: # pragma: no cover
|
||||
bar.close()
|
||||
# check we want to unzip the contents
|
||||
if fullpath.endswith('zip') and unpack:
|
||||
with zipfile.ZipFile(fullpath, 'r') as handle:
|
||||
handle.extractall(savepath)
|
||||
|
||||
return fullpath
|
||||
|
||||
|
||||
def getMyPlexAccount(opts=None): # pragma: no cover
|
||||
""" Helper function tries to get a MyPlex Account instance by checking
|
||||
the the following locations for a username and password. This is
|
||||
useful to create user-friendly command line tools.
|
||||
1. command-line options (opts).
|
||||
2. environment variables and config.ini
|
||||
3. Prompt on the command line.
|
||||
"""
|
||||
from plexapi import CONFIG
|
||||
from plexapi.myplex import MyPlexAccount
|
||||
# 1. Check command-line options
|
||||
if opts and opts.username and opts.password:
|
||||
print(f'Authenticating with Plex.tv as {opts.username}..')
|
||||
return MyPlexAccount(opts.username, opts.password)
|
||||
# 2. Check Plexconfig (environment variables and config.ini)
|
||||
config_username = CONFIG.get('auth.myplex_username')
|
||||
config_password = CONFIG.get('auth.myplex_password')
|
||||
if config_username and config_password:
|
||||
print(f'Authenticating with Plex.tv as {config_username}..')
|
||||
return MyPlexAccount(config_username, config_password)
|
||||
config_token = CONFIG.get('auth.server_token')
|
||||
if config_token:
|
||||
print('Authenticating with Plex.tv with token')
|
||||
return MyPlexAccount(token=config_token)
|
||||
# 3. Prompt for username and password on the command line
|
||||
username = input('What is your plex.tv username: ')
|
||||
password = getpass('What is your plex.tv password: ')
|
||||
print(f'Authenticating with Plex.tv as {username}..')
|
||||
return MyPlexAccount(username, password)
|
||||
|
||||
|
||||
def createMyPlexDevice(headers, account, timeout=10): # pragma: no cover
|
||||
""" Helper function to create a new MyPlexDevice. Returns a new MyPlexDevice instance.
|
||||
|
||||
Parameters:
|
||||
headers (dict): Provide the X-Plex- headers for the new device.
|
||||
A unique X-Plex-Client-Identifier is required.
|
||||
account (MyPlexAccount): The Plex account to create the device on.
|
||||
timeout (int): Timeout in seconds to wait for device login.
|
||||
"""
|
||||
from plexapi.myplex import MyPlexPinLogin
|
||||
|
||||
if 'X-Plex-Client-Identifier' not in headers:
|
||||
raise BadRequest('The X-Plex-Client-Identifier header is required.')
|
||||
|
||||
clientIdentifier = headers['X-Plex-Client-Identifier']
|
||||
|
||||
pinlogin = MyPlexPinLogin(headers=headers)
|
||||
pinlogin.run(timeout=timeout)
|
||||
account.link(pinlogin.pin)
|
||||
pinlogin.waitForLogin()
|
||||
|
||||
return account.device(clientId=clientIdentifier)
|
||||
|
||||
|
||||
def plexOAuth(headers, forwardUrl=None, timeout=120): # pragma: no cover
|
||||
""" Helper function for Plex OAuth login. Returns a new MyPlexAccount instance.
|
||||
|
||||
Parameters:
|
||||
headers (dict): Provide the X-Plex- headers for the new device.
|
||||
A unique X-Plex-Client-Identifier is required.
|
||||
forwardUrl (str, optional): The url to redirect the client to after login.
|
||||
timeout (int, optional): Timeout in seconds to wait for device login. Default 120 seconds.
|
||||
"""
|
||||
from plexapi.myplex import MyPlexAccount, MyPlexPinLogin
|
||||
|
||||
if 'X-Plex-Client-Identifier' not in headers:
|
||||
raise BadRequest('The X-Plex-Client-Identifier header is required.')
|
||||
|
||||
pinlogin = MyPlexPinLogin(headers=headers, oauth=True)
|
||||
print('Login to Plex at the following url:')
|
||||
print(pinlogin.oauthUrl(forwardUrl))
|
||||
pinlogin.run(timeout=timeout)
|
||||
pinlogin.waitForLogin()
|
||||
|
||||
if pinlogin.token:
|
||||
print('Login successful!')
|
||||
return MyPlexAccount(token=pinlogin.token)
|
||||
else:
|
||||
print('Login failed.')
|
||||
|
||||
|
||||
def choose(msg, items, attr): # pragma: no cover
|
||||
""" Command line helper to display a list of choices, asking the
|
||||
user to choose one of the options.
|
||||
"""
|
||||
# Return the first item if there is only one choice
|
||||
if len(items) == 1:
|
||||
return items[0]
|
||||
# Print all choices to the command line
|
||||
print()
|
||||
for index, i in enumerate(items):
|
||||
name = attr(i) if callable(attr) else getattr(i, attr)
|
||||
print(f' {index}: {name}')
|
||||
print()
|
||||
# Request choice from the user
|
||||
while True:
|
||||
try:
|
||||
inp = input(f'{msg}: ')
|
||||
if any(s in inp for s in (':', '::', '-')):
|
||||
idx = slice(*map(lambda x: int(x.strip()) if x.strip() else None, inp.split(':')))
|
||||
return items[idx]
|
||||
else:
|
||||
return items[int(inp)]
|
||||
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
|
||||
def getAgentIdentifier(section, agent):
|
||||
""" Return the full agent identifier from a short identifier, name, or confirm full identifier. """
|
||||
agents = []
|
||||
for ag in section.agents():
|
||||
identifiers = [ag.identifier, ag.shortIdentifier, ag.name]
|
||||
if agent in identifiers:
|
||||
return ag.identifier
|
||||
agents += identifiers
|
||||
raise NotFound(f"Could not find \"{agent}\" in agents list ({', '.join(agents)})")
|
||||
|
||||
|
||||
def base64str(text):
|
||||
return base64.b64encode(text.encode('utf-8')).decode('utf-8')
|
||||
|
||||
|
||||
def deprecated(message, stacklevel=2):
|
||||
def decorator(func):
|
||||
"""This is a decorator which can be used to mark functions
|
||||
as deprecated. It will result in a warning being emitted
|
||||
when the function is used."""
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
msg = f'Call to deprecated function or method "{func.__name__}", {message}.'
|
||||
warnings.warn(msg, category=DeprecationWarning, stacklevel=stacklevel)
|
||||
log.warning(msg)
|
||||
return func(*args, **kwargs)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def iterXMLBFS(root, tag=None):
|
||||
""" Iterate through an XML tree using a breadth-first search.
|
||||
If tag is specified, only return nodes with that tag.
|
||||
"""
|
||||
queue = deque([root])
|
||||
while queue:
|
||||
node = queue.popleft()
|
||||
if tag is None or node.tag == tag:
|
||||
yield node
|
||||
queue.extend(list(node))
|
||||
|
||||
|
||||
def toJson(obj, **kwargs):
|
||||
""" Convert an object to a JSON string.
|
||||
|
||||
Parameters:
|
||||
obj (object): The object to convert.
|
||||
**kwargs (dict): Keyword arguments to pass to ``json.dumps()``.
|
||||
"""
|
||||
def serialize(obj):
|
||||
if isinstance(obj, datetime):
|
||||
return obj.isoformat()
|
||||
return {k: v for k, v in obj.__dict__.items() if not k.startswith('_')}
|
||||
return json.dumps(obj, default=serialize, **kwargs)
|
||||
|
||||
|
||||
def openOrRead(file):
|
||||
if hasattr(file, 'read'):
|
||||
return file.read()
|
||||
with open(file, 'rb') as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def sha1hash(guid):
|
||||
""" Return the SHA1 hash of a guid. """
|
||||
return sha1(guid.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
# https://stackoverflow.com/a/64570125
|
||||
_illegal_XML_characters = [
|
||||
(0x00, 0x08),
|
||||
(0x0B, 0x0C),
|
||||
(0x0E, 0x1F),
|
||||
(0x7F, 0x84),
|
||||
(0x86, 0x9F),
|
||||
(0xFDD0, 0xFDDF),
|
||||
(0xFFFE, 0xFFFF),
|
||||
]
|
||||
if sys.maxunicode >= 0x10000: # not narrow build
|
||||
_illegal_XML_characters.extend(
|
||||
[
|
||||
(0x1FFFE, 0x1FFFF),
|
||||
(0x2FFFE, 0x2FFFF),
|
||||
(0x3FFFE, 0x3FFFF),
|
||||
(0x4FFFE, 0x4FFFF),
|
||||
(0x5FFFE, 0x5FFFF),
|
||||
(0x6FFFE, 0x6FFFF),
|
||||
(0x7FFFE, 0x7FFFF),
|
||||
(0x8FFFE, 0x8FFFF),
|
||||
(0x9FFFE, 0x9FFFF),
|
||||
(0xAFFFE, 0xAFFFF),
|
||||
(0xBFFFE, 0xBFFFF),
|
||||
(0xCFFFE, 0xCFFFF),
|
||||
(0xDFFFE, 0xDFFFF),
|
||||
(0xEFFFE, 0xEFFFF),
|
||||
(0xFFFFE, 0xFFFFF),
|
||||
(0x10FFFE, 0x10FFFF),
|
||||
]
|
||||
)
|
||||
_illegal_XML_ranges = [
|
||||
fr'{chr(low)}-{chr(high)}'
|
||||
for (low, high) in _illegal_XML_characters
|
||||
]
|
||||
_illegal_XML_re = re.compile(fr'[{"".join(_illegal_XML_ranges)}]')
|
||||
|
||||
|
||||
def cleanXMLString(s):
|
||||
return _illegal_XML_re.sub('', s)
|
File diff suppressed because it is too large
Load Diff
Loading…
Reference in new issue