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: `` ()``
+ * Episode: `` - s00e00 - ``
+ * Track: `` - - 00 -