diff --git a/bazarr/app/config.py b/bazarr/app/config.py index 585b73729..2e65d5913 100644 --- a/bazarr/app/config.py +++ b/bazarr/app/config.py @@ -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']), diff --git a/bazarr/plex/__init__.py b/bazarr/plex/__init__.py new file mode 100644 index 000000000..9bad5790a --- /dev/null +++ b/bazarr/plex/__init__.py @@ -0,0 +1 @@ +# coding=utf-8 diff --git a/bazarr/plex/operations.py b/bazarr/plex/operations.py new file mode 100644 index 000000000..2ae326ff2 --- /dev/null +++ b/bazarr/plex/operations.py @@ -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}") \ No newline at end of file diff --git a/bazarr/subtitles/processing.py b/bazarr/subtitles/processing.py index c008e72fa..8617cbed6 100644 --- a/bazarr/subtitles/processing.py +++ b/bazarr/subtitles/processing.py @@ -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) diff --git a/frontend/src/Router/index.tsx b/frontend/src/Router/index.tsx index 6b2b0bb82..7ccff2ceb 100644 --- a/frontend/src/Router/index.tsx +++ b/frontend/src/Router/index.tsx @@ -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: , }, + { + path: "plex", + name: "Plex", + element: , + }, { path: "notifications", name: "Notifications", diff --git a/frontend/src/pages/Settings/Plex/index.tsx b/frontend/src/pages/Settings/Plex/index.tsx new file mode 100644 index 000000000..135ef6116 --- /dev/null +++ b/frontend/src/pages/Settings/Plex/index.tsx @@ -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 ( + +
+ +
+ +
+ + + Hostname or IPv4 Address + + +
+
+ + +
+
+
+ ); +}; + +export default SettingsPlexView; diff --git a/frontend/src/pages/Settings/keys.ts b/frontend/src/pages/Settings/keys.ts index 3d6444882..f7b3e0757 100644 --- a/frontend/src/pages/Settings/keys.ts +++ b/frontend/src/pages/Settings/keys.ts @@ -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"; diff --git a/frontend/src/types/settings.d.ts b/frontend/src/types/settings.d.ts index 7b57f10cc..6b11c5752 100644 --- a/frontend/src/types/settings.d.ts +++ b/frontend/src/types/settings.d.ts @@ -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; } diff --git a/libs/PlexAPI-4.16.1.dist-info/AUTHORS.txt b/libs/PlexAPI-4.16.1.dist-info/AUTHORS.txt new file mode 100644 index 000000000..7e5808e13 --- /dev/null +++ b/libs/PlexAPI-4.16.1.dist-info/AUTHORS.txt @@ -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) diff --git a/libs/PlexAPI-4.16.1.dist-info/INSTALLER b/libs/PlexAPI-4.16.1.dist-info/INSTALLER new file mode 100644 index 000000000..a1b589e38 --- /dev/null +++ b/libs/PlexAPI-4.16.1.dist-info/INSTALLER @@ -0,0 +1 @@ +pip diff --git a/libs/PlexAPI-4.16.1.dist-info/LICENSE.txt b/libs/PlexAPI-4.16.1.dist-info/LICENSE.txt new file mode 100644 index 000000000..5b6bfae02 --- /dev/null +++ b/libs/PlexAPI-4.16.1.dist-info/LICENSE.txt @@ -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. diff --git a/libs/PlexAPI-4.16.1.dist-info/METADATA b/libs/PlexAPI-4.16.1.dist-info/METADATA new file mode 100644 index 000000000..97bc91f4b --- /dev/null +++ b/libs/PlexAPI-4.16.1.dist-info/METADATA @@ -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 +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('', '') + plex = account.resource('').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 diff --git a/libs/PlexAPI-4.16.1.dist-info/RECORD b/libs/PlexAPI-4.16.1.dist-info/RECORD new file mode 100644 index 000000000..02ca75395 --- /dev/null +++ b/libs/PlexAPI-4.16.1.dist-info/RECORD @@ -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 diff --git a/libs/PlexAPI-4.16.1.dist-info/REQUESTED b/libs/PlexAPI-4.16.1.dist-info/REQUESTED new file mode 100644 index 000000000..e69de29bb diff --git a/libs/PlexAPI-4.16.1.dist-info/WHEEL b/libs/PlexAPI-4.16.1.dist-info/WHEEL new file mode 100644 index 000000000..57e3d840d --- /dev/null +++ b/libs/PlexAPI-4.16.1.dist-info/WHEEL @@ -0,0 +1,5 @@ +Wheel-Version: 1.0 +Generator: bdist_wheel (0.38.4) +Root-Is-Purelib: true +Tag: py3-none-any + diff --git a/libs/PlexAPI-4.16.1.dist-info/top_level.txt b/libs/PlexAPI-4.16.1.dist-info/top_level.txt new file mode 100644 index 000000000..9587b2f29 --- /dev/null +++ b/libs/PlexAPI-4.16.1.dist-info/top_level.txt @@ -0,0 +1 @@ +plexapi diff --git a/libs/plexapi/__init__.py b/libs/plexapi/__init__.py new file mode 100644 index 000000000..1d4fb471e --- /dev/null +++ b/libs/plexapi/__init__.py @@ -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) diff --git a/libs/plexapi/alert.py b/libs/plexapi/alert.py new file mode 100644 index 000000000..2d6a18e8a --- /dev/null +++ b/libs/plexapi/alert.py @@ -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) diff --git a/libs/plexapi/audio.py b/libs/plexapi/audio.py new file mode 100644 index 000000000..05d38a9c7 --- /dev/null +++ b/libs/plexapi/audio.py @@ -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//art/). + 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/). + 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//thumb/). + 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/). + labels (List<:class:`~plexapi.media.Label`>): List of label objects. + locations (List): 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//theme/). + 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/). + 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 (int): Unique key identifying the album artist. + parentTheme (str): URL to artist theme resource (/library/metadata//theme/). + parentThumb (str): URL to album artist thumbnail image (/library/metadata//thumb/). + 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//art/). + grandparentGuid (str): Plex GUID for the album artist (plex://artist/5d07bcb0403c64029053ac4c). + grandparentKey (str): API URL of the album artist (/library/metadata/). + grandparentRatingKey (int): Unique key identifying the album artist. + grandparentTheme (str): URL to artist theme resource (/library/metadata//theme/). + (/library/metadata//theme/). + grandparentThumb (str): URL to album artist thumbnail image + (/library/metadata//thumb/). + 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 (int): Unique key identifying the album. + parentThumb (str): URL to album thumbnail image (/library/metadata//thumb/). + 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:///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 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) diff --git a/libs/plexapi/base.py b/libs/plexapi/base.py new file mode 100644 index 000000000..675ac5d98 --- /dev/null +++ b/libs/plexapi/base.py @@ -0,0 +1,1138 @@ +# -*- coding: utf-8 -*- +import re +from typing import TYPE_CHECKING, Generic, Iterable, List, Optional, TypeVar, Union +import weakref +from functools import cached_property +from urllib.parse import parse_qsl, urlencode, urlparse +from xml.etree import ElementTree +from xml.etree.ElementTree import Element + +from plexapi import CONFIG, X_PLEX_CONTAINER_SIZE, log, utils +from plexapi.exceptions import BadRequest, NotFound, UnknownType, Unsupported + +if TYPE_CHECKING: + from plexapi.server import PlexServer + +PlexObjectT = TypeVar("PlexObjectT", bound='PlexObject') +MediaContainerT = TypeVar("MediaContainerT", bound="MediaContainer") + +USER_DONT_RELOAD_FOR_KEYS = set() +_DONT_RELOAD_FOR_KEYS = {'key', 'sourceURI'} +OPERATORS = { + 'exact': lambda v, q: v == q, + 'iexact': lambda v, q: v.lower() == q.lower(), + 'contains': lambda v, q: q in v, + 'icontains': lambda v, q: q.lower() in v.lower(), + 'ne': lambda v, q: v != q, + 'in': lambda v, q: v in q, + 'gt': lambda v, q: v > q, + 'gte': lambda v, q: v >= q, + 'lt': lambda v, q: v < q, + 'lte': lambda v, q: v <= q, + 'startswith': lambda v, q: v.startswith(q), + 'istartswith': lambda v, q: v.lower().startswith(q.lower()), + 'endswith': lambda v, q: v.endswith(q), + 'iendswith': lambda v, q: v.lower().endswith(q.lower()), + 'exists': lambda v, q: v is not None if q else v is None, + 'regex': lambda v, q: bool(re.search(q, v)), + 'iregex': lambda v, q: bool(re.search(q, v, flags=re.IGNORECASE)), +} + + +class PlexObject: + """ Base class for all Plex objects. + + Parameters: + server (:class:`~plexapi.server.PlexServer`): PlexServer this client is connected to (optional) + data (ElementTree): Response from PlexServer used to build this object (optional). + initpath (str): Relative path requested when retrieving specified `data` (optional). + parent (:class:`~plexapi.base.PlexObject`): The parent object that this object is built from (optional). + """ + TAG = None # xml element tag + TYPE = None # xml element type + key = None # plex relative url + + def __init__(self, server, data, initpath=None, parent=None): + self._server = server + self._data = data + self._initpath = initpath or self.key + self._parent = weakref.ref(parent) if parent is not None else None + self._details_key = None + + # Allow overwriting previous attribute values with `None` when manually reloading + self._overwriteNone = True + # Automatically reload the object when accessing a missing attribute + self._autoReload = CONFIG.get('plexapi.autoreload', True, bool) + # Attribute to save batch edits for a single API call + self._edits = None + + if data is not None: + self._loadData(data) + self._details_key = self._buildDetailsKey() + + def __repr__(self): + uid = self._clean(self.firstAttr('_baseurl', 'ratingKey', 'id', 'key', 'playQueueID', 'uri', 'type')) + name = self._clean(self.firstAttr('title', 'name', 'username', 'product', 'tag', 'value')) + return f"<{':'.join([p for p in [self.__class__.__name__, uid, name] if p])}>" + + def __setattr__(self, attr, value): + overwriteNone = self.__dict__.get('_overwriteNone') + # Don't overwrite an attr with None unless it's a private variable or overwrite None is True + if value is not None or attr.startswith('_') or attr not in self.__dict__ or overwriteNone: + self.__dict__[attr] = value + + def _clean(self, value): + """ Clean attr value for display in __repr__. """ + if value: + value = str(value).replace('/library/metadata/', '') + value = value.replace('/children', '') + value = value.replace('/accounts/', '') + value = value.replace('/devices/', '') + return value.replace(' ', '-')[:20] + + def _buildItem(self, elem, cls=None, initpath=None): + """ Factory function to build objects based on registered PLEXOBJECTS. """ + # cls is specified, build the object and return + initpath = initpath or self._initpath + if cls is not None: + return cls(self._server, elem, initpath, parent=self) + # cls is not specified, try looking it up in PLEXOBJECTS + etype = elem.attrib.get('streamType', elem.attrib.get('tagType', elem.attrib.get('type'))) + ehash = f'{elem.tag}.{etype}' if etype else elem.tag + if initpath == '/status/sessions': + ehash = f"{ehash}.session" + elif initpath.startswith('/status/sessions/history'): + ehash = f"{ehash}.history" + ecls = utils.getPlexObject(ehash, default=elem.tag) + # log.debug('Building %s as %s', elem.tag, ecls.__name__) + if ecls is not None: + return ecls(self._server, elem, initpath, parent=self) + raise UnknownType(f"Unknown library type <{elem.tag} type='{etype}'../>") + + def _buildItemOrNone(self, elem, cls=None, initpath=None): + """ Calls :func:`~plexapi.base.PlexObject._buildItem` but returns + None if elem is an unknown type. + """ + try: + return self._buildItem(elem, cls, initpath) + except UnknownType: + return None + + def _buildDetailsKey(self, **kwargs): + """ Builds the details key with the XML include parameters. + All parameters are included by default with the option to override each parameter + or disable each parameter individually by setting it to False or 0. + """ + details_key = self.key + params = {} + + if details_key and hasattr(self, '_INCLUDES'): + for k, v in self._INCLUDES.items(): + value = kwargs.pop(k, v) + if value not in [False, 0, '0']: + params[k] = 1 if value is True else value + + if details_key and hasattr(self, '_EXCLUDES'): + for k, v in self._EXCLUDES.items(): + value = kwargs.pop(k, None) + if value is not None: + params[k] = 1 if value is True else value + + if params: + details_key += '?' + urlencode(sorted(params.items())) + return details_key + + def _isChildOf(self, **kwargs): + """ Returns True if this object is a child of the given attributes. + This will search the parent objects all the way to the top. + + Parameters: + **kwargs (dict): The attributes and values to search for in the parent objects. + See all possible `**kwargs*` in :func:`~plexapi.base.PlexObject.fetchItem`. + """ + obj = self + while obj and obj._parent is not None: + obj = obj._parent() + if obj and obj._checkAttrs(obj._data, **kwargs): + return True + return False + + def _manuallyLoadXML(self, xml, cls=None): + """ Manually load an XML string as a :class:`~plexapi.base.PlexObject`. + + Parameters: + xml (str): The XML string to load. + cls (:class:`~plexapi.base.PlexObject`): If you know the class of the + items to be fetched, passing this in will help the parser ensure + it only returns those items. By default we convert the xml elements + with the best guess PlexObjects based on tag and type attrs. + """ + elem = ElementTree.fromstring(xml) + return self._buildItemOrNone(elem, cls) + + def fetchItems( + self, + ekey, + cls=None, + container_start=None, + container_size=None, + maxresults=None, + params=None, + **kwargs, + ): + """ Load the specified key to find and build all items with the specified tag + and attrs. + + Parameters: + ekey (str or List): API URL path in Plex to fetch items from. If a list of ints is passed + in, the key will be translated to /library/metadata/. This allows + fetching multiple items only knowing their key-ids. + cls (:class:`~plexapi.base.PlexObject`): If you know the class of the + items to be fetched, passing this in will help the parser ensure + it only returns those items. By default we convert the xml elements + with the best guess PlexObjects based on tag and type attrs. + etag (str): Only fetch items with the specified tag. + container_start (None, int): offset to get a subset of the data + container_size (None, int): How many items in data + maxresults (int, optional): Only return the specified number of results. + params (dict, optional): Any additional params to add to the request. + **kwargs (dict): Optionally add XML attribute to filter the items. + See the details below for more info. + + **Filtering XML Attributes** + + Any XML attribute can be filtered when fetching results. Filtering is done before + the Python objects are built to help keep things speedy. For example, passing in + ``viewCount=0`` will only return matching items where the view count is ``0``. + Note that case matters when specifying attributes. Attributes further down in the XML + tree can be filtered by *prepending* the attribute with each element tag ``Tag__``. + + Examples: + + .. code-block:: python + + fetchItem(ekey, viewCount=0) + fetchItem(ekey, contentRating="PG") + fetchItem(ekey, Genre__tag="Animation") + fetchItem(ekey, Media__videoCodec="h265") + fetchItem(ekey, Media__Part__container="mp4) + + Note that because some attribute names are already used as arguments to this + function, such as ``tag``, you may still reference the attr tag by prepending an + underscore. For example, passing in ``_tag='foobar'`` will return all items where + ``tag='foobar'``. + + **Using PlexAPI Operators** + + Optionally, PlexAPI operators can be specified by *appending* it to the end of the + attribute for more complex lookups. For example, passing in ``viewCount__gte=0`` + will return all items where ``viewCount >= 0``. + + List of Available Operators: + + * ``__contains``: Value contains specified arg. + * ``__endswith``: Value ends with specified arg. + * ``__exact``: Value matches specified arg. + * ``__exists`` (*bool*): Value is or is not present in the attrs. + * ``__gt``: Value is greater than specified arg. + * ``__gte``: Value is greater than or equal to specified arg. + * ``__icontains``: Case insensitive value contains specified arg. + * ``__iendswith``: Case insensitive value ends with specified arg. + * ``__iexact``: Case insensitive value matches specified arg. + * ``__in``: Value is in a specified list or tuple. + * ``__iregex``: Case insensitive value matches the specified regular expression. + * ``__istartswith``: Case insensitive value starts with specified arg. + * ``__lt``: Value is less than specified arg. + * ``__lte``: Value is less than or equal to specified arg. + * ``__regex``: Value matches the specified regular expression. + * ``__startswith``: Value starts with specified arg. + + Examples: + + .. code-block:: python + + fetchItem(ekey, viewCount__gte=0) + fetchItem(ekey, Media__container__in=["mp4", "mkv"]) + fetchItem(ekey, guid__regex=r"com\\.plexapp\\.agents\\.(imdb|themoviedb)://|tt\\d+") + fetchItem(ekey, guid__id__regex=r"(imdb|tmdb|tvdb)://") + fetchItem(ekey, Media__Part__file__startswith="D:\\Movies") + + """ + if ekey is None: + raise BadRequest('ekey was not provided') + + if isinstance(ekey, list) and all(isinstance(key, int) for key in ekey): + ekey = f'/library/metadata/{",".join(str(key) for key in ekey)}' + + container_start = container_start or 0 + container_size = container_size or X_PLEX_CONTAINER_SIZE + offset = container_start + + if maxresults is not None: + container_size = min(container_size, maxresults) + + results = MediaContainer[cls](self._server, Element('MediaContainer'), initpath=ekey) + headers = {} + + while True: + headers['X-Plex-Container-Start'] = str(container_start) + headers['X-Plex-Container-Size'] = str(container_size) + + data = self._server.query(ekey, headers=headers, params=params) + subresults = self.findItems(data, cls, ekey, **kwargs) + total_size = utils.cast(int, data.attrib.get('totalSize') or data.attrib.get('size')) or len(subresults) + + if not subresults: + if offset > total_size: + log.info('container_start is greater than the number of items') + + librarySectionID = utils.cast(int, data.attrib.get('librarySectionID')) + if librarySectionID: + for item in subresults: + item.librarySectionID = librarySectionID + + results.extend(subresults) + + container_start += container_size + + if container_start > total_size: + break + + wanted_number_of_items = total_size - offset + if maxresults is not None: + wanted_number_of_items = min(maxresults, wanted_number_of_items) + container_size = min(container_size, wanted_number_of_items - len(results)) + + if wanted_number_of_items <= len(results): + break + + return results + + def fetchItem(self, ekey, cls=None, **kwargs): + """ Load the specified key to find and build the first item with the + specified tag and attrs. If no tag or attrs are specified then + the first item in the result set is returned. + + Parameters: + ekey (str or int): Path in Plex to fetch items from. If an int is passed + in, the key will be translated to /library/metadata/. This allows + fetching an item only knowing its key-id. + cls (:class:`~plexapi.base.PlexObject`): If you know the class of the + items to be fetched, passing this in will help the parser ensure + it only returns those items. By default we convert the xml elements + with the best guess PlexObjects based on tag and type attrs. + etag (str): Only fetch items with the specified tag. + **kwargs (dict): Optionally add XML attribute to filter the items. + See :func:`~plexapi.base.PlexObject.fetchItems` for more details + on how this is used. + """ + if isinstance(ekey, int): + ekey = f'/library/metadata/{ekey}' + + try: + return self.fetchItems(ekey, cls, **kwargs)[0] + except IndexError: + clsname = cls.__name__ if cls else 'None' + raise NotFound(f'Unable to find elem: cls={clsname}, attrs={kwargs}') from None + + def findItems(self, data, cls=None, initpath=None, rtag=None, **kwargs): + """ Load the specified data to find and build all items with the specified tag + and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details + on how this is used. + """ + # filter on cls attrs if specified + if cls and cls.TAG and 'tag' not in kwargs: + kwargs['etag'] = cls.TAG + if cls and cls.TYPE and 'type' not in kwargs: + kwargs['type'] = cls.TYPE + # rtag to iter on a specific root tag using breadth-first search + if rtag: + data = next(utils.iterXMLBFS(data, rtag), Element('Empty')) + # loop through all data elements to find matches + items = MediaContainer[cls](self._server, data, initpath=initpath) if data.tag == 'MediaContainer' else [] + for elem in data: + if self._checkAttrs(elem, **kwargs): + item = self._buildItemOrNone(elem, cls, initpath) + if item is not None: + items.append(item) + return items + + def findItem(self, data, cls=None, initpath=None, rtag=None, **kwargs): + """ Load the specified data to find and build the first items with the specified tag + and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details + on how this is used. + """ + try: + return self.findItems(data, cls, initpath, rtag, **kwargs)[0] + except IndexError: + return None + + def firstAttr(self, *attrs): + """ Return the first attribute in attrs that is not None. """ + for attr in attrs: + value = getattr(self, attr, None) + if value is not None: + return value + + def listAttrs(self, data, attr, rtag=None, **kwargs): + """ Return a list of values from matching attribute. """ + results = [] + # rtag to iter on a specific root tag using breadth-first search + if rtag: + data = next(utils.iterXMLBFS(data, rtag), []) + for elem in data: + kwargs[f'{attr}__exists'] = True + if self._checkAttrs(elem, **kwargs): + results.append(elem.attrib.get(attr)) + return results + + def reload(self, key=None, **kwargs): + """ Reload the data for this object from self.key. + + Parameters: + key (string, optional): Override the key to reload. + **kwargs (dict): A dictionary of XML include parameters to include/exclude or override. + See :class:`~plexapi.base.PlexPartialObject` for all the available include parameters. + Set parameter to True to include and False to exclude. + + Example: + + .. code-block:: python + + from plexapi.server import PlexServer + plex = PlexServer('http://localhost:32400', token='xxxxxxxxxxxxxxxxxxxx') + + # Search results are partial objects. + movie = plex.library.section('Movies').get('Cars') + movie.isPartialObject() # Returns True + + # Partial reload of the movie without a default include parameter. + # The movie object will remain as a partial object. + movie.reload(includeMarkers=False) + movie.isPartialObject() # Returns True + + # Full reload of the movie with all default include parameters. + # The movie object will be a full object. + movie.reload() + movie.isFullObject() # Returns True + + # Full reload of the movie with all default and extra include parameter. + # Including `checkFiles` will tell the Plex server to check if the file + # still exists and is accessible. + # The movie object will be a full object. + movie.reload(checkFiles=True) + movie.isFullObject() # Returns True + + """ + return self._reload(key=key, **kwargs) + + def _reload(self, key=None, _overwriteNone=True, **kwargs): + """ Perform the actual reload. """ + details_key = self._buildDetailsKey(**kwargs) if kwargs else self._details_key + key = key or details_key or self.key + if not key: + raise Unsupported('Cannot reload an object not built from a URL.') + self._initpath = key + data = self._server.query(key) + self._overwriteNone = _overwriteNone + self._loadData(data[0]) + self._overwriteNone = True + return self + + def _checkAttrs(self, elem, **kwargs): + attrsFound = {} + for attr, query in kwargs.items(): + attr, op, operator = self._getAttrOperator(attr) + values = self._getAttrValue(elem, attr) + # special case query in (None, 0, '') to include missing attr + if op == 'exact' and not values and query in (None, 0, ''): + return True + # return if attr were looking for is missing + attrsFound[attr] = False + for value in values: + value = self._castAttrValue(op, query, value) + if operator(value, query): + attrsFound[attr] = True + break + # log.debug('Checking %s for %s found: %s', elem.tag, kwargs, attrsFound) + return all(attrsFound.values()) + + def _getAttrOperator(self, attr): + for op, operator in OPERATORS.items(): + if attr.endswith(f'__{op}'): + attr = attr.rsplit('__', 1)[0] + return attr, op, operator + # default to exact match + return attr, 'exact', OPERATORS['exact'] + + def _getAttrValue(self, elem, attrstr, results=None): + # log.debug('Fetching %s in %s', attrstr, elem.tag) + parts = attrstr.split('__', 1) + attr = parts[0] + attrstr = parts[1] if len(parts) == 2 else None + if attrstr: + results = [] if results is None else results + for child in (c for c in elem if c.tag.lower() == attr.lower()): + results += self._getAttrValue(child, attrstr, results) + return [r for r in results if r is not None] + # check were looking for the tag + if attr.lower() == 'etag': + return [elem.tag] + # loop through attrs so we can perform case-insensitive match + for _attr, value in elem.attrib.items(): + if attr.lower() == _attr.lower(): + return [value] + return [] + + def _castAttrValue(self, op, query, value): + if op == 'exists': + return value + if isinstance(query, bool): + return bool(int(value)) + if isinstance(query, int) and '.' in value: + return float(value) + if isinstance(query, int): + return int(value) + if isinstance(query, float): + return float(value) + return value + + def _loadData(self, data): + raise NotImplementedError('Abstract method not implemented.') + + @property + def _searchType(self): + return self.TYPE + + +class PlexPartialObject(PlexObject): + """ Not all objects in the Plex listings return the complete list of elements + for the object. This object will allow you to assume each object is complete, + and if the specified value you request is None it will fetch the full object + automatically and update itself. + """ + _INCLUDES = { + 'checkFiles': 0, + 'includeAllConcerts': 0, + 'includeBandwidths': 1, + 'includeChapters': 1, + 'includeChildren': 0, + 'includeConcerts': 0, + 'includeExternalMedia': 0, + 'includeExtras': 0, + 'includeFields': 'thumbBlurHash,artBlurHash', + 'includeGeolocation': 1, + 'includeLoudnessRamps': 1, + 'includeMarkers': 1, + 'includeOnDeck': 0, + 'includePopularLeaves': 0, + 'includePreferences': 0, + 'includeRelated': 0, + 'includeRelatedCount': 0, + 'includeReviews': 0, + 'includeStations': 0, + } + _EXCLUDES = { + 'excludeElements': ( + 'Media,Genre,Country,Guid,Rating,Collection,Director,Writer,Role,Producer,Similar,Style,Mood,Format' + ), + 'excludeFields': 'summary,tagline', + 'skipRefresh': 1, + } + + def __eq__(self, other): + if isinstance(other, PlexPartialObject): + return self.key == other.key + return NotImplemented + + def __hash__(self): + return hash(repr(self)) + + def __iter__(self): + yield self + + def __getattribute__(self, attr): + # Dragons inside.. :-/ + value = super(PlexPartialObject, self).__getattribute__(attr) + # Check a few cases where we don't want to reload + if attr in _DONT_RELOAD_FOR_KEYS: return value + if attr in USER_DONT_RELOAD_FOR_KEYS: return value + if attr.startswith('_'): return value + if value not in (None, []): return value + if self.isFullObject(): return value + if isinstance(self, (PlexSession, PlexHistory)): return value + if self._autoReload is False: return value + # Log the reload. + clsname = self.__class__.__name__ + title = self.__dict__.get('title', self.__dict__.get('name')) + objname = f"{clsname} '{title}'" if title else clsname + log.debug("Reloading %s for attr '%s'", objname, attr) + # Reload and return the value + self._reload(_overwriteNone=False) + return super(PlexPartialObject, self).__getattribute__(attr) + + def analyze(self): + """ Tell Plex Media Server to performs analysis on it this item to gather + information. Analysis includes: + + * Gather Media Properties: All of the media you add to a Library has + properties that are useful to know–whether it's a video file, a + music track, or one of your photos (container, codec, resolution, etc). + * Generate Default Artwork: Artwork will automatically be grabbed from a + video file. A background image will be pulled out as well as a + smaller image to be used for poster/thumbnail type purposes. + * Generate Video Preview Thumbnails: Video preview thumbnails are created, + if you have that feature enabled. Video preview thumbnails allow + graphical seeking in some Apps. It's also used in the Plex Web App Now + Playing screen to show a graphical representation of where playback + is. Video preview thumbnails creation is a CPU-intensive process akin + to transcoding the file. + * Generate intro video markers: Detects show intros, exposing the + 'Skip Intro' button in clients. + """ + key = f"/{self.key.lstrip('/')}/analyze" + self._server.query(key, method=self._server._session.put) + + def isFullObject(self): + """ Returns True if this is already a full object. A full object means all attributes + were populated from the api path representing only this item. For example, the + search result for a movie often only contain a portion of the attributes a full + object (main url) for that movie would contain. + """ + parsed_key = urlparse(self._details_key or self.key) + parsed_initpath = urlparse(self._initpath) + query_key = set(parse_qsl(parsed_key.query)) + query_init = set(parse_qsl(parsed_initpath.query)) + return not self.key or (parsed_key.path == parsed_initpath.path and query_key <= query_init) + + def isPartialObject(self): + """ Returns True if this is not a full object. """ + return not self.isFullObject() + + def isLocked(self, field: str): + """ Returns True if the specified field is locked, otherwise False. + + Parameters: + field (str): The name of the field. + """ + return next((f.locked for f in self.fields if f.name == field), False) + + def _edit(self, **kwargs): + """ Actually edit an object. """ + if isinstance(self._edits, dict): + self._edits.update(kwargs) + return self + + if 'type' not in kwargs: + kwargs['type'] = utils.searchType(self._searchType) + + self.section()._edit(items=self, **kwargs) + return self + + def edit(self, **kwargs): + """ Edit an object. + Note: This is a low level method and you need to know all the field/tag keys. + See :class:`~plexapi.mixins.EditFieldMixin` and :class:`~plexapi.mixins.EditTagsMixin` + for individual field and tag editing methods. + + Parameters: + kwargs (dict): Dict of settings to edit. + + Example: + + .. code-block:: python + + edits = { + 'type': 1, + 'id': movie.ratingKey, + 'title.value': 'A new title', + 'title.locked': 1, + 'summary.value': 'This is a summary.', + 'summary.locked': 1, + 'collection[0].tag.tag': 'A tag', + 'collection.locked': 1} + } + movie.edit(**edits) + + """ + return self._edit(**kwargs) + + def batchEdits(self): + """ Enable batch editing mode to save API calls. + Must call :func:`~plexapi.base.PlexPartialObject.saveEdits` at the end to save all the edits. + See :class:`~plexapi.mixins.EditFieldMixin` and :class:`~plexapi.mixins.EditTagsMixin` + for individual field and tag editing methods. + + Example: + + .. code-block:: python + + # Batch editing multiple fields and tags in a single API call + Movie.batchEdits() + Movie.editTitle('A New Title').editSummary('A new summary').editTagline('A new tagline') \\ + .addCollection('New Collection').removeGenre('Action').addLabel('Favorite') + Movie.saveEdits() + + """ + self._edits = {} + return self + + def saveEdits(self): + """ Save all the batch edits. The object needs to be reloaded manually, + if required. + See :func:`~plexapi.base.PlexPartialObject.batchEdits` for details. + """ + if not isinstance(self._edits, dict): + raise BadRequest('Batch editing mode not enabled. Must call `batchEdits()` first.') + + edits = self._edits + self._edits = None + self._edit(**edits) + return self + + def refresh(self): + """ Refreshing a Library or individual item causes the metadata for the item to be + refreshed, even if it already has metadata. You can think of refreshing as + "update metadata for the requested item even if it already has some". You should + refresh a Library or individual item if: + + * You've changed the Library Metadata Agent. + * You've added "Local Media Assets" (such as artwork, theme music, external + subtitle files, etc.) + * You want to freshen the item posters, summary, etc. + * There's a problem with the poster image that's been downloaded. + * Items are missing posters or other downloaded information. This is possible if + the refresh process is interrupted (the Server is turned off, internet + connection dies, etc). + """ + key = f'{self.key}/refresh' + self._server.query(key, method=self._server._session.put) + + def section(self): + """ Returns the :class:`~plexapi.library.LibrarySection` this item belongs to. """ + return self._server.library.sectionByID(self.librarySectionID) + + def delete(self): + """ Delete a media element. This has to be enabled under settings > server > library in plex webui. """ + try: + return self._server.query(self.key, method=self._server._session.delete) + except BadRequest: # pragma: no cover + log.error('Failed to delete %s. This could be because you ' + 'have not allowed items to be deleted', self.key) + raise + + def history(self, maxresults=None, mindate=None): + """ Get Play History for a media item. + + Parameters: + maxresults (int): Only return the specified number of results (optional). + mindate (datetime): Min datetime to return results from. + """ + return self._server.history(maxresults=maxresults, mindate=mindate, ratingKey=self.ratingKey) + + def _getWebURL(self, base=None): + """ Get the Plex Web URL with the correct parameters. + Private method to allow overriding parameters from subclasses. + """ + return self._server._buildWebURL(base=base, endpoint='details', key=self.key) + + def getWebURL(self, base=None): + """ Returns the Plex Web URL for a media item. + + Parameters: + base (str): The base URL before the fragment (``#!``). + Default is https://app.plex.tv/desktop. + """ + return self._getWebURL(base=base) + + def playQueue(self, *args, **kwargs): + """ Returns a new :class:`~plexapi.playqueue.PlayQueue` from this media item. + See :func:`~plexapi.playqueue.PlayQueue.create` for available parameters. + """ + from plexapi.playqueue import PlayQueue + return PlayQueue.create(self._server, self, *args, **kwargs) + + +class Playable: + """ This is a general place to store functions specific to media that is Playable. + Things were getting mixed up a bit when dealing with Shows, Season, Artists, + Albums which are all not playable. + + Attributes: + playlistItemID (int): Playlist item ID (only populated for :class:`~plexapi.playlist.Playlist` items). + playQueueItemID (int): PlayQueue item ID (only populated for :class:`~plexapi.playlist.PlayQueue` items). + """ + + def _loadData(self, data): + self.playlistItemID = utils.cast(int, data.attrib.get('playlistItemID')) # playlist + self.playQueueItemID = utils.cast(int, data.attrib.get('playQueueItemID')) # playqueue + + def getStreamURL(self, **kwargs): + """ Returns a stream url that may be used by external applications such as VLC. + + Parameters: + **kwargs (dict): optional parameters to manipulate the playback when accessing + the stream. A few known parameters include: maxVideoBitrate, videoResolution + offset, copyts, protocol, mediaIndex, partIndex, platform. + + Raises: + :exc:`~plexapi.exceptions.Unsupported`: When the item doesn't support fetching a stream URL. + """ + if self.TYPE not in ('movie', 'episode', 'track', 'clip'): + raise Unsupported(f'Fetching stream URL for {self.TYPE} is unsupported.') + + mvb = kwargs.pop('maxVideoBitrate', None) + vr = kwargs.pop('videoResolution', '') + protocol = kwargs.pop('protocol', None) + + params = { + 'path': self.key, + 'mediaIndex': kwargs.pop('mediaIndex', 0), + 'partIndex': kwargs.pop('mediaIndex', 0), + 'protocol': protocol, + 'fastSeek': kwargs.pop('fastSeek', 1), + 'copyts': kwargs.pop('copyts', 1), + 'offset': kwargs.pop('offset', 0), + 'maxVideoBitrate': max(mvb, 64) if mvb else None, + 'videoResolution': vr if re.match(r'^\d+x\d+$', vr) else None, + 'X-Plex-Platform': kwargs.pop('platform', 'Chrome') + } + params.update(kwargs) + + # remove None values + params = {k: v for k, v in params.items() if v is not None} + streamtype = 'audio' if self.TYPE in ('track', 'album') else 'video' + ext = 'mpd' if protocol == 'dash' else 'm3u8' + + return self._server.url( + f'/{streamtype}/:/transcode/universal/start.{ext}?{urlencode(params)}', + includeToken=True + ) + + def iterParts(self): + """ Iterates over the parts of this media item. """ + for item in self.media: + for part in item.parts: + yield part + + def videoStreams(self): + """ Returns a list of :class:`~plexapi.media.videoStream` objects for all MediaParts. """ + if self.isPartialObject(): + self.reload() + return sum((part.videoStreams() for part in self.iterParts()), []) + + def audioStreams(self): + """ Returns a list of :class:`~plexapi.media.AudioStream` objects for all MediaParts. """ + if self.isPartialObject(): + self.reload() + return sum((part.audioStreams() for part in self.iterParts()), []) + + def subtitleStreams(self): + """ Returns a list of :class:`~plexapi.media.SubtitleStream` objects for all MediaParts. """ + if self.isPartialObject(): + self.reload() + return sum((part.subtitleStreams() for part in self.iterParts()), []) + + def lyricStreams(self): + """ Returns a list of :class:`~plexapi.media.LyricStream` objects for all MediaParts. """ + if self.isPartialObject(): + self.reload() + return sum((part.lyricStreams() for part in self.iterParts()), []) + + def play(self, client): + """ Start playback on the specified client. + + Parameters: + client (:class:`~plexapi.client.PlexClient`): Client to start playing on. + """ + client.playMedia(self) + + def download(self, savepath=None, keep_original_name=False, **kwargs): + """ Downloads the media item to the specified location. Returns a list of + filepaths that have been saved to disk. + + Parameters: + savepath (str): Defaults to current working dir. + keep_original_name (bool): True to keep the original filename otherwise + a friendlier filename is generated. See filenames below. + **kwargs (dict): Additional options passed into :func:`~plexapi.audio.Track.getStreamURL` + to download a transcoded stream, otherwise the media item will be downloaded + as-is and saved to disk. + + **Filenames** + + * Movie: `` (<year>)`` + * Episode: ``<show title> - s00e00 - <episode title>`` + * Track: ``<artist title> - <album title> - 00 - <track title>`` + * Photo: ``<photoalbum title> - <photo/clip title>`` or ``<photo/clip title>`` + """ + filepaths = [] + parts = [i for i in self.iterParts() if i] + + for part in parts: + if not keep_original_name: + filename = utils.cleanFilename(f'{self._prettyfilename()}.{part.container}') + else: + filename = part.file + + if kwargs: + # So this seems to be a a lot slower but allows transcode. + kwargs['mediaIndex'] = self.media.index(part._parent()) + kwargs['partIndex'] = part._parent().parts.index(part) + download_url = self.getStreamURL(**kwargs) + else: + download_url = self._server.url(f'{part.key}?download=1') + + filepath = utils.download( + download_url, + self._server._token, + filename=filename, + savepath=savepath, + session=self._server._session + ) + + if filepath: + filepaths.append(filepath) + + return filepaths + + def updateProgress(self, time, state='stopped'): + """ Set the watched progress for this video. + + Note that setting the time to 0 will not work. + Use :func:`~plexapi.mixins.PlayedUnplayedMixin.markPlayed` or + :func:`~plexapi.mixins.PlayedUnplayedMixin.markUnplayed` to achieve + that goal. + + Parameters: + time (int): milliseconds watched + state (string): state of the video, default 'stopped' + """ + key = f'/:/progress?key={self.ratingKey}&identifier=com.plexapp.plugins.library&time={time}&state={state}' + self._server.query(key) + return self + + def updateTimeline(self, time, state='stopped', duration=None): + """ Set the timeline progress for this video. + + Parameters: + time (int): milliseconds watched + state (string): state of the video, default 'stopped' + duration (int): duration of the item + """ + durationStr = '&duration=' + if duration is not None: + durationStr = durationStr + str(duration) + else: + durationStr = durationStr + str(self.duration) + key = (f'/:/timeline?ratingKey={self.ratingKey}&key={self.key}&' + f'identifier=com.plexapp.plugins.library&time={int(time)}&state={state}{durationStr}') + self._server.query(key) + return self + + +class PlexSession(object): + """ This is a general place to store functions specific to media that is a Plex Session. + + Attributes: + live (bool): True if this is a live tv session. + player (:class:`~plexapi.client.PlexClient`): PlexClient object for the session. + session (:class:`~plexapi.media.Session`): Session object for the session + if the session is using bandwidth (None otherwise). + sessionKey (int): The session key for the session. + transcodeSession (:class:`~plexapi.media.TranscodeSession`): TranscodeSession object + if item is being transcoded (None otherwise). + """ + + def _loadData(self, data): + self.live = utils.cast(bool, data.attrib.get('live', '0')) + self.player = self.findItem(data, etag='Player') + self.session = self.findItem(data, etag='Session') + self.sessionKey = utils.cast(int, data.attrib.get('sessionKey')) + self.transcodeSession = self.findItem(data, etag='TranscodeSession') + + user = data.find('User') + self._username = user.attrib.get('title') + self._userId = utils.cast(int, user.attrib.get('id')) + + # For backwards compatibility + self.players = [self.player] if self.player else [] + self.sessions = [self.session] if self.session else [] + self.transcodeSessions = [self.transcodeSession] if self.transcodeSession else [] + self.usernames = [self._username] if self._username else [] + + @cached_property + def user(self): + """ Returns the :class:`~plexapi.myplex.MyPlexAccount` object (for admin) + or :class:`~plexapi.myplex.MyPlexUser` object (for users) for this session. + """ + myPlexAccount = self._server.myPlexAccount() + if self._userId == 1: + return myPlexAccount + + return myPlexAccount.user(self._username) + + def reload(self): + """ Reload the data for the session. + Note: This will return the object as-is if the session is no longer active. + """ + return self._reload() + + def _reload(self, _autoReload=False, **kwargs): + """ Perform the actual reload. """ + # Do not auto reload sessions + if _autoReload: + return self + + key = self._initpath + data = self._server.query(key) + for elem in data: + if elem.attrib.get('sessionKey') == str(self.sessionKey): + self._loadData(elem) + break + return self + + def source(self): + """ Return the source media object for the session. """ + return self.fetchItem(self._details_key) + + def stop(self, reason=''): + """ Stop playback for the session. + + Parameters: + reason (str): Message displayed to the user for stopping playback. + """ + params = { + 'sessionId': self.session.id, + 'reason': reason, + } + key = '/status/sessions/terminate' + return self._server.query(key, params=params) + + +class PlexHistory(object): + """ This is a general place to store functions specific to media that is a Plex history item. + + Attributes: + accountID (int): The associated :class:`~plexapi.server.SystemAccount` ID. + deviceID (int): The associated :class:`~plexapi.server.SystemDevice` ID. + historyKey (str): API URL (/status/sessions/history/<historyID>). + viewedAt (datetime): Datetime item was last watched. + """ + + def _loadData(self, data): + self.accountID = utils.cast(int, data.attrib.get('accountID')) + self.deviceID = utils.cast(int, data.attrib.get('deviceID')) + self.historyKey = data.attrib.get('historyKey') + self.viewedAt = utils.toDatetime(data.attrib.get('viewedAt')) + + def _reload(self, **kwargs): + """ Reload the data for the history entry. """ + raise NotImplementedError('History objects cannot be reloaded. Use source() to get the source media item.') + + def source(self): + """ Return the source media object for the history entry + or None if the media no longer exists on the server. + """ + return self.fetchItem(self._details_key) if self._details_key else None + + def delete(self): + """ Delete the history entry. """ + return self._server.query(self.historyKey, method=self._server._session.delete) + + +class MediaContainer( + Generic[PlexObjectT], + List[PlexObjectT], + PlexObject, +): + """ Represents a single MediaContainer. + + Attributes: + TAG (str): 'MediaContainer' + allowSync (int): Sync/Download is allowed/disallowed for feature. + augmentationKey (str): API URL (/library/metadata/augmentations/<augmentationKey>). + identifier (str): "com.plexapp.plugins.library" + librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID. + librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title. + librarySectionUUID (str): :class:`~plexapi.library.LibrarySection` UUID. + mediaTagPrefix (str): "/system/bundle/media/flags/" + mediaTagVersion (int): Unknown + offset (int): The offset of current results. + size (int): The number of items in the hub. + totalSize (int): The total number of items for the query. + + """ + TAG = 'MediaContainer' + + def __init__( + self, + server: "PlexServer", + data: Element, + *args: PlexObjectT, + initpath: Optional[str] = None, + parent: Optional[PlexObject] = None, + ) -> None: + # super calls Generic.__init__ which calls list.__init__ eventually + super().__init__(*args) + PlexObject.__init__(self, server, data, initpath, parent) + + def extend( + self: MediaContainerT, + __iterable: Union[Iterable[PlexObjectT], MediaContainerT], + ) -> None: + curr_size = self.size if self.size is not None else len(self) + super().extend(__iterable) + # update size, totalSize, and offset + if not isinstance(__iterable, MediaContainer): + return + + # prefer the totalSize of the new iterable even if it is smaller + self.totalSize = ( + __iterable.totalSize + if __iterable.totalSize is not None + else self.totalSize + ) # ideally both should be equal + + # the size of the new iterable is added to the current size + self.size = curr_size + ( + __iterable.size if __iterable.size is not None else len(__iterable) + ) + + # the offset is the minimum of the two, prefering older values + if self.offset is not None and __iterable.offset is not None: + self.offset = min(self.offset, __iterable.offset) + else: + self.offset = ( + self.offset if self.offset is not None else __iterable.offset + ) + + # for all other attributes, overwrite with the new iterable's values if previously None + for key in ( + "allowSync", + "augmentationKey", + "identifier", + "librarySectionID", + "librarySectionTitle", + "librarySectionUUID", + "mediaTagPrefix", + "mediaTagVersion", + ): + if (not hasattr(self, key)) or (getattr(self, key) is None): + if not hasattr(__iterable, key): + continue + setattr(self, key, getattr(__iterable, key)) + + def _loadData(self, data): + self._data = data + self.allowSync = utils.cast(int, data.attrib.get('allowSync')) + self.augmentationKey = data.attrib.get('augmentationKey') + self.identifier = data.attrib.get('identifier') + self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID')) + self.librarySectionTitle = data.attrib.get('librarySectionTitle') + self.librarySectionUUID = data.attrib.get('librarySectionUUID') + self.mediaTagPrefix = data.attrib.get('mediaTagPrefix') + self.mediaTagVersion = data.attrib.get('mediaTagVersion') + self.offset = utils.cast(int, data.attrib.get("offset")) + self.size = utils.cast(int, data.attrib.get('size')) + self.totalSize = utils.cast(int, data.attrib.get("totalSize")) diff --git a/libs/plexapi/client.py b/libs/plexapi/client.py new file mode 100644 index 000000000..3d89e3dc6 --- /dev/null +++ b/libs/plexapi/client.py @@ -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')) diff --git a/libs/plexapi/collection.py b/libs/plexapi/collection.py new file mode 100644 index 000000000..63ea83730 --- /dev/null +++ b/libs/plexapi/collection.py @@ -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') diff --git a/libs/plexapi/config.py b/libs/plexapi/config.py new file mode 100644 index 000000000..5cfa74c8e --- /dev/null +++ b/libs/plexapi/config.py @@ -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', + } diff --git a/libs/plexapi/const.py b/libs/plexapi/const.py new file mode 100644 index 000000000..bc3e81aa3 --- /dev/null +++ b/libs/plexapi/const.py @@ -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}" diff --git a/libs/plexapi/exceptions.py b/libs/plexapi/exceptions.py new file mode 100644 index 000000000..182feb13d --- /dev/null +++ b/libs/plexapi/exceptions.py @@ -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 diff --git a/libs/plexapi/gdm.py b/libs/plexapi/gdm.py new file mode 100644 index 000000000..b9edac087 --- /dev/null +++ b/libs/plexapi/gdm.py @@ -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() diff --git a/libs/plexapi/library.py b/libs/plexapi/library.py new file mode 100644 index 000000000..6df8b6d2e --- /dev/null +++ b/libs/plexapi/library.py @@ -0,0 +1,3326 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +import re +from typing import Any, TYPE_CHECKING +import warnings +from collections import defaultdict +from datetime import datetime +from functools import cached_property +from urllib.parse import parse_qs, quote_plus, urlencode, urlparse + +from plexapi import log, media, utils +from plexapi.base import OPERATORS, PlexObject +from plexapi.exceptions import BadRequest, NotFound +from plexapi.mixins import ( + MovieEditMixins, ShowEditMixins, SeasonEditMixins, EpisodeEditMixins, + ArtistEditMixins, AlbumEditMixins, TrackEditMixins, PhotoalbumEditMixins, PhotoEditMixins +) +from plexapi.settings import Setting +from plexapi.utils import deprecated + + +if TYPE_CHECKING: + from plexapi.audio import Track + + +class Library(PlexObject): + """ Represents a PlexServer library. This contains all sections of media defined + in your Plex server including video, shows and audio. + + Attributes: + key (str): '/library' + identifier (str): Unknown ('com.plexapp.plugins.library'). + mediaTagVersion (str): Unknown (/system/bundle/media/flags/) + server (:class:`~plexapi.server.PlexServer`): PlexServer this client is connected to. + title1 (str): 'Plex Library' (not sure how useful this is). + title2 (str): Second title (this is blank on my setup). + """ + key = '/library' + + def _loadData(self, data): + self._data = data + self.identifier = data.attrib.get('identifier') + self.mediaTagVersion = data.attrib.get('mediaTagVersion') + self.title1 = data.attrib.get('title1') + self.title2 = data.attrib.get('title2') + self._sectionsByID = {} # cached sections by key + self._sectionsByTitle = {} # cached sections by title + + def _loadSections(self): + """ Loads and caches all the library sections. """ + key = '/library/sections' + sectionsByID = {} + sectionsByTitle = defaultdict(list) + libcls = { + 'movie': MovieSection, + 'show': ShowSection, + 'artist': MusicSection, + 'photo': PhotoSection, + } + + for elem in self._server.query(key): + section = libcls.get(elem.attrib.get('type'), LibrarySection)(self._server, elem, initpath=key) + sectionsByID[section.key] = section + sectionsByTitle[section.title.lower().strip()].append(section) + + self._sectionsByID = sectionsByID + self._sectionsByTitle = dict(sectionsByTitle) + + def sections(self): + """ Returns a list of all media sections in this library. Library sections may be any of + :class:`~plexapi.library.MovieSection`, :class:`~plexapi.library.ShowSection`, + :class:`~plexapi.library.MusicSection`, :class:`~plexapi.library.PhotoSection`. + """ + self._loadSections() + return list(self._sectionsByID.values()) + + def section(self, title): + """ Returns the :class:`~plexapi.library.LibrarySection` that matches the specified title. + Note: Multiple library sections with the same title is ambiguous. + Use :func:`~plexapi.library.Library.sectionByID` instead for an exact match. + + Parameters: + title (str): Title of the section to return. + + Raises: + :exc:`~plexapi.exceptions.NotFound`: The library section title is not found on the server. + """ + normalized_title = title.lower().strip() + if not self._sectionsByTitle or normalized_title not in self._sectionsByTitle: + self._loadSections() + try: + sections = self._sectionsByTitle[normalized_title] + except KeyError: + raise NotFound(f'Invalid library section: {title}') from None + + if len(sections) > 1: + warnings.warn( + 'Multiple library sections with the same title found, use "sectionByID" instead. ' + 'Returning the last section.' + ) + return sections[-1] + + def sectionByID(self, sectionID): + """ Returns the :class:`~plexapi.library.LibrarySection` that matches the specified sectionID. + + Parameters: + sectionID (int): ID of the section to return. + + Raises: + :exc:`~plexapi.exceptions.NotFound`: The library section ID is not found on the server. + """ + if not self._sectionsByID or sectionID not in self._sectionsByID: + self._loadSections() + try: + return self._sectionsByID[sectionID] + except KeyError: + raise NotFound(f'Invalid library sectionID: {sectionID}') from None + + def hubs(self, sectionID=None, identifier=None, **kwargs): + """ Returns a list of :class:`~plexapi.library.Hub` across all library sections. + + Parameters: + sectionID (int or str or list, optional): + IDs of the sections to limit results or "playlists". + identifier (str or list, optional): + Names of identifiers to limit results. + Available on `Hub` instances as the `hubIdentifier` attribute. + Examples: 'home.continue' or 'home.ondeck' + """ + if sectionID: + if not isinstance(sectionID, list): + sectionID = [sectionID] + kwargs['contentDirectoryID'] = ",".join(map(str, sectionID)) + if identifier: + if not isinstance(identifier, list): + identifier = [identifier] + kwargs['identifier'] = ",".join(identifier) + key = f'/hubs{utils.joinArgs(kwargs)}' + return self.fetchItems(key) + + def all(self, **kwargs): + """ Returns a list of all media from all library sections. + This may be a very large dataset to retrieve. + """ + items = [] + for section in self.sections(): + for item in section.all(**kwargs): + items.append(item) + return items + + def onDeck(self): + """ Returns a list of all media items on deck. """ + return self.fetchItems('/library/onDeck') + + def recentlyAdded(self): + """ Returns a list of all media items recently added. """ + return self.fetchItems('/library/recentlyAdded') + + def search(self, title=None, libtype=None, **kwargs): + """ Searching within a library section is much more powerful. It seems certain + attributes on the media objects can be targeted to filter this search down + a bit, but I haven't found the documentation for it. + + Example: "studio=Comedy%20Central" or "year=1999" "title=Kung Fu" all work. Other items + such as actor=<id> seem to work, but require you already know the id of the actor. + TLDR: This is untested but seems to work. Use library section search when you can. + """ + args = {} + if title: + args['title'] = title + if libtype: + args['type'] = utils.searchType(libtype) + for attr, value in kwargs.items(): + args[attr] = value + key = f'/library/all{utils.joinArgs(args)}' + return self.fetchItems(key) + + def cleanBundles(self): + """ Poster images and other metadata for items in your library are kept in "bundle" + packages. When you remove items from your library, these bundles aren't immediately + removed. Removing these old bundles can reduce the size of your install. By default, your + server will automatically clean up old bundles once a week as part of Scheduled Tasks. + """ + self._server.query('/library/clean/bundles?async=1', method=self._server._session.put) + return self + + def emptyTrash(self): + """ If a library has items in the Library Trash, use this option to empty the Trash. """ + for section in self.sections(): + section.emptyTrash() + return self + + def optimize(self): + """ The Optimize option cleans up the server database from unused or fragmented data. + For example, if you have deleted or added an entire library or many items in a + library, you may like to optimize the database. + """ + self._server.query('/library/optimize?async=1', method=self._server._session.put) + return self + + def update(self): + """ Scan this library for new items.""" + self._server.query('/library/sections/all/refresh') + return self + + def cancelUpdate(self): + """ Cancel a library update. """ + key = '/library/sections/all/refresh' + self._server.query(key, method=self._server._session.delete) + return self + + def refresh(self): + """ Forces a download of fresh media information from the internet. + This can take a long time. Any locked fields are not modified. + """ + self._server.query('/library/sections/all/refresh?force=1') + return self + + def deleteMediaPreviews(self): + """ Delete the preview thumbnails for the all sections. This cannot be + undone. Recreating media preview files can take hours or even days. + """ + for section in self.sections(): + section.deleteMediaPreviews() + return self + + def add(self, name='', type='', agent='', scanner='', location='', language='en-US', *args, **kwargs): + """ Simplified add for the most common options. + + Parameters: + name (str): Name of the library + agent (str): Example com.plexapp.agents.imdb + type (str): movie, show, # check me + location (str or list): /path/to/files, ["/path/to/files", "/path/to/morefiles"] + language (str): Four letter language code (e.g. en-US) + kwargs (dict): Advanced options should be passed as a dict. where the id is the key. + + **Photo Preferences** + + * **agent** (str): com.plexapp.agents.none + * **enableAutoPhotoTags** (bool): Tag photos. Default value false. + * **enableBIFGeneration** (bool): Enable video preview thumbnails. Default value true. + * **includeInGlobal** (bool): Include in dashboard. Default value true. + * **scanner** (str): Plex Photo Scanner + + **Movie Preferences** + + * **agent** (str): com.plexapp.agents.none, com.plexapp.agents.imdb, tv.plex.agents.movie, + com.plexapp.agents.themoviedb + * **enableBIFGeneration** (bool): Enable video preview thumbnails. Default value true. + * **enableCinemaTrailers** (bool): Enable Cinema Trailers. Default value true. + * **includeInGlobal** (bool): Include in dashboard. Default value true. + * **scanner** (str): Plex Movie, Plex Movie Scanner, Plex Video Files Scanner, Plex Video Files + + **IMDB Movie Options** (com.plexapp.agents.imdb) + + * **title** (bool): Localized titles. Default value false. + * **extras** (bool): Find trailers and extras automatically (Plex Pass required). Default value true. + * **only_trailers** (bool): Skip extras which aren't trailers. Default value false. + * **redband** (bool): Use red band (restricted audiences) trailers when available. Default value false. + * **native_subs** (bool): Include extras with subtitles in Library language. Default value false. + * **cast_list** (int): Cast List Source: Default value 1 Possible options: 0:IMDb,1:The Movie Database. + * **ratings** (int): Ratings Source, Default value 0 Possible options: + 0:Rotten Tomatoes, 1:IMDb, 2:The Movie Database. + * **summary** (int): Plot Summary Source: Default value 1 Possible options: 0:IMDb,1:The Movie Database. + * **country** (int): Default value 46 Possible options 0:Argentina, 1:Australia, 2:Austria, + 3:Belgium, 4:Belize, 5:Bolivia, 6:Brazil, 7:Canada, 8:Chile, 9:Colombia, 10:Costa Rica, + 11:Czech Republic, 12:Denmark, 13:Dominican Republic, 14:Ecuador, 15:El Salvador, + 16:France, 17:Germany, 18:Guatemala, 19:Honduras, 20:Hong Kong SAR, 21:Ireland, + 22:Italy, 23:Jamaica, 24:Korea, 25:Liechtenstein, 26:Luxembourg, 27:Mexico, 28:Netherlands, + 29:New Zealand, 30:Nicaragua, 31:Panama, 32:Paraguay, 33:Peru, 34:Portugal, + 35:Peoples Republic of China, 36:Puerto Rico, 37:Russia, 38:Singapore, 39:South Africa, + 40:Spain, 41:Sweden, 42:Switzerland, 43:Taiwan, 44:Trinidad, 45:United Kingdom, + 46:United States, 47:Uruguay, 48:Venezuela. + * **collections** (bool): Use collection info from The Movie Database. Default value false. + * **localart** (bool): Prefer artwork based on library language. Default value true. + * **adult** (bool): Include adult content. Default value false. + * **usage** (bool): Send anonymous usage data to Plex. Default value true. + + **TheMovieDB Movie Options** (com.plexapp.agents.themoviedb) + + * **collections** (bool): Use collection info from The Movie Database. Default value false. + * **localart** (bool): Prefer artwork based on library language. Default value true. + * **adult** (bool): Include adult content. Default value false. + * **country** (int): Country (used for release date and content rating). Default value 47 Possible + options 0:, 1:Argentina, 2:Australia, 3:Austria, 4:Belgium, 5:Belize, 6:Bolivia, 7:Brazil, 8:Canada, + 9:Chile, 10:Colombia, 11:Costa Rica, 12:Czech Republic, 13:Denmark, 14:Dominican Republic, 15:Ecuador, + 16:El Salvador, 17:France, 18:Germany, 19:Guatemala, 20:Honduras, 21:Hong Kong SAR, 22:Ireland, + 23:Italy, 24:Jamaica, 25:Korea, 26:Liechtenstein, 27:Luxembourg, 28:Mexico, 29:Netherlands, + 30:New Zealand, 31:Nicaragua, 32:Panama, 33:Paraguay, 34:Peru, 35:Portugal, + 36:Peoples Republic of China, 37:Puerto Rico, 38:Russia, 39:Singapore, 40:South Africa, 41:Spain, + 42:Sweden, 43:Switzerland, 44:Taiwan, 45:Trinidad, 46:United Kingdom, 47:United States, 48:Uruguay, + 49:Venezuela. + + **Show Preferences** + + * **agent** (str): com.plexapp.agents.none, com.plexapp.agents.thetvdb, com.plexapp.agents.themoviedb, + tv.plex.agents.series + * **enableBIFGeneration** (bool): Enable video preview thumbnails. Default value true. + * **episodeSort** (int): Episode order. Default -1 Possible options: 0:Oldest first, 1:Newest first. + * **flattenSeasons** (int): Seasons. Default value 0 Possible options: 0:Show,1:Hide. + * **includeInGlobal** (bool): Include in dashboard. Default value true. + * **scanner** (str): Plex TV Series, Plex Series Scanner + + **TheTVDB Show Options** (com.plexapp.agents.thetvdb) + + * **extras** (bool): Find trailers and extras automatically (Plex Pass required). Default value true. + * **native_subs** (bool): Include extras with subtitles in Library language. Default value false. + + **TheMovieDB Show Options** (com.plexapp.agents.themoviedb) + + * **collections** (bool): Use collection info from The Movie Database. Default value false. + * **localart** (bool): Prefer artwork based on library language. Default value true. + * **adult** (bool): Include adult content. Default value false. + * **country** (int): Country (used for release date and content rating). Default value 47 options + 0:, 1:Argentina, 2:Australia, 3:Austria, 4:Belgium, 5:Belize, 6:Bolivia, 7:Brazil, 8:Canada, 9:Chile, + 10:Colombia, 11:Costa Rica, 12:Czech Republic, 13:Denmark, 14:Dominican Republic, 15:Ecuador, + 16:El Salvador, 17:France, 18:Germany, 19:Guatemala, 20:Honduras, 21:Hong Kong SAR, 22:Ireland, + 23:Italy, 24:Jamaica, 25:Korea, 26:Liechtenstein, 27:Luxembourg, 28:Mexico, 29:Netherlands, + 30:New Zealand, 31:Nicaragua, 32:Panama, 33:Paraguay, 34:Peru, 35:Portugal, + 36:Peoples Republic of China, 37:Puerto Rico, 38:Russia, 39:Singapore, 40:South Africa, + 41:Spain, 42:Sweden, 43:Switzerland, 44:Taiwan, 45:Trinidad, 46:United Kingdom, 47:United States, + 48:Uruguay, 49:Venezuela. + + **Other Video Preferences** + + * **agent** (str): com.plexapp.agents.none, com.plexapp.agents.imdb, com.plexapp.agents.themoviedb + * **enableBIFGeneration** (bool): Enable video preview thumbnails. Default value true. + * **enableCinemaTrailers** (bool): Enable Cinema Trailers. Default value true. + * **includeInGlobal** (bool): Include in dashboard. Default value true. + * **scanner** (str): Plex Movie Scanner, Plex Video Files Scanner + + **IMDB Other Video Options** (com.plexapp.agents.imdb) + + * **title** (bool): Localized titles. Default value false. + * **extras** (bool): Find trailers and extras automatically (Plex Pass required). Default value true. + * **only_trailers** (bool): Skip extras which aren't trailers. Default value false. + * **redband** (bool): Use red band (restricted audiences) trailers when available. Default value false. + * **native_subs** (bool): Include extras with subtitles in Library language. Default value false. + * **cast_list** (int): Cast List Source: Default value 1 Possible options: 0:IMDb,1:The Movie Database. + * **ratings** (int): Ratings Source Default value 0 Possible options: + 0:Rotten Tomatoes,1:IMDb,2:The Movie Database. + * **summary** (int): Plot Summary Source: Default value 1 Possible options: 0:IMDb,1:The Movie Database. + * **country** (int): Country: Default value 46 Possible options: 0:Argentina, 1:Australia, 2:Austria, + 3:Belgium, 4:Belize, 5:Bolivia, 6:Brazil, 7:Canada, 8:Chile, 9:Colombia, 10:Costa Rica, + 11:Czech Republic, 12:Denmark, 13:Dominican Republic, 14:Ecuador, 15:El Salvador, 16:France, + 17:Germany, 18:Guatemala, 19:Honduras, 20:Hong Kong SAR, 21:Ireland, 22:Italy, 23:Jamaica, + 24:Korea, 25:Liechtenstein, 26:Luxembourg, 27:Mexico, 28:Netherlands, 29:New Zealand, 30:Nicaragua, + 31:Panama, 32:Paraguay, 33:Peru, 34:Portugal, 35:Peoples Republic of China, 36:Puerto Rico, + 37:Russia, 38:Singapore, 39:South Africa, 40:Spain, 41:Sweden, 42:Switzerland, 43:Taiwan, 44:Trinidad, + 45:United Kingdom, 46:United States, 47:Uruguay, 48:Venezuela. + * **collections** (bool): Use collection info from The Movie Database. Default value false. + * **localart** (bool): Prefer artwork based on library language. Default value true. + * **adult** (bool): Include adult content. Default value false. + * **usage** (bool): Send anonymous usage data to Plex. Default value true. + + **TheMovieDB Other Video Options** (com.plexapp.agents.themoviedb) + + * **collections** (bool): Use collection info from The Movie Database. Default value false. + * **localart** (bool): Prefer artwork based on library language. Default value true. + * **adult** (bool): Include adult content. Default value false. + * **country** (int): Country (used for release date and content rating). Default + value 47 Possible options 0:, 1:Argentina, 2:Australia, 3:Austria, 4:Belgium, 5:Belize, + 6:Bolivia, 7:Brazil, 8:Canada, 9:Chile, 10:Colombia, 11:Costa Rica, 12:Czech Republic, + 13:Denmark, 14:Dominican Republic, 15:Ecuador, 16:El Salvador, 17:France, 18:Germany, + 19:Guatemala, 20:Honduras, 21:Hong Kong SAR, 22:Ireland, 23:Italy, 24:Jamaica, + 25:Korea, 26:Liechtenstein, 27:Luxembourg, 28:Mexico, 29:Netherlands, 30:New Zealand, + 31:Nicaragua, 32:Panama, 33:Paraguay, 34:Peru, 35:Portugal, + 36:Peoples Republic of China, 37:Puerto Rico, 38:Russia, 39:Singapore, + 40:South Africa, 41:Spain, 42:Sweden, 43:Switzerland, 44:Taiwan, 45:Trinidad, + 46:United Kingdom, 47:United States, 48:Uruguay, 49:Venezuela. + """ + if isinstance(location, str): + location = [location] + locations = [] + for path in location: + if not self._server.isBrowsable(path): + raise BadRequest(f'Path: {path} does not exist.') + locations.append(('location', path)) + + part = (f'/library/sections?name={quote_plus(name)}&type={type}&agent={agent}' + f'&scanner={quote_plus(scanner)}&language={language}&{urlencode(locations, doseq=True)}') + if kwargs: + prefs_params = {f'prefs[{k}]': v for k, v in kwargs.items()} + part += f'&{urlencode(prefs_params)}' + return self._server.query(part, method=self._server._session.post) + + def history(self, maxresults=None, mindate=None): + """ Get Play History for all library Sections for the owner. + Parameters: + maxresults (int): Only return the specified number of results (optional). + mindate (datetime): Min datetime to return results from. + """ + hist = [] + for section in self.sections(): + hist.extend(section.history(maxresults=maxresults, mindate=mindate)) + return hist + + def tags(self, tag): + """ Returns a list of :class:`~plexapi.library.LibraryMediaTag` objects for the specified tag. + + Parameters: + tag (str): Tag name (see :data:`~plexapi.utils.TAGTYPES`). + """ + tagType = utils.tagType(tag) + data = self._server.query(f'/library/tags?type={tagType}') + return self.findItems(data) + + +class LibrarySection(PlexObject): + """ Base class for a single library section. + + Attributes: + agent (str): The metadata agent used for the library section (com.plexapp.agents.imdb, etc). + allowSync (bool): True if you allow syncing content from the library section. + art (str): Background artwork used to respresent the library section. + composite (str): Composite image used to represent the library section. + createdAt (datetime): Datetime the library section was created. + filters (bool): True if filters are available for the library section. + key (int): Key (or ID) of this library section. + language (str): Language represented in this section (en, xn, etc). + locations (List<str>): List of folder paths added to the library section. + refreshing (bool): True if this section is currently being refreshed. + scanner (str): Internal scanner used to find media (Plex Movie Scanner, Plex Premium Music Scanner, etc.) + thumb (str): Thumbnail image used to represent the library section. + title (str): Name of the library section. + type (str): Type of content section represents (movie, show, artist, photo). + updatedAt (datetime): Datetime the library section was last updated. + uuid (str): Unique id for the section (32258d7c-3e6c-4ac5-98ad-bad7a3b78c63) + """ + + def _loadData(self, data): + self._data = data + self.agent = data.attrib.get('agent') + self.allowSync = utils.cast(bool, data.attrib.get('allowSync')) + self.art = data.attrib.get('art') + self.composite = data.attrib.get('composite') + self.createdAt = utils.toDatetime(data.attrib.get('createdAt')) + self.filters = utils.cast(bool, data.attrib.get('filters')) + self.key = utils.cast(int, data.attrib.get('key')) + self.language = data.attrib.get('language') + self.locations = self.listAttrs(data, 'path', etag='Location') + self.refreshing = utils.cast(bool, data.attrib.get('refreshing')) + self.scanner = data.attrib.get('scanner') + self.thumb = data.attrib.get('thumb') + self.title = data.attrib.get('title') + self.type = data.attrib.get('type') + self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) + self.uuid = data.attrib.get('uuid') + # Private attrs as we don't want a reload. + self._filterTypes = None + self._fieldTypes = None + self._totalViewSize = None + self._totalDuration = None + self._totalStorage = None + + @cached_property + def totalSize(self): + """ Returns the total number of items in the library for the default library type. """ + return self.totalViewSize(includeCollections=False) + + @property + def totalDuration(self): + """ Returns the total duration (in milliseconds) of items in the library. """ + if self._totalDuration is None: + self._getTotalDurationStorage() + return self._totalDuration + + @property + def totalStorage(self): + """ Returns the total storage (in bytes) of items in the library. """ + if self._totalStorage is None: + self._getTotalDurationStorage() + return self._totalStorage + + def __getattribute__(self, attr): + # Intercept to call EditFieldMixin and EditTagMixin methods + # based on the item type being batch multi-edited + value = super().__getattribute__(attr) + if attr.startswith('_'): return value + if callable(value) and 'Mixin' in value.__qualname__: + if not isinstance(self._edits, dict): + raise AttributeError("Must enable batchMultiEdit() to use this method") + elif not hasattr(self._edits['items'][0], attr): + raise AttributeError( + f"Batch multi-editing '{self._edits['items'][0].__class__.__name__}' object has no attribute '{attr}'" + ) + return value + + def _getTotalDurationStorage(self): + """ Queries the Plex server for the total library duration and storage and caches the values. """ + data = self._server.query('/media/providers?includeStorage=1') + xpath = ( + './MediaProvider[@identifier="com.plexapp.plugins.library"]' + '/Feature[@type="content"]' + f'/Directory[@id="{self.key}"]' + ) + directory = next(iter(data.findall(xpath)), None) + if directory: + self._totalDuration = utils.cast(int, directory.attrib.get('durationTotal')) + self._totalStorage = utils.cast(int, directory.attrib.get('storageTotal')) + + def totalViewSize(self, libtype=None, includeCollections=True): + """ Returns the total number of items in the library for a specified libtype. + The number of items for the default library type will be returned if no libtype is specified. + (e.g. Specify ``libtype='episode'`` for the total number of episodes + or ``libtype='albums'`` for the total number of albums.) + + Parameters: + libtype (str, optional): The type of items to return the total number for (movie, show, season, episode, + artist, album, track, photoalbum). Default is the main library type. + includeCollections (bool, optional): True or False to include collections in the total number. + Default is True. + """ + args = { + 'includeCollections': int(bool(includeCollections)), + 'X-Plex-Container-Start': 0, + 'X-Plex-Container-Size': 0 + } + if libtype is not None: + if libtype == 'photo': + args['clusterZoomLevel'] = 1 + else: + args['type'] = utils.searchType(libtype) + part = f'/library/sections/{self.key}/all{utils.joinArgs(args)}' + data = self._server.query(part) + return utils.cast(int, data.attrib.get("totalSize")) + + def delete(self): + """ Delete a library section. """ + try: + return self._server.query(f'/library/sections/{self.key}', method=self._server._session.delete) + except BadRequest: # pragma: no cover + msg = f'Failed to delete library {self.key}' + msg += 'You may need to allow this permission in your Plex settings.' + log.error(msg) + raise + + def reload(self): + """ Reload the data for the library section. """ + self._server.library._loadSections() + newLibrary = self._server.library.sectionByID(self.key) + self.__dict__.update(newLibrary.__dict__) + return self + + def edit(self, agent=None, **kwargs): + """ Edit a library. See :class:`~plexapi.library.Library` for example usage. + + Parameters: + agent (str, optional): The library agent. + kwargs (dict): Dict of settings to edit. + """ + if not agent: + agent = self.agent + + locations = [] + if kwargs.get('location'): + if isinstance(kwargs['location'], str): + kwargs['location'] = [kwargs['location']] + for path in kwargs.pop('location'): + if not self._server.isBrowsable(path): + raise BadRequest(f'Path: {path} does not exist.') + locations.append(('location', path)) + + params = list(kwargs.items()) + locations + + part = f'/library/sections/{self.key}?agent={agent}&{urlencode(params, doseq=True)}' + self._server.query(part, method=self._server._session.put) + return self + + def addLocations(self, location): + """ Add a location to a library. + + Parameters: + location (str or list): A single folder path, list of paths. + + Example: + + .. code-block:: python + + LibrarySection.addLocations('/path/1') + LibrarySection.addLocations(['/path/1', 'path/2', '/path/3']) + + """ + locations = self.locations + if isinstance(location, str): + location = [location] + for path in location: + if not self._server.isBrowsable(path): + raise BadRequest(f'Path: {path} does not exist.') + locations.append(path) + return self.edit(location=locations) + + def removeLocations(self, location): + """ Remove a location from a library. + + Parameters: + location (str or list): A single folder path, list of paths. + + Example: + + .. code-block:: python + + LibrarySection.removeLocations('/path/1') + LibrarySection.removeLocations(['/path/1', 'path/2', '/path/3']) + + """ + locations = self.locations + if isinstance(location, str): + location = [location] + for path in location: + if path in locations: + locations.remove(path) + else: + raise BadRequest(f'Path: {location} does not exist in the library.') + if len(locations) == 0: + raise BadRequest('You are unable to remove all locations from a library.') + return self.edit(location=locations) + + def get(self, title, **kwargs): + """ Returns the media item with the specified title and kwargs. + + Parameters: + title (str): Title of the item to return. + kwargs (dict): Additional search parameters. + See :func:`~plexapi.library.LibrarySection.search` for more info. + + Raises: + :exc:`~plexapi.exceptions.NotFound`: The title is not found in the library. + """ + try: + return self.search(title, limit=1, **kwargs)[0] + except IndexError: + msg = f"Unable to find item with title '{title}'" + if kwargs: + msg += f" and kwargs {kwargs}" + raise NotFound(msg) from None + + def getGuid(self, guid): + """ Returns the media item with the specified external Plex, IMDB, TMDB, or TVDB ID. + Note: Only available for the Plex Movie and Plex TV Series agents. + + Parameters: + guid (str): The external guid of the item to return. + Examples: Plex ``plex://show/5d9c086c46115600200aa2fe`` + IMDB ``imdb://tt0944947``, TMDB ``tmdb://1399``, TVDB ``tvdb://121361``. + + Raises: + :exc:`~plexapi.exceptions.NotFound`: The guid is not found in the library. + + Example: + + .. code-block:: python + + result1 = library.getGuid('plex://show/5d9c086c46115600200aa2fe') + result2 = library.getGuid('imdb://tt0944947') + result3 = library.getGuid('tmdb://1399') + result4 = library.getGuid('tvdb://121361') + + # Alternatively, create your own guid lookup dictionary for faster performance + guidLookup = {} + for item in library.all(): + guidLookup[item.guid] = item + guidLookup.update({guid.id: item for guid in item.guids}) + + result1 = guidLookup['plex://show/5d9c086c46115600200aa2fe'] + result2 = guidLookup['imdb://tt0944947'] + result3 = guidLookup['tmdb://1399'] + result4 = guidLookup['tvdb://121361'] + + """ + + try: + if guid.startswith('plex://'): + result = self.search(guid=guid)[0] + return result + else: + dummy = self.search(maxresults=1)[0] + match = dummy.matches(agent=self.agent, title=guid.replace('://', '-')) + return self.search(guid=match[0].guid)[0] + except IndexError: + raise NotFound(f"Guid '{guid}' is not found in the library") from None + + def all(self, libtype=None, **kwargs): + """ Returns a list of all items from this library section. + See description of :func:`~plexapi.library.LibrarySection.search()` for details about filtering / sorting. + """ + libtype = libtype or self.TYPE + return self.search(libtype=libtype, **kwargs) + + def folders(self): + """ Returns a list of available :class:`~plexapi.library.Folder` for this library section. + """ + key = f'/library/sections/{self.key}/folder' + return self.fetchItems(key, Folder) + + def managedHubs(self): + """ Returns a list of available :class:`~plexapi.library.ManagedHub` for this library section. + """ + key = f'/hubs/sections/{self.key}/manage' + return self.fetchItems(key, ManagedHub) + + def resetManagedHubs(self): + """ Reset the managed hub customizations for this library section. + """ + key = f'/hubs/sections/{self.key}/manage' + self._server.query(key, method=self._server._session.delete) + + def hubs(self): + """ Returns a list of available :class:`~plexapi.library.Hub` for this library section. + """ + key = f'/hubs/sections/{self.key}?includeStations=1' + return self.fetchItems(key) + + def agents(self): + """ Returns a list of available :class:`~plexapi.media.Agent` for this library section. + """ + return self._server.agents(self.type) + + def settings(self): + """ Returns a list of all library settings. """ + key = f'/library/sections/{self.key}/prefs' + data = self._server.query(key) + return self.findItems(data, cls=Setting) + + def editAdvanced(self, **kwargs): + """ Edit a library's advanced settings. """ + data = {} + idEnums = {} + key = 'prefs[{}]' + + for setting in self.settings(): + if setting.type != 'bool': + idEnums[setting.id] = setting.enumValues + else: + idEnums[setting.id] = {0: False, 1: True} + + for settingID, value in kwargs.items(): + try: + enums = idEnums[settingID] + except KeyError: + raise NotFound(f'{value} not found in {list(idEnums.keys())}') + if value in enums: + data[key.format(settingID)] = value + else: + raise NotFound(f'{value} not found in {enums}') + + return self.edit(**data) + + def defaultAdvanced(self): + """ Edit all of library's advanced settings to default. """ + data = {} + key = 'prefs[{}]' + for setting in self.settings(): + if setting.type == 'bool': + data[key.format(setting.id)] = int(setting.default) + else: + data[key.format(setting.id)] = setting.default + + return self.edit(**data) + + def _lockUnlockAllField(self, field, libtype=None, locked=True): + """ Lock or unlock a field for all items in the library. """ + libtype = libtype or self.TYPE + args = { + 'type': utils.searchType(libtype), + f'{field}.locked': int(locked) + } + key = f'/library/sections/{self.key}/all{utils.joinArgs(args)}' + self._server.query(key, method=self._server._session.put) + return self + + def lockAllField(self, field, libtype=None): + """ Lock a field for all items in the library. + + Parameters: + field (str): The field to lock (e.g. thumb, rating, collection). + libtype (str, optional): The library type to lock (movie, show, season, episode, + artist, album, track, photoalbum, photo). Default is the main library type. + """ + return self._lockUnlockAllField(field, libtype=libtype, locked=True) + + def unlockAllField(self, field, libtype=None): + """ Unlock a field for all items in the library. + + Parameters: + field (str): The field to unlock (e.g. thumb, rating, collection). + libtype (str, optional): The library type to lock (movie, show, season, episode, + artist, album, track, photoalbum, photo). Default is the main library type. + """ + return self._lockUnlockAllField(field, libtype=libtype, locked=False) + + def timeline(self): + """ Returns a timeline query for this library section. """ + key = f'/library/sections/{self.key}/timeline' + data = self._server.query(key) + return LibraryTimeline(self, data) + + def onDeck(self): + """ Returns a list of media items on deck from this library section. """ + key = f'/library/sections/{self.key}/onDeck' + return self.fetchItems(key) + + def continueWatching(self): + """ Return a list of media items in the library's Continue Watching hub. """ + key = f'/hubs/sections/{self.key}/continueWatching/items' + return self.fetchItems(key) + + def recentlyAdded(self, maxresults=50, libtype=None): + """ Returns a list of media items recently added from this library section. + + Parameters: + maxresults (int): Max number of items to return (default 50). + libtype (str, optional): The library type to filter (movie, show, season, episode, + artist, album, track, photoalbum, photo). Default is the main library type. + """ + libtype = libtype or self.TYPE + return self.search(sort='addedAt:desc', maxresults=maxresults, libtype=libtype) + + def firstCharacter(self): + key = f'/library/sections/{self.key}/firstCharacter' + return self.fetchItems(key, cls=FirstCharacter) + + def analyze(self): + """ Run an analysis on all of the items in this library section. See + See :func:`~plexapi.base.PlexPartialObject.analyze` for more details. + """ + key = f'/library/sections/{self.key}/analyze' + self._server.query(key, method=self._server._session.put) + return self + + def emptyTrash(self): + """ If a section has items in the Trash, use this option to empty the Trash. """ + key = f'/library/sections/{self.key}/emptyTrash' + self._server.query(key, method=self._server._session.put) + return self + + def update(self, path=None): + """ Scan this section for new media. + + Parameters: + path (str, optional): Full path to folder to scan. + """ + key = f'/library/sections/{self.key}/refresh' + if path is not None: + key += f'?path={quote_plus(path)}' + self._server.query(key) + return self + + def cancelUpdate(self): + """ Cancel update of this Library Section. """ + key = f'/library/sections/{self.key}/refresh' + self._server.query(key, method=self._server._session.delete) + return self + + def refresh(self): + """ Forces a download of fresh media information from the internet. + This can take a long time. Any locked fields are not modified. + """ + key = f'/library/sections/{self.key}/refresh?force=1' + self._server.query(key) + return self + + def deleteMediaPreviews(self): + """ Delete the preview thumbnails for items in this library. This cannot + be undone. Recreating media preview files can take hours or even days. + """ + key = f'/library/sections/{self.key}/indexes' + self._server.query(key, method=self._server._session.delete) + return self + + def _loadFilters(self): + """ Retrieves and caches the list of :class:`~plexapi.library.FilteringType` and + list of :class:`~plexapi.library.FilteringFieldType` for this library section. + """ + _key = ('/library/sections/{key}/{filter}?includeMeta=1&includeAdvanced=1' + '&X-Plex-Container-Start=0&X-Plex-Container-Size=0') + + key = _key.format(key=self.key, filter='all') + data = self._server.query(key) + self._filterTypes = self.findItems(data, FilteringType, rtag='Meta') + self._fieldTypes = self.findItems(data, FilteringFieldType, rtag='Meta') + + if self.TYPE != 'photo': # No collections for photo library + key = _key.format(key=self.key, filter='collections') + data = self._server.query(key) + self._filterTypes.extend(self.findItems(data, FilteringType, rtag='Meta')) + + # Manually add guid field type, only allowing "is" operator + guidFieldType = '<FieldType type="guid"><Operator key="=" title="is"/></FieldType>' + self._fieldTypes.append(self._manuallyLoadXML(guidFieldType, FilteringFieldType)) + + def filterTypes(self): + """ Returns a list of available :class:`~plexapi.library.FilteringType` for this library section. """ + if self._filterTypes is None: + self._loadFilters() + return self._filterTypes + + def getFilterType(self, libtype=None): + """ Returns a :class:`~plexapi.library.FilteringType` for a specified libtype. + + Parameters: + libtype (str, optional): The library type to filter (movie, show, season, episode, + artist, album, track, photoalbum, photo, collection). + + Raises: + :exc:`~plexapi.exceptions.NotFound`: Unknown libtype for this library. + """ + libtype = libtype or self.TYPE + try: + return next(f for f in self.filterTypes() if f.type == libtype) + except StopIteration: + availableLibtypes = [f.type for f in self.filterTypes()] + raise NotFound(f'Unknown libtype "{libtype}" for this library. ' + f'Available libtypes: {availableLibtypes}') from None + + def fieldTypes(self): + """ Returns a list of available :class:`~plexapi.library.FilteringFieldType` for this library section. """ + if self._fieldTypes is None: + self._loadFilters() + return self._fieldTypes + + def getFieldType(self, fieldType): + """ Returns a :class:`~plexapi.library.FilteringFieldType` for a specified fieldType. + + Parameters: + fieldType (str): The data type for the field (tag, integer, string, boolean, date, + subtitleLanguage, audioLanguage, resolution). + + Raises: + :exc:`~plexapi.exceptions.NotFound`: Unknown fieldType for this library. + """ + try: + return next(f for f in self.fieldTypes() if f.type == fieldType) + except StopIteration: + availableFieldTypes = [f.type for f in self.fieldTypes()] + raise NotFound(f'Unknown field type "{fieldType}" for this library. ' + f'Available field types: {availableFieldTypes}') from None + + def listFilters(self, libtype=None): + """ Returns a list of available :class:`~plexapi.library.FilteringFilter` for a specified libtype. + This is the list of options in the filter dropdown menu + (`screenshot <../_static/images/LibrarySection.listFilters.png>`__). + + Parameters: + libtype (str, optional): The library type to filter (movie, show, season, episode, + artist, album, track, photoalbum, photo, collection). + + Example: + + .. code-block:: python + + availableFilters = [f.filter for f in library.listFilters()] + print("Available filter fields:", availableFilters) + + """ + return self.getFilterType(libtype).filters + + def listSorts(self, libtype=None): + """ Returns a list of available :class:`~plexapi.library.FilteringSort` for a specified libtype. + This is the list of options in the sorting dropdown menu + (`screenshot <../_static/images/LibrarySection.listSorts.png>`__). + + Parameters: + libtype (str, optional): The library type to filter (movie, show, season, episode, + artist, album, track, photoalbum, photo, collection). + + Example: + + .. code-block:: python + + availableSorts = [f.key for f in library.listSorts()] + print("Available sort fields:", availableSorts) + + """ + return self.getFilterType(libtype).sorts + + def listFields(self, libtype=None): + """ Returns a list of available :class:`~plexapi.library.FilteringFields` for a specified libtype. + This is the list of options in the custom filter dropdown menu + (`screenshot <../_static/images/LibrarySection.search.png>`__). + + Parameters: + libtype (str, optional): The library type to filter (movie, show, season, episode, + artist, album, track, photoalbum, photo, collection). + + Example: + + .. code-block:: python + + availableFields = [f.key.split('.')[-1] for f in library.listFields()] + print("Available fields:", availableFields) + + """ + return self.getFilterType(libtype).fields + + def listOperators(self, fieldType): + """ Returns a list of available :class:`~plexapi.library.FilteringOperator` for a specified fieldType. + This is the list of options in the custom filter operator dropdown menu + (`screenshot <../_static/images/LibrarySection.search.png>`__). + + Parameters: + fieldType (str): The data type for the field (tag, integer, string, boolean, date, + subtitleLanguage, audioLanguage, resolution). + + Example: + + .. code-block:: python + + field = 'genre' # Available filter field from listFields() + filterField = next(f for f in library.listFields() if f.key.endswith(field)) + availableOperators = [o.key for o in library.listOperators(filterField.type)] + print(f"Available operators for {field}:", availableOperators) + + """ + return self.getFieldType(fieldType).operators + + def listFilterChoices(self, field, libtype=None): + """ Returns a list of available :class:`~plexapi.library.FilterChoice` for a specified + :class:`~plexapi.library.FilteringFilter` or filter field. + This is the list of available values for a custom filter + (`screenshot <../_static/images/LibrarySection.search.png>`__). + + Parameters: + field (str): :class:`~plexapi.library.FilteringFilter` object, + or the name of the field (genre, year, contentRating, etc.). + libtype (str, optional): The library type to filter (movie, show, season, episode, + artist, album, track, photoalbum, photo, collection). + + Raises: + :exc:`~plexapi.exceptions.BadRequest`: Invalid filter field. + :exc:`~plexapi.exceptions.NotFound`: Unknown filter field. + + Example: + + .. code-block:: python + + field = 'genre' # Available filter field from listFilters() + availableChoices = [f.title for f in library.listFilterChoices(field)] + print(f"Available choices for {field}:", availableChoices) + + """ + if isinstance(field, str): + match = re.match(r'(?:([a-zA-Z]*)\.)?([a-zA-Z]+)', field) + if not match: + raise BadRequest(f'Invalid filter field: {field}') + _libtype, field = match.groups() + libtype = _libtype or libtype or self.TYPE + try: + field = next(f for f in self.listFilters(libtype) if f.filter == field) + except StopIteration: + availableFilters = [f.filter for f in self.listFilters(libtype)] + raise NotFound(f'Unknown filter field "{field}" for libtype "{libtype}". ' + f'Available filters: {availableFilters}') from None + + data = self._server.query(field.key) + return self.findItems(data, FilterChoice) + + def _validateFilterField(self, field, values, libtype=None): + """ Validates a filter field and values are available as a custom filter for the library. + Returns the validated field and values as a URL encoded parameter string. + """ + match = re.match(r'(?:([a-zA-Z]*)\.)?([a-zA-Z]+)([!<>=&]*)', field) + if not match: + raise BadRequest(f'Invalid filter field: {field}') + _libtype, field, operator = match.groups() + libtype = _libtype or libtype or self.TYPE + + try: + filterField = next(f for f in self.listFields(libtype) if f.key.split('.')[-1] == field) + except StopIteration: + for filterType in reversed(self.filterTypes()): + if filterType.type != libtype: + filterField = next((f for f in filterType.fields if f.key.split('.')[-1] == field), None) + if filterField: + break + else: + availableFields = [f.key for f in self.listFields(libtype)] + raise NotFound(f'Unknown filter field "{field}" for libtype "{libtype}". ' + f'Available filter fields: {availableFields}') from None + + field = filterField.key + operator = self._validateFieldOperator(filterField, operator) + result = self._validateFieldValue(filterField, values, libtype) + + if operator == '&=': + args = {field: result} + return urlencode(args, doseq=True) + else: + args = {field + operator[:-1]: ','.join(result)} + return urlencode(args) + + def _validateFieldOperator(self, filterField, operator): + """ Validates filter operator is in the available operators. + Returns the validated operator string. + """ + fieldType = self.getFieldType(filterField.type) + + and_operator = False + if operator in {'&', '&='}: + and_operator = True + operator = '' + if fieldType.type == 'string' and operator in {'=', '!='}: + operator += '=' + operator = (operator[:-1] if operator[-1:] == '=' else operator) + '=' + + try: + next(o for o in fieldType.operators if o.key == operator) + except StopIteration: + availableOperators = [o.key for o in self.listOperators(filterField.type)] + raise NotFound(f'Unknown operator "{operator}" for filter field "{filterField.key}". ' + f'Available operators: {availableOperators}') from None + + return '&=' if and_operator else operator + + def _validateFieldValue(self, filterField, values, libtype=None): + """ Validates filter values are the correct datatype and in the available filter choices. + Returns the validated list of values. + """ + if not isinstance(values, (list, tuple)): + values = [values] + + fieldType = self.getFieldType(filterField.type) + results = [] + + try: + for value in values: + if fieldType.type == 'boolean': + value = int(bool(value)) + elif fieldType.type == 'date': + value = self._validateFieldValueDate(value) + elif fieldType.type == 'integer': + value = float(value) if '.' in str(value) else int(value) + elif fieldType.type == 'string': + value = str(value) + elif fieldType.type in {'tag', 'subtitleLanguage', 'audioLanguage', 'resolution'}: + value = self._validateFieldValueTag(value, filterField, libtype) + results.append(str(value)) + except (ValueError, AttributeError): + raise BadRequest(f'Invalid value "{value}" for filter field "{filterField.key}", ' + f'value should be type {fieldType.type}') from None + + return results + + def _validateFieldValueDate(self, value): + """ Validates a filter date value. A filter date value can be a datetime object, + a relative date (e.g. -30d), or a date in YYYY-MM-DD format. + """ + if isinstance(value, datetime): + return int(value.timestamp()) + elif re.match(r'^-?\d+(mon|[smhdwy])$', value): + return '-' + value.lstrip('-') + else: + return int(utils.toDatetime(value, '%Y-%m-%d').timestamp()) + + def _validateFieldValueTag(self, value, filterField, libtype): + """ Validates a filter tag value. A filter tag value can be a :class:`~plexapi.library.FilterChoice` object, + a :class:`~plexapi.media.MediaTag` object, the exact name :attr:`MediaTag.tag` (*str*), + or the exact id :attr:`MediaTag.id` (*int*). + """ + if isinstance(value, FilterChoice): + return value.key + if isinstance(value, (media.MediaTag, LibraryMediaTag)): + value = str(value.id or value.tag) + else: + value = str(value) + filterChoices = self.listFilterChoices(filterField.key, libtype) + matchValue = value.lower() + return next((f.key for f in filterChoices if matchValue in {f.key.lower(), f.title.lower()}), value) + + def _validateSortFields(self, sort, libtype=None): + """ Validates a list of filter sort fields is available for the library. Sort fields can be a + list of :class:`~plexapi.library.FilteringSort` objects, or a comma separated string. + Returns the validated comma separated sort fields string. + """ + if isinstance(sort, str): + sort = sort.split(',') + + if not isinstance(sort, (list, tuple)): + sort = [sort] + + validatedSorts = [] + for _sort in sort: + validatedSorts.append(self._validateSortField(_sort, libtype)) + + return ','.join(validatedSorts) + + def _validateSortField(self, sort, libtype=None): + """ Validates a filter sort field is available for the library. A sort field can be a + :class:`~plexapi.library.FilteringSort` object, or a string. + Returns the validated sort field string. + """ + if isinstance(sort, FilteringSort): + return f'{libtype or self.TYPE}.{sort.key}:{sort.defaultDirection}' + + match = re.match(r'(?:([a-zA-Z]*)\.)?([a-zA-Z]+):?([a-zA-Z]*)', sort.strip()) + if not match: + raise BadRequest(f'Invalid filter sort: {sort}') + _libtype, sortField, sortDir = match.groups() + libtype = _libtype or libtype or self.TYPE + + try: + filterSort = next(f for f in self.listSorts(libtype) if f.key == sortField) + except StopIteration: + availableSorts = [f.key for f in self.listSorts(libtype)] + raise NotFound(f'Unknown sort field "{sortField}" for libtype "{libtype}". ' + f'Available sort fields: {availableSorts}') from None + + sortField = libtype + '.' + filterSort.key + + availableDirections = ['', 'asc', 'desc', 'nullsLast'] + if sortDir not in availableDirections: + raise NotFound(f'Unknown sort direction "{sortDir}". Available sort directions: {availableDirections}') + + return f'{sortField}:{sortDir}' if sortDir else sortField + + def _validateAdvancedSearch(self, filters, libtype): + """ Validates an advanced search filter dictionary. + Returns the list of validated URL encoded parameter strings for the advanced search. + """ + if not isinstance(filters, dict): + raise BadRequest('Filters must be a dictionary.') + + validatedFilters = [] + + for field, values in filters.items(): + if field.lower() in {'and', 'or'}: + if len(filters.items()) > 1: + raise BadRequest('Multiple keys in the same dictionary with and/or is not allowed.') + if not isinstance(values, list): + raise BadRequest('Value for and/or keys must be a list of dictionaries.') + + validatedFilters.append('push=1') + + for value in values: + validatedFilters.extend(self._validateAdvancedSearch(value, libtype)) + validatedFilters.append(f'{field.lower()}=1') + + del validatedFilters[-1] + validatedFilters.append('pop=1') + + else: + validatedFilters.append(self._validateFilterField(field, values, libtype)) + + return validatedFilters + + def _buildSearchKey(self, title=None, sort=None, libtype=None, limit=None, filters=None, returnKwargs=False, **kwargs): + """ Returns the validated and formatted search query API key + (``/library/sections/<sectionKey>/all?<params>``). + """ + args = {} + filter_args = [] + + args['includeGuids'] = int(bool(kwargs.pop('includeGuids', True))) + for field, values in list(kwargs.items()): + if field.split('__')[-1] not in OPERATORS: + filter_args.append(self._validateFilterField(field, values, libtype)) + del kwargs[field] + if title is not None: + if isinstance(title, (list, tuple)): + filter_args.append(self._validateFilterField('title', title, libtype)) + else: + args['title'] = title + if filters is not None: + filter_args.extend(self._validateAdvancedSearch(filters, libtype)) + if sort is not None: + args['sort'] = self._validateSortFields(sort, libtype) + if libtype is not None: + args['type'] = utils.searchType(libtype) + if limit is not None: + args['limit'] = limit + + joined_args = utils.joinArgs(args).lstrip('?') + joined_filter_args = '&'.join(filter_args) if filter_args else '' + params = '&'.join([joined_args, joined_filter_args]).strip('&') + key = f'/library/sections/{self.key}/all?{params}' + + if returnKwargs: + return key, kwargs + return key + + def hubSearch(self, query, mediatype=None, limit=None): + """ Returns the hub search results for this library. See :func:`plexapi.server.PlexServer.search` + for details and parameters. + """ + return self._server.search(query, mediatype, limit, sectionId=self.key) + + def search(self, title=None, sort=None, maxresults=None, libtype=None, + container_start=None, container_size=None, limit=None, filters=None, **kwargs): + """ Search the library. The http requests will be batched in container_size. If you are only looking for the + first <num> results, it would be wise to set the maxresults option to that amount so the search doesn't iterate + over all results on the server. + + Parameters: + title (str, optional): General string query to search for. Partial string matches are allowed. + sort (:class:`~plexapi.library.FilteringSort` or str or list, optional): A field to sort the results. + See the details below for more info. + maxresults (int, optional): Only return the specified number of results. + libtype (str, optional): Return results of a specific type (movie, show, season, episode, + artist, album, track, photoalbum, photo, collection) (e.g. ``libtype='episode'`` will only + return :class:`~plexapi.video.Episode` objects) + container_start (int, optional): Default 0. + container_size (int, optional): Default X_PLEX_CONTAINER_SIZE in your config file. + limit (int, optional): Limit the number of results from the filter. + filters (dict, optional): A dictionary of advanced filters. See the details below for more info. + **kwargs (dict): Additional custom filters to apply to the search results. + See the details below for more info. + + Raises: + :exc:`~plexapi.exceptions.BadRequest`: When the sort or filter is invalid. + :exc:`~plexapi.exceptions.NotFound`: When applying an unknown sort or filter. + + **Sorting Results** + + The search results can be sorted by including the ``sort`` parameter. + + * See :func:`~plexapi.library.LibrarySection.listSorts` to get a list of available sort fields. + + The ``sort`` parameter can be a :class:`~plexapi.library.FilteringSort` object or a sort string in the + format ``field:dir``. The sort direction ``dir`` can be ``asc``, ``desc``, or ``nullsLast``. Omitting the + sort direction or using a :class:`~plexapi.library.FilteringSort` object will sort the results in the default + direction of the field. Multi-sorting on multiple fields can be achieved by using a comma separated list of + sort strings, or a list of :class:`~plexapi.library.FilteringSort` object or strings. + + Examples: + + .. code-block:: python + + library.search(sort="titleSort:desc") # Sort title in descending order + library.search(sort="titleSort") # Sort title in the default order + # Multi-sort by year in descending order, then by audience rating in descending order + library.search(sort="year:desc,audienceRating:desc") + library.search(sort=["year:desc", "audienceRating:desc"]) + + **Using Plex Filters** + + Any of the available custom filters can be applied to the search results + (`screenshot <../_static/images/LibrarySection.search.png>`__). + + * See :func:`~plexapi.library.LibrarySection.listFields` to get a list of all available fields. + * See :func:`~plexapi.library.LibrarySection.listOperators` to get a list of all available operators. + * See :func:`~plexapi.library.LibrarySection.listFilterChoices` to get a list of all available filter values. + + The following filter fields are just some examples of the possible filters. The list is not exhaustive, + and not all filters apply to all library types. + + * **actor** (:class:`~plexapi.media.MediaTag`): Search for the name of an actor. + * **addedAt** (*datetime*): Search for items added before or after a date. See operators below. + * **audioLanguage** (*str*): Search for a specific audio language (3 character code, e.g. jpn). + * **collection** (:class:`~plexapi.media.MediaTag`): Search for the name of a collection. + * **contentRating** (:class:`~plexapi.media.MediaTag`): Search for a specific content rating. + * **country** (:class:`~plexapi.media.MediaTag`): Search for the name of a country. + * **decade** (*int*): Search for a specific decade (e.g. 2000). + * **director** (:class:`~plexapi.media.MediaTag`): Search for the name of a director. + * **duplicate** (*bool*) Search for duplicate items. + * **genre** (:class:`~plexapi.media.MediaTag`): Search for a specific genre. + * **hdr** (*bool*): Search for HDR items. + * **inProgress** (*bool*): Search for in progress items. + * **label** (:class:`~plexapi.media.MediaTag`): Search for a specific label. + * **lastViewedAt** (*datetime*): Search for items watched before or after a date. See operators below. + * **mood** (:class:`~plexapi.media.MediaTag`): Search for a specific mood. + * **producer** (:class:`~plexapi.media.MediaTag`): Search for the name of a producer. + * **resolution** (*str*): Search for a specific resolution (e.g. 1080). + * **studio** (*str*): Search for the name of a studio. + * **style** (:class:`~plexapi.media.MediaTag`): Search for a specific style. + * **subtitleLanguage** (*str*): Search for a specific subtitle language (3 character code, e.g. eng) + * **unmatched** (*bool*): Search for unmatched items. + * **unwatched** (*bool*): Search for unwatched items. + * **userRating** (*int*): Search for items with a specific user rating. + * **writer** (:class:`~plexapi.media.MediaTag`): Search for the name of a writer. + * **year** (*int*): Search for a specific year. + + Tag type filter values can be a :class:`~plexapi.library.FilterChoice` object, + :class:`~plexapi.media.MediaTag` object, the exact name :attr:`MediaTag.tag` (*str*), + or the exact id :attr:`MediaTag.id` (*int*). + + Date type filter values can be a ``datetime`` object, a relative date using a one of the + available date suffixes (e.g. ``30d``) (*str*), or a date in ``YYYY-MM-DD`` (*str*) format. + + Relative date suffixes: + + * ``s``: ``seconds`` + * ``m``: ``minutes`` + * ``h``: ``hours`` + * ``d``: ``days`` + * ``w``: ``weeks`` + * ``mon``: ``months`` + * ``y``: ``years`` + + Multiple values can be ``OR`` together by providing a list of values. + + Examples: + + .. code-block:: python + + library.search(unwatched=True, year=2020, resolution="4k") + library.search(actor="Arnold Schwarzenegger", decade=1990) + library.search(contentRating="TV-G", genre="animation") + library.search(genre=["animation", "comedy"]) # Genre is animation OR comedy + library.search(studio=["Disney", "Pixar"]) # Studio contains Disney OR Pixar + + **Using a** ``libtype`` **Prefix** + + Some filters may be prefixed by the ``libtype`` separated by a ``.`` (e.g. ``show.collection``, + ``episode.title``, ``artist.style``, ``album.genre``, ``track.userRating``, etc.). This should not be + confused with the ``libtype`` parameter. If no ``libtype`` prefix is provided, then the default library + type is assumed. For example, in a TV show library ``viewCount`` is assumed to be ``show.viewCount``. + If you want to filter using episode view count then you must specify ``episode.viewCount`` explicitly. + In addition, if the filter does not exist for the default library type it will fallback to the most + specific ``libtype`` available. For example, ``show.unwatched`` does not exists so it will fallback to + ``episode.unwatched``. The ``libtype`` prefix cannot be included directly in the function parameters so + the filters must be provided as a filters dictionary. + + Examples: + + .. code-block:: python + + library.search(filters={"show.collection": "Documentary", "episode.inProgress": True}) + library.search(filters={"artist.genre": "pop", "album.decade": 2000}) + + # The following three options are identical and will return Episode objects + showLibrary.search(title="Winter is Coming", libtype='episode') + showLibrary.search(libtype='episode', filters={"episode.title": "Winter is Coming"}) + showLibrary.searchEpisodes(title="Winter is Coming") + + # The following will search for the episode title but return Show objects + showLibrary.search(filters={"episode.title": "Winter is Coming"}) + + # The following will fallback to episode.unwatched + showLibrary.search(unwatched=True) + + **Using Plex Operators** + + Operators can be appended to the filter field to narrow down results with more granularity. + The following is a list of possible operators depending on the data type of the filter being applied. + A special ``&`` operator can also be used to ``AND`` together a list of values. + + Type: :class:`~plexapi.media.MediaTag` or *subtitleLanguage* or *audioLanguage* + + * no operator: ``is`` + * ``!``: ``is not`` + + Type: *int* + + * no operator: ``is`` + * ``!``: ``is not`` + * ``>>``: ``is greater than`` + * ``<<``: ``is less than`` + + Type: *str* + + * no operator: ``contains`` + * ``!``: ``does not contain`` + * ``=``: ``is`` + * ``!=``: ``is not`` + * ``<``: ``begins with`` + * ``>``: ``ends with`` + + Type: *bool* + + * no operator: ``is true`` + * ``!``: ``is false`` + + Type: *datetime* + + * ``<<``: ``is before`` + * ``>>``: ``is after`` + + Type: *resolution* or *guid* + + * no operator: ``is`` + + Operators cannot be included directly in the function parameters so the filters + must be provided as a filters dictionary. + + Examples: + + .. code-block:: python + + # Genre is horror AND thriller + library.search(filters={"genre&": ["horror", "thriller"]}) + + # Director is not Steven Spielberg + library.search(filters={"director!": "Steven Spielberg"}) + + # Title starts with Marvel and added before 2021-01-01 + library.search(filters={"title<": "Marvel", "addedAt<<": "2021-01-01"}) + + # Added in the last 30 days using relative dates + library.search(filters={"addedAt>>": "30d"}) + + # Collection is James Bond and user rating is greater than 8 + library.search(filters={"collection": "James Bond", "userRating>>": 8}) + + **Using Advanced Filters** + + Any of the Plex filters described above can be combined into a single ``filters`` dictionary that mimics + the advanced filters used in Plex Web with a tree of ``and``/``or`` branches. Each level of the tree must + start with ``and`` (Match all of the following) or ``or`` (Match any of the following) as the dictionary + key, and a list of dictionaries with the desired filters as the dictionary value. + + The following example matches `this <../_static/images/LibrarySection.search_filters.png>`__ advanced filter + in Plex Web. + + Examples: + + .. code-block:: python + + advancedFilters = { + 'and': [ # Match all of the following in this list + { + 'or': [ # Match any of the following in this list + {'title': 'elephant'}, + {'title': 'bunny'} + ] + }, + {'year>>': 1990}, + {'unwatched': True} + ] + } + library.search(filters=advancedFilters) + + **Using PlexAPI Operators** + + For even more advanced filtering which cannot be achieved in Plex, the PlexAPI operators can be applied + to any XML attribute. See :func:`plexapi.base.PlexObject.fetchItems` for a list of operators and how they + are used. Note that using the Plex filters above will be faster since the filters are applied by the Plex + server before the results are returned to PlexAPI. Using the PlexAPI operators requires the Plex server + to return *all* results to allow PlexAPI to do the filtering. The Plex filters and the PlexAPI operators + can be used in conjunction with each other. + + Examples: + + .. code-block:: python + + library.search(summary__icontains="Christmas") + library.search(duration__gt=7200000) + library.search(audienceRating__lte=6.0, audienceRatingImage__startswith="rottentomatoes://") + library.search(media__videoCodec__exact="h265") + library.search(genre="holiday", viewCount__gte=3) + + """ + key, kwargs = self._buildSearchKey( + title=title, sort=sort, libtype=libtype, limit=limit, filters=filters, returnKwargs=True, **kwargs) + return self.fetchItems( + key, container_start=container_start, container_size=container_size, maxresults=maxresults, **kwargs) + + def _locations(self): + """ Returns a list of :class:`~plexapi.library.Location` objects + """ + return self.findItems(self._data, Location) + + def sync(self, policy, mediaSettings, client=None, clientId=None, title=None, sort=None, libtype=None, + **kwargs): + """ Add current library section as sync item for specified device. + See description of :func:`~plexapi.library.LibrarySection.search` for details about filtering / sorting + and :func:`~plexapi.myplex.MyPlexAccount.sync` for possible exceptions. + + Parameters: + policy (:class:`~plexapi.sync.Policy`): policy of syncing the media (how many items to sync and process + watched media or not), generated automatically when method + called on specific LibrarySection object. + mediaSettings (:class:`~plexapi.sync.MediaSettings`): Transcoding settings used for the media, generated + automatically when method called on specific + LibrarySection object. + client (:class:`~plexapi.myplex.MyPlexDevice`): sync destination, see + :func:`~plexapi.myplex.MyPlexAccount.sync`. + clientId (str): sync destination, see :func:`~plexapi.myplex.MyPlexAccount.sync`. + title (str): descriptive title for the new :class:`~plexapi.sync.SyncItem`, if empty the value would be + generated from metadata of current media. + sort (str): formatted as `column:dir`; column can be any of {`addedAt`, `originallyAvailableAt`, + `lastViewedAt`, `titleSort`, `rating`, `mediaHeight`, `duration`}. dir can be `asc` or + `desc`. + libtype (str): Filter results to a specific libtype (`movie`, `show`, `episode`, `artist`, `album`, + `track`). + + Returns: + :class:`~plexapi.sync.SyncItem`: an instance of created syncItem. + + Raises: + :exc:`~plexapi.exceptions.BadRequest`: When the library is not allowed to sync. + :exc:`~plexapi.exceptions.BadRequest`: When the sort or filter is invalid. + :exc:`~plexapi.exceptions.NotFound`: When applying an unknown sort or filter. + + Example: + + .. code-block:: python + + from plexapi import myplex + from plexapi.sync import Policy, MediaSettings, VIDEO_QUALITY_3_MBPS_720p + + c = myplex.MyPlexAccount() + target = c.device('Plex Client') + sync_items_wd = c.syncItems(target.clientIdentifier) + srv = c.resource('Server Name').connect() + section = srv.library.section('Movies') + policy = Policy('count', unwatched=True, value=1) + media_settings = MediaSettings.create(VIDEO_QUALITY_3_MBPS_720p) + section.sync(target, policy, media_settings, title='Next best movie', sort='rating:desc') + + """ + from plexapi.sync import SyncItem + + if not self.allowSync: + raise BadRequest('The requested library is not allowed to sync') + + 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.CONTENT_TYPE + sync_item.metadataType = self.METADATA_TYPE + sync_item.machineIdentifier = self._server.machineIdentifier + + key = self._buildSearchKey(title=title, sort=sort, libtype=libtype, **kwargs) + + sync_item.location = f'library://{self.uuid}/directory/{quote_plus(key)}' + sync_item.policy = policy + sync_item.mediaSettings = mediaSettings + + return myplex.sync(client=client, clientId=clientId, sync_item=sync_item) + + def history(self, maxresults=None, mindate=None): + """ Get Play History for this library Section for the owner. + Parameters: + maxresults (int): Only return the specified number of results (optional). + mindate (datetime): Min datetime to return results from. + """ + return self._server.history(maxresults=maxresults, mindate=mindate, librarySectionID=self.key, accountID=1) + + def createCollection(self, title, items=None, smart=False, limit=None, + libtype=None, sort=None, filters=None, **kwargs): + """ Alias for :func:`~plexapi.server.PlexServer.createCollection` using this + :class:`~plexapi.library.LibrarySection`. + """ + return self._server.createCollection( + title, section=self, items=items, smart=smart, limit=limit, + libtype=libtype, sort=sort, filters=filters, **kwargs) + + def collection(self, title): + """ Returns the collection with the specified title. + + Parameters: + title (str): Title of the item to return. + + Raises: + :exc:`~plexapi.exceptions.NotFound`: Unable to find collection. + """ + try: + return self.collections(title=title, title__iexact=title)[0] + except IndexError: + raise NotFound(f'Unable to find collection with title "{title}".') from None + + def collections(self, **kwargs): + """ Returns a list of collections from this library section. + See description of :func:`~plexapi.library.LibrarySection.search` for details about filtering / sorting. + """ + return self.search(libtype='collection', **kwargs) + + def createPlaylist(self, title, items=None, smart=False, limit=None, + sort=None, filters=None, m3ufilepath=None, **kwargs): + """ Alias for :func:`~plexapi.server.PlexServer.createPlaylist` using this + :class:`~plexapi.library.LibrarySection`. + """ + return self._server.createPlaylist( + title, section=self, items=items, smart=smart, limit=limit, + sort=sort, filters=filters, m3ufilepath=m3ufilepath, **kwargs) + + def playlist(self, title): + """ Returns the playlist with the specified title. + + Parameters: + title (str): Title of the item to return. + + Raises: + :exc:`~plexapi.exceptions.NotFound`: Unable to find playlist. + """ + try: + return self.playlists(title=title, title__iexact=title)[0] + except IndexError: + raise NotFound(f'Unable to find playlist with title "{title}".') from None + + def playlists(self, sort=None, **kwargs): + """ Returns a list of playlists from this library section. """ + return self._server.playlists( + playlistType=self.CONTENT_TYPE, sectionId=self.key, sort=sort, **kwargs) + + @deprecated('use "listFields" instead') + def filterFields(self, mediaType=None): + return self.listFields(libtype=mediaType) + + @deprecated('use "listFilterChoices" instead') + def listChoices(self, category, libtype=None, **kwargs): + return self.listFilterChoices(field=category, libtype=libtype) + + def getWebURL(self, base=None, tab=None, key=None): + """ Returns the Plex Web URL for the library. + + Parameters: + base (str): The base URL before the fragment (``#!``). + Default is https://app.plex.tv/desktop. + tab (str): The library tab (recommended, library, collections, playlists, timeline). + key (str): A hub key. + """ + params = {'source': self.key} + if tab is not None: + params['pivot'] = tab + if key is not None: + params['key'] = key + params['pageType'] = 'list' + return self._server._buildWebURL(base=base, **params) + + def _validateItems(self, items): + """ Validates the specified items are from this library and of the same type. """ + if items is None or items == []: + raise BadRequest('No items specified.') + + if not isinstance(items, list): + items = [items] + + itemType = items[0].type + for item in items: + if item.librarySectionID != self.key: + raise BadRequest(f'{item.title} is not from this library.') + elif item.type != itemType: + raise BadRequest(f'Cannot mix items of different type: {itemType} and {item.type}') + + return items + + def common(self, items): + """ Returns a :class:`~plexapi.library.Common` object for the specified items. """ + params = { + 'id': ','.join(str(item.ratingKey) for item in self._validateItems(items)), + 'type': utils.searchType(items[0].type) + } + part = f'/library/sections/{self.key}/common{utils.joinArgs(params)}' + return self.fetchItem(part, cls=Common) + + def _edit(self, items=None, **kwargs): + """ Actually edit multiple objects. """ + if isinstance(self._edits, dict) and items is None: + self._edits.update(kwargs) + return self + + kwargs['id'] = ','.join(str(item.ratingKey) for item in self._validateItems(items)) + if 'type' not in kwargs: + kwargs['type'] = utils.searchType(items[0].type) + + part = f'/library/sections/{self.key}/all{utils.joinArgs(kwargs)}' + self._server.query(part, method=self._server._session.put) + return self + + def multiEdit(self, items, **kwargs): + """ Edit multiple objects at once. + Note: This is a low level method and you need to know all the field/tag keys. + See :class:`~plexapi.LibrarySection.batchMultiEdits` instead. + + Parameters: + items (List): List of :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`, + :class:`~plexapi.photo.Photo`, or :class:`~plexapi.collection.Collection` + objects to be edited. + kwargs (dict): Dict of settings to edit. + """ + return self._edit(items, **kwargs) + + def batchMultiEdits(self, items): + """ Enable batch multi-editing mode to save API calls. + Must call :func:`~plexapi.library.LibrarySection.saveMultiEdits` at the end to save all the edits. + See :class:`~plexapi.mixins.EditFieldMixin` and :class:`~plexapi.mixins.EditTagsMixin` + for individual field and tag editing methods. + + Parameters: + items (List): List of :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`, + :class:`~plexapi.photo.Photo`, or :class:`~plexapi.collection.Collection` + objects to be edited. + + Example: + + .. code-block:: python + + movies = MovieSection.all() + items = [movies[0], movies[3], movies[5]] + + # Batch multi-editing multiple fields and tags in a single API call + MovieSection.batchMultiEdits(items) + MovieSection.editTitle('A New Title').editSummary('A new summary').editTagline('A new tagline') \\ + .addCollection('New Collection').removeGenre('Action').addLabel('Favorite') + MovieSection.saveMultiEdits() + + """ + self._edits = {'items': self._validateItems(items)} + return self + + def saveMultiEdits(self): + """ Save all the batch multi-edits. + See :func:`~plexapi.library.LibrarySection.batchMultiEdits` for details. + """ + if not isinstance(self._edits, dict): + raise BadRequest('Batch multi-editing mode not enabled. Must call `batchMultiEdits()` first.') + + edits = self._edits + self._edits = None + self._edit(items=edits.pop('items'), **edits) + return self + + +class MovieSection(LibrarySection, MovieEditMixins): + """ Represents a :class:`~plexapi.library.LibrarySection` section containing movies. + + Attributes: + TAG (str): 'Directory' + TYPE (str): 'movie' + """ + TAG = 'Directory' + TYPE = 'movie' + METADATA_TYPE = 'movie' + CONTENT_TYPE = 'video' + + def searchMovies(self, **kwargs): + """ Search for a movie. See :func:`~plexapi.library.LibrarySection.search` for usage. """ + return self.search(libtype='movie', **kwargs) + + def recentlyAddedMovies(self, maxresults=50): + """ Returns a list of recently added movies from this library section. + + Parameters: + maxresults (int): Max number of items to return (default 50). + """ + return self.recentlyAdded(maxresults=maxresults, libtype='movie') + + def sync(self, videoQuality, limit=None, unwatched=False, **kwargs): + """ Add current Movie library section as sync item for specified device. + See description of :func:`~plexapi.library.LibrarySection.search` for details about filtering / sorting and + :func:`~plexapi.library.LibrarySection.sync` for details on syncing libraries and possible exceptions. + + Parameters: + videoQuality (int): idx of quality of the video, one of VIDEO_QUALITY_* values defined in + :mod:`~plexapi.sync` module. + limit (int): maximum count of movies to sync, unlimited if `None`. + unwatched (bool): if `True` watched videos wouldn't be synced. + + Returns: + :class:`~plexapi.sync.SyncItem`: an instance of created syncItem. + + Example: + + .. code-block:: python + + from plexapi import myplex + from plexapi.sync import VIDEO_QUALITY_3_MBPS_720p + + c = myplex.MyPlexAccount() + target = c.device('Plex Client') + sync_items_wd = c.syncItems(target.clientIdentifier) + srv = c.resource('Server Name').connect() + section = srv.library.section('Movies') + section.sync(VIDEO_QUALITY_3_MBPS_720p, client=target, limit=1, unwatched=True, + title='Next best movie', sort='rating:desc') + + """ + from plexapi.sync import Policy, MediaSettings + kwargs['mediaSettings'] = MediaSettings.createVideo(videoQuality) + kwargs['policy'] = Policy.create(limit, unwatched) + return super(MovieSection, self).sync(**kwargs) + + +class ShowSection(LibrarySection, ShowEditMixins, SeasonEditMixins, EpisodeEditMixins): + """ Represents a :class:`~plexapi.library.LibrarySection` section containing tv shows. + + Attributes: + TAG (str): 'Directory' + TYPE (str): 'show' + """ + TAG = 'Directory' + TYPE = 'show' + METADATA_TYPE = 'episode' + CONTENT_TYPE = 'video' + + def searchShows(self, **kwargs): + """ Search for a show. See :func:`~plexapi.library.LibrarySection.search` for usage. """ + return self.search(libtype='show', **kwargs) + + def searchSeasons(self, **kwargs): + """ Search for a season. See :func:`~plexapi.library.LibrarySection.search` for usage. """ + return self.search(libtype='season', **kwargs) + + def searchEpisodes(self, **kwargs): + """ Search for an episode. See :func:`~plexapi.library.LibrarySection.search` for usage. """ + return self.search(libtype='episode', **kwargs) + + def recentlyAddedShows(self, maxresults=50): + """ Returns a list of recently added shows from this library section. + + Parameters: + maxresults (int): Max number of items to return (default 50). + """ + return self.recentlyAdded(maxresults=maxresults, libtype='show') + + def recentlyAddedSeasons(self, maxresults=50): + """ Returns a list of recently added seasons from this library section. + + Parameters: + maxresults (int): Max number of items to return (default 50). + """ + return self.recentlyAdded(maxresults=maxresults, libtype='season') + + def recentlyAddedEpisodes(self, maxresults=50): + """ Returns a list of recently added episodes from this library section. + + Parameters: + maxresults (int): Max number of items to return (default 50). + """ + return self.recentlyAdded(maxresults=maxresults, libtype='episode') + + def sync(self, videoQuality, limit=None, unwatched=False, **kwargs): + """ Add current Show library section as sync item for specified device. + See description of :func:`~plexapi.library.LibrarySection.search` for details about filtering / sorting and + :func:`~plexapi.library.LibrarySection.sync` for details on syncing libraries and possible exceptions. + + Parameters: + videoQuality (int): idx of quality of the video, one of VIDEO_QUALITY_* values defined in + :mod:`~plexapi.sync` module. + limit (int): maximum count of episodes to sync, unlimited if `None`. + unwatched (bool): if `True` watched videos wouldn't be synced. + + Returns: + :class:`~plexapi.sync.SyncItem`: an instance of created syncItem. + + Example: + + .. code-block:: python + + from plexapi import myplex + from plexapi.sync import VIDEO_QUALITY_3_MBPS_720p + + c = myplex.MyPlexAccount() + target = c.device('Plex Client') + sync_items_wd = c.syncItems(target.clientIdentifier) + srv = c.resource('Server Name').connect() + section = srv.library.section('TV-Shows') + section.sync(VIDEO_QUALITY_3_MBPS_720p, client=target, limit=1, unwatched=True, + title='Next unwatched episode') + + """ + from plexapi.sync import Policy, MediaSettings + kwargs['mediaSettings'] = MediaSettings.createVideo(videoQuality) + kwargs['policy'] = Policy.create(limit, unwatched) + return super(ShowSection, self).sync(**kwargs) + + +class MusicSection(LibrarySection, ArtistEditMixins, AlbumEditMixins, TrackEditMixins): + """ Represents a :class:`~plexapi.library.LibrarySection` section containing music artists. + + Attributes: + TAG (str): 'Directory' + TYPE (str): 'artist' + """ + TAG = 'Directory' + TYPE = 'artist' + METADATA_TYPE = 'track' + CONTENT_TYPE = 'audio' + + def albums(self): + """ Returns a list of :class:`~plexapi.audio.Album` objects in this section. """ + key = f'/library/sections/{self.key}/albums' + return self.fetchItems(key) + + def stations(self): + """ Returns a list of :class:`~plexapi.playlist.Playlist` stations in this section. """ + return next((hub.items for hub in self.hubs() if hub.context == 'hub.music.stations'), None) + + def searchArtists(self, **kwargs): + """ Search for an artist. See :func:`~plexapi.library.LibrarySection.search` for usage. """ + return self.search(libtype='artist', **kwargs) + + def searchAlbums(self, **kwargs): + """ Search for an album. See :func:`~plexapi.library.LibrarySection.search` for usage. """ + return self.search(libtype='album', **kwargs) + + def searchTracks(self, **kwargs): + """ Search for a track. See :func:`~plexapi.library.LibrarySection.search` for usage. """ + return self.search(libtype='track', **kwargs) + + def recentlyAddedArtists(self, maxresults=50): + """ Returns a list of recently added artists from this library section. + + Parameters: + maxresults (int): Max number of items to return (default 50). + """ + return self.recentlyAdded(maxresults=maxresults, libtype='artist') + + def recentlyAddedAlbums(self, maxresults=50): + """ Returns a list of recently added albums from this library section. + + Parameters: + maxresults (int): Max number of items to return (default 50). + """ + return self.recentlyAdded(maxresults=maxresults, libtype='album') + + def recentlyAddedTracks(self, maxresults=50): + """ Returns a list of recently added tracks from this library section. + + Parameters: + maxresults (int): Max number of items to return (default 50). + """ + return self.recentlyAdded(maxresults=maxresults, libtype='track') + + def sync(self, bitrate, limit=None, **kwargs): + """ Add current Music library section as sync item for specified device. + See description of :func:`~plexapi.library.LibrarySection.search` for details about filtering / sorting and + :func:`~plexapi.library.LibrarySection.sync` for details on syncing libraries and possible exceptions. + + Parameters: + bitrate (int): maximum bitrate for synchronized music, better use one of MUSIC_BITRATE_* values from the + module :mod:`~plexapi.sync`. + limit (int): maximum count of tracks to sync, unlimited if `None`. + + Returns: + :class:`~plexapi.sync.SyncItem`: an instance of created syncItem. + + Example: + + .. code-block:: python + + from plexapi import myplex + from plexapi.sync import AUDIO_BITRATE_320_KBPS + + c = myplex.MyPlexAccount() + target = c.device('Plex Client') + sync_items_wd = c.syncItems(target.clientIdentifier) + srv = c.resource('Server Name').connect() + section = srv.library.section('Music') + section.sync(AUDIO_BITRATE_320_KBPS, client=target, limit=100, sort='addedAt:desc', + title='New music') + + """ + from plexapi.sync import Policy, MediaSettings + kwargs['mediaSettings'] = MediaSettings.createMusic(bitrate) + kwargs['policy'] = Policy.create(limit) + return super(MusicSection, self).sync(**kwargs) + + def sonicAdventure( + self, + start: Track | int, + end: Track | int, + **kwargs: Any, + ) -> list[Track]: + """ Returns a list of tracks from this library section that are part of a sonic adventure. + ID's should be of a track, other ID's will return an empty list or items itself or an error. + + Parameters: + start (Track | int): The :class:`~plexapi.audio.Track` or ID of the first track in the sonic adventure. + end (Track | int): The :class:`~plexapi.audio.Track` or ID of the last track in the sonic adventure. + kwargs: Additional parameters to pass to :func:`~plexapi.base.PlexObject.fetchItems`. + + Returns: + List[:class:`~plexapi.audio.Track`]: a list of tracks from this library section + that are part of a sonic adventure. + """ + # can not use Track due to circular import + startID = start if isinstance(start, int) else start.ratingKey + endID = end if isinstance(end, int) else end.ratingKey + + key = f"/library/sections/{self.key}/computePath?startID={startID}&endID={endID}" + return self.fetchItems(key, **kwargs) + + +class PhotoSection(LibrarySection, PhotoalbumEditMixins, PhotoEditMixins): + """ Represents a :class:`~plexapi.library.LibrarySection` section containing photos. + + Attributes: + TAG (str): 'Directory' + TYPE (str): 'photo' + """ + TAG = 'Directory' + TYPE = 'photo' + METADATA_TYPE = 'photo' + CONTENT_TYPE = 'photo' + + def all(self, libtype=None, **kwargs): + """ Returns a list of all items from this library section. + See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting. + """ + libtype = libtype or 'photoalbum' + return self.search(libtype=libtype, **kwargs) + + def collections(self, **kwargs): + raise NotImplementedError('Collections are not available for a Photo library.') + + def searchAlbums(self, **kwargs): + """ Search for a photo album. See :func:`~plexapi.library.LibrarySection.search` for usage. """ + return self.search(libtype='photoalbum', **kwargs) + + def searchPhotos(self, **kwargs): + """ Search for a photo. See :func:`~plexapi.library.LibrarySection.search` for usage. """ + return self.search(libtype='photo', **kwargs) + + def recentlyAddedAlbums(self, maxresults=50): + """ Returns a list of recently added photo albums from this library section. + + Parameters: + maxresults (int): Max number of items to return (default 50). + """ + # Use search() instead of recentlyAdded() because libtype=None + return self.search(sort='addedAt:desc', maxresults=maxresults) + + def sync(self, resolution, limit=None, **kwargs): + """ Add current Music library section as sync item for specified device. + See description of :func:`~plexapi.library.LibrarySection.search` for details about filtering / sorting and + :func:`~plexapi.library.LibrarySection.sync` for details on syncing libraries and possible exceptions. + + Parameters: + resolution (str): maximum allowed resolution for synchronized photos, see PHOTO_QUALITY_* values in the + module :mod:`~plexapi.sync`. + limit (int): maximum count of tracks to sync, unlimited if `None`. + + Returns: + :class:`~plexapi.sync.SyncItem`: an instance of created syncItem. + + Example: + + .. code-block:: python + + from plexapi import myplex + from plexapi.sync import PHOTO_QUALITY_HIGH + + c = myplex.MyPlexAccount() + target = c.device('Plex Client') + sync_items_wd = c.syncItems(target.clientIdentifier) + srv = c.resource('Server Name').connect() + section = srv.library.section('Photos') + section.sync(PHOTO_QUALITY_HIGH, client=target, limit=100, sort='addedAt:desc', + title='Fresh photos') + + """ + from plexapi.sync import Policy, MediaSettings + kwargs['mediaSettings'] = MediaSettings.createPhoto(resolution) + kwargs['policy'] = Policy.create(limit) + return super(PhotoSection, self).sync(**kwargs) + + +@utils.registerPlexObject +class LibraryTimeline(PlexObject): + """Represents a LibrarySection timeline. + + Attributes: + TAG (str): 'LibraryTimeline' + size (int): Unknown + allowSync (bool): Unknown + art (str): Relative path to art image. + content (str): "secondary" + identifier (str): "com.plexapp.plugins.library" + latestEntryTime (int): Epoch timestamp + mediaTagPrefix (str): "/system/bundle/media/flags/" + mediaTagVersion (int): Unknown + thumb (str): Relative path to library thumb image. + title1 (str): Name of library section. + updateQueueSize (int): Number of items queued to update. + viewGroup (str): "secondary" + viewMode (int): Unknown + """ + TAG = 'LibraryTimeline' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.size = utils.cast(int, data.attrib.get('size')) + self.allowSync = utils.cast(bool, data.attrib.get('allowSync')) + self.art = data.attrib.get('art') + self.content = data.attrib.get('content') + self.identifier = data.attrib.get('identifier') + self.latestEntryTime = utils.cast(int, data.attrib.get('latestEntryTime')) + self.mediaTagPrefix = data.attrib.get('mediaTagPrefix') + self.mediaTagVersion = utils.cast(int, data.attrib.get('mediaTagVersion')) + self.thumb = data.attrib.get('thumb') + self.title1 = data.attrib.get('title1') + self.updateQueueSize = utils.cast(int, data.attrib.get('updateQueueSize')) + self.viewGroup = data.attrib.get('viewGroup') + self.viewMode = utils.cast(int, data.attrib.get('viewMode')) + + +@utils.registerPlexObject +class Location(PlexObject): + """ Represents a single library Location. + + Attributes: + TAG (str): 'Location' + id (int): Location path ID. + path (str): Path used for library.. + """ + TAG = 'Location' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.id = utils.cast(int, data.attrib.get('id')) + self.path = data.attrib.get('path') + + +@utils.registerPlexObject +class Hub(PlexObject): + """ Represents a single Hub (or category) in the PlexServer search. + + Attributes: + TAG (str): 'Hub' + context (str): The context of the hub. + hubKey (str): API URL for these specific hub items. + hubIdentifier (str): The identifier of the hub. + items (list): List of items in the hub. + key (str): API URL for the hub. + more (bool): True if there are more items to load (call reload() to fetch all items). + size (int): The number of items in the hub. + style (str): The style of the hub. + title (str): The title of the hub. + type (str): The type of items in the hub. + """ + TAG = 'Hub' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.context = data.attrib.get('context') + self.hubKey = data.attrib.get('hubKey') + self.hubIdentifier = data.attrib.get('hubIdentifier') + self.items = self.findItems(data) + self.key = data.attrib.get('key') + self.more = utils.cast(bool, data.attrib.get('more')) + self.size = utils.cast(int, data.attrib.get('size')) + self.style = data.attrib.get('style') + self.title = data.attrib.get('title') + self.type = data.attrib.get('type') + self._section = None # cache for self.section + + def __len__(self): + return self.size + + def reload(self): + """ Reloads the hub to fetch all items in the hub. """ + if self.more and self.key: + self.items = self.fetchItems(self.key) + self.more = False + self.size = len(self.items) + + def section(self): + """ Returns the :class:`~plexapi.library.LibrarySection` this hub belongs to. + """ + if self._section is None: + self._section = self._server.library.sectionByID(self.librarySectionID) + return self._section + + +class LibraryMediaTag(PlexObject): + """ Base class of library media tags. + + Attributes: + TAG (str): 'Directory' + count (int): The number of items where this tag is found. + filter (str): The URL filter for the tag. + id (int): The id of the tag. + key (str): API URL (/library/section/<librarySectionID>/all?<filter>). + librarySectionID (int): The library section ID where the tag is found. + librarySectionKey (str): API URL for the library section (/library/section/<librarySectionID>) + librarySectionTitle (str): The library title where the tag is found. + librarySectionType (int): The library type where the tag is found. + reason (str): The reason for the search result. + reasonID (int): The reason ID for the search result. + reasonTitle (str): The reason title for the search result. + score (float): The score for the search result. + type (str): The type of search result (tag). + tag (str): The title of the tag. + tagKey (str): The Plex Discover ratingKey (guid) for people. + tagType (int): The type ID of the tag. + tagValue (int): The value of the tag. + thumb (str): The URL for the thumbnail of the tag (if available). + """ + TAG = 'Directory' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.count = utils.cast(int, data.attrib.get('count')) + self.filter = data.attrib.get('filter') + self.id = utils.cast(int, data.attrib.get('id')) + self.key = data.attrib.get('key') + self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID')) + self.librarySectionKey = data.attrib.get('librarySectionKey') + self.librarySectionTitle = data.attrib.get('librarySectionTitle') + self.librarySectionType = utils.cast(int, data.attrib.get('librarySectionType')) + self.reason = data.attrib.get('reason') + self.reasonID = utils.cast(int, data.attrib.get('reasonID')) + self.reasonTitle = data.attrib.get('reasonTitle') + self.score = utils.cast(float, data.attrib.get('score')) + self.type = data.attrib.get('type') + self.tag = data.attrib.get('tag') + self.tagKey = data.attrib.get('tagKey') + self.tagType = utils.cast(int, data.attrib.get('tagType')) + self.tagValue = utils.cast(int, data.attrib.get('tagValue')) + self.thumb = data.attrib.get('thumb') + + def items(self, *args, **kwargs): + """ Return the list of items within this tag. """ + if not self.key: + raise BadRequest(f'Key is not defined for this tag: {self.tag}') + return self.fetchItems(self.key) + + +@utils.registerPlexObject +class Aperture(LibraryMediaTag): + """ Represents a single Aperture library media tag. + + Attributes: + TAGTYPE (int): 202 + """ + TAGTYPE = 202 + + +@utils.registerPlexObject +class Art(LibraryMediaTag): + """ Represents a single Art library media tag. + + Attributes: + TAGTYPE (int): 313 + """ + TAGTYPE = 313 + + +@utils.registerPlexObject +class Autotag(LibraryMediaTag): + """ Represents a single Autotag library media tag. + + Attributes: + TAGTYPE (int): 207 + """ + TAGTYPE = 207 + + +@utils.registerPlexObject +class Chapter(LibraryMediaTag): + """ Represents a single Chapter library media tag. + + Attributes: + TAGTYPE (int): 9 + """ + TAGTYPE = 9 + + +@utils.registerPlexObject +class Collection(LibraryMediaTag): + """ Represents a single Collection library media tag. + + Attributes: + TAGTYPE (int): 2 + """ + TAGTYPE = 2 + + +@utils.registerPlexObject +class Concert(LibraryMediaTag): + """ Represents a single Concert library media tag. + + Attributes: + TAGTYPE (int): 306 + """ + TAGTYPE = 306 + + +@utils.registerPlexObject +class Country(LibraryMediaTag): + """ Represents a single Country library media tag. + + Attributes: + TAGTYPE (int): 8 + """ + TAGTYPE = 8 + + +@utils.registerPlexObject +class Device(LibraryMediaTag): + """ Represents a single Device library media tag. + + Attributes: + TAGTYPE (int): 206 + """ + TAGTYPE = 206 + + +@utils.registerPlexObject +class Director(LibraryMediaTag): + """ Represents a single Director library media tag. + + Attributes: + TAGTYPE (int): 4 + """ + TAGTYPE = 4 + + +@utils.registerPlexObject +class Exposure(LibraryMediaTag): + """ Represents a single Exposure library media tag. + + Attributes: + TAGTYPE (int): 203 + """ + TAGTYPE = 203 + + +@utils.registerPlexObject +class Format(LibraryMediaTag): + """ Represents a single Format library media tag. + + Attributes: + TAGTYPE (int): 302 + """ + TAGTYPE = 302 + + +@utils.registerPlexObject +class Genre(LibraryMediaTag): + """ Represents a single Genre library media tag. + + Attributes: + TAGTYPE (int): 1 + """ + TAGTYPE = 1 + + +@utils.registerPlexObject +class Guid(LibraryMediaTag): + """ Represents a single Guid library media tag. + + Attributes: + TAGTYPE (int): 314 + """ + TAGTYPE = 314 + + +@utils.registerPlexObject +class ISO(LibraryMediaTag): + """ Represents a single ISO library media tag. + + Attributes: + TAGTYPE (int): 204 + """ + TAGTYPE = 204 + + +@utils.registerPlexObject +class Label(LibraryMediaTag): + """ Represents a single Label library media tag. + + Attributes: + TAGTYPE (int): 11 + """ + TAGTYPE = 11 + + +@utils.registerPlexObject +class Lens(LibraryMediaTag): + """ Represents a single Lens library media tag. + + Attributes: + TAGTYPE (int): 205 + """ + TAGTYPE = 205 + + +@utils.registerPlexObject +class Make(LibraryMediaTag): + """ Represents a single Make library media tag. + + Attributes: + TAGTYPE (int): 200 + """ + TAGTYPE = 200 + + +@utils.registerPlexObject +class Marker(LibraryMediaTag): + """ Represents a single Marker library media tag. + + Attributes: + TAGTYPE (int): 12 + """ + TAGTYPE = 12 + + +@utils.registerPlexObject +class MediaProcessingTarget(LibraryMediaTag): + """ Represents a single MediaProcessingTarget library media tag. + + Attributes: + TAG (str): 'Tag' + TAGTYPE (int): 42 + """ + TAG = 'Tag' + TAGTYPE = 42 + + +@utils.registerPlexObject +class Model(LibraryMediaTag): + """ Represents a single Model library media tag. + + Attributes: + TAGTYPE (int): 201 + """ + TAGTYPE = 201 + + +@utils.registerPlexObject +class Mood(LibraryMediaTag): + """ Represents a single Mood library media tag. + + Attributes: + TAGTYPE (int): 300 + """ + TAGTYPE = 300 + + +@utils.registerPlexObject +class Network(LibraryMediaTag): + """ Represents a single Network library media tag. + + Attributes: + TAGTYPE (int): 319 + """ + TAGTYPE = 319 + + +@utils.registerPlexObject +class Place(LibraryMediaTag): + """ Represents a single Place library media tag. + + Attributes: + TAGTYPE (int): 400 + """ + TAGTYPE = 400 + + +@utils.registerPlexObject +class Poster(LibraryMediaTag): + """ Represents a single Poster library media tag. + + Attributes: + TAGTYPE (int): 312 + """ + TAGTYPE = 312 + + +@utils.registerPlexObject +class Producer(LibraryMediaTag): + """ Represents a single Producer library media tag. + + Attributes: + TAGTYPE (int): 7 + """ + TAGTYPE = 7 + + +@utils.registerPlexObject +class RatingImage(LibraryMediaTag): + """ Represents a single RatingImage library media tag. + + Attributes: + TAGTYPE (int): 316 + """ + TAGTYPE = 316 + + +@utils.registerPlexObject +class Review(LibraryMediaTag): + """ Represents a single Review library media tag. + + Attributes: + TAGTYPE (int): 10 + """ + TAGTYPE = 10 + + +@utils.registerPlexObject +class Role(LibraryMediaTag): + """ Represents a single Role library media tag. + + Attributes: + TAGTYPE (int): 6 + """ + TAGTYPE = 6 + + +@utils.registerPlexObject +class Similar(LibraryMediaTag): + """ Represents a single Similar library media tag. + + Attributes: + TAGTYPE (int): 305 + """ + TAGTYPE = 305 + + +@utils.registerPlexObject +class Studio(LibraryMediaTag): + """ Represents a single Studio library media tag. + + Attributes: + TAGTYPE (int): 318 + """ + TAGTYPE = 318 + + +@utils.registerPlexObject +class Style(LibraryMediaTag): + """ Represents a single Style library media tag. + + Attributes: + TAGTYPE (int): 301 + """ + TAGTYPE = 301 + + +@utils.registerPlexObject +class Tag(LibraryMediaTag): + """ Represents a single Tag library media tag. + + Attributes: + TAGTYPE (int): 0 + """ + TAGTYPE = 0 + + +@utils.registerPlexObject +class Theme(LibraryMediaTag): + """ Represents a single Theme library media tag. + + Attributes: + TAGTYPE (int): 317 + """ + TAGTYPE = 317 + + +@utils.registerPlexObject +class Writer(LibraryMediaTag): + """ Represents a single Writer library media tag. + + Attributes: + TAGTYPE (int): 5 + """ + TAGTYPE = 5 + + +class FilteringType(PlexObject): + """ Represents a single filtering Type object for a library. + + Attributes: + TAG (str): 'Type' + active (bool): True if this filter type is currently active. + fields (List<:class:`~plexapi.library.FilteringField`>): List of field objects. + filters (List<:class:`~plexapi.library.FilteringFilter`>): List of filter objects. + key (str): The API URL path for the libtype filter. + sorts (List<:class:`~plexapi.library.FilteringSort`>): List of sort objects. + title (str): The title for the libtype filter. + type (str): The libtype for the filter. + """ + TAG = 'Type' + + def __repr__(self): + _type = self._clean(self.firstAttr('type')) + return f"<{':'.join([p for p in [self.__class__.__name__, _type] if p])}>" + + def _loadData(self, data): + self._data = data + self.active = utils.cast(bool, data.attrib.get('active', '0')) + self.fields = self.findItems(data, FilteringField) + self.filters = self.findItems(data, FilteringFilter) + self.key = data.attrib.get('key') + self.sorts = self.findItems(data, FilteringSort) + self.title = data.attrib.get('title') + self.type = data.attrib.get('type') + + self._librarySectionID = self._parent().key + + # Add additional manual filters, sorts, and fields which are available + # but not exposed on the Plex server + self.filters += self._manualFilters() + self.sorts += self._manualSorts() + self.fields += self._manualFields() + + def _manualFilters(self): + """ Manually add additional filters which are available + but not exposed on the Plex server. + """ + # Filters: (filter, type, title) + additionalFilters = [ + ] + + if self.type == 'season': + additionalFilters.extend([ + ('label', 'string', 'Labels') + ]) + elif self.type == 'episode': + additionalFilters.extend([ + ('label', 'string', 'Labels') + ]) + elif self.type == 'artist': + additionalFilters.extend([ + ('label', 'string', 'Labels') + ]) + elif self.type == 'track': + additionalFilters.extend([ + ('label', 'string', 'Labels') + ]) + elif self.type == 'collection': + additionalFilters.extend([ + ('label', 'string', 'Labels') + ]) + + manualFilters = [] + for filterTag, filterType, filterTitle in additionalFilters: + filterKey = f'/library/sections/{self._librarySectionID}/{filterTag}?type={utils.searchType(self.type)}' + filterXML = ( + f'<Filter filter="{filterTag}" ' + f'filterType="{filterType}" ' + f'key="{filterKey}" ' + f'title="{filterTitle}" ' + f'type="filter" />' + ) + manualFilters.append(self._manuallyLoadXML(filterXML, FilteringFilter)) + + return manualFilters + + def _manualSorts(self): + """ Manually add additional sorts which are available + but not exposed on the Plex server. + """ + # Sorts: (key, dir, title) + additionalSorts = [ + ('guid', 'asc', 'Guid'), + ('id', 'asc', 'Rating Key'), + ('index', 'asc', f'{self.type.capitalize()} Number'), + ('summary', 'asc', 'Summary'), + ('tagline', 'asc', 'Tagline'), + ('updatedAt', 'asc', 'Date Updated') + ] + + if self.type == 'season': + additionalSorts.extend([ + ('titleSort', 'asc', 'Title') + ]) + elif self.type == 'track': + # Don't know what this is but it is valid + additionalSorts.extend([ + ('absoluteIndex', 'asc', 'Absolute Index') + ]) + elif self.type == 'photo': + additionalSorts.extend([ + ('viewUpdatedAt', 'desc', 'View Updated At') + ]) + elif self.type == 'collection': + additionalSorts.extend([ + ('addedAt', 'asc', 'Date Added') + ]) + + manualSorts = [] + for sortField, sortDir, sortTitle in additionalSorts: + sortXML = ( + f'<Sort defaultDirection="{sortDir}" ' + f'descKey="{sortField}:desc" ' + f'key="{sortField}" ' + f'title="{sortTitle}" />' + ) + manualSorts.append(self._manuallyLoadXML(sortXML, FilteringSort)) + + return manualSorts + + def _manualFields(self): + """ Manually add additional fields which are available + but not exposed on the Plex server. + """ + # Fields: (key, type, title) + additionalFields = [ + ('guid', 'guid', 'Guid'), + ('id', 'integer', 'Rating Key'), + ('index', 'integer', f'{self.type.capitalize()} Number'), + ('lastRatedAt', 'date', f'{self.type.capitalize()} Last Rated'), + ('updatedAt', 'date', 'Date Updated'), + ('group', 'string', 'SQL Group By Statement'), + ('having', 'string', 'SQL Having Clause') + ] + + if self.type == 'movie': + additionalFields.extend([ + ('audienceRating', 'integer', 'Audience Rating'), + ('rating', 'integer', 'Critic Rating'), + ('viewOffset', 'integer', 'View Offset') + ]) + elif self.type == 'show': + additionalFields.extend([ + ('audienceRating', 'integer', 'Audience Rating'), + ('originallyAvailableAt', 'date', 'Show Release Date'), + ('rating', 'integer', 'Critic Rating'), + ('unviewedLeafCount', 'integer', 'Episode Unplayed Count') + ]) + elif self.type == 'season': + additionalFields.extend([ + ('addedAt', 'date', 'Date Season Added'), + ('unviewedLeafCount', 'integer', 'Episode Unplayed Count'), + ('year', 'integer', 'Season Year'), + ('label', 'tag', 'Label') + ]) + elif self.type == 'episode': + additionalFields.extend([ + ('audienceRating', 'integer', 'Audience Rating'), + ('duration', 'integer', 'Duration'), + ('rating', 'integer', 'Critic Rating'), + ('viewOffset', 'integer', 'View Offset'), + ('label', 'tag', 'Label') + ]) + elif self.type == 'artist': + additionalFields.extend([ + ('label', 'tag', 'Label') + ]) + elif self.type == 'track': + additionalFields.extend([ + ('duration', 'integer', 'Duration'), + ('viewOffset', 'integer', 'View Offset'), + ('label', 'tag', 'Label'), + ('ratingCount', 'integer', 'Rating Count'), + ]) + elif self.type == 'collection': + additionalFields.extend([ + ('addedAt', 'date', 'Date Added'), + ('label', 'tag', 'Label') + ]) + + prefix = '' if self.type == 'movie' else self.type + '.' + + manualFields = [] + for field, fieldType, fieldTitle in additionalFields: + if field not in {'group', 'having'}: + field = f"{prefix}{field}" + fieldXML = ( + f'<Field key="{field}" ' + f'title="{fieldTitle}" ' + f'type="{fieldType}"/>' + ) + + manualFields.append(self._manuallyLoadXML(fieldXML, FilteringField)) + + return manualFields + + +class FilteringFilter(PlexObject): + """ Represents a single Filter object for a :class:`~plexapi.library.FilteringType`. + + Attributes: + TAG (str): 'Filter' + filter (str): The key for the filter. + filterType (str): The :class:`~plexapi.library.FilteringFieldType` type (string, boolean, integer, date, etc). + key (str): The API URL path for the filter. + title (str): The title of the filter. + type (str): 'filter' + """ + TAG = 'Filter' + + def _loadData(self, data): + self._data = data + self.filter = data.attrib.get('filter') + self.filterType = data.attrib.get('filterType') + self.key = data.attrib.get('key') + self.title = data.attrib.get('title') + self.type = data.attrib.get('type') + + +class FilteringSort(PlexObject): + """ Represents a single Sort object for a :class:`~plexapi.library.FilteringType`. + + Attributes: + TAG (str): 'Sort' + active (bool): True if the sort is currently active. + activeDirection (str): The currently active sorting direction. + default (str): The currently active default sorting direction. + defaultDirection (str): The default sorting direction. + descKey (str): The URL key for sorting with desc. + firstCharacterKey (str): API URL path for first character endpoint. + key (str): The URL key for the sorting. + title (str): The title of the sorting. + """ + TAG = 'Sort' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.active = utils.cast(bool, data.attrib.get('active', '0')) + self.activeDirection = data.attrib.get('activeDirection') + self.default = data.attrib.get('default') + self.defaultDirection = data.attrib.get('defaultDirection') + self.descKey = data.attrib.get('descKey') + self.firstCharacterKey = data.attrib.get('firstCharacterKey') + self.key = data.attrib.get('key') + self.title = data.attrib.get('title') + + +class FilteringField(PlexObject): + """ Represents a single Field object for a :class:`~plexapi.library.FilteringType`. + + Attributes: + TAG (str): 'Field' + key (str): The URL key for the filter field. + title (str): The title of the filter field. + type (str): The :class:`~plexapi.library.FilteringFieldType` type (string, boolean, integer, date, etc). + subType (str): The subtype of the filter (decade, rating, etc). + """ + TAG = 'Field' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.key = data.attrib.get('key') + self.title = data.attrib.get('title') + self.type = data.attrib.get('type') + self.subType = data.attrib.get('subType') + + +class FilteringFieldType(PlexObject): + """ Represents a single FieldType for library filtering. + + Attributes: + TAG (str): 'FieldType' + type (str): The filtering data type (string, boolean, integer, date, etc). + operators (List<:class:`~plexapi.library.FilteringOperator`>): List of operator objects. + """ + TAG = 'FieldType' + + def __repr__(self): + _type = self._clean(self.firstAttr('type')) + return f"<{':'.join([p for p in [self.__class__.__name__, _type] if p])}>" + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.type = data.attrib.get('type') + self.operators = self.findItems(data, FilteringOperator) + + +class FilteringOperator(PlexObject): + """ Represents an single Operator for a :class:`~plexapi.library.FilteringFieldType`. + + Attributes: + TAG (str): 'Operator' + key (str): The URL key for the operator. + title (str): The title of the operator. + """ + TAG = 'Operator' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self.key = data.attrib.get('key') + self.title = data.attrib.get('title') + + +class FilterChoice(PlexObject): + """ Represents a single FilterChoice object. + These objects are gathered when using filters while searching for library items and is the + object returned in the result set of :func:`~plexapi.library.LibrarySection.listFilterChoices`. + + Attributes: + TAG (str): 'Directory' + fastKey (str): API URL path to quickly list all items with this filter choice. + (/library/sections/<section>/all?genre=<key>) + key (str): The id value of this filter choice. + thumb (str): Thumbnail URL for the filter choice. + title (str): The title of the filter choice. + type (str): The filter type (genre, contentRating, etc). + """ + TAG = 'Directory' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.fastKey = data.attrib.get('fastKey') + self.key = data.attrib.get('key') + self.thumb = data.attrib.get('thumb') + self.title = data.attrib.get('title') + self.type = data.attrib.get('type') + + def items(self): + """ Returns a list of items for this filter choice. """ + return self.fetchItems(self.fastKey) + + +class ManagedHub(PlexObject): + """ Represents a Managed Hub (recommendation) inside a library. + + Attributes: + TAG (str): 'Hub' + deletable (bool): True if the Hub can be deleted (promoted collection). + homeVisibility (str): Promoted home visibility (none, all, admin, or shared). + identifier (str): Hub identifier for the managed hub. + promotedToOwnHome (bool): Promoted to own home. + promotedToRecommended (bool): Promoted to recommended. + promotedToSharedHome (bool): Promoted to shared home. + recommendationsVisibility (str): Promoted recommendation visibility (none or all). + title (str): Title of managed hub. + """ + TAG = 'Hub' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.deletable = utils.cast(bool, data.attrib.get('deletable', True)) + self.homeVisibility = data.attrib.get('homeVisibility', 'none') + self.identifier = data.attrib.get('identifier') + self.promotedToOwnHome = utils.cast(bool, data.attrib.get('promotedToOwnHome', False)) + self.promotedToRecommended = utils.cast(bool, data.attrib.get('promotedToRecommended', False)) + self.promotedToSharedHome = utils.cast(bool, data.attrib.get('promotedToSharedHome', False)) + self.recommendationsVisibility = data.attrib.get('recommendationsVisibility', 'none') + self.title = data.attrib.get('title') + self._promoted = True # flag to indicate if this hub has been promoted on the list of managed recommendations + + parent = self._parent() + self.librarySectionID = parent.key if isinstance(parent, LibrarySection) else parent.librarySectionID + + def reload(self): + """ Reload the data for this managed hub. """ + key = f'/hubs/sections/{self.librarySectionID}/manage' + hub = self.fetchItem(key, self.__class__, identifier=self.identifier) + self.__dict__.update(hub.__dict__) + return self + + def move(self, after=None): + """ Move a managed hub to a new position in the library's Managed Recommendations. + + Parameters: + after (obj): :class:`~plexapi.library.ManagedHub` object to move the item after in the collection. + + Raises: + :class:`plexapi.exceptions.BadRequest`: When trying to move a Hub that is not a Managed Recommendation. + """ + if not self._promoted: + raise BadRequest('Collection must be a Managed Recommendation to be moved') + key = f'/hubs/sections/{self.librarySectionID}/manage/{self.identifier}/move' + if after: + key = f'{key}?after={after.identifier}' + self._server.query(key, method=self._server._session.put) + + def remove(self): + """ Removes a managed hub from the library's Managed Recommendations. + + Raises: + :class:`plexapi.exceptions.BadRequest`: When trying to remove a Hub that is not a Managed Recommendation + or when the Hub cannot be removed. + """ + if not self._promoted: + raise BadRequest('Collection must be a Managed Recommendation to be removed') + if not self.deletable: + raise BadRequest(f'{self.title} managed hub cannot be removed' % self.title) + key = f'/hubs/sections/{self.librarySectionID}/manage/{self.identifier}' + self._server.query(key, method=self._server._session.delete) + + def updateVisibility(self, recommended=None, home=None, shared=None): + """ Update the managed hub's visibility settings. + + Parameters: + recommended (bool): True to make visible on your Library Recommended page. False to hide. Default None. + home (bool): True to make visible on your Home page. False to hide. Default None. + shared (bool): True to make visible on your Friends' Home page. False to hide. Default None. + + Example: + + .. code-block:: python + + managedHub.updateVisibility(recommended=True, home=True, shared=False).reload() + # or using chained methods + managedHub.promoteRecommended().promoteHome().demoteShared().reload() + + """ + params = { + 'promotedToRecommended': int(self.promotedToRecommended), + 'promotedToOwnHome': int(self.promotedToOwnHome), + 'promotedToSharedHome': int(self.promotedToSharedHome), + } + if recommended is not None: + params['promotedToRecommended'] = int(recommended) + if home is not None: + params['promotedToOwnHome'] = int(home) + if shared is not None: + params['promotedToSharedHome'] = int(shared) + + if not self._promoted: + params['metadataItemId'] = self.identifier.rsplit('.')[-1] + key = f'/hubs/sections/{self.librarySectionID}/manage' + self._server.query(key, method=self._server._session.post, params=params) + else: + key = f'/hubs/sections/{self.librarySectionID}/manage/{self.identifier}' + self._server.query(key, method=self._server._session.put, params=params) + return self.reload() + + def promoteRecommended(self): + """ Show the managed hub on your Library Recommended Page. """ + return self.updateVisibility(recommended=True) + + def demoteRecommended(self): + """ Hide the managed hub on your Library Recommended Page. """ + return self.updateVisibility(recommended=False) + + def promoteHome(self): + """ Show the managed hub on your Home Page. """ + return self.updateVisibility(home=True) + + def demoteHome(self): + """ Hide the manged hub on your Home Page. """ + return self.updateVisibility(home=False) + + def promoteShared(self): + """ Show the managed hub on your Friends' Home Page. """ + return self.updateVisibility(shared=True) + + def demoteShared(self): + """ Hide the managed hub on your Friends' Home Page. """ + return self.updateVisibility(shared=False) + + +class Folder(PlexObject): + """ Represents a Folder inside a library. + + Attributes: + key (str): Url key for folder. + title (str): Title of folder. + """ + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self.key = data.attrib.get('key') + self.title = data.attrib.get('title') + + def subfolders(self): + """ Returns a list of available :class:`~plexapi.library.Folder` for this folder. + Continue down subfolders until a mediaType is found. + """ + if self.key.startswith('/library/metadata'): + return self.fetchItems(self.key) + else: + return self.fetchItems(self.key, Folder) + + def allSubfolders(self): + """ Returns a list of all available :class:`~plexapi.library.Folder` for this folder. + Only returns :class:`~plexapi.library.Folder`. + """ + folders = [] + for folder in self.subfolders(): + if not folder.key.startswith('/library/metadata'): + folders.append(folder) + while True: + for subfolder in folder.subfolders(): + if not subfolder.key.startswith('/library/metadata'): + folders.append(subfolder) + continue + break + return folders + + +class FirstCharacter(PlexObject): + """ Represents a First Character element from a library. + + Attributes: + key (str): Url key for character. + size (str): Total amount of library items starting with this character. + title (str): Character (#, !, A, B, C, ...). + """ + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.key = data.attrib.get('key') + self.size = data.attrib.get('size') + self.title = data.attrib.get('title') + + +@utils.registerPlexObject +class Path(PlexObject): + """ Represents a single directory Path. + + Attributes: + TAG (str): 'Path' + home (bool): True if the path is the home directory + key (str): API URL (/services/browse/<base64path>) + network (bool): True if path is a network location + path (str): Full path to folder + title (str): Folder name + """ + TAG = 'Path' + + def _loadData(self, data): + self.home = utils.cast(bool, data.attrib.get('home')) + self.key = data.attrib.get('key') + self.network = utils.cast(bool, data.attrib.get('network')) + self.path = data.attrib.get('path') + self.title = data.attrib.get('title') + + def browse(self, includeFiles=True): + """ Alias for :func:`~plexapi.server.PlexServer.browse`. """ + return self._server.browse(self, includeFiles) + + def walk(self): + """ Alias for :func:`~plexapi.server.PlexServer.walk`. """ + for path, paths, files in self._server.walk(self): + yield path, paths, files + + +@utils.registerPlexObject +class File(PlexObject): + """ Represents a single File. + + Attributes: + TAG (str): 'File' + key (str): API URL (/services/browse/<base64path>) + path (str): Full path to file + title (str): File name + """ + TAG = 'File' + + def _loadData(self, data): + self.key = data.attrib.get('key') + self.path = data.attrib.get('path') + self.title = data.attrib.get('title') + + +@utils.registerPlexObject +class Common(PlexObject): + """ Represents a Common element from a library. This object lists common fields between multiple objects. + + Attributes: + TAG (str): 'Common' + collections (List<:class:`~plexapi.media.Collection`>): List of collection objects. + contentRating (str): Content rating of the items. + countries (List<:class:`~plexapi.media.Country`>): List of countries objects. + directors (List<:class:`~plexapi.media.Director`>): List of director objects. + editionTitle (str): Edition title of the items. + fields (List<:class:`~plexapi.media.Field`>): List of field objects. + genres (List<:class:`~plexapi.media.Genre`>): List of genre objects. + grandparentRatingKey (int): Grandparent rating key of the items. + grandparentTitle (str): Grandparent title of the items. + guid (str): Plex GUID of the items. + guids (List<:class:`~plexapi.media.Guid`>): List of guid objects. + index (int): Index of the items. + key (str): API URL (/library/metadata/<ratingkey>). + labels (List<:class:`~plexapi.media.Label`>): List of label objects. + mixedFields (List<str>): List of mixed fields. + moods (List<:class:`~plexapi.media.Mood`>): List of mood objects. + originallyAvailableAt (datetime): Datetime of the release date of the items. + parentRatingKey (int): Parent rating key of the items. + parentTitle (str): Parent title of the items. + producers (List<:class:`~plexapi.media.Producer`>): List of producer objects. + ratingKey (int): Rating key of the items. + ratings (List<:class:`~plexapi.media.Rating`>): List of rating objects. + roles (List<:class:`~plexapi.media.Role`>): List of role objects. + studio (str): Studio name of the items. + styles (List<:class:`~plexapi.media.Style`>): List of style objects. + summary (str): Summary of the items. + tagline (str): Tagline of the items. + tags (List<:class:`~plexapi.media.Tag`>): List of tag objects. + title (str): Title of the items. + titleSort (str): Title to use when sorting of the items. + type (str): Type of the media (common). + writers (List<:class:`~plexapi.media.Writer`>): List of writer objects. + year (int): Year of the items. + """ + TAG = 'Common' + + def _loadData(self, data): + self._data = data + self.collections = self.findItems(data, media.Collection) + self.contentRating = data.attrib.get('contentRating') + self.countries = self.findItems(data, media.Country) + self.directors = self.findItems(data, media.Director) + self.editionTitle = data.attrib.get('editionTitle') + self.fields = self.findItems(data, media.Field) + self.genres = self.findItems(data, media.Genre) + self.grandparentRatingKey = utils.cast(int, data.attrib.get('grandparentRatingKey')) + self.grandparentTitle = data.attrib.get('grandparentTitle') + self.guid = data.attrib.get('guid') + self.guids = self.findItems(data, media.Guid) + self.index = utils.cast(int, data.attrib.get('index')) + self.key = data.attrib.get('key') + self.labels = self.findItems(data, media.Label) + self.mixedFields = data.attrib.get('mixedFields').split(',') + self.moods = self.findItems(data, media.Mood) + self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt')) + self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey')) + self.parentTitle = data.attrib.get('parentTitle') + self.producers = self.findItems(data, media.Producer) + self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) + self.ratings = self.findItems(data, media.Rating) + self.roles = self.findItems(data, media.Role) + self.studio = data.attrib.get('studio') + self.styles = self.findItems(data, media.Style) + self.summary = data.attrib.get('summary') + self.tagline = data.attrib.get('tagline') + self.tags = self.findItems(data, media.Tag) + self.title = data.attrib.get('title') + self.titleSort = data.attrib.get('titleSort') + self.type = data.attrib.get('type') + self.writers = self.findItems(data, media.Writer) + self.year = utils.cast(int, data.attrib.get('year')) + + def __repr__(self): + return '<%s:%s:%s>' % ( + self.__class__.__name__, + self.commonType, + ','.join(str(key) for key in self.ratingKeys) + ) + + @property + def commonType(self): + """ Returns the media type of the common items. """ + parsed_query = parse_qs(urlparse(self._initpath).query) + return utils.reverseSearchType(parsed_query['type'][0]) + + @property + def ratingKeys(self): + """ Returns a list of rating keys for the common items. """ + parsed_query = parse_qs(urlparse(self._initpath).query) + return [int(value.strip()) for value in parsed_query['id'][0].split(',')] + + def items(self): + """ Returns a list of the common items. """ + return self._server.fetchItems(self.ratingKeys) diff --git a/libs/plexapi/media.py b/libs/plexapi/media.py new file mode 100644 index 000000000..9c6e3115b --- /dev/null +++ b/libs/plexapi/media.py @@ -0,0 +1,1339 @@ +# -*- coding: utf-8 -*- +import xml +from pathlib import Path +from urllib.parse import quote_plus + +from plexapi import log, settings, utils +from plexapi.base import PlexObject +from plexapi.exceptions import BadRequest +from plexapi.utils import deprecated + + +@utils.registerPlexObject +class Media(PlexObject): + """ Container object for all MediaPart objects. Provides useful data about the + video or audio this media belong to such as video framerate, resolution, etc. + + Attributes: + TAG (str): 'Media' + aspectRatio (float): The aspect ratio of the media (ex: 2.35). + audioChannels (int): The number of audio channels of the media (ex: 6). + audioCodec (str): The audio codec of the media (ex: ac3). + audioProfile (str): The audio profile of the media (ex: dts). + bitrate (int): The bitrate of the media (ex: 1624). + container (str): The container of the media (ex: avi). + duration (int): The duration of the media in milliseconds (ex: 6990483). + height (int): The height of the media in pixels (ex: 256). + id (int): The unique ID for this media on the server. + has64bitOffsets (bool): True if video has 64 bit offsets. + hasVoiceActivity (bool): True if video has voice activity analyzed. + optimizedForStreaming (bool): True if video is optimized for streaming. + parts (List<:class:`~plexapi.media.MediaPart`>): List of media part objects. + proxyType (int): Equals 42 for optimized versions. + target (str): The media version target name. + title (str): The title of the media. + videoCodec (str): The video codec of the media (ex: ac3). + videoFrameRate (str): The video frame rate of the media (ex: 24p). + videoProfile (str): The video profile of the media (ex: high). + videoResolution (str): The video resolution of the media (ex: sd). + width (int): The width of the video in pixels (ex: 608). + + Photo_only_attributes: The following attributes are only available for photos. + + * aperture (str): The aperture used to take the photo. + * exposure (str): The exposure used to take the photo. + * iso (int): The iso used to take the photo. + * lens (str): The lens used to take the photo. + * make (str): The make of the camera used to take the photo. + * model (str): The model of the camera used to take the photo. + """ + TAG = 'Media' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.aspectRatio = utils.cast(float, data.attrib.get('aspectRatio')) + self.audioChannels = utils.cast(int, data.attrib.get('audioChannels')) + self.audioCodec = data.attrib.get('audioCodec') + self.audioProfile = data.attrib.get('audioProfile') + self.bitrate = utils.cast(int, data.attrib.get('bitrate')) + self.container = data.attrib.get('container') + self.duration = utils.cast(int, data.attrib.get('duration')) + self.height = utils.cast(int, data.attrib.get('height')) + self.id = utils.cast(int, data.attrib.get('id')) + self.has64bitOffsets = utils.cast(bool, data.attrib.get('has64bitOffsets')) + self.hasVoiceActivity = utils.cast(bool, data.attrib.get('hasVoiceActivity', '0')) + self.optimizedForStreaming = utils.cast(bool, data.attrib.get('optimizedForStreaming')) + self.parts = self.findItems(data, MediaPart) + self.proxyType = utils.cast(int, data.attrib.get('proxyType')) + self.selected = utils.cast(bool, data.attrib.get('selected')) + self.target = data.attrib.get('target') + self.title = data.attrib.get('title') + self.videoCodec = data.attrib.get('videoCodec') + self.videoFrameRate = data.attrib.get('videoFrameRate') + self.videoProfile = data.attrib.get('videoProfile') + self.videoResolution = data.attrib.get('videoResolution') + self.width = utils.cast(int, data.attrib.get('width')) + self.uuid = data.attrib.get('uuid') + + # Photo only attributes + self.aperture = data.attrib.get('aperture') + self.exposure = data.attrib.get('exposure') + self.iso = utils.cast(int, data.attrib.get('iso')) + self.lens = data.attrib.get('lens') + self.make = data.attrib.get('make') + self.model = data.attrib.get('model') + + parent = self._parent() + self._parentKey = parent.key + + @property + def isOptimizedVersion(self): + """ Returns True if the media is a Plex optimized version. """ + return self.proxyType == utils.SEARCHTYPES['optimizedVersion'] + + def delete(self): + part = f'{self._parentKey}/media/{self.id}' + try: + return self._server.query(part, method=self._server._session.delete) + except BadRequest: + log.error("Failed to delete %s. This could be because you haven't allowed items to be deleted", part) + raise + + +@utils.registerPlexObject +class MediaPart(PlexObject): + """ Represents a single media part (often a single file) for the media this belongs to. + + Attributes: + TAG (str): 'Part' + accessible (bool): True if the file is accessible. + Requires reloading the media with ``checkFiles=True``. + Refer to :func:`~plexapi.base.PlexObject.reload`. + audioProfile (str): The audio profile of the file. + container (str): The container type of the file (ex: avi). + decision (str): Unknown. + deepAnalysisVersion (int): The Plex deep analysis version for the file. + duration (int): The duration of the file in milliseconds. + exists (bool): True if the file exists. + Requires reloading the media with ``checkFiles=True``. + Refer to :func:`~plexapi.base.PlexObject.reload`. + file (str): The path to this file on disk (ex: /media/Movies/Cars (2006)/Cars (2006).mkv) + has64bitOffsets (bool): True if the file has 64 bit offsets. + hasThumbnail (bool): True if the file (track) has an embedded thumbnail. + id (int): The unique ID for this media part on the server. + indexes (str, None): sd if the file has generated preview (BIF) thumbnails. + key (str): API URL (ex: /library/parts/46618/1389985872/file.mkv). + optimizedForStreaming (bool): True if the file is optimized for streaming. + packetLength (int): The packet length of the file. + requiredBandwidths (str): The required bandwidths to stream the file. + selected (bool): True if this media part is selected. + size (int): The size of the file in bytes (ex: 733884416). + streams (List<:class:`~plexapi.media.MediaPartStream`>): List of stream objects. + syncItemId (int): The unique ID for this media part if it is synced. + syncState (str): The sync state for this media part. + videoProfile (str): The video profile of the file. + """ + TAG = 'Part' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.accessible = utils.cast(bool, data.attrib.get('accessible')) + self.audioProfile = data.attrib.get('audioProfile') + self.container = data.attrib.get('container') + self.decision = data.attrib.get('decision') + self.deepAnalysisVersion = utils.cast(int, data.attrib.get('deepAnalysisVersion')) + self.duration = utils.cast(int, data.attrib.get('duration')) + self.exists = utils.cast(bool, data.attrib.get('exists')) + self.file = data.attrib.get('file') + self.has64bitOffsets = utils.cast(bool, data.attrib.get('has64bitOffsets')) + self.hasThumbnail = utils.cast(bool, data.attrib.get('hasThumbnail')) + self.id = utils.cast(int, data.attrib.get('id')) + self.indexes = data.attrib.get('indexes') + self.key = data.attrib.get('key') + self.optimizedForStreaming = utils.cast(bool, data.attrib.get('optimizedForStreaming')) + self.packetLength = utils.cast(int, data.attrib.get('packetLength')) + self.protocol = data.attrib.get('protocol') + self.requiredBandwidths = data.attrib.get('requiredBandwidths') + self.selected = utils.cast(bool, data.attrib.get('selected')) + self.size = utils.cast(int, data.attrib.get('size')) + self.streams = self._buildStreams(data) + self.syncItemId = utils.cast(int, data.attrib.get('syncItemId')) + self.syncState = data.attrib.get('syncState') + self.videoProfile = data.attrib.get('videoProfile') + + def _buildStreams(self, data): + """ Returns a list of :class:`~plexapi.media.MediaPartStream` objects in this MediaPart. """ + return self.findItems(data) + + @property + def hasPreviewThumbnails(self): + """ Returns True if the media part has generated preview (BIF) thumbnails. """ + return self.indexes == 'sd' + + def videoStreams(self): + """ Returns a list of :class:`~plexapi.media.VideoStream` objects in this MediaPart. """ + return [stream for stream in self.streams if isinstance(stream, VideoStream)] + + def audioStreams(self): + """ Returns a list of :class:`~plexapi.media.AudioStream` objects in this MediaPart. """ + return [stream for stream in self.streams if isinstance(stream, AudioStream)] + + def subtitleStreams(self): + """ Returns a list of :class:`~plexapi.media.SubtitleStream` objects in this MediaPart. """ + return [stream for stream in self.streams if isinstance(stream, SubtitleStream)] + + def lyricStreams(self): + """ Returns a list of :class:`~plexapi.media.LyricStream` objects in this MediaPart. """ + return [stream for stream in self.streams if isinstance(stream, LyricStream)] + + def setSelectedAudioStream(self, stream): + """ Set the selected :class:`~plexapi.media.AudioStream` for this MediaPart. + + Parameters: + stream (:class:`~plexapi.media.AudioStream`): Audio stream to set as selected + """ + key = f'/library/parts/{self.id}' + params = {'allParts': 1} + + if isinstance(stream, AudioStream): + params['audioStreamID'] = stream.id + else: + params['audioStreamID'] = stream + + self._server.query(key, method=self._server._session.put, params=params) + return self + + def setSelectedSubtitleStream(self, stream): + """ Set the selected :class:`~plexapi.media.SubtitleStream` for this MediaPart. + + Parameters: + stream (:class:`~plexapi.media.SubtitleStream`): Subtitle stream to set as selected. + """ + key = f'/library/parts/{self.id}' + params = {'allParts': 1} + + if isinstance(stream, SubtitleStream): + params['subtitleStreamID'] = stream.id + else: + params['subtitleStreamID'] = stream + + self._server.query(key, method=self._server._session.put, params=params) + return self + + def resetSelectedSubtitleStream(self): + """ Set the selected subtitle of this MediaPart to 'None'. """ + key = f'/library/parts/{self.id}' + params = {'subtitleStreamID': 0, 'allParts': 1} + + self._server.query(key, method=self._server._session.put, params=params) + return self + + @deprecated('Use "setSelectedAudioStream" instead.') + def setDefaultAudioStream(self, stream): + return self.setSelectedAudioStream(stream) + + @deprecated('Use "setSelectedSubtitleStream" instead.') + def setDefaultSubtitleStream(self, stream): + return self.setSelectedSubtitleStream(stream) + + @deprecated('Use "resetSelectedSubtitleStream" instead.') + def resetDefaultSubtitleStream(self): + return self.resetSelectedSubtitleStream() + + +class MediaPartStream(PlexObject): + """ Base class for media streams. These consist of video, audio, subtitles, and lyrics. + + Attributes: + bitrate (int): The bitrate of the stream. + codec (str): The codec of the stream (ex: srt, ac3, mpeg4). + default (bool): True if this is the default stream. + displayTitle (str): The display title of the stream. + extendedDisplayTitle (str): The extended display title of the stream. + key (str): API URL (/library/streams/<id>) + id (int): The unique ID for this stream on the server. + index (int): The index of the stream. + language (str): The language of the stream (ex: English, ไทย). + languageCode (str): The ASCII language code of the stream (ex: eng, tha). + languageTag (str): The two letter language tag of the stream (ex: en, fr). + requiredBandwidths (str): The required bandwidths to stream the file. + selected (bool): True if this stream is selected. + streamType (int): The stream type (1= :class:`~plexapi.media.VideoStream`, + 2= :class:`~plexapi.media.AudioStream`, 3= :class:`~plexapi.media.SubtitleStream`). + title (str): The title of the stream. + type (int): Alias for streamType. + """ + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.bitrate = utils.cast(int, data.attrib.get('bitrate')) + self.codec = data.attrib.get('codec') + self.decision = data.attrib.get('decision') + self.default = utils.cast(bool, data.attrib.get('default')) + self.displayTitle = data.attrib.get('displayTitle') + self.extendedDisplayTitle = data.attrib.get('extendedDisplayTitle') + self.id = utils.cast(int, data.attrib.get('id')) + self.index = utils.cast(int, data.attrib.get('index', '-1')) + self.key = data.attrib.get('key') + self.language = data.attrib.get('language') + self.languageCode = data.attrib.get('languageCode') + self.languageTag = data.attrib.get('languageTag') + self.location = data.attrib.get('location') + self.requiredBandwidths = data.attrib.get('requiredBandwidths') + self.selected = utils.cast(bool, data.attrib.get('selected', '0')) + self.streamType = utils.cast(int, data.attrib.get('streamType')) + self.title = data.attrib.get('title') + self.type = utils.cast(int, data.attrib.get('streamType')) + + +@utils.registerPlexObject +class VideoStream(MediaPartStream): + """ Represents a video stream within a :class:`~plexapi.media.MediaPart`. + + Attributes: + TAG (str): 'Stream' + STREAMTYPE (int): 1 + anamorphic (str): If the video is anamorphic. + bitDepth (int): The bit depth of the video stream (ex: 8). + cabac (int): The context-adaptive binary arithmetic coding. + chromaLocation (str): The chroma location of the video stream. + chromaSubsampling (str): The chroma subsampling of the video stream (ex: 4:2:0). + codecID (str): The codec ID (ex: XVID). + codedHeight (int): The coded height of the video stream in pixels. + codedWidth (int): The coded width of the video stream in pixels. + colorPrimaries (str): The color primaries of the video stream. + colorRange (str): The color range of the video stream. + colorSpace (str): The color space of the video stream (ex: bt2020). + colorTrc (str): The color trc of the video stream. + DOVIBLCompatID (int): Dolby Vision base layer compatibility ID. + DOVIBLPresent (bool): True if Dolby Vision base layer is present. + DOVIELPresent (bool): True if Dolby Vision enhancement layer is present. + DOVILevel (int): Dolby Vision level. + DOVIPresent (bool): True if Dolby Vision is present. + DOVIProfile (int): Dolby Vision profile. + DOVIRPUPresent (bool): True if Dolby Vision reference processing unit is present. + DOVIVersion (float): The Dolby Vision version. + duration (int): The duration of video stream in milliseconds. + frameRate (float): The frame rate of the video stream (ex: 23.976). + frameRateMode (str): The frame rate mode of the video stream. + hasScalingMatrix (bool): True if video stream has a scaling matrix. + height (int): The height of the video stream in pixels (ex: 1080). + level (int): The codec encoding level of the video stream (ex: 41). + profile (str): The profile of the video stream (ex: asp). + pixelAspectRatio (str): The pixel aspect ratio of the video stream. + pixelFormat (str): The pixel format of the video stream. + refFrames (int): The number of reference frames of the video stream. + scanType (str): The scan type of the video stream (ex: progressive). + streamIdentifier(int): The stream identifier of the video stream. + width (int): The width of the video stream in pixels (ex: 1920). + """ + TAG = 'Stream' + STREAMTYPE = 1 + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + super(VideoStream, self)._loadData(data) + self.anamorphic = data.attrib.get('anamorphic') + self.bitDepth = utils.cast(int, data.attrib.get('bitDepth')) + self.cabac = utils.cast(int, data.attrib.get('cabac')) + self.chromaLocation = data.attrib.get('chromaLocation') + self.chromaSubsampling = data.attrib.get('chromaSubsampling') + self.codecID = data.attrib.get('codecID') + self.codedHeight = utils.cast(int, data.attrib.get('codedHeight')) + self.codedWidth = utils.cast(int, data.attrib.get('codedWidth')) + self.colorPrimaries = data.attrib.get('colorPrimaries') + self.colorRange = data.attrib.get('colorRange') + self.colorSpace = data.attrib.get('colorSpace') + self.colorTrc = data.attrib.get('colorTrc') + self.DOVIBLCompatID = utils.cast(int, data.attrib.get('DOVIBLCompatID')) + self.DOVIBLPresent = utils.cast(bool, data.attrib.get('DOVIBLPresent')) + self.DOVIELPresent = utils.cast(bool, data.attrib.get('DOVIELPresent')) + self.DOVILevel = utils.cast(int, data.attrib.get('DOVILevel')) + self.DOVIPresent = utils.cast(bool, data.attrib.get('DOVIPresent')) + self.DOVIProfile = utils.cast(int, data.attrib.get('DOVIProfile')) + self.DOVIRPUPresent = utils.cast(bool, data.attrib.get('DOVIRPUPresent')) + self.DOVIVersion = utils.cast(float, data.attrib.get('DOVIVersion')) + self.duration = utils.cast(int, data.attrib.get('duration')) + self.frameRate = utils.cast(float, data.attrib.get('frameRate')) + self.frameRateMode = data.attrib.get('frameRateMode') + self.hasScalingMatrix = utils.cast(bool, data.attrib.get('hasScalingMatrix')) + self.height = utils.cast(int, data.attrib.get('height')) + self.level = utils.cast(int, data.attrib.get('level')) + self.profile = data.attrib.get('profile') + self.pixelAspectRatio = data.attrib.get('pixelAspectRatio') + self.pixelFormat = data.attrib.get('pixelFormat') + self.refFrames = utils.cast(int, data.attrib.get('refFrames')) + self.scanType = data.attrib.get('scanType') + self.streamIdentifier = utils.cast(int, data.attrib.get('streamIdentifier')) + self.width = utils.cast(int, data.attrib.get('width')) + + +@utils.registerPlexObject +class AudioStream(MediaPartStream): + """ Represents a audio stream within a :class:`~plexapi.media.MediaPart`. + + Attributes: + TAG (str): 'Stream' + STREAMTYPE (int): 2 + audioChannelLayout (str): The audio channel layout of the audio stream (ex: 5.1(side)). + bitDepth (int): The bit depth of the audio stream (ex: 16). + bitrateMode (str): The bitrate mode of the audio stream (ex: cbr). + channels (int): The number of audio channels of the audio stream (ex: 6). + duration (int): The duration of audio stream in milliseconds. + profile (str): The profile of the audio stream. + samplingRate (int): The sampling rate of the audio stream (ex: xxx) + streamIdentifier (int): The stream identifier of the audio stream. + + Track_only_attributes: The following attributes are only available for tracks. + + * albumGain (float): The gain for the album. + * albumPeak (float): The peak for the album. + * albumRange (float): The range for the album. + * endRamp (str): The end ramp for the track. + * gain (float): The gain for the track. + * loudness (float): The loudness for the track. + * lra (float): The lra for the track. + * peak (float): The peak for the track. + * startRamp (str): The start ramp for the track. + """ + TAG = 'Stream' + STREAMTYPE = 2 + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + super(AudioStream, self)._loadData(data) + self.audioChannelLayout = data.attrib.get('audioChannelLayout') + self.bitDepth = utils.cast(int, data.attrib.get('bitDepth')) + self.bitrateMode = data.attrib.get('bitrateMode') + self.channels = utils.cast(int, data.attrib.get('channels')) + self.duration = utils.cast(int, data.attrib.get('duration')) + self.profile = data.attrib.get('profile') + self.samplingRate = utils.cast(int, data.attrib.get('samplingRate')) + self.streamIdentifier = utils.cast(int, data.attrib.get('streamIdentifier')) + + # Track only attributes + self.albumGain = utils.cast(float, data.attrib.get('albumGain')) + self.albumPeak = utils.cast(float, data.attrib.get('albumPeak')) + self.albumRange = utils.cast(float, data.attrib.get('albumRange')) + self.endRamp = data.attrib.get('endRamp') + self.gain = utils.cast(float, data.attrib.get('gain')) + self.loudness = utils.cast(float, data.attrib.get('loudness')) + self.lra = utils.cast(float, data.attrib.get('lra')) + self.peak = utils.cast(float, data.attrib.get('peak')) + self.startRamp = data.attrib.get('startRamp') + + def setSelected(self): + """ Sets this audio stream as the selected audio stream. + Alias for :func:`~plexapi.media.MediaPart.setSelectedAudioStream`. + """ + return self._parent().setSelectedAudioStream(self) + + @deprecated('Use "setSelected" instead.') + def setDefault(self): + return self.setSelected() + + +@utils.registerPlexObject +class SubtitleStream(MediaPartStream): + """ Represents a audio stream within a :class:`~plexapi.media.MediaPart`. + + Attributes: + TAG (str): 'Stream' + STREAMTYPE (int): 3 + canAutoSync (bool): True if the subtitle stream can be auto synced. + container (str): The container of the subtitle stream. + forced (bool): True if this is a forced subtitle. + format (str): The format of the subtitle stream (ex: srt). + headerCompression (str): The header compression of the subtitle stream. + hearingImpaired (bool): True if this is a hearing impaired (SDH) subtitle. + perfectMatch (bool): True if the on-demand subtitle is a perfect match. + providerTitle (str): The provider title where the on-demand subtitle is downloaded from. + score (int): The match score (download count) of the on-demand subtitle. + sourceKey (str): The source key of the on-demand subtitle. + transient (str): Unknown. + userID (int): The user id of the user that downloaded the on-demand subtitle. + """ + TAG = 'Stream' + STREAMTYPE = 3 + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + super(SubtitleStream, self)._loadData(data) + self.canAutoSync = utils.cast(bool, data.attrib.get('canAutoSync')) + self.container = data.attrib.get('container') + self.forced = utils.cast(bool, data.attrib.get('forced', '0')) + self.format = data.attrib.get('format') + self.headerCompression = data.attrib.get('headerCompression') + self.hearingImpaired = utils.cast(bool, data.attrib.get('hearingImpaired', '0')) + self.perfectMatch = utils.cast(bool, data.attrib.get('perfectMatch')) + self.providerTitle = data.attrib.get('providerTitle') + self.score = utils.cast(int, data.attrib.get('score')) + self.sourceKey = data.attrib.get('sourceKey') + self.transient = data.attrib.get('transient') + self.userID = utils.cast(int, data.attrib.get('userID')) + + def setSelected(self): + """ Sets this subtitle stream as the selected subtitle stream. + Alias for :func:`~plexapi.media.MediaPart.setSelectedSubtitleStream`. + """ + return self._parent().setSelectedSubtitleStream(self) + + @deprecated('Use "setSelected" instead.') + def setDefault(self): + return self.setSelected() + + +@utils.registerPlexObject +class LyricStream(MediaPartStream): + """ Represents a lyric stream within a :class:`~plexapi.media.MediaPart`. + + Attributes: + TAG (str): 'Stream' + STREAMTYPE (int): 4 + format (str): The format of the lyric stream (ex: lrc). + minLines (int): The minimum number of lines in the (timed) lyric stream. + provider (str): The provider of the lyric stream (ex: com.plexapp.agents.lyricfind). + timed (bool): True if the lyrics are timed to the track. + """ + TAG = 'Stream' + STREAMTYPE = 4 + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + super(LyricStream, self)._loadData(data) + self.format = data.attrib.get('format') + self.minLines = utils.cast(int, data.attrib.get('minLines')) + self.provider = data.attrib.get('provider') + self.timed = utils.cast(bool, data.attrib.get('timed', '0')) + + +@utils.registerPlexObject +class Session(PlexObject): + """ Represents a current session. + + Attributes: + TAG (str): 'Session' + id (str): The unique identifier for the session. + bandwidth (int): The Plex streaming brain reserved bandwidth for the session. + location (str): The location of the session (lan, wan, or cellular) + """ + TAG = 'Session' + + def _loadData(self, data): + self.id = data.attrib.get('id') + self.bandwidth = utils.cast(int, data.attrib.get('bandwidth')) + self.location = data.attrib.get('location') + + +@utils.registerPlexObject +class TranscodeSession(PlexObject): + """ Represents a current transcode session. + + Attributes: + TAG (str): 'TranscodeSession' + audioChannels (int): The number of audio channels of the transcoded media. + audioCodec (str): The audio codec of the transcoded media. + audioDecision (str): The transcode decision for the audio stream. + complete (bool): True if the transcode is complete. + container (str): The container of the transcoded media. + context (str): The context for the transcode session. + duration (int): The duration of the transcoded media in milliseconds. + height (int): The height of the transcoded media in pixels. + key (str): API URL (ex: /transcode/sessions/<id>). + maxOffsetAvailable (float): Unknown. + minOffsetAvailable (float): Unknown. + progress (float): The progress percentage of the transcode. + protocol (str): The protocol of the transcode. + remaining (int): Unknown. + size (int): The size of the transcoded media in bytes. + sourceAudioCodec (str): The audio codec of the source media. + sourceVideoCodec (str): The video codec of the source media. + speed (float): The speed of the transcode. + subtitleDecision (str): The transcode decision for the subtitle stream + throttled (bool): True if the transcode is throttled. + timestamp (int): The epoch timestamp when the transcode started. + transcodeHwDecoding (str): The hardware transcoding decoder engine. + transcodeHwDecodingTitle (str): The title of the hardware transcoding decoder engine. + transcodeHwEncoding (str): The hardware transcoding encoder engine. + transcodeHwEncodingTitle (str): The title of the hardware transcoding encoder engine. + transcodeHwFullPipeline (str): True if hardware decoding and encoding is being used for the transcode. + transcodeHwRequested (str): True if hardware transcoding was requested for the transcode. + videoCodec (str): The video codec of the transcoded media. + videoDecision (str): The transcode decision for the video stream. + width (str): The width of the transcoded media in pixels. + """ + TAG = 'TranscodeSession' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.audioChannels = utils.cast(int, data.attrib.get('audioChannels')) + self.audioCodec = data.attrib.get('audioCodec') + self.audioDecision = data.attrib.get('audioDecision') + self.complete = utils.cast(bool, data.attrib.get('complete', '0')) + self.container = data.attrib.get('container') + self.context = data.attrib.get('context') + self.duration = utils.cast(int, data.attrib.get('duration')) + self.height = utils.cast(int, data.attrib.get('height')) + self.key = data.attrib.get('key') + self.maxOffsetAvailable = utils.cast(float, data.attrib.get('maxOffsetAvailable')) + self.minOffsetAvailable = utils.cast(float, data.attrib.get('minOffsetAvailable')) + self.progress = utils.cast(float, data.attrib.get('progress')) + self.protocol = data.attrib.get('protocol') + self.remaining = utils.cast(int, data.attrib.get('remaining')) + self.size = utils.cast(int, data.attrib.get('size')) + self.sourceAudioCodec = data.attrib.get('sourceAudioCodec') + self.sourceVideoCodec = data.attrib.get('sourceVideoCodec') + self.speed = utils.cast(float, data.attrib.get('speed')) + self.subtitleDecision = data.attrib.get('subtitleDecision') + self.throttled = utils.cast(bool, data.attrib.get('throttled', '0')) + self.timestamp = utils.cast(float, data.attrib.get('timeStamp')) + self.transcodeHwDecoding = data.attrib.get('transcodeHwDecoding') + self.transcodeHwDecodingTitle = data.attrib.get('transcodeHwDecodingTitle') + self.transcodeHwEncoding = data.attrib.get('transcodeHwEncoding') + self.transcodeHwEncodingTitle = data.attrib.get('transcodeHwEncodingTitle') + self.transcodeHwFullPipeline = utils.cast(bool, data.attrib.get('transcodeHwFullPipeline', '0')) + self.transcodeHwRequested = utils.cast(bool, data.attrib.get('transcodeHwRequested', '0')) + self.videoCodec = data.attrib.get('videoCodec') + self.videoDecision = data.attrib.get('videoDecision') + self.width = utils.cast(int, data.attrib.get('width')) + + +@utils.registerPlexObject +class TranscodeJob(PlexObject): + """ Represents an Optimizing job. + TrancodeJobs are the process for optimizing conversions. + Active or paused optimization items. Usually one item as a time.""" + TAG = 'TranscodeJob' + + def _loadData(self, data): + self._data = data + self.generatorID = data.attrib.get('generatorID') + self.key = data.attrib.get('key') + self.progress = data.attrib.get('progress') + self.ratingKey = data.attrib.get('ratingKey') + self.size = data.attrib.get('size') + self.targetTagID = data.attrib.get('targetTagID') + self.thumb = data.attrib.get('thumb') + self.title = data.attrib.get('title') + self.type = data.attrib.get('type') + + +@utils.registerPlexObject +class Optimized(PlexObject): + """ Represents a Optimized item. + Optimized items are optimized and queued conversions items.""" + TAG = 'Item' + + def _loadData(self, data): + self._data = data + self.id = data.attrib.get('id') + self.composite = data.attrib.get('composite') + self.title = data.attrib.get('title') + self.type = data.attrib.get('type') + self.target = data.attrib.get('target') + self.targetTagID = data.attrib.get('targetTagID') + + def items(self): + """ Returns a list of all :class:`~plexapi.media.Video` objects + in this optimized item. + """ + key = f'{self._initpath}/{self.id}/items' + return self.fetchItems(key) + + def remove(self): + """ Remove an Optimized item""" + key = f'{self._initpath}/{self.id}' + self._server.query(key, method=self._server._session.delete) + + def rename(self, title): + """ Rename an Optimized item""" + key = f'{self._initpath}/{self.id}?Item[title]={title}' + self._server.query(key, method=self._server._session.put) + + def reprocess(self, ratingKey): + """ Reprocess a removed Conversion item that is still a listed Optimize item""" + key = f'{self._initpath}/{self.id}/{ratingKey}/enable' + self._server.query(key, method=self._server._session.put) + + +@utils.registerPlexObject +class Conversion(PlexObject): + """ Represents a Conversion item. + Conversions are items queued for optimization or being actively optimized.""" + TAG = 'Video' + + def _loadData(self, data): + self._data = data + self.addedAt = data.attrib.get('addedAt') + self.art = data.attrib.get('art') + self.chapterSource = data.attrib.get('chapterSource') + self.contentRating = data.attrib.get('contentRating') + self.duration = data.attrib.get('duration') + self.generatorID = data.attrib.get('generatorID') + self.generatorType = data.attrib.get('generatorType') + self.guid = data.attrib.get('guid') + self.key = data.attrib.get('key') + self.lastViewedAt = data.attrib.get('lastViewedAt') + self.librarySectionID = data.attrib.get('librarySectionID') + self.librarySectionKey = data.attrib.get('librarySectionKey') + self.librarySectionTitle = data.attrib.get('librarySectionTitle') + self.originallyAvailableAt = data.attrib.get('originallyAvailableAt') + self.playQueueItemID = data.attrib.get('playQueueItemID') + self.playlistID = data.attrib.get('playlistID') + self.primaryExtraKey = data.attrib.get('primaryExtraKey') + self.rating = data.attrib.get('rating') + self.ratingKey = data.attrib.get('ratingKey') + self.studio = data.attrib.get('studio') + self.summary = data.attrib.get('summary') + self.tagline = data.attrib.get('tagline') + self.target = data.attrib.get('target') + self.thumb = data.attrib.get('thumb') + self.title = data.attrib.get('title') + self.type = data.attrib.get('type') + self.updatedAt = data.attrib.get('updatedAt') + self.userID = data.attrib.get('userID') + self.username = data.attrib.get('username') + self.viewOffset = data.attrib.get('viewOffset') + self.year = data.attrib.get('year') + + def remove(self): + """ Remove Conversion from queue """ + key = f'/playlists/{self.playlistID}/items/{self.generatorID}/{self.ratingKey}/disable' + self._server.query(key, method=self._server._session.put) + + def move(self, after): + """ Move Conversion items position in queue + after (int): Place item after specified playQueueItemID. '-1' is the active conversion. + + Example: + Move 5th conversion Item to active conversion + conversions[4].move('-1') + + Move 4th conversion Item to 3rd in conversion queue + conversions[3].move(conversions[1].playQueueItemID) + """ + + key = f'{self._initpath}/items/{self.playQueueItemID}/move?after={after}' + self._server.query(key, method=self._server._session.put) + + +class MediaTag(PlexObject): + """ Base class for media tags used for filtering and searching your library + items or navigating the metadata of media items in your library. Tags are + the construct used for things such as Country, Director, Genre, etc. + + Attributes: + filter (str): The library filter for the tag. + id (id): Tag ID (This seems meaningless except to use it as a unique id). + key (str): API URL (/library/section/<librarySectionID>/all?<filter>). + role (str): The name of the character role for :class:`~plexapi.media.Role` only. + tag (str): Name of the tag. This will be Animation, SciFi etc for Genres. The name of + person for Directors and Roles (ex: Animation, Stephen Graham, etc). + tagKey (str): Plex GUID for the actor/actress for :class:`~plexapi.media.Role` only. + thumb (str): URL to thumbnail image for :class:`~plexapi.media.Role` only. + """ + + def __str__(self): + """ Returns the tag name. """ + return self.tag + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.filter = data.attrib.get('filter') + self.id = utils.cast(int, data.attrib.get('id')) + self.key = data.attrib.get('key') + self.role = data.attrib.get('role') + self.tag = data.attrib.get('tag') + self.tagKey = data.attrib.get('tagKey') + self.thumb = data.attrib.get('thumb') + + parent = self._parent() + self._librarySectionID = utils.cast(int, parent._data.attrib.get('librarySectionID')) + self._librarySectionKey = parent._data.attrib.get('librarySectionKey') + self._librarySectionTitle = parent._data.attrib.get('librarySectionTitle') + self._parentType = parent.TYPE + + if self._librarySectionKey and self.filter: + self.key = f'{self._librarySectionKey}/all?{self.filter}&type={utils.searchType(self._parentType)}' + + def items(self): + """ Return the list of items within this tag. """ + if not self.key: + raise BadRequest(f'Key is not defined for this tag: {self.tag}. Reload the parent object.') + return self.fetchItems(self.key) + + +@utils.registerPlexObject +class Collection(MediaTag): + """ Represents a single Collection media tag. + + Attributes: + TAG (str): 'Collection' + FILTER (str): 'collection' + """ + TAG = 'Collection' + FILTER = 'collection' + + def collection(self): + """ Return the :class:`~plexapi.collection.Collection` object for this collection tag. + """ + key = f'{self._librarySectionKey}/collections' + return self.fetchItem(key, etag='Directory', index=self.id) + + +@utils.registerPlexObject +class Country(MediaTag): + """ Represents a single Country media tag. + + Attributes: + TAG (str): 'Country' + FILTER (str): 'country' + """ + TAG = 'Country' + FILTER = 'country' + + +@utils.registerPlexObject +class Director(MediaTag): + """ Represents a single Director media tag. + + Attributes: + TAG (str): 'Director' + FILTER (str): 'director' + """ + TAG = 'Director' + FILTER = 'director' + + +@utils.registerPlexObject +class Format(MediaTag): + """ Represents a single Format media tag. + + Attributes: + TAG (str): 'Format' + FILTER (str): 'format' + """ + TAG = 'Format' + FILTER = 'format' + + +@utils.registerPlexObject +class Genre(MediaTag): + """ Represents a single Genre media tag. + + Attributes: + TAG (str): 'Genre' + FILTER (str): 'genre' + """ + TAG = 'Genre' + FILTER = 'genre' + + +@utils.registerPlexObject +class Label(MediaTag): + """ Represents a single Label media tag. + + Attributes: + TAG (str): 'Label' + FILTER (str): 'label' + """ + TAG = 'Label' + FILTER = 'label' + + +@utils.registerPlexObject +class Mood(MediaTag): + """ Represents a single Mood media tag. + + Attributes: + TAG (str): 'Mood' + FILTER (str): 'mood' + """ + TAG = 'Mood' + FILTER = 'mood' + + +@utils.registerPlexObject +class Producer(MediaTag): + """ Represents a single Producer media tag. + + Attributes: + TAG (str): 'Producer' + FILTER (str): 'producer' + """ + TAG = 'Producer' + FILTER = 'producer' + + +@utils.registerPlexObject +class Role(MediaTag): + """ Represents a single Role (actor/actress) media tag. + + Attributes: + TAG (str): 'Role' + FILTER (str): 'role' + """ + TAG = 'Role' + FILTER = 'role' + + +@utils.registerPlexObject +class Similar(MediaTag): + """ Represents a single Similar media tag. + + Attributes: + TAG (str): 'Similar' + FILTER (str): 'similar' + """ + TAG = 'Similar' + FILTER = 'similar' + + +@utils.registerPlexObject +class Style(MediaTag): + """ Represents a single Style media tag. + + Attributes: + TAG (str): 'Style' + FILTER (str): 'style' + """ + TAG = 'Style' + FILTER = 'style' + + +@utils.registerPlexObject +class Subformat(MediaTag): + """ Represents a single Subformat media tag. + + Attributes: + TAG (str): 'Subformat' + FILTER (str): 'subformat' + """ + TAG = 'Subformat' + FILTER = 'subformat' + + +@utils.registerPlexObject +class Tag(MediaTag): + """ Represents a single Tag media tag. + + Attributes: + TAG (str): 'Tag' + FILTER (str): 'tag' + """ + TAG = 'Tag' + FILTER = 'tag' + + +@utils.registerPlexObject +class Writer(MediaTag): + """ Represents a single Writer media tag. + + Attributes: + TAG (str): 'Writer' + FILTER (str): 'writer' + """ + TAG = 'Writer' + FILTER = 'writer' + + +@utils.registerPlexObject +class Guid(PlexObject): + """ Represents a single Guid media tag. + + Attributes: + TAG (str): 'Guid' + id (id): The guid for external metadata sources (e.g. IMDB, TMDB, TVDB, MBID). + """ + TAG = 'Guid' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.id = data.attrib.get('id') + + +@utils.registerPlexObject +class Image(PlexObject): + """ Represents a single Image media tag. + + Attributes: + TAG (str): 'Image' + alt (str): The alt text for the image. + type (str): The type of image (e.g. coverPoster, background, snapshot). + url (str): The API URL (/library/metadata/<ratingKey>/thumb/<thumbid>). + """ + TAG = 'Image' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.alt = data.attrib.get('alt') + self.type = data.attrib.get('type') + self.url = data.attrib.get('url') + + +@utils.registerPlexObject +class Rating(PlexObject): + """ Represents a single Rating media tag. + + Attributes: + TAG (str): 'Rating' + image (str): The uri for the rating image + (e.g. ``imdb://image.rating``, ``rottentomatoes://image.rating.ripe``, + ``rottentomatoes://image.rating.upright``, ``themoviedb://image.rating``). + type (str): The type of rating (e.g. audience or critic). + value (float): The rating value. + """ + TAG = 'Rating' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.image = data.attrib.get('image') + self.type = data.attrib.get('type') + self.value = utils.cast(float, data.attrib.get('value')) + + +@utils.registerPlexObject +class Review(PlexObject): + """ Represents a single Review for a Movie. + + Attributes: + TAG (str): 'Review' + filter (str): The library filter for the review. + id (int): The ID of the review. + image (str): The image uri for the review. + link (str): The url to the online review. + source (str): The source of the review. + tag (str): The name of the reviewer. + text (str): The text of the review. + """ + TAG = 'Review' + + def _loadData(self, data): + self._data = data + self.filter = data.attrib.get('filter') + self.id = utils.cast(int, data.attrib.get('id', 0)) + self.image = data.attrib.get('image') + self.link = data.attrib.get('link') + self.source = data.attrib.get('source') + self.tag = data.attrib.get('tag') + self.text = data.attrib.get('text') + + +@utils.registerPlexObject +class UltraBlurColors(PlexObject): + """ Represents a single UltraBlurColors media tag. + + Attributes: + TAG (str): 'UltraBlurColors' + bottomLeft (str): The bottom left hex color. + bottomRight (str): The bottom right hex color. + topLeft (str): The top left hex color. + topRight (str): The top right hex color. + """ + TAG = 'UltraBlurColors' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.bottomLeft = data.attrib.get('bottomLeft') + self.bottomRight = data.attrib.get('bottomRight') + self.topLeft = data.attrib.get('topLeft') + self.topRight = data.attrib.get('topRight') + + +class BaseResource(PlexObject): + """ Base class for all Art, Poster, and Theme objects. + + Attributes: + TAG (str): 'Photo' or 'Track' + key (str): API URL (/library/metadata/<ratingkey>). + provider (str): The source of the resource. 'local' for local files (e.g. theme.mp3), + None if uploaded or agent-/plugin-supplied. + ratingKey (str): Unique key identifying the resource. + selected (bool): True if the resource is currently selected. + thumb (str): The URL to retrieve the resource thumbnail. + """ + + def _loadData(self, data): + self._data = data + self.key = data.attrib.get('key') + self.provider = data.attrib.get('provider') + self.ratingKey = data.attrib.get('ratingKey') + self.selected = utils.cast(bool, data.attrib.get('selected')) + self.thumb = data.attrib.get('thumb') + + def select(self): + key = self._initpath[:-1] + data = f'{key}?url={quote_plus(self.ratingKey)}' + try: + self._server.query(data, method=self._server._session.put) + except xml.etree.ElementTree.ParseError: + pass + + @property + def resourceFilepath(self): + """ Returns the file path to the resource in the Plex Media Server data directory. + Note: Returns the URL if the resource is not stored locally. + """ + if self.ratingKey.startswith('media://'): + return str(Path('Media') / 'localhost' / self.ratingKey.split('://')[-1]) + elif self.ratingKey.startswith('metadata://'): + return str(Path(self._parent().metadataDirectory) / 'Contents' / '_combined' / self.ratingKey.split('://')[-1]) + elif self.ratingKey.startswith('upload://'): + return str(Path(self._parent().metadataDirectory) / 'Uploads' / self.ratingKey.split('://')[-1]) + else: + return self.ratingKey + + +class Art(BaseResource): + """ Represents a single Art object. """ + TAG = 'Photo' + + +class Logo(BaseResource): + """ Represents a single Logo object. """ + TAG = 'Photo' + + +class Poster(BaseResource): + """ Represents a single Poster object. """ + TAG = 'Photo' + + +class Theme(BaseResource): + """ Represents a single Theme object. """ + TAG = 'Track' + + +@utils.registerPlexObject +class Chapter(PlexObject): + """ Represents a single Chapter media tag. + + Attributes: + TAG (str): 'Chapter' + end (int): The end time of the chapter in milliseconds. + filter (str): The library filter for the chapter. + id (int): The ID of the chapter. + index (int): The index of the chapter. + tag (str): The name of the chapter. + title (str): The title of the chapter. + thumb (str): The URL to retrieve the chapter thumbnail. + start (int): The start time of the chapter in milliseconds. + """ + TAG = 'Chapter' + + def __repr__(self): + name = self._clean(self.firstAttr('tag')) + start = utils.millisecondToHumanstr(self._clean(self.firstAttr('start'))) + end = utils.millisecondToHumanstr(self._clean(self.firstAttr('end'))) + offsets = f'{start}-{end}' + return f"<{':'.join([self.__class__.__name__, name, offsets])}>" + + def _loadData(self, data): + self._data = data + self.end = utils.cast(int, data.attrib.get('endTimeOffset')) + self.filter = data.attrib.get('filter') + self.id = utils.cast(int, data.attrib.get('id', 0)) + self.index = utils.cast(int, data.attrib.get('index')) + self.tag = data.attrib.get('tag') + self.title = self.tag + self.thumb = data.attrib.get('thumb') + self.start = utils.cast(int, data.attrib.get('startTimeOffset')) + + +@utils.registerPlexObject +class Marker(PlexObject): + """ Represents a single Marker media tag. + + Attributes: + TAG (str): 'Marker' + end (int): The end time of the marker in milliseconds. + final (bool): True if the marker is the final credits marker. + id (int): The ID of the marker. + type (str): The type of marker. + start (int): The start time of the marker in milliseconds. + version (int): The Plex marker version. + """ + TAG = 'Marker' + + def __repr__(self): + name = self._clean(self.firstAttr('type')) + start = utils.millisecondToHumanstr(self._clean(self.firstAttr('start'))) + end = utils.millisecondToHumanstr(self._clean(self.firstAttr('end'))) + offsets = f'{start}-{end}' + return f"<{':'.join([self.__class__.__name__, name, offsets])}>" + + def _loadData(self, data): + self._data = data + self.end = utils.cast(int, data.attrib.get('endTimeOffset')) + self.final = utils.cast(bool, data.attrib.get('final')) + self.id = utils.cast(int, data.attrib.get('id')) + self.type = data.attrib.get('type') + self.start = utils.cast(int, data.attrib.get('startTimeOffset')) + + attributes = data.find('Attributes') + self.version = attributes.attrib.get('version') + + @property + def first(self): + """ Returns True if the marker in the first credits marker. """ + if self.type != 'credits': + return None + first = min( + (marker for marker in self._parent().markers if marker.type == 'credits'), + key=lambda m: m.start + ) + return first == self + + +@utils.registerPlexObject +class Field(PlexObject): + """ Represents a single Field. + + Attributes: + TAG (str): 'Field' + locked (bool): True if the field is locked. + name (str): The name of the field. + """ + TAG = 'Field' + + def _loadData(self, data): + self._data = data + self.locked = utils.cast(bool, data.attrib.get('locked')) + self.name = data.attrib.get('name') + + +@utils.registerPlexObject +class SearchResult(PlexObject): + """ Represents a single SearchResult. + + Attributes: + TAG (str): 'SearchResult' + """ + TAG = 'SearchResult' + + def __repr__(self): + name = self._clean(self.firstAttr('name')) + score = self._clean(self.firstAttr('score')) + return f"<{':'.join([p for p in [self.__class__.__name__, name, score] if p])}>" + + def _loadData(self, data): + self._data = data + self.guid = data.attrib.get('guid') + self.lifespanEnded = data.attrib.get('lifespanEnded') + self.name = data.attrib.get('name') + self.score = utils.cast(int, data.attrib.get('score')) + self.year = data.attrib.get('year') + + +@utils.registerPlexObject +class Agent(PlexObject): + """ Represents a single Agent. + + Attributes: + TAG (str): 'Agent' + """ + TAG = 'Agent' + + def __repr__(self): + uid = self._clean(self.firstAttr('shortIdentifier')) + return f"<{':'.join([p for p in [self.__class__.__name__, uid] if p])}>" + + def _loadData(self, data): + self._data = data + self.hasAttribution = data.attrib.get('hasAttribution') + self.hasPrefs = data.attrib.get('hasPrefs') + self.identifier = data.attrib.get('identifier') + self.name = data.attrib.get('name') + self.primary = data.attrib.get('primary') + self.shortIdentifier = self.identifier.rsplit('.', 1)[1] + + if 'mediaType' in self._initpath: + self.languageCodes = self.listAttrs(data, 'code', etag='Language') + self.mediaTypes = [] + else: + self.languageCodes = [] + self.mediaTypes = self.findItems(data, cls=AgentMediaType) + + @property + @deprecated('use "languageCodes" instead') + def languageCode(self): + return self.languageCodes + + def settings(self): + key = f'/:/plugins/{self.identifier}/prefs' + data = self._server.query(key) + return self.findItems(data, cls=settings.Setting) + + @deprecated('use "settings" instead') + def _settings(self): + return self.settings() + + +class AgentMediaType(Agent): + """ Represents a single Agent MediaType. + + Attributes: + TAG (str): 'MediaType' + """ + TAG = 'MediaType' + + def __repr__(self): + uid = self._clean(self.firstAttr('name')) + return f"<{':'.join([p for p in [self.__class__.__name__, uid] if p])}>" + + def _loadData(self, data): + self.languageCodes = self.listAttrs(data, 'code', etag='Language') + self.mediaType = utils.cast(int, data.attrib.get('mediaType')) + self.name = data.attrib.get('name') + + @property + @deprecated('use "languageCodes" instead') + def languageCode(self): + return self.languageCodes + + +@utils.registerPlexObject +class Availability(PlexObject): + """ Represents a single online streaming service Availability. + + Attributes: + TAG (str): 'Availability' + country (str): The streaming service country. + offerType (str): Subscription, buy, or rent from the streaming service. + platform (str): The platform slug for the streaming service. + platformColorThumb (str): Thumbnail icon for the streaming service. + platformInfo (str): The streaming service platform info. + platformUrl (str): The URL to the media on the streaming service. + price (float): The price to buy or rent from the streaming service. + priceDescription (str): The display price to buy or rent from the streaming service. + quality (str): The video quality on the streaming service. + title (str): The title of the streaming service. + url (str): The Plex availability URL. + """ + TAG = 'Availability' + + def __repr__(self): + return f'<{self.__class__.__name__}:{self.platform}:{self.offerType}>' + + def _loadData(self, data): + self._data = data + self.country = data.attrib.get('country') + self.offerType = data.attrib.get('offerType') + self.platform = data.attrib.get('platform') + self.platformColorThumb = data.attrib.get('platformColorThumb') + self.platformInfo = data.attrib.get('platformInfo') + self.platformUrl = data.attrib.get('platformUrl') + self.price = utils.cast(float, data.attrib.get('price')) + self.priceDescription = data.attrib.get('priceDescription') + self.quality = data.attrib.get('quality') + self.title = data.attrib.get('title') + self.url = data.attrib.get('url') diff --git a/libs/plexapi/mixins.py b/libs/plexapi/mixins.py new file mode 100644 index 000000000..95f785fcc --- /dev/null +++ b/libs/plexapi/mixins.py @@ -0,0 +1,1325 @@ +# -*- coding: utf-8 -*- +from collections import deque +from datetime import datetime +from typing import Deque, Set, Tuple, Union +from urllib.parse import parse_qsl, quote, quote_plus, unquote, urlencode, urlsplit + +from plexapi import media, settings, utils +from plexapi.exceptions import BadRequest, NotFound +from plexapi.utils import deprecated, openOrRead + + +class AdvancedSettingsMixin: + """ Mixin for Plex objects that can have advanced settings. """ + + def preferences(self): + """ Returns a list of :class:`~plexapi.settings.Preferences` objects. """ + key = f'{self.key}?includePreferences=1' + return self.fetchItems(key, cls=settings.Preferences, rtag='Preferences') + + def preference(self, pref): + """ Returns a :class:`~plexapi.settings.Preferences` object for the specified pref. + + Parameters: + pref (str): The id of the preference to return. + """ + prefs = self.preferences() + try: + return next(p for p in prefs if p.id == pref) + except StopIteration: + availablePrefs = [p.id for p in prefs] + raise NotFound(f'Unknown preference "{pref}" for {self.TYPE}. ' + f'Available preferences: {availablePrefs}') from None + + def editAdvanced(self, **kwargs): + """ Edit a Plex object's advanced settings. """ + data = {} + key = f'{self.key}/prefs?' + preferences = {pref.id: pref for pref in self.preferences() if pref.enumValues} + for settingID, value in kwargs.items(): + try: + pref = preferences[settingID] + except KeyError: + raise NotFound(f'{value} not found in {list(preferences.keys())}') + + enumValues = pref.enumValues + if enumValues.get(value, enumValues.get(str(value))): + data[settingID] = value + else: + raise NotFound(f'{value} not found in {list(enumValues)}') + url = key + urlencode(data) + self._server.query(url, method=self._server._session.put) + return self + + def defaultAdvanced(self): + """ Edit all of a Plex object's advanced settings to default. """ + data = {} + key = f'{self.key}/prefs?' + for preference in self.preferences(): + data[preference.id] = preference.default + url = key + urlencode(data) + self._server.query(url, method=self._server._session.put) + return self + + +class SmartFilterMixin: + """ Mixin for Plex objects that can have smart filters. """ + + def _parseFilterGroups(self, feed: Deque[Tuple[str, str]], returnOn: Union[Set[str], None] = None) -> dict: + """ Parse filter groups from input lines between push and pop. """ + currentFiltersStack: list[dict] = [] + operatorForStack = None + if returnOn is None: + returnOn = set("pop") + else: + returnOn.add("pop") + allowedLogicalOperators = ["and", "or"] # first is the default + + while feed: + key, value = feed.popleft() # consume the first item + if key == "push": + # recurse and add the result to the current stack + currentFiltersStack.append( + self._parseFilterGroups(feed, returnOn) + ) + elif key in returnOn: + # stop iterating and return the current stack + if not key == "pop": + feed.appendleft((key, value)) # put the item back + break + + elif key in allowedLogicalOperators: + # set the operator + if operatorForStack and not operatorForStack == key: + raise ValueError( + "cannot have different logical operators for the same" + " filter group" + ) + operatorForStack = key + + else: + # add the key value pair to the current filter + currentFiltersStack.append({key: value}) + + if not operatorForStack and len(currentFiltersStack) > 1: + # consider 'and' as the default operator + operatorForStack = allowedLogicalOperators[0] + + if operatorForStack: + return {operatorForStack: currentFiltersStack} + return currentFiltersStack.pop() + + def _parseQueryFeed(self, feed: "deque[Tuple[str, str]]") -> dict: + """ Parse the query string into a dict. """ + filtersDict = {} + special_keys = {"type", "sort"} + integer_keys = {"includeGuids", "limit"} + as_is_keys = {"group", "having"} + reserved_keys = special_keys | integer_keys | as_is_keys + while feed: + key, value = feed.popleft() + if key in integer_keys: + filtersDict[key] = int(value) + elif key in as_is_keys: + filtersDict[key] = value + elif key == "type": + filtersDict["libtype"] = utils.reverseSearchType(value) + elif key == "sort": + filtersDict["sort"] = value.split(",") + else: + feed.appendleft((key, value)) # put the item back + filter_group = self._parseFilterGroups( + feed, returnOn=reserved_keys + ) + if "filters" in filtersDict: + filtersDict["filters"] = { + "and": [filtersDict["filters"], filter_group] + } + else: + filtersDict["filters"] = filter_group + + return filtersDict + + def _parseFilters(self, content): + """ Parse the content string and returns the filter dict. """ + content = urlsplit(unquote(content)) + feed = deque() + + for key, value in parse_qsl(content.query): + # Move = sign to key when operator is == + if value.startswith("="): + key, value = f"{key}=", value[1:] + + feed.append((key, value)) + + return self._parseQueryFeed(feed) + + +class SplitMergeMixin: + """ Mixin for Plex objects that can be split and merged. """ + + def split(self): + """ Split duplicated Plex object into separate objects. """ + key = f'{self.key}/split' + self._server.query(key, method=self._server._session.put) + return self + + def merge(self, ratingKeys): + """ Merge other Plex objects into the current object. + + Parameters: + ratingKeys (list): A list of rating keys to merge. + """ + if not isinstance(ratingKeys, list): + ratingKeys = str(ratingKeys).split(',') + + key = f"{self.key}/merge?ids={','.join(str(r) for r in ratingKeys)}" + self._server.query(key, method=self._server._session.put) + return self + + +class UnmatchMatchMixin: + """ Mixin for Plex objects that can be unmatched and matched. """ + + def unmatch(self): + """ Unmatches metadata match from object. """ + key = f'{self.key}/unmatch' + self._server.query(key, method=self._server._session.put) + + def matches(self, agent=None, title=None, year=None, language=None): + """ Return list of (:class:`~plexapi.media.SearchResult`) metadata matches. + + Parameters: + agent (str): Agent name to be used (imdb, thetvdb, themoviedb, etc.) + title (str): Title of item to search for + year (str): Year of item to search in + language (str) : Language of item to search in + + Examples: + 1. video.matches() + 2. video.matches(title="something", year=2020) + 3. video.matches(title="something") + 4. video.matches(year=2020) + 5. video.matches(title="something", year="") + 6. video.matches(title="", year=2020) + 7. video.matches(title="", year="") + + 1. The default behaviour in Plex Web = no params in plexapi + 2. Both title and year specified by user + 3. Year automatically filled in + 4. Title automatically filled in + 5. Explicitly searches for title with blank year + 6. Explicitly searches for blank title with year + 7. I don't know what the user is thinking... return the same result as 1 + + For 2 to 7, the agent and language is automatically filled in + """ + key = f'{self.key}/matches' + params = {'manual': 1} + + if agent and not any([title, year, language]): + params['language'] = self.section().language + params['agent'] = utils.getAgentIdentifier(self.section(), agent) + else: + if any(x is not None for x in [agent, title, year, language]): + if title is None: + params['title'] = self.title + else: + params['title'] = title + + if year is None: + params['year'] = getattr(self, 'year', '') + else: + params['year'] = year + + params['language'] = language or self.section().language + + if agent is None: + params['agent'] = self.section().agent + else: + params['agent'] = utils.getAgentIdentifier(self.section(), agent) + + key = key + '?' + urlencode(params) + return self.fetchItems(key, cls=media.SearchResult) + + def fixMatch(self, searchResult=None, auto=False, agent=None): + """ Use match result to update show metadata. + + Parameters: + auto (bool): True uses first match from matches + False allows user to provide the match + searchResult (:class:`~plexapi.media.SearchResult`): Search result from + ~plexapi.base.matches() + agent (str): Agent name to be used (imdb, thetvdb, themoviedb, etc.) + """ + key = f'{self.key}/match' + if auto: + autoMatch = self.matches(agent=agent) + if autoMatch: + searchResult = autoMatch[0] + else: + raise NotFound(f'No matches found using this agent: ({agent}:{autoMatch})') + elif not searchResult: + raise NotFound('fixMatch() requires either auto=True or ' + 'searchResult=:class:`~plexapi.media.SearchResult`.') + + params = {'guid': searchResult.guid, + 'name': searchResult.name} + + data = key + '?' + urlencode(params) + self._server.query(data, method=self._server._session.put) + return self + + +class ExtrasMixin: + """ Mixin for Plex objects that can have extras. """ + + def extras(self): + """ Returns a list of :class:`~plexapi.video.Extra` objects. """ + from plexapi.video import Extra + key = f'{self.key}/extras' + return self.fetchItems(key, cls=Extra) + + +class HubsMixin: + """ Mixin for Plex objects that can have related hubs. """ + + def hubs(self): + """ Returns a list of :class:`~plexapi.library.Hub` objects. """ + from plexapi.library import Hub + key = f'{self.key}/related' + return self.fetchItems(key, cls=Hub) + + +class PlayedUnplayedMixin: + """ Mixin for Plex objects that can be marked played and unplayed. """ + + @property + def isPlayed(self): + """ Returns True if this video is played. """ + return bool(self.viewCount > 0) if self.viewCount else False + + def markPlayed(self): + """ Mark the Plex object as played. """ + key = '/:/scrobble' + params = {'key': self.ratingKey, 'identifier': 'com.plexapp.plugins.library'} + self._server.query(key, params=params) + return self + + def markUnplayed(self): + """ Mark the Plex object as unplayed. """ + key = '/:/unscrobble' + params = {'key': self.ratingKey, 'identifier': 'com.plexapp.plugins.library'} + self._server.query(key, params=params) + return self + + @property + def isWatched(self): + """ Alias to self.isPlayed. """ + return self.isPlayed + + def markWatched(self): + """ Alias to :func:`~plexapi.mixins.PlayedUnplayedMixin.markPlayed`. """ + self.markPlayed() + + def markUnwatched(self): + """ Alias to :func:`~plexapi.mixins.PlayedUnplayedMixin.markUnplayed`. """ + self.markUnplayed() + + +class RatingMixin: + """ Mixin for Plex objects that can have user star ratings. """ + + def rate(self, rating=None): + """ Rate the Plex object. Note: Plex ratings are displayed out of 5 stars (e.g. rating 7.0 = 3.5 stars). + + Parameters: + rating (float, optional): Rating from 0 to 10. Exclude to reset the rating. + + Raises: + :exc:`~plexapi.exceptions.BadRequest`: If the rating is invalid. + """ + if rating is None: + rating = -1 + elif not isinstance(rating, (int, float)) or rating < 0 or rating > 10: + raise BadRequest('Rating must be between 0 to 10.') + key = f'/:/rate?key={self.ratingKey}&identifier=com.plexapp.plugins.library&rating={rating}' + self._server.query(key, method=self._server._session.put) + return self + + +class ArtUrlMixin: + """ Mixin for Plex objects that can have a background artwork url. """ + + @property + def artUrl(self): + """ Return the art url for the Plex object. """ + art = self.firstAttr('art', 'grandparentArt') + return self._server.url(art, includeToken=True) if art else None + + +class ArtLockMixin: + """ Mixin for Plex objects that can have a locked background artwork. """ + + def lockArt(self): + """ Lock the background artwork for a Plex object. """ + return self._edit(**{'art.locked': 1}) + + def unlockArt(self): + """ Unlock the background artwork for a Plex object. """ + return self._edit(**{'art.locked': 0}) + + +class ArtMixin(ArtUrlMixin, ArtLockMixin): + """ Mixin for Plex objects that can have background artwork. """ + + def arts(self): + """ Returns list of available :class:`~plexapi.media.Art` objects. """ + return self.fetchItems(f'/library/metadata/{self.ratingKey}/arts', cls=media.Art) + + def uploadArt(self, url=None, filepath=None): + """ Upload a background artwork from a url or filepath. + + Parameters: + url (str): The full URL to the image to upload. + filepath (str): The full file path the the image to upload or file-like object. + """ + if url: + key = f'/library/metadata/{self.ratingKey}/arts?url={quote_plus(url)}' + self._server.query(key, method=self._server._session.post) + elif filepath: + key = f'/library/metadata/{self.ratingKey}/arts' + data = openOrRead(filepath) + self._server.query(key, method=self._server._session.post, data=data) + return self + + def setArt(self, art): + """ Set the background artwork for a Plex object. + + Parameters: + art (:class:`~plexapi.media.Art`): The art object to select. + """ + art.select() + return self + + +class LogoUrlMixin: + """ Mixin for Plex objects that can have a logo url. """ + + @property + def logoUrl(self): + """ Return the logo url for the Plex object. """ + image = next((i for i in self.images if i.type == 'clearLogo'), None) + return self._server.url(image.url, includeToken=True) if image else None + + +class LogoLockMixin: + """ Mixin for Plex objects that can have a locked logo. """ + + def lockLogo(self): + """ Lock the logo for a Plex object. """ + raise NotImplementedError('Logo cannot be locked through the API.') + + def unlockLogo(self): + """ Unlock the logo for a Plex object. """ + raise NotImplementedError('Logo cannot be unlocked through the API.') + + +class LogoMixin(LogoUrlMixin, LogoLockMixin): + """ Mixin for Plex objects that can have logos. """ + + def logos(self): + """ Returns list of available :class:`~plexapi.media.Logo` objects. """ + return self.fetchItems(f'/library/metadata/{self.ratingKey}/clearLogos', cls=media.Logo) + + def uploadLogo(self, url=None, filepath=None): + """ Upload a logo from a url or filepath. + + Parameters: + url (str): The full URL to the image to upload. + filepath (str): The full file path the the image to upload or file-like object. + """ + if url: + key = f'/library/metadata/{self.ratingKey}/clearLogos?url={quote_plus(url)}' + self._server.query(key, method=self._server._session.post) + elif filepath: + key = f'/library/metadata/{self.ratingKey}/clearLogos' + data = openOrRead(filepath) + self._server.query(key, method=self._server._session.post, data=data) + return self + + def setLogo(self, logo): + """ Set the logo for a Plex object. + + Raises: + :exc:`~plexapi.exceptions.NotImplementedError`: Logo cannot be set through the API. + """ + raise NotImplementedError( + 'Logo cannot be set through the API. ' + 'Re-upload the logo using "uploadLogo" to set it.' + ) + + +class PosterUrlMixin: + """ Mixin for Plex objects that can have a poster url. """ + + @property + def thumbUrl(self): + """ Return the thumb url for the Plex object. """ + thumb = self.firstAttr('thumb', 'parentThumb', 'grandparentThumb') + return self._server.url(thumb, includeToken=True) if thumb else None + + @property + def posterUrl(self): + """ Alias to self.thumbUrl. """ + return self.thumbUrl + + +class PosterLockMixin: + """ Mixin for Plex objects that can have a locked poster. """ + + def lockPoster(self): + """ Lock the poster for a Plex object. """ + return self._edit(**{'thumb.locked': 1}) + + def unlockPoster(self): + """ Unlock the poster for a Plex object. """ + return self._edit(**{'thumb.locked': 0}) + + +class PosterMixin(PosterUrlMixin, PosterLockMixin): + """ Mixin for Plex objects that can have posters. """ + + def posters(self): + """ Returns list of available :class:`~plexapi.media.Poster` objects. """ + return self.fetchItems(f'/library/metadata/{self.ratingKey}/posters', cls=media.Poster) + + def uploadPoster(self, url=None, filepath=None): + """ Upload a poster from a url or filepath. + + Parameters: + url (str): The full URL to the image to upload. + filepath (str): The full file path the the image to upload or file-like object. + """ + if url: + key = f'/library/metadata/{self.ratingKey}/posters?url={quote_plus(url)}' + self._server.query(key, method=self._server._session.post) + elif filepath: + key = f'/library/metadata/{self.ratingKey}/posters' + data = openOrRead(filepath) + self._server.query(key, method=self._server._session.post, data=data) + return self + + def setPoster(self, poster): + """ Set the poster for a Plex object. + + Parameters: + poster (:class:`~plexapi.media.Poster`): The poster object to select. + """ + poster.select() + return self + + +class ThemeUrlMixin: + """ Mixin for Plex objects that can have a theme url. """ + + @property + def themeUrl(self): + """ Return the theme url for the Plex object. """ + theme = self.firstAttr('theme', 'parentTheme', 'grandparentTheme') + return self._server.url(theme, includeToken=True) if theme else None + + +class ThemeLockMixin: + """ Mixin for Plex objects that can have a locked theme. """ + + def lockTheme(self): + """ Lock the theme for a Plex object. """ + return self._edit(**{'theme.locked': 1}) + + def unlockTheme(self): + """ Unlock the theme for a Plex object. """ + return self._edit(**{'theme.locked': 0}) + + +class ThemeMixin(ThemeUrlMixin, ThemeLockMixin): + """ Mixin for Plex objects that can have themes. """ + + def themes(self): + """ Returns list of available :class:`~plexapi.media.Theme` objects. """ + return self.fetchItems(f'/library/metadata/{self.ratingKey}/themes', cls=media.Theme) + + def uploadTheme(self, url=None, filepath=None, timeout=None): + """ Upload a theme from url or filepath. + + Warning: Themes cannot be deleted using PlexAPI! + + Parameters: + url (str): The full URL to the theme to upload. + filepath (str): The full file path to the theme to upload or file-like object. + timeout (int, optional): Timeout, in seconds, to use when uploading themes to the server. + (default config.TIMEOUT). + """ + if url: + key = f'/library/metadata/{self.ratingKey}/themes?url={quote_plus(url)}' + self._server.query(key, method=self._server._session.post, timeout=timeout) + elif filepath: + key = f'/library/metadata/{self.ratingKey}/themes' + data = openOrRead(filepath) + self._server.query(key, method=self._server._session.post, data=data, timeout=timeout) + return self + + def setTheme(self, theme): + """ Set the theme for a Plex object. + + Raises: + :exc:`~plexapi.exceptions.NotImplementedError`: Themes cannot be set through the API. + """ + raise NotImplementedError( + 'Themes cannot be set through the API. ' + 'Re-upload the theme using "uploadTheme" to set it.' + ) + + +class EditFieldMixin: + """ Mixin for editing Plex object fields. """ + + def editField(self, field, value, locked=True, **kwargs): + """ Edit the field of a Plex object. All field editing methods can be chained together. + Also see :func:`~plexapi.base.PlexPartialObject.batchEdits` for batch editing fields. + + Parameters: + field (str): The name of the field to edit. + value (str): The value to edit the field to. + locked (bool): True (default) to lock the field, False to unlock the field. + + Example: + + .. code-block:: python + + # Chaining multiple field edits with reloading + Movie.editTitle('A New Title').editSummary('A new summary').editTagline('A new tagline').reload() + + """ + edits = { + f'{field}.value': value or '', + f'{field}.locked': 1 if locked else 0 + } + edits.update(kwargs) + return self._edit(**edits) + + +class AddedAtMixin(EditFieldMixin): + """ Mixin for Plex objects that can have an added at date. """ + + def editAddedAt(self, addedAt, locked=True): + """ Edit the added at date. + + Parameters: + addedAt (int or str or datetime): The new value as a unix timestamp (int), + "YYYY-MM-DD" (str), or datetime object. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + if isinstance(addedAt, str): + addedAt = int(round(datetime.strptime(addedAt, '%Y-%m-%d').timestamp())) + elif isinstance(addedAt, datetime): + addedAt = int(round(addedAt.timestamp())) + return self.editField('addedAt', addedAt, locked=locked) + + +class AudienceRatingMixin(EditFieldMixin): + """ Mixin for Plex objects that can have an audience rating. """ + + def editAudienceRating(self, audienceRating, locked=True): + """ Edit the audience rating. + + Parameters: + audienceRating (float): The new value. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editField('audienceRating', audienceRating, locked=locked) + + +class ContentRatingMixin(EditFieldMixin): + """ Mixin for Plex objects that can have a content rating. """ + + def editContentRating(self, contentRating, locked=True): + """ Edit the content rating. + + Parameters: + contentRating (str): The new value. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editField('contentRating', contentRating, locked=locked) + + +class CriticRatingMixin(EditFieldMixin): + """ Mixin for Plex objects that can have a critic rating. """ + + def editCriticRating(self, criticRating, locked=True): + """ Edit the critic rating. + + Parameters: + criticRating (float): The new value. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editField('rating', criticRating, locked=locked) + + +class EditionTitleMixin(EditFieldMixin): + """ Mixin for Plex objects that can have an edition title. """ + + def editEditionTitle(self, editionTitle, locked=True): + """ Edit the edition title. Plex Pass is required to edit this field. + + Parameters: + editionTitle (str): The new value. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editField('editionTitle', editionTitle, locked=locked) + + +class OriginallyAvailableMixin(EditFieldMixin): + """ Mixin for Plex objects that can have an originally available date. """ + + def editOriginallyAvailable(self, originallyAvailable, locked=True): + """ Edit the originally available date. + + Parameters: + originallyAvailable (str or datetime): The new value "YYYY-MM-DD (str) or datetime object. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + if isinstance(originallyAvailable, datetime): + originallyAvailable = originallyAvailable.strftime('%Y-%m-%d') + return self.editField('originallyAvailableAt', originallyAvailable, locked=locked) + + +class OriginalTitleMixin(EditFieldMixin): + """ Mixin for Plex objects that can have an original title. """ + + def editOriginalTitle(self, originalTitle, locked=True): + """ Edit the original title. + + Parameters: + originalTitle (str): The new value. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editField('originalTitle', originalTitle, locked=locked) + + +class SortTitleMixin(EditFieldMixin): + """ Mixin for Plex objects that can have a sort title. """ + + def editSortTitle(self, sortTitle, locked=True): + """ Edit the sort title. + + Parameters: + sortTitle (str): The new value. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editField('titleSort', sortTitle, locked=locked) + + +class StudioMixin(EditFieldMixin): + """ Mixin for Plex objects that can have a studio. """ + + def editStudio(self, studio, locked=True): + """ Edit the studio. + + Parameters: + studio (str): The new value. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editField('studio', studio, locked=locked) + + +class SummaryMixin(EditFieldMixin): + """ Mixin for Plex objects that can have a summary. """ + + def editSummary(self, summary, locked=True): + """ Edit the summary. + + Parameters: + summary (str): The new value. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editField('summary', summary, locked=locked) + + +class TaglineMixin(EditFieldMixin): + """ Mixin for Plex objects that can have a tagline. """ + + def editTagline(self, tagline, locked=True): + """ Edit the tagline. + + Parameters: + tagline (str): The new value. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editField('tagline', tagline, locked=locked) + + +class TitleMixin(EditFieldMixin): + """ Mixin for Plex objects that can have a title. """ + + def editTitle(self, title, locked=True): + """ Edit the title. + + Parameters: + title (str): The new value. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + kwargs = {} + if self.TYPE == 'album': + # Editing album title also requires the artist ratingKey + kwargs['artist.id.value'] = self.parentRatingKey + return self.editField('title', title, locked=locked, **kwargs) + + +class TrackArtistMixin(EditFieldMixin): + """ Mixin for Plex objects that can have a track artist. """ + + def editTrackArtist(self, trackArtist, locked=True): + """ Edit the track artist. + + Parameters: + trackArtist (str): The new value. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editField('originalTitle', trackArtist, locked=locked) + + +class TrackNumberMixin(EditFieldMixin): + """ Mixin for Plex objects that can have a track number. """ + + def editTrackNumber(self, trackNumber, locked=True): + """ Edit the track number. + + Parameters: + trackNumber (int): The new value. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editField('index', trackNumber, locked=locked) + + +class TrackDiscNumberMixin(EditFieldMixin): + """ Mixin for Plex objects that can have a track disc number. """ + + def editDiscNumber(self, discNumber, locked=True): + """ Edit the track disc number. + + Parameters: + discNumber (int): The new value. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editField('parentIndex', discNumber, locked=locked) + + +class PhotoCapturedTimeMixin(EditFieldMixin): + """ Mixin for Plex objects that can have a captured time. """ + + def editCapturedTime(self, capturedTime, locked=True): + """ Edit the photo captured time. + + Parameters: + capturedTime (str or datetime): The new value "YYYY-MM-DD hh:mm:ss" (str) or datetime object. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + if isinstance(capturedTime, datetime): + capturedTime = capturedTime.strftime('%Y-%m-%d %H:%M:%S') + return self.editField('originallyAvailableAt', capturedTime, locked=locked) + + +class UserRatingMixin(EditFieldMixin): + """ Mixin for Plex objects that can have a user rating. """ + + def editUserRating(self, userRating, locked=True): + """ Edit the user rating. + + Parameters: + userRating (float): The new value. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editField('userRating', userRating, locked=locked) + + +class EditTagsMixin: + """ Mixin for editing Plex object tags. """ + + @deprecated('use "editTags" instead') + def _edit_tags(self, tag, items, locked=True, remove=False): + return self.editTags(tag, items, locked, remove) + + def editTags(self, tag, items, locked=True, remove=False, **kwargs): + """ Edit the tags of a Plex object. All tag editing methods can be chained together. + Also see :func:`~plexapi.base.PlexPartialObject.batchEdits` for batch editing tags. + + Parameters: + tag (str): Name of the tag to edit. + items (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags to add or remove. + locked (bool): True (default) to lock the tags, False to unlock the tags. + remove (bool): True to remove the tags in items. + + Example: + + .. code-block:: python + + # Chaining multiple tag edits with reloading + Show.addCollection('New Collection').removeGenre('Action').addLabel('Favorite').reload() + + """ + if not isinstance(items, list): + items = [items] + + if not remove: + tags = getattr(self, self._tagPlural(tag), []) + if isinstance(tags, list): + items = tags + items + + edits = self._tagHelper(self._tagSingular(tag), items, locked, remove) + edits.update(kwargs) + return self._edit(**edits) + + @staticmethod + def _tagSingular(tag): + """ Return the singular name of a tag. """ + if tag == 'countries': + return 'country' + elif tag == 'similar': + return 'similar' + elif tag[-1] == 's': + return tag[:-1] + return tag + + @staticmethod + def _tagPlural(tag): + """ Return the plural name of a tag. """ + if tag == 'country': + return 'countries' + elif tag == 'similar': + return 'similar' + elif tag[-1] != 's': + return tag + 's' + return tag + + @staticmethod + def _tagHelper(tag, items, locked=True, remove=False): + """ Return a dict of the query parameters for editing a tag. """ + if not isinstance(items, list): + items = [items] + + data = { + f'{tag}.locked': 1 if locked else 0 + } + + if remove: + tagname = f'{tag}[].tag.tag-' + data[tagname] = ','.join(quote(str(t)) for t in items) + else: + for i, item in enumerate(items): + tagname = f'{str(tag)}[{i}].tag.tag' + data[tagname] = item + + return data + + +class CollectionMixin(EditTagsMixin): + """ Mixin for Plex objects that can have collections. """ + + def addCollection(self, collections, locked=True): + """ Add a collection tag(s). + + Parameters: + collections (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editTags('collection', collections, locked=locked) + + def removeCollection(self, collections, locked=True): + """ Remove a collection tag(s). + + Parameters: + collections (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editTags('collection', collections, locked=locked, remove=True) + + +class CountryMixin(EditTagsMixin): + """ Mixin for Plex objects that can have countries. """ + + def addCountry(self, countries, locked=True): + """ Add a country tag(s). + + Parameters: + countries (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editTags('country', countries, locked=locked) + + def removeCountry(self, countries, locked=True): + """ Remove a country tag(s). + + Parameters: + countries (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editTags('country', countries, locked=locked, remove=True) + + +class DirectorMixin(EditTagsMixin): + """ Mixin for Plex objects that can have directors. """ + + def addDirector(self, directors, locked=True): + """ Add a director tag(s). + + Parameters: + directors (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editTags('director', directors, locked=locked) + + def removeDirector(self, directors, locked=True): + """ Remove a director tag(s). + + Parameters: + directors (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editTags('director', directors, locked=locked, remove=True) + + +class GenreMixin(EditTagsMixin): + """ Mixin for Plex objects that can have genres. """ + + def addGenre(self, genres, locked=True): + """ Add a genre tag(s). + + Parameters: + genres (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editTags('genre', genres, locked=locked) + + def removeGenre(self, genres, locked=True): + """ Remove a genre tag(s). + + Parameters: + genres (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editTags('genre', genres, locked=locked, remove=True) + + +class LabelMixin(EditTagsMixin): + """ Mixin for Plex objects that can have labels. """ + + def addLabel(self, labels, locked=True): + """ Add a label tag(s). + + Parameters: + labels (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editTags('label', labels, locked=locked) + + def removeLabel(self, labels, locked=True): + """ Remove a label tag(s). + + Parameters: + labels (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editTags('label', labels, locked=locked, remove=True) + + +class MoodMixin(EditTagsMixin): + """ Mixin for Plex objects that can have moods. """ + + def addMood(self, moods, locked=True): + """ Add a mood tag(s). + + Parameters: + moods (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editTags('mood', moods, locked=locked) + + def removeMood(self, moods, locked=True): + """ Remove a mood tag(s). + + Parameters: + moods (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editTags('mood', moods, locked=locked, remove=True) + + +class ProducerMixin(EditTagsMixin): + """ Mixin for Plex objects that can have producers. """ + + def addProducer(self, producers, locked=True): + """ Add a producer tag(s). + + Parameters: + producers (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editTags('producer', producers, locked=locked) + + def removeProducer(self, producers, locked=True): + """ Remove a producer tag(s). + + Parameters: + producers (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editTags('producer', producers, locked=locked, remove=True) + + +class SimilarArtistMixin(EditTagsMixin): + """ Mixin for Plex objects that can have similar artists. """ + + def addSimilarArtist(self, artists, locked=True): + """ Add a similar artist tag(s). + + Parameters: + artists (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editTags('similar', artists, locked=locked) + + def removeSimilarArtist(self, artists, locked=True): + """ Remove a similar artist tag(s). + + Parameters: + artists (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editTags('similar', artists, locked=locked, remove=True) + + +class StyleMixin(EditTagsMixin): + """ Mixin for Plex objects that can have styles. """ + + def addStyle(self, styles, locked=True): + """ Add a style tag(s). + + Parameters: + styles (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editTags('style', styles, locked=locked) + + def removeStyle(self, styles, locked=True): + """ Remove a style tag(s). + + Parameters: + styles (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editTags('style', styles, locked=locked, remove=True) + + +class TagMixin(EditTagsMixin): + """ Mixin for Plex objects that can have tags. """ + + def addTag(self, tags, locked=True): + """ Add a tag(s). + + Parameters: + tags (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editTags('tag', tags, locked=locked) + + def removeTag(self, tags, locked=True): + """ Remove a tag(s). + + Parameters: + tags (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editTags('tag', tags, locked=locked, remove=True) + + +class WriterMixin(EditTagsMixin): + """ Mixin for Plex objects that can have writers. """ + + def addWriter(self, writers, locked=True): + """ Add a writer tag(s). + + Parameters: + writers (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editTags('writer', writers, locked=locked) + + def removeWriter(self, writers, locked=True): + """ Remove a writer tag(s). + + Parameters: + writers (List<str> or List<:class:`~plexapi.media.MediaTag`>): List of tags. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editTags('writer', writers, locked=locked, remove=True) + + +class WatchlistMixin: + """ Mixin for Plex objects that can be added to a user's watchlist. """ + + def onWatchlist(self, account=None): + """ Returns True if the item is on the user's watchlist. + Also see :func:`~plexapi.myplex.MyPlexAccount.onWatchlist`. + + Parameters: + account (:class:`~plexapi.myplex.MyPlexAccount`, optional): Account to check item on the watchlist. + Note: This is required if you are not connected to a Plex server instance using the admin account. + """ + try: + account = account or self._server.myPlexAccount() + except AttributeError: + account = self._server + return account.onWatchlist(self) + + def addToWatchlist(self, account=None): + """ Add this item to the specified user's watchlist. + Also see :func:`~plexapi.myplex.MyPlexAccount.addToWatchlist`. + + Parameters: + account (:class:`~plexapi.myplex.MyPlexAccount`, optional): Account to add item to the watchlist. + Note: This is required if you are not connected to a Plex server instance using the admin account. + """ + try: + account = account or self._server.myPlexAccount() + except AttributeError: + account = self._server + account.addToWatchlist(self) + return self + + def removeFromWatchlist(self, account=None): + """ Remove this item from the specified user's watchlist. + Also see :func:`~plexapi.myplex.MyPlexAccount.removeFromWatchlist`. + + Parameters: + account (:class:`~plexapi.myplex.MyPlexAccount`, optional): Account to remove item from the watchlist. + Note: This is required if you are not connected to a Plex server instance using the admin account. + """ + try: + account = account or self._server.myPlexAccount() + except AttributeError: + account = self._server + account.removeFromWatchlist(self) + return self + + def streamingServices(self, account=None): + """ Return a list of :class:`~plexapi.media.Availability` + objects for the available streaming services for this item. + + Parameters: + account (:class:`~plexapi.myplex.MyPlexAccount`, optional): Account used to retrieve availability. + Note: This is required if you are not connected to a Plex server instance using the admin account. + """ + try: + account = account or self._server.myPlexAccount() + except AttributeError: + account = self._server + ratingKey = self.guid.rsplit('/', 1)[-1] + data = account.query(f"{account.METADATA}/library/metadata/{ratingKey}/availabilities") + return self.findItems(data) + + +class MovieEditMixins( + ArtLockMixin, PosterLockMixin, ThemeLockMixin, + AddedAtMixin, AudienceRatingMixin, ContentRatingMixin, CriticRatingMixin, EditionTitleMixin, + OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, + StudioMixin, SummaryMixin, TaglineMixin, TitleMixin, UserRatingMixin, + CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin +): + pass + + +class ShowEditMixins( + ArtLockMixin, PosterLockMixin, ThemeLockMixin, + AddedAtMixin, AudienceRatingMixin, ContentRatingMixin, CriticRatingMixin, + OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, StudioMixin, + SummaryMixin, TaglineMixin, TitleMixin, UserRatingMixin, + CollectionMixin, GenreMixin, LabelMixin, +): + pass + + +class SeasonEditMixins( + ArtLockMixin, PosterLockMixin, ThemeLockMixin, + AddedAtMixin, AudienceRatingMixin, CriticRatingMixin, + SummaryMixin, TitleMixin, UserRatingMixin, + CollectionMixin, LabelMixin +): + pass + + +class EpisodeEditMixins( + ArtLockMixin, PosterLockMixin, ThemeLockMixin, + AddedAtMixin, AudienceRatingMixin, ContentRatingMixin, CriticRatingMixin, + OriginallyAvailableMixin, SortTitleMixin, SummaryMixin, TitleMixin, UserRatingMixin, + CollectionMixin, DirectorMixin, LabelMixin, WriterMixin +): + pass + + +class ArtistEditMixins( + ArtLockMixin, PosterLockMixin, ThemeLockMixin, + AddedAtMixin, AudienceRatingMixin, CriticRatingMixin, + SortTitleMixin, SummaryMixin, TitleMixin, UserRatingMixin, + CollectionMixin, CountryMixin, GenreMixin, LabelMixin, MoodMixin, SimilarArtistMixin, StyleMixin +): + pass + + +class AlbumEditMixins( + ArtLockMixin, PosterLockMixin, ThemeLockMixin, + AddedAtMixin, AudienceRatingMixin, CriticRatingMixin, + OriginallyAvailableMixin, SortTitleMixin, StudioMixin, SummaryMixin, TitleMixin, UserRatingMixin, + CollectionMixin, GenreMixin, LabelMixin, MoodMixin, StyleMixin +): + pass + + +class TrackEditMixins( + ArtLockMixin, PosterLockMixin, ThemeLockMixin, + AddedAtMixin, AudienceRatingMixin, CriticRatingMixin, + TitleMixin, TrackArtistMixin, TrackNumberMixin, TrackDiscNumberMixin, UserRatingMixin, + CollectionMixin, GenreMixin, LabelMixin, MoodMixin +): + pass + + +class PhotoalbumEditMixins( + ArtLockMixin, PosterLockMixin, + AddedAtMixin, SortTitleMixin, SummaryMixin, TitleMixin, UserRatingMixin +): + pass + + +class PhotoEditMixins( + ArtLockMixin, PosterLockMixin, + AddedAtMixin, PhotoCapturedTimeMixin, SortTitleMixin, SummaryMixin, TitleMixin, UserRatingMixin, + TagMixin +): + pass + + +class CollectionEditMixins( + ArtLockMixin, PosterLockMixin, ThemeLockMixin, + AddedAtMixin, AudienceRatingMixin, ContentRatingMixin, CriticRatingMixin, + SortTitleMixin, SummaryMixin, TitleMixin, UserRatingMixin, + LabelMixin +): + pass + + +class PlaylistEditMixins( + ArtLockMixin, PosterLockMixin, + SortTitleMixin, SummaryMixin, TitleMixin +): + pass diff --git a/libs/plexapi/myplex.py b/libs/plexapi/myplex.py new file mode 100644 index 000000000..abcc9417e --- /dev/null +++ b/libs/plexapi/myplex.py @@ -0,0 +1,2044 @@ +# -*- coding: utf-8 -*- +import copy +import html +import threading +import time +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit +from xml.etree import ElementTree + +import requests + +from plexapi import (BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_ENABLE_FAST_CONNECT, X_PLEX_IDENTIFIER, + log, logfilter, utils) +from plexapi.base import PlexObject +from plexapi.client import PlexClient +from plexapi.exceptions import BadRequest, NotFound, Unauthorized, TwoFactorRequired +from plexapi.library import LibrarySection +from plexapi.server import PlexServer +from plexapi.sonos import PlexSonosClient +from plexapi.sync import SyncItem, SyncList +from requests.status_codes import _codes as codes + + +class MyPlexAccount(PlexObject): + """ MyPlex account and profile information. This object represents the data found Account on + the myplex.tv servers at the url https://plex.tv/api/v2/user. You may create this object + directly by passing in your username & password (or token). There is also a convenience + method provided at :class:`~plexapi.server.PlexServer.myPlexAccount()` which will create + and return this object. + + Parameters: + username (str): Plex login username if not using a token. + password (str): Plex login password if not using a token. + token (str): Plex authentication token instead of username and password. + session (requests.Session, optional): Use your own session object if you want to + cache the http responses from PMS. + timeout (int): timeout in seconds on initial connect to myplex (default config.TIMEOUT). + code (str): Two-factor authentication code to use when logging in with username and password. + remember (bool): Remember the account token for 14 days (Default True). + + Attributes: + key (str): 'https://plex.tv/api/v2/user' + adsConsent (str): Unknown. + adsConsentReminderAt (str): Unknown. + adsConsentSetAt (str): Unknown. + anonymous (str): Unknown. + authToken (str): The account token. + backupCodesCreated (bool): If the two-factor authentication backup codes have been created. + confirmed (bool): If the account has been confirmed. + country (str): The account country. + email (str): The account email address. + emailOnlyAuth (bool): If login with email only is enabled. + experimentalFeatures (bool): If experimental features are enabled. + friendlyName (str): Your account full name. + entitlements (List<str>): List of devices your allowed to use with this account. + guest (bool): If the account is a Plex Home guest user. + hasPassword (bool): If the account has a password. + home (bool): If the account is a Plex Home user. + homeAdmin (bool): If the account is the Plex Home admin. + homeSize (int): The number of accounts in the Plex Home. + id (int): The Plex account ID. + joinedAt (datetime): Date the account joined Plex. + locale (str): the account locale + mailingListActive (bool): If you are subscribed to the Plex newsletter. + mailingListStatus (str): Your current mailing list status. + maxHomeSize (int): The maximum number of accounts allowed in the Plex Home. + pin (str): The hashed Plex Home PIN. + profileAutoSelectAudio (bool): If the account has automatically select audio and subtitle tracks enabled. + profileDefaultAudioLanguage (str): The preferred audio language for the account. + profileDefaultSubtitleLanguage (str): The preferred subtitle language for the account. + profileAutoSelectSubtitle (int): The auto-select subtitle mode + (0 = Manually selected, 1 = Shown with foreign audio, 2 = Always enabled). + profileDefaultSubtitleAccessibility (int): The subtitles for the deaf or hard-of-hearing (SDH) searches mode + (0 = Prefer non-SDH subtitles, 1 = Prefer SDH subtitles, 2 = Only show SDH subtitles, + 3 = Only shown non-SDH subtitles). + profileDefaultSubtitleForced (int): The forced subtitles searches mode + (0 = Prefer non-forced subtitles, 1 = Prefer forced subtitles, 2 = Only show forced subtitles, + 3 = Only show non-forced subtitles). + protected (bool): If the account has a Plex Home PIN enabled. + rememberExpiresAt (datetime): Date the token expires. + restricted (bool): If the account is a Plex Home managed user. + roles: (List<str>) Lit of account roles. Plexpass membership listed here. + scrobbleTypes (List<int>): Unknown. + subscriptionActive (bool): If the account's Plex Pass subscription is active. + subscriptionDescription (str): Description of the Plex Pass subscription. + subscriptionFeatures: (List<str>) List of features allowed on your Plex Pass subscription. + subscriptionPaymentService (str): Payment service used for your Plex Pass subscription. + subscriptionPlan (str): Name of Plex Pass subscription plan. + subscriptionStatus (str): String representation of ``subscriptionActive``. + subscriptionSubscribedAt (datetime): Date the account subscribed to Plex Pass. + thumb (str): URL of the account thumbnail. + title (str): The title of the account (username or friendly name). + twoFactorEnabled (bool): If two-factor authentication is enabled. + username (str): The account username. + uuid (str): The account UUID. + """ + FRIENDINVITE = 'https://plex.tv/api/servers/{machineId}/shared_servers' # post with data + HOMEUSERS = 'https://plex.tv/api/home/users' + HOMEUSERCREATE = 'https://plex.tv/api/home/users?title={title}' # post with data + EXISTINGUSER = 'https://plex.tv/api/home/users?invitedEmail={username}' # post with data + FRIENDSERVERS = 'https://plex.tv/api/servers/{machineId}/shared_servers/{serverId}' # put with data + PLEXSERVERS = 'https://plex.tv/api/servers/{machineId}' # get + FRIENDUPDATE = 'https://plex.tv/api/v2/sharings/{userId}' # put with args, delete + HOMEUSER = 'https://plex.tv/api/home/users/{userId}' # delete, put + MANAGEDHOMEUSER = 'https://plex.tv/api/v2/home/users/restricted/{userId}' # put + SIGNIN = 'https://plex.tv/api/v2/users/signin' # post with auth + SIGNOUT = 'https://plex.tv/api/v2/users/signout' # delete + WEBHOOKS = 'https://plex.tv/api/v2/user/webhooks' # get, post with data + OPTOUTS = 'https://plex.tv/api/v2/user/{userUUID}/settings/opt_outs' # get + LINK = 'https://plex.tv/api/v2/pins/link' # put + VIEWSTATESYNC = 'https://plex.tv/api/v2/user/view_state_sync' # put + PING = 'https://plex.tv/api/v2/ping' + # Hub sections + VOD = 'https://vod.provider.plex.tv' # get + MUSIC = 'https://music.provider.plex.tv' # get + DISCOVER = 'https://discover.provider.plex.tv' + METADATA = 'https://metadata.provider.plex.tv' + key = 'https://plex.tv/api/v2/user' + + def __init__(self, username=None, password=None, token=None, session=None, timeout=None, code=None, remember=True): + self._token = logfilter.add_secret(token or CONFIG.get('auth.server_token')) + self._session = session or requests.Session() + self._timeout = timeout or TIMEOUT + self._sonos_cache = [] + self._sonos_cache_timestamp = 0 + data, initpath = self._signin(username, password, code, remember, timeout) + super(MyPlexAccount, self).__init__(self, data, initpath) + + def _signin(self, username, password, code, remember, timeout): + if self._token: + return self.query(self.key), self.key + payload = { + 'login': username or CONFIG.get('auth.myplex_username'), + 'password': password or CONFIG.get('auth.myplex_password'), + 'rememberMe': remember + } + if code: + payload['verificationCode'] = code + data = self.query(self.SIGNIN, method=self._session.post, data=payload, timeout=timeout) + return data, self.SIGNIN + + def signout(self): + """ Sign out of the Plex account. Invalidates the authentication token. """ + return self.query(self.SIGNOUT, method=self._session.delete) + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self._token = logfilter.add_secret(data.attrib.get('authToken')) + self._webhooks = [] + + self.adsConsent = data.attrib.get('adsConsent') + self.adsConsentReminderAt = data.attrib.get('adsConsentReminderAt') + self.adsConsentSetAt = data.attrib.get('adsConsentSetAt') + self.anonymous = data.attrib.get('anonymous') + self.authToken = self._token + self.backupCodesCreated = utils.cast(bool, data.attrib.get('backupCodesCreated')) + self.confirmed = utils.cast(bool, data.attrib.get('confirmed')) + self.country = data.attrib.get('country') + self.email = data.attrib.get('email') + self.emailOnlyAuth = utils.cast(bool, data.attrib.get('emailOnlyAuth')) + self.experimentalFeatures = utils.cast(bool, data.attrib.get('experimentalFeatures')) + self.friendlyName = data.attrib.get('friendlyName') + self.guest = utils.cast(bool, data.attrib.get('guest')) + self.hasPassword = utils.cast(bool, data.attrib.get('hasPassword')) + self.home = utils.cast(bool, data.attrib.get('home')) + self.homeAdmin = utils.cast(bool, data.attrib.get('homeAdmin')) + self.homeSize = utils.cast(int, data.attrib.get('homeSize')) + self.id = utils.cast(int, data.attrib.get('id')) + self.joinedAt = utils.toDatetime(data.attrib.get('joinedAt')) + self.locale = data.attrib.get('locale') + self.mailingListActive = utils.cast(bool, data.attrib.get('mailingListActive')) + self.mailingListStatus = data.attrib.get('mailingListStatus') + self.maxHomeSize = utils.cast(int, data.attrib.get('maxHomeSize')) + self.pin = data.attrib.get('pin') + self.protected = utils.cast(bool, data.attrib.get('protected')) + self.rememberExpiresAt = utils.toDatetime(data.attrib.get('rememberExpiresAt')) + self.restricted = utils.cast(bool, data.attrib.get('restricted')) + self.scrobbleTypes = [utils.cast(int, x) for x in data.attrib.get('scrobbleTypes').split(',')] + self.thumb = data.attrib.get('thumb') + self.title = data.attrib.get('title') + self.twoFactorEnabled = utils.cast(bool, data.attrib.get('twoFactorEnabled')) + self.username = data.attrib.get('username') + self.uuid = data.attrib.get('uuid') + + subscription = data.find('subscription') + self.subscriptionActive = utils.cast(bool, subscription.attrib.get('active')) + self.subscriptionDescription = data.attrib.get('subscriptionDescription') + self.subscriptionFeatures = self.listAttrs(subscription, 'id', rtag='features', etag='feature') + self.subscriptionPaymentService = subscription.attrib.get('paymentService') + self.subscriptionPlan = subscription.attrib.get('plan') + self.subscriptionStatus = subscription.attrib.get('status') + self.subscriptionSubscribedAt = utils.toDatetime( + subscription.attrib.get('subscribedAt') or None, '%Y-%m-%d %H:%M:%S %Z' + ) + + profile = data.find('profile') + self.profileAutoSelectAudio = utils.cast(bool, profile.attrib.get('autoSelectAudio')) + self.profileDefaultAudioLanguage = profile.attrib.get('defaultAudioLanguage') + self.profileDefaultSubtitleLanguage = profile.attrib.get('defaultSubtitleLanguage') + self.profileAutoSelectSubtitle = utils.cast(int, profile.attrib.get('autoSelectSubtitle')) + self.profileDefaultSubtitleAccessibility = utils.cast(int, profile.attrib.get('defaultSubtitleAccessibility')) + self.profileDefaultSubtitleForces = utils.cast(int, profile.attrib.get('defaultSubtitleForces')) + + self.entitlements = self.listAttrs(data, 'id', rtag='entitlements', etag='entitlement') + self.roles = self.listAttrs(data, 'id', rtag='roles', etag='role') + + self.services = None + + @property + def authenticationToken(self): + """ Returns the authentication token for the account. Alias for ``authToken``. """ + return self.authToken + + def _reload(self, key=None, **kwargs): + """ Perform the actual reload. """ + data = self.query(self.key) + self._loadData(data) + return self + + def _headers(self, **kwargs): + """ Returns dict containing base headers for all requests to the server. """ + headers = BASE_HEADERS.copy() + if self._token: + headers['X-Plex-Token'] = self._token + headers.update(kwargs) + return headers + + def query(self, url, method=None, headers=None, timeout=None, **kwargs): + method = method or self._session.get + timeout = timeout or self._timeout + log.debug('%s %s %s', method.__name__.upper(), url, kwargs.get('json', '')) + headers = self._headers(**headers or {}) + response = method(url, headers=headers, timeout=timeout, **kwargs) + if response.status_code not in (200, 201, 204): # pragma: no cover + codename = codes.get(response.status_code)[0] + errtext = response.text.replace('\n', ' ') + message = f'({response.status_code}) {codename}; {response.url} {errtext}' + if response.status_code == 401: + if "verification code" in response.text: + raise TwoFactorRequired(message) + raise Unauthorized(message) + elif response.status_code == 404: + raise NotFound(message) + elif response.status_code == 422 and "Invalid token" in response.text: + raise Unauthorized(message) + else: + raise BadRequest(message) + if 'application/json' in response.headers.get('Content-Type', ''): + return response.json() + elif 'text/plain' in response.headers.get('Content-Type', ''): + return response.text.strip() + data = utils.cleanXMLString(response.text).encode('utf8') + return ElementTree.fromstring(data) if data.strip() else None + + def ping(self): + """ Ping the Plex.tv API. + This will refresh the authentication token to prevent it from expiring. + """ + pong = self.query(self.PING) + if pong is not None: + return utils.cast(bool, pong.text) + return False + + def device(self, name=None, clientId=None): + """ Returns the :class:`~plexapi.myplex.MyPlexDevice` that matches the name specified. + + Parameters: + name (str): Name to match against. + clientId (str): clientIdentifier to match against. + """ + for device in self.devices(): + if (name and device.name.lower() == name.lower() or device.clientIdentifier == clientId): + return device + raise NotFound(f'Unable to find device {name}') + + def devices(self): + """ Returns a list of all :class:`~plexapi.myplex.MyPlexDevice` objects connected to the server. """ + data = self.query(MyPlexDevice.key) + return [MyPlexDevice(self, elem) for elem in data] + + def resource(self, name): + """ Returns the :class:`~plexapi.myplex.MyPlexResource` that matches the name specified. + + Parameters: + name (str): Name or machine identifier to match against. + """ + for resource in self.resources(): + if resource.name.lower() == name.lower() or resource.clientIdentifier == name: + return resource + raise NotFound(f'Unable to find resource {name}') + + def resources(self): + """ Returns a list of all :class:`~plexapi.myplex.MyPlexResource` objects connected to the server. """ + data = self.query(MyPlexResource.key) + return [MyPlexResource(self, elem) for elem in data] + + def sonos_speakers(self): + if 'companions_sonos' not in self.subscriptionFeatures: + return [] + + t = time.time() + if t - self._sonos_cache_timestamp > 5: + self._sonos_cache_timestamp = t + data = self.query('https://sonos.plex.tv/resources') + self._sonos_cache = [PlexSonosClient(self, elem) for elem in data] + + return self._sonos_cache + + def sonos_speaker(self, name): + return next((x for x in self.sonos_speakers() if x.title.split("+")[0].strip() == name), None) + + def sonos_speaker_by_id(self, identifier): + return next((x for x in self.sonos_speakers() if x.machineIdentifier.startswith(identifier)), None) + + def inviteFriend(self, user, server, sections=None, allowSync=False, allowCameraUpload=False, + allowChannels=False, filterMovies=None, filterTelevision=None, filterMusic=None): + """ Share library content with the specified user. + + Parameters: + user (:class:`~plexapi.myplex.MyPlexUser`): `MyPlexUser` object, username, or email + of the user to be added. + server (:class:`~plexapi.server.PlexServer`): `PlexServer` object, or machineIdentifier + containing the library sections to share. + sections (List<:class:`~plexapi.library.LibrarySection`>): List of `LibrarySection` objects, or names + to be shared (default None). `sections` must be defined in order to update shared libraries. + allowSync (Bool): Set True to allow user to sync content. + allowCameraUpload (Bool): Set True to allow user to upload photos. + allowChannels (Bool): Set True to allow user to utilize installed channels. + filterMovies (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of + values to be filtered. ex: `{'contentRating':['G'], 'label':['foo']}` + filterTelevision (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of + values to be filtered. ex: `{'contentRating':['G'], 'label':['foo']}` + filterMusic (Dict): Dict containing key 'label' set to a list of values to be filtered. + ex: `{'label':['foo']}` + """ + username = user.username if isinstance(user, MyPlexUser) else user + machineId = server.machineIdentifier if isinstance(server, PlexServer) else server + sectionIds = self._getSectionIds(machineId, sections) + params = { + 'server_id': machineId, + 'shared_server': {'library_section_ids': sectionIds, 'invited_email': username}, + 'sharing_settings': { + 'allowSync': ('1' if allowSync else '0'), + 'allowCameraUpload': ('1' if allowCameraUpload else '0'), + 'allowChannels': ('1' if allowChannels else '0'), + 'filterMovies': self._filterDictToStr(filterMovies or {}), + 'filterTelevision': self._filterDictToStr(filterTelevision or {}), + 'filterMusic': self._filterDictToStr(filterMusic or {}), + }, + } + headers = {'Content-Type': 'application/json'} + url = self.FRIENDINVITE.format(machineId=machineId) + return self.query(url, self._session.post, json=params, headers=headers) + + def createHomeUser(self, user, server, sections=None, allowSync=False, allowCameraUpload=False, + allowChannels=False, filterMovies=None, filterTelevision=None, filterMusic=None): + """ Share library content with the specified user. + + Parameters: + user (:class:`~plexapi.myplex.MyPlexUser`): `MyPlexUser` object, username, or email + of the user to be added. + server (:class:`~plexapi.server.PlexServer`): `PlexServer` object, or machineIdentifier + containing the library sections to share. + sections (List<:class:`~plexapi.library.LibrarySection`>): List of `LibrarySection` objects, or names + to be shared (default None). `sections` must be defined in order to update shared libraries. + allowSync (Bool): Set True to allow user to sync content. + allowCameraUpload (Bool): Set True to allow user to upload photos. + allowChannels (Bool): Set True to allow user to utilize installed channels. + filterMovies (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of + values to be filtered. ex: `{'contentRating':['G'], 'label':['foo']}` + filterTelevision (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of + values to be filtered. ex: `{'contentRating':['G'], 'label':['foo']}` + filterMusic (Dict): Dict containing key 'label' set to a list of values to be filtered. + ex: `{'label':['foo']}` + """ + machineId = server.machineIdentifier if isinstance(server, PlexServer) else server + sectionIds = self._getSectionIds(server, sections) + + headers = {'Content-Type': 'application/json'} + url = self.HOMEUSERCREATE.format(title=user) + # UserID needs to be created and referenced when adding sections + user_creation = self.query(url, self._session.post, headers=headers) + userIds = {} + for elem in user_creation.findall("."): + # Find userID + userIds['id'] = elem.attrib.get('id') + log.debug(userIds) + params = { + 'server_id': machineId, + 'shared_server': {'library_section_ids': sectionIds, 'invited_id': userIds['id']}, + 'sharing_settings': { + 'allowSync': ('1' if allowSync else '0'), + 'allowCameraUpload': ('1' if allowCameraUpload else '0'), + 'allowChannels': ('1' if allowChannels else '0'), + 'filterMovies': self._filterDictToStr(filterMovies or {}), + 'filterTelevision': self._filterDictToStr(filterTelevision or {}), + 'filterMusic': self._filterDictToStr(filterMusic or {}), + }, + } + url = self.FRIENDINVITE.format(machineId=machineId) + library_assignment = self.query(url, self._session.post, json=params, headers=headers) + return user_creation, library_assignment + + def createExistingUser(self, user, server, sections=None, allowSync=False, allowCameraUpload=False, + allowChannels=False, filterMovies=None, filterTelevision=None, filterMusic=None): + """ Share library content with the specified user. + + Parameters: + user (:class:`~plexapi.myplex.MyPlexUser`): `MyPlexUser` object, username, or email + of the user to be added. + server (:class:`~plexapi.server.PlexServer`): `PlexServer` object, or machineIdentifier + containing the library sections to share. + sections (List<:class:`~plexapi.library.LibrarySection`>): List of `LibrarySection` objects, or names + to be shared (default None). `sections` must be defined in order to update shared libraries. + allowSync (Bool): Set True to allow user to sync content. + allowCameraUpload (Bool): Set True to allow user to upload photos. + allowChannels (Bool): Set True to allow user to utilize installed channels. + filterMovies (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of + values to be filtered. ex: `{'contentRating':['G'], 'label':['foo']}` + filterTelevision (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of + values to be filtered. ex: `{'contentRating':['G'], 'label':['foo']}` + filterMusic (Dict): Dict containing key 'label' set to a list of values to be filtered. + ex: `{'label':['foo']}` + """ + headers = {'Content-Type': 'application/json'} + # If user already exists, carry over sections and settings. + if isinstance(user, MyPlexUser): + username = user.username + elif user in [_user.username for _user in self.users()]: + username = self.user(user).username + else: + # If user does not already exists, treat request as new request and include sections and settings. + newUser = user + url = self.EXISTINGUSER.format(username=newUser) + user_creation = self.query(url, self._session.post, headers=headers) + machineId = server.machineIdentifier if isinstance(server, PlexServer) else server + sectionIds = self._getSectionIds(server, sections) + params = { + 'server_id': machineId, + 'shared_server': {'library_section_ids': sectionIds, 'invited_email': newUser}, + 'sharing_settings': { + 'allowSync': ('1' if allowSync else '0'), + 'allowCameraUpload': ('1' if allowCameraUpload else '0'), + 'allowChannels': ('1' if allowChannels else '0'), + 'filterMovies': self._filterDictToStr(filterMovies or {}), + 'filterTelevision': self._filterDictToStr(filterTelevision or {}), + 'filterMusic': self._filterDictToStr(filterMusic or {}), + }, + } + url = self.FRIENDINVITE.format(machineId=machineId) + library_assignment = self.query(url, self._session.post, json=params, headers=headers) + return user_creation, library_assignment + + url = self.EXISTINGUSER.format(username=username) + return self.query(url, self._session.post, headers=headers) + + def removeFriend(self, user): + """ Remove the specified user from your friends. + + Parameters: + user (:class:`~plexapi.myplex.MyPlexUser` or str): :class:`~plexapi.myplex.MyPlexUser`, + username, or email of the user to be removed. + """ + user = user if isinstance(user, MyPlexUser) else self.user(user) + url = self.FRIENDUPDATE.format(userId=user.id) + return self.query(url, self._session.delete) + + def removeHomeUser(self, user): + """ Remove the specified user from your home users. + + Parameters: + user (:class:`~plexapi.myplex.MyPlexUser` or str): :class:`~plexapi.myplex.MyPlexUser`, + username, or email of the user to be removed. + """ + user = user if isinstance(user, MyPlexUser) else self.user(user) + url = self.HOMEUSER.format(userId=user.id) + return self.query(url, self._session.delete) + + def switchHomeUser(self, user, pin=None): + """ Returns a new :class:`~plexapi.myplex.MyPlexAccount` object switched to the given home user. + + Parameters: + user (:class:`~plexapi.myplex.MyPlexUser` or str): :class:`~plexapi.myplex.MyPlexUser`, + username, or email of the home user to switch to. + pin (str): PIN for the home user (required if the home user has a PIN set). + + Example: + + .. code-block:: python + + from plexapi.myplex import MyPlexAccount + # Login to a Plex Home account + account = MyPlexAccount('<USERNAME>', '<PASSWORD>') + # Switch to a different Plex Home user + userAccount = account.switchHomeUser('Username') + + """ + user = user if isinstance(user, MyPlexUser) else self.user(user) + url = f'{self.HOMEUSERS}/{user.id}/switch' + params = {} + if pin: + params['pin'] = pin + data = self.query(url, self._session.post, params=params) + userToken = data.attrib.get('authenticationToken') + return MyPlexAccount(token=userToken, session=self._session) + + def setPin(self, newPin, currentPin=None): + """ Set a new Plex Home PIN for the account. + + Parameters: + newPin (str): New PIN to set for the account. + currentPin (str): Current PIN for the account (required to change the PIN). + """ + url = self.HOMEUSER.format(userId=self.id) + params = {'pin': newPin} + if currentPin: + params['currentPin'] = currentPin + return self.query(url, self._session.put, params=params) + + def removePin(self, currentPin): + """ Remove the Plex Home PIN for the account. + + Parameters: + currentPin (str): Current PIN for the account (required to remove the PIN). + """ + return self.setPin('', currentPin) + + def setManagedUserPin(self, user, newPin): + """ Set a new Plex Home PIN for a managed home user. This must be done from the Plex Home admin account. + + Parameters: + user (:class:`~plexapi.myplex.MyPlexUser` or str): :class:`~plexapi.myplex.MyPlexUser` + or username of the managed home user. + newPin (str): New PIN to set for the managed home user. + """ + user = user if isinstance(user, MyPlexUser) else self.user(user) + url = self.MANAGEDHOMEUSER.format(userId=user.id) + params = {'pin': newPin} + return self.query(url, self._session.post, params=params) + + def removeManagedUserPin(self, user): + """ Remove the Plex Home PIN for a managed home user. This must be done from the Plex Home admin account. + + Parameters: + user (:class:`~plexapi.myplex.MyPlexUser` or str): :class:`~plexapi.myplex.MyPlexUser` + or username of the managed home user. + """ + user = user if isinstance(user, MyPlexUser) else self.user(user) + url = self.MANAGEDHOMEUSER.format(userId=user.id) + params = {'removePin': 1} + return self.query(url, self._session.post, params=params) + + def acceptInvite(self, user): + """ Accept a pending friend invite from the specified user. + + Parameters: + user (:class:`~plexapi.myplex.MyPlexInvite` or str): :class:`~plexapi.myplex.MyPlexInvite`, + username, or email of the friend invite to accept. + """ + invite = user if isinstance(user, MyPlexInvite) else self.pendingInvite(user, includeSent=False) + params = { + 'friend': int(invite.friend), + 'home': int(invite.home), + 'server': int(invite.server) + } + url = MyPlexInvite.REQUESTS + f'/{invite.id}' + utils.joinArgs(params) + return self.query(url, self._session.put) + + def cancelInvite(self, user): + """ Cancel a pending firend invite for the specified user. + + Parameters: + user (:class:`~plexapi.myplex.MyPlexInvite` or str): :class:`~plexapi.myplex.MyPlexInvite`, + username, or email of the friend invite to cancel. + """ + invite = user if isinstance(user, MyPlexInvite) else self.pendingInvite(user, includeReceived=False) + params = { + 'friend': int(invite.friend), + 'home': int(invite.home), + 'server': int(invite.server) + } + url = MyPlexInvite.REQUESTED + f'/{invite.id}' + utils.joinArgs(params) + return self.query(url, self._session.delete) + + def updateFriend(self, user, server, sections=None, removeSections=False, allowSync=None, allowCameraUpload=None, + allowChannels=None, filterMovies=None, filterTelevision=None, filterMusic=None): + """ Update the specified user's share settings. + + Parameters: + user (:class:`~plexapi.myplex.MyPlexUser`): `MyPlexUser` object, username, or email + of the user to be updated. + server (:class:`~plexapi.server.PlexServer`): `PlexServer` object, or machineIdentifier + containing the library sections to share. + sections (List<:class:`~plexapi.library.LibrarySection`>): List of `LibrarySection` objects, or names + to be shared (default None). `sections` must be defined in order to update shared libraries. + removeSections (Bool): Set True to remove all shares. Supersedes sections. + allowSync (Bool): Set True to allow user to sync content. + allowCameraUpload (Bool): Set True to allow user to upload photos. + allowChannels (Bool): Set True to allow user to utilize installed channels. + filterMovies (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of + values to be filtered. ex: `{'contentRating':['G'], 'label':['foo']}` + filterTelevision (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of + values to be filtered. ex: `{'contentRating':['G'], 'label':['foo']}` + filterMusic (Dict): Dict containing key 'label' set to a list of values to be filtered. + ex: `{'label':['foo']}` + """ + # Update friend servers + response_filters = '' + response_servers = '' + user = user if isinstance(user, MyPlexUser) else self.user(user) + machineId = server.machineIdentifier if isinstance(server, PlexServer) else server + sectionIds = self._getSectionIds(machineId, sections) + headers = {'Content-Type': 'application/json'} + # Determine whether user has access to the shared server. + user_servers = [s for s in user.servers if s.machineIdentifier == machineId] + if user_servers and sectionIds: + serverId = user_servers[0].id + params = {'server_id': machineId, 'shared_server': {'library_section_ids': sectionIds}} + url = self.FRIENDSERVERS.format(machineId=machineId, serverId=serverId) + else: + params = {'server_id': machineId, + 'shared_server': {'library_section_ids': sectionIds, 'invited_id': user.id}} + url = self.FRIENDINVITE.format(machineId=machineId) + # Remove share sections, add shares to user without shares, or update shares + if not user_servers or sectionIds: + if removeSections is True: + response_servers = self.query(url, self._session.delete, json=params, headers=headers) + elif 'invited_id' in params.get('shared_server', ''): + response_servers = self.query(url, self._session.post, json=params, headers=headers) + else: + response_servers = self.query(url, self._session.put, json=params, headers=headers) + else: + log.warning('Section name, number of section object is required changing library sections') + # Update friend filters + url = self.FRIENDUPDATE.format(userId=user.id) + params = {} + if isinstance(allowSync, bool): + params['allowSync'] = '1' if allowSync else '0' + if isinstance(allowCameraUpload, bool): + params['allowCameraUpload'] = '1' if allowCameraUpload else '0' + if isinstance(allowChannels, bool): + params['allowChannels'] = '1' if allowChannels else '0' + if isinstance(filterMovies, dict): + params['filterMovies'] = self._filterDictToStr(filterMovies or {}) # '1' if allowChannels else '0' + if isinstance(filterTelevision, dict): + params['filterTelevision'] = self._filterDictToStr(filterTelevision or {}) + if isinstance(allowChannels, dict): + params['filterMusic'] = self._filterDictToStr(filterMusic or {}) + if params: + url += utils.joinArgs(params) + response_filters = self.query(url, self._session.put) + return response_servers, response_filters + + def user(self, username): + """ Returns the :class:`~plexapi.myplex.MyPlexUser` that matches the specified username or email. + + Parameters: + username (str): Username, email or id of the user to return. + """ + username = str(username) + for user in self.users(): + # Home users don't have email, username etc. + if username.lower() == user.title.lower(): + return user + + elif (user.username and user.email and user.id and username.lower() in + (user.username.lower(), user.email.lower(), str(user.id))): + return user + + raise NotFound(f'Unable to find user {username}') + + def users(self): + """ Returns a list of all :class:`~plexapi.myplex.MyPlexUser` objects connected to your account. + """ + elem = self.query(MyPlexUser.key) + return self.findItems(elem, cls=MyPlexUser) + + def pendingInvite(self, username, includeSent=True, includeReceived=True): + """ Returns the :class:`~plexapi.myplex.MyPlexInvite` that matches the specified username or email. + Note: This can be a pending invite sent from your account or received to your account. + + Parameters: + username (str): Username, email or id of the user to return. + includeSent (bool): True to include sent invites. + includeReceived (bool): True to include received invites. + """ + username = str(username) + for invite in self.pendingInvites(includeSent, includeReceived): + if (invite.username and invite.email and invite.id and username.lower() in + (invite.username.lower(), invite.email.lower(), str(invite.id))): + return invite + + raise NotFound(f'Unable to find invite {username}') + + def pendingInvites(self, includeSent=True, includeReceived=True): + """ Returns a list of all :class:`~plexapi.myplex.MyPlexInvite` objects connected to your account. + Note: This includes all pending invites sent from your account and received to your account. + + Parameters: + includeSent (bool): True to include sent invites. + includeReceived (bool): True to include received invites. + """ + invites = [] + if includeSent: + elem = self.query(MyPlexInvite.REQUESTED) + invites += self.findItems(elem, cls=MyPlexInvite) + if includeReceived: + elem = self.query(MyPlexInvite.REQUESTS) + invites += self.findItems(elem, cls=MyPlexInvite) + return invites + + def _getSectionIds(self, server, sections): + """ Converts a list of section objects or names to sectionIds needed for library sharing. """ + if not sections: return [] + # Get a list of all section ids for looking up each section. + allSectionIds = {} + machineIdentifier = server.machineIdentifier if isinstance(server, PlexServer) else server + url = self.PLEXSERVERS.format(machineId=machineIdentifier) + data = self.query(url, self._session.get) + for elem in data[0]: + _id = utils.cast(int, elem.attrib.get('id')) + _key = utils.cast(int, elem.attrib.get('key')) + _title = elem.attrib.get('title', '').lower() + allSectionIds[_id] = _id + allSectionIds[_key] = _id + allSectionIds[_title] = _id + log.debug(allSectionIds) + # Convert passed in section items to section ids from above lookup + sectionIds = [] + for section in sections: + sectionKey = section.key if isinstance(section, LibrarySection) else section.lower() + sectionIds.append(allSectionIds[sectionKey]) + return sectionIds + + def _filterDictToStr(self, filterDict): + """ Converts friend filters to a string representation for transport. """ + values = [] + for key, vals in filterDict.items(): + if key not in ('contentRating', 'label', 'contentRating!', 'label!'): + raise BadRequest(f'Unknown filter key: {key}') + values.append(f"{key}={'%2C'.join(vals)}") + return '|'.join(values) + + def addWebhook(self, url): + # copy _webhooks and append url + urls = self._webhooks[:] + [url] + return self.setWebhooks(urls) + + def deleteWebhook(self, url): + urls = copy.copy(self._webhooks) + if url not in urls: + raise BadRequest(f'Webhook does not exist: {url}') + urls.remove(url) + return self.setWebhooks(urls) + + def setWebhooks(self, urls): + log.info('Setting webhooks: %s', urls) + data = {'urls[]': urls} if len(urls) else {'urls': ''} + data = self.query(self.WEBHOOKS, self._session.post, data=data) + self._webhooks = self.listAttrs(data, 'url', etag='webhook') + return self._webhooks + + def webhooks(self): + data = self.query(self.WEBHOOKS) + self._webhooks = self.listAttrs(data, 'url', etag='webhook') + return self._webhooks + + def optOut(self, playback=None, library=None): + """ Opt in or out of sharing stuff with plex. + See: https://www.plex.tv/about/privacy-legal/ + """ + params = {} + if playback is not None: + params['optOutPlayback'] = int(playback) + if library is not None: + params['optOutLibraryStats'] = int(library) + url = 'https://plex.tv/api/v2/user/privacy' + return self.query(url, method=self._session.put, data=params) + + def syncItems(self, client=None, clientId=None): + """ Returns an instance of :class:`~plexapi.sync.SyncList` for specified client. + + Parameters: + client (:class:`~plexapi.myplex.MyPlexDevice`): a client to query SyncItems for. + clientId (str): an identifier of a client to query SyncItems for. + + If both `client` and `clientId` provided the client would be preferred. + If neither `client` nor `clientId` provided the clientId would be set to current clients's identifier. + """ + if client: + clientId = client.clientIdentifier + elif clientId is None: + clientId = X_PLEX_IDENTIFIER + + data = self.query(SyncList.key.format(clientId=clientId)) + + return SyncList(self, data) + + def sync(self, sync_item, client=None, clientId=None): + """ Adds specified sync item for the client. It's always easier to use methods defined directly in the media + objects, e.g. :func:`~plexapi.video.Video.sync`, :func:`~plexapi.audio.Audio.sync`. + + Parameters: + client (:class:`~plexapi.myplex.MyPlexDevice`): a client for which you need to add SyncItem to. + clientId (str): an identifier of a client for which you need to add SyncItem to. + sync_item (:class:`~plexapi.sync.SyncItem`): prepared SyncItem object with all fields set. + + If both `client` and `clientId` provided the client would be preferred. + If neither `client` nor `clientId` provided the clientId would be set to current clients's identifier. + + Returns: + :class:`~plexapi.sync.SyncItem`: an instance of created syncItem. + + Raises: + :exc:`~plexapi.exceptions.BadRequest`: When client with provided clientId wasn't found. + :exc:`~plexapi.exceptions.BadRequest`: Provided client doesn't provides `sync-target`. + """ + if not client and not clientId: + clientId = X_PLEX_IDENTIFIER + + if not client: + for device in self.devices(): + if device.clientIdentifier == clientId: + client = device + break + + if not client: + raise BadRequest(f'Unable to find client by clientId={clientId}') + + if 'sync-target' not in client.provides: + raise BadRequest("Received client doesn't provides sync-target") + + params = { + 'SyncItem[title]': sync_item.title, + 'SyncItem[rootTitle]': sync_item.rootTitle, + 'SyncItem[metadataType]': sync_item.metadataType, + 'SyncItem[machineIdentifier]': sync_item.machineIdentifier, + 'SyncItem[contentType]': sync_item.contentType, + 'SyncItem[Policy][scope]': sync_item.policy.scope, + 'SyncItem[Policy][unwatched]': str(int(sync_item.policy.unwatched)), + 'SyncItem[Policy][value]': str(sync_item.policy.value if hasattr(sync_item.policy, 'value') else 0), + 'SyncItem[Location][uri]': sync_item.location, + 'SyncItem[MediaSettings][audioBoost]': str(sync_item.mediaSettings.audioBoost), + 'SyncItem[MediaSettings][maxVideoBitrate]': str(sync_item.mediaSettings.maxVideoBitrate), + 'SyncItem[MediaSettings][musicBitrate]': str(sync_item.mediaSettings.musicBitrate), + 'SyncItem[MediaSettings][photoQuality]': str(sync_item.mediaSettings.photoQuality), + 'SyncItem[MediaSettings][photoResolution]': sync_item.mediaSettings.photoResolution, + 'SyncItem[MediaSettings][subtitleSize]': str(sync_item.mediaSettings.subtitleSize), + 'SyncItem[MediaSettings][videoQuality]': str(sync_item.mediaSettings.videoQuality), + 'SyncItem[MediaSettings][videoResolution]': sync_item.mediaSettings.videoResolution, + } + + url = SyncList.key.format(clientId=client.clientIdentifier) + data = self.query(url, method=self._session.post, params=params) + + return SyncItem(self, data, None, clientIdentifier=client.clientIdentifier) + + def claimToken(self): + """ Returns a str, a new "claim-token", which you can use to register your new Plex Server instance to your + account. + See: https://hub.docker.com/r/plexinc/pms-docker/, https://www.plex.tv/claim/ + """ + response = self._session.get('https://plex.tv/api/claim/token.json', headers=self._headers(), timeout=TIMEOUT) + if response.status_code not in (200, 201, 204): # pragma: no cover + codename = codes.get(response.status_code)[0] + errtext = response.text.replace('\n', ' ') + raise BadRequest(f'({response.status_code}) {codename} {response.url}; {errtext}') + return response.json()['token'] + + def history(self, maxresults=None, mindate=None): + """ Get Play History for all library sections on all servers for the owner. + + Parameters: + maxresults (int): Only return the specified number of results (optional). + mindate (datetime): Min datetime to return results from. + """ + servers = [x for x in self.resources() if x.provides == 'server' and x.owned] + hist = [] + for server in servers: + conn = server.connect() + hist.extend(conn.history(maxresults=maxresults, mindate=mindate, accountID=1)) + return hist + + def onlineMediaSources(self): + """ Returns a list of user account Online Media Sources settings :class:`~plexapi.myplex.AccountOptOut` + """ + url = self.OPTOUTS.format(userUUID=self.uuid) + elem = self.query(url) + return self.findItems(elem, cls=AccountOptOut, etag='optOut') + + def videoOnDemand(self): + """ Returns a list of VOD Hub items :class:`~plexapi.library.Hub` + """ + data = self.query(f'{self.VOD}/hubs') + return self.findItems(data) + + def tidal(self): + """ Returns a list of tidal Hub items :class:`~plexapi.library.Hub` + """ + data = self.query(f'{self.MUSIC}/hubs') + return self.findItems(data) + + def watchlist(self, filter=None, sort=None, libtype=None, maxresults=None, **kwargs): + """ Returns a list of :class:`~plexapi.video.Movie` and :class:`~plexapi.video.Show` items in the user's watchlist. + Note: The objects returned are from Plex's online metadata. To get the matching item on a Plex server, + search for the media using the guid. + + Parameters: + filter (str, optional): 'available' or 'released' to only return items that are available or released, + otherwise return all items. + sort (str, optional): In the format ``field:dir``. Available fields are ``watchlistedAt`` (Added At), + ``titleSort`` (Title), ``originallyAvailableAt`` (Release Date), or ``rating`` (Critic Rating). + ``dir`` can be ``asc`` or ``desc``. + libtype (str, optional): 'movie' or 'show' to only return movies or shows, otherwise return all items. + maxresults (int, optional): Only return the specified number of results. + **kwargs (dict): Additional custom filters to apply to the search results. + + + Example: + + .. code-block:: python + + # Watchlist for released movies sorted by critic rating in descending order + watchlist = account.watchlist(filter='released', sort='rating:desc', libtype='movie') + item = watchlist[0] # First item in the watchlist + + # Search for the item on a Plex server + result = plex.library.search(guid=item.guid, libtype=item.type) + + """ + params = { + 'includeCollections': 1, + 'includeExternalMedia': 1 + } + + if not filter: + filter = 'all' + if sort: + params['sort'] = sort + if libtype: + params['type'] = utils.searchType(libtype) + + params.update(kwargs) + + key = f'{self.METADATA}/library/sections/watchlist/{filter}{utils.joinArgs(params)}' + return self._toOnlineMetadata(self.fetchItems(key, maxresults=maxresults), **kwargs) + + def onWatchlist(self, item): + """ Returns True if the item is on the user's watchlist. + + Parameters: + item (:class:`~plexapi.video.Movie` or :class:`~plexapi.video.Show`): Item to check + if it is on the user's watchlist. + """ + return bool(self.userState(item).watchlistedAt) + + def addToWatchlist(self, items): + """ Add media items to the user's watchlist + + Parameters: + items (List): List of :class:`~plexapi.video.Movie` or :class:`~plexapi.video.Show` + objects to be added to the watchlist. + + Raises: + :exc:`~plexapi.exceptions.BadRequest`: When trying to add invalid or existing + media to the watchlist. + """ + if not isinstance(items, list): + items = [items] + + for item in items: + if self.onWatchlist(item): + raise BadRequest(f'"{item.title}" is already on the watchlist') + ratingKey = item.guid.rsplit('/', 1)[-1] + self.query(f'{self.METADATA}/actions/addToWatchlist?ratingKey={ratingKey}', method=self._session.put) + return self + + def removeFromWatchlist(self, items): + """ Remove media items from the user's watchlist + + Parameters: + items (List): List of :class:`~plexapi.video.Movie` or :class:`~plexapi.video.Show` + objects to be added to the watchlist. + + Raises: + :exc:`~plexapi.exceptions.BadRequest`: When trying to remove invalid or non-existing + media to the watchlist. + """ + if not isinstance(items, list): + items = [items] + + for item in items: + if not self.onWatchlist(item): + raise BadRequest(f'"{item.title}" is not on the watchlist') + ratingKey = item.guid.rsplit('/', 1)[-1] + self.query(f'{self.METADATA}/actions/removeFromWatchlist?ratingKey={ratingKey}', method=self._session.put) + return self + + def userState(self, item): + """ Returns a :class:`~plexapi.myplex.UserState` object for the specified item. + + Parameters: + item (:class:`~plexapi.video.Movie` or :class:`~plexapi.video.Show`): Item to return the user state. + """ + ratingKey = item.guid.rsplit('/', 1)[-1] + data = self.query(f"{self.METADATA}/library/metadata/{ratingKey}/userState") + return self.findItem(data, cls=UserState) + + def isPlayed(self, item): + """ Return True if the item is played on Discover. + + Parameters: + item (:class:`~plexapi.video.Movie`, + :class:`~plexapi.video.Show`, :class:`~plexapi.video.Season` or + :class:`~plexapi.video.Episode`): Object from searchDiscover(). + Can be also result from Plex Movie or Plex TV Series agent. + """ + userState = self.userState(item) + return bool(userState.viewCount > 0) if userState.viewCount else False + + def markPlayed(self, item): + """ Mark the Plex object as played on Discover. + + Parameters: + item (:class:`~plexapi.video.Movie`, + :class:`~plexapi.video.Show`, :class:`~plexapi.video.Season` or + :class:`~plexapi.video.Episode`): Object from searchDiscover(). + Can be also result from Plex Movie or Plex TV Series agent. + """ + key = f'{self.METADATA}/actions/scrobble' + ratingKey = item.guid.rsplit('/', 1)[-1] + params = {'key': ratingKey, 'identifier': 'com.plexapp.plugins.library'} + self.query(key, params=params) + return self + + def markUnplayed(self, item): + """ Mark the Plex object as unplayed on Discover. + + Parameters: + item (:class:`~plexapi.video.Movie`, + :class:`~plexapi.video.Show`, :class:`~plexapi.video.Season` or + :class:`~plexapi.video.Episode`): Object from searchDiscover(). + Can be also result from Plex Movie or Plex TV Series agent. + """ + key = f'{self.METADATA}/actions/unscrobble' + ratingKey = item.guid.rsplit('/', 1)[-1] + params = {'key': ratingKey, 'identifier': 'com.plexapp.plugins.library'} + self.query(key, params=params) + return self + + def searchDiscover(self, query, limit=30, libtype=None, providers='discover'): + """ Search for movies and TV shows in Discover. + Returns a list of :class:`~plexapi.video.Movie` and :class:`~plexapi.video.Show` objects. + + Parameters: + query (str): Search query. + limit (int, optional): Limit to the specified number of results. Default 30. + libtype (str, optional): 'movie' or 'show' to only return movies or shows, otherwise return all items. + providers (str, optional): 'discover' for default behavior + or 'discover,PLEXAVOD' to also include the Plex ad-suported video service + or 'discover,PLEXAVOD,PLEXTVOD' to also include the Plex video rental service + """ + libtypes = {'movie': 'movies', 'show': 'tv'} + libtype = libtypes.get(libtype, 'movies,tv') + + headers = { + 'Accept': 'application/json' + } + params = { + 'query': query, + 'limit': limit, + 'searchTypes': libtype, + 'searchProviders': providers, + 'includeMetadata': 1 + } + + data = self.query(f'{self.DISCOVER}/library/search', headers=headers, params=params) + searchResults = data['MediaContainer'].get('SearchResults', []) + searchResult = next((s.get('SearchResult', []) for s in searchResults if s.get('id') == 'external'), []) + + results = [] + for result in searchResult: + metadata = result['Metadata'] + type = metadata['type'] + if type == 'movie': + tag = 'Video' + elif type == 'show': + tag = 'Directory' + else: + continue + attrs = ''.join(f'{k}="{html.escape(str(v))}" ' for k, v in metadata.items()) + xml = f'<{tag} {attrs}/>' + results.append(self._manuallyLoadXML(xml)) + + return self._toOnlineMetadata(results) + + @property + def viewStateSync(self): + """ Returns True or False if syncing of watch state and ratings + is enabled or disabled, respectively, for the account. + """ + headers = {'Accept': 'application/json'} + data = self.query(self.VIEWSTATESYNC, headers=headers) + return data.get('consent') + + def enableViewStateSync(self): + """ Enable syncing of watch state and ratings for the account. """ + self._updateViewStateSync(True) + + def disableViewStateSync(self): + """ Disable syncing of watch state and ratings for the account. """ + self._updateViewStateSync(False) + + def _updateViewStateSync(self, consent): + """ Enable or disable syncing of watch state and ratings for the account. + + Parameters: + consent (bool): True to enable, False to disable. + """ + params = {'consent': consent} + self.query(self.VIEWSTATESYNC, method=self._session.put, params=params) + + def link(self, pin): + """ Link a device to the account using a pin code. + + Parameters: + pin (str): The 4 digit link pin code. + """ + headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Plex-Product': 'Plex SSO' + } + data = {'code': pin} + self.query(self.LINK, self._session.put, headers=headers, data=data) + + def _toOnlineMetadata(self, objs, **kwargs): + """ Convert a list of media objects to online metadata objects. """ + # Temporary workaround to allow reloading and browsing of online media objects + server = PlexServer(self.METADATA, self._token, session=self._session) + + includeUserState = int(bool(kwargs.pop('includeUserState', True))) + + if not isinstance(objs, list): + objs = [objs] + + for obj in objs: + obj._server = server + + # Parse details key to modify query string + url = urlsplit(obj._details_key) + query = dict(parse_qsl(url.query)) + query['includeUserState'] = includeUserState + query.pop('includeFields', None) + obj._details_key = urlunsplit((url.scheme, url.netloc, url.path, urlencode(query), url.fragment)) + + return objs + + def publicIP(self): + """ Returns your public IP address. """ + return self.query('https://plex.tv/:/ip') + + def geoip(self, ip_address): + """ Returns a :class:`~plexapi.myplex.GeoLocation` object with geolocation information + for an IP address using Plex's GeoIP database. + + Parameters: + ip_address (str): IP address to lookup. + """ + params = {'ip_address': ip_address} + data = self.query('https://plex.tv/api/v2/geoip', params=params) + return GeoLocation(self, data) + + +class MyPlexUser(PlexObject): + """ This object represents non-signed in users such as friends and linked + accounts. NOTE: This should not be confused with the :class:`~plexapi.myplex.MyPlexAccount` + which is your specific account. The raw xml for the data presented here + can be found at: https://plex.tv/api/users/ + + Attributes: + TAG (str): 'User' + key (str): 'https://plex.tv/api/users/' + allowCameraUpload (bool): True if this user can upload images. + allowChannels (bool): True if this user has access to channels. + allowSync (bool): True if this user can sync. + email (str): User's email address (user@gmail.com). + filterAll (str): Unknown. + filterMovies (str): Unknown. + filterMusic (str): Unknown. + filterPhotos (str): Unknown. + filterTelevision (str): Unknown. + home (bool): Unknown. + id (int): User's Plex account ID. + protected (False): Unknown (possibly SSL enabled?). + recommendationsPlaylistId (str): Unknown. + restricted (str): Unknown. + servers (List<:class:`~plexapi.myplex.<MyPlexServerShare`>)): Servers shared with the user. + thumb (str): Link to the users avatar. + title (str): Seems to be an alias for username. + username (str): User's username. + """ + TAG = 'User' + key = 'https://plex.tv/api/users/' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.friend = self._initpath == self.key + self.allowCameraUpload = utils.cast(bool, data.attrib.get('allowCameraUpload')) + self.allowChannels = utils.cast(bool, data.attrib.get('allowChannels')) + self.allowSync = utils.cast(bool, data.attrib.get('allowSync')) + self.email = data.attrib.get('email') + self.filterAll = data.attrib.get('filterAll') + self.filterMovies = data.attrib.get('filterMovies') + self.filterMusic = data.attrib.get('filterMusic') + self.filterPhotos = data.attrib.get('filterPhotos') + self.filterTelevision = data.attrib.get('filterTelevision') + self.home = utils.cast(bool, data.attrib.get('home')) + self.id = utils.cast(int, data.attrib.get('id')) + self.protected = utils.cast(bool, data.attrib.get('protected')) + self.recommendationsPlaylistId = data.attrib.get('recommendationsPlaylistId') + self.restricted = data.attrib.get('restricted') + self.thumb = data.attrib.get('thumb') + self.title = data.attrib.get('title', '') + self.username = data.attrib.get('username', '') + self.servers = self.findItems(data, MyPlexServerShare) + for server in self.servers: + server.accountID = self.id + + def get_token(self, machineIdentifier): + try: + for item in self._server.query(self._server.FRIENDINVITE.format(machineId=machineIdentifier)): + if utils.cast(int, item.attrib.get('userID')) == self.id: + return item.attrib.get('accessToken') + except Exception: + log.exception('Failed to get access token for %s', self.title) + + def server(self, name): + """ Returns the :class:`~plexapi.myplex.MyPlexServerShare` that matches the name specified. + + Parameters: + name (str): Name of the server to return. + """ + for server in self.servers: + if name.lower() == server.name.lower(): + return server + + raise NotFound(f'Unable to find server {name}') + + def history(self, maxresults=None, mindate=None): + """ Get all Play History for a user in all shared servers. + Parameters: + maxresults (int): Only return the specified number of results (optional). + mindate (datetime): Min datetime to return results from. + """ + hist = [] + for server in self.servers: + hist.extend(server.history(maxresults=maxresults, mindate=mindate)) + return hist + + +class MyPlexInvite(PlexObject): + """ This object represents pending friend invites. + + Attributes: + TAG (str): 'Invite' + createdAt (datetime): Datetime the user was invited. + email (str): User's email address (user@gmail.com). + friend (bool): True or False if the user is invited as a friend. + friendlyName (str): The user's friendly name. + home (bool): True or False if the user is invited to a Plex Home. + id (int): User's Plex account ID. + server (bool): True or False if the user is invited to any servers. + servers (List<:class:`~plexapi.myplex.<MyPlexServerShare`>)): Servers shared with the user. + thumb (str): Link to the users avatar. + username (str): User's username. + """ + TAG = 'Invite' + REQUESTS = 'https://plex.tv/api/invites/requests' + REQUESTED = 'https://plex.tv/api/invites/requested' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.createdAt = utils.toDatetime(data.attrib.get('createdAt')) + self.email = data.attrib.get('email') + self.friend = utils.cast(bool, data.attrib.get('friend')) + self.friendlyName = data.attrib.get('friendlyName') + self.home = utils.cast(bool, data.attrib.get('home')) + self.id = utils.cast(int, data.attrib.get('id')) + self.server = utils.cast(bool, data.attrib.get('server')) + self.servers = self.findItems(data, MyPlexServerShare) + self.thumb = data.attrib.get('thumb') + self.username = data.attrib.get('username', '') + for server in self.servers: + server.accountID = self.id + + +class Section(PlexObject): + """ This refers to a shared section. The raw xml for the data presented here + can be found at: https://plex.tv/api/servers/{machineId}/shared_servers + + Attributes: + TAG (str): section + id (int): The shared section ID + key (int): The shared library section key + shared (bool): If this section is shared with the user + title (str): Title of the section + type (str): movie, tvshow, artist + + """ + TAG = 'Section' + + def _loadData(self, data): + self._data = data + self.id = utils.cast(int, data.attrib.get('id')) + self.key = utils.cast(int, data.attrib.get('key')) + self.shared = utils.cast(bool, data.attrib.get('shared', '0')) + self.title = data.attrib.get('title') + self.type = data.attrib.get('type') + self.sectionId = self.id # For backwards compatibility + self.sectionKey = self.key # For backwards compatibility + + def history(self, maxresults=None, mindate=None): + """ Get all Play History for a user for this section in this shared server. + Parameters: + maxresults (int): Only return the specified number of results (optional). + mindate (datetime): Min datetime to return results from. + """ + server = self._server._server.resource(self._server.name).connect() + return server.history(maxresults=maxresults, mindate=mindate, + accountID=self._server.accountID, librarySectionID=self.sectionKey) + + +class MyPlexServerShare(PlexObject): + """ Represents a single user's server reference. Used for library sharing. + + Attributes: + id (int): id for this share + serverId (str): what id plex uses for this. + machineIdentifier (str): The servers machineIdentifier + name (str): The servers name + lastSeenAt (datetime): Last connected to the server? + numLibraries (int): Total number of libraries + allLibraries (bool): True if all libraries is shared with this user. + owned (bool): 1 if the server is owned by the user + pending (bool): True if the invite is pending. + + """ + TAG = 'Server' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.id = utils.cast(int, data.attrib.get('id')) + self.accountID = utils.cast(int, data.attrib.get('accountID')) + self.serverId = utils.cast(int, data.attrib.get('serverId')) + self.machineIdentifier = data.attrib.get('machineIdentifier') + self.name = data.attrib.get('name') + self.lastSeenAt = utils.toDatetime(data.attrib.get('lastSeenAt')) + self.numLibraries = utils.cast(int, data.attrib.get('numLibraries')) + self.allLibraries = utils.cast(bool, data.attrib.get('allLibraries')) + self.owned = utils.cast(bool, data.attrib.get('owned')) + self.pending = utils.cast(bool, data.attrib.get('pending')) + + def section(self, name): + """ Returns the :class:`~plexapi.myplex.Section` that matches the name specified. + + Parameters: + name (str): Name of the section to return. + """ + for section in self.sections(): + if name.lower() == section.title.lower(): + return section + + raise NotFound(f'Unable to find section {name}') + + def sections(self): + """ Returns a list of all :class:`~plexapi.myplex.Section` objects shared with this user. + """ + url = MyPlexAccount.FRIENDSERVERS.format(machineId=self.machineIdentifier, serverId=self.id) + data = self._server.query(url) + return self.findItems(data, Section, rtag='SharedServer') + + def history(self, maxresults=9999999, mindate=None): + """ Get all Play History for a user in this shared server. + Parameters: + maxresults (int): Only return the specified number of results (optional). + mindate (datetime): Min datetime to return results from. + """ + server = self._server.resource(self.name).connect() + return server.history(maxresults=maxresults, mindate=mindate, accountID=self.accountID) + + +class MyPlexResource(PlexObject): + """ This object represents resources connected to your Plex server that can provide + content such as Plex Media Servers, iPhone or Android clients, etc. The raw xml + for the data presented here can be found at: + https://plex.tv/api/v2/resources?includeHttps=1&includeRelay=1 + + Attributes: + TAG (str): 'Device' + key (str): 'https://plex.tv/api/v2/resources?includeHttps=1&includeRelay=1' + accessToken (str): This resource's Plex access token. + clientIdentifier (str): Unique ID for this resource. + connections (list): List of :class:`~plexapi.myplex.ResourceConnection` objects + for this resource. + createdAt (datetime): Timestamp this resource first connected to your server. + device (str): Best guess on the type of device this is (PS, iPhone, Linux, etc). + dnsRebindingProtection (bool): True if the server had DNS rebinding protection. + home (bool): Unknown + httpsRequired (bool): True if the resource requires https. + lastSeenAt (datetime): Timestamp this resource last connected. + name (str): Descriptive name of this resource. + natLoopbackSupported (bool): True if the resource supports NAT loopback. + owned (bool): True if this resource is one of your own (you logged into it). + ownerId (int): ID of the user that owns this resource (shared resources only). + platform (str): OS the resource is running (Linux, Windows, Chrome, etc.) + platformVersion (str): Version of the platform. + presence (bool): True if the resource is online + product (str): Plex product (Plex Media Server, Plex for iOS, Plex Web, etc.) + productVersion (str): Version of the product. + provides (str): List of services this resource provides (client, server, + player, pubsub-player, etc.) + publicAddressMatches (bool): True if the public IP address matches the client's public IP address. + relay (bool): True if this resource has the Plex Relay enabled. + sourceTitle (str): Username of the user that owns this resource (shared resources only). + synced (bool): Unknown (possibly True if the resource has synced content?) + """ + TAG = 'resource' + key = 'https://plex.tv/api/v2/resources?includeHttps=1&includeRelay=1' + + # Default order to prioritize available resource connections + DEFAULT_LOCATION_ORDER = ['local', 'remote', 'relay'] + DEFAULT_SCHEME_ORDER = ['https', 'http'] + + def _loadData(self, data): + self._data = data + self.accessToken = logfilter.add_secret(data.attrib.get('accessToken')) + self.clientIdentifier = data.attrib.get('clientIdentifier') + self.connections = self.findItems(data, ResourceConnection, rtag='connections') + self.createdAt = utils.toDatetime(data.attrib.get('createdAt'), "%Y-%m-%dT%H:%M:%SZ") + self.device = data.attrib.get('device') + self.dnsRebindingProtection = utils.cast(bool, data.attrib.get('dnsRebindingProtection')) + self.home = utils.cast(bool, data.attrib.get('home')) + self.httpsRequired = utils.cast(bool, data.attrib.get('httpsRequired')) + self.lastSeenAt = utils.toDatetime(data.attrib.get('lastSeenAt'), "%Y-%m-%dT%H:%M:%SZ") + self.name = data.attrib.get('name') + self.natLoopbackSupported = utils.cast(bool, data.attrib.get('natLoopbackSupported')) + self.owned = utils.cast(bool, data.attrib.get('owned')) + self.ownerId = utils.cast(int, data.attrib.get('ownerId', 0)) + self.platform = data.attrib.get('platform') + self.platformVersion = data.attrib.get('platformVersion') + self.presence = utils.cast(bool, data.attrib.get('presence')) + self.product = data.attrib.get('product') + self.productVersion = data.attrib.get('productVersion') + self.provides = data.attrib.get('provides') + self.publicAddressMatches = utils.cast(bool, data.attrib.get('publicAddressMatches')) + self.relay = utils.cast(bool, data.attrib.get('relay')) + self.sourceTitle = data.attrib.get('sourceTitle') + self.synced = utils.cast(bool, data.attrib.get('synced')) + + def preferred_connections( + self, + ssl=None, + locations=None, + schemes=None, + ): + """ Returns a sorted list of the available connection addresses for this resource. + Often times there is more than one address specified for a server or client. + Default behavior will prioritize local connections before remote or relay and HTTPS before HTTP. + + Parameters: + ssl (bool, optional): Set True to only connect to HTTPS connections. Set False to + only connect to HTTP connections. Set None (default) to connect to any + HTTP or HTTPS connection. + """ + if locations is None: + locations = self.DEFAULT_LOCATION_ORDER[:] + if schemes is None: + schemes = self.DEFAULT_SCHEME_ORDER[:] + + connections_dict = {location: {scheme: [] for scheme in schemes} for location in locations} + for connection in self.connections: + # Only check non-local connections unless we own the resource + if self.owned or (not self.owned and not connection.local): + location = 'relay' if connection.relay else ('local' if connection.local else 'remote') + if location not in locations: + continue + if 'http' in schemes: + connections_dict[location]['http'].append(connection.httpuri) + if 'https' in schemes: + connections_dict[location]['https'].append(connection.uri) + if ssl is True: schemes.remove('http') + elif ssl is False: schemes.remove('https') + connections = [] + for location in locations: + for scheme in schemes: + connections.extend(connections_dict[location][scheme]) + return connections + + def connect( + self, + ssl=None, + timeout=None, + locations=None, + schemes=None, + ): + """ Returns a new :class:`~plexapi.server.PlexServer` or :class:`~plexapi.client.PlexClient` object. + Uses `MyPlexResource.preferred_connections()` to generate the priority order of connection addresses. + After trying to connect to all available addresses for this resource and + assuming at least one connection was successful, the PlexServer object is built and returned. + + Parameters: + ssl (bool, optional): Set True to only connect to HTTPS connections. Set False to + only connect to HTTP connections. Set None (default) to connect to any + HTTP or HTTPS connection. + timeout (int, optional): The timeout in seconds to attempt each connection. + + Raises: + :exc:`~plexapi.exceptions.NotFound`: When unable to connect to any addresses for this resource. + """ + if locations is None: + locations = self.DEFAULT_LOCATION_ORDER[:] + if schemes is None: + schemes = self.DEFAULT_SCHEME_ORDER[:] + + connections = self.preferred_connections(ssl, locations, schemes) + # Try connecting to all known resource connections in parallel, but + # only return the first server (in order) that provides a response. + cls = PlexServer if 'server' in self.provides else PlexClient + listargs = [[cls, url, self.accessToken, self._server._session, timeout] for url in connections] + log.debug('Testing %s resource connections..', len(listargs)) + results = utils.threaded(_connect, listargs) + return _chooseConnection('Resource', self.name, results) + + +class ResourceConnection(PlexObject): + """ Represents a Resource Connection object found within the + :class:`~plexapi.myplex.MyPlexResource` objects. + + Attributes: + TAG (str): 'Connection' + address (str): The connection IP address + httpuri (str): Full HTTP URL + ipv6 (bool): True if the address is IPv6 + local (bool): True if the address is local + port (int): The connection port + protocol (str): HTTP or HTTPS + relay (bool): True if the address uses the Plex Relay + uri (str): Full connetion URL + """ + TAG = 'connection' + + def _loadData(self, data): + self._data = data + self.address = data.attrib.get('address') + self.ipv6 = utils.cast(bool, data.attrib.get('IPv6')) + self.local = utils.cast(bool, data.attrib.get('local')) + self.port = utils.cast(int, data.attrib.get('port')) + self.protocol = data.attrib.get('protocol') + self.relay = utils.cast(bool, data.attrib.get('relay')) + self.uri = data.attrib.get('uri') + self.httpuri = f'http://{self.address}:{self.port}' + + +class MyPlexDevice(PlexObject): + """ This object represents resources connected to your Plex server that provide + playback ability from your Plex Server, iPhone or Android clients, Plex Web, + this API, etc. The raw xml for the data presented here can be found at: + https://plex.tv/devices.xml + + Attributes: + TAG (str): 'Device' + key (str): 'https://plex.tv/devices.xml' + clientIdentifier (str): Unique ID for this resource. + connections (list): List of connection URIs for the device. + device (str): Best guess on the type of device this is (Linux, iPad, AFTB, etc). + id (str): MyPlex ID of the device. + model (str): Model of the device (bueller, Linux, x86_64, etc.) + name (str): Hostname of the device. + platform (str): OS the resource is running (Linux, Windows, Chrome, etc.) + platformVersion (str): Version of the platform. + product (str): Plex product (Plex Media Server, Plex for iOS, Plex Web, etc.) + productVersion (string): Version of the product. + provides (str): List of services this resource provides (client, controller, + sync-target, player, pubsub-player). + publicAddress (str): Public IP address. + screenDensity (str): Unknown + screenResolution (str): Screen resolution (750x1334, 1242x2208, etc.) + token (str): Plex authentication token for the device. + vendor (str): Device vendor (ubuntu, etc). + version (str): Unknown (1, 2, 1.3.3.3148-b38628e, 1.3.15, etc.) + """ + TAG = 'Device' + key = 'https://plex.tv/devices.xml' + + def _loadData(self, data): + self._data = data + self.name = data.attrib.get('name') + self.publicAddress = data.attrib.get('publicAddress') + self.product = data.attrib.get('product') + self.productVersion = data.attrib.get('productVersion') + self.platform = data.attrib.get('platform') + self.platformVersion = data.attrib.get('platformVersion') + self.device = data.attrib.get('device') + self.model = data.attrib.get('model') + self.vendor = data.attrib.get('vendor') + self.provides = data.attrib.get('provides') + self.clientIdentifier = data.attrib.get('clientIdentifier') + self.version = data.attrib.get('version') + self.id = data.attrib.get('id') + self.token = logfilter.add_secret(data.attrib.get('token')) + self.screenResolution = data.attrib.get('screenResolution') + self.screenDensity = data.attrib.get('screenDensity') + self.createdAt = utils.toDatetime(data.attrib.get('createdAt')) + self.lastSeenAt = utils.toDatetime(data.attrib.get('lastSeenAt')) + self.connections = self.listAttrs(data, 'uri', etag='Connection') + + def connect(self, timeout=None): + """ Returns a new :class:`~plexapi.client.PlexClient` or :class:`~plexapi.server.PlexServer` + Sometimes there is more than one address specified for a server or client. + After trying to connect to all available addresses for this client and assuming + at least one connection was successful, the PlexClient object is built and returned. + + Raises: + :exc:`~plexapi.exceptions.NotFound`: When unable to connect to any addresses for this device. + """ + cls = PlexServer if 'server' in self.provides else PlexClient + listargs = [[cls, url, self.token, self._server._session, timeout] for url in self.connections] + log.debug('Testing %s device connections..', len(listargs)) + results = utils.threaded(_connect, listargs) + return _chooseConnection('Device', self.name, results) + + def delete(self): + """ Remove this device from your account. """ + key = f'https://plex.tv/devices/{self.id}.xml' + self._server.query(key, self._server._session.delete) + + def syncItems(self): + """ Returns an instance of :class:`~plexapi.sync.SyncList` for current device. + + Raises: + :exc:`~plexapi.exceptions.BadRequest`: when the device doesn't provides `sync-target`. + """ + if 'sync-target' not in self.provides: + raise BadRequest('Requested syncList for device which do not provides sync-target') + + return self._server.syncItems(client=self) + + +class MyPlexPinLogin: + """ + MyPlex PIN login class which supports getting the four character PIN which the user must + enter on https://plex.tv/link to authenticate the client and provide an access token to + create a :class:`~plexapi.myplex.MyPlexAccount` instance. + This helper class supports a polling, threaded and callback approach. + + - The polling approach expects the developer to periodically check if the PIN login was + successful using :func:`~plexapi.myplex.MyPlexPinLogin.checkLogin`. + - The threaded approach expects the developer to call + :func:`~plexapi.myplex.MyPlexPinLogin.run` and then at a later time call + :func:`~plexapi.myplex.MyPlexPinLogin.waitForLogin` to wait for and check the result. + - The callback approach is an extension of the threaded approach and expects the developer + to pass the `callback` parameter to the call to :func:`~plexapi.myplex.MyPlexPinLogin.run`. + The callback will be called when the thread waiting for the PIN login to succeed either + finishes or expires. The parameter passed to the callback is the received authentication + token or `None` if the login expired. + + Parameters: + session (requests.Session, optional): Use your own session object if you want to + cache the http responses from PMS + requestTimeout (int): timeout in seconds on initial connect to plex.tv (default config.TIMEOUT). + headers (dict): A dict of X-Plex headers to send with requests. + oauth (bool): True to use Plex OAuth instead of PIN login. + + Attributes: + PINS (str): 'https://plex.tv/api/v2/pins' + CHECKPINS (str): 'https://plex.tv/api/v2/pins/{pinid}' + POLLINTERVAL (int): 1 + finished (bool): Whether the pin login has finished or not. + expired (bool): Whether the pin login has expired or not. + token (str): Token retrieved through the pin login. + pin (str): Pin to use for the login on https://plex.tv/link. + """ + PINS = 'https://plex.tv/api/v2/pins' # get + CHECKPINS = 'https://plex.tv/api/v2/pins/{pinid}' # get + POLLINTERVAL = 1 + + def __init__(self, session=None, requestTimeout=None, headers=None, oauth=False): + super(MyPlexPinLogin, self).__init__() + self._session = session or requests.Session() + self._requestTimeout = requestTimeout or TIMEOUT + self.headers = headers + + self._oauth = oauth + self._loginTimeout = None + self._callback = None + self._thread = None + self._abort = False + self._id = None + self._code = None + self._getCode() + + self.finished = False + self.expired = False + self.token = None + + @property + def pin(self): + """ Return the 4 character PIN used for linking a device at + https://plex.tv/link. + """ + if self._oauth: + raise BadRequest('Cannot use PIN for Plex OAuth login') + return self._code + + def oauthUrl(self, forwardUrl=None): + """ Return the Plex OAuth url for login. + + Parameters: + forwardUrl (str, optional): The url to redirect the client to after login. + """ + if not self._oauth: + raise BadRequest('Must use "MyPlexPinLogin(oauth=True)" for Plex OAuth login.') + + headers = self._headers() + params = { + 'clientID': headers['X-Plex-Client-Identifier'], + 'context[device][product]': headers['X-Plex-Product'], + 'context[device][version]': headers['X-Plex-Version'], + 'context[device][platform]': headers['X-Plex-Platform'], + 'context[device][platformVersion]': headers['X-Plex-Platform-Version'], + 'context[device][device]': headers['X-Plex-Device'], + 'context[device][deviceName]': headers['X-Plex-Device-Name'], + 'code': self._code + } + if forwardUrl: + params['forwardUrl'] = forwardUrl + + return f'https://app.plex.tv/auth/#!?{urlencode(params)}' + + def run(self, callback=None, timeout=None): + """ Starts the thread which monitors the PIN login state. + + Parameters: + callback (Callable[str]): Callback called with the received authentication token (optional). + timeout (int): Timeout in seconds waiting for the PIN login to succeed (optional). + + Raises: + :class:`RuntimeError`: If the thread is already running. + :class:`RuntimeError`: If the PIN login for the current PIN has expired. + """ + if self._thread and not self._abort: + raise RuntimeError('MyPlexPinLogin thread is already running') + if self.expired: + raise RuntimeError('MyPlexPinLogin has expired') + + self._loginTimeout = timeout + self._callback = callback + self._abort = False + self.finished = False + self._thread = threading.Thread(target=self._pollLogin, name='plexapi.myplex.MyPlexPinLogin') + self._thread.start() + + def waitForLogin(self): + """ Waits for the PIN login to succeed or expire. + + Parameters: + callback (Callable[str]): Callback called with the received authentication token (optional). + timeout (int): Timeout in seconds waiting for the PIN login to succeed (optional). + + Returns: + `True` if the PIN login succeeded or `False` otherwise. + """ + if not self._thread or self._abort: + return False + + self._thread.join() + if self.expired or not self.token: + return False + + return True + + def stop(self): + """ Stops the thread monitoring the PIN login state. """ + if not self._thread or self._abort: + return + + self._abort = True + self._thread.join() + + def checkLogin(self): + """ Returns `True` if the PIN login has succeeded. """ + if self._thread: + return False + + try: + return self._checkLogin() + except Exception: + self.expired = True + self.finished = True + + return False + + def _getCode(self): + url = self.PINS + + if self._oauth: + params = {'strong': True} + else: + params = None + + response = self._query(url, self._session.post, params=params) + if response is None: + return None + + self._id = response.attrib.get('id') + self._code = response.attrib.get('code') + + return self._code + + def _checkLogin(self): + if not self._id: + return False + + if self.token: + return True + + url = self.CHECKPINS.format(pinid=self._id) + response = self._query(url) + if response is None: + return False + + token = response.attrib.get('authToken') + if not token: + return False + + self.token = token + self.finished = True + return True + + def _pollLogin(self): + try: + start = time.time() + while not self._abort and (not self._loginTimeout or (time.time() - start) < self._loginTimeout): + try: + result = self._checkLogin() + except Exception: + self.expired = True + break + + if result: + break + + time.sleep(self.POLLINTERVAL) + + if self.token and self._callback: + self._callback(self.token) + finally: + self.finished = True + + def _headers(self, **kwargs): + """ Returns dict containing base headers for all requests for pin login. """ + headers = BASE_HEADERS.copy() + if self.headers: + headers.update(self.headers) + headers.update(kwargs) + return headers + + def _query(self, url, method=None, headers=None, **kwargs): + method = method or self._session.get + log.debug('%s %s', method.__name__.upper(), url) + headers = headers or self._headers() + response = method(url, headers=headers, timeout=self._requestTimeout, **kwargs) + if not response.ok: # pragma: no cover + codename = codes.get(response.status_code)[0] + errtext = response.text.replace('\n', ' ') + raise BadRequest(f'({response.status_code}) {codename} {response.url}; {errtext}') + data = response.text.encode('utf8') + return ElementTree.fromstring(data) if data.strip() else None + + +def _connect(cls, url, token, session, timeout, results, i, job_is_done_event=None): + """ Connects to the specified cls with url and token. Stores the connection + information to results[i] in a threadsafe way. + + Arguments: + cls: the class which is responsible for establishing connection, basically it's + :class:`~plexapi.client.PlexClient` or :class:`~plexapi.server.PlexServer` + url (str): url which should be passed as `baseurl` argument to cls.__init__() + session (requests.Session): session which sould be passed as `session` argument to cls.__init() + token (str): authentication token which should be passed as `baseurl` argument to cls.__init__() + timeout (int): timeout which should be passed as `baseurl` argument to cls.__init__() + results (list): pre-filled list for results + i (int): index of current job, should be less than len(results) + job_is_done_event (:class:`~threading.Event`): is X_PLEX_ENABLE_FAST_CONNECT is True then the + event would be set as soon the connection is established + """ + starttime = time.time() + try: + device = cls(baseurl=url, token=token, session=session, timeout=timeout) + runtime = int(time.time() - starttime) + results[i] = (url, token, device, runtime) + if X_PLEX_ENABLE_FAST_CONNECT and job_is_done_event: + job_is_done_event.set() + except Exception as err: + runtime = int(time.time() - starttime) + log.error('%s: %s', url, err) + results[i] = (url, token, None, runtime) + + +def _chooseConnection(ctype, name, results): + """ Chooses the first (best) connection from the given _connect results. """ + # At this point we have a list of result tuples containing (url, token, PlexServer, runtime) + # or (url, token, None, runtime) in the case a connection could not be established. + for url, token, result, runtime in results: + okerr = 'OK' if result else 'ERR' + log.debug('%s connection %s (%ss): %s?X-Plex-Token=%s', ctype, okerr, runtime, url, token) + results = [r[2] for r in results if r and r[2] is not None] + if results: + log.debug('Connecting to %s: %s?X-Plex-Token=%s', ctype, results[0]._baseurl, results[0]._token) + return results[0] + raise NotFound(f'Unable to connect to {ctype.lower()}: {name}') + + +class AccountOptOut(PlexObject): + """ Represents a single AccountOptOut + 'https://plex.tv/api/v2/user/{userUUID}/settings/opt_outs' + + Attributes: + TAG (str): optOut + key (str): Online Media Source key + value (str): Online Media Source opt_in, opt_out, or opt_out_managed + """ + TAG = 'optOut' + CHOICES = {'opt_in', 'opt_out', 'opt_out_managed'} + + def _loadData(self, data): + self.key = data.attrib.get('key') + self.value = data.attrib.get('value') + + def _updateOptOut(self, option): + """ Sets the Online Media Sources option. + + Parameters: + option (str): see CHOICES + + Raises: + :exc:`~plexapi.exceptions.NotFound`: ``option`` str not found in CHOICES. + """ + if option not in self.CHOICES: + raise NotFound(f'{option} not found in available choices: {self.CHOICES}') + url = self._server.OPTOUTS.format(userUUID=self._server.uuid) + params = {'key': self.key, 'value': option} + self._server.query(url, method=self._server._session.post, params=params) + self.value = option # assume query successful and set the value to option + + def optIn(self): + """ Sets the Online Media Source to "Enabled". """ + self._updateOptOut('opt_in') + + def optOut(self): + """ Sets the Online Media Source to "Disabled". """ + self._updateOptOut('opt_out') + + def optOutManaged(self): + """ Sets the Online Media Source to "Disabled for Managed Users". + + Raises: + :exc:`~plexapi.exceptions.BadRequest`: When trying to opt out music. + """ + if self.key == 'tv.plex.provider.music': + raise BadRequest(f'{self.key} does not have the option to opt out managed users.') + self._updateOptOut('opt_out_managed') + + +class UserState(PlexObject): + """ Represents a single UserState + + Attributes: + TAG (str): UserState + lastViewedAt (datetime): Datetime the item was last played. + ratingKey (str): Unique key identifying the item. + type (str): The media type of the item. + viewCount (int): Count of times the item was played. + viewedLeafCount (int): Number of items marked as played in the show/season. + viewOffset (int): Time offset in milliseconds from the start of the content + viewState (bool): True or False if the item has been played. + watchlistedAt (datetime): Datetime the item was added to the watchlist. + """ + TAG = 'UserState' + + def __repr__(self): + return f'<{self.__class__.__name__}:{self.ratingKey}>' + + def _loadData(self, data): + self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt')) + self.ratingKey = data.attrib.get('ratingKey') + self.type = data.attrib.get('type') + self.viewCount = utils.cast(int, data.attrib.get('viewCount', 0)) + self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount', 0)) + self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0)) + self.viewState = data.attrib.get('viewState') == 'complete' + self.watchlistedAt = utils.toDatetime(data.attrib.get('watchlistedAt')) + + +class GeoLocation(PlexObject): + """ Represents a signle IP address geolocation + + Attributes: + TAG (str): location + city (str): City name + code (str): Country code + continentCode (str): Continent code + coordinates (Tuple<float>): Latitude and longitude + country (str): Country name + europeanUnionMember (bool): True if the country is a member of the European Union + inPrivacyRestrictedCountry (bool): True if the country is privacy restricted + postalCode (str): Postal code + subdivisions (str): Subdivision name + timezone (str): Timezone + """ + TAG = 'location' + + def _loadData(self, data): + self._data = data + self.city = data.attrib.get('city') + self.code = data.attrib.get('code') + self.continentCode = data.attrib.get('continent_code') + self.coordinates = tuple( + utils.cast(float, coord) for coord in (data.attrib.get('coordinates') or ',').split(',')) + self.country = data.attrib.get('country') + self.postalCode = data.attrib.get('postal_code') + self.subdivisions = data.attrib.get('subdivisions') + self.timezone = data.attrib.get('time_zone') + + europeanUnionMember = data.attrib.get('european_union_member') + self.europeanUnionMember = ( + False if europeanUnionMember == 'Unknown' else utils.cast(bool, europeanUnionMember)) + + inPrivacyRestrictedCountry = data.attrib.get('in_privacy_restricted_country') + self.inPrivacyRestrictedCountry = ( + False if inPrivacyRestrictedCountry == 'Unknown' else utils.cast(bool, inPrivacyRestrictedCountry)) diff --git a/libs/plexapi/photo.py b/libs/plexapi/photo.py new file mode 100644 index 000000000..4347f31a8 --- /dev/null +++ b/libs/plexapi/photo.py @@ -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) diff --git a/libs/plexapi/playlist.py b/libs/plexapi/playlist.py new file mode 100644 index 000000000..e2c4da635 --- /dev/null +++ b/libs/plexapi/playlist.py @@ -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') diff --git a/libs/plexapi/playqueue.py b/libs/plexapi/playqueue.py new file mode 100644 index 000000000..9835c0dd2 --- /dev/null +++ b/libs/plexapi/playqueue.py @@ -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 diff --git a/libs/plexapi/server.py b/libs/plexapi/server.py new file mode 100644 index 000000000..1a1cf8355 --- /dev/null +++ b/libs/plexapi/server.py @@ -0,0 +1,1306 @@ +# -*- coding: utf-8 -*- +import os +from functools import cached_property +from urllib.parse import urlencode +from xml.etree import ElementTree + +import requests + +from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, log, logfilter +from plexapi import utils +from plexapi.alert import AlertListener +from plexapi.base import PlexObject +from plexapi.client import PlexClient +from plexapi.collection import Collection +from plexapi.exceptions import BadRequest, NotFound, Unauthorized +from plexapi.library import Hub, Library, Path, File +from plexapi.media import Conversion, Optimized +from plexapi.playlist import Playlist +from plexapi.playqueue import PlayQueue +from plexapi.settings import Settings +from plexapi.utils import deprecated +from requests.status_codes import _codes as codes + +# Need these imports to populate utils.PLEXOBJECTS +from plexapi import audio as _audio # noqa: F401 +from plexapi import collection as _collection # noqa: F401 +from plexapi import media as _media # noqa: F401 +from plexapi import photo as _photo # noqa: F401 +from plexapi import playlist as _playlist # noqa: F401 +from plexapi import video as _video # noqa: F401 + + +class PlexServer(PlexObject): + """ This is the main entry point to interacting with a Plex server. It allows you to + list connected clients, browse your library sections and perform actions such as + emptying trash. If you do not know the auth token required to access your Plex + server, or simply want to access your server with your username and password, you + can also create an PlexServer instance from :class:`~plexapi.myplex.MyPlexAccount`. + + Parameters: + baseurl (str): Base url for to access the Plex Media Server (default: 'http://localhost:32400'). + token (str): Required Plex authentication token to access the server. + session (requests.Session, optional): Use your own session object if you want to + cache the http responses from the server. + timeout (int, optional): Timeout in seconds on initial connection to the server + (default config.TIMEOUT). + + Attributes: + allowCameraUpload (bool): True if server allows camera upload. + allowChannelAccess (bool): True if server allows channel access (iTunes?). + allowMediaDeletion (bool): True is server allows media to be deleted. + allowSharing (bool): True is server allows sharing. + allowSync (bool): True is server allows sync. + backgroundProcessing (bool): Unknown + certificate (bool): True if server has an HTTPS certificate. + companionProxy (bool): Unknown + diagnostics (bool): Unknown + eventStream (bool): Unknown + friendlyName (str): Human friendly name for this server. + hubSearch (bool): True if `Hub Search <https://www.plex.tv/blog + /seek-plex-shall-find-leveling-web-app/>`_ is enabled. I believe this + is enabled for everyone + machineIdentifier (str): Unique ID for this server (looks like an md5). + multiuser (bool): True if `multiusers <https://support.plex.tv/hc/en-us/articles + /200250367-Multi-User-Support>`_ are enabled. + myPlex (bool): Unknown (True if logged into myPlex?). + myPlexMappingState (str): Unknown (ex: mapped). + myPlexSigninState (str): Unknown (ex: ok). + myPlexSubscription (bool): True if you have a myPlex subscription. + myPlexUsername (str): Email address if signed into myPlex (user@example.com) + ownerFeatures (list): List of features allowed by the server owner. This may be based + on your PlexPass subscription. Features include: camera_upload, cloudsync, + content_filter, dvr, hardware_transcoding, home, lyrics, music_videos, pass, + photo_autotags, premium_music_metadata, session_bandwidth_restrictions, sync, + trailers, webhooks (and maybe more). + photoAutoTag (bool): True if photo `auto-tagging <https://support.plex.tv/hc/en-us + /articles/234976627-Auto-Tagging-of-Photos>`_ is enabled. + platform (str): Platform the server is hosted on (ex: Linux) + platformVersion (str): Platform version (ex: '6.1 (Build 7601)', '4.4.0-59-generic'). + pluginHost (bool): Unknown + readOnlyLibraries (bool): Unknown + requestParametersInCookie (bool): Unknown + streamingBrainVersion (bool): Current `Streaming Brain <https://www.plex.tv/blog + /mcstreamy-brain-take-world-two-easy-steps/>`_ version. + sync (bool): True if `syncing to a device <https://support.plex.tv/hc/en-us/articles + /201053678-Sync-Media-to-a-Device>`_ is enabled. + transcoderActiveVideoSessions (int): Number of active video transcoding sessions. + transcoderAudio (bool): True if audio transcoding audio is available. + transcoderLyrics (bool): True if audio transcoding lyrics is available. + transcoderPhoto (bool): True if audio transcoding photos is available. + transcoderSubtitles (bool): True if audio transcoding subtitles is available. + transcoderVideo (bool): True if audio transcoding video is available. + transcoderVideoBitrates (bool): List of video bitrates. + transcoderVideoQualities (bool): List of video qualities. + transcoderVideoResolutions (bool): List of video resolutions. + updatedAt (int): Datetime the server was updated. + updater (bool): Unknown + version (str): Current Plex version (ex: 1.3.2.3112-1751929) + voiceSearch (bool): True if voice search is enabled. (is this Google Voice search?) + _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. + """ + key = '/' + + def __init__(self, baseurl=None, token=None, session=None, timeout=None): + self._baseurl = baseurl or CONFIG.get('auth.server_baseurl', 'http://localhost:32400') + self._baseurl = self._baseurl.rstrip('/') + self._token = logfilter.add_secret(token or CONFIG.get('auth.server_token')) + self._showSecrets = CONFIG.get('log.show_secrets', '').lower() == 'true' + self._session = session or requests.Session() + self._timeout = timeout or TIMEOUT + self._myPlexAccount = None # cached myPlexAccount + self._systemAccounts = None # cached list of SystemAccount + self._systemDevices = None # cached list of SystemDevice + data = self.query(self.key, timeout=self._timeout) + super(PlexServer, self).__init__(self, data, self.key) + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.allowCameraUpload = utils.cast(bool, data.attrib.get('allowCameraUpload')) + self.allowChannelAccess = utils.cast(bool, data.attrib.get('allowChannelAccess')) + self.allowMediaDeletion = utils.cast(bool, data.attrib.get('allowMediaDeletion')) + self.allowSharing = utils.cast(bool, data.attrib.get('allowSharing')) + self.allowSync = utils.cast(bool, data.attrib.get('allowSync')) + self.backgroundProcessing = utils.cast(bool, data.attrib.get('backgroundProcessing')) + self.certificate = utils.cast(bool, data.attrib.get('certificate')) + self.companionProxy = utils.cast(bool, data.attrib.get('companionProxy')) + self.diagnostics = utils.toList(data.attrib.get('diagnostics')) + self.eventStream = utils.cast(bool, data.attrib.get('eventStream')) + self.friendlyName = data.attrib.get('friendlyName') + self.hubSearch = utils.cast(bool, data.attrib.get('hubSearch')) + self.machineIdentifier = data.attrib.get('machineIdentifier') + self.multiuser = utils.cast(bool, data.attrib.get('multiuser')) + self.myPlex = utils.cast(bool, data.attrib.get('myPlex')) + self.myPlexMappingState = data.attrib.get('myPlexMappingState') + self.myPlexSigninState = data.attrib.get('myPlexSigninState') + self.myPlexSubscription = utils.cast(bool, data.attrib.get('myPlexSubscription')) + self.myPlexUsername = data.attrib.get('myPlexUsername') + self.ownerFeatures = utils.toList(data.attrib.get('ownerFeatures')) + self.photoAutoTag = utils.cast(bool, data.attrib.get('photoAutoTag')) + self.platform = data.attrib.get('platform') + self.platformVersion = data.attrib.get('platformVersion') + self.pluginHost = utils.cast(bool, data.attrib.get('pluginHost')) + self.readOnlyLibraries = utils.cast(int, data.attrib.get('readOnlyLibraries')) + self.requestParametersInCookie = utils.cast(bool, data.attrib.get('requestParametersInCookie')) + self.streamingBrainVersion = data.attrib.get('streamingBrainVersion') + self.sync = utils.cast(bool, data.attrib.get('sync')) + self.transcoderActiveVideoSessions = int(data.attrib.get('transcoderActiveVideoSessions', 0)) + self.transcoderAudio = utils.cast(bool, data.attrib.get('transcoderAudio')) + self.transcoderLyrics = utils.cast(bool, data.attrib.get('transcoderLyrics')) + self.transcoderPhoto = utils.cast(bool, data.attrib.get('transcoderPhoto')) + self.transcoderSubtitles = utils.cast(bool, data.attrib.get('transcoderSubtitles')) + self.transcoderVideo = utils.cast(bool, data.attrib.get('transcoderVideo')) + self.transcoderVideoBitrates = utils.toList(data.attrib.get('transcoderVideoBitrates')) + self.transcoderVideoQualities = utils.toList(data.attrib.get('transcoderVideoQualities')) + self.transcoderVideoResolutions = utils.toList(data.attrib.get('transcoderVideoResolutions')) + self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) + self.updater = utils.cast(bool, data.attrib.get('updater')) + self.version = data.attrib.get('version') + self.voiceSearch = utils.cast(bool, data.attrib.get('voiceSearch')) + + def _headers(self, **kwargs): + """ Returns dict containing base headers for all requests to the server. """ + headers = BASE_HEADERS.copy() + if self._token: + headers['X-Plex-Token'] = self._token + headers.update(kwargs) + return headers + + def _uriRoot(self): + return f'server://{self.machineIdentifier}/com.plexapp.plugins.library' + + @cached_property + def library(self): + """ Library to browse or search your media. """ + try: + data = self.query(Library.key) + except BadRequest: + # Only the owner has access to /library + # so just return the library without the data. + data = self.query('/library/sections/') + return Library(self, data) + + @cached_property + def settings(self): + """ Returns a list of all server settings. """ + data = self.query(Settings.key) + return Settings(self, data) + + def identity(self): + """ Returns the Plex server identity. """ + data = self.query('/identity') + return Identity(self, data) + + def account(self): + """ Returns the :class:`~plexapi.server.Account` object this server belongs to. """ + data = self.query(Account.key) + return Account(self, data) + + def claim(self, account): + """ Claim the Plex server using a :class:`~plexapi.myplex.MyPlexAccount`. + This will only work with an unclaimed server on localhost or the same subnet. + + Parameters: + account (:class:`~plexapi.myplex.MyPlexAccount`): The account used to + claim the server. + """ + key = '/myplex/claim' + params = {'token': account.claimToken()} + data = self.query(key, method=self._session.post, params=params) + return Account(self, data) + + def unclaim(self): + """ Unclaim the Plex server. This will remove the server from your + :class:`~plexapi.myplex.MyPlexAccount`. + """ + data = self.query(Account.key, method=self._session.delete) + return Account(self, data) + + @property + def activities(self): + """Returns all current PMS activities.""" + activities = [] + for elem in self.query(Activity.key): + activities.append(Activity(self, elem)) + return activities + + def agents(self, mediaType=None): + """ Returns a list of :class:`~plexapi.media.Agent` objects this server has available. """ + key = '/system/agents' + if mediaType: + key += f'?mediaType={utils.searchType(mediaType)}' + return self.fetchItems(key) + + def createToken(self, type='delegation', scope='all'): + """ Create a temp access token for the server. """ + if not self._token: + # Handle unclaimed servers + return None + q = self.query(f'/security/token?type={type}&scope={scope}') + return q.attrib.get('token') + + def switchUser(self, user, session=None, timeout=None): + """ Returns a new :class:`~plexapi.server.PlexServer` object logged in as the given username. + Note: Only the admin account can switch to other users. + + Parameters: + user (:class:`~plexapi.myplex.MyPlexUser` or str): `MyPlexUser` object, username, + email, or user id of the user to log in to the server. + session (requests.Session, optional): Use your own session object if you want to + cache the http responses from the server. This will default to the same + session as the admin account if no new session is provided. + timeout (int, optional): Timeout in seconds on initial connection to the server. + This will default to the same timeout as the admin account if no new timeout + is provided. + + Example: + + .. code-block:: python + + from plexapi.server import PlexServer + # Login to the Plex server using the admin token + plex = PlexServer('http://plexserver:32400', token='2ffLuB84dqLswk9skLos') + # Login to the same Plex server using a different account + userPlex = plex.switchUser("Username") + + """ + from plexapi.myplex import MyPlexUser + user = user if isinstance(user, MyPlexUser) else self.myPlexAccount().user(user) + userToken = user.get_token(self.machineIdentifier) + if session is None: + session = self._session + if timeout is None: + timeout = self._timeout + return PlexServer(self._baseurl, token=userToken, session=session, timeout=timeout) + + def systemAccounts(self): + """ Returns a list of :class:`~plexapi.server.SystemAccount` objects this server contains. """ + if self._systemAccounts is None: + key = '/accounts' + self._systemAccounts = self.fetchItems(key, SystemAccount) + return self._systemAccounts + + def systemAccount(self, accountID): + """ Returns the :class:`~plexapi.server.SystemAccount` object for the specified account ID. + + Parameters: + accountID (int): The :class:`~plexapi.server.SystemAccount` ID. + """ + try: + return next(account for account in self.systemAccounts() if account.id == accountID) + except StopIteration: + raise NotFound(f'Unknown account with accountID={accountID}') from None + + def systemDevices(self): + """ Returns a list of :class:`~plexapi.server.SystemDevice` objects this server contains. """ + if self._systemDevices is None: + key = '/devices' + self._systemDevices = self.fetchItems(key, SystemDevice) + return self._systemDevices + + def systemDevice(self, deviceID): + """ Returns the :class:`~plexapi.server.SystemDevice` object for the specified device ID. + + Parameters: + deviceID (int): The :class:`~plexapi.server.SystemDevice` ID. + """ + try: + return next(device for device in self.systemDevices() if device.id == deviceID) + except StopIteration: + raise NotFound(f'Unknown device with deviceID={deviceID}') from None + + def myPlexAccount(self): + """ Returns a :class:`~plexapi.myplex.MyPlexAccount` object using the same + token to access this server. If you are not the owner of this PlexServer + you're likely to receive an authentication error calling this. + """ + if self._myPlexAccount is None: + from plexapi.myplex import MyPlexAccount + self._myPlexAccount = MyPlexAccount(token=self._token, session=self._session) + return self._myPlexAccount + + def _myPlexClientPorts(self): + """ Sometimes the PlexServer does not properly advertise port numbers required + to connect. This attempts to look up device port number from plex.tv. + See issue #126: Make PlexServer.clients() more user friendly. + https://github.com/pkkid/python-plexapi/issues/126 + """ + try: + ports = {} + account = self.myPlexAccount() + for device in account.devices(): + if device.connections and ':' in device.connections[0][6:]: + ports[device.clientIdentifier] = device.connections[0].split(':')[-1] + return ports + except Exception as err: + log.warning('Unable to fetch client ports from myPlex: %s', err) + return ports + + def browse(self, path=None, includeFiles=True): + """ Browse the system file path using the Plex API. + Returns list of :class:`~plexapi.library.Path` and :class:`~plexapi.library.File` objects. + + Parameters: + path (:class:`~plexapi.library.Path` or str, optional): Full path to browse. + includeFiles (bool): True to include files when browsing (Default). + False to only return folders. + """ + if isinstance(path, Path): + key = path.key + elif path is not None: + base64path = utils.base64str(path) + key = f'/services/browse/{base64path}' + else: + key = '/services/browse' + key += f'?includeFiles={int(includeFiles)}' # starting with PMS v1.32.7.7621 this must set explicitly + return self.fetchItems(key) + + def walk(self, path=None): + """ Walk the system file tree using the Plex API similar to `os.walk`. + Yields a 3-tuple `(path, paths, files)` where + `path` is a string of the directory path, + `paths` is a list of :class:`~plexapi.library.Path` objects, and + `files` is a list of :class:`~plexapi.library.File` objects. + + Parameters: + path (:class:`~plexapi.library.Path` or str, optional): Full path to walk. + """ + paths = [] + files = [] + for item in self.browse(path): + if isinstance(item, Path): + paths.append(item) + elif isinstance(item, File): + files.append(item) + + if isinstance(path, Path): + path = path.path + + yield path or '', paths, files + + for _path in paths: + for path, paths, files in self.walk(_path): + yield path, paths, files + + def isBrowsable(self, path): + """ Returns True if the Plex server can browse the given path. + + Parameters: + path (:class:`~plexapi.library.Path` or str): Full path to browse. + """ + if isinstance(path, Path): + path = path.path + paths = [p.path for p in self.browse(os.path.dirname(path), includeFiles=False)] + return path in paths + + def clients(self): + """ Returns list of all :class:`~plexapi.client.PlexClient` objects connected to server. """ + items = [] + ports = None + for elem in self.query('/clients'): + port = elem.attrib.get('port') + if not port: + log.warning('%s did not advertise a port, checking plex.tv.', elem.attrib.get('name')) + ports = self._myPlexClientPorts() if ports is None else ports + port = ports.get(elem.attrib.get('machineIdentifier')) + baseurl = f"http://{elem.attrib['host']}:{port}" + items.append(PlexClient(baseurl=baseurl, server=self, + token=self._token, data=elem, connect=False)) + + return items + + def client(self, name): + """ Returns the :class:`~plexapi.client.PlexClient` that matches the specified name + or machine identifier. + + Parameters: + name (str): Name or machine identifier of the client to return. + + Raises: + :exc:`~plexapi.exceptions.NotFound`: Unknown client name. + """ + for client in self.clients(): + if client and (client.title == name or client.machineIdentifier == name): + return client + + raise NotFound(f'Unknown client name: {name}') + + def createCollection(self, title, section, items=None, smart=False, limit=None, + libtype=None, sort=None, filters=None, **kwargs): + """ Creates and returns a new :class:`~plexapi.collection.Collection`. + + Parameters: + 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. + + Example: + + .. code-block:: python + + # Create a regular collection + movies = plex.library.section("Movies") + movie1 = movies.get("Big Buck Bunny") + movie2 = movies.get("Sita Sings the Blues") + collection = plex.createCollection( + title="Favorite Movies", + section=movies, + items=[movie1, movie2] + ) + + # Create a smart collection + collection = plex.createCollection( + title="Recently Aired Comedy TV Shows", + section="TV Shows", + smart=True, + sort="episode.originallyAvailableAt:desc", + filters={"episode.originallyAvailableAt>>": "4w", "genre": "comedy"} + ) + + """ + return Collection.create( + self, title, section, items=items, smart=smart, limit=limit, + libtype=libtype, sort=sort, filters=filters, **kwargs) + + def createPlaylist(self, title, section=None, items=None, smart=False, limit=None, + libtype=None, sort=None, filters=None, m3ufilepath=None, **kwargs): + """ Creates and returns a new :class:`~plexapi.playlist.Playlist`. + + Parameters: + 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. + + Example: + + .. code-block:: python + + # Create a regular playlist + episodes = plex.library.section("TV Shows").get("Game of Thrones").episodes() + playlist = plex.createPlaylist( + title="GoT Episodes", + items=episodes + ) + + # Create a smart playlist + playlist = plex.createPlaylist( + title="Top 10 Unwatched Movies", + section="Movies", + smart=True, + limit=10, + sort="audienceRating:desc", + filters={"audienceRating>>": 8.0, "unwatched": True} + ) + + # Create a music playlist from an m3u file + playlist = plex.createPlaylist( + title="Favorite Tracks", + section="Music", + m3ufilepath="/path/to/playlist.m3u" + ) + + """ + return Playlist.create( + self, title, section=section, items=items, smart=smart, limit=limit, + libtype=libtype, sort=sort, filters=filters, m3ufilepath=m3ufilepath, **kwargs) + + def createPlayQueue(self, item, **kwargs): + """ Creates and returns a new :class:`~plexapi.playqueue.PlayQueue`. + + Parameters: + item (Media or Playlist): Media or playlist to add to PlayQueue. + kwargs (dict): See `~plexapi.playqueue.PlayQueue.create`. + """ + return PlayQueue.create(self, item, **kwargs) + + def downloadDatabases(self, savepath=None, unpack=False, showstatus=False): + """ Download databases. + + Parameters: + savepath (str): Defaults to current working dir. + unpack (bool): Unpack the zip file. + showstatus(bool): Display a progressbar. + """ + url = self.url('/diagnostics/databases') + filepath = utils.download(url, self._token, None, savepath, self._session, unpack=unpack, showstatus=showstatus) + return filepath + + def downloadLogs(self, savepath=None, unpack=False, showstatus=False): + """ Download server logs. + + Parameters: + savepath (str): Defaults to current working dir. + unpack (bool): Unpack the zip file. + showstatus(bool): Display a progressbar. + """ + url = self.url('/diagnostics/logs') + filepath = utils.download(url, self._token, None, savepath, self._session, unpack=unpack, showstatus=showstatus) + return filepath + + def butlerTasks(self): + """ Return a list of :class:`~plexapi.base.ButlerTask` objects. """ + return self.fetchItems('/butler') + + def runButlerTask(self, task): + """ Manually run a butler task immediately instead of waiting for the scheduled task to run. + Note: The butler task is run asynchronously. Check Plex Web to monitor activity. + + Parameters: + task (str): The name of the task to run. (e.g. 'BackupDatabase') + + Example: + + .. code-block:: python + + availableTasks = [task.name for task in plex.butlerTasks()] + print("Available butler tasks:", availableTasks) + + """ + validTasks = [_task.name for _task in self.butlerTasks()] + if task not in validTasks: + raise BadRequest( + f'Invalid butler task: {task}. Available tasks are: {validTasks}' + ) + self.query(f'/butler/{task}', method=self._session.post) + return self + + @deprecated('use "checkForUpdate" instead') + def check_for_update(self, force=True, download=False): + return self.checkForUpdate(force=force, download=download) + + def checkForUpdate(self, force=True, download=False): + """ Returns a :class:`~plexapi.server.Release` object containing release info + if an update is available or None if no update is available. + + Parameters: + force (bool): Force server to check for new releases + download (bool): Download if a update is available. + """ + part = f'/updater/check?download={1 if download else 0}' + if force: + self.query(part, method=self._session.put) + releases = self.fetchItems('/updater/status') + if len(releases): + return releases[0] + + def isLatest(self): + """ Returns True if the installed version of Plex Media Server is the latest. """ + release = self.checkForUpdate(force=True) + return release is None + + def canInstallUpdate(self): + """ Returns True if the newest version of Plex Media Server can be installed automatically. + (e.g. Windows and Mac can install updates automatically, but Docker and NAS devices cannot.) + """ + release = self.query('/updater/status') + return utils.cast(bool, release.get('canInstall')) + + def installUpdate(self): + """ Automatically install the newest version of Plex Media Server. """ + # We can add this but dunno how useful this is since it sometimes + # requires user action using a gui. + part = '/updater/apply' + release = self.checkForUpdate(force=True, download=True) + if release and release.version != self.version: + # figure out what method this is.. + return self.query(part, method=self._session.put) + + def history(self, maxresults=None, mindate=None, ratingKey=None, accountID=None, librarySectionID=None): + """ Returns a list of media items from watched history. If there are many results, they will + be fetched from the server in batches of X_PLEX_CONTAINER_SIZE amounts. If you're only + looking for the first <num> results, it would be wise to set the maxresults option to that + amount so this functions doesn't iterate over all results on the server. + + Parameters: + maxresults (int): Only return the specified number of results (optional). + mindate (datetime): Min datetime to return results from. This really helps speed + up the result listing. For example: datetime.now() - timedelta(days=7) + ratingKey (int/str) Request history for a specific ratingKey item. + accountID (int/str) Request history for a specific account ID. + librarySectionID (int/str) Request history for a specific library section ID. + """ + args = {'sort': 'viewedAt:desc'} + if ratingKey: + args['metadataItemID'] = ratingKey + if accountID: + args['accountID'] = accountID + if librarySectionID: + args['librarySectionID'] = librarySectionID + if mindate: + args['viewedAt>'] = int(mindate.timestamp()) + + key = f'/status/sessions/history/all{utils.joinArgs(args)}' + return self.fetchItems(key, maxresults=maxresults) + + def playlists(self, playlistType=None, sectionId=None, title=None, sort=None, **kwargs): + """ Returns a list of all :class:`~plexapi.playlist.Playlist` objects on the server. + + Parameters: + playlistType (str, optional): The type of playlists to return (audio, video, photo). + Default returns all playlists. + sectionId (int, optional): The section ID (key) of the library to search within. + title (str, optional): General string query to search for. Partial string matches are allowed. + sort (str or list, optional): A string of comma separated sort fields in the format ``column:dir``. + """ + args = {} + if playlistType is not None: + args['playlistType'] = playlistType + if sectionId is not None: + args['sectionID'] = sectionId + if title is not None: + args['title'] = title + if sort is not None: + args['sort'] = sort + + key = f'/playlists{utils.joinArgs(args)}' + return self.fetchItems(key, **kwargs) + + def playlist(self, title): + """ Returns the :class:`~plexapi.client.Playlist` that matches the specified title. + + Parameters: + title (str): Title of the playlist to return. + + Raises: + :exc:`~plexapi.exceptions.NotFound`: Unable to find playlist. + """ + try: + return self.playlists(title=title, title__iexact=title)[0] + except IndexError: + raise NotFound(f'Unable to find playlist with title "{title}".') from None + + def optimizedItems(self, removeAll=None): + """ Returns list of all :class:`~plexapi.media.Optimized` objects connected to server. """ + if removeAll is True: + key = '/playlists/generators?type=42' + self.query(key, method=self._server._session.delete) + else: + backgroundProcessing = self.fetchItem('/playlists?type=42') + return self.fetchItems(f'{backgroundProcessing.key}/items', cls=Optimized) + + @deprecated('use "plexapi.media.Optimized.items()" instead') + def optimizedItem(self, optimizedID): + """ Returns single queued optimized item :class:`~plexapi.media.Video` object. + Allows for using optimized item ID to connect back to source item. + """ + + backgroundProcessing = self.fetchItem('/playlists?type=42') + return self.fetchItem(f'{backgroundProcessing.key}/items/{optimizedID}/items') + + def conversions(self, pause=None): + """ Returns list of all :class:`~plexapi.media.Conversion` objects connected to server. """ + if pause is True: + self.query('/:/prefs?BackgroundQueueIdlePaused=1', method=self._server._session.put) + elif pause is False: + self.query('/:/prefs?BackgroundQueueIdlePaused=0', method=self._server._session.put) + else: + return self.fetchItems('/playQueues/1', cls=Conversion) + + def currentBackgroundProcess(self): + """ Returns list of all :class:`~plexapi.media.TranscodeJob` objects running or paused on server. """ + return self.fetchItems('/status/sessions/background') + + def query(self, key, method=None, headers=None, params=None, timeout=None, **kwargs): + """ Main method used to handle HTTPS requests to the Plex server. This method helps + by encoding the response to utf-8 and parsing the returned XML into and + ElementTree object. Returns None if no data exists in the response. + """ + url = self.url(key) + 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, params=params, 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 search(self, query, mediatype=None, limit=None, sectionId=None): + """ Returns a list of media items or filter categories from the resulting + `Hub Search <https://www.plex.tv/blog/seek-plex-shall-find-leveling-web-app/>`_ + against all items in your Plex library. This searches genres, actors, directors, + playlists, as well as all the obvious media titles. It performs spell-checking + against your search terms (because KUROSAWA is hard to spell). It also provides + contextual search results. So for example, if you search for 'Pernice', it’ll + return 'Pernice Brothers' as the artist result, but we’ll also go ahead and + return your most-listened to albums and tracks from the artist. If you type + 'Arnold' you’ll get a result for the actor, but also the most recently added + movies he’s in. + + Parameters: + query (str): Query to use when searching your library. + mediatype (str, optional): Limit your search to the specified media type. + actor, album, artist, autotag, collection, director, episode, game, genre, + movie, photo, photoalbum, place, playlist, shared, show, tag, track + limit (int, optional): Limit to the specified number of results per Hub. + sectionId (int, optional): The section ID (key) of the library to search within. + """ + results = [] + params = { + 'query': query, + 'includeCollections': 1, + 'includeExternalMedia': 1} + if limit: + params['limit'] = limit + if sectionId: + params['sectionId'] = sectionId + key = f'/hubs/search?{urlencode(params)}' + for hub in self.fetchItems(key, Hub): + if mediatype: + if hub.type == mediatype: + return hub.items + else: + results += hub.items + return results + + def continueWatching(self): + """ Return a list of all items in the Continue Watching hub. """ + return self.fetchItems('/hubs/continueWatching/items') + + def sessions(self): + """ Returns a list of all active session (currently playing) media objects. """ + return self.fetchItems('/status/sessions') + + def transcodeSessions(self): + """ Returns a list of all active :class:`~plexapi.media.TranscodeSession` objects. """ + return self.fetchItems('/transcode/sessions') + + def startAlertListener(self, callback=None, callbackError=None): + """ Creates a websocket connection to the Plex Server to optionally receive + notifications. These often include messages from Plex about media scans + as well as updates to currently running Transcode Sessions. + + Returns a new :class:`~plexapi.alert.AlertListener` object. + + Note: ``websocket-client`` must be installed in order to use this feature. + + .. code-block:: python + + >> pip install websocket-client + + Parameters: + callback (func): Callback function to call on received messages. + callbackError (func): Callback function to call on errors. + + Raises: + :exc:`~plexapi.exception.Unsupported`: Websocket-client not installed. + """ + notifier = AlertListener(self, callback, callbackError) + notifier.start() + return notifier + + def transcodeImage(self, imageUrl, height, width, + opacity=None, saturation=None, blur=None, background=None, blendColor=None, + minSize=True, upscale=True, imageFormat=None): + """ Returns the URL for a transcoded image. + + Parameters: + imageUrl (str): The URL to the image + (eg. returned by :func:`~plexapi.mixins.PosterUrlMixin.thumbUrl` + or :func:`~plexapi.mixins.ArtUrlMixin.artUrl`). + The URL can be an online image. + height (int): Height to transcode the image to. + width (int): Width to transcode the image to. + opacity (int, optional): Change the opacity of the image (0 to 100) + saturation (int, optional): Change the saturation of the image (0 to 100). + blur (int, optional): The blur to apply to the image in pixels (e.g. 3). + background (str, optional): The background hex colour to apply behind the opacity (e.g. '000000'). + blendColor (str, optional): The hex colour to blend the image with (e.g. '000000'). + minSize (bool, optional): Maintain smallest dimension. Default True. + upscale (bool, optional): Upscale the image if required. Default True. + imageFormat (str, optional): 'jpeg' (default) or 'png'. + """ + params = { + 'url': imageUrl, + 'height': height, + 'width': width, + 'minSize': int(bool(minSize)), + 'upscale': int(bool(upscale)) + } + if opacity is not None: + params['opacity'] = opacity + if saturation is not None: + params['saturation'] = saturation + if blur is not None: + params['blur'] = blur + if background is not None: + params['background'] = str(background).strip('#') + if blendColor is not None: + params['blendColor'] = str(blendColor).strip('#') + if imageFormat is not None: + params['format'] = imageFormat.lower() + + key = f'/photo/:/transcode{utils.joinArgs(params)}' + return self.url(key, includeToken=True) + + def url(self, key, includeToken=None): + """ 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 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}' + + def refreshSynclist(self): + """ Force PMS to download new SyncList from Plex.tv. """ + return self.query('/sync/refreshSynclists', self._session.put) + + def refreshContent(self): + """ Force PMS to refresh content for known SyncLists. """ + return self.query('/sync/refreshContent', self._session.put) + + def refreshSync(self): + """ Calls :func:`~plexapi.server.PlexServer.refreshSynclist` and + :func:`~plexapi.server.PlexServer.refreshContent`, just like the Plex Web UI does when you click 'refresh'. + """ + self.refreshSynclist() + self.refreshContent() + + def _allowMediaDeletion(self, toggle=False): + """ Toggle allowMediaDeletion. + Parameters: + toggle (bool): True enables Media Deletion + False or None disable Media Deletion (Default) + """ + if self.allowMediaDeletion and toggle is False: + log.debug('Plex is currently allowed to delete media. Toggling off.') + elif self.allowMediaDeletion and toggle is True: + log.debug('Plex is currently allowed to delete media. Toggle set to allow, exiting.') + raise BadRequest('Plex is currently allowed to delete media. Toggle set to allow, exiting.') + elif self.allowMediaDeletion is None and toggle is True: + log.debug('Plex is currently not allowed to delete media. Toggle set to allow.') + else: + log.debug('Plex is currently not allowed to delete media. Toggle set to not allow, exiting.') + raise BadRequest('Plex is currently not allowed to delete media. Toggle set to not allow, exiting.') + value = 1 if toggle is True else 0 + return self.query(f'/:/prefs?allowMediaDeletion={value}', self._session.put) + + def bandwidth(self, timespan=None, **kwargs): + """ Returns a list of :class:`~plexapi.server.StatisticsBandwidth` objects + with the Plex server dashboard bandwidth data. + + Parameters: + timespan (str, optional): The timespan to bin the bandwidth data. Default is seconds. + Available timespans: seconds, hours, days, weeks, months. + **kwargs (dict, optional): Any of the available filters that can be applied to the bandwidth data. + The time frame (at) and bytes can also be filtered using less than or greater than (see examples below). + + * accountID (int): The :class:`~plexapi.server.SystemAccount` ID to filter. + * at (datetime): The time frame to filter (inclusive). The time frame can be either: + 1. An exact time frame (e.g. Only December 1st 2020 `at=datetime(2020, 12, 1)`). + 2. Before a specific time (e.g. Before and including December 2020 `at<=datetime(2020, 12, 1)`). + 3. After a specific time (e.g. After and including January 2021 `at>=datetime(2021, 1, 1)`). + * bytes (int): The amount of bytes to filter (inclusive). The bytes can be either: + 1. An exact number of bytes (not very useful) (e.g. `bytes=1024**3`). + 2. Less than or equal number of bytes (e.g. `bytes<=1024**3`). + 3. Greater than or equal number of bytes (e.g. `bytes>=1024**3`). + * deviceID (int): The :class:`~plexapi.server.SystemDevice` ID to filter. + * lan (bool): True to only retrieve local bandwidth, False to only retrieve remote bandwidth. + Default returns all local and remote bandwidth. + + Raises: + :exc:`~plexapi.exceptions.BadRequest`: When applying an invalid timespan or unknown filter. + + Example: + + .. code-block:: python + + from plexapi.server import PlexServer + plex = PlexServer('http://localhost:32400', token='xxxxxxxxxxxxxxxxxxxx') + + # Filter bandwidth data for December 2020 and later, and more than 1 GB used. + filters = { + 'at>': datetime(2020, 12, 1), + 'bytes>': 1024**3 + } + + # Retrieve bandwidth data in one day timespans. + bandwidthData = plex.bandwidth(timespan='days', **filters) + + # Print out bandwidth usage for each account and device combination. + for bandwidth in sorted(bandwidthData, key=lambda x: x.at): + account = bandwidth.account() + device = bandwidth.device() + gigabytes = round(bandwidth.bytes / 1024**3, 3) + local = 'local' if bandwidth.lan else 'remote' + date = bandwidth.at.strftime('%Y-%m-%d') + print(f'{account.name} used {gigabytes} GB of {local} bandwidth on {date} from {device.name}') + + """ + params = {} + + if timespan is None: + params['timespan'] = 6 # Default to seconds + else: + timespans = { + 'seconds': 6, + 'hours': 4, + 'days': 3, + 'weeks': 2, + 'months': 1 + } + try: + params['timespan'] = timespans[timespan] + except KeyError: + raise BadRequest(f"Invalid timespan specified: {timespan}. " + f"Available timespans: {', '.join(timespans.keys())}") + + filters = {'accountID', 'at', 'at<', 'at>', 'bytes', 'bytes<', 'bytes>', 'deviceID', 'lan'} + + for key, value in kwargs.items(): + if key not in filters: + raise BadRequest(f'Unknown filter: {key}={value}') + if key.startswith('at'): + try: + value = utils.cast(int, value.timestamp()) + except AttributeError: + raise BadRequest(f'Time frame filter must be a datetime object: {key}={value}') + elif key.startswith('bytes') or key == 'lan': + value = utils.cast(int, value) + elif key == 'accountID': + if value == self.myPlexAccount().id: + value = 1 # The admin account is accountID=1 + params[key] = value + + key = f'/statistics/bandwidth?{urlencode(params)}' + return self.fetchItems(key, StatisticsBandwidth) + + def resources(self): + """ Returns a list of :class:`~plexapi.server.StatisticsResources` objects + with the Plex server dashboard resources data. """ + key = '/statistics/resources?timespan=6' + return self.fetchItems(key, StatisticsResources) + + def _buildWebURL(self, base=None, endpoint=None, **kwargs): + """ Build the Plex Web URL for the object. + + Parameters: + base (str): The base URL before the fragment (``#!``). + Default is https://app.plex.tv/desktop. + endpoint (str): The Plex Web URL endpoint. + None for server, 'playlist' for playlists, 'details' for all other media types. + **kwargs (dict): Dictionary of URL parameters. + """ + if base is None: + base = 'https://app.plex.tv/desktop/' + + if endpoint: + return f'{base}#!/server/{self.machineIdentifier}/{endpoint}{utils.joinArgs(kwargs)}' + else: + return f'{base}#!/media/{self.machineIdentifier}/com.plexapp.plugins.library{utils.joinArgs(kwargs)}' + + def getWebURL(self, base=None, playlistTab=None): + """ Returns the Plex Web URL for the server. + + Parameters: + base (str): The base URL before the fragment (``#!``). + Default is https://app.plex.tv/desktop. + playlistTab (str): The playlist tab (audio, video, photo). Only used for the playlist URL. + """ + if playlistTab is not None: + params = {'source': 'playlists', 'pivot': f'playlists.{playlistTab}'} + else: + params = {'key': '/hubs', 'pageType': 'hub'} + return self._buildWebURL(base=base, **params) + + +class Account(PlexObject): + """ Contains the locally cached MyPlex account information. The properties provided don't + match the :class:`~plexapi.myplex.MyPlexAccount` object very well. I believe this exists + because access to myplex is not required to get basic plex information. I can't imagine + object is terribly useful except unless you were needed this information while offline. + + Parameters: + server (:class:`~plexapi.server.PlexServer`): PlexServer this account is connected to (optional) + data (ElementTree): Response from PlexServer used to build this object (optional). + + Attributes: + authToken (str): Plex authentication token to access the server. + mappingError (str): Unknown + mappingErrorMessage (str): Unknown + mappingState (str): Unknown + privateAddress (str): Local IP address of the Plex server. + privatePort (str): Local port of the Plex server. + publicAddress (str): Public IP address of the Plex server. + publicPort (str): Public port of the Plex server. + signInState (str): Signin state for this account (ex: ok). + subscriptionActive (str): True if the account subscription is active. + subscriptionFeatures (str): List of features allowed by the server for this account. + This may be based on your PlexPass subscription. Features include: camera_upload, + cloudsync, content_filter, dvr, hardware_transcoding, home, lyrics, music_videos, + pass, photo_autotags, premium_music_metadata, session_bandwidth_restrictions, + sync, trailers, webhooks' (and maybe more). + subscriptionState (str): 'Active' if this subscription is active. + username (str): Plex account username (user@example.com). + """ + key = '/myplex/account' + + def _loadData(self, data): + self._data = data + self.authToken = data.attrib.get('authToken') + self.username = data.attrib.get('username') + self.mappingState = data.attrib.get('mappingState') + self.mappingError = data.attrib.get('mappingError') + self.mappingErrorMessage = data.attrib.get('mappingErrorMessage') + self.signInState = data.attrib.get('signInState') + self.publicAddress = data.attrib.get('publicAddress') + self.publicPort = data.attrib.get('publicPort') + self.privateAddress = data.attrib.get('privateAddress') + self.privatePort = data.attrib.get('privatePort') + self.subscriptionFeatures = utils.toList(data.attrib.get('subscriptionFeatures')) + self.subscriptionActive = utils.cast(bool, data.attrib.get('subscriptionActive')) + self.subscriptionState = data.attrib.get('subscriptionState') + + +class Activity(PlexObject): + """A currently running activity on the PlexServer.""" + key = '/activities' + + def _loadData(self, data): + self._data = data + self.cancellable = utils.cast(bool, data.attrib.get('cancellable')) + self.progress = utils.cast(int, data.attrib.get('progress')) + self.title = data.attrib.get('title') + self.subtitle = data.attrib.get('subtitle') + self.type = data.attrib.get('type') + self.uuid = data.attrib.get('uuid') + + +@utils.registerPlexObject +class Release(PlexObject): + TAG = 'Release' + key = '/updater/status' + + def _loadData(self, data): + self.download_key = data.attrib.get('key') + self.version = data.attrib.get('version') + self.added = data.attrib.get('added') + self.fixed = data.attrib.get('fixed') + self.downloadURL = data.attrib.get('downloadURL') + self.state = data.attrib.get('state') + + +class SystemAccount(PlexObject): + """ Represents a single system account. + + Attributes: + TAG (str): 'Account' + autoSelectAudio (bool): True or False if the account has automatic audio language enabled. + defaultAudioLanguage (str): The default audio language code for the account. + defaultSubtitleLanguage (str): The default subtitle language code for the account. + id (int): The Plex account ID. + key (str): API URL (/accounts/<id>) + name (str): The username of the account. + subtitleMode (bool): The subtitle mode for the account. + thumb (str): URL for the account thumbnail. + """ + TAG = 'Account' + + def _loadData(self, data): + self._data = data + self.autoSelectAudio = utils.cast(bool, data.attrib.get('autoSelectAudio')) + self.defaultAudioLanguage = data.attrib.get('defaultAudioLanguage') + self.defaultSubtitleLanguage = data.attrib.get('defaultSubtitleLanguage') + self.id = utils.cast(int, data.attrib.get('id')) + self.key = data.attrib.get('key') + self.name = data.attrib.get('name') + self.subtitleMode = utils.cast(int, data.attrib.get('subtitleMode')) + self.thumb = data.attrib.get('thumb') + # For backwards compatibility + self.accountID = self.id + self.accountKey = self.key + + +class SystemDevice(PlexObject): + """ Represents a single system device. + + Attributes: + TAG (str): 'Device' + clientIdentifier (str): The unique identifier for the device. + createdAt (datetime): Datetime the device was created. + id (int): The ID of the device (not the same as :class:`~plexapi.myplex.MyPlexDevice` ID). + key (str): API URL (/devices/<id>) + name (str): The name of the device. + platform (str): OS the device is running (Linux, Windows, Chrome, etc.) + """ + TAG = 'Device' + + def _loadData(self, data): + self._data = data + self.clientIdentifier = data.attrib.get('clientIdentifier') + self.createdAt = utils.toDatetime(data.attrib.get('createdAt')) + self.id = utils.cast(int, data.attrib.get('id')) + self.key = f'/devices/{self.id}' + self.name = data.attrib.get('name') + self.platform = data.attrib.get('platform') + + +class StatisticsBandwidth(PlexObject): + """ Represents a single statistics bandwidth data. + + Attributes: + TAG (str): 'StatisticsBandwidth' + accountID (int): The associated :class:`~plexapi.server.SystemAccount` ID. + at (datetime): Datetime of the bandwidth data. + bytes (int): The total number of bytes for the specified time span. + deviceID (int): The associated :class:`~plexapi.server.SystemDevice` ID. + lan (bool): True or False whether the bandwidth is local or remote. + timespan (int): The time span for the bandwidth data. + 1: months, 2: weeks, 3: days, 4: hours, 6: seconds. + + """ + TAG = 'StatisticsBandwidth' + + def _loadData(self, data): + self._data = data + self.accountID = utils.cast(int, data.attrib.get('accountID')) + self.at = utils.toDatetime(data.attrib.get('at')) + self.bytes = utils.cast(int, data.attrib.get('bytes')) + self.deviceID = utils.cast(int, data.attrib.get('deviceID')) + self.lan = utils.cast(bool, data.attrib.get('lan')) + self.timespan = utils.cast(int, data.attrib.get('timespan')) + + def __repr__(self): + return '<{}>'.format( + ':'.join([p for p in [ + self.__class__.__name__, + self._clean(self.accountID), + self._clean(self.deviceID), + self._clean(int(self.at.timestamp())) + ] if p]) + ) + + def account(self): + """ Returns the :class:`~plexapi.server.SystemAccount` associated with the bandwidth data. """ + return self._server.systemAccount(self.accountID) + + def device(self): + """ Returns the :class:`~plexapi.server.SystemDevice` associated with the bandwidth data. """ + return self._server.systemDevice(self.deviceID) + + +class StatisticsResources(PlexObject): + """ Represents a single statistics resources data. + + Attributes: + TAG (str): 'StatisticsResources' + at (datetime): Datetime of the resource data. + hostCpuUtilization (float): The system CPU usage %. + hostMemoryUtilization (float): The Plex Media Server CPU usage %. + processCpuUtilization (float): The system RAM usage %. + processMemoryUtilization (float): The Plex Media Server RAM usage %. + timespan (int): The time span for the resource data (6: seconds). + """ + TAG = 'StatisticsResources' + + def _loadData(self, data): + self._data = data + self.at = utils.toDatetime(data.attrib.get('at')) + self.hostCpuUtilization = utils.cast(float, data.attrib.get('hostCpuUtilization')) + self.hostMemoryUtilization = utils.cast(float, data.attrib.get('hostMemoryUtilization')) + self.processCpuUtilization = utils.cast(float, data.attrib.get('processCpuUtilization')) + self.processMemoryUtilization = utils.cast(float, data.attrib.get('processMemoryUtilization')) + self.timespan = utils.cast(int, data.attrib.get('timespan')) + + def __repr__(self): + return f"<{':'.join([p for p in [self.__class__.__name__, self._clean(int(self.at.timestamp()))] if p])}>" + + +@utils.registerPlexObject +class ButlerTask(PlexObject): + """ Represents a single scheduled butler task. + + Attributes: + TAG (str): 'ButlerTask' + description (str): The description of the task. + enabled (bool): Whether the task is enabled. + interval (int): The interval the task is run in days. + name (str): The name of the task. + scheduleRandomized (bool): Whether the task schedule is randomized. + title (str): The title of the task. + """ + TAG = 'ButlerTask' + + def _loadData(self, data): + self._data = data + self.description = data.attrib.get('description') + self.enabled = utils.cast(bool, data.attrib.get('enabled')) + self.interval = utils.cast(int, data.attrib.get('interval')) + self.name = data.attrib.get('name') + self.scheduleRandomized = utils.cast(bool, data.attrib.get('scheduleRandomized')) + self.title = data.attrib.get('title') + + +class Identity(PlexObject): + """ Represents a server identity. + + Attributes: + claimed (bool): True or False if the server is claimed. + machineIdentifier (str): The Plex server machine identifier. + version (str): The Plex server version. + """ + + def __repr__(self): + return f"<{self.__class__.__name__}:{self.machineIdentifier}>" + + def _loadData(self, data): + self._data = data + self.claimed = utils.cast(bool, data.attrib.get('claimed')) + self.machineIdentifier = data.attrib.get('machineIdentifier') + self.version = data.attrib.get('version') diff --git a/libs/plexapi/settings.py b/libs/plexapi/settings.py new file mode 100644 index 000000000..c191e3689 --- /dev/null +++ b/libs/plexapi/settings.py @@ -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) diff --git a/libs/plexapi/sonos.py b/libs/plexapi/sonos.py new file mode 100644 index 000000000..8f1295f44 --- /dev/null +++ b/libs/plexapi/sonos.py @@ -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 + ) + ) diff --git a/libs/plexapi/sync.py b/libs/plexapi/sync.py new file mode 100644 index 000000000..f57e89d96 --- /dev/null +++ b/libs/plexapi/sync.py @@ -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' diff --git a/libs/plexapi/utils.py b/libs/plexapi/utils.py new file mode 100644 index 000000000..dd1cfc9ce --- /dev/null +++ b/libs/plexapi/utils.py @@ -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) diff --git a/libs/plexapi/video.py b/libs/plexapi/video.py new file mode 100644 index 000000000..9e4201b88 --- /dev/null +++ b/libs/plexapi/video.py @@ -0,0 +1,1278 @@ +# -*- coding: utf-8 -*- +import os +from functools import cached_property +from pathlib import Path +from urllib.parse import quote_plus + +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, LogoMixin, PosterUrlMixin, PosterMixin, ThemeUrlMixin, ThemeMixin, + MovieEditMixins, ShowEditMixins, SeasonEditMixins, EpisodeEditMixins, + WatchlistMixin +) + + +class Video(PlexPartialObject, PlayedUnplayedMixin): + """ Base class for all video objects including :class:`~plexapi.video.Movie`, + :class:`~plexapi.video.Show`, :class:`~plexapi.video.Season`, + :class:`~plexapi.video.Episode`, and :class:`~plexapi.video.Clip`. + + 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. + fields (List<:class:`~plexapi.media.Field`>): List of field objects. + guid (str): Plex GUID for the movie, show, season, episode, or clip (plex://movie/5d776b59ad5437001f79c6f8). + images (List<:class:`~plexapi.media.Image`>): List of image objects. + 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 'video' (useful for search filters). + ratingKey (int): Unique key identifying the item. + summary (str): Summary of the movie, show, season, episode, or clip. + thumb (str): URL to thumbnail image (/library/metadata/<ratingKey>/thumb/<thumbid>). + thumbBlurHash (str): BlurHash string for thumbnail image. + title (str): Name of the movie, show, season, episode, or clip. + titleSort (str): Title to use when sorting (defaults to title). + type (str): 'movie', 'show', 'season', 'episode', or 'clip'. + 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. + """ + + 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.fields = self.findItems(data, media.Field) + self.guid = data.attrib.get('guid') + self.images = self.findItems(data, media.Image) + 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 = 'video' + 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 something. Typically used for getting a specific image. """ + return self._server.url(part, includeToken=True) if part else None + + def augmentation(self): + """ Returns a list of :class:`~plexapi.library.Hub` objects. + Augmentation returns hub items relating to online media sources + such as Tidal Music "Track from {item}" or "Soundtrack of {item}". + Plex Pass and linked Tidal account are required. + """ + account = self._server.myPlexAccount() + tidalOptOut = next( + (service.value for service in account.onlineMediaSources() + if service.key == 'tv.plex.provider.music'), + None + ) + if account.subscriptionStatus != 'Active' or tidalOptOut == 'opt_out': + raise BadRequest('Requires Plex Pass and Tidal Music enabled.') + data = self._server.query(self.key + '?asyncAugmentMetadata=1') + augmentationKey = data.attrib.get('augmentationKey') + return self.fetchItems(augmentationKey) + + def _defaultSyncTitle(self): + """ Returns str, default title for a new syncItem. """ + return self.title + + def uploadSubtitles(self, filepath): + """ Upload a subtitle file for the video. + + Parameters: + filepath (str): Path to subtitle file. + """ + url = f'{self.key}/subtitles' + filename = os.path.basename(filepath) + subFormat = os.path.splitext(filepath)[1][1:] + params = { + 'title': filename, + 'format': subFormat, + } + headers = {'Accept': 'text/plain, */*'} + with open(filepath, 'rb') as subfile: + self._server.query(url, self._server._session.post, data=subfile, params=params, headers=headers) + return self + + def searchSubtitles(self, language='en', hearingImpaired=0, forced=0): + """ Search for on-demand subtitles for the video. + See https://support.plex.tv/articles/subtitle-search/. + + Parameters: + language (str, optional): Language code (ISO 639-1) of the subtitles to search for. + Default 'en'. + hearingImpaired (int, optional): Search option for SDH subtitles. + Default 0. + (0 = Prefer non-SDH subtitles, 1 = Prefer SDH subtitles, + 2 = Only show SDH subtitles, 3 = Only show non-SDH subtitles) + forced (int, optional): Search option for forced subtitles. + Default 0. + (0 = Prefer non-forced subtitles, 1 = Prefer forced subtitles, + 2 = Only show forced subtitles, 3 = Only show non-forced subtitles) + + Returns: + List<:class:`~plexapi.media.SubtitleStream`>: List of SubtitleStream objects. + """ + params = { + 'language': language, + 'hearingImpaired': hearingImpaired, + 'forced': forced, + } + key = f'{self.key}/subtitles{utils.joinArgs(params)}' + return self.fetchItems(key) + + def downloadSubtitles(self, subtitleStream): + """ Download on-demand subtitles for the video. + See https://support.plex.tv/articles/subtitle-search/. + + Note: This method is asynchronous and returns immediately before subtitles are fully downloaded. + + Parameters: + subtitleStream (:class:`~plexapi.media.SubtitleStream`): + Subtitle object returned from :func:`~plexapi.video.Video.searchSubtitles`. + """ + key = f'{self.key}/subtitles' + params = {'key': subtitleStream.key} + self._server.query(key, self._server._session.put, params=params) + return self + + def removeSubtitles(self, subtitleStream=None, streamID=None, streamTitle=None): + """ Remove an upload or downloaded subtitle from the video. + + Note: If the subtitle file is located inside video directory it will be deleted. + Files outside of video directory are not affected. + Embedded subtitles cannot be removed. + + Parameters: + subtitleStream (:class:`~plexapi.media.SubtitleStream`, optional): Subtitle object to remove. + streamID (int, optional): ID of the subtitle stream to remove. + streamTitle (str, optional): Title of the subtitle stream to remove. + """ + if subtitleStream is None: + try: + subtitleStream = next( + stream for stream in self.subtitleStreams() + if streamID == stream.id or streamTitle == stream.title + ) + except StopIteration: + raise BadRequest(f"Subtitle stream with ID '{streamID}' or title '{streamTitle}' not found.") from None + + self._server.query(subtitleStream.key, self._server._session.delete) + return self + + def optimize(self, title='', target='', deviceProfile='', videoQuality=None, + locationID=-1, limit=None, unwatched=False): + """ Create an optimized version of the video. + + Parameters: + title (str, optional): Title of the optimized video. + target (str, optional): Target quality profile: + "Optimized for Mobile" ("mobile"), "Optimized for TV" ("tv"), "Original Quality" ("original"), + or custom quality profile name (default "Custom: {deviceProfile}"). + deviceProfile (str, optional): Custom quality device profile: + "Android", "iOS", "Universal Mobile", "Universal TV", "Windows Phone", "Windows", "Xbox One". + Required if ``target`` is custom. + videoQuality (int, optional): Index of the quality profile, one of ``VIDEO_QUALITY_*`` + values defined in the :mod:`~plexapi.sync` module. Only used if ``target`` is custom. + locationID (int or :class:`~plexapi.library.Location`, optional): Default -1 for + "In folder with original items", otherwise a :class:`~plexapi.library.Location` object or ID. + See examples below. + limit (int, optional): Maximum count of items to optimize, unlimited if ``None``. + unwatched (bool, optional): ``True`` to only optimized unwatched videos. + + Raises: + :exc:`~plexapi.exceptions.BadRequest`: Unknown quality profile target + or missing deviceProfile and videoQuality. + :exc:`~plexapi.exceptions.BadRequest`: Unknown location ID. + + Example: + + .. code-block:: python + + # Optimize for mobile using defaults + video.optimize(target="mobile") + + # Optimize for Android at 10 Mbps 1080p + from plexapi.sync import VIDEO_QUALITY_10_MBPS_1080p + video.optimize(deviceProfile="Android", videoQuality=sync.VIDEO_QUALITY_10_MBPS_1080p) + + # Optimize for iOS at original quality in library location + from plexapi.sync import VIDEO_QUALITY_ORIGINAL + locations = plex.library.section("Movies")._locations() + video.optimize(deviceProfile="iOS", videoQuality=VIDEO_QUALITY_ORIGINAL, locationID=locations[0]) + + # Optimize for tv the next 5 unwatched episodes + show.optimize(target="tv", limit=5, unwatched=True) + + """ + from plexapi.library import Location + from plexapi.sync import Policy, MediaSettings + + backgroundProcessing = self.fetchItem('/playlists?type=42') + key = f'{backgroundProcessing.key}/items' + + tags = {t.tag.lower(): t.id for t in self._server.library.tags('mediaProcessingTarget')} + # Additional keys for shorthand values + tags['mobile'] = tags['optimized for mobile'] + tags['tv'] = tags['optimized for tv'] + tags['original'] = tags['original quality'] + + targetTagID = tags.get(target.lower(), '') + if not targetTagID and (not deviceProfile or videoQuality is None): + raise BadRequest('Unknown quality profile target or missing deviceProfile and videoQuality.') + if targetTagID: + target = '' + elif deviceProfile and not target: + target = f'Custom: {deviceProfile}' + + section = self.section() + libraryLocationIDs = [-1] + [location.id for location in section._locations()] + if isinstance(locationID, Location): + locationID = locationID.id + if locationID not in libraryLocationIDs: + raise BadRequest(f'Unknown location ID "{locationID}" not in {libraryLocationIDs}') + + if isinstance(self, (Show, Season)): + uri = f'library:///directory/{quote_plus(f"{self.key}/children")}' + else: + uri = f'library://{section.uuid}/item/{quote_plus(self.key)}' + + policy = Policy.create(limit, unwatched) + + params = { + 'Item[type]': 42, + 'Item[title]': title or self._defaultSyncTitle(), + 'Item[target]': target, + 'Item[targetTagID]': targetTagID, + 'Item[locationID]': locationID, + 'Item[Location][uri]': uri, + 'Item[Policy][scope]': policy.scope, + 'Item[Policy][value]': str(policy.value), + 'Item[Policy][unwatched]': str(int(policy.unwatched)), + } + + if deviceProfile: + params['Item[Device][profile]'] = deviceProfile + + if videoQuality: + mediaSettings = MediaSettings.createVideo(videoQuality) + params['Item[MediaSettings][videoQuality]'] = mediaSettings.videoQuality + params['Item[MediaSettings][videoResolution]'] = mediaSettings.videoResolution + params['Item[MediaSettings][maxVideoBitrate]'] = mediaSettings.maxVideoBitrate + params['Item[MediaSettings][audioBoost]'] = '' + params['Item[MediaSettings][subtitleSize]'] = '' + params['Item[MediaSettings][musicBitrate]'] = '' + params['Item[MediaSettings][photoQuality]'] = '' + params['Item[MediaSettings][photoResolution]'] = '' + + url = key + utils.joinArgs(params) + self._server.query(url, method=self._server._session.put) + return self + + def sync(self, videoQuality, client=None, clientId=None, limit=None, unwatched=False, title=None): + """ Add current video (movie, tv-show, season or episode) as sync item for specified device. + See :func:`~plexapi.myplex.MyPlexAccount.sync` for possible exceptions. + + Parameters: + videoQuality (int): idx of quality of the video, one of VIDEO_QUALITY_* values defined in + :mod:`~plexapi.sync` module. + 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 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, unwatched) + sync_item.mediaSettings = MediaSettings.createVideo(videoQuality) + + return myplex.sync(sync_item, client=client, clientId=clientId) + + +@utils.registerPlexObject +class Movie( + Video, Playable, + AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin, + ArtMixin, LogoMixin, PosterMixin, ThemeMixin, + MovieEditMixins, + WatchlistMixin +): + """ Represents a single Movie. + + Attributes: + TAG (str): 'Video' + TYPE (str): 'movie' + audienceRating (float): Audience rating (usually from Rotten Tomatoes). + audienceRatingImage (str): Key to audience rating image (rottentomatoes://image.rating.spilled). + chapters (List<:class:`~plexapi.media.Chapter`>): List of Chapter objects. + chapterSource (str): Chapter source (agent; media; mixed). + collections (List<:class:`~plexapi.media.Collection`>): List of collection objects. + contentRating (str) Content rating (PG-13; NR; TV-G). + countries (List<:class:`~plexapi.media.Country`>): List of countries objects. + directors (List<:class:`~plexapi.media.Director`>): List of director objects. + duration (int): Duration of the movie in milliseconds. + editionTitle (str): The edition title of the movie (e.g. Director's Cut, Extended Edition, etc.). + enableCreditsMarkerGeneration (int): Setting that indicates if credits markers detection is enabled. + (-1 = Library default, 0 = Disabled) + genres (List<:class:`~plexapi.media.Genre`>): List of genre objects. + guids (List<:class:`~plexapi.media.Guid`>): List of guid objects. + labels (List<:class:`~plexapi.media.Label`>): List of label objects. + languageOverride (str): Setting that indicates if a language is used to override metadata + (eg. en-CA, None = Library default). + markers (List<:class:`~plexapi.media.Marker`>): List of marker objects. + media (List<:class:`~plexapi.media.Media`>): List of media objects. + originallyAvailableAt (datetime): Datetime the movie was released. + originalTitle (str): Original title, often the foreign title (転々; 엽기적인 그녀). + primaryExtraKey (str) Primary extra key (/library/metadata/66351). + producers (List<:class:`~plexapi.media.Producer`>): List of producers objects. + rating (float): Movie critic rating (7.9; 9.8; 8.1). + ratingImage (str): Key to critic rating image (rottentomatoes://image.rating.rotten). + ratings (List<:class:`~plexapi.media.Rating`>): List of rating objects. + roles (List<:class:`~plexapi.media.Role`>): List of role objects. + slug (str): The clean watch.plex.tv URL identifier for the movie. + similar (List<:class:`~plexapi.media.Similar`>): List of Similar objects. + sourceURI (str): Remote server URI (server://<machineIdentifier>/com.plexapp.plugins.library) + (remote playlist item only). + studio (str): Studio that created movie (Di Bonaventura Pictures; 21 Laps Entertainment). + tagline (str): Movie tag line (Back 2 Work; Who says men can't change?). + theme (str): URL to theme resource (/library/metadata/<ratingkey>/theme/<themeid>). + ultraBlurColors (:class:`~plexapi.media.UltraBlurColors`): Ultra blur color object. + useOriginalTitle (int): Setting that indicates if the original title is used for the movie + (-1 = Library default, 0 = No, 1 = Yes). + viewOffset (int): View offset in milliseconds. + writers (List<:class:`~plexapi.media.Writer`>): List of writers objects. + year (int): Year movie was released. + """ + TAG = 'Video' + TYPE = 'movie' + METADATA_TYPE = 'movie' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + Video._loadData(self, data) + Playable._loadData(self, data) + self.audienceRating = utils.cast(float, data.attrib.get('audienceRating')) + self.audienceRatingImage = data.attrib.get('audienceRatingImage') + self.chapters = self.findItems(data, media.Chapter) + self.chapterSource = data.attrib.get('chapterSource') + self.collections = self.findItems(data, media.Collection) + self.contentRating = data.attrib.get('contentRating') + self.countries = self.findItems(data, media.Country) + self.directors = self.findItems(data, media.Director) + self.duration = utils.cast(int, data.attrib.get('duration')) + self.editionTitle = data.attrib.get('editionTitle') + self.enableCreditsMarkerGeneration = utils.cast(int, data.attrib.get('enableCreditsMarkerGeneration', '-1')) + self.genres = self.findItems(data, media.Genre) + self.guids = self.findItems(data, media.Guid) + self.labels = self.findItems(data, media.Label) + self.languageOverride = data.attrib.get('languageOverride') + self.markers = self.findItems(data, media.Marker) + self.media = self.findItems(data, media.Media) + self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') + self.originalTitle = data.attrib.get('originalTitle') + self.primaryExtraKey = data.attrib.get('primaryExtraKey') + self.producers = self.findItems(data, media.Producer) + self.rating = utils.cast(float, data.attrib.get('rating')) + self.ratingImage = data.attrib.get('ratingImage') + self.ratings = self.findItems(data, media.Rating) + self.roles = self.findItems(data, media.Role) + self.slug = data.attrib.get('slug') + self.similar = self.findItems(data, media.Similar) + self.sourceURI = data.attrib.get('source') # remote playlist item + self.studio = data.attrib.get('studio') + self.tagline = data.attrib.get('tagline') + self.theme = data.attrib.get('theme') + self.ultraBlurColors = self.findItem(data, media.UltraBlurColors) + self.useOriginalTitle = utils.cast(int, data.attrib.get('useOriginalTitle', '-1')) + self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0)) + self.writers = self.findItems(data, media.Writer) + self.year = utils.cast(int, data.attrib.get('year')) + + @property + def actors(self): + """ Alias to self.roles. """ + return self.roles + + @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 movie. + + Returns: + List<str> of file paths where the movie is found on disk. + """ + return [part.file for part in self.iterParts() if part] + + @property + def hasCreditsMarker(self): + """ Returns True if the movie has a credits marker. """ + return any(marker.type == 'credits' for marker in self.markers) + + @property + def hasVoiceActivity(self): + """ Returns True if any of the media has voice activity analyzed. """ + return any(media.hasVoiceActivity for media in self.media) + + @property + def hasPreviewThumbnails(self): + """ Returns True if any of the media parts has generated preview (BIF) thumbnails. """ + return any(part.hasPreviewThumbnails for media in self.media for part in media.parts) + + def _prettyfilename(self): + """ Returns a filename for use in download. """ + return f'{self.title} ({self.year})' + + def reviews(self): + """ Returns a list of :class:`~plexapi.media.Review` objects. """ + key = f'{self.key}?includeReviews=1' + return self.fetchItems(key, cls=media.Review, rtag='Video') + + def editions(self): + """ Returns a list of :class:`~plexapi.video.Movie` objects + for other editions of the same movie. + """ + filters = { + 'guid': self.guid, + 'id!': self.ratingKey + } + return self.section().search(filters=filters) + + def removeFromContinueWatching(self): + """ Remove the movie from continue watching. """ + key = '/actions/removeFromContinueWatching' + params = {'ratingKey': self.ratingKey} + self._server.query(key, params=params, method=self._server._session.put) + return self + + @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') / 'Movies' / guid_hash[0] / f'{guid_hash[1:]}.bundle') + + +@utils.registerPlexObject +class Show( + Video, + AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin, + ArtMixin, LogoMixin, PosterMixin, ThemeMixin, + ShowEditMixins, + WatchlistMixin +): + """ Represents a single Show (including all seasons and episodes). + + Attributes: + TAG (str): 'Directory' + TYPE (str): 'show' + audienceRating (float): Audience rating (TMDB or TVDB). + audienceRatingImage (str): Key to audience rating image (tmdb://image.rating). + audioLanguage (str): Setting that indicates the preferred audio language. + autoDeletionItemPolicyUnwatchedLibrary (int): Setting that indicates the number of unplayed + episodes to keep for the show (0 = All episodes, 5 = 5 latest episodes, 3 = 3 latest episodes, + 1 = 1 latest episode, -3 = Episodes added in the past 3 days, -7 = Episodes added in the + past 7 days, -30 = Episodes added in the past 30 days). + autoDeletionItemPolicyWatchedLibrary (int): Setting that indicates if episodes are deleted + after being watched for the show (0 = Never, 1 = After a day, 7 = After a week, + 100 = On next refresh). + childCount (int): Number of seasons (including Specials) in the show. + collections (List<:class:`~plexapi.media.Collection`>): List of collection objects. + contentRating (str) Content rating (PG-13; NR; TV-G). + duration (int): Typical duration of the show episodes in milliseconds. + enableCreditsMarkerGeneration (int): Setting that indicates if credits markers detection is enabled. + (-1 = Library default, 0 = Disabled). + episodeSort (int): Setting that indicates how episodes are sorted for the show + (-1 = Library default, 0 = Oldest first, 1 = Newest first). + flattenSeasons (int): Setting that indicates if seasons are set to hidden for the show + (-1 = Library default, 0 = Hide, 1 = Show). + genres (List<:class:`~plexapi.media.Genre`>): List of genre objects. + guids (List<:class:`~plexapi.media.Guid`>): List of guid objects. + index (int): Plex index number for the show. + key (str): API URL (/library/metadata/<ratingkey>). + labels (List<:class:`~plexapi.media.Label`>): List of label objects. + languageOverride (str): Setting that indicates if a language is used to override metadata + (eg. en-CA, None = Library default). + leafCount (int): Number of items in the show view. + locations (List<str>): List of folder paths where the show is found on disk. + network (str): The network that distributed the show. + originallyAvailableAt (datetime): Datetime the show was released. + originalTitle (str): The original title of the show. + rating (float): Show rating (7.9; 9.8; 8.1). + ratings (List<:class:`~plexapi.media.Rating`>): List of rating objects. + roles (List<:class:`~plexapi.media.Role`>): List of role objects. + seasonCount (int): Number of seasons (excluding Specials) in the show. + showOrdering (str): Setting that indicates the episode ordering for the show + (None = Library default, tmdbAiring = The Movie Database (Aired), + aired = TheTVDB (Aired), dvd = TheTVDB (DVD), absolute = TheTVDB (Absolute)). + similar (List<:class:`~plexapi.media.Similar`>): List of Similar objects. + slug (str): The clean watch.plex.tv URL identifier for the show. + studio (str): Studio that created show (Di Bonaventura Pictures; 21 Laps Entertainment). + subtitleLanguage (str): Setting that indicates the preferred subtitle language. + subtitleMode (int): Setting that indicates the auto-select subtitle mode. + (-1 = Account default, 0 = Manually selected, 1 = Shown with foreign audio, 2 = Always enabled). + tagline (str): Show tag line. + theme (str): URL to theme resource (/library/metadata/<ratingkey>/theme/<themeid>). + ultraBlurColors (:class:`~plexapi.media.UltraBlurColors`): Ultra blur color object. + useOriginalTitle (int): Setting that indicates if the original title is used for the show + (-1 = Library default, 0 = No, 1 = Yes). + viewedLeafCount (int): Number of items marked as played in the show view. + year (int): Year the show was released. + """ + TAG = 'Directory' + TYPE = 'show' + METADATA_TYPE = 'episode' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + Video._loadData(self, data) + self.audienceRating = utils.cast(float, data.attrib.get('audienceRating')) + self.audienceRatingImage = data.attrib.get('audienceRatingImage') + self.audioLanguage = data.attrib.get('audioLanguage', '') + self.autoDeletionItemPolicyUnwatchedLibrary = utils.cast( + int, data.attrib.get('autoDeletionItemPolicyUnwatchedLibrary', '0')) + self.autoDeletionItemPolicyWatchedLibrary = utils.cast( + int, data.attrib.get('autoDeletionItemPolicyWatchedLibrary', '0')) + self.childCount = utils.cast(int, data.attrib.get('childCount')) + self.collections = self.findItems(data, media.Collection) + self.contentRating = data.attrib.get('contentRating') + self.duration = utils.cast(int, data.attrib.get('duration')) + self.enableCreditsMarkerGeneration = utils.cast(int, data.attrib.get('enableCreditsMarkerGeneration', '-1')) + self.episodeSort = utils.cast(int, data.attrib.get('episodeSort', '-1')) + self.flattenSeasons = utils.cast(int, data.attrib.get('flattenSeasons', '-1')) + self.genres = self.findItems(data, media.Genre) + self.guids = self.findItems(data, media.Guid) + self.index = utils.cast(int, data.attrib.get('index')) + self.key = self.key.replace('/children', '') # FIX_BUG_50 + self.labels = self.findItems(data, media.Label) + self.languageOverride = data.attrib.get('languageOverride') + self.leafCount = utils.cast(int, data.attrib.get('leafCount')) + self.locations = self.listAttrs(data, 'path', etag='Location') + self.network = data.attrib.get('network') + self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') + self.originalTitle = data.attrib.get('originalTitle') + self.rating = utils.cast(float, data.attrib.get('rating')) + self.ratings = self.findItems(data, media.Rating) + self.roles = self.findItems(data, media.Role) + self.seasonCount = utils.cast(int, data.attrib.get('seasonCount', self.childCount)) + self.showOrdering = data.attrib.get('showOrdering') + self.similar = self.findItems(data, media.Similar) + self.slug = data.attrib.get('slug') + self.studio = data.attrib.get('studio') + self.subtitleLanguage = data.attrib.get('subtitleLanguage', '') + self.subtitleMode = utils.cast(int, data.attrib.get('subtitleMode', '-1')) + self.tagline = data.attrib.get('tagline') + self.theme = data.attrib.get('theme') + self.ultraBlurColors = self.findItem(data, media.UltraBlurColors) + self.useOriginalTitle = utils.cast(int, data.attrib.get('useOriginalTitle', '-1')) + self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount')) + self.year = utils.cast(int, data.attrib.get('year')) + + def __iter__(self): + for season in self.seasons(): + yield season + + @property + def actors(self): + """ Alias to self.roles. """ + return self.roles + + @property + def isPlayed(self): + """ Returns True if the show is fully played. """ + return bool(self.viewedLeafCount == self.leafCount) + + def onDeck(self): + """ Returns show's On Deck :class:`~plexapi.video.Video` object or `None`. + If show is unwatched, return will likely be the first episode. + """ + key = f'{self.key}?includeOnDeck=1' + return next(iter(self.fetchItems(key, cls=Episode, rtag='OnDeck')), None) + + def season(self, title=None, season=None): + """ Returns the season with the specified title or number. + + Parameters: + title (str): Title of the season to return. + season (int): Season number (default: None; required if title not specified). + + Raises: + :exc:`~plexapi.exceptions.BadRequest`: If title or season parameter is missing. + """ + key = f'{self.key}/children?excludeAllLeaves=1' + if title is not None and not isinstance(title, int): + return self.fetchItem(key, Season, title__iexact=title) + elif season is not None or isinstance(title, int): + if isinstance(title, int): + index = title + else: + index = season + return self.fetchItem(key, Season, index=index) + raise BadRequest('Missing argument: title or season is required') + + def seasons(self, **kwargs): + """ Returns a list of :class:`~plexapi.video.Season` objects in the show. """ + key = f'{self.key}/children?excludeAllLeaves=1' + return self.fetchItems(key, Season, container_size=self.childCount, **kwargs) + + def episode(self, title=None, season=None, episode=None): + """ Find a episode using a title or season and episode. + + Parameters: + title (str): Title of the episode to return + season (int): Season number (default: None; required if title not specified). + episode (int): Episode number (default: None; required if title not specified). + + Raises: + :exc:`~plexapi.exceptions.BadRequest`: If title or season and episode parameters are missing. + """ + key = f'{self.key}/allLeaves' + if title is not None: + return self.fetchItem(key, Episode, title__iexact=title) + elif season is not None and episode is not None: + return self.fetchItem(key, Episode, parentIndex=season, index=episode) + raise BadRequest('Missing argument: title or season and episode are required') + + def episodes(self, **kwargs): + """ Returns a list of :class:`~plexapi.video.Episode` objects in the show. """ + key = f'{self.key}/allLeaves' + return self.fetchItems(key, Episode, **kwargs) + + def get(self, title=None, season=None, episode=None): + """ Alias to :func:`~plexapi.video.Show.episode`. """ + return self.episode(title, season, episode) + + def watched(self): + """ Returns list of watched :class:`~plexapi.video.Episode` objects. """ + return self.episodes(viewCount__gt=0) + + def unwatched(self): + """ Returns list of unwatched :class:`~plexapi.video.Episode` objects. """ + return self.episodes(viewCount=0) + + def download(self, savepath=None, keep_original_name=False, subfolders=False, **kwargs): + """ Download all episodes from the show. 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 episodes in to season folders. + **kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.getStreamURL`. + """ + filepaths = [] + for episode in self.episodes(): + _savepath = os.path.join(savepath, f'Season {str(episode.seasonNumber).zfill(2)}') if subfolders else savepath + filepaths += episode.download(_savepath, keep_original_name, **kwargs) + return filepaths + + @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') / 'TV Shows' / guid_hash[0] / f'{guid_hash[1:]}.bundle') + + +@utils.registerPlexObject +class Season( + Video, + AdvancedSettingsMixin, ExtrasMixin, RatingMixin, + ArtMixin, LogoMixin, PosterMixin, ThemeUrlMixin, + SeasonEditMixins +): + """ Represents a single Season. + + Attributes: + TAG (str): 'Directory' + TYPE (str): 'season' + audienceRating (float): Audience rating. + audioLanguage (str): Setting that indicates the preferred audio language. + collections (List<:class:`~plexapi.media.Collection`>): List of collection objects. + guids (List<:class:`~plexapi.media.Guid`>): List of guid objects. + index (int): Season number. + key (str): API URL (/library/metadata/<ratingkey>). + labels (List<:class:`~plexapi.media.Label`>): List of label objects. + leafCount (int): Number of items in the season view. + parentGuid (str): Plex GUID for the show (plex://show/5d9c086fe9d5a1001f4d9fe6). + parentIndex (int): Plex index number for the show. + parentKey (str): API URL of the show (/library/metadata/<parentRatingKey>). + parentRatingKey (int): Unique key identifying the show. + parentSlug (str): The clean watch.plex.tv URL identifier for the show. + parentStudio (str): Studio that created show. + parentTheme (str): URL to show theme resource (/library/metadata/<parentRatingkey>/theme/<themeid>). + parentThumb (str): URL to show thumbnail image (/library/metadata/<parentRatingKey>/thumb/<thumbid>). + parentTitle (str): Name of the show for the season. + rating (float): Season rating (7.9; 9.8; 8.1). + ratings (List<:class:`~plexapi.media.Rating`>): List of rating objects. + subtitleLanguage (str): Setting that indicates the preferred subtitle language. + subtitleMode (int): Setting that indicates the auto-select subtitle mode. + (-1 = Series default, 0 = Manually selected, 1 = Shown with foreign audio, 2 = Always enabled). + ultraBlurColors (:class:`~plexapi.media.UltraBlurColors`): Ultra blur color object. + viewedLeafCount (int): Number of items marked as played in the season view. + year (int): Year the season was released. + """ + TAG = 'Directory' + TYPE = 'season' + METADATA_TYPE = 'episode' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + Video._loadData(self, data) + self.audienceRating = utils.cast(float, data.attrib.get('audienceRating')) + self.audioLanguage = data.attrib.get('audioLanguage', '') + self.collections = self.findItems(data, media.Collection) + self.guids = self.findItems(data, media.Guid) + self.index = utils.cast(int, data.attrib.get('index')) + 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.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.parentSlug = data.attrib.get('parentSlug') + self.parentStudio = data.attrib.get('parentStudio') + 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.ratings = self.findItems(data, media.Rating) + self.subtitleLanguage = data.attrib.get('subtitleLanguage', '') + self.subtitleMode = utils.cast(int, data.attrib.get('subtitleMode', '-1')) + 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 episode in self.episodes(): + yield episode + + def __repr__(self): + return '<{}>'.format( + ':'.join([p for p in [ + self.__class__.__name__, + self.key.replace('/library/metadata/', '').replace('/children', ''), + f"{self.parentTitle.replace(' ', '-')[:20]}-{self.seasonNumber}", + ] if p]) + ) + + @property + def isPlayed(self): + """ Returns True if the season is fully played. """ + return bool(self.viewedLeafCount == self.leafCount) + + @property + def seasonNumber(self): + """ Returns the season number. """ + return self.index + + def onDeck(self): + """ Returns season's On Deck :class:`~plexapi.video.Video` object or `None`. + Will only return a match if the show's On Deck episode is in this season. + """ + key = f'{self.key}?includeOnDeck=1' + return next(iter(self.fetchItems(key, cls=Episode, rtag='OnDeck')), None) + + def episode(self, title=None, episode=None): + """ Returns the episode with the given title or number. + + Parameters: + title (str): Title of the episode to return. + episode (int): Episode number (default: None; required if title not specified). + + Raises: + :exc:`~plexapi.exceptions.BadRequest`: If title or episode parameter is missing. + """ + key = f'{self.key}/children' + if title is not None and not isinstance(title, int): + return self.fetchItem(key, Episode, title__iexact=title) + elif episode is not None or isinstance(title, int): + if isinstance(title, int): + index = title + else: + index = episode + return self.fetchItem(key, Episode, parentIndex=self.index, index=index) + raise BadRequest('Missing argument: title or episode is required') + + def episodes(self, **kwargs): + """ Returns a list of :class:`~plexapi.video.Episode` objects in the season. """ + key = f'{self.key}/children' + return self.fetchItems(key, Episode, **kwargs) + + def get(self, title=None, episode=None): + """ Alias to :func:`~plexapi.video.Season.episode`. """ + return self.episode(title, episode) + + def show(self): + """ Return the season's :class:`~plexapi.video.Show`. """ + return self.fetchItem(self.parentKey) + + def watched(self): + """ Returns list of watched :class:`~plexapi.video.Episode` objects. """ + return self.episodes(viewCount__gt=0) + + def unwatched(self): + """ Returns list of unwatched :class:`~plexapi.video.Episode` objects. """ + return self.episodes(viewCount=0) + + def download(self, savepath=None, keep_original_name=False, **kwargs): + """ Download all episodes from the season. 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 episode in self.episodes(): + filepaths += episode.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.parentGuid) + return str(Path('Metadata') / 'TV Shows' / guid_hash[0] / f'{guid_hash[1:]}.bundle') + + +@utils.registerPlexObject +class Episode( + Video, Playable, + ExtrasMixin, RatingMixin, + ArtMixin, LogoMixin, PosterMixin, ThemeUrlMixin, + EpisodeEditMixins +): + """ Represents a single Episode. + + Attributes: + TAG (str): 'Video' + TYPE (str): 'episode' + audienceRating (float): Audience rating (TMDB or TVDB). + audienceRatingImage (str): Key to audience rating image (tmdb://image.rating). + chapters (List<:class:`~plexapi.media.Chapter`>): List of Chapter objects. + chapterSource (str): Chapter source (agent; media; mixed). + collections (List<:class:`~plexapi.media.Collection`>): List of collection objects. + contentRating (str) Content rating (PG-13; NR; TV-G). + directors (List<:class:`~plexapi.media.Director`>): List of director objects. + duration (int): Duration of the episode in milliseconds. + grandparentArt (str): URL to show artwork (/library/metadata/<grandparentRatingKey>/art/<artid>). + grandparentGuid (str): Plex GUID for the show (plex://show/5d9c086fe9d5a1001f4d9fe6). + grandparentKey (str): API URL of the show (/library/metadata/<grandparentRatingKey>). + grandparentRatingKey (int): Unique key identifying the show. + grandparentSlug (str): The clean watch.plex.tv URL identifier for the show. + grandparentTheme (str): URL to show theme resource (/library/metadata/<grandparentRatingkey>/theme/<themeid>). + grandparentThumb (str): URL to show thumbnail image (/library/metadata/<grandparentRatingKey>/thumb/<thumbid>). + grandparentTitle (str): Name of the show for the episode. + guids (List<:class:`~plexapi.media.Guid`>): List of guid objects. + index (int): Episode number. + labels (List<:class:`~plexapi.media.Label`>): List of label objects. + markers (List<:class:`~plexapi.media.Marker`>): List of marker objects. + media (List<:class:`~plexapi.media.Media`>): List of media objects. + originallyAvailableAt (datetime): Datetime the episode was released. + parentGuid (str): Plex GUID for the season (plex://season/5d9c09e42df347001e3c2a72). + parentIndex (int): Season number of episode. + parentKey (str): API URL of the season (/library/metadata/<parentRatingKey>). + parentRatingKey (int): Unique key identifying the season. + parentThumb (str): URL to season thumbnail image (/library/metadata/<parentRatingKey>/thumb/<thumbid>). + parentTitle (str): Name of the season for the episode. + parentYear (int): Year the season was released. + producers (List<:class:`~plexapi.media.Producer`>): List of producers objects. + rating (float): Episode rating (7.9; 9.8; 8.1). + ratings (List<:class:`~plexapi.media.Rating`>): List of rating objects. + roles (List<:class:`~plexapi.media.Role`>): List of role objects. + skipParent (bool): True if the show's seasons are set to hidden. + sourceURI (str): Remote server URI (server://<machineIdentifier>/com.plexapp.plugins.library) + (remote playlist item only). + ultraBlurColors (:class:`~plexapi.media.UltraBlurColors`): Ultra blur color object. + viewOffset (int): View offset in milliseconds. + writers (List<:class:`~plexapi.media.Writer`>): List of writers objects. + year (int): Year the episode was released. + """ + TAG = 'Video' + TYPE = 'episode' + METADATA_TYPE = 'episode' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + Video._loadData(self, data) + Playable._loadData(self, data) + self.audienceRating = utils.cast(float, data.attrib.get('audienceRating')) + self.audienceRatingImage = data.attrib.get('audienceRatingImage') + self.chapters = self.findItems(data, media.Chapter) + self.chapterSource = data.attrib.get('chapterSource') + self.collections = self.findItems(data, media.Collection) + self.contentRating = data.attrib.get('contentRating') + self.directors = self.findItems(data, media.Director) + self.duration = utils.cast(int, data.attrib.get('duration')) + 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.grandparentSlug = data.attrib.get('grandparentSlug') + 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.index = utils.cast(int, data.attrib.get('index')) + self.labels = self.findItems(data, media.Label) + self.markers = self.findItems(data, media.Marker) + 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.parentTitle = data.attrib.get('parentTitle') + self.parentYear = utils.cast(int, data.attrib.get('parentYear')) + self.producers = self.findItems(data, media.Producer) + self.rating = utils.cast(float, data.attrib.get('rating')) + self.ratings = self.findItems(data, media.Rating) + self.roles = self.findItems(data, media.Role) + self.skipParent = utils.cast(bool, data.attrib.get('skipParent', '0')) + self.sourceURI = data.attrib.get('source') # remote playlist item + self.ultraBlurColors = self.findItem(data, media.UltraBlurColors) + self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0)) + self.writers = self.findItems(data, media.Writer) + self.year = utils.cast(int, data.attrib.get('year')) + + # If seasons are hidden, parentKey and parentRatingKey are missing from the XML response. + # https://forums.plex.tv/t/parentratingkey-not-in-episode-xml-when-seasons-are-hidden/300553 + # Use cached properties below to return the correct values if they are missing to avoid auto-reloading. + self._parentKey = data.attrib.get('parentKey') + self._parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey')) + self._parentThumb = data.attrib.get('parentThumb') + + @cached_property + def parentKey(self): + """ Returns the parentKey. Refer to the Episode attributes. """ + if self._parentKey: + return self._parentKey + if self.parentRatingKey: + return f'/library/metadata/{self.parentRatingKey}' + return None + + @cached_property + def parentRatingKey(self): + """ Returns the parentRatingKey. Refer to the Episode attributes. """ + if self._parentRatingKey is not None: + return self._parentRatingKey + # Parse the parentRatingKey from the parentThumb + if self._parentThumb and self._parentThumb.startswith('/library/metadata/'): + return utils.cast(int, self._parentThumb.split('/')[3]) + # Get the parentRatingKey from the season's ratingKey if available + if self._season: + return self._season.ratingKey + return None + + @cached_property + def parentThumb(self): + """ Returns the parentThumb. Refer to the Episode attributes. """ + if self._parentThumb: + return self._parentThumb + if self._season: + return self._season.thumb + return None + + @cached_property + def _season(self): + """ Returns the :class:`~plexapi.video.Season` object by querying for the show's children. """ + if self.grandparentKey and self.parentIndex is not None: + return self.fetchItem(f'{self.grandparentKey}/children?excludeAllLeaves=1&index={self.parentIndex}') + return None + + def __repr__(self): + return '<{}>'.format( + ':'.join([p for p in [ + self.__class__.__name__, + self.key.replace('/library/metadata/', '').replace('/children', ''), + f"{self.grandparentTitle.replace(' ', '-')[:20]}-{self.seasonEpisode}", + ] if p]) + ) + + def _prettyfilename(self): + """ Returns a filename for use in download. """ + return f'{self.grandparentTitle} - {self.seasonEpisode} - {self.title}' + + @property + def actors(self): + """ Alias to self.roles. """ + return self.roles + + @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 episode. + + Returns: + List<str> of file paths where the episode is found on disk. + """ + return [part.file for part in self.iterParts() if part] + + @property + def episodeNumber(self): + """ Returns the episode number. """ + return self.index + + @cached_property + def seasonNumber(self): + """ Returns the episode's season number. """ + if isinstance(self.parentIndex, int): + return self.parentIndex + elif self._season: + return self._season.index + return None + + @property + def seasonEpisode(self): + """ Returns the s00e00 string containing the season and episode numbers. """ + return f's{str(self.seasonNumber).zfill(2)}e{str(self.episodeNumber).zfill(2)}' + + @property + def hasCommercialMarker(self): + """ Returns True if the episode has a commercial marker. """ + return any(marker.type == 'commercial' for marker in self.markers) + + @property + def hasIntroMarker(self): + """ Returns True if the episode has an intro marker. """ + return any(marker.type == 'intro' for marker in self.markers) + + @property + def hasCreditsMarker(self): + """ Returns True if the episode has a credits marker. """ + return any(marker.type == 'credits' for marker in self.markers) + + @property + def hasVoiceActivity(self): + """ Returns True if any of the media has voice activity analyzed. """ + return any(media.hasVoiceActivity for media in self.media) + + @property + def hasPreviewThumbnails(self): + """ Returns True if any of the media parts has generated preview (BIF) thumbnails. """ + return any(part.hasPreviewThumbnails for media in self.media for part in media.parts) + + def season(self): + """" Return the episode's :class:`~plexapi.video.Season`. """ + return self.fetchItem(self.parentKey) + + def show(self): + """" Return the episode's :class:`~plexapi.video.Show`. """ + return self.fetchItem(self.grandparentKey) + + def _defaultSyncTitle(self): + """ Returns str, default title for a new syncItem. """ + return f'{self.grandparentTitle} - {self.parentTitle} - ({self.seasonEpisode}) {self.title}' + + def removeFromContinueWatching(self): + """ Remove the movie from continue watching. """ + key = '/actions/removeFromContinueWatching' + params = {'ratingKey': self.ratingKey} + self._server.query(key, params=params, method=self._server._session.put) + return self + + @property + def metadataDirectory(self): + """ Returns the Plex Media Server data directory where the metadata is stored. """ + guid_hash = utils.sha1hash(self.grandparentGuid) + return str(Path('Metadata') / 'TV Shows' / guid_hash[0] / f'{guid_hash[1:]}.bundle') + + +@utils.registerPlexObject +class Clip( + Video, Playable, + ArtUrlMixin, PosterUrlMixin +): + """ Represents a single Clip. + + Attributes: + TAG (str): 'Video' + TYPE (str): 'clip' + duration (int): Duration of the clip in milliseconds. + extraType (int): Unknown. + index (int): Plex index number for the clip. + media (List<:class:`~plexapi.media.Media`>): List of media objects. + originallyAvailableAt (datetime): Datetime the clip was released. + skipDetails (int): Unknown. + subtype (str): Type of clip (trailer, behindTheScenes, sceneOrSample, etc.). + thumbAspectRatio (str): Aspect ratio of the thumbnail image. + viewOffset (int): View offset in milliseconds. + year (int): Year clip was released. + """ + TAG = 'Video' + TYPE = 'clip' + METADATA_TYPE = 'clip' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + Video._loadData(self, data) + Playable._loadData(self, data) + self._data = data + self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) + self.duration = utils.cast(int, data.attrib.get('duration')) + self.extraType = utils.cast(int, data.attrib.get('extraType')) + self.index = utils.cast(int, data.attrib.get('index')) + self.media = self.findItems(data, media.Media) + self.originallyAvailableAt = utils.toDatetime( + data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') + self.skipDetails = utils.cast(int, data.attrib.get('skipDetails')) + self.subtype = data.attrib.get('subtype') + self.thumbAspectRatio = data.attrib.get('thumbAspectRatio') + 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 clip. + + Returns: + List<str> of file paths where the clip is found on disk. + """ + return [part.file for part in self.iterParts() if part] + + def _prettyfilename(self): + """ Returns a filename for use in download. """ + return 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') / 'Movies' / guid_hash[0] / f'{guid_hash[1:]}.bundle') + + +class Extra(Clip): + """ Represents a single Extra (trailer, behindTheScenes, etc). """ + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + super(Extra, self)._loadData(data) + parent = self._parent() + self.librarySectionID = parent.librarySectionID + self.librarySectionKey = parent.librarySectionKey + self.librarySectionTitle = parent.librarySectionTitle + + def _prettyfilename(self): + """ Returns a filename for use in download. """ + return f'{self.title} ({self.subtype})' + + +@utils.registerPlexObject +class MovieSession(PlexSession, Movie): + """ Represents a single Movie session + loaded from :func:`~plexapi.server.PlexServer.sessions`. + """ + _SESSIONTYPE = True + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + Movie._loadData(self, data) + PlexSession._loadData(self, data) + + +@utils.registerPlexObject +class EpisodeSession(PlexSession, Episode): + """ Represents a single Episode session + loaded from :func:`~plexapi.server.PlexServer.sessions`. + """ + _SESSIONTYPE = True + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + Episode._loadData(self, data) + PlexSession._loadData(self, data) + + +@utils.registerPlexObject +class ClipSession(PlexSession, Clip): + """ Represents a single Clip session + loaded from :func:`~plexapi.server.PlexServer.sessions`. + """ + _SESSIONTYPE = True + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + Clip._loadData(self, data) + PlexSession._loadData(self, data) + + +@utils.registerPlexObject +class MovieHistory(PlexHistory, Movie): + """ Represents a single Movie history entry + loaded from :func:`~plexapi.server.PlexServer.history`. + """ + _HISTORYTYPE = True + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + Movie._loadData(self, data) + PlexHistory._loadData(self, data) + + +@utils.registerPlexObject +class EpisodeHistory(PlexHistory, Episode): + """ Represents a single Episode history entry + loaded from :func:`~plexapi.server.PlexServer.history`. + """ + _HISTORYTYPE = True + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + Episode._loadData(self, data) + PlexHistory._loadData(self, data) + + +@utils.registerPlexObject +class ClipHistory(PlexHistory, Clip): + """ Represents a single Clip history entry + loaded from :func:`~plexapi.server.PlexServer.history`. + """ + _HISTORYTYPE = True + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + Clip._loadData(self, data) + PlexHistory._loadData(self, data) diff --git a/libs/version.txt b/libs/version.txt index 7311ddbbc..79d29c195 100644 --- a/libs/version.txt +++ b/libs/version.txt @@ -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