Added feature to change "added" info in Plex once subtitles are downloaded

pull/2835/head v1.5.2-beta.9
destpstrzy 4 weeks ago committed by GitHub
parent 2fc8f10a94
commit fe7b224916
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -215,6 +215,14 @@ validators = [
Validator('radarr.defer_search_signalr', must_exist=True, default=False, is_type_of=bool),
Validator('radarr.sync_only_monitored_movies', must_exist=True, default=False, is_type_of=bool),
# plex section
Validator('plex.ip', must_exist=True, default='127.0.0.1', is_type_of=str),
Validator('plex.port', must_exist=True, default=32400, is_type_of=int, gte=1, lte=65535),
Validator('plex.ssl', must_exist=True, default=False, is_type_of=bool),
Validator('plex.apikey', must_exist=True, default='', is_type_of=str),
Validator('plex.movie_library', must_exist=True, default='', is_type_of=str),
Validator('plex.set_added', must_exist=True, default=False, is_type_of=bool),
# proxy section
Validator('proxy.type', must_exist=True, default=None, is_type_of=(NoneType, str),
is_in=[None, 'socks5', 'http']),

@ -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}")

@ -11,6 +11,7 @@ from app.database import TableEpisodes, TableMovies, database, select
from utilities.analytics import event_tracker
from radarr.notify import notify_radarr
from sonarr.notify import notify_sonarr
from plex.operations import plex_set_added_date_now
from app.event_handler import event_stream
from .utils import _get_download_code3
@ -95,7 +96,7 @@ def process_subtitle(subtitle, media_type, audio_language, path, max_score, is_u
sonarr_episode_id=episode_metadata.sonarrEpisodeId)
else:
movie_metadata = database.execute(
select(TableMovies.radarrId)
select(TableMovies.radarrId, TableMovies.imdbId)
.where(TableMovies.path == path_mappings.path_replace_reverse_movie(path)))\
.first()
if not movie_metadata:
@ -145,6 +146,8 @@ def process_subtitle(subtitle, media_type, audio_language, path, max_score, is_u
reversed_subtitles_path = path_mappings.path_replace_reverse_movie(downloaded_path)
notify_radarr(movie_metadata.radarrId)
event_stream(type='movie-wanted', action='delete', payload=movie_metadata.radarrId)
if settings.plex.set_added is True:
plex_set_added_date_now(movie_metadata)
event_tracker.track_subtitles(provider=downloaded_provider, action=action, language=downloaded_language)

@ -34,6 +34,7 @@ import SeriesMassEditor from "@/pages/Series/Editor";
import SettingsGeneralView from "@/pages/Settings/General";
import SettingsLanguagesView from "@/pages/Settings/Languages";
import SettingsNotificationsView from "@/pages/Settings/Notifications";
import SettingsPlexView from "@/pages/Settings/Plex";
import SettingsProvidersView from "@/pages/Settings/Providers";
import SettingsRadarrView from "@/pages/Settings/Radarr";
import SettingsSchedulerView from "@/pages/Settings/Scheduler";
@ -222,6 +223,11 @@ function useRoutes(): CustomRouteObject[] {
name: "Radarr",
element: <SettingsRadarrView></SettingsRadarrView>,
},
{
path: "plex",
name: "Plex",
element: <SettingsPlexView></SettingsPlexView>,
},
{
path: "notifications",
name: "Notifications",

@ -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;

@ -12,3 +12,4 @@ export const pathMappingsMovieKey = "settings-general-path_mappings_movie";
export const seriesEnabledKey = "settings-general-use_sonarr";
export const moviesEnabledKey = "settings-general-use_radarr";
export const plexEnabledKey = "settings-general-use_plex";

@ -173,6 +173,15 @@ declare namespace Settings {
excluded_tags: string[];
}
interface Plex {
ip: string;
port: number;
apikey?: string;
ssl?: boolean;
set_added?: boolean;
movie_library?: string;
}
interface Anticaptcha {
anti_captcha_key?: string;
}

@ -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,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,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

@ -25,6 +25,7 @@ inflect==7.5.0
jsonschema<=4.17.3 # newer version require other compiled dependency
knowit<=0.5.3 # newer version doesn't support Python 3.8 anymore
Mako==1.3.8
plexapi>=4.16.1
pycountry==24.6.1
pyrsistent==0.20.0
pysubs2==1.8.0

Loading…
Cancel
Save