mirror of
https://github.com/morpheus65535/bazarr.git
synced 2025-04-24 06:37:16 -04:00
Added feature to change "added" info in Plex once subtitles are downloaded
This commit is contained in:
parent
2fc8f10a94
commit
fe7b224916
40 changed files with 16963 additions and 1 deletions
|
@ -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']),
|
||||
|
|
1
bazarr/plex/__init__.py
Normal file
1
bazarr/plex/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
# coding=utf-8
|
27
bazarr/plex/operations.py
Normal file
27
bazarr/plex/operations.py
Normal file
|
@ -0,0 +1,27 @@
|
|||
# coding=utf-8
|
||||
from datetime import datetime
|
||||
from app.config import settings
|
||||
from plexapi.server import PlexServer
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def plex_set_added_date_now(movie_metadata):
|
||||
try:
|
||||
if settings.plex.ssl:
|
||||
protocol_plex = "https://"
|
||||
else:
|
||||
protocol_plex = "http://"
|
||||
|
||||
baseurl = f'{protocol_plex}{settings.plex.ip}:{settings.plex.port}'
|
||||
token = settings.plex.apikey
|
||||
plex = PlexServer(baseurl, token)
|
||||
library = plex.library.section(settings.plex.movie_library)
|
||||
video = library.getGuid(guid=movie_metadata.imdbId)
|
||||
# Get the current date and time in the desired format
|
||||
current_date = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
updates = {"addedAt.value": current_date}
|
||||
video.edit(**updates)
|
||||
except Exception as e:
|
||||
logger.error(f"A Plex error occurred: {e}")
|
|
@ -11,6 +11,7 @@ from app.database import TableEpisodes, TableMovies, database, select
|
|||
from utilities.analytics import event_tracker
|
||||
from radarr.notify import notify_radarr
|
||||
from sonarr.notify import notify_sonarr
|
||||
from plex.operations import plex_set_added_date_now
|
||||
from app.event_handler import event_stream
|
||||
|
||||
from .utils import _get_download_code3
|
||||
|
@ -95,7 +96,7 @@ def process_subtitle(subtitle, media_type, audio_language, path, max_score, is_u
|
|||
sonarr_episode_id=episode_metadata.sonarrEpisodeId)
|
||||
else:
|
||||
movie_metadata = database.execute(
|
||||
select(TableMovies.radarrId)
|
||||
select(TableMovies.radarrId, TableMovies.imdbId)
|
||||
.where(TableMovies.path == path_mappings.path_replace_reverse_movie(path)))\
|
||||
.first()
|
||||
if not movie_metadata:
|
||||
|
@ -145,6 +146,8 @@ def process_subtitle(subtitle, media_type, audio_language, path, max_score, is_u
|
|||
reversed_subtitles_path = path_mappings.path_replace_reverse_movie(downloaded_path)
|
||||
notify_radarr(movie_metadata.radarrId)
|
||||
event_stream(type='movie-wanted', action='delete', payload=movie_metadata.radarrId)
|
||||
if settings.plex.set_added is True:
|
||||
plex_set_added_date_now(movie_metadata)
|
||||
|
||||
event_tracker.track_subtitles(provider=downloaded_provider, action=action, language=downloaded_language)
|
||||
|
||||
|
|
|
@ -34,6 +34,7 @@ import SeriesMassEditor from "@/pages/Series/Editor";
|
|||
import SettingsGeneralView from "@/pages/Settings/General";
|
||||
import SettingsLanguagesView from "@/pages/Settings/Languages";
|
||||
import SettingsNotificationsView from "@/pages/Settings/Notifications";
|
||||
import SettingsPlexView from "@/pages/Settings/Plex";
|
||||
import SettingsProvidersView from "@/pages/Settings/Providers";
|
||||
import SettingsRadarrView from "@/pages/Settings/Radarr";
|
||||
import SettingsSchedulerView from "@/pages/Settings/Scheduler";
|
||||
|
@ -222,6 +223,11 @@ function useRoutes(): CustomRouteObject[] {
|
|||
name: "Radarr",
|
||||
element: <SettingsRadarrView></SettingsRadarrView>,
|
||||
},
|
||||
{
|
||||
path: "plex",
|
||||
name: "Plex",
|
||||
element: <SettingsPlexView></SettingsPlexView>,
|
||||
},
|
||||
{
|
||||
path: "notifications",
|
||||
name: "Notifications",
|
||||
|
|
46
frontend/src/pages/Settings/Plex/index.tsx
Normal file
46
frontend/src/pages/Settings/Plex/index.tsx
Normal file
|
@ -0,0 +1,46 @@
|
|||
import { FunctionComponent } from "react";
|
||||
import {
|
||||
Check,
|
||||
CollapseBox,
|
||||
Layout,
|
||||
Message,
|
||||
Number,
|
||||
Section,
|
||||
Text,
|
||||
} from "@/pages/Settings/components";
|
||||
import { plexEnabledKey } from "@/pages/Settings/keys";
|
||||
|
||||
const SettingsPlexView: FunctionComponent = () => {
|
||||
return (
|
||||
<Layout name="Interface">
|
||||
<Section header="Use Plex integration">
|
||||
<Check label="Enabled" settingKey={plexEnabledKey}></Check>
|
||||
</Section>
|
||||
<CollapseBox settingKey={plexEnabledKey}>
|
||||
<Section header="Host">
|
||||
<Text label="Address" settingKey="settings-plex-ip"></Text>
|
||||
<Number
|
||||
label="Port"
|
||||
settingKey="settings-plex-port"
|
||||
defaultValue={32400}
|
||||
></Number>
|
||||
<Message>Hostname or IPv4 Address</Message>
|
||||
<Text label="API Token" settingKey="settings-plex-apikey"></Text>
|
||||
<Check label="SSL" settingKey="settings-plex-ssl"></Check>
|
||||
</Section>
|
||||
<Section header="Movie editing">
|
||||
<Text
|
||||
label="Name of the library"
|
||||
settingKey="settings-plex-movie_library"
|
||||
></Text>
|
||||
<Check
|
||||
label="Set the movie as recently added after downloading the subtitles"
|
||||
settingKey="settings-plex-set_added"
|
||||
></Check>
|
||||
</Section>
|
||||
</CollapseBox>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsPlexView;
|
|
@ -12,3 +12,4 @@ export const pathMappingsMovieKey = "settings-general-path_mappings_movie";
|
|||
|
||||
export const seriesEnabledKey = "settings-general-use_sonarr";
|
||||
export const moviesEnabledKey = "settings-general-use_radarr";
|
||||
export const plexEnabledKey = "settings-general-use_plex";
|
||||
|
|
9
frontend/src/types/settings.d.ts
vendored
9
frontend/src/types/settings.d.ts
vendored
|
@ -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;
|
||||
}
|
||||
|
|
7
libs/PlexAPI-4.16.1.dist-info/AUTHORS.txt
Normal file
7
libs/PlexAPI-4.16.1.dist-info/AUTHORS.txt
Normal file
|
@ -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)
|
1
libs/PlexAPI-4.16.1.dist-info/INSTALLER
Normal file
1
libs/PlexAPI-4.16.1.dist-info/INSTALLER
Normal file
|
@ -0,0 +1 @@
|
|||
pip
|
25
libs/PlexAPI-4.16.1.dist-info/LICENSE.txt
Normal file
25
libs/PlexAPI-4.16.1.dist-info/LICENSE.txt
Normal file
|
@ -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.
|
282
libs/PlexAPI-4.16.1.dist-info/METADATA
Normal file
282
libs/PlexAPI-4.16.1.dist-info/METADATA
Normal file
|
@ -0,0 +1,282 @@
|
|||
Metadata-Version: 2.1
|
||||
Name: PlexAPI
|
||||
Version: 4.16.1
|
||||
Summary: Python bindings for the Plex API.
|
||||
Author-email: Michael Shepanski <michael.shepanski@gmail.com>
|
||||
License: BSD-3-Clause
|
||||
Project-URL: Homepage, https://github.com/pkkid/python-plexapi
|
||||
Project-URL: Documentation, https://python-plexapi.readthedocs.io
|
||||
Keywords: plex,api
|
||||
Classifier: Operating System :: OS Independent
|
||||
Classifier: Programming Language :: Python :: 3
|
||||
Classifier: License :: OSI Approved :: BSD License
|
||||
Requires-Python: >=3.9
|
||||
Description-Content-Type: text/x-rst
|
||||
License-File: LICENSE.txt
|
||||
License-File: AUTHORS.txt
|
||||
Requires-Dist: requests
|
||||
Provides-Extra: alert
|
||||
Requires-Dist: websocket-client (>=1.3.3) ; extra == 'alert'
|
||||
|
||||
Python-PlexAPI
|
||||
==============
|
||||
.. image:: https://github.com/pkkid/python-plexapi/workflows/CI/badge.svg
|
||||
:target: https://github.com/pkkid/python-plexapi/actions?query=workflow%3ACI
|
||||
.. image:: https://readthedocs.org/projects/python-plexapi/badge/?version=latest
|
||||
:target: http://python-plexapi.readthedocs.io/en/latest/?badge=latest
|
||||
.. image:: https://codecov.io/gh/pkkid/python-plexapi/branch/master/graph/badge.svg?token=fOECznuMtw
|
||||
:target: https://codecov.io/gh/pkkid/python-plexapi
|
||||
.. image:: https://img.shields.io/github/tag/pkkid/python-plexapi.svg?label=github+release
|
||||
:target: https://github.com/pkkid/python-plexapi/releases
|
||||
.. image:: https://badge.fury.io/py/PlexAPI.svg
|
||||
:target: https://badge.fury.io/py/PlexAPI
|
||||
.. image:: https://img.shields.io/github/last-commit/pkkid/python-plexapi.svg
|
||||
:target: https://img.shields.io/github/last-commit/pkkid/python-plexapi.svg
|
||||
|
||||
|
||||
Overview
|
||||
--------
|
||||
Unofficial Python bindings for the Plex API. Our goal is to match all capabilities of the official
|
||||
Plex Web Client. A few of the many features we currently support are:
|
||||
|
||||
* Navigate local or remote shared libraries.
|
||||
* Perform library actions such as scan, analyze, empty trash.
|
||||
* Remote control and play media on connected clients, including `Controlling Sonos speakers`_
|
||||
* Listen in on all Plex Server notifications.
|
||||
|
||||
|
||||
Installation & Documentation
|
||||
----------------------------
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
pip install plexapi
|
||||
|
||||
*Install extra features:*
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
pip install plexapi[alert] # Install with dependencies required for plexapi.alert
|
||||
|
||||
Documentation_ can be found at Read the Docs.
|
||||
|
||||
.. _Documentation: http://python-plexapi.readthedocs.io/en/latest/
|
||||
|
||||
Join our Discord_ for support and discussion.
|
||||
|
||||
.. _Discord: https://discord.gg/GtAnnZAkuw
|
||||
|
||||
|
||||
Getting a PlexServer Instance
|
||||
-----------------------------
|
||||
|
||||
There are two types of authentication. If you are running on a separate network
|
||||
or using Plex Users you can log into MyPlex to get a PlexServer instance. An
|
||||
example of this is below. NOTE: Servername below is the name of the server (not
|
||||
the hostname and port). If logged into Plex Web you can see the server name in
|
||||
the top left above your available libraries.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from plexapi.myplex import MyPlexAccount
|
||||
account = MyPlexAccount('<USERNAME>', '<PASSWORD>')
|
||||
plex = account.resource('<SERVERNAME>').connect() # returns a PlexServer instance
|
||||
|
||||
If you want to avoid logging into MyPlex and you already know your auth token
|
||||
string, you can use the PlexServer object directly as above, by passing in
|
||||
the baseurl and auth token directly.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from plexapi.server import PlexServer
|
||||
baseurl = 'http://plexserver:32400'
|
||||
token = '2ffLuB84dqLswk9skLos'
|
||||
plex = PlexServer(baseurl, token)
|
||||
|
||||
|
||||
Usage Examples
|
||||
--------------
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Example 1: List all unwatched movies.
|
||||
movies = plex.library.section('Movies')
|
||||
for video in movies.search(unwatched=True):
|
||||
print(video.title)
|
||||
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Example 2: Mark all Game of Thrones episodes as played.
|
||||
plex.library.section('TV Shows').get('Game of Thrones').markPlayed()
|
||||
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Example 3: List all clients connected to the Server.
|
||||
for client in plex.clients():
|
||||
print(client.title)
|
||||
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Example 4: Play the movie Cars on another client.
|
||||
# Note: Client must be on same network as server.
|
||||
cars = plex.library.section('Movies').get('Cars')
|
||||
client = plex.client("Michael's iPhone")
|
||||
client.playMedia(cars)
|
||||
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Example 5: List all content with the word 'Game' in the title.
|
||||
for video in plex.search('Game'):
|
||||
print(f'{video.title} ({video.TYPE})')
|
||||
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Example 6: List all movies directed by the same person as Elephants Dream.
|
||||
movies = plex.library.section('Movies')
|
||||
elephants_dream = movies.get('Elephants Dream')
|
||||
director = elephants_dream.directors[0]
|
||||
for movie in movies.search(None, director=director):
|
||||
print(movie.title)
|
||||
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Example 7: List files for the latest episode of The 100.
|
||||
last_episode = plex.library.section('TV Shows').get('The 100').episodes()[-1]
|
||||
for part in last_episode.iterParts():
|
||||
print(part.file)
|
||||
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Example 8: Get audio/video/all playlists
|
||||
for playlist in plex.playlists():
|
||||
print(playlist.title)
|
||||
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Example 9: Rate the 100 four stars.
|
||||
plex.library.section('TV Shows').get('The 100').rate(8.0)
|
||||
|
||||
|
||||
Controlling Sonos speakers
|
||||
--------------------------
|
||||
|
||||
To control Sonos speakers directly using Plex APIs, the following requirements must be met:
|
||||
|
||||
1. Active Plex Pass subscription
|
||||
2. Sonos account linked to Plex account
|
||||
3. Plex remote access enabled
|
||||
|
||||
Due to the design of Sonos music services, the API calls to control Sonos speakers route through https://sonos.plex.tv
|
||||
and back via the Plex server's remote access. Actual media playback is local unless networking restrictions prevent the
|
||||
Sonos speakers from connecting to the Plex server directly.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from plexapi.myplex import MyPlexAccount
|
||||
from plexapi.server import PlexServer
|
||||
|
||||
baseurl = 'http://plexserver:32400'
|
||||
token = '2ffLuB84dqLswk9skLos'
|
||||
|
||||
account = MyPlexAccount(token)
|
||||
server = PlexServer(baseurl, token)
|
||||
|
||||
# List available speakers/groups
|
||||
for speaker in account.sonos_speakers():
|
||||
print(speaker.title)
|
||||
|
||||
# Obtain PlexSonosPlayer instance
|
||||
speaker = account.sonos_speaker("Kitchen")
|
||||
|
||||
album = server.library.section('Music').get('Stevie Wonder').album('Innervisions')
|
||||
|
||||
# Speaker control examples
|
||||
speaker.playMedia(album)
|
||||
speaker.pause()
|
||||
speaker.setVolume(10)
|
||||
speaker.skipNext()
|
||||
|
||||
|
||||
Running tests over PlexAPI
|
||||
--------------------------
|
||||
|
||||
Use:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
tools/plex-boostraptest.py
|
||||
|
||||
with appropriate
|
||||
arguments and add this new server to a shared user which username is defined in environment variable `SHARED_USERNAME`.
|
||||
It uses `official docker image`_ to create a proper instance.
|
||||
|
||||
For skipping the docker and reuse a existing server use
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
python plex-bootstraptest.py --no-docker --username USERNAME --password PASSWORD --server-name NAME-OF-YOUR-SEVER
|
||||
|
||||
Also in order to run most of the tests you have to provide some environment variables:
|
||||
|
||||
* `PLEXAPI_AUTH_SERVER_BASEURL` containing an URL to your Plex instance, e.g. `http://127.0.0.1:32400` (without trailing
|
||||
slash)
|
||||
* `PLEXAPI_AUTH_MYPLEX_USERNAME` and `PLEXAPI_AUTH_MYPLEX_PASSWORD` with your MyPlex username and password accordingly
|
||||
|
||||
After this step you can run tests with following command:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
py.test tests -rxXs --ignore=tests/test_sync.py
|
||||
|
||||
Some of the tests in main test-suite require a shared user in your account (e.g. `test_myplex_users`,
|
||||
`test_myplex_updateFriend`, etc.), you need to provide a valid shared user's username to get them running you need to
|
||||
provide the username of the shared user as an environment variable `SHARED_USERNAME`. You can enable a Guest account and
|
||||
simply pass `Guest` as `SHARED_USERNAME` (or just create a user like `plexapitest` and play with it).
|
||||
|
||||
To be able to run tests over Mobile Sync api you have to some some more environment variables, to following values
|
||||
exactly:
|
||||
|
||||
* PLEXAPI_HEADER_PROVIDES='controller,sync-target'
|
||||
* PLEXAPI_HEADER_PLATFORM=iOS
|
||||
* PLEXAPI_HEADER_PLATFORM_VERSION=11.4.1
|
||||
* PLEXAPI_HEADER_DEVICE=iPhone
|
||||
|
||||
And finally run the sync-related tests:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
py.test tests/test_sync.py -rxXs
|
||||
|
||||
.. _official docker image: https://hub.docker.com/r/plexinc/pms-docker/
|
||||
|
||||
Common Questions
|
||||
----------------
|
||||
|
||||
**Why are you using camelCase and not following PEP8 guidelines?**
|
||||
|
||||
This API reads XML documents provided by MyPlex and the Plex Server.
|
||||
We decided to conform to their style so that the API variable names directly
|
||||
match with the provided XML documents.
|
||||
|
||||
|
||||
**Why don't you offer feature XYZ?**
|
||||
|
||||
This library is meant to be a wrapper around the XML pages the Plex
|
||||
server provides. If we are not providing an API that is offered in the
|
||||
XML pages, please let us know! -- Adding additional features beyond that
|
||||
should be done outside the scope of this library.
|
||||
|
||||
|
||||
**What are some helpful links if trying to understand the raw Plex API?**
|
||||
|
||||
* https://github.com/plexinc/plex-media-player/wiki/Remote-control-API
|
||||
* https://forums.plex.tv/discussion/104353/pms-web-api-documentation
|
||||
* https://github.com/Arcanemagus/plex-api/wiki
|
31
libs/PlexAPI-4.16.1.dist-info/RECORD
Normal file
31
libs/PlexAPI-4.16.1.dist-info/RECORD
Normal file
|
@ -0,0 +1,31 @@
|
|||
PlexAPI-4.16.1.dist-info/AUTHORS.txt,sha256=iEonabCDE0G6AnfT0tCcppsJ0AaTJZGhRjIM4lIIAck,228
|
||||
PlexAPI-4.16.1.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
|
||||
PlexAPI-4.16.1.dist-info/LICENSE.txt,sha256=ZmoFInlwd6lOpMMQCWIbjLLtu4pwhwWArg_dnYS3X5A,1515
|
||||
PlexAPI-4.16.1.dist-info/METADATA,sha256=Wqd-vI8B0Geygwyrt4NqBcSsuUZxoDxqBHbLtKjz6Wc,9284
|
||||
PlexAPI-4.16.1.dist-info/RECORD,,
|
||||
PlexAPI-4.16.1.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
PlexAPI-4.16.1.dist-info/WHEEL,sha256=2wepM1nk4DS4eFpYrW1TTqPcoGNfHhhO_i5m4cOimbo,92
|
||||
PlexAPI-4.16.1.dist-info/top_level.txt,sha256=PTwXHiZDiXtrZnSI7lpZkRz1oJs5DyYpiiu_FuhuSlk,8
|
||||
plexapi/__init__.py,sha256=rsy6uvdxBP64y4v5lC4yLTP3l5VY3S-Rsk8rE_gDPIM,2144
|
||||
plexapi/alert.py,sha256=pSAIwtzsOnY2b97137dG_8YZOpSBxmKJ_kRz0oZw5jA,4065
|
||||
plexapi/audio.py,sha256=A6hI88X3nP2fTiXMMu7Y_-iRGOtr6K_iRJtw2yzuX6g,29505
|
||||
plexapi/base.py,sha256=aeCngmI8GHicvzIurfGoPFtAfFJXbMJzZ8b8Fi0d3yo,49100
|
||||
plexapi/client.py,sha256=IMbtTVes6_XFO6KBOtMqX4DDvY-iC-94lzb4wdIzYS8,27906
|
||||
plexapi/collection.py,sha256=Cv4xQQMY0YzfrD4FJirUwepPbq28CkJOiCPauNueaQQ,26267
|
||||
plexapi/config.py,sha256=1kiGaq-DooB9zy5KvMLls6_FyIyPIXwddh0zjcmpD-U,2696
|
||||
plexapi/const.py,sha256=0tyh_Wsx9JgzwWD0mCyTM6cLnFtukiMEhUqn9ibDSIU,239
|
||||
plexapi/exceptions.py,sha256=yQYnQk07EQcwvFGJ44rXPt9Q3L415BYqyxxOCj2R8CI,683
|
||||
plexapi/gdm.py,sha256=SVi6uZu5pCuLNUAPIm8WeIJy1J55NTtN-bsBnTvB6Ec,5066
|
||||
plexapi/library.py,sha256=ceJryNLApNus7MCmQ8nm4HuO2_UKBgTdD1EGVI-u6yA,143847
|
||||
plexapi/media.py,sha256=Gsx8IqSUF71Qg4fCVQiEdPcepc3-i0jlgowEVgZiAj0,56451
|
||||
plexapi/mixins.py,sha256=hICrNwbVznjPDibsQHiHXMQ2T4fDooszlR7U47wJ3QM,49090
|
||||
plexapi/myplex.py,sha256=AQR2ZHM045-OAF8JN-f4yKMwFFO0B1r_eQPBsgVW0ps,99769
|
||||
plexapi/photo.py,sha256=eOyn_0wbXLQ7r0zADWbRTfbuRv70_NNN1DViwG2nW24,15702
|
||||
plexapi/playlist.py,sha256=SABCcXfDs3fLE_N0rUwqAkKTbduscQ6cDGpGoutGsrU,24436
|
||||
plexapi/playqueue.py,sha256=MU8fZMyTNTZOIJuPkNSGXijDAGeAuAVAiurtGzVFxG0,12937
|
||||
plexapi/server.py,sha256=tojLUl4sJdu2qnCwu0f_kac5_LKVfEI9SN5qJ553tms,64062
|
||||
plexapi/settings.py,sha256=3suRjHsJUBeRG61WXLpjmNxoTiRFLJMcuZZbzkaDK_Q,7149
|
||||
plexapi/sonos.py,sha256=tIr216CC-o2Vk8GLxsNPkXeyq4JYs9pgz244wbbFfgA,5099
|
||||
plexapi/sync.py,sha256=1NK-oeUKVvNnLFIVAq8d8vy2jG8Nu4gkQB295Qx2xYE,13728
|
||||
plexapi/utils.py,sha256=BvcUNCm_lPnDo5ny4aRlLtVT6KobVG4EqwPjN4w3kAc,24246
|
||||
plexapi/video.py,sha256=9DUhtyA1KCwVN8IoJUfa_kUlZEbplWFCli6-D0nr__k,62939
|
0
libs/PlexAPI-4.16.1.dist-info/REQUESTED
Normal file
0
libs/PlexAPI-4.16.1.dist-info/REQUESTED
Normal file
5
libs/PlexAPI-4.16.1.dist-info/WHEEL
Normal file
5
libs/PlexAPI-4.16.1.dist-info/WHEEL
Normal file
|
@ -0,0 +1,5 @@
|
|||
Wheel-Version: 1.0
|
||||
Generator: bdist_wheel (0.38.4)
|
||||
Root-Is-Purelib: true
|
||||
Tag: py3-none-any
|
||||
|
1
libs/PlexAPI-4.16.1.dist-info/top_level.txt
Normal file
1
libs/PlexAPI-4.16.1.dist-info/top_level.txt
Normal file
|
@ -0,0 +1 @@
|
|||
plexapi
|
53
libs/plexapi/__init__.py
Normal file
53
libs/plexapi/__init__.py
Normal file
|
@ -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)
|
97
libs/plexapi/alert.py
Normal file
97
libs/plexapi/alert.py
Normal file
|
@ -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)
|
609
libs/plexapi/audio.py
Normal file
609
libs/plexapi/audio.py
Normal file
|
@ -0,0 +1,609 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from typing import Any, Dict, List, Optional, TypeVar
|
||||
|
||||
from plexapi import media, utils
|
||||
from plexapi.base import Playable, PlexPartialObject, PlexHistory, PlexSession
|
||||
from plexapi.exceptions import BadRequest
|
||||
from plexapi.mixins import (
|
||||
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, PlayedUnplayedMixin, RatingMixin,
|
||||
ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin, ThemeMixin, ThemeUrlMixin,
|
||||
ArtistEditMixins, AlbumEditMixins, TrackEditMixins
|
||||
)
|
||||
from plexapi.playlist import Playlist
|
||||
|
||||
|
||||
TAudio = TypeVar("TAudio", bound="Audio")
|
||||
TTrack = TypeVar("TTrack", bound="Track")
|
||||
|
||||
|
||||
class Audio(PlexPartialObject, PlayedUnplayedMixin):
|
||||
""" Base class for all audio objects including :class:`~plexapi.audio.Artist`,
|
||||
:class:`~plexapi.audio.Album`, and :class:`~plexapi.audio.Track`.
|
||||
|
||||
Attributes:
|
||||
addedAt (datetime): Datetime the item was added to the library.
|
||||
art (str): URL to artwork image (/library/metadata/<ratingKey>/art/<artid>).
|
||||
artBlurHash (str): BlurHash string for artwork image.
|
||||
distance (float): Sonic Distance of the item from the seed item.
|
||||
fields (List<:class:`~plexapi.media.Field`>): List of field objects.
|
||||
guid (str): Plex GUID for the artist, album, or track (plex://artist/5d07bcb0403c64029053ac4c).
|
||||
images (List<:class:`~plexapi.media.Image`>): List of image objects.
|
||||
index (int): Plex index number (often the track number).
|
||||
key (str): API URL (/library/metadata/<ratingkey>).
|
||||
lastRatedAt (datetime): Datetime the item was last rated.
|
||||
lastViewedAt (datetime): Datetime the item was last played.
|
||||
librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID.
|
||||
librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key.
|
||||
librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title.
|
||||
listType (str): Hardcoded as 'audio' (useful for search filters).
|
||||
moods (List<:class:`~plexapi.media.Mood`>): List of mood objects.
|
||||
musicAnalysisVersion (int): The Plex music analysis version for the item.
|
||||
ratingKey (int): Unique key identifying the item.
|
||||
summary (str): Summary of the artist, album, or track.
|
||||
thumb (str): URL to thumbnail image (/library/metadata/<ratingKey>/thumb/<thumbid>).
|
||||
thumbBlurHash (str): BlurHash string for thumbnail image.
|
||||
title (str): Name of the artist, album, or track (Jason Mraz, We Sing, Lucky, etc.).
|
||||
titleSort (str): Title to use when sorting (defaults to title).
|
||||
type (str): 'artist', 'album', or 'track'.
|
||||
updatedAt (datetime): Datetime the item was updated.
|
||||
userRating (float): Rating of the item (0.0 - 10.0) equaling (0 stars - 5 stars).
|
||||
viewCount (int): Count of times the item was played.
|
||||
"""
|
||||
METADATA_TYPE = 'track'
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self._data = data
|
||||
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
|
||||
self.art = data.attrib.get('art')
|
||||
self.artBlurHash = data.attrib.get('artBlurHash')
|
||||
self.distance = utils.cast(float, data.attrib.get('distance'))
|
||||
self.fields = self.findItems(data, media.Field)
|
||||
self.guid = data.attrib.get('guid')
|
||||
self.images = self.findItems(data, media.Image)
|
||||
self.index = utils.cast(int, data.attrib.get('index'))
|
||||
self.key = data.attrib.get('key', '')
|
||||
self.lastRatedAt = utils.toDatetime(data.attrib.get('lastRatedAt'))
|
||||
self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt'))
|
||||
self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID'))
|
||||
self.librarySectionKey = data.attrib.get('librarySectionKey')
|
||||
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
|
||||
self.listType = 'audio'
|
||||
self.moods = self.findItems(data, media.Mood)
|
||||
self.musicAnalysisVersion = utils.cast(int, data.attrib.get('musicAnalysisVersion'))
|
||||
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
|
||||
self.summary = data.attrib.get('summary')
|
||||
self.thumb = data.attrib.get('thumb')
|
||||
self.thumbBlurHash = data.attrib.get('thumbBlurHash')
|
||||
self.title = data.attrib.get('title')
|
||||
self.titleSort = data.attrib.get('titleSort', self.title)
|
||||
self.type = data.attrib.get('type')
|
||||
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
|
||||
self.userRating = utils.cast(float, data.attrib.get('userRating'))
|
||||
self.viewCount = utils.cast(int, data.attrib.get('viewCount', 0))
|
||||
|
||||
def url(self, part):
|
||||
""" Returns the full URL for the audio item. Typically used for getting a specific track. """
|
||||
return self._server.url(part, includeToken=True) if part else None
|
||||
|
||||
def _defaultSyncTitle(self):
|
||||
""" Returns str, default title for a new syncItem. """
|
||||
return self.title
|
||||
|
||||
@property
|
||||
def hasSonicAnalysis(self):
|
||||
""" Returns True if the audio has been sonically analyzed. """
|
||||
return self.musicAnalysisVersion == 1
|
||||
|
||||
def sync(self, bitrate, client=None, clientId=None, limit=None, title=None):
|
||||
""" Add current audio (artist, album or track) as sync item for specified device.
|
||||
See :func:`~plexapi.myplex.MyPlexAccount.sync` for possible exceptions.
|
||||
|
||||
Parameters:
|
||||
bitrate (int): maximum bitrate for synchronized music, better use one of MUSIC_BITRATE_* values from the
|
||||
module :mod:`~plexapi.sync`.
|
||||
client (:class:`~plexapi.myplex.MyPlexDevice`): sync destination, see
|
||||
:func:`~plexapi.myplex.MyPlexAccount.sync`.
|
||||
clientId (str): sync destination, see :func:`~plexapi.myplex.MyPlexAccount.sync`.
|
||||
limit (int): maximum count of items to sync, unlimited if `None`.
|
||||
title (str): descriptive title for the new :class:`~plexapi.sync.SyncItem`, if empty the value would be
|
||||
generated from metadata of current media.
|
||||
|
||||
Returns:
|
||||
:class:`~plexapi.sync.SyncItem`: an instance of created syncItem.
|
||||
"""
|
||||
|
||||
from plexapi.sync import SyncItem, Policy, MediaSettings
|
||||
|
||||
myplex = self._server.myPlexAccount()
|
||||
sync_item = SyncItem(self._server, None)
|
||||
sync_item.title = title if title else self._defaultSyncTitle()
|
||||
sync_item.rootTitle = self.title
|
||||
sync_item.contentType = self.listType
|
||||
sync_item.metadataType = self.METADATA_TYPE
|
||||
sync_item.machineIdentifier = self._server.machineIdentifier
|
||||
|
||||
section = self._server.library.sectionByID(self.librarySectionID)
|
||||
|
||||
sync_item.location = f'library://{section.uuid}/item/{quote_plus(self.key)}'
|
||||
sync_item.policy = Policy.create(limit)
|
||||
sync_item.mediaSettings = MediaSettings.createMusic(bitrate)
|
||||
|
||||
return myplex.sync(sync_item, client=client, clientId=clientId)
|
||||
|
||||
def sonicallySimilar(
|
||||
self: TAudio,
|
||||
limit: Optional[int] = None,
|
||||
maxDistance: Optional[float] = None,
|
||||
**kwargs,
|
||||
) -> List[TAudio]:
|
||||
"""Returns a list of sonically similar audio items.
|
||||
|
||||
Parameters:
|
||||
limit (int): Maximum count of items to return. Default 50 (server default)
|
||||
maxDistance (float): Maximum distance between tracks, 0.0 - 1.0. Default 0.25 (server default).
|
||||
**kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.fetchItems`.
|
||||
|
||||
Returns:
|
||||
List[:class:`~plexapi.audio.Audio`]: list of sonically similar audio items.
|
||||
"""
|
||||
|
||||
key = f"{self.key}/nearest"
|
||||
params: Dict[str, Any] = {}
|
||||
if limit is not None:
|
||||
params['limit'] = limit
|
||||
if maxDistance is not None:
|
||||
params['maxDistance'] = maxDistance
|
||||
key += utils.joinArgs(params)
|
||||
|
||||
return self.fetchItems(
|
||||
key,
|
||||
cls=type(self),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Artist(
|
||||
Audio,
|
||||
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin,
|
||||
ArtMixin, PosterMixin, ThemeMixin,
|
||||
ArtistEditMixins
|
||||
):
|
||||
""" Represents a single Artist.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Directory'
|
||||
TYPE (str): 'artist'
|
||||
albumSort (int): Setting that indicates how albums are sorted for the artist
|
||||
(-1 = Library default, 0 = Newest first, 1 = Oldest first, 2 = By name).
|
||||
audienceRating (float): Audience rating.
|
||||
collections (List<:class:`~plexapi.media.Collection`>): List of collection objects.
|
||||
countries (List<:class:`~plexapi.media.Country`>): List country objects.
|
||||
genres (List<:class:`~plexapi.media.Genre`>): List of genre objects.
|
||||
guids (List<:class:`~plexapi.media.Guid`>): List of guid objects.
|
||||
key (str): API URL (/library/metadata/<ratingkey>).
|
||||
labels (List<:class:`~plexapi.media.Label`>): List of label objects.
|
||||
locations (List<str>): List of folder paths where the artist is found on disk.
|
||||
rating (float): Artist rating (7.9; 9.8; 8.1).
|
||||
similar (List<:class:`~plexapi.media.Similar`>): List of similar objects.
|
||||
styles (List<:class:`~plexapi.media.Style`>): List of style objects.
|
||||
theme (str): URL to theme resource (/library/metadata/<ratingkey>/theme/<themeid>).
|
||||
ultraBlurColors (:class:`~plexapi.media.UltraBlurColors`): Ultra blur color object.
|
||||
"""
|
||||
TAG = 'Directory'
|
||||
TYPE = 'artist'
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
Audio._loadData(self, data)
|
||||
self.albumSort = utils.cast(int, data.attrib.get('albumSort', '-1'))
|
||||
self.audienceRating = utils.cast(float, data.attrib.get('audienceRating'))
|
||||
self.collections = self.findItems(data, media.Collection)
|
||||
self.countries = self.findItems(data, media.Country)
|
||||
self.genres = self.findItems(data, media.Genre)
|
||||
self.guids = self.findItems(data, media.Guid)
|
||||
self.key = self.key.replace('/children', '') # FIX_BUG_50
|
||||
self.labels = self.findItems(data, media.Label)
|
||||
self.locations = self.listAttrs(data, 'path', etag='Location')
|
||||
self.rating = utils.cast(float, data.attrib.get('rating'))
|
||||
self.similar = self.findItems(data, media.Similar)
|
||||
self.styles = self.findItems(data, media.Style)
|
||||
self.theme = data.attrib.get('theme')
|
||||
self.ultraBlurColors = self.findItem(data, media.UltraBlurColors)
|
||||
|
||||
def __iter__(self):
|
||||
for album in self.albums():
|
||||
yield album
|
||||
|
||||
def album(self, title):
|
||||
""" Returns the :class:`~plexapi.audio.Album` that matches the specified title.
|
||||
|
||||
Parameters:
|
||||
title (str): Title of the album to return.
|
||||
"""
|
||||
return self.section().get(
|
||||
title=title,
|
||||
libtype='album',
|
||||
filters={'artist.id': self.ratingKey}
|
||||
)
|
||||
|
||||
def albums(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.audio.Album` objects by the artist. """
|
||||
return self.section().search(
|
||||
libtype='album',
|
||||
filters={**kwargs.pop('filters', {}), 'artist.id': self.ratingKey},
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def track(self, title=None, album=None, track=None):
|
||||
""" Returns the :class:`~plexapi.audio.Track` that matches the specified title.
|
||||
|
||||
Parameters:
|
||||
title (str): Title of the track to return.
|
||||
album (str): Album name (default: None; required if title not specified).
|
||||
track (int): Track number (default: None; required if title not specified).
|
||||
|
||||
Raises:
|
||||
:exc:`~plexapi.exceptions.BadRequest`: If title or album and track parameters are missing.
|
||||
"""
|
||||
key = f'{self.key}/allLeaves'
|
||||
if title is not None:
|
||||
return self.fetchItem(key, Track, title__iexact=title)
|
||||
elif album is not None and track is not None:
|
||||
return self.fetchItem(key, Track, parentTitle__iexact=album, index=track)
|
||||
raise BadRequest('Missing argument: title or album and track are required')
|
||||
|
||||
def tracks(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.audio.Track` objects by the artist. """
|
||||
key = f'{self.key}/allLeaves'
|
||||
return self.fetchItems(key, Track, **kwargs)
|
||||
|
||||
def get(self, title=None, album=None, track=None):
|
||||
""" Alias of :func:`~plexapi.audio.Artist.track`. """
|
||||
return self.track(title, album, track)
|
||||
|
||||
def download(self, savepath=None, keep_original_name=False, subfolders=False, **kwargs):
|
||||
""" Download all tracks from the artist. See :func:`~plexapi.base.Playable.download` for details.
|
||||
|
||||
Parameters:
|
||||
savepath (str): Defaults to current working dir.
|
||||
keep_original_name (bool): True to keep the original filename otherwise
|
||||
a friendlier filename is generated.
|
||||
subfolders (bool): True to separate tracks in to album folders.
|
||||
**kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.getStreamURL`.
|
||||
"""
|
||||
filepaths = []
|
||||
for track in self.tracks():
|
||||
_savepath = os.path.join(savepath, track.parentTitle) if subfolders else savepath
|
||||
filepaths += track.download(_savepath, keep_original_name, **kwargs)
|
||||
return filepaths
|
||||
|
||||
def popularTracks(self):
|
||||
""" Returns a list of :class:`~plexapi.audio.Track` popular tracks by the artist. """
|
||||
filters = {
|
||||
'album.subformat!': 'Compilation,Live',
|
||||
'artist.id': self.ratingKey,
|
||||
'group': 'title',
|
||||
'ratingCount>>': 0,
|
||||
}
|
||||
return self.section().search(
|
||||
libtype='track',
|
||||
filters=filters,
|
||||
sort='ratingCount:desc',
|
||||
limit=100
|
||||
)
|
||||
|
||||
def station(self):
|
||||
""" Returns a :class:`~plexapi.playlist.Playlist` artist radio station or `None`. """
|
||||
key = f'{self.key}?includeStations=1'
|
||||
return next(iter(self.fetchItems(key, cls=Playlist, rtag="Stations")), None)
|
||||
|
||||
@property
|
||||
def metadataDirectory(self):
|
||||
""" Returns the Plex Media Server data directory where the metadata is stored. """
|
||||
guid_hash = utils.sha1hash(self.guid)
|
||||
return str(Path('Metadata') / 'Artists' / guid_hash[0] / f'{guid_hash[1:]}.bundle')
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Album(
|
||||
Audio,
|
||||
SplitMergeMixin, UnmatchMatchMixin, RatingMixin,
|
||||
ArtMixin, PosterMixin, ThemeUrlMixin,
|
||||
AlbumEditMixins
|
||||
):
|
||||
""" Represents a single Album.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Directory'
|
||||
TYPE (str): 'album'
|
||||
audienceRating (float): Audience rating.
|
||||
collections (List<:class:`~plexapi.media.Collection`>): List of collection objects.
|
||||
formats (List<:class:`~plexapi.media.Format`>): List of format objects.
|
||||
genres (List<:class:`~plexapi.media.Genre`>): List of genre objects.
|
||||
guids (List<:class:`~plexapi.media.Guid`>): List of guid objects.
|
||||
key (str): API URL (/library/metadata/<ratingkey>).
|
||||
labels (List<:class:`~plexapi.media.Label`>): List of label objects.
|
||||
leafCount (int): Number of items in the album view.
|
||||
loudnessAnalysisVersion (int): The Plex loudness analysis version level.
|
||||
originallyAvailableAt (datetime): Datetime the album was released.
|
||||
parentGuid (str): Plex GUID for the album artist (plex://artist/5d07bcb0403c64029053ac4c).
|
||||
parentKey (str): API URL of the album artist (/library/metadata/<parentRatingKey>).
|
||||
parentRatingKey (int): Unique key identifying the album artist.
|
||||
parentTheme (str): URL to artist theme resource (/library/metadata/<parentRatingkey>/theme/<themeid>).
|
||||
parentThumb (str): URL to album artist thumbnail image (/library/metadata/<parentRatingKey>/thumb/<thumbid>).
|
||||
parentTitle (str): Name of the album artist.
|
||||
rating (float): Album rating (7.9; 9.8; 8.1).
|
||||
studio (str): Studio that released the album.
|
||||
styles (List<:class:`~plexapi.media.Style`>): List of style objects.
|
||||
subformats (List<:class:`~plexapi.media.Subformat`>): List of subformat objects.
|
||||
ultraBlurColors (:class:`~plexapi.media.UltraBlurColors`): Ultra blur color object.
|
||||
viewedLeafCount (int): Number of items marked as played in the album view.
|
||||
year (int): Year the album was released.
|
||||
"""
|
||||
TAG = 'Directory'
|
||||
TYPE = 'album'
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
Audio._loadData(self, data)
|
||||
self.audienceRating = utils.cast(float, data.attrib.get('audienceRating'))
|
||||
self.collections = self.findItems(data, media.Collection)
|
||||
self.formats = self.findItems(data, media.Format)
|
||||
self.genres = self.findItems(data, media.Genre)
|
||||
self.guids = self.findItems(data, media.Guid)
|
||||
self.key = self.key.replace('/children', '') # FIX_BUG_50
|
||||
self.labels = self.findItems(data, media.Label)
|
||||
self.leafCount = utils.cast(int, data.attrib.get('leafCount'))
|
||||
self.loudnessAnalysisVersion = utils.cast(int, data.attrib.get('loudnessAnalysisVersion'))
|
||||
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
|
||||
self.parentGuid = data.attrib.get('parentGuid')
|
||||
self.parentKey = data.attrib.get('parentKey')
|
||||
self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey'))
|
||||
self.parentTheme = data.attrib.get('parentTheme')
|
||||
self.parentThumb = data.attrib.get('parentThumb')
|
||||
self.parentTitle = data.attrib.get('parentTitle')
|
||||
self.rating = utils.cast(float, data.attrib.get('rating'))
|
||||
self.studio = data.attrib.get('studio')
|
||||
self.styles = self.findItems(data, media.Style)
|
||||
self.subformats = self.findItems(data, media.Subformat)
|
||||
self.ultraBlurColors = self.findItem(data, media.UltraBlurColors)
|
||||
self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount'))
|
||||
self.year = utils.cast(int, data.attrib.get('year'))
|
||||
|
||||
def __iter__(self):
|
||||
for track in self.tracks():
|
||||
yield track
|
||||
|
||||
def track(self, title=None, track=None):
|
||||
""" Returns the :class:`~plexapi.audio.Track` that matches the specified title.
|
||||
|
||||
Parameters:
|
||||
title (str): Title of the track to return.
|
||||
track (int): Track number (default: None; required if title not specified).
|
||||
|
||||
Raises:
|
||||
:exc:`~plexapi.exceptions.BadRequest`: If title or track parameter is missing.
|
||||
"""
|
||||
key = f'{self.key}/children'
|
||||
if title is not None and not isinstance(title, int):
|
||||
return self.fetchItem(key, Track, title__iexact=title)
|
||||
elif track is not None or isinstance(title, int):
|
||||
if isinstance(title, int):
|
||||
index = title
|
||||
else:
|
||||
index = track
|
||||
return self.fetchItem(key, Track, parentTitle__iexact=self.title, index=index)
|
||||
raise BadRequest('Missing argument: title or track is required')
|
||||
|
||||
def tracks(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.audio.Track` objects in the album. """
|
||||
key = f'{self.key}/children'
|
||||
return self.fetchItems(key, Track, **kwargs)
|
||||
|
||||
def get(self, title=None, track=None):
|
||||
""" Alias of :func:`~plexapi.audio.Album.track`. """
|
||||
return self.track(title, track)
|
||||
|
||||
def artist(self):
|
||||
""" Return the album's :class:`~plexapi.audio.Artist`. """
|
||||
return self.fetchItem(self.parentKey)
|
||||
|
||||
def download(self, savepath=None, keep_original_name=False, **kwargs):
|
||||
""" Download all tracks from the album. See :func:`~plexapi.base.Playable.download` for details.
|
||||
|
||||
Parameters:
|
||||
savepath (str): Defaults to current working dir.
|
||||
keep_original_name (bool): True to keep the original filename otherwise
|
||||
a friendlier filename is generated.
|
||||
**kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.getStreamURL`.
|
||||
"""
|
||||
filepaths = []
|
||||
for track in self.tracks():
|
||||
filepaths += track.download(savepath, keep_original_name, **kwargs)
|
||||
return filepaths
|
||||
|
||||
def _defaultSyncTitle(self):
|
||||
""" Returns str, default title for a new syncItem. """
|
||||
return f'{self.parentTitle} - {self.title}'
|
||||
|
||||
@property
|
||||
def metadataDirectory(self):
|
||||
""" Returns the Plex Media Server data directory where the metadata is stored. """
|
||||
guid_hash = utils.sha1hash(self.guid)
|
||||
return str(Path('Metadata') / 'Albums' / guid_hash[0] / f'{guid_hash[1:]}.bundle')
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Track(
|
||||
Audio, Playable,
|
||||
ExtrasMixin, RatingMixin,
|
||||
ArtUrlMixin, PosterUrlMixin, ThemeUrlMixin,
|
||||
TrackEditMixins
|
||||
):
|
||||
""" Represents a single Track.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Directory'
|
||||
TYPE (str): 'track'
|
||||
audienceRating (float): Audience rating.
|
||||
chapters (List<:class:`~plexapi.media.Chapter`>): List of Chapter objects.
|
||||
chapterSource (str): Unknown
|
||||
collections (List<:class:`~plexapi.media.Collection`>): List of collection objects.
|
||||
duration (int): Length of the track in milliseconds.
|
||||
genres (List<:class:`~plexapi.media.Genre`>): List of genre objects.
|
||||
grandparentArt (str): URL to album artist artwork (/library/metadata/<grandparentRatingKey>/art/<artid>).
|
||||
grandparentGuid (str): Plex GUID for the album artist (plex://artist/5d07bcb0403c64029053ac4c).
|
||||
grandparentKey (str): API URL of the album artist (/library/metadata/<grandparentRatingKey>).
|
||||
grandparentRatingKey (int): Unique key identifying the album artist.
|
||||
grandparentTheme (str): URL to artist theme resource (/library/metadata/<grandparentRatingkey>/theme/<themeid>).
|
||||
(/library/metadata/<grandparentRatingkey>/theme/<themeid>).
|
||||
grandparentThumb (str): URL to album artist thumbnail image
|
||||
(/library/metadata/<grandparentRatingKey>/thumb/<thumbid>).
|
||||
grandparentTitle (str): Name of the album artist for the track.
|
||||
guids (List<:class:`~plexapi.media.Guid`>): List of guid objects.
|
||||
labels (List<:class:`~plexapi.media.Label`>): List of label objects.
|
||||
media (List<:class:`~plexapi.media.Media`>): List of media objects.
|
||||
originalTitle (str): The artist for the track.
|
||||
parentGuid (str): Plex GUID for the album (plex://album/5d07cd8e403c640290f180f9).
|
||||
parentIndex (int): Disc number of the track.
|
||||
parentKey (str): API URL of the album (/library/metadata/<parentRatingKey>).
|
||||
parentRatingKey (int): Unique key identifying the album.
|
||||
parentThumb (str): URL to album thumbnail image (/library/metadata/<parentRatingKey>/thumb/<thumbid>).
|
||||
parentTitle (str): Name of the album for the track.
|
||||
primaryExtraKey (str) API URL for the primary extra for the track.
|
||||
rating (float): Track rating (7.9; 9.8; 8.1).
|
||||
ratingCount (int): Number of listeners who have scrobbled this track, as reported by Last.fm.
|
||||
skipCount (int): Number of times the track has been skipped.
|
||||
sourceURI (str): Remote server URI (server://<machineIdentifier>/com.plexapp.plugins.library)
|
||||
(remote playlist item only).
|
||||
viewOffset (int): View offset in milliseconds.
|
||||
year (int): Year the track was released.
|
||||
"""
|
||||
TAG = 'Track'
|
||||
TYPE = 'track'
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
Audio._loadData(self, data)
|
||||
Playable._loadData(self, data)
|
||||
self.audienceRating = utils.cast(float, data.attrib.get('audienceRating'))
|
||||
self.chapters = self.findItems(data, media.Chapter)
|
||||
self.chapterSource = data.attrib.get('chapterSource')
|
||||
self.collections = self.findItems(data, media.Collection)
|
||||
self.duration = utils.cast(int, data.attrib.get('duration'))
|
||||
self.genres = self.findItems(data, media.Genre)
|
||||
self.grandparentArt = data.attrib.get('grandparentArt')
|
||||
self.grandparentGuid = data.attrib.get('grandparentGuid')
|
||||
self.grandparentKey = data.attrib.get('grandparentKey')
|
||||
self.grandparentRatingKey = utils.cast(int, data.attrib.get('grandparentRatingKey'))
|
||||
self.grandparentTheme = data.attrib.get('grandparentTheme')
|
||||
self.grandparentThumb = data.attrib.get('grandparentThumb')
|
||||
self.grandparentTitle = data.attrib.get('grandparentTitle')
|
||||
self.guids = self.findItems(data, media.Guid)
|
||||
self.labels = self.findItems(data, media.Label)
|
||||
self.media = self.findItems(data, media.Media)
|
||||
self.originalTitle = data.attrib.get('originalTitle')
|
||||
self.parentGuid = data.attrib.get('parentGuid')
|
||||
self.parentIndex = utils.cast(int, data.attrib.get('parentIndex'))
|
||||
self.parentKey = data.attrib.get('parentKey')
|
||||
self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey'))
|
||||
self.parentThumb = data.attrib.get('parentThumb')
|
||||
self.parentTitle = data.attrib.get('parentTitle')
|
||||
self.primaryExtraKey = data.attrib.get('primaryExtraKey')
|
||||
self.rating = utils.cast(float, data.attrib.get('rating'))
|
||||
self.ratingCount = utils.cast(int, data.attrib.get('ratingCount'))
|
||||
self.skipCount = utils.cast(int, data.attrib.get('skipCount'))
|
||||
self.sourceURI = data.attrib.get('source') # remote playlist item
|
||||
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
|
||||
self.year = utils.cast(int, data.attrib.get('year'))
|
||||
|
||||
@property
|
||||
def locations(self):
|
||||
""" This does not exist in plex xml response but is added to have a common
|
||||
interface to get the locations of the track.
|
||||
|
||||
Returns:
|
||||
List<str> of file paths where the track is found on disk.
|
||||
"""
|
||||
return [part.file for part in self.iterParts() if part]
|
||||
|
||||
@property
|
||||
def trackNumber(self):
|
||||
""" Returns the track number. """
|
||||
return self.index
|
||||
|
||||
def _prettyfilename(self):
|
||||
""" Returns a filename for use in download. """
|
||||
return f'{self.grandparentTitle} - {self.parentTitle} - {str(self.trackNumber).zfill(2)} - {self.title}'
|
||||
|
||||
def album(self):
|
||||
""" Return the track's :class:`~plexapi.audio.Album`. """
|
||||
return self.fetchItem(self.parentKey)
|
||||
|
||||
def artist(self):
|
||||
""" Return the track's :class:`~plexapi.audio.Artist`. """
|
||||
return self.fetchItem(self.grandparentKey)
|
||||
|
||||
def _defaultSyncTitle(self):
|
||||
""" Returns str, default title for a new syncItem. """
|
||||
return f'{self.grandparentTitle} - {self.parentTitle} - {self.title}'
|
||||
|
||||
def _getWebURL(self, base=None):
|
||||
""" Get the Plex Web URL with the correct parameters. """
|
||||
return self._server._buildWebURL(base=base, endpoint='details', key=self.parentKey)
|
||||
|
||||
@property
|
||||
def metadataDirectory(self):
|
||||
""" Returns the Plex Media Server data directory where the metadata is stored. """
|
||||
guid_hash = utils.sha1hash(self.parentGuid)
|
||||
return str(Path('Metadata') / 'Albums' / guid_hash[0] / f'{guid_hash[1:]}.bundle')
|
||||
|
||||
def sonicAdventure(
|
||||
self: TTrack,
|
||||
to: TTrack,
|
||||
**kwargs: Any,
|
||||
) -> list[TTrack]:
|
||||
"""Returns a sonic adventure from the current track to the specified track.
|
||||
|
||||
Parameters:
|
||||
to (:class:`~plexapi.audio.Track`): The target track for the sonic adventure.
|
||||
**kwargs: Additional options passed into :func:`~plexapi.library.MusicSection.sonicAdventure`.
|
||||
|
||||
Returns:
|
||||
List[:class:`~plexapi.audio.Track`]: list of tracks in the sonic adventure.
|
||||
"""
|
||||
return self.section().sonicAdventure(self, to, **kwargs)
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class TrackSession(PlexSession, Track):
|
||||
""" Represents a single Track session
|
||||
loaded from :func:`~plexapi.server.PlexServer.sessions`.
|
||||
"""
|
||||
_SESSIONTYPE = True
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
Track._loadData(self, data)
|
||||
PlexSession._loadData(self, data)
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class TrackHistory(PlexHistory, Track):
|
||||
""" Represents a single Track history entry
|
||||
loaded from :func:`~plexapi.server.PlexServer.history`.
|
||||
"""
|
||||
_HISTORYTYPE = True
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
Track._loadData(self, data)
|
||||
PlexHistory._loadData(self, data)
|
1138
libs/plexapi/base.py
Normal file
1138
libs/plexapi/base.py
Normal file
File diff suppressed because it is too large
Load diff
638
libs/plexapi/client.py
Normal file
638
libs/plexapi/client.py
Normal file
|
@ -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'))
|
579
libs/plexapi/collection.py
Normal file
579
libs/plexapi/collection.py
Normal file
|
@ -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')
|
69
libs/plexapi/config.py
Normal file
69
libs/plexapi/config.py
Normal file
|
@ -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',
|
||||
}
|
9
libs/plexapi/const.py
Normal file
9
libs/plexapi/const.py
Normal file
|
@ -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}"
|
36
libs/plexapi/exceptions.py
Normal file
36
libs/plexapi/exceptions.py
Normal file
|
@ -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
|
151
libs/plexapi/gdm.py
Normal file
151
libs/plexapi/gdm.py
Normal file
|
@ -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()
|
3326
libs/plexapi/library.py
Normal file
3326
libs/plexapi/library.py
Normal file
File diff suppressed because it is too large
Load diff
1339
libs/plexapi/media.py
Normal file
1339
libs/plexapi/media.py
Normal file
File diff suppressed because it is too large
Load diff
1325
libs/plexapi/mixins.py
Normal file
1325
libs/plexapi/mixins.py
Normal file
File diff suppressed because it is too large
Load diff
2044
libs/plexapi/myplex.py
Normal file
2044
libs/plexapi/myplex.py
Normal file
File diff suppressed because it is too large
Load diff
324
libs/plexapi/photo.py
Normal file
324
libs/plexapi/photo.py
Normal file
|
@ -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)
|
534
libs/plexapi/playlist.py
Normal file
534
libs/plexapi/playlist.py
Normal file
|
@ -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')
|
319
libs/plexapi/playqueue.py
Normal file
319
libs/plexapi/playqueue.py
Normal file
|
@ -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
|
1306
libs/plexapi/server.py
Normal file
1306
libs/plexapi/server.py
Normal file
File diff suppressed because it is too large
Load diff
186
libs/plexapi/settings.py
Normal file
186
libs/plexapi/settings.py
Normal file
|
@ -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)
|
116
libs/plexapi/sonos.py
Normal file
116
libs/plexapi/sonos.py
Normal file
|
@ -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
|
||||
)
|
||||
)
|
312
libs/plexapi/sync.py
Normal file
312
libs/plexapi/sync.py
Normal file
|
@ -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'
|
720
libs/plexapi/utils.py
Normal file
720
libs/plexapi/utils.py
Normal file
|
@ -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)
|
1278
libs/plexapi/video.py
Normal file
1278
libs/plexapi/video.py
Normal file
File diff suppressed because it is too large
Load diff
|
@ -25,6 +25,7 @@ inflect==7.5.0
|
|||
jsonschema<=4.17.3 # newer version require other compiled dependency
|
||||
knowit<=0.5.3 # newer version doesn't support Python 3.8 anymore
|
||||
Mako==1.3.8
|
||||
plexapi>=4.16.1
|
||||
pycountry==24.6.1
|
||||
pyrsistent==0.20.0
|
||||
pysubs2==1.8.0
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue