Compare commits
24 Commits
v2.1.0-bet
...
v2.1.1-bet
Author | SHA1 | Date | |
---|---|---|---|
![]() |
1d3cd431eb | ||
![]() |
8f8318da6d | ||
![]() |
36ce751875 | ||
![]() |
858ea33680 | ||
![]() |
eee759d0d0 | ||
![]() |
dbe3b492fd | ||
![]() |
4e4fde2e9a | ||
![]() |
5283126608 | ||
![]() |
df72ecebf5 | ||
![]() |
d316aa34e2 | ||
![]() |
405aec8bb8 | ||
![]() |
4a62f8c395 | ||
![]() |
eabea2deeb | ||
![]() |
3742021dcc | ||
![]() |
9c4219b42e | ||
![]() |
f624908302 | ||
![]() |
ab9132cdd4 | ||
![]() |
0186363753 | ||
![]() |
653ad36f17 | ||
![]() |
5073f82d53 | ||
![]() |
833937eced | ||
![]() |
32df79bb83 | ||
![]() |
fabced9942 | ||
![]() |
8aa34321c9 |
14
CHANGELOG.md
14
CHANGELOG.md
@@ -1,5 +1,19 @@
|
||||
# Changelog
|
||||
|
||||
## v2.1.1-beta (2018-04-11)
|
||||
|
||||
* Monitoring:
|
||||
* Fix: Live TV transcoding showing incorrectly as direct play.
|
||||
* Newsletters:
|
||||
* New: Added week number as parameter. (Thanks @samip5)
|
||||
* Fix: Fallback to cover art on the newsletter cards.
|
||||
* Change: Option to set newsletter time frame by calendar days or hours.
|
||||
* Notifications:
|
||||
* New: Added week number as parameter. (Thanks @samip5)
|
||||
* Other:
|
||||
* New: Added plexapi library for custom scripts.
|
||||
|
||||
|
||||
## v2.1.0-beta (2018-04-07)
|
||||
|
||||
* Newsletters:
|
||||
|
@@ -4046,6 +4046,19 @@ a:hover .overlay-refresh-image:hover {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
.newsletter-time_frame .input-group-addon {
|
||||
height: 32px;
|
||||
width: 52px;
|
||||
margin-top: 5px;
|
||||
line-height: 1.42857143;
|
||||
}
|
||||
.newsletter-time_frame input.form-control {
|
||||
width: calc(50% - 37px);
|
||||
}
|
||||
.newsletter-time_frame select.form-control {
|
||||
width: calc(50% - 15px);
|
||||
height: 32px;
|
||||
}
|
||||
.newsletter-loader-container {
|
||||
font-family: 'Open Sans', Arial, sans-serif;
|
||||
position: absolute;
|
||||
|
@@ -53,6 +53,22 @@
|
||||
<span id="custom_cron_message">Set the schedule for the newsletter using a <a href="${anon_url('https://crontab.guru')}" target="_blank">custom crontab</a>. Only standard cron values are valid.</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="time_frame">Time Frame</label>
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="input-group newsletter-time_frame">
|
||||
<span class="input-group-addon form-control btn-dark inactive">Last</span>
|
||||
<input type="number" class="form-control" id="newsletter_config_time_frame" name="newsletter_config_time_frame" value="${newsletter['config']['time_frame']}">
|
||||
<select class="form-control" id="newsletter_config_time_frame_units" name="newsletter_config_time_frame_units">
|
||||
<option value="days" ${'selected' if newsletter['config']['time_frame_units'] == 'days' else ''}>days</option>
|
||||
<option value="hours" ${'selected' if newsletter['config']['time_frame_units'] == 'hours' else ''}>hours</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-block">Set the time frame to include in the newsletter. Note: Days uses calendar days (i.e. since midnight).</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-12" style="padding-top: 10px; border-top: 1px solid #444;">
|
||||
<input type="hidden" id="newsletter_id" name="newsletter_id" value="${newsletter['id']}" />
|
||||
|
@@ -448,6 +448,7 @@
|
||||
}
|
||||
.card-info-body > p {
|
||||
max-width: 325px;
|
||||
color: #ffffff;
|
||||
}
|
||||
.card-instance.movie .card-info-body,
|
||||
.card-instance.show .card-info-body {
|
||||
@@ -499,6 +500,7 @@
|
||||
border-radius: 2px;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* -------------------------------------
|
||||
@@ -697,11 +699,11 @@
|
||||
<tr>
|
||||
<td class="card-info-body" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 0.75rem;vertical-align: top;padding: 5px;height: 100%;">
|
||||
% if movie['tagline']:
|
||||
<p class="nowrap mb5" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-weight: 400;margin: 0;margin-bottom: 5px;white-space: nowrap;text-overflow: ellipsis;overflow: hidden;max-width: 325px;">
|
||||
<p class="nowrap mb5" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-weight: 400;margin: 0;margin-bottom: 5px;white-space: nowrap;text-overflow: ellipsis;overflow: hidden;max-width: 325px;color: #ffffff;">
|
||||
<em>${movie['tagline']}</em>
|
||||
</p>
|
||||
% endif
|
||||
<p style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-weight: 400;margin: 0;max-width: 325px;">
|
||||
<p style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-weight: 400;margin: 0;max-width: 325px;color: #ffffff;">
|
||||
${movie['summary'][:450] + (movie['summary'][450:] and '...')}
|
||||
</p>
|
||||
</td>
|
||||
@@ -713,15 +715,15 @@
|
||||
<tr>
|
||||
<td class="badge-container" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;max-width: 265px;white-space: nowrap;overflow: hidden;text-overflow: ellipsis;">
|
||||
% if movie['year']:
|
||||
<span class="badge" title="${movie['year']}" style="display: inline-block;min-width: 10px;padding: 3px 7px;font-size: 11px;line-height: 1;text-align: center;white-space: nowrap;vertical-align: middle;background-color: rgba(0, 0, 0, .25);border-radius: 2px;text-overflow: ellipsis;overflow: hidden;">${movie['year']}</span>
|
||||
<span class="badge" title="${movie['year']}" style="display: inline-block;min-width: 10px;padding: 3px 7px;font-size: 11px;line-height: 1;text-align: center;white-space: nowrap;vertical-align: middle;background-color: rgba(0, 0, 0, .25);border-radius: 2px;text-overflow: ellipsis;overflow: hidden;color: #ffffff;">${movie['year']}</span>
|
||||
% endif
|
||||
% if movie['duration']:
|
||||
<% duration = int(int(movie['duration'])/60000) %>
|
||||
<span class="badge" title="${duration} mins" style="display: inline-block;min-width: 10px;padding: 3px 7px;font-size: 11px;line-height: 1;text-align: center;white-space: nowrap;vertical-align: middle;background-color: rgba(0, 0, 0, .25);border-radius: 2px;text-overflow: ellipsis;overflow: hidden;">${duration} mins</span>
|
||||
<span class="badge" title="${duration} mins" style="display: inline-block;min-width: 10px;padding: 3px 7px;font-size: 11px;line-height: 1;text-align: center;white-space: nowrap;vertical-align: middle;background-color: rgba(0, 0, 0, .25);border-radius: 2px;text-overflow: ellipsis;overflow: hidden;color: #ffffff;">${duration} mins</span>
|
||||
% endif
|
||||
% if movie['genres']:
|
||||
% for genre in movie['genres'][:2]:
|
||||
<span class="badge" title="${genre}" style="display: inline-block;min-width: 10px;padding: 3px 7px;font-size: 11px;line-height: 1;text-align: center;white-space: nowrap;vertical-align: middle;background-color: rgba(0, 0, 0, .25);border-radius: 2px;text-overflow: ellipsis;overflow: hidden;">${genre}</span>
|
||||
<span class="badge" title="${genre}" style="display: inline-block;min-width: 10px;padding: 3px 7px;font-size: 11px;line-height: 1;text-align: center;white-space: nowrap;vertical-align: middle;background-color: rgba(0, 0, 0, .25);border-radius: 2px;text-overflow: ellipsis;overflow: hidden;color: #ffffff;">${genre}</span>
|
||||
% endfor
|
||||
% endif
|
||||
</td>
|
||||
@@ -829,14 +831,14 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="card-info-body" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 0.75rem;vertical-align: top;padding: 5px;height: 100%;">
|
||||
<p class="nowrap mb5" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-weight: 400;margin: 0;margin-bottom: 5px;white-space: nowrap;text-overflow: ellipsis;overflow: hidden;max-width: 325px;">
|
||||
<p class="nowrap mb5" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-weight: 400;margin: 0;margin-bottom: 5px;white-space: nowrap;text-overflow: ellipsis;overflow: hidden;max-width: 325px;color: #ffffff;">
|
||||
% if show['season_count'] > 1:
|
||||
<em>${show['season_count']} seasons /</em>
|
||||
% endif
|
||||
<% total_show_episodes = sum(s['episode_count'] for s in show['season']) %>
|
||||
<em>${total_show_episodes} episode${'s' if total_show_episodes > 1 else ''}</em>
|
||||
</p>
|
||||
<p class="nowrap mb5" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-weight: 400;margin: 0;margin-bottom: 5px;white-space: nowrap;text-overflow: ellipsis;overflow: hidden;max-width: 325px;">
|
||||
<p class="nowrap mb5" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-weight: 400;margin: 0;margin-bottom: 5px;white-space: nowrap;text-overflow: ellipsis;overflow: hidden;max-width: 325px;color: #ffffff;">
|
||||
% for i, season in enumerate(show['season'][:8]):
|
||||
Season ${season['media_index']} ·
|
||||
% if season['episode_count'] == 1:
|
||||
@@ -851,7 +853,7 @@
|
||||
% endif
|
||||
% endfor
|
||||
</p>
|
||||
<p style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-weight: 400;margin: 0;max-width: 325px;">
|
||||
<p style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-weight: 400;margin: 0;max-width: 325px;color: #ffffff;">
|
||||
% if show['season_count'] == 1 and show['season'][0]['episode_count'] == 1:
|
||||
${show['season'][0]['episode'][0]['summary'][:350] + (show['season'][0]['episode'][0]['summary'][350:] and '...')}
|
||||
% else:
|
||||
@@ -870,15 +872,15 @@
|
||||
<tr>
|
||||
<td class="badge-container" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;max-width: 265px;white-space: nowrap;overflow: hidden;text-overflow: ellipsis;">
|
||||
% if show['year']:
|
||||
<span class="badge" title="${show['year']}" style="display: inline-block;min-width: 10px;padding: 3px 7px;font-size: 11px;line-height: 1;text-align: center;white-space: nowrap;vertical-align: middle;background-color: rgba(0, 0, 0, .25);border-radius: 2px;text-overflow: ellipsis;overflow: hidden;">${show['year']}</span>
|
||||
<span class="badge" title="${show['year']}" style="display: inline-block;min-width: 10px;padding: 3px 7px;font-size: 11px;line-height: 1;text-align: center;white-space: nowrap;vertical-align: middle;background-color: rgba(0, 0, 0, .25);border-radius: 2px;text-overflow: ellipsis;overflow: hidden;color: #ffffff;">${show['year']}</span>
|
||||
% endif
|
||||
% if show['duration']:
|
||||
<% duration = int(int(show['duration'])/60000) %>
|
||||
<span class="badge" title="${duration} mins" style="display: inline-block;min-width: 10px;padding: 3px 7px;font-size: 11px;line-height: 1;text-align: center;white-space: nowrap;vertical-align: middle;background-color: rgba(0, 0, 0, .25);border-radius: 2px;text-overflow: ellipsis;overflow: hidden;">${duration} mins</span>
|
||||
<span class="badge" title="${duration} mins" style="display: inline-block;min-width: 10px;padding: 3px 7px;font-size: 11px;line-height: 1;text-align: center;white-space: nowrap;vertical-align: middle;background-color: rgba(0, 0, 0, .25);border-radius: 2px;text-overflow: ellipsis;overflow: hidden;color: #ffffff;">${duration} mins</span>
|
||||
% endif
|
||||
% if show['genres']:
|
||||
% for genre in show['genres'][:2]:
|
||||
<span class="badge" title="${genre}" style="display: inline-block;min-width: 10px;padding: 3px 7px;font-size: 11px;line-height: 1;text-align: center;white-space: nowrap;vertical-align: middle;background-color: rgba(0, 0, 0, .25);border-radius: 2px;text-overflow: ellipsis;overflow: hidden;">${genre}</span>
|
||||
<span class="badge" title="${genre}" style="display: inline-block;min-width: 10px;padding: 3px 7px;font-size: 11px;line-height: 1;text-align: center;white-space: nowrap;vertical-align: middle;background-color: rgba(0, 0, 0, .25);border-radius: 2px;text-overflow: ellipsis;overflow: hidden;color: #ffffff;">${genre}</span>
|
||||
% endfor
|
||||
% endif
|
||||
</td>
|
||||
@@ -977,11 +979,11 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="card-info-body" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 0.75rem;vertical-align: top;padding: 5px;height: 82px;min-height: 82px;">
|
||||
<p class="nowrap mb5" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-weight: 400;margin: 0;margin-bottom: 5px;white-space: nowrap;text-overflow: ellipsis;overflow: hidden;max-width: 325px;">
|
||||
<p class="nowrap mb5" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-weight: 400;margin: 0;margin-bottom: 5px;white-space: nowrap;text-overflow: ellipsis;overflow: hidden;max-width: 325px;color: #ffffff;">
|
||||
<em>${album['parent_title']} · ${album['track_count']} track${'s' if album['track_count'] > 1 else ''}</em>
|
||||
</p>
|
||||
% if artist['title'].lower() != 'various artists':
|
||||
<p style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-weight: 400;margin: 0;max-width: 325px;">
|
||||
<p style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-weight: 400;margin: 0;max-width: 325px;color: #ffffff;">
|
||||
${album['summary'][:200] + (album['summary'][200:] and '...')}
|
||||
</p>
|
||||
% endif
|
||||
@@ -994,11 +996,11 @@
|
||||
<tr>
|
||||
<td class="badge-container" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 14px;vertical-align: top;max-width: 265px;white-space: nowrap;overflow: hidden;text-overflow: ellipsis;">
|
||||
% if album['year']:
|
||||
<span class="badge" title="${album['year']}" style="display: inline-block;min-width: 10px;padding: 3px 7px;font-size: 11px;line-height: 1;text-align: center;white-space: nowrap;vertical-align: middle;background-color: rgba(0, 0, 0, .25);border-radius: 2px;text-overflow: ellipsis;overflow: hidden;">${album['year']}</span>
|
||||
<span class="badge" title="${album['year']}" style="display: inline-block;min-width: 10px;padding: 3px 7px;font-size: 11px;line-height: 1;text-align: center;white-space: nowrap;vertical-align: middle;background-color: rgba(0, 0, 0, .25);border-radius: 2px;text-overflow: ellipsis;overflow: hidden;color: #ffffff;">${album['year']}</span>
|
||||
% endif
|
||||
% if album['genres']:
|
||||
% for genre in album['genres'][:2]:
|
||||
<span class="badge" title="${genre}" style="display: inline-block;min-width: 10px;padding: 3px 7px;font-size: 11px;line-height: 1;text-align: center;white-space: nowrap;vertical-align: middle;background-color: rgba(0, 0, 0, .25);border-radius: 2px;text-overflow: ellipsis;overflow: hidden;">${genre}</span>
|
||||
<span class="badge" title="${genre}" style="display: inline-block;min-width: 10px;padding: 3px 7px;font-size: 11px;line-height: 1;text-align: center;white-space: nowrap;vertical-align: middle;background-color: rgba(0, 0, 0, .25);border-radius: 2px;text-overflow: ellipsis;overflow: hidden;color: #ffffff;">${genre}</span>
|
||||
% endfor
|
||||
% endif
|
||||
</td>
|
||||
|
@@ -448,6 +448,7 @@
|
||||
}
|
||||
.card-info-body > p {
|
||||
max-width: 325px;
|
||||
color: #ffffff;
|
||||
}
|
||||
.card-instance.movie .card-info-body,
|
||||
.card-instance.show .card-info-body {
|
||||
@@ -499,6 +500,7 @@
|
||||
border-radius: 2px;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* -------------------------------------
|
||||
|
49
lib/plexapi/__init__.py
Normal file
49
lib/plexapi/__init__.py
Normal file
@@ -0,0 +1,49 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
import os
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from platform import uname
|
||||
from plexapi.config import PlexConfig, reset_base_headers
|
||||
from plexapi.utils import SecretsFilter
|
||||
from uuid import getnode
|
||||
|
||||
# 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 = '3.0.6'
|
||||
TIMEOUT = CONFIG.get('plexapi.timeout', 30, int)
|
||||
X_PLEX_CONTAINER_SIZE = CONFIG.get('plexapi.container_size', 100, int)
|
||||
|
||||
# Plex Header Configuation
|
||||
X_PLEX_PROVIDES = CONFIG.get('header.provides', 'controller')
|
||||
X_PLEX_PLATFORM = CONFIG.get('header.platorm', 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())))
|
||||
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)
|
58
lib/plexapi/alert.py
Normal file
58
lib/plexapi/alert.py
Normal file
@@ -0,0 +1,58 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import json
|
||||
import threading
|
||||
import websocket
|
||||
from plexapi import log
|
||||
|
||||
|
||||
class AlertListener(threading.Thread):
|
||||
""" Creates a websocket connection to the PlexServer to optionally recieve 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, therfore 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.
|
||||
|
||||
Parameters:
|
||||
server (:class:`~plexapi.server.PlexServer`): PlexServer this listener is connected to.
|
||||
callback (func): Callback function to call on recieved messages. The callback function
|
||||
will be sent a single argument 'data' which will contain a dictionary of data
|
||||
recieved from the server. :samp:`def my_callback(data): ...`
|
||||
"""
|
||||
key = '/:/websockets/notifications'
|
||||
|
||||
def __init__(self, server, callback=None):
|
||||
super(AlertListener, self).__init__()
|
||||
self.daemon = True
|
||||
self._server = server
|
||||
self._callback = callback
|
||||
self._ws = None
|
||||
|
||||
def run(self):
|
||||
# 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)
|
||||
self._ws.run_forever()
|
||||
|
||||
def stop(self):
|
||||
""" Stop the AlertListener thread. Once the notifier is stopped, it cannot be diractly
|
||||
started again. You must call :func:`plexapi.server.PlexServer.startAlertListener()`
|
||||
from a PlexServer instance.
|
||||
"""
|
||||
log.info('Stopping AlertListener.')
|
||||
self._ws.close()
|
||||
|
||||
def _onMessage(self, ws, message):
|
||||
""" Called when websocket message is recieved. """
|
||||
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, ws, err): # pragma: no cover
|
||||
""" Called when websocket error is recieved. """
|
||||
log.error('AlertListener Error: %s' % err)
|
304
lib/plexapi/audio.py
Normal file
304
lib/plexapi/audio.py
Normal file
@@ -0,0 +1,304 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from plexapi import media, utils
|
||||
from plexapi.base import Playable, PlexPartialObject
|
||||
|
||||
|
||||
class Audio(PlexPartialObject):
|
||||
""" Base class for audio :class:`~plexapi.audio.Artist`, :class:`~plexapi.audio.Album`
|
||||
and :class:`~plexapi.audio.Track` objects.
|
||||
|
||||
Attributes:
|
||||
addedAt (datetime): Datetime this item was added to the library.
|
||||
index (sting): Index Number (often the track number).
|
||||
key (str): API URL (/library/metadata/<ratingkey>).
|
||||
lastViewedAt (datetime): Datetime item was last accessed.
|
||||
librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID.
|
||||
listType (str): Hardcoded as 'audio' (useful for search filters).
|
||||
ratingKey (int): Unique key identifying this item.
|
||||
summary (str): Summary of the artist, track, or album.
|
||||
thumb (str): URL to thumbnail image.
|
||||
title (str): Artist, Album or Track title. (Jason Mraz, We Sing, Lucky, etc.)
|
||||
titleSort (str): Title to use when sorting (defaults to title).
|
||||
type (str): 'artist', 'album', or 'track'.
|
||||
updatedAt (datatime): Datetime this item was updated.
|
||||
viewCount (int): Count of times this item was accessed.
|
||||
"""
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self._data = data
|
||||
self.listType = 'audio'
|
||||
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
|
||||
self.index = data.attrib.get('index')
|
||||
self.key = data.attrib.get('key')
|
||||
self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt'))
|
||||
self.librarySectionID = data.attrib.get('librarySectionID')
|
||||
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.viewCount = utils.cast(int, data.attrib.get('viewCount', 0))
|
||||
|
||||
@property
|
||||
def thumbUrl(self):
|
||||
""" Return url to for the thumbnail image. """
|
||||
key = self.firstAttr('thumb', 'parentThumb', 'granparentThumb')
|
||||
return self._server.url(key, includeToken=True) if key else None
|
||||
|
||||
@property
|
||||
def artUrl(self):
|
||||
""" Return the first art url starting on the most specific for that item."""
|
||||
art = self.firstAttr('art', 'grandparentArt')
|
||||
return self._server.url(art, includeToken=True) if art else None
|
||||
|
||||
def url(self, part):
|
||||
""" Returns the full URL for this audio item. Typically used for getting a specific track. """
|
||||
return self._server.url(part, includeToken=True) if part else None
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Artist(Audio):
|
||||
""" Represents a single audio artist.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Directory'
|
||||
TYPE (str): 'artist'
|
||||
art (str): Artist artwork (/library/metadata/<ratingkey>/art/<artid>)
|
||||
countries (list): List of :class:`~plexapi.media.Country` objects this artist respresents.
|
||||
genres (list): List of :class:`~plexapi.media.Genre` objects this artist respresents.
|
||||
guid (str): Unknown (unique ID; com.plexapp.agents.plexmusic://gracenote/artist/05517B8701668D28?lang=en)
|
||||
key (str): API URL (/library/metadata/<ratingkey>).
|
||||
location (str): Filepath this artist is found on disk.
|
||||
similar (list): List of :class:`~plexapi.media.Similar` artists.
|
||||
"""
|
||||
TAG = 'Directory'
|
||||
TYPE = 'artist'
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
Audio._loadData(self, data)
|
||||
self.art = data.attrib.get('art')
|
||||
self.guid = data.attrib.get('guid')
|
||||
self.key = self.key.replace('/children', '') # FIX_BUG_50
|
||||
self.locations = self.listAttrs(data, 'path', etag='Location')
|
||||
self.countries = self.findItems(data, media.Country)
|
||||
self.genres = self.findItems(data, media.Genre)
|
||||
self.similar = self.findItems(data, media.Similar)
|
||||
self.collections = self.findItems(data, media.Collection)
|
||||
|
||||
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.
|
||||
"""
|
||||
key = '%s/children' % self.key
|
||||
return self.fetchItem(key, title__iexact=title)
|
||||
|
||||
def albums(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.audio.Album` objects by this artist. """
|
||||
key = '%s/children' % self.key
|
||||
return self.fetchItems(key, **kwargs)
|
||||
|
||||
def track(self, title):
|
||||
""" Returns the :class:`~plexapi.audio.Track` that matches the specified title.
|
||||
|
||||
Parameters:
|
||||
title (str): Title of the track to return.
|
||||
"""
|
||||
key = '%s/allLeaves' % self.key
|
||||
return self.fetchItem(key, title__iexact=title)
|
||||
|
||||
def tracks(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.audio.Track` objects by this artist. """
|
||||
key = '%s/allLeaves' % self.key
|
||||
return self.fetchItems(key, **kwargs)
|
||||
|
||||
def get(self, title):
|
||||
""" Alias of :func:`~plexapi.audio.Artist.track`. """
|
||||
return self.track(title)
|
||||
|
||||
def download(self, savepath=None, keep_orginal_name=False, **kwargs):
|
||||
""" Downloads all tracks for this artist to the specified location.
|
||||
|
||||
Parameters:
|
||||
savepath (str): Title of the track to return.
|
||||
keep_orginal_name (bool): Set True to keep the original filename as stored in
|
||||
the Plex server. False will create a new filename with the format
|
||||
"<Atrist> - <Album> <Track>".
|
||||
kwargs (dict): If specified, a :func:`~plexapi.audio.Track.getStreamURL()` will
|
||||
be returned and the additional arguments passed in will be sent to that
|
||||
function. If kwargs is not specified, the media items will be downloaded
|
||||
and saved to disk.
|
||||
"""
|
||||
filepaths = []
|
||||
for album in self.albums():
|
||||
for track in album.tracks():
|
||||
filepaths += track.download(savepath, keep_orginal_name, **kwargs)
|
||||
return filepaths
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Album(Audio):
|
||||
""" Represents a single audio album.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Directory'
|
||||
TYPE (str): 'album'
|
||||
art (str): Album artwork (/library/metadata/<ratingkey>/art/<artid>)
|
||||
genres (list): List of :class:`~plexapi.media.Genre` objects this album respresents.
|
||||
key (str): API URL (/library/metadata/<ratingkey>).
|
||||
originallyAvailableAt (datetime): Datetime this album was released.
|
||||
parentKey (str): API URL of this artist.
|
||||
parentRatingKey (int): Unique key identifying artist.
|
||||
parentThumb (str): URL to artist thumbnail image.
|
||||
parentTitle (str): Name of the artist for this album.
|
||||
studio (str): Studio that released this album.
|
||||
year (int): Year this album was released.
|
||||
"""
|
||||
TAG = 'Directory'
|
||||
TYPE = 'album'
|
||||
|
||||
def __iter__(self):
|
||||
for track in self.tracks:
|
||||
yield track
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
Audio._loadData(self, data)
|
||||
self.art = data.attrib.get('art')
|
||||
self.key = self.key.replace('/children', '') # fixes bug #50
|
||||
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
|
||||
self.parentKey = data.attrib.get('parentKey')
|
||||
self.parentRatingKey = data.attrib.get('parentRatingKey')
|
||||
self.parentThumb = data.attrib.get('parentThumb')
|
||||
self.parentTitle = data.attrib.get('parentTitle')
|
||||
self.studio = data.attrib.get('studio')
|
||||
self.year = utils.cast(int, data.attrib.get('year'))
|
||||
self.genres = self.findItems(data, media.Genre)
|
||||
self.collections = self.findItems(data, media.Collection)
|
||||
self.labels = self.findItems(data, media.Label)
|
||||
|
||||
def track(self, title):
|
||||
""" Returns the :class:`~plexapi.audio.Track` that matches the specified title.
|
||||
|
||||
Parameters:
|
||||
title (str): Title of the track to return.
|
||||
"""
|
||||
key = '%s/children' % self.key
|
||||
return self.fetchItem(key, title__iexact=title)
|
||||
|
||||
def tracks(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.audio.Track` objects in this album. """
|
||||
key = '%s/children' % self.key
|
||||
return self.fetchItems(key, **kwargs)
|
||||
|
||||
def get(self, title):
|
||||
""" Alias of :func:`~plexapi.audio.Album.track`. """
|
||||
return self.track(title)
|
||||
|
||||
def artist(self):
|
||||
""" Return :func:`~plexapi.audio.Artist` of this album. """
|
||||
return self.fetchItem(self.parentKey)
|
||||
|
||||
def download(self, savepath=None, keep_orginal_name=False, **kwargs):
|
||||
""" Downloads all tracks for this artist to the specified location.
|
||||
|
||||
Parameters:
|
||||
savepath (str): Title of the track to return.
|
||||
keep_orginal_name (bool): Set True to keep the original filename as stored in
|
||||
the Plex server. False will create a new filename with the format
|
||||
"<Atrist> - <Album> <Track>".
|
||||
kwargs (dict): If specified, a :func:`~plexapi.audio.Track.getStreamURL()` will
|
||||
be returned and the additional arguments passed in will be sent to that
|
||||
function. If kwargs is not specified, the media items will be downloaded
|
||||
and saved to disk.
|
||||
"""
|
||||
filepaths = []
|
||||
for track in self.tracks():
|
||||
filepaths += track.download(savepath, keep_orginal_name, **kwargs)
|
||||
return filepaths
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Track(Audio, Playable):
|
||||
""" Represents a single audio track.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Directory'
|
||||
TYPE (str): 'track'
|
||||
art (str): Track artwork (/library/metadata/<ratingkey>/art/<artid>)
|
||||
chapterSource (TYPE): Unknown
|
||||
duration (int): Length of this album in seconds.
|
||||
grandparentArt (str): Artist artowrk.
|
||||
grandparentKey (str): Artist API URL.
|
||||
grandparentRatingKey (str): Unique key identifying artist.
|
||||
grandparentThumb (str): URL to artist thumbnail image.
|
||||
grandparentTitle (str): Name of the artist for this track.
|
||||
guid (str): Unknown (unique ID).
|
||||
media (list): List of :class:`~plexapi.media.Media` objects for this track.
|
||||
moods (list): List of :class:`~plexapi.media.Mood` objects for this track.
|
||||
originalTitle (str): Original track title (if translated).
|
||||
parentIndex (int): Album index.
|
||||
parentKey (str): Album API URL.
|
||||
parentRatingKey (int): Unique key identifying album.
|
||||
parentThumb (str): URL to album thumbnail image.
|
||||
parentTitle (str): Name of the album for this track.
|
||||
primaryExtraKey (str): Unknown
|
||||
ratingCount (int): Unknown
|
||||
userRating (float): Rating of this track (0.0 - 10.0) equaling (0 stars - 5 stars)
|
||||
viewOffset (int): Unknown
|
||||
year (int): Year this track was released.
|
||||
sessionKey (int): Session Key (active sessions only).
|
||||
usernames (str): Username of person playing this track (active sessions only).
|
||||
player (str): :class:`~plexapi.client.PlexClient` for playing track (active sessions only).
|
||||
transcodeSessions (None): :class:`~plexapi.media.TranscodeSession` for playing
|
||||
track (active sessions only).
|
||||
"""
|
||||
TAG = 'Track'
|
||||
TYPE = 'track'
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
Audio._loadData(self, data)
|
||||
Playable._loadData(self, data)
|
||||
self.art = data.attrib.get('art')
|
||||
self.chapterSource = data.attrib.get('chapterSource')
|
||||
self.duration = utils.cast(int, data.attrib.get('duration'))
|
||||
self.grandparentArt = data.attrib.get('grandparentArt')
|
||||
self.grandparentKey = data.attrib.get('grandparentKey')
|
||||
self.grandparentRatingKey = data.attrib.get('grandparentRatingKey')
|
||||
self.grandparentThumb = data.attrib.get('grandparentThumb')
|
||||
self.grandparentTitle = data.attrib.get('grandparentTitle')
|
||||
self.guid = data.attrib.get('guid')
|
||||
self.originalTitle = data.attrib.get('originalTitle')
|
||||
self.parentIndex = data.attrib.get('parentIndex')
|
||||
self.parentKey = data.attrib.get('parentKey')
|
||||
self.parentRatingKey = data.attrib.get('parentRatingKey')
|
||||
self.parentThumb = data.attrib.get('parentThumb')
|
||||
self.parentTitle = data.attrib.get('parentTitle')
|
||||
self.primaryExtraKey = data.attrib.get('primaryExtraKey')
|
||||
self.ratingCount = utils.cast(int, data.attrib.get('ratingCount'))
|
||||
self.userRating = utils.cast(float, data.attrib.get('userRating', 0))
|
||||
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
|
||||
self.year = utils.cast(int, data.attrib.get('year'))
|
||||
self.media = self.findItems(data, media.Media)
|
||||
self.moods = self.findItems(data, media.Mood)
|
||||
|
||||
def _prettyfilename(self):
|
||||
""" Returns a filename for use in download. """
|
||||
return '%s - %s %s' % (self.grandparentTitle, self.parentTitle, self.title)
|
||||
|
||||
def album(self):
|
||||
""" Return this track's :class:`~plexapi.audio.Album`. """
|
||||
return self.fetchItem(self.parentKey)
|
||||
|
||||
def artist(self):
|
||||
""" Return this track's :class:`~plexapi.audio.Artist`. """
|
||||
return self.fetchItem(self.grandparentKey)
|
581
lib/plexapi/base.py
Normal file
581
lib/plexapi/base.py
Normal file
@@ -0,0 +1,581 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
|
||||
from plexapi import log, utils
|
||||
from plexapi.compat import quote_plus, urlencode
|
||||
from plexapi.exceptions import BadRequest, NotFound, UnknownType, Unsupported
|
||||
from plexapi.utils import tag_helper
|
||||
|
||||
OPERATORS = {
|
||||
'exact': lambda v, q: v == q,
|
||||
'iexact': lambda v, q: v.lower() == q.lower(),
|
||||
'contains': lambda v, q: q in v,
|
||||
'icontains': lambda v, q: q.lower() in v.lower(),
|
||||
'ne': lambda v, q: v != q,
|
||||
'in': lambda v, q: v in q,
|
||||
'gt': lambda v, q: v > q,
|
||||
'gte': lambda v, q: v >= q,
|
||||
'lt': lambda v, q: v < q,
|
||||
'lte': lambda v, q: v <= q,
|
||||
'startswith': lambda v, q: v.startswith(q),
|
||||
'istartswith': lambda v, q: v.lower().startswith(q),
|
||||
'endswith': lambda v, q: v.endswith(q),
|
||||
'iendswith': lambda v, q: v.lower().endswith(q),
|
||||
'exists': lambda v, q: v is not None if q else v is None,
|
||||
'regex': lambda v, q: re.match(q, v),
|
||||
'iregex': lambda v, q: re.match(q, v, flags=re.IGNORECASE),
|
||||
}
|
||||
|
||||
|
||||
class PlexObject(object):
|
||||
""" Base class for all Plex objects.
|
||||
|
||||
Parameters:
|
||||
server (:class:`~plexapi.server.PlexServer`): PlexServer this client is connected to (optional)
|
||||
data (ElementTree): Response from PlexServer used to build this object (optional).
|
||||
initpath (str): Relative path requested when retrieving specified `data` (optional).
|
||||
"""
|
||||
TAG = None # xml element tag
|
||||
TYPE = None # xml element type
|
||||
key = None # plex relative url
|
||||
|
||||
def __init__(self, server, data, initpath=None):
|
||||
self._server = server
|
||||
self._data = data
|
||||
self._initpath = initpath or self.key
|
||||
self._details_key = ''
|
||||
if data is not None:
|
||||
self._loadData(data)
|
||||
|
||||
def __repr__(self):
|
||||
uid = self._clean(self.firstAttr('_baseurl', 'key', 'id', 'playQueueID', 'uri'))
|
||||
name = self._clean(self.firstAttr('title', 'name', 'username', 'product', 'tag', 'value'))
|
||||
return '<%s>' % ':'.join([p for p in [self.__class__.__name__, uid, name] if p])
|
||||
|
||||
def __setattr__(self, attr, value):
|
||||
# dont overwrite an attr with None unless its a private variable
|
||||
if value is not None or attr.startswith('_') or attr not in self.__dict__:
|
||||
self.__dict__[attr] = value
|
||||
|
||||
def _clean(self, value):
|
||||
""" Clean attr value for display in __repr__. """
|
||||
if value:
|
||||
value = str(value).replace('/library/metadata/', '')
|
||||
value = value.replace('/children', '')
|
||||
return value.replace(' ', '-')[:20]
|
||||
|
||||
def _buildItem(self, elem, cls=None, initpath=None):
|
||||
""" Factory function to build objects based on registered PLEXOBJECTS. """
|
||||
# cls is specified, build the object and return
|
||||
initpath = initpath or self._initpath
|
||||
if cls is not None:
|
||||
return cls(self._server, elem, initpath)
|
||||
# cls is not specified, try looking it up in PLEXOBJECTS
|
||||
etype = elem.attrib.get('type', elem.attrib.get('streamType'))
|
||||
ehash = '%s.%s' % (elem.tag, etype) if etype else elem.tag
|
||||
ecls = utils.PLEXOBJECTS.get(ehash, utils.PLEXOBJECTS.get(elem.tag))
|
||||
# log.debug('Building %s as %s', elem.tag, ecls.__name__)
|
||||
if ecls is not None:
|
||||
return ecls(self._server, elem, initpath)
|
||||
raise UnknownType("Unknown library type <%s type='%s'../>" % (elem.tag, etype))
|
||||
|
||||
def _buildItemOrNone(self, elem, cls=None, initpath=None):
|
||||
""" Calls :func:`~plexapi.base.PlexObject._buildItem()` but returns
|
||||
None if elem is an unknown type.
|
||||
"""
|
||||
try:
|
||||
return self._buildItem(elem, cls, initpath)
|
||||
except UnknownType:
|
||||
return None
|
||||
|
||||
def fetchItem(self, ekey, cls=None, **kwargs):
|
||||
""" Load the specified key to find and build the first item with the
|
||||
specified tag and attrs. If no tag or attrs are specified then
|
||||
the first item in the result set is returned.
|
||||
|
||||
Parameters:
|
||||
ekey (str or int): Path in Plex to fetch items from. If an int is passed
|
||||
in, the key will be translated to /library/metadata/<key>. This allows
|
||||
fetching an item only knowing its key-id.
|
||||
cls (:class:`~plexapi.base.PlexObject`): If you know the class of the
|
||||
items to be fetched, passing this in will help the parser ensure
|
||||
it only returns those items. By default we convert the xml elements
|
||||
with the best guess PlexObjects based on tag and type attrs.
|
||||
etag (str): Only fetch items with the specified tag.
|
||||
**kwargs (dict): Optionally add attribute filters on the items to fetch. For
|
||||
example, passing in viewCount=0 will only return matching items. Filtering
|
||||
is done before the Python objects are built to help keep things speedy.
|
||||
Note: Because some attribute names are already used as arguments to this
|
||||
function, such as 'tag', you may still reference the attr tag byappending
|
||||
an underscore. For example, passing in _tag='foobar' will return all items
|
||||
where tag='foobar'. Also Note: Case very much matters when specifying kwargs
|
||||
-- Optionally, operators can be specified by append it
|
||||
to the end of the attribute name for more complex lookups. For example,
|
||||
passing in viewCount__gte=0 will return all items where viewCount >= 0.
|
||||
Available operations include:
|
||||
|
||||
* __contains: Value contains specified arg.
|
||||
* __endswith: Value ends with specified arg.
|
||||
* __exact: Value matches specified arg.
|
||||
* __exists (bool): Value is or is not present in the attrs.
|
||||
* __gt: Value is greater than specified arg.
|
||||
* __gte: Value is greater than or equal to specified arg.
|
||||
* __icontains: Case insensative value contains specified arg.
|
||||
* __iendswith: Case insensative value ends with specified arg.
|
||||
* __iexact: Case insensative value matches specified arg.
|
||||
* __in: Value is in a specified list or tuple.
|
||||
* __iregex: Case insensative value matches the specified regular expression.
|
||||
* __istartswith: Case insensative value starts with specified arg.
|
||||
* __lt: Value is less than specified arg.
|
||||
* __lte: Value is less than or equal to specified arg.
|
||||
* __regex: Value matches the specified regular expression.
|
||||
* __startswith: Value starts with specified arg.
|
||||
"""
|
||||
if isinstance(ekey, int):
|
||||
ekey = '/library/metadata/%s' % ekey
|
||||
for elem in self._server.query(ekey):
|
||||
if self._checkAttrs(elem, **kwargs):
|
||||
return self._buildItem(elem, cls, ekey)
|
||||
clsname = cls.__name__ if cls else 'None'
|
||||
raise NotFound('Unable to find elem: cls=%s, attrs=%s' % (clsname, kwargs))
|
||||
|
||||
def fetchItems(self, ekey, cls=None, **kwargs):
|
||||
""" Load the specified key to find and build all items with the specified tag
|
||||
and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details
|
||||
on how this is used.
|
||||
"""
|
||||
data = self._server.query(ekey)
|
||||
return self.findItems(data, cls, ekey, **kwargs)
|
||||
|
||||
def findItems(self, data, cls=None, initpath=None, **kwargs):
|
||||
""" Load the specified data to find and build all items with the specified tag
|
||||
and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details
|
||||
on how this is used.
|
||||
"""
|
||||
# filter on cls attrs if specified
|
||||
if cls and cls.TAG and 'tag' not in kwargs:
|
||||
kwargs['etag'] = cls.TAG
|
||||
if cls and cls.TYPE and 'type' not in kwargs:
|
||||
kwargs['type'] = cls.TYPE
|
||||
# loop through all data elements to find matches
|
||||
items = []
|
||||
for elem in data:
|
||||
if self._checkAttrs(elem, **kwargs):
|
||||
item = self._buildItemOrNone(elem, cls, initpath)
|
||||
if item is not None:
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
def firstAttr(self, *attrs):
|
||||
""" Return the first attribute in attrs that is not None. """
|
||||
for attr in attrs:
|
||||
value = self.__dict__.get(attr)
|
||||
if value is not None:
|
||||
return value
|
||||
|
||||
def listAttrs(self, data, attr, **kwargs):
|
||||
results = []
|
||||
for elem in data:
|
||||
kwargs['%s__exists' % attr] = True
|
||||
if self._checkAttrs(elem, **kwargs):
|
||||
results.append(elem.attrib.get(attr))
|
||||
return results
|
||||
|
||||
def reload(self, key=None):
|
||||
""" Reload the data for this object from self.key. """
|
||||
key = key or self._details_key or self.key
|
||||
if not key:
|
||||
raise Unsupported('Cannot reload an object not built from a URL.')
|
||||
self._initpath = key
|
||||
data = self._server.query(key)
|
||||
self._loadData(data[0])
|
||||
return self
|
||||
|
||||
def _checkAttrs(self, elem, **kwargs):
|
||||
attrsFound = {}
|
||||
for attr, query in kwargs.items():
|
||||
attr, op, operator = self._getAttrOperator(attr)
|
||||
values = self._getAttrValue(elem, attr)
|
||||
# special case query in (None, 0, '') to include missing attr
|
||||
if op == 'exact' and not values and query in (None, 0, ''):
|
||||
return True
|
||||
# return if attr were looking for is missing
|
||||
attrsFound[attr] = False
|
||||
for value in values:
|
||||
value = self._castAttrValue(op, query, value)
|
||||
if operator(value, query):
|
||||
attrsFound[attr] = True
|
||||
break
|
||||
# log.debug('Checking %s for %s found: %s', elem.tag, kwargs, attrsFound)
|
||||
return all(attrsFound.values())
|
||||
|
||||
def _getAttrOperator(self, attr):
|
||||
for op, operator in OPERATORS.items():
|
||||
if attr.endswith('__%s' % op):
|
||||
attr = attr.rsplit('__', 1)[0]
|
||||
return attr, op, operator
|
||||
# default to exact match
|
||||
return attr, 'exact', OPERATORS['exact']
|
||||
|
||||
def _getAttrValue(self, elem, attrstr, results=None):
|
||||
# log.debug('Fetching %s in %s', attrstr, elem.tag)
|
||||
parts = attrstr.split('__', 1)
|
||||
attr = parts[0]
|
||||
attrstr = parts[1] if len(parts) == 2 else None
|
||||
if attrstr:
|
||||
results = [] if results is None else results
|
||||
for child in [c for c in elem if c.tag.lower() == attr.lower()]:
|
||||
results += self._getAttrValue(child, attrstr, results)
|
||||
return [r for r in results if r is not None]
|
||||
# check were looking for the tag
|
||||
if attr.lower() == 'etag':
|
||||
return [elem.tag]
|
||||
# loop through attrs so we can perform case-insensative match
|
||||
for _attr, value in elem.attrib.items():
|
||||
if attr.lower() == _attr.lower():
|
||||
return [value]
|
||||
return []
|
||||
|
||||
def _castAttrValue(self, op, query, value):
|
||||
if op == 'exists':
|
||||
return value
|
||||
if isinstance(query, bool):
|
||||
return bool(int(value))
|
||||
if isinstance(query, int) and '.' in value:
|
||||
return float(value)
|
||||
if isinstance(query, int):
|
||||
return int(value)
|
||||
if isinstance(query, float):
|
||||
return float(value)
|
||||
return value
|
||||
|
||||
def _loadData(self, data):
|
||||
raise NotImplementedError('Abstract method not implemented.')
|
||||
|
||||
|
||||
class PlexPartialObject(PlexObject):
|
||||
""" Not all objects in the Plex listings return the complete list of elements
|
||||
for the object. This object will allow you to assume each object is complete,
|
||||
and if the specified value you request is None it will fetch the full object
|
||||
automatically and update itself.
|
||||
"""
|
||||
|
||||
def __eq__(self, other):
|
||||
return other is not None and self.key == other.key
|
||||
|
||||
def __hash__(self):
|
||||
return hash(repr(self))
|
||||
|
||||
def __iter__(self):
|
||||
yield self
|
||||
|
||||
def __getattribute__(self, attr):
|
||||
# Dragons inside.. :-/
|
||||
value = super(PlexPartialObject, self).__getattribute__(attr)
|
||||
# Check a few cases where we dont want to reload
|
||||
if attr == 'key' or attr.startswith('_'): return value
|
||||
if value not in (None, []): return value
|
||||
if self.isFullObject(): return value
|
||||
# Log the reload.
|
||||
clsname = self.__class__.__name__
|
||||
title = self.__dict__.get('title', self.__dict__.get('name'))
|
||||
objname = "%s '%s'" % (clsname, title) if title else clsname
|
||||
log.debug("Reloading %s for attr '%s'" % (objname, attr))
|
||||
# Reload and return the value
|
||||
self.reload()
|
||||
return super(PlexPartialObject, self).__getattribute__(attr)
|
||||
|
||||
def analyze(self):
|
||||
""" Tell Plex Media Server to performs analysis on it this item to gather
|
||||
information. Analysis includes:
|
||||
|
||||
* Gather Media Properties: All of the media you add to a Library has
|
||||
properties that are useful to know–whether it's a video file, a
|
||||
music track, or one of your photos (container, codec, resolution, etc).
|
||||
* Generate Default Artwork: Artwork will automatically be grabbed from a
|
||||
video file. A background image will be pulled out as well as a
|
||||
smaller image to be used for poster/thumbnail type purposes.
|
||||
* Generate Video Preview Thumbnails: Video preview thumbnails are created,
|
||||
if you have that feature enabled. Video preview thumbnails allow
|
||||
graphical seeking in some Apps. It's also used in the Plex Web App Now
|
||||
Playing screen to show a graphical representation of where playback
|
||||
is. Video preview thumbnails creation is a CPU-intensive process akin
|
||||
to transcoding the file.
|
||||
"""
|
||||
key = '/%s/analyze' % self.key.lstrip('/')
|
||||
self._server.query(key, method=self._server._session.put)
|
||||
|
||||
def isFullObject(self):
|
||||
""" Retruns True if this is already a full object. A full object means all attributes
|
||||
were populated from the api path representing only this item. For example, the
|
||||
search result for a movie often only contain a portion of the attributes a full
|
||||
object (main url) for that movie contain.
|
||||
"""
|
||||
return not self.key or self.key == self._initpath
|
||||
|
||||
def isPartialObject(self):
|
||||
""" Returns True if this is not a full object. """
|
||||
return not self.isFullObject()
|
||||
|
||||
def edit(self, **kwargs):
|
||||
""" Edit an object.
|
||||
|
||||
Parameters:
|
||||
kwargs (dict): Dict of settings to edit.
|
||||
|
||||
Example:
|
||||
{'type': 1,
|
||||
'id': movie.ratingKey,
|
||||
'collection[0].tag.tag': 'Super',
|
||||
'collection.locked': 0}
|
||||
"""
|
||||
if 'id' not in kwargs:
|
||||
kwargs['id'] = self.ratingKey
|
||||
if 'type' not in kwargs:
|
||||
kwargs['type'] = utils.searchType(self.type)
|
||||
|
||||
part = '/library/sections/%s/all?%s' % (self.librarySectionID,
|
||||
urlencode(kwargs))
|
||||
self._server.query(part, method=self._server._session.put)
|
||||
|
||||
def _edit_tags(self, tag, items, locked=True, remove=False):
|
||||
""" Helper to edit and refresh a tags.
|
||||
|
||||
Parameters:
|
||||
tag (str): tag name
|
||||
items (list): list of tags to add
|
||||
locked (bool): lock this field.
|
||||
remove (bool): If this is active remove the tags in items.
|
||||
"""
|
||||
if not isinstance(items, list):
|
||||
items = [items]
|
||||
value = getattr(self, tag + 's')
|
||||
existing_cols = [t.tag for t in value if t and remove is False]
|
||||
d = tag_helper(tag, existing_cols + items, locked, remove)
|
||||
self.edit(**d)
|
||||
self.refresh()
|
||||
|
||||
def addCollection(self, collections):
|
||||
""" Add a collection(s).
|
||||
|
||||
Parameters:
|
||||
collections (list): list of strings
|
||||
"""
|
||||
self._edit_tags('collection', collections)
|
||||
|
||||
def removeCollection(self, collections):
|
||||
""" Remove a collection(s). """
|
||||
self._edit_tags('collection', collections, remove=True)
|
||||
|
||||
def addLabel(self, labels):
|
||||
""" Add a label(s). """
|
||||
self._edit_tags('label', labels)
|
||||
|
||||
def removeLabel(self, labels):
|
||||
""" Remove a label(s). """
|
||||
self._edit_tags('label', labels, remove=True)
|
||||
|
||||
def addGenre(self, genres):
|
||||
""" Add a genre(s). """
|
||||
self._edit_tags('genre', genres)
|
||||
|
||||
def removeGenre(self, genres):
|
||||
""" Remove a genre(s). """
|
||||
self._edit_tags('genre', genres, remove=True)
|
||||
|
||||
def refresh(self):
|
||||
""" Refreshing a Library or individual item causes the metadata for the item to be
|
||||
refreshed, even if it already has metadata. You can think of refreshing as
|
||||
"update metadata for the requested item even if it already has some". You should
|
||||
refresh a Library or individual item if:
|
||||
|
||||
* You've changed the Library Metadata Agent.
|
||||
* You've added "Local Media Assets" (such as artwork, theme music, external
|
||||
subtitle files, etc.)
|
||||
* You want to freshen the item posters, summary, etc.
|
||||
* There's a problem with the poster image that's been downloaded.
|
||||
* Items are missing posters or other downloaded information. This is possible if
|
||||
the refresh process is interrupted (the Server is turned off, internet
|
||||
connection dies, etc).
|
||||
"""
|
||||
key = '%s/refresh' % self.key
|
||||
self._server.query(key, method=self._server._session.put)
|
||||
|
||||
def section(self):
|
||||
""" Returns the :class:`~plexapi.library.LibrarySection` this item belongs to. """
|
||||
return self._server.library.sectionByID(self.librarySectionID)
|
||||
|
||||
def delete(self):
|
||||
""" Delete a media element. This has to be enabled under settings > server > library in plex webui. """
|
||||
try:
|
||||
return self._server.query(self.key, method=self._server._session.delete)
|
||||
except BadRequest: # pragma: no cover
|
||||
log.error('Failed to delete %s. This could be because you '
|
||||
'havnt allowed items to be deleted' % self.key)
|
||||
raise
|
||||
|
||||
# The photo tag cant be built atm. TODO
|
||||
# def arts(self):
|
||||
# part = '%s/arts' % self.key
|
||||
# return self.fetchItem(part)
|
||||
|
||||
# def poster(self):
|
||||
# part = '%s/posters' % self.key
|
||||
# return self.fetchItem(part, etag='Photo')
|
||||
|
||||
|
||||
class Playable(object):
|
||||
""" This is a general place to store functions specific to media that is Playable.
|
||||
Things were getting mixed up a bit when dealing with Shows, Season, Artists,
|
||||
Albums which are all not playable.
|
||||
|
||||
Attributes:
|
||||
sessionKey (int): Active session key.
|
||||
usernames (str): Username of the person playing this item (for active sessions).
|
||||
players (:class:`~plexapi.client.PlexClient`): Client objects playing this item (for active sessions).
|
||||
session (:class:`~plexapi.media.Session`): Session object, for a playing media file.
|
||||
transcodeSessions (:class:`~plexapi.media.TranscodeSession`): Transcode Session object
|
||||
if item is being transcoded (None otherwise).
|
||||
viewedAt (datetime): Datetime item was last viewed (history).
|
||||
playlistItemID (int): Playlist item ID (only populated for :class:`~plexapi.playlist.Playlist` items).
|
||||
"""
|
||||
|
||||
def _loadData(self, data):
|
||||
self.sessionKey = utils.cast(int, data.attrib.get('sessionKey')) # session
|
||||
self.usernames = self.listAttrs(data, 'title', etag='User') # session
|
||||
self.players = self.findItems(data, etag='Player') # session
|
||||
self.transcodeSessions = self.findItems(data, etag='TranscodeSession') # session
|
||||
self.session = self.findItems(data, etag='Session') # session
|
||||
self.viewedAt = utils.toDatetime(data.attrib.get('viewedAt')) # history
|
||||
self.playlistItemID = utils.cast(int, data.attrib.get('playlistItemID')) # playlist
|
||||
|
||||
def isFullObject(self):
|
||||
""" Retruns True if this is already a full object. A full object means all attributes
|
||||
were populated from the api path representing only this item. For example, the
|
||||
search result for a movie often only contain a portion of the attributes a full
|
||||
object (main url) for that movie contain.
|
||||
"""
|
||||
return self._details_key == self._initpath or not self.key
|
||||
|
||||
def getStreamURL(self, **params):
|
||||
""" Returns a stream url that may be used by external applications such as VLC.
|
||||
|
||||
Parameters:
|
||||
**params (dict): optional parameters to manipulate the playback when accessing
|
||||
the stream. A few known parameters include: maxVideoBitrate, videoResolution
|
||||
offset, copyts, protocol, mediaIndex, platform.
|
||||
|
||||
Raises:
|
||||
Unsupported: When the item doesn't support fetching a stream URL.
|
||||
"""
|
||||
if self.TYPE not in ('movie', 'episode', 'track'):
|
||||
raise Unsupported('Fetching stream URL for %s is unsupported.' % self.TYPE)
|
||||
mvb = params.get('maxVideoBitrate')
|
||||
vr = params.get('videoResolution', '')
|
||||
params = {
|
||||
'path': self.key,
|
||||
'offset': params.get('offset', 0),
|
||||
'copyts': params.get('copyts', 1),
|
||||
'protocol': params.get('protocol'),
|
||||
'mediaIndex': params.get('mediaIndex', 0),
|
||||
'X-Plex-Platform': params.get('platform', 'Chrome'),
|
||||
'maxVideoBitrate': max(mvb, 64) if mvb else None,
|
||||
'videoResolution': vr if re.match('^\d+x\d+$', vr) else None
|
||||
}
|
||||
# remove None values
|
||||
params = {k: v for k, v in params.items() if v is not None}
|
||||
streamtype = 'audio' if self.TYPE in ('track', 'album') else 'video'
|
||||
# sort the keys since the randomness fucks with my tests..
|
||||
sorted_params = sorted(params.items(), key=lambda val: val[0])
|
||||
return self._server.url('/%s/:/transcode/universal/start.m3u8?%s' %
|
||||
(streamtype, urlencode(sorted_params)), includeToken=True)
|
||||
|
||||
def iterParts(self):
|
||||
""" Iterates over the parts of this media item. """
|
||||
for item in self.media:
|
||||
for part in item.parts:
|
||||
yield part
|
||||
|
||||
def split(self):
|
||||
"""Split a duplicate."""
|
||||
key = '%s/split' % self.key
|
||||
return self._server.query(key, method=self._server._session.put)
|
||||
|
||||
def unmatch(self):
|
||||
"""Unmatch a media file."""
|
||||
key = '%s/unmatch' % self.key
|
||||
return self._server.query(key, method=self._server._session.put)
|
||||
|
||||
def play(self, client):
|
||||
""" Start playback on the specified client.
|
||||
|
||||
Parameters:
|
||||
client (:class:`~plexapi.client.PlexClient`): Client to start playing on.
|
||||
"""
|
||||
client.playMedia(self)
|
||||
|
||||
def download(self, savepath=None, keep_orginal_name=False, **kwargs):
|
||||
""" Downloads this items media to the specified location. Returns a list of
|
||||
filepaths that have been saved to disk.
|
||||
|
||||
Parameters:
|
||||
savepath (str): Title of the track to return.
|
||||
keep_orginal_name (bool): Set True to keep the original filename as stored in
|
||||
the Plex server. False will create a new filename with the format
|
||||
"<Artist> - <Album> <Track>".
|
||||
kwargs (dict): If specified, a :func:`~plexapi.audio.Track.getStreamURL()` will
|
||||
be returned and the additional arguments passed in will be sent to that
|
||||
function. If kwargs is not specified, the media items will be downloaded
|
||||
and saved to disk.
|
||||
"""
|
||||
filepaths = []
|
||||
locations = [i for i in self.iterParts() if i]
|
||||
for location in locations:
|
||||
filename = location.file
|
||||
if keep_orginal_name is False:
|
||||
filename = '%s.%s' % (self._prettyfilename(), location.container)
|
||||
# So this seems to be a alot slower but allows transcode.
|
||||
if kwargs:
|
||||
download_url = self.getStreamURL(**kwargs)
|
||||
else:
|
||||
download_url = self._server.url('%s?download=1' % location.key)
|
||||
filepath = utils.download(download_url, self._server._token, filename=filename,
|
||||
savepath=savepath, session=self._server._session)
|
||||
if filepath:
|
||||
filepaths.append(filepath)
|
||||
return filepaths
|
||||
|
||||
def stop(self, reason=''):
|
||||
""" Stop playback for a media item. """
|
||||
key = '/status/sessions/terminate?sessionId=%s&reason=%s' % (self.session[0].id, quote_plus(reason))
|
||||
return self._server.query(key)
|
||||
|
||||
def updateProgress(self, time, state='stopped'):
|
||||
""" Set the watched progress for this video.
|
||||
|
||||
Note that setting the time to 0 will not work.
|
||||
Use `markWatched` or `markUnwatched` to achieve
|
||||
that goal.
|
||||
|
||||
Parameters:
|
||||
time (int): milliseconds watched
|
||||
state (string): state of the video, default 'stopped'
|
||||
"""
|
||||
key = '/:/progress?key=%s&identifier=com.plexapp.plugins.library&time=%d&state=%s' % (self.ratingKey,
|
||||
time, state)
|
||||
self._server.query(key)
|
||||
self.reload()
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Release(PlexObject):
|
||||
TAG = 'Release'
|
||||
key = '/updater/status'
|
||||
|
||||
def _loadData(self, data):
|
||||
self.download_key = data.attrib.get('key')
|
||||
self.version = data.attrib.get('version')
|
||||
self.added = data.attrib.get('added')
|
||||
self.fixed = data.attrib.get('fixed')
|
||||
self.downloadURL = data.attrib.get('downloadURL')
|
||||
self.state = data.attrib.get('state')
|
527
lib/plexapi/client.py
Normal file
527
lib/plexapi/client.py
Normal file
@@ -0,0 +1,527 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import requests
|
||||
|
||||
from requests.status_codes import _codes as codes
|
||||
from plexapi import BASE_HEADERS, CONFIG, TIMEOUT
|
||||
from plexapi import log, logfilter, utils
|
||||
from plexapi.base import PlexObject
|
||||
from plexapi.compat import ElementTree
|
||||
from plexapi.exceptions import BadRequest, Unsupported
|
||||
from plexapi.playqueue import PlayQueue
|
||||
|
||||
|
||||
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 dirrectly to this client.
|
||||
token (str): X-Plex-Token used for authenication (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 authenication
|
||||
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,
|
||||
token=None, connect=True, session=None, timeout=None):
|
||||
super(PlexClient, self).__init__(server, data, initpath)
|
||||
self._baseurl = baseurl.strip('/') if baseurl else None
|
||||
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._proxyThroughServer = False
|
||||
self._commandId = 0
|
||||
if not any([data, 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)
|
||||
self._loadData(data[0])
|
||||
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 cant 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.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))
|
||||
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:
|
||||
:class:`~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 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):
|
||||
codename = codes.get(response.status_code)[0]
|
||||
errtext = response.text.replace('\n', ' ')
|
||||
log.warning('BadRequest (%s) %s %s; %s' % (response.status_code, codename, response.url, errtext))
|
||||
raise BadRequest('(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext))
|
||||
data = 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:
|
||||
:class:`~plexapi.exceptions.Unsupported`: When we detect the client
|
||||
doesn't support this capability.
|
||||
"""
|
||||
command = command.strip('/')
|
||||
controller = command.split('/')[0]
|
||||
if controller not in self.protocolCapabilities:
|
||||
log.debug('Client %s doesnt support %s controller.'
|
||||
'What your trying might not work' % (self.title, controller))
|
||||
|
||||
params['commandID'] = self._nextCommandId()
|
||||
key = '/player/%s%s' % (command, utils.joinArgs(params))
|
||||
headers = {'X-Plex-Target-Client-Identifier': self.machineIdentifier}
|
||||
proxy = self._proxyThroughServer if proxy is None else proxy
|
||||
if proxy:
|
||||
return self._server.query(key, headers=headers)
|
||||
return self.query(key, headers=headers)
|
||||
|
||||
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 '%s%s%sX-Plex-Token=%s' % (self._baseurl, key, delim, self._token)
|
||||
return '%s%s' % (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.
|
||||
|
||||
Raises:
|
||||
:class:`~plexapi.exceptions.Unsupported`: When no PlexServer specified in this object.
|
||||
"""
|
||||
if not self._server:
|
||||
raise Unsupported('A server must be specified before using this command.')
|
||||
server_url = media._server._baseurl.split(':')
|
||||
self.sendCommand('mirror/details', **dict({
|
||||
'machineIdentifier': self._server.machineIdentifier,
|
||||
'address': server_url[1].strip('/'),
|
||||
'port': server_url[-1],
|
||||
'key': media.key,
|
||||
}, **params))
|
||||
|
||||
# -------------------
|
||||
# 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
|
||||
|
||||
Raises:
|
||||
:class:`~plexapi.exceptions.Unsupported`: When no PlexServer specified in this object.
|
||||
"""
|
||||
if not self._server:
|
||||
raise Unsupported('A server must be specified before using this command.')
|
||||
server_url = media._server._baseurl.split(':')
|
||||
|
||||
if self.product != 'OpenPHT':
|
||||
try:
|
||||
self.sendCommand('timeline/subscribe', port=server_url[1].strip('/'), protocol='http')
|
||||
except: # noqa: E722
|
||||
# some clients dont need or like this and raises http 400.
|
||||
# We want to include the exception in the log,
|
||||
# but it might still work so we swallow it.
|
||||
log.exception('%s failed to subscribe ' % self.title)
|
||||
|
||||
playqueue = media if isinstance(media, PlayQueue) else self._server.createPlayQueue(media)
|
||||
self.sendCommand('playback/playMedia', **dict({
|
||||
'machineIdentifier': self._server.machineIdentifier,
|
||||
'address': server_url[1].strip('/'),
|
||||
'port': server_url[-1],
|
||||
'offset': offset,
|
||||
'key': media.key,
|
||||
'token': media._server._token,
|
||||
'containerKey': '/playQueues/%s?window=100&own=1' % playqueue.playQueueID,
|
||||
}, **params))
|
||||
|
||||
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 timeline(self):
|
||||
""" Poll the current timeline and return the XML response. """
|
||||
return self.sendCommand('timeline/poll', wait=1)
|
||||
|
||||
def isPlayingMedia(self, includePaused=False):
|
||||
""" Returns True if any media is currently playing.
|
||||
|
||||
Parameters:
|
||||
includePaused (bool): Set True to treat currently paused items
|
||||
as playing (optional; default True).
|
||||
"""
|
||||
for mediatype in self.timeline():
|
||||
if mediatype.get('state') == 'playing':
|
||||
return True
|
||||
if includePaused and mediatype.get('state') == 'paused':
|
||||
return True
|
||||
return False
|
53
lib/plexapi/compat.py
Normal file
53
lib/plexapi/compat.py
Normal file
@@ -0,0 +1,53 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Python 2/3 compatability
|
||||
# Always try Py3 first
|
||||
import os
|
||||
from sys import version_info
|
||||
|
||||
ustr = str
|
||||
if version_info < (3,):
|
||||
ustr = unicode
|
||||
|
||||
try:
|
||||
string_type = basestring
|
||||
except NameError:
|
||||
string_type = str
|
||||
|
||||
try:
|
||||
from urllib.parse import urlencode
|
||||
except ImportError:
|
||||
from urllib import urlencode
|
||||
|
||||
try:
|
||||
from urllib.parse import quote
|
||||
except ImportError:
|
||||
from urllib import quote
|
||||
|
||||
try:
|
||||
from urllib.parse import quote_plus
|
||||
except ImportError:
|
||||
from urllib import quote_plus
|
||||
|
||||
try:
|
||||
from urllib.parse import unquote
|
||||
except ImportError:
|
||||
from urllib import unquote
|
||||
|
||||
try:
|
||||
from configparser import ConfigParser
|
||||
except ImportError:
|
||||
from ConfigParser import ConfigParser
|
||||
|
||||
try:
|
||||
from xml.etree import cElementTree as ElementTree
|
||||
except ImportError:
|
||||
from xml.etree import ElementTree
|
||||
|
||||
|
||||
def makedirs(name, mode=0o777, exist_ok=False):
|
||||
""" Mimicks os.makedirs() from Python 3. """
|
||||
try:
|
||||
os.makedirs(name, mode)
|
||||
except OSError:
|
||||
if not os.path.isdir(name) or not exist_ok:
|
||||
raise
|
63
lib/plexapi/config.py
Normal file
63
lib/plexapi/config.py
Normal file
@@ -0,0 +1,63 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from plexapi.compat import ConfigParser
|
||||
|
||||
|
||||
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 = 'PLEXAPI_%s' % 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 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,
|
||||
}
|
31
lib/plexapi/exceptions.py
Normal file
31
lib/plexapi/exceptions.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# -*- 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(PlexApiException):
|
||||
""" Invalid username or password. """
|
||||
pass
|
716
lib/plexapi/library.py
Normal file
716
lib/plexapi/library.py
Normal file
@@ -0,0 +1,716 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from plexapi import X_PLEX_CONTAINER_SIZE, log, utils
|
||||
from plexapi.base import PlexObject
|
||||
from plexapi.compat import unquote, urlencode, quote_plus
|
||||
from plexapi.media import MediaTag
|
||||
from plexapi.exceptions import BadRequest, NotFound
|
||||
|
||||
|
||||
class Library(PlexObject):
|
||||
""" Represents a PlexServer library. This contains all sections of media defined
|
||||
in your Plex server including video, shows and audio.
|
||||
|
||||
Attributes:
|
||||
key (str): '/library'
|
||||
identifier (str): Unknown ('com.plexapp.plugins.library').
|
||||
mediaTagVersion (str): Unknown (/system/bundle/media/flags/)
|
||||
server (:class:`~plexapi.server.PlexServer`): PlexServer this client is connected to.
|
||||
title1 (str): 'Plex Library' (not sure how useful this is).
|
||||
title2 (str): Second title (this is blank on my setup).
|
||||
"""
|
||||
key = '/library'
|
||||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
self._sectionsByID = {} # cached Section UUIDs
|
||||
self.identifier = data.attrib.get('identifier')
|
||||
self.mediaTagVersion = data.attrib.get('mediaTagVersion')
|
||||
self.title1 = data.attrib.get('title1')
|
||||
self.title2 = data.attrib.get('title2')
|
||||
|
||||
def sections(self):
|
||||
""" Returns a list of all media sections in this library. Library sections may be any of
|
||||
:class:`~plexapi.library.MovieSection`, :class:`~plexapi.library.ShowSection`,
|
||||
:class:`~plexapi.library.MusicSection`, :class:`~plexapi.library.PhotoSection`.
|
||||
"""
|
||||
key = '/library/sections'
|
||||
sections = []
|
||||
for elem in self._server.query(key):
|
||||
for cls in (MovieSection, ShowSection, MusicSection, PhotoSection):
|
||||
if elem.attrib.get('type') == cls.TYPE:
|
||||
section = cls(self._server, elem, key)
|
||||
self._sectionsByID[section.key] = section
|
||||
sections.append(section)
|
||||
return sections
|
||||
|
||||
def section(self, title=None):
|
||||
""" Returns the :class:`~plexapi.library.LibrarySection` that matches the specified title.
|
||||
|
||||
Parameters:
|
||||
title (str): Title of the section to return.
|
||||
"""
|
||||
for section in self.sections():
|
||||
if section.title.lower() == title.lower():
|
||||
return section
|
||||
raise NotFound('Invalid library section: %s' % title)
|
||||
|
||||
def sectionByID(self, sectionID):
|
||||
""" Returns the :class:`~plexapi.library.LibrarySection` that matches the specified sectionID.
|
||||
|
||||
Parameters:
|
||||
sectionID (str): ID of the section to return.
|
||||
"""
|
||||
if not self._sectionsByID or sectionID not in self._sectionsByID:
|
||||
self.sections()
|
||||
return self._sectionsByID[sectionID]
|
||||
|
||||
def all(self, **kwargs):
|
||||
""" Returns a list of all media from all library sections.
|
||||
This may be a very large dataset to retrieve.
|
||||
"""
|
||||
items = []
|
||||
for section in self.sections():
|
||||
for item in section.all(**kwargs):
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
def onDeck(self):
|
||||
""" Returns a list of all media items on deck. """
|
||||
return self.fetchItems('/library/onDeck')
|
||||
|
||||
def recentlyAdded(self):
|
||||
""" Returns a list of all media items recently added. """
|
||||
return self.fetchItems('/library/recentlyAdded')
|
||||
|
||||
def search(self, title=None, libtype=None, **kwargs):
|
||||
""" Searching within a library section is much more powerful. It seems certain
|
||||
attributes on the media objects can be targeted to filter this search down
|
||||
a bit, but I havent found the documentation for it.
|
||||
|
||||
Example: "studio=Comedy%20Central" or "year=1999" "title=Kung Fu" all work. Other items
|
||||
such as actor=<id> seem to work, but require you already know the id of the actor.
|
||||
TLDR: This is untested but seems to work. Use library section search when you can.
|
||||
"""
|
||||
args = {}
|
||||
if title:
|
||||
args['title'] = title
|
||||
if libtype:
|
||||
args['type'] = utils.searchType(libtype)
|
||||
for attr, value in kwargs.items():
|
||||
args[attr] = value
|
||||
key = '/library/all%s' % utils.joinArgs(args)
|
||||
return self.fetchItems(key)
|
||||
|
||||
def cleanBundles(self):
|
||||
""" Poster images and other metadata for items in your library are kept in "bundle"
|
||||
packages. When you remove items from your library, these bundles aren't immediately
|
||||
removed. Removing these old bundles can reduce the size of your install. By default, your
|
||||
server will automatically clean up old bundles once a week as part of Scheduled Tasks.
|
||||
"""
|
||||
# TODO: Should this check the response for success or the correct mediaprefix?
|
||||
self._server.query('/library/clean/bundles')
|
||||
|
||||
def emptyTrash(self):
|
||||
""" If a library has items in the Library Trash, use this option to empty the Trash. """
|
||||
for section in self.sections():
|
||||
section.emptyTrash()
|
||||
|
||||
def optimize(self):
|
||||
""" The Optimize option cleans up the server database from unused or fragmented data.
|
||||
For example, if you have deleted or added an entire library or many items in a
|
||||
library, you may like to optimize the database.
|
||||
"""
|
||||
self._server.query('/library/optimize')
|
||||
|
||||
def update(self):
|
||||
""" Scan this library for new items."""
|
||||
self._server.query('/library/sections/all/refresh')
|
||||
|
||||
def cancelUpdate(self):
|
||||
""" Cancel a library update. """
|
||||
key = '/library/sections/all/refresh'
|
||||
self._server.query(key, method=self._server._session.delete)
|
||||
|
||||
def refresh(self):
|
||||
""" Forces a download of fresh media information from the internet.
|
||||
This can take a long time. Any locked fields are not modified.
|
||||
"""
|
||||
self._server.query('/library/sections/all/refresh?force=1')
|
||||
|
||||
def deleteMediaPreviews(self):
|
||||
""" Delete the preview thumbnails for the all sections. This cannot be
|
||||
undone. Recreating media preview files can take hours or even days.
|
||||
"""
|
||||
for section in self.sections():
|
||||
section.deleteMediaPreviews()
|
||||
|
||||
def add(self, name='', type='', agent='', scanner='', location='', language='en', *args, **kwargs):
|
||||
""" Simplified add for the most common options.
|
||||
|
||||
Parameters:
|
||||
name (str): Name of the library
|
||||
agent (str): Example com.plexapp.agents.imdb
|
||||
type (str): movie, show, # check me
|
||||
location (str): /path/to/files
|
||||
language (str): Two letter language fx en
|
||||
kwargs (dict): Advanced options should be passed as a dict. where the id is the key.
|
||||
|
||||
**Photo Preferences**
|
||||
|
||||
* **agent** (str): com.plexapp.agents.none
|
||||
* **enableAutoPhotoTags** (bool): Tag photos. Default value false.
|
||||
* **enableBIFGeneration** (bool): Enable video preview thumbnails. Default value true.
|
||||
* **includeInGlobal** (bool): Include in dashboard. Default value true.
|
||||
* **scanner** (str): Plex Photo Scanner
|
||||
|
||||
**Movie Preferences**
|
||||
|
||||
* **agent** (str): com.plexapp.agents.none, com.plexapp.agents.imdb, com.plexapp.agents.themoviedb
|
||||
* **enableBIFGeneration** (bool): Enable video preview thumbnails. Default value true.
|
||||
* **enableCinemaTrailers** (bool): Enable Cinema Trailers. Default value true.
|
||||
* **includeInGlobal** (bool): Include in dashboard. Default value true.
|
||||
* **scanner** (str): Plex Movie Scanner, Plex Video Files Scanner
|
||||
|
||||
**IMDB Movie Options** (com.plexapp.agents.imdb)
|
||||
|
||||
* **title** (bool): Localized titles. Default value false.
|
||||
* **extras** (bool): Find trailers and extras automatically (Plex Pass required). Default value true.
|
||||
* **only_trailers** (bool): Skip extras which aren't trailers. Default value false.
|
||||
* **redband** (bool): Use red band (restricted audiences) trailers when available. Default value false.
|
||||
* **native_subs** (bool): Include extras with subtitles in Library language. Default value false.
|
||||
* **cast_list** (int): Cast List Source: Default value 1 Possible options: 0:IMDb,1:The Movie Database.
|
||||
* **ratings** (int): Ratings Source, Default value 0 Possible options:
|
||||
0:Rotten Tomatoes, 1:IMDb, 2:The Movie Database.
|
||||
* **summary** (int): Plot Summary Source: Default value 1 Possible options: 0:IMDb,1:The Movie Database.
|
||||
* **country** (int): Default value 46 Possible options 0:Argentina, 1:Australia, 2:Austria,
|
||||
3:Belgium, 4:Belize, 5:Bolivia, 6:Brazil, 7:Canada, 8:Chile, 9:Colombia, 10:Costa Rica,
|
||||
11:Czech Republic, 12:Denmark, 13:Dominican Republic, 14:Ecuador, 15:El Salvador,
|
||||
16:France, 17:Germany, 18:Guatemala, 19:Honduras, 20:Hong Kong SAR, 21:Ireland,
|
||||
22:Italy, 23:Jamaica, 24:Korea, 25:Liechtenstein, 26:Luxembourg, 27:Mexico, 28:Netherlands,
|
||||
29:New Zealand, 30:Nicaragua, 31:Panama, 32:Paraguay, 33:Peru, 34:Portugal,
|
||||
35:Peoples Republic of China, 36:Puerto Rico, 37:Russia, 38:Singapore, 39:South Africa,
|
||||
40:Spain, 41:Sweden, 42:Switzerland, 43:Taiwan, 44:Trinidad, 45:United Kingdom,
|
||||
46:United States, 47:Uruguay, 48:Venezuela.
|
||||
* **collections** (bool): Use collection info from The Movie Database. Default value false.
|
||||
* **localart** (bool): Prefer artwork based on library language. Default value true.
|
||||
* **adult** (bool): Include adult content. Default value false.
|
||||
* **usage** (bool): Send anonymous usage data to Plex. Default value true.
|
||||
|
||||
**TheMovieDB Movie Options** (com.plexapp.agents.themoviedb)
|
||||
|
||||
* **collections** (bool): Use collection info from The Movie Database. Default value false.
|
||||
* **localart** (bool): Prefer artwork based on library language. Default value true.
|
||||
* **adult** (bool): Include adult content. Default value false.
|
||||
* **country** (int): Country (used for release date and content rating). Default value 47 Possible
|
||||
options 0:, 1:Argentina, 2:Australia, 3:Austria, 4:Belgium, 5:Belize, 6:Bolivia, 7:Brazil, 8:Canada,
|
||||
9:Chile, 10:Colombia, 11:Costa Rica, 12:Czech Republic, 13:Denmark, 14:Dominican Republic, 15:Ecuador,
|
||||
16:El Salvador, 17:France, 18:Germany, 19:Guatemala, 20:Honduras, 21:Hong Kong SAR, 22:Ireland,
|
||||
23:Italy, 24:Jamaica, 25:Korea, 26:Liechtenstein, 27:Luxembourg, 28:Mexico, 29:Netherlands,
|
||||
30:New Zealand, 31:Nicaragua, 32:Panama, 33:Paraguay, 34:Peru, 35:Portugal,
|
||||
36:Peoples Republic of China, 37:Puerto Rico, 38:Russia, 39:Singapore, 40:South Africa, 41:Spain,
|
||||
42:Sweden, 43:Switzerland, 44:Taiwan, 45:Trinidad, 46:United Kingdom, 47:United States, 48:Uruguay,
|
||||
49:Venezuela.
|
||||
|
||||
**Show Preferences**
|
||||
|
||||
* **agent** (str): com.plexapp.agents.none, com.plexapp.agents.thetvdb, com.plexapp.agents.themoviedb
|
||||
* **enableBIFGeneration** (bool): Enable video preview thumbnails. Default value true.
|
||||
* **episodeSort** (int): Episode order. Default -1 Possible options: 0:Oldest first, 1:Newest first.
|
||||
* **flattenSeasons** (int): Seasons. Default value 0 Possible options: 0:Show,1:Hide.
|
||||
* **includeInGlobal** (bool): Include in dashboard. Default value true.
|
||||
* **scanner** (str): Plex Series Scanner
|
||||
|
||||
**TheTVDB Show Options** (com.plexapp.agents.thetvdb)
|
||||
|
||||
* **extras** (bool): Find trailers and extras automatically (Plex Pass required). Default value true.
|
||||
* **native_subs** (bool): Include extras with subtitles in Library language. Default value false.
|
||||
|
||||
**TheMovieDB Show Options** (com.plexapp.agents.themoviedb)
|
||||
|
||||
* **collections** (bool): Use collection info from The Movie Database. Default value false.
|
||||
* **localart** (bool): Prefer artwork based on library language. Default value true.
|
||||
* **adult** (bool): Include adult content. Default value false.
|
||||
* **country** (int): Country (used for release date and content rating). Default value 47 options
|
||||
0:, 1:Argentina, 2:Australia, 3:Austria, 4:Belgium, 5:Belize, 6:Bolivia, 7:Brazil, 8:Canada, 9:Chile,
|
||||
10:Colombia, 11:Costa Rica, 12:Czech Republic, 13:Denmark, 14:Dominican Republic, 15:Ecuador,
|
||||
16:El Salvador, 17:France, 18:Germany, 19:Guatemala, 20:Honduras, 21:Hong Kong SAR, 22:Ireland,
|
||||
23:Italy, 24:Jamaica, 25:Korea, 26:Liechtenstein, 27:Luxembourg, 28:Mexico, 29:Netherlands,
|
||||
30:New Zealand, 31:Nicaragua, 32:Panama, 33:Paraguay, 34:Peru, 35:Portugal,
|
||||
36:Peoples Republic of China, 37:Puerto Rico, 38:Russia, 39:Singapore, 40:South Africa,
|
||||
41:Spain, 42:Sweden, 43:Switzerland, 44:Taiwan, 45:Trinidad, 46:United Kingdom, 47:United States,
|
||||
48:Uruguay, 49:Venezuela.
|
||||
|
||||
**Other Video Preferences**
|
||||
|
||||
* **agent** (str): com.plexapp.agents.none, com.plexapp.agents.imdb, com.plexapp.agents.themoviedb
|
||||
* **enableBIFGeneration** (bool): Enable video preview thumbnails. Default value true.
|
||||
* **enableCinemaTrailers** (bool): Enable Cinema Trailers. Default value true.
|
||||
* **includeInGlobal** (bool): Include in dashboard. Default value true.
|
||||
* **scanner** (str): Plex Movie Scanner, Plex Video Files Scanner
|
||||
|
||||
**IMDB Other Video Options** (com.plexapp.agents.imdb)
|
||||
|
||||
* **title** (bool): Localized titles. Default value false.
|
||||
* **extras** (bool): Find trailers and extras automatically (Plex Pass required). Default value true.
|
||||
* **only_trailers** (bool): Skip extras which aren't trailers. Default value false.
|
||||
* **redband** (bool): Use red band (restricted audiences) trailers when available. Default value false.
|
||||
* **native_subs** (bool): Include extras with subtitles in Library language. Default value false.
|
||||
* **cast_list** (int): Cast List Source: Default value 1 Possible options: 0:IMDb,1:The Movie Database.
|
||||
* **ratings** (int): Ratings Source Default value 0 Possible options:
|
||||
0:Rotten Tomatoes,1:IMDb,2:The Movie Database.
|
||||
* **summary** (int): Plot Summary Source: Default value 1 Possible options: 0:IMDb,1:The Movie Database.
|
||||
* **country** (int): Country: Default value 46 Possible options: 0:Argentina, 1:Australia, 2:Austria,
|
||||
3:Belgium, 4:Belize, 5:Bolivia, 6:Brazil, 7:Canada, 8:Chile, 9:Colombia, 10:Costa Rica,
|
||||
11:Czech Republic, 12:Denmark, 13:Dominican Republic, 14:Ecuador, 15:El Salvador, 16:France,
|
||||
17:Germany, 18:Guatemala, 19:Honduras, 20:Hong Kong SAR, 21:Ireland, 22:Italy, 23:Jamaica,
|
||||
24:Korea, 25:Liechtenstein, 26:Luxembourg, 27:Mexico, 28:Netherlands, 29:New Zealand, 30:Nicaragua,
|
||||
31:Panama, 32:Paraguay, 33:Peru, 34:Portugal, 35:Peoples Republic of China, 36:Puerto Rico,
|
||||
37:Russia, 38:Singapore, 39:South Africa, 40:Spain, 41:Sweden, 42:Switzerland, 43:Taiwan, 44:Trinidad,
|
||||
45:United Kingdom, 46:United States, 47:Uruguay, 48:Venezuela.
|
||||
* **collections** (bool): Use collection info from The Movie Database. Default value false.
|
||||
* **localart** (bool): Prefer artwork based on library language. Default value true.
|
||||
* **adult** (bool): Include adult content. Default value false.
|
||||
* **usage** (bool): Send anonymous usage data to Plex. Default value true.
|
||||
|
||||
**TheMovieDB Other Video Options** (com.plexapp.agents.themoviedb)
|
||||
|
||||
* **collections** (bool): Use collection info from The Movie Database. Default value false.
|
||||
* **localart** (bool): Prefer artwork based on library language. Default value true.
|
||||
* **adult** (bool): Include adult content. Default value false.
|
||||
* **country** (int): Country (used for release date and content rating). Default
|
||||
value 47 Possible options 0:, 1:Argentina, 2:Australia, 3:Austria, 4:Belgium, 5:Belize,
|
||||
6:Bolivia, 7:Brazil, 8:Canada, 9:Chile, 10:Colombia, 11:Costa Rica, 12:Czech Republic,
|
||||
13:Denmark, 14:Dominican Republic, 15:Ecuador, 16:El Salvador, 17:France, 18:Germany,
|
||||
19:Guatemala, 20:Honduras, 21:Hong Kong SAR, 22:Ireland, 23:Italy, 24:Jamaica,
|
||||
25:Korea, 26:Liechtenstein, 27:Luxembourg, 28:Mexico, 29:Netherlands, 30:New Zealand,
|
||||
31:Nicaragua, 32:Panama, 33:Paraguay, 34:Peru, 35:Portugal,
|
||||
36:Peoples Republic of China, 37:Puerto Rico, 38:Russia, 39:Singapore,
|
||||
40:South Africa, 41:Spain, 42:Sweden, 43:Switzerland, 44:Taiwan, 45:Trinidad,
|
||||
46:United Kingdom, 47:United States, 48:Uruguay, 49:Venezuela.
|
||||
"""
|
||||
part = '/library/sections?name=%s&type=%s&agent=%s&scanner=%s&language=%s&location=%s' % (
|
||||
quote_plus(name), type, agent, quote_plus(scanner), language, quote_plus(location)) # noqa E126
|
||||
if kwargs:
|
||||
part += urlencode(kwargs)
|
||||
return self._server.query(part, method=self._server._session.post)
|
||||
|
||||
|
||||
class LibrarySection(PlexObject):
|
||||
""" Base class for a single library section.
|
||||
|
||||
Attributes:
|
||||
ALLOWED_FILTERS (tuple): ()
|
||||
ALLOWED_SORT (tuple): ()
|
||||
BOOLEAN_FILTERS (tuple<str>): ('unwatched', 'duplicate')
|
||||
server (:class:`~plexapi.server.PlexServer`): Server this client is connected to.
|
||||
initpath (str): Path requested when building this object.
|
||||
agent (str): Unknown (com.plexapp.agents.imdb, etc)
|
||||
allowSync (bool): True if you allow syncing content from this section.
|
||||
art (str): Wallpaper artwork used to respresent this section.
|
||||
composite (str): Composit image used to represent this section.
|
||||
createdAt (datetime): Datetime this library section was created.
|
||||
filters (str): Unknown
|
||||
key (str): Key (or ID) of this library section.
|
||||
language (str): Language represented in this section (en, xn, etc).
|
||||
locations (str): Paths on disk where section content is stored.
|
||||
refreshing (str): True if this section is currently being refreshed.
|
||||
scanner (str): Internal scanner used to find media (Plex Movie Scanner, Plex Premium Music Scanner, etc.)
|
||||
thumb (str): Thumbnail image used to represent this section.
|
||||
title (str): Title of this section.
|
||||
type (str): Type of content section represents (movie, artist, photo, show).
|
||||
updatedAt (datetime): Datetime this library section was last updated.
|
||||
uuid (str): Unique id for this section (32258d7c-3e6c-4ac5-98ad-bad7a3b78c63)
|
||||
"""
|
||||
ALLOWED_FILTERS = ()
|
||||
ALLOWED_SORT = ()
|
||||
BOOLEAN_FILTERS = ('unwatched', 'duplicate')
|
||||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
self.agent = data.attrib.get('agent')
|
||||
self.allowSync = utils.cast(bool, data.attrib.get('allowSync'))
|
||||
self.art = data.attrib.get('art')
|
||||
self.composite = data.attrib.get('composite')
|
||||
self.createdAt = utils.toDatetime(data.attrib.get('createdAt'))
|
||||
self.filters = data.attrib.get('filters')
|
||||
self.key = data.attrib.get('key') # invalid key from plex
|
||||
self.language = data.attrib.get('language')
|
||||
self.locations = self.listAttrs(data, 'path', etag='Location')
|
||||
self.refreshing = utils.cast(bool, data.attrib.get('refreshing'))
|
||||
self.scanner = data.attrib.get('scanner')
|
||||
self.thumb = data.attrib.get('thumb')
|
||||
self.title = data.attrib.get('title')
|
||||
self.type = data.attrib.get('type')
|
||||
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
|
||||
self.uuid = data.attrib.get('uuid')
|
||||
|
||||
def delete(self):
|
||||
""" Delete a library section. """
|
||||
try:
|
||||
return self._server.query('/library/sections/%s' % self.key, method=self._server._session.delete)
|
||||
except BadRequest: # pragma: no cover
|
||||
msg = 'Failed to delete library %s' % self.key
|
||||
msg += 'You may need to allow this permission in your Plex settings.'
|
||||
log.error(msg)
|
||||
raise
|
||||
|
||||
def edit(self, **kwargs):
|
||||
""" Edit a library (Note: agent is required). See :class:`~plexapi.library.Library` for example usage.
|
||||
|
||||
Parameters:
|
||||
kwargs (dict): Dict of settings to edit.
|
||||
"""
|
||||
part = '/library/sections/%s?%s' % (self.key, urlencode(kwargs))
|
||||
self._server.query(part, method=self._server._session.put)
|
||||
|
||||
# Reload this way since the self.key dont have a full path, but is simply a id.
|
||||
for s in self._server.library.sections():
|
||||
if s.key == self.key:
|
||||
return s
|
||||
|
||||
def get(self, title):
|
||||
""" Returns the media item with the specified title.
|
||||
|
||||
Parameters:
|
||||
title (str): Title of the item to return.
|
||||
"""
|
||||
key = '/library/sections/%s/all' % self.key
|
||||
return self.fetchItem(key, title__iexact=title)
|
||||
|
||||
def all(self, **kwargs):
|
||||
""" Returns a list of media from this library section. """
|
||||
key = '/library/sections/%s/all' % self.key
|
||||
return self.fetchItems(key, **kwargs)
|
||||
|
||||
def onDeck(self):
|
||||
""" Returns a list of media items on deck from this library section. """
|
||||
key = '/library/sections/%s/onDeck' % self.key
|
||||
return self.fetchItems(key)
|
||||
|
||||
def recentlyAdded(self, maxresults=50):
|
||||
""" Returns a list of media items recently added from this library section.
|
||||
|
||||
Parameters:
|
||||
maxresults (int): Max number of items to return (default 50).
|
||||
"""
|
||||
return self.search(sort='addedAt:desc', maxresults=maxresults)
|
||||
|
||||
def analyze(self):
|
||||
""" Run an analysis on all of the items in this library section. See
|
||||
See :func:`~plexapi.base.PlexPartialObject.analyze` for more details.
|
||||
"""
|
||||
key = '/library/sections/%s/analyze' % self.key
|
||||
self._server.query(key, method=self._server._session.put)
|
||||
|
||||
def emptyTrash(self):
|
||||
""" If a section has items in the Trash, use this option to empty the Trash. """
|
||||
key = '/library/sections/%s/emptyTrash' % self.key
|
||||
self._server.query(key, method=self._server._session.put)
|
||||
|
||||
def update(self):
|
||||
""" Scan this section for new media. """
|
||||
key = '/library/sections/%s/refresh' % self.key
|
||||
self._server.query(key)
|
||||
|
||||
def cancelUpdate(self):
|
||||
""" Cancel update of this Library Section. """
|
||||
key = '/library/sections/%s/refresh' % self.key
|
||||
self._server.query(key, method=self._server._session.delete)
|
||||
|
||||
def refresh(self):
|
||||
""" Forces a download of fresh media information from the internet.
|
||||
This can take a long time. Any locked fields are not modified.
|
||||
"""
|
||||
key = '/library/sections/%s/refresh?force=1' % self.key
|
||||
self._server.query(key)
|
||||
|
||||
def deleteMediaPreviews(self):
|
||||
""" Delete the preview thumbnails for items in this library. This cannot
|
||||
be undone. Recreating media preview files can take hours or even days.
|
||||
"""
|
||||
key = '/library/sections/%s/indexes' % self.key
|
||||
self._server.query(key, method=self._server._session.delete)
|
||||
|
||||
def listChoices(self, category, libtype=None, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.library.FilterChoice` objects for the
|
||||
specified category and libtype. kwargs can be any of the same kwargs in
|
||||
:func:`plexapi.library.LibraySection.search()` to help narrow down the choices
|
||||
to only those that matter in your current context.
|
||||
|
||||
Parameters:
|
||||
category (str): Category to list choices for (genre, contentRating, etc).
|
||||
libtype (int): Library type of item filter.
|
||||
**kwargs (dict): Additional kwargs to narrow down the choices.
|
||||
|
||||
Raises:
|
||||
:class:`~plexapi.exceptions.BadRequest`: Cannot include kwarg equal to specified category.
|
||||
"""
|
||||
# TODO: Should this be moved to base?
|
||||
if category in kwargs:
|
||||
raise BadRequest('Cannot include kwarg equal to specified category: %s' % category)
|
||||
args = {}
|
||||
for subcategory, value in kwargs.items():
|
||||
args[category] = self._cleanSearchFilter(subcategory, value)
|
||||
if libtype is not None:
|
||||
args['type'] = utils.searchType(libtype)
|
||||
key = '/library/sections/%s/%s%s' % (self.key, category, utils.joinArgs(args))
|
||||
return self.fetchItems(key, cls=FilterChoice)
|
||||
|
||||
def search(self, title=None, sort=None, maxresults=999999, libtype=None, **kwargs):
|
||||
""" Search the library. If there are many results, they will be fetched from the server
|
||||
in batches of X_PLEX_CONTAINER_SIZE amounts. If you're only looking for the first <num>
|
||||
results, it would be wise to set the maxresults option to that amount so this functions
|
||||
doesn't iterate over all results on the server.
|
||||
|
||||
Parameters:
|
||||
title (str): General string query to search for (optional).
|
||||
sort (str): column:dir; column can be any of {addedAt, originallyAvailableAt, lastViewedAt,
|
||||
titleSort, rating, mediaHeight, duration}. dir can be asc or desc (optional).
|
||||
maxresults (int): Only return the specified number of results (optional).
|
||||
libtype (str): Filter results to a spcifiec libtype (movie, show, episode, artist,
|
||||
album, track; optional).
|
||||
**kwargs (dict): Any of the available filters for the current library section. Partial string
|
||||
matches allowed. Multiple matches OR together. All inputs will be compared with the
|
||||
available options and a warning logged if the option does not appear valid.
|
||||
|
||||
* unwatched: Display or hide unwatched content (True, False). [all]
|
||||
* duplicate: Display or hide duplicate items (True, False). [movie]
|
||||
* actor: List of actors to search ([actor_or_id, ...]). [movie]
|
||||
* collection: List of collections to search within ([collection_or_id, ...]). [all]
|
||||
* contentRating: List of content ratings to search within ([rating_or_key, ...]). [movie,tv]
|
||||
* country: List of countries to search within ([country_or_key, ...]). [movie,music]
|
||||
* decade: List of decades to search within ([yyy0, ...]). [movie]
|
||||
* director: List of directors to search ([director_or_id, ...]). [movie]
|
||||
* genre: List Genres to search within ([genere_or_id, ...]). [all]
|
||||
* network: List of TV networks to search within ([resolution_or_key, ...]). [tv]
|
||||
* resolution: List of video resolutions to search within ([resolution_or_key, ...]). [movie]
|
||||
* studio: List of studios to search within ([studio_or_key, ...]). [music]
|
||||
* year: List of years to search within ([yyyy, ...]). [all]
|
||||
"""
|
||||
# cleanup the core arguments
|
||||
args = {}
|
||||
for category, value in kwargs.items():
|
||||
args[category] = self._cleanSearchFilter(category, value, libtype)
|
||||
if title is not None:
|
||||
args['title'] = title
|
||||
if sort is not None:
|
||||
args['sort'] = self._cleanSearchSort(sort)
|
||||
if libtype is not None:
|
||||
args['type'] = utils.searchType(libtype)
|
||||
# iterate over the results
|
||||
results, subresults = [], '_init'
|
||||
args['X-Plex-Container-Start'] = 0
|
||||
args['X-Plex-Container-Size'] = min(X_PLEX_CONTAINER_SIZE, maxresults)
|
||||
while subresults and maxresults > len(results):
|
||||
key = '/library/sections/%s/all%s' % (self.key, utils.joinArgs(args))
|
||||
subresults = self.fetchItems(key)
|
||||
results += subresults[:maxresults - len(results)]
|
||||
args['X-Plex-Container-Start'] += args['X-Plex-Container-Size']
|
||||
return results
|
||||
|
||||
def _cleanSearchFilter(self, category, value, libtype=None):
|
||||
# check a few things before we begin
|
||||
if category not in self.ALLOWED_FILTERS:
|
||||
raise BadRequest('Unknown filter category: %s' % category)
|
||||
if category in self.BOOLEAN_FILTERS:
|
||||
return '1' if value else '0'
|
||||
if not isinstance(value, (list, tuple)):
|
||||
value = [value]
|
||||
# convert list of values to list of keys or ids
|
||||
result = set()
|
||||
choices = self.listChoices(category, libtype)
|
||||
lookup = {c.title.lower(): unquote(unquote(c.key)) for c in choices}
|
||||
allowed = set(c.key for c in choices)
|
||||
for item in value:
|
||||
item = str((item.id or item.tag) if isinstance(item, MediaTag) else item).lower()
|
||||
# find most logical choice(s) to use in url
|
||||
if item in allowed: result.add(item); continue
|
||||
if item in lookup: result.add(lookup[item]); continue
|
||||
matches = [k for t, k in lookup.items() if item in t]
|
||||
if matches: map(result.add, matches); continue
|
||||
# nothing matched; use raw item value
|
||||
log.warning('Filter value not listed, using raw item value: %s' % item)
|
||||
result.add(item)
|
||||
return ','.join(result)
|
||||
|
||||
def _cleanSearchSort(self, sort):
|
||||
sort = '%s:asc' % sort if ':' not in sort else sort
|
||||
scol, sdir = sort.lower().split(':')
|
||||
lookup = {s.lower(): s for s in self.ALLOWED_SORT}
|
||||
if scol not in lookup:
|
||||
raise BadRequest('Unknown sort column: %s' % scol)
|
||||
if sdir not in ('asc', 'desc'):
|
||||
raise BadRequest('Unknown sort dir: %s' % sdir)
|
||||
return '%s:%s' % (lookup[scol], sdir)
|
||||
|
||||
|
||||
class MovieSection(LibrarySection):
|
||||
""" Represents a :class:`~plexapi.library.LibrarySection` section containing movies.
|
||||
|
||||
Attributes:
|
||||
ALLOWED_FILTERS (list<str>): List of allowed search filters. ('unwatched',
|
||||
'duplicate', 'year', 'decade', 'genre', 'contentRating', 'collection',
|
||||
'director', 'actor', 'country', 'studio', 'resolution', 'guid', 'label')
|
||||
ALLOWED_SORT (list<str>): List of allowed sorting keys. ('addedAt',
|
||||
'originallyAvailableAt', 'lastViewedAt', 'titleSort', 'rating',
|
||||
'mediaHeight', 'duration')
|
||||
TAG (str): 'Directory'
|
||||
TYPE (str): 'movie'
|
||||
"""
|
||||
ALLOWED_FILTERS = ('unwatched', 'duplicate', 'year', 'decade', 'genre', 'contentRating',
|
||||
'collection', 'director', 'actor', 'country', 'studio', 'resolution',
|
||||
'guid', 'label')
|
||||
ALLOWED_SORT = ('addedAt', 'originallyAvailableAt', 'lastViewedAt', 'titleSort', 'rating',
|
||||
'mediaHeight', 'duration')
|
||||
TAG = 'Directory'
|
||||
TYPE = 'movie'
|
||||
|
||||
|
||||
class ShowSection(LibrarySection):
|
||||
""" Represents a :class:`~plexapi.library.LibrarySection` section containing tv shows.
|
||||
|
||||
Attributes:
|
||||
ALLOWED_FILTERS (list<str>): List of allowed search filters. ('unwatched',
|
||||
'year', 'genre', 'contentRating', 'network', 'collection', 'guid', 'label')
|
||||
ALLOWED_SORT (list<str>): List of allowed sorting keys. ('addedAt', 'lastViewedAt',
|
||||
'originallyAvailableAt', 'titleSort', 'rating', 'unwatched')
|
||||
TAG (str): 'Directory'
|
||||
TYPE (str): 'show'
|
||||
"""
|
||||
ALLOWED_FILTERS = ('unwatched', 'year', 'genre', 'contentRating', 'network', 'collection',
|
||||
'guid', 'duplicate', 'label')
|
||||
ALLOWED_SORT = ('addedAt', 'lastViewedAt', 'originallyAvailableAt', 'titleSort',
|
||||
'rating', 'unwatched')
|
||||
TAG = 'Directory'
|
||||
TYPE = 'show'
|
||||
|
||||
def searchShows(self, **kwargs):
|
||||
""" Search for a show. See :func:`~plexapi.library.LibrarySection.search()` for usage. """
|
||||
return self.search(libtype='show', **kwargs)
|
||||
|
||||
def searchEpisodes(self, **kwargs):
|
||||
""" Search for an episode. See :func:`~plexapi.library.LibrarySection.search()` for usage. """
|
||||
return self.search(libtype='episode', **kwargs)
|
||||
|
||||
def recentlyAdded(self, libtype='episode', maxresults=50):
|
||||
""" Returns a list of recently added episodes from this library section.
|
||||
|
||||
Parameters:
|
||||
maxresults (int): Max number of items to return (default 50).
|
||||
"""
|
||||
return self.search(sort='addedAt:desc', libtype=libtype, maxresults=maxresults)
|
||||
|
||||
|
||||
class MusicSection(LibrarySection):
|
||||
""" Represents a :class:`~plexapi.library.LibrarySection` section containing music artists.
|
||||
|
||||
Attributes:
|
||||
ALLOWED_FILTERS (list<str>): List of allowed search filters. ('genre',
|
||||
'country', 'collection')
|
||||
ALLOWED_SORT (list<str>): List of allowed sorting keys. ('addedAt',
|
||||
'lastViewedAt', 'viewCount', 'titleSort')
|
||||
TAG (str): 'Directory'
|
||||
TYPE (str): 'artist'
|
||||
"""
|
||||
ALLOWED_FILTERS = ('genre', 'country', 'collection', 'mood')
|
||||
ALLOWED_SORT = ('addedAt', 'lastViewedAt', 'viewCount', 'titleSort')
|
||||
TAG = 'Directory'
|
||||
TYPE = 'artist'
|
||||
|
||||
def albums(self):
|
||||
""" Returns a list of :class:`~plexapi.audio.Album` objects in this section. """
|
||||
key = '/library/sections/%s/albums' % self.key
|
||||
return self.fetchItems(key)
|
||||
|
||||
def searchArtists(self, **kwargs):
|
||||
""" Search for an artist. See :func:`~plexapi.library.LibrarySection.search()` for usage. """
|
||||
return self.search(libtype='artist', **kwargs)
|
||||
|
||||
def searchAlbums(self, **kwargs):
|
||||
""" Search for an album. See :func:`~plexapi.library.LibrarySection.search()` for usage. """
|
||||
return self.search(libtype='album', **kwargs)
|
||||
|
||||
def searchTracks(self, **kwargs):
|
||||
""" Search for a track. See :func:`~plexapi.library.LibrarySection.search()` for usage. """
|
||||
return self.search(libtype='track', **kwargs)
|
||||
|
||||
|
||||
class PhotoSection(LibrarySection):
|
||||
""" Represents a :class:`~plexapi.library.LibrarySection` section containing photos.
|
||||
|
||||
Attributes:
|
||||
ALLOWED_FILTERS (list<str>): List of allowed search filters. ('all', 'iso',
|
||||
'make', 'lens', 'aperture', 'exposure')
|
||||
ALLOWED_SORT (list<str>): List of allowed sorting keys. ('addedAt')
|
||||
TAG (str): 'Directory'
|
||||
TYPE (str): 'photo'
|
||||
"""
|
||||
ALLOWED_FILTERS = ('all', 'iso', 'make', 'lens', 'aperture', 'exposure')
|
||||
ALLOWED_SORT = ('addedAt',)
|
||||
TAG = 'Directory'
|
||||
TYPE = 'photo'
|
||||
|
||||
def searchAlbums(self, title, **kwargs):
|
||||
""" Search for an album. See :func:`~plexapi.library.LibrarySection.search()` for usage. """
|
||||
key = '/library/sections/%s/all?type=14' % self.key
|
||||
return self.fetchItems(key, title=title)
|
||||
|
||||
def searchPhotos(self, title, **kwargs):
|
||||
""" Search for a photo. See :func:`~plexapi.library.LibrarySection.search()` for usage. """
|
||||
key = '/library/sections/%s/all?type=13' % self.key
|
||||
return self.fetchItems(key, title=title)
|
||||
|
||||
|
||||
class FilterChoice(PlexObject):
|
||||
""" Represents a single filter choice. These objects are gathered when using filters
|
||||
while searching for library items and is the object returned in the result set of
|
||||
:func:`~plexapi.library.LibrarySection.listChoices()`.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Directory'
|
||||
server (:class:`~plexapi.server.PlexServer`): PlexServer this client is connected to.
|
||||
initpath (str): Relative path requested when retrieving specified `data` (optional).
|
||||
fastKey (str): API path to quickly list all items in this filter
|
||||
(/library/sections/<section>/all?genre=<key>)
|
||||
key (str): Short key (id) of this filter option (used ad <key> in fastKey above).
|
||||
thumb (str): Thumbnail used to represent this filter option.
|
||||
title (str): Human readable name for this filter option.
|
||||
type (str): Filter type (genre, contentRating, etc).
|
||||
"""
|
||||
TAG = 'Directory'
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self._data = data
|
||||
self.fastKey = data.attrib.get('fastKey')
|
||||
self.key = data.attrib.get('key')
|
||||
self.thumb = data.attrib.get('thumb')
|
||||
self.title = data.attrib.get('title')
|
||||
self.type = data.attrib.get('type')
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Hub(PlexObject):
|
||||
""" Represents a single Hub (or category) in the PlexServer search.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Hub'
|
||||
hubIdentifier (str): Unknown.
|
||||
size (int): Number of items found.
|
||||
title (str): Title of this Hub.
|
||||
type (str): Type of items in the Hub.
|
||||
items (str): List of items in the Hub.
|
||||
"""
|
||||
TAG = 'Hub'
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self._data = data
|
||||
self.hubIdentifier = data.attrib.get('hubIdentifier')
|
||||
self.size = utils.cast(int, data.attrib.get('size'))
|
||||
self.title = data.attrib.get('title')
|
||||
self.type = data.attrib.get('type')
|
||||
self.items = self.findItems(data)
|
||||
|
||||
def __len__(self):
|
||||
return self.size
|
504
lib/plexapi/media.py
Normal file
504
lib/plexapi/media.py
Normal file
@@ -0,0 +1,504 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from plexapi import log, utils
|
||||
from plexapi.base import PlexObject
|
||||
from plexapi.exceptions import BadRequest
|
||||
from plexapi.utils import cast
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Media(PlexObject):
|
||||
""" Container object for all MediaPart objects. Provides useful data about the
|
||||
video this media belong to such as video framerate, resolution, etc.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Media'
|
||||
server (:class:`~plexapi.server.PlexServer`): PlexServer object this is from.
|
||||
initpath (str): Relative path requested when retrieving specified data.
|
||||
video (str): Video this media belongs to.
|
||||
aspectRatio (float): Aspect ratio of the video (ex: 2.35).
|
||||
audioChannels (int): Number of audio channels for this video (ex: 6).
|
||||
audioCodec (str): Audio codec used within the video (ex: ac3).
|
||||
bitrate (int): Bitrate of the video (ex: 1624)
|
||||
container (str): Container this video is in (ex: avi).
|
||||
duration (int): Length of the video in milliseconds (ex: 6990483).
|
||||
height (int): Height of the video in pixels (ex: 256).
|
||||
id (int): Plex ID of this media item (ex: 46184).
|
||||
has64bitOffsets (bool): True if video has 64 bit offsets (?).
|
||||
optimizedForStreaming (bool): True if video is optimized for streaming.
|
||||
videoCodec (str): Video codec used within the video (ex: ac3).
|
||||
videoFrameRate (str): Video frame rate (ex: 24p).
|
||||
videoResolution (str): Video resolution (ex: sd).
|
||||
width (int): Width of the video in pixels (ex: 608).
|
||||
parts (list<:class:`~plexapi.media.MediaPart`>): List of MediaParts in this video.
|
||||
"""
|
||||
TAG = 'Media'
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self._data = data
|
||||
self.aspectRatio = cast(float, data.attrib.get('aspectRatio'))
|
||||
self.audioChannels = cast(int, data.attrib.get('audioChannels'))
|
||||
self.audioCodec = data.attrib.get('audioCodec')
|
||||
self.bitrate = cast(int, data.attrib.get('bitrate'))
|
||||
self.container = data.attrib.get('container')
|
||||
self.duration = cast(int, data.attrib.get('duration'))
|
||||
self.height = cast(int, data.attrib.get('height'))
|
||||
self.id = cast(int, data.attrib.get('id'))
|
||||
self.has64bitOffsets = cast(bool, data.attrib.get('has64bitOffsets'))
|
||||
self.optimizedForStreaming = cast(bool, data.attrib.get('optimizedForStreaming'))
|
||||
self.videoCodec = data.attrib.get('videoCodec')
|
||||
self.videoFrameRate = data.attrib.get('videoFrameRate')
|
||||
self.videoResolution = data.attrib.get('videoResolution')
|
||||
self.width = cast(int, data.attrib.get('width'))
|
||||
self.parts = self.findItems(data, MediaPart)
|
||||
|
||||
def delete(self):
|
||||
part = self._initpath + '/media/%s' % self.id
|
||||
try:
|
||||
return self._server.query(part, method=self._server._session.delete)
|
||||
except BadRequest:
|
||||
log.error("Failed to delete %s. This could be because you havn't allowed "
|
||||
"items to be deleted" % part)
|
||||
raise
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class MediaPart(PlexObject):
|
||||
""" Represents a single media part (often a single file) for the media this belongs to.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Part'
|
||||
server (:class:`~plexapi.server.PlexServer`): PlexServer object this is from.
|
||||
initpath (str): Relative path requested when retrieving specified data.
|
||||
media (:class:`~plexapi.media.Media`): Media object this part belongs to.
|
||||
container (str): Container type of this media part (ex: avi).
|
||||
duration (int): Length of this media part in milliseconds.
|
||||
file (str): Path to this file on disk (ex: /media/Movies/Cars.(2006)/Cars.cd2.avi)
|
||||
id (int): Unique ID of this media part.
|
||||
indexes (str, None): None or SD.
|
||||
key (str): Key used to access this media part (ex: /library/parts/46618/1389985872/file.avi).
|
||||
size (int): Size of this file in bytes (ex: 733884416).
|
||||
streams (list<:class:`~plexapi.media.MediaPartStream`>): List of streams in this media part.
|
||||
"""
|
||||
TAG = 'Part'
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self._data = data
|
||||
self.container = data.attrib.get('container')
|
||||
self.duration = cast(int, data.attrib.get('duration'))
|
||||
self.file = data.attrib.get('file')
|
||||
self.id = cast(int, data.attrib.get('id'))
|
||||
self.indexes = data.attrib.get('indexes')
|
||||
self.key = data.attrib.get('key')
|
||||
self.size = cast(int, data.attrib.get('size'))
|
||||
self.streams = self._buildStreams(data)
|
||||
|
||||
def _buildStreams(self, data):
|
||||
streams = []
|
||||
for elem in data:
|
||||
for cls in (VideoStream, AudioStream, SubtitleStream):
|
||||
if elem.attrib.get('streamType') == str(cls.STREAMTYPE):
|
||||
streams.append(cls(self._server, elem, self._initpath))
|
||||
return streams
|
||||
|
||||
def videoStreams(self):
|
||||
""" Returns a list of :class:`~plexapi.media.VideoStream` objects in this MediaPart. """
|
||||
return [stream for stream in self.streams if stream.streamType == VideoStream.STREAMTYPE]
|
||||
|
||||
def audioStreams(self):
|
||||
""" Returns a list of :class:`~plexapi.media.AudioStream` objects in this MediaPart. """
|
||||
return [stream for stream in self.streams if stream.streamType == AudioStream.STREAMTYPE]
|
||||
|
||||
def subtitleStreams(self):
|
||||
""" Returns a list of :class:`~plexapi.media.SubtitleStream` objects in this MediaPart. """
|
||||
return [stream for stream in self.streams if stream.streamType == SubtitleStream.STREAMTYPE]
|
||||
|
||||
|
||||
class MediaPartStream(PlexObject):
|
||||
""" Base class for media streams. These consist of video, audio and subtitles.
|
||||
|
||||
Attributes:
|
||||
server (:class:`~plexapi.server.PlexServer`): PlexServer object this is from.
|
||||
initpath (str): Relative path requested when retrieving specified data.
|
||||
part (:class:`~plexapi.media.MediaPart`): Media part this stream belongs to.
|
||||
codec (str): Codec of this stream (ex: srt, ac3, mpeg4).
|
||||
codecID (str): Codec ID (ex: XVID).
|
||||
id (int): Unique stream ID on this server.
|
||||
index (int): Unknown
|
||||
language (str): Stream language (ex: English, ไทย).
|
||||
languageCode (str): Ascii code for language (ex: eng, tha).
|
||||
selected (bool): True if this stream is selected.
|
||||
streamType (int): Stream type (1=:class:`~plexapi.media.VideoStream`,
|
||||
2=:class:`~plexapi.media.AudioStream`, 3=:class:`~plexapi.media.SubtitleStream`).
|
||||
type (int): Alias for streamType.
|
||||
"""
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self._data = data
|
||||
self.codec = data.attrib.get('codec')
|
||||
self.codecID = data.attrib.get('codecID')
|
||||
self.id = cast(int, data.attrib.get('id'))
|
||||
self.index = cast(int, data.attrib.get('index', '-1'))
|
||||
self.language = data.attrib.get('language')
|
||||
self.languageCode = data.attrib.get('languageCode')
|
||||
self.selected = cast(bool, data.attrib.get('selected', '0'))
|
||||
self.streamType = cast(int, data.attrib.get('streamType'))
|
||||
self.type = cast(int, data.attrib.get('streamType'))
|
||||
|
||||
@staticmethod
|
||||
def parse(server, data, initpath): # pragma: no cover seems to be dead code.
|
||||
""" Factory method returns a new MediaPartStream from xml data. """
|
||||
STREAMCLS = {1: VideoStream, 2: AudioStream, 3: SubtitleStream}
|
||||
stype = cast(int, data.attrib.get('streamType'))
|
||||
cls = STREAMCLS.get(stype, MediaPartStream)
|
||||
return cls(server, data, initpath)
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class VideoStream(MediaPartStream):
|
||||
""" Respresents a video stream within a :class:`~plexapi.media.MediaPart`.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Stream'
|
||||
STREAMTYPE (int): 1
|
||||
bitDepth (int): Bit depth (ex: 8).
|
||||
bitrate (int): Bitrate (ex: 1169)
|
||||
cabac (int): Unknown
|
||||
chromaSubsampling (str): Chroma Subsampling (ex: 4:2:0).
|
||||
colorSpace (str): Unknown
|
||||
duration (int): Duration of video stream in milliseconds.
|
||||
frameRate (float): Frame rate (ex: 23.976)
|
||||
frameRateMode (str): Unknown
|
||||
hasScallingMatrix (bool): True if video stream has a scaling matrix.
|
||||
height (int): Height of video stream.
|
||||
level (int): Videl stream level (?).
|
||||
profile (str): Video stream profile (ex: asp).
|
||||
refFrames (int): Unknown
|
||||
scanType (str): Video stream scan type (ex: progressive).
|
||||
title (str): Title of this video stream.
|
||||
width (int): Width of video stream.
|
||||
"""
|
||||
TAG = 'Stream'
|
||||
STREAMTYPE = 1
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
super(VideoStream, self)._loadData(data)
|
||||
self.bitDepth = cast(int, data.attrib.get('bitDepth'))
|
||||
self.bitrate = cast(int, data.attrib.get('bitrate'))
|
||||
self.cabac = cast(int, data.attrib.get('cabac'))
|
||||
self.chromaSubsampling = data.attrib.get('chromaSubsampling')
|
||||
self.colorSpace = data.attrib.get('colorSpace')
|
||||
self.duration = cast(int, data.attrib.get('duration'))
|
||||
self.frameRate = cast(float, data.attrib.get('frameRate'))
|
||||
self.frameRateMode = data.attrib.get('frameRateMode')
|
||||
self.hasScallingMatrix = cast(bool, data.attrib.get('hasScallingMatrix'))
|
||||
self.height = cast(int, data.attrib.get('height'))
|
||||
self.level = cast(int, data.attrib.get('level'))
|
||||
self.profile = data.attrib.get('profile')
|
||||
self.refFrames = cast(int, data.attrib.get('refFrames'))
|
||||
self.scanType = data.attrib.get('scanType')
|
||||
self.title = data.attrib.get('title')
|
||||
self.width = cast(int, data.attrib.get('width'))
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class AudioStream(MediaPartStream):
|
||||
""" Respresents a audio stream within a :class:`~plexapi.media.MediaPart`.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Stream'
|
||||
STREAMTYPE (int): 2
|
||||
audioChannelLayout (str): Audio channel layout (ex: 5.1(side)).
|
||||
bitDepth (int): Bit depth (ex: 16).
|
||||
bitrate (int): Audio bitrate (ex: 448).
|
||||
bitrateMode (str): Bitrate mode (ex: cbr).
|
||||
channels (int): number of channels in this stream (ex: 6).
|
||||
dialogNorm (int): Unknown (ex: -27).
|
||||
duration (int): Duration of audio stream in milliseconds.
|
||||
samplingRate (int): Sampling rate (ex: xxx)
|
||||
title (str): Title of this audio stream.
|
||||
"""
|
||||
TAG = 'Stream'
|
||||
STREAMTYPE = 2
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
super(AudioStream, self)._loadData(data)
|
||||
self.audioChannelLayout = data.attrib.get('audioChannelLayout')
|
||||
self.bitDepth = cast(int, data.attrib.get('bitDepth'))
|
||||
self.bitrate = cast(int, data.attrib.get('bitrate'))
|
||||
self.bitrateMode = data.attrib.get('bitrateMode')
|
||||
self.channels = cast(int, data.attrib.get('channels'))
|
||||
self.dialogNorm = cast(int, data.attrib.get('dialogNorm'))
|
||||
self.duration = cast(int, data.attrib.get('duration'))
|
||||
self.samplingRate = cast(int, data.attrib.get('samplingRate'))
|
||||
self.title = data.attrib.get('title')
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class SubtitleStream(MediaPartStream):
|
||||
""" Respresents a audio stream within a :class:`~plexapi.media.MediaPart`.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Stream'
|
||||
STREAMTYPE (int): 3
|
||||
format (str): Subtitle format (ex: srt).
|
||||
key (str): Key of this subtitle stream (ex: /library/streams/212284).
|
||||
title (str): Title of this subtitle stream.
|
||||
"""
|
||||
TAG = 'Stream'
|
||||
STREAMTYPE = 3
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
super(SubtitleStream, self)._loadData(data)
|
||||
self.format = data.attrib.get('format')
|
||||
self.key = data.attrib.get('key')
|
||||
self.title = data.attrib.get('title')
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Session(PlexObject):
|
||||
""" Represents a current session. """
|
||||
TAG = 'Session'
|
||||
|
||||
def _loadData(self, data):
|
||||
self.id = data.attrib.get('id')
|
||||
self.bandwidth = utils.cast(int, data.attrib.get('bandwidth'))
|
||||
self.location = data.attrib.get('location')
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class TranscodeSession(PlexObject):
|
||||
""" Represents a current transcode session.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'TranscodeSession'
|
||||
TODO: Document this.
|
||||
"""
|
||||
TAG = 'TranscodeSession'
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self._data = data
|
||||
self.audioChannels = cast(int, data.attrib.get('audioChannels'))
|
||||
self.audioCodec = data.attrib.get('audioCodec')
|
||||
self.audioDecision = data.attrib.get('audioDecision')
|
||||
self.container = data.attrib.get('container')
|
||||
self.context = data.attrib.get('context')
|
||||
self.duration = cast(int, data.attrib.get('duration'))
|
||||
self.height = cast(int, data.attrib.get('height'))
|
||||
self.key = data.attrib.get('key')
|
||||
self.progress = cast(float, data.attrib.get('progress'))
|
||||
self.protocol = data.attrib.get('protocol')
|
||||
self.remaining = cast(int, data.attrib.get('remaining'))
|
||||
self.speed = cast(int, data.attrib.get('speed'))
|
||||
self.throttled = cast(int, data.attrib.get('throttled'))
|
||||
self.sourceVideoCodec = data.attrib.get('sourceVideoCodec')
|
||||
self.videoCodec = data.attrib.get('videoCodec')
|
||||
self.videoDecision = data.attrib.get('videoDecision')
|
||||
self.width = cast(int, data.attrib.get('width'))
|
||||
|
||||
|
||||
class MediaTag(PlexObject):
|
||||
""" Base class for media tags used for filtering and searching your library
|
||||
items or navigating the metadata of media items in your library. Tags are
|
||||
the construct used for things such as Country, Director, Genre, etc.
|
||||
|
||||
Attributes:
|
||||
server (:class:`~plexapi.server.PlexServer`): Server this client is connected to.
|
||||
id (id): Tag ID (This seems meaningless except to use it as a unique id).
|
||||
role (str): Unknown
|
||||
tag (str): Name of the tag. This will be Animation, SciFi etc for Genres. The name of
|
||||
person for Directors and Roles (ex: Animation, Stephen Graham, etc).
|
||||
<Hub_Search_Attributes>: Attributes only applicable in search results from
|
||||
PlexServer :func:`~plexapi.server.PlexServer.search()`. They provide details of which
|
||||
library section the tag was found as well as the url to dig deeper into the results.
|
||||
|
||||
* key (str): API URL to dig deeper into this tag (ex: /library/sections/1/all?actor=9081).
|
||||
* librarySectionID (int): Section ID this tag was generated from.
|
||||
* librarySectionTitle (str): Library section title this tag was found.
|
||||
* librarySectionType (str): Media type of the library section this tag was found.
|
||||
* tagType (int): Tag type ID.
|
||||
* thumb (str): URL to thumbnail image.
|
||||
"""
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self._data = data
|
||||
self.id = cast(int, data.attrib.get('id'))
|
||||
self.role = data.attrib.get('role')
|
||||
self.tag = data.attrib.get('tag')
|
||||
# additional attributes only from hub search
|
||||
self.key = data.attrib.get('key')
|
||||
self.librarySectionID = cast(int, data.attrib.get('librarySectionID'))
|
||||
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
|
||||
self.librarySectionType = data.attrib.get('librarySectionType')
|
||||
self.tagType = cast(int, data.attrib.get('tagType'))
|
||||
self.thumb = data.attrib.get('thumb')
|
||||
|
||||
def items(self, *args, **kwargs):
|
||||
""" Return the list of items within this tag. This function is only applicable
|
||||
in search results from PlexServer :func:`~plexapi.server.PlexServer.search()`.
|
||||
"""
|
||||
if not self.key:
|
||||
raise BadRequest('Key is not defined for this tag: %s' % self.tag)
|
||||
return self.fetchItems(self.key)
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Collection(MediaTag):
|
||||
""" Represents a single Collection media tag.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Collection'
|
||||
FILTER (str): 'collection'
|
||||
"""
|
||||
TAG = 'Collection'
|
||||
FILTER = 'collection'
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Label(MediaTag):
|
||||
""" Represents a single label media tag.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'label'
|
||||
FILTER (str): 'label'
|
||||
"""
|
||||
TAG = 'Label'
|
||||
FILTER = 'label'
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Country(MediaTag):
|
||||
""" Represents a single Country media tag.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Country'
|
||||
FILTER (str): 'country'
|
||||
"""
|
||||
TAG = 'Country'
|
||||
FILTER = 'country'
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Director(MediaTag):
|
||||
""" Represents a single Director media tag.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Director'
|
||||
FILTER (str): 'director'
|
||||
"""
|
||||
TAG = 'Director'
|
||||
FILTER = 'director'
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Genre(MediaTag):
|
||||
""" Represents a single Genre media tag.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Genre'
|
||||
FILTER (str): 'genre'
|
||||
"""
|
||||
TAG = 'Genre'
|
||||
FILTER = 'genre'
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Mood(MediaTag):
|
||||
""" Represents a single Mood media tag.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Mood'
|
||||
FILTER (str): 'mood'
|
||||
"""
|
||||
TAG = 'Mood'
|
||||
FILTER = 'mood'
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Producer(MediaTag):
|
||||
""" Represents a single Producer media tag.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Producer'
|
||||
FILTER (str): 'producer'
|
||||
"""
|
||||
TAG = 'Producer'
|
||||
FILTER = 'producer'
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Role(MediaTag):
|
||||
""" Represents a single Role (actor/actress) media tag.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Role'
|
||||
FILTER (str): 'role'
|
||||
"""
|
||||
TAG = 'Role'
|
||||
FILTER = 'role'
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Similar(MediaTag):
|
||||
""" Represents a single Similar media tag.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Similar'
|
||||
FILTER (str): 'similar'
|
||||
"""
|
||||
TAG = 'Similar'
|
||||
FILTER = 'similar'
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Writer(MediaTag):
|
||||
""" Represents a single Writer media tag.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Writer'
|
||||
FILTER (str): 'writer'
|
||||
"""
|
||||
TAG = 'Writer'
|
||||
FILTER = 'writer'
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Chapter(PlexObject):
|
||||
""" Represents a single Writer media tag.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Chapter'
|
||||
"""
|
||||
TAG = 'Chapter'
|
||||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
self.id = cast(int, data.attrib.get('id', 0))
|
||||
self.filter = data.attrib.get('filter') # I couldn't filter on it anyways
|
||||
self.tag = data.attrib.get('tag')
|
||||
self.title = self.tag
|
||||
self.index = cast(int, data.attrib.get('index'))
|
||||
self.start = cast(int, data.attrib.get('startTimeOffset'))
|
||||
self.end = cast(int, data.attrib.get('endTimeOffset'))
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Field(PlexObject):
|
||||
""" Represents a single Field.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Field'
|
||||
"""
|
||||
TAG = 'Field'
|
||||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
self.name = data.attrib.get('name')
|
||||
self.locked = cast(bool, data.attrib.get('locked'))
|
727
lib/plexapi/myplex.py
Normal file
727
lib/plexapi/myplex.py
Normal file
@@ -0,0 +1,727 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import copy
|
||||
import requests
|
||||
import time
|
||||
from requests.status_codes import _codes as codes
|
||||
from plexapi import BASE_HEADERS, CONFIG, TIMEOUT
|
||||
from plexapi import log, logfilter, utils
|
||||
from plexapi.base import PlexObject
|
||||
from plexapi.exceptions import BadRequest, NotFound
|
||||
from plexapi.client import PlexClient
|
||||
from plexapi.compat import ElementTree
|
||||
from plexapi.library import LibrarySection
|
||||
from plexapi.server import PlexServer
|
||||
from plexapi.utils import joinArgs
|
||||
|
||||
|
||||
class MyPlexAccount(PlexObject):
|
||||
""" MyPlex account and profile information. This object represents the data found Account on
|
||||
the myplex.tv servers at the url https://plex.tv/users/account. You may create this object
|
||||
directly by passing in your username & password (or token). There is also a convenience
|
||||
method provided at :class:`~plexapi.server.PlexServer.myPlexAccount()` which will create
|
||||
and return this object.
|
||||
|
||||
Parameters:
|
||||
username (str): Your MyPlex username.
|
||||
password (str): Your MyPlex password.
|
||||
session (requests.Session, optional): Use your own session object if you want to
|
||||
cache the http responses from PMS
|
||||
timeout (int): timeout in seconds on initial connect to myplex (default config.TIMEOUT).
|
||||
|
||||
Attributes:
|
||||
SIGNIN (str): 'https://plex.tv/users/sign_in.xml'
|
||||
key (str): 'https://plex.tv/users/account'
|
||||
authenticationToken (str): Unknown.
|
||||
certificateVersion (str): Unknown.
|
||||
cloudSyncDevice (str): Unknown.
|
||||
email (str): Your current Plex email address.
|
||||
entitlements (List<str>): List of devices your allowed to use with this account.
|
||||
guest (bool): Unknown.
|
||||
home (bool): Unknown.
|
||||
homeSize (int): Unknown.
|
||||
id (str): Your Plex account ID.
|
||||
locale (str): Your Plex locale
|
||||
mailing_list_status (str): Your current mailing list status.
|
||||
maxHomeSize (int): Unknown.
|
||||
queueEmail (str): Email address to add items to your `Watch Later` queue.
|
||||
queueUid (str): Unknown.
|
||||
restricted (bool): Unknown.
|
||||
roles: (List<str>) Lit of account roles. Plexpass membership listed here.
|
||||
scrobbleTypes (str): Description
|
||||
secure (bool): Description
|
||||
subscriptionActive (bool): True if your subsctiption is active.
|
||||
subscriptionFeatures: (List<str>) List of features allowed on your subscription.
|
||||
subscriptionPlan (str): Name of subscription plan.
|
||||
subscriptionStatus (str): String representation of `subscriptionActive`.
|
||||
thumb (str): URL of your account thumbnail.
|
||||
title (str): Unknown. - Looks like an alias for `username`.
|
||||
username (str): Your account username.
|
||||
uuid (str): Unknown.
|
||||
_token (str): Token used to access this client.
|
||||
_session (obj): Requests session object used to access this client.
|
||||
"""
|
||||
FRIENDINVITE = 'https://plex.tv/api/servers/{machineId}/shared_servers' # post with data
|
||||
FRIENDSERVERS = 'https://plex.tv/api/servers/{machineId}/shared_servers/{serverId}' # put with data
|
||||
PLEXSERVERS = 'https://plex.tv/api/servers/{machineId}' # get
|
||||
FRIENDUPDATE = 'https://plex.tv/api/friends/{userId}' # put with args, delete
|
||||
REMOVEINVITE = 'https://plex.tv/api/invites/requested/{userId}?friend=0&server=1&home=0' # delete
|
||||
REQUESTED = 'https://plex.tv/api/invites/requested' # get
|
||||
REQUESTS = 'https://plex.tv/api/invites/requests' # get
|
||||
SIGNIN = 'https://plex.tv/users/sign_in.xml' # get with auth
|
||||
WEBHOOKS = 'https://plex.tv/api/v2/user/webhooks' # get, post with data
|
||||
# Key may someday switch to the following url. For now the current value works.
|
||||
# https://plex.tv/api/v2/user?X-Plex-Token={token}&X-Plex-Client-Identifier={clientId}
|
||||
key = 'https://plex.tv/users/account'
|
||||
|
||||
def __init__(self, username=None, password=None, token=None, session=None, timeout=None):
|
||||
self._token = token
|
||||
self._session = session or requests.Session()
|
||||
data, initpath = self._signin(username, password, timeout)
|
||||
super(MyPlexAccount, self).__init__(self, data, initpath)
|
||||
|
||||
def _signin(self, username, password, timeout):
|
||||
if self._token:
|
||||
return self.query(self.key), self.key
|
||||
username = username or CONFIG.get('auth.myplex_username')
|
||||
password = password or CONFIG.get('auth.myplex_password')
|
||||
data = self.query(self.SIGNIN, method=self._session.post, auth=(username, password), timeout=timeout)
|
||||
return data, self.SIGNIN
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self._data = data
|
||||
self._token = logfilter.add_secret(data.attrib.get('authenticationToken'))
|
||||
self._webhooks = []
|
||||
self.authenticationToken = self._token
|
||||
self.certificateVersion = data.attrib.get('certificateVersion')
|
||||
self.cloudSyncDevice = data.attrib.get('cloudSyncDevice')
|
||||
self.email = data.attrib.get('email')
|
||||
self.guest = utils.cast(bool, data.attrib.get('guest'))
|
||||
self.home = utils.cast(bool, data.attrib.get('home'))
|
||||
self.homeSize = utils.cast(int, data.attrib.get('homeSize'))
|
||||
self.id = data.attrib.get('id')
|
||||
self.locale = data.attrib.get('locale')
|
||||
self.mailing_list_status = data.attrib.get('mailing_list_status')
|
||||
self.maxHomeSize = utils.cast(int, data.attrib.get('maxHomeSize'))
|
||||
self.queueEmail = data.attrib.get('queueEmail')
|
||||
self.queueUid = data.attrib.get('queueUid')
|
||||
self.restricted = utils.cast(bool, data.attrib.get('restricted'))
|
||||
self.scrobbleTypes = data.attrib.get('scrobbleTypes')
|
||||
self.secure = utils.cast(bool, data.attrib.get('secure'))
|
||||
self.thumb = data.attrib.get('thumb')
|
||||
self.title = data.attrib.get('title')
|
||||
self.username = data.attrib.get('username')
|
||||
self.uuid = data.attrib.get('uuid')
|
||||
# TODO: Fetch missing MyPlexAccount attributes
|
||||
self.subscriptionActive = None # renamed on server
|
||||
self.subscriptionStatus = None # renamed on server
|
||||
self.subscriptionPlan = None # renmaed on server
|
||||
self.subscriptionFeatures = None # renamed on server
|
||||
self.roles = None
|
||||
self.entitlements = None
|
||||
|
||||
def device(self, name):
|
||||
""" Returns the :class:`~plexapi.myplex.MyPlexDevice` that matches the name specified.
|
||||
|
||||
Parameters:
|
||||
name (str): Name to match against.
|
||||
"""
|
||||
for device in self.devices():
|
||||
if device.name.lower() == name.lower():
|
||||
return device
|
||||
raise NotFound('Unable to find device %s' % name)
|
||||
|
||||
def devices(self):
|
||||
""" Returns a list of all :class:`~plexapi.myplex.MyPlexDevice` objects connected to the server. """
|
||||
data = self.query(MyPlexDevice.key)
|
||||
return [MyPlexDevice(self, elem) for elem in data]
|
||||
|
||||
def _headers(self, **kwargs):
|
||||
""" Returns dict containing base headers for all requests to the server. """
|
||||
headers = BASE_HEADERS.copy()
|
||||
if self._token:
|
||||
headers['X-Plex-Token'] = self._token
|
||||
headers.update(kwargs)
|
||||
return headers
|
||||
|
||||
def query(self, url, method=None, headers=None, timeout=None, **kwargs):
|
||||
method = method or self._session.get
|
||||
timeout = timeout or TIMEOUT
|
||||
log.debug('%s %s %s', method.__name__.upper(), url, kwargs.get('json', ''))
|
||||
headers = self._headers(**headers or {})
|
||||
response = method(url, headers=headers, timeout=timeout, **kwargs)
|
||||
if response.status_code not in (200, 201, 204): # pragma: no cover
|
||||
codename = codes.get(response.status_code)[0]
|
||||
errtext = response.text.replace('\n', ' ')
|
||||
log.warning('BadRequest (%s) %s %s; %s' % (response.status_code, codename, response.url, errtext))
|
||||
raise BadRequest('(%s) %s %s; %s' % (response.status_code, codename, response.url, errtext))
|
||||
data = response.text.encode('utf8')
|
||||
return ElementTree.fromstring(data) if data.strip() else None
|
||||
|
||||
def resource(self, name):
|
||||
""" Returns the :class:`~plexapi.myplex.MyPlexResource` that matches the name specified.
|
||||
|
||||
Parameters:
|
||||
name (str): Name to match against.
|
||||
"""
|
||||
for resource in self.resources():
|
||||
if resource.name.lower() == name.lower():
|
||||
return resource
|
||||
raise NotFound('Unable to find resource %s' % name)
|
||||
|
||||
def resources(self):
|
||||
""" Returns a list of all :class:`~plexapi.myplex.MyPlexResource` objects connected to the server. """
|
||||
data = self.query(MyPlexResource.key)
|
||||
return [MyPlexResource(self, elem) for elem in data]
|
||||
|
||||
def inviteFriend(self, user, server, sections=None, allowSync=False, allowCameraUpload=False,
|
||||
allowChannels=False, filterMovies=None, filterTelevision=None, filterMusic=None):
|
||||
""" Share library content with the specified user.
|
||||
|
||||
Parameters:
|
||||
user (str): MyPlexUser, username, email of the user to be added.
|
||||
server (PlexServer): PlexServer object or machineIdentifier containing the library sections to share.
|
||||
sections ([Section]): Library sections, names or ids to be shared (default None shares all sections).
|
||||
allowSync (Bool): Set True to allow user to sync content.
|
||||
allowCameraUpload (Bool): Set True to allow user to upload photos.
|
||||
allowChannels (Bool): Set True to allow user to utilize installed channels.
|
||||
filterMovies (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of
|
||||
values to be filtered. ex: {'contentRating':['G'], 'label':['foo']}
|
||||
filterTelevision (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of
|
||||
values to be filtered. ex: {'contentRating':['G'], 'label':['foo']}
|
||||
filterMusic (Dict): Dict containing key 'label' set to a list of values to be filtered.
|
||||
ex: {'label':['foo']}
|
||||
"""
|
||||
username = user.username if isinstance(user, MyPlexUser) else user
|
||||
machineId = server.machineIdentifier if isinstance(server, PlexServer) else server
|
||||
sectionIds = self._getSectionIds(machineId, sections)
|
||||
params = {
|
||||
'server_id': machineId,
|
||||
'shared_server': {'library_section_ids': sectionIds, 'invited_email': username},
|
||||
'sharing_settings': {
|
||||
'allowSync': ('1' if allowSync else '0'),
|
||||
'allowCameraUpload': ('1' if allowCameraUpload else '0'),
|
||||
'allowChannels': ('1' if allowChannels else '0'),
|
||||
'filterMovies': self._filterDictToStr(filterMovies or {}),
|
||||
'filterTelevision': self._filterDictToStr(filterTelevision or {}),
|
||||
'filterMusic': self._filterDictToStr(filterMusic or {}),
|
||||
},
|
||||
}
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
url = self.FRIENDINVITE.format(machineId=machineId)
|
||||
return self.query(url, self._session.post, json=params, headers=headers)
|
||||
|
||||
def removeFriend(self, user):
|
||||
""" Remove the specified user from all sharing.
|
||||
|
||||
Parameters:
|
||||
user (str): MyPlexUser, username, email of the user to be added.
|
||||
"""
|
||||
user = self.user(user)
|
||||
url = self.FRIENDUPDATE if user.friend else self.REMOVEINVITE
|
||||
url = url.format(userId=user.id)
|
||||
return self.query(url, self._session.delete)
|
||||
|
||||
def updateFriend(self, user, server, sections=None, removeSections=False, allowSync=None, allowCameraUpload=None,
|
||||
allowChannels=None, filterMovies=None, filterTelevision=None, filterMusic=None):
|
||||
""" Update the specified user's share settings.
|
||||
|
||||
Parameters:
|
||||
user (str): MyPlexUser, username, email of the user to be added.
|
||||
server (PlexServer): PlexServer object or machineIdentifier containing the library sections to share.
|
||||
sections: ([Section]): Library sections, names or ids to be shared (default None shares all sections).
|
||||
removeSections (Bool): Set True to remove all shares. Supersedes sections.
|
||||
allowSync (Bool): Set True to allow user to sync content.
|
||||
allowCameraUpload (Bool): Set True to allow user to upload photos.
|
||||
allowChannels (Bool): Set True to allow user to utilize installed channels.
|
||||
filterMovies (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of
|
||||
values to be filtered. ex: {'contentRating':['G'], 'label':['foo']}
|
||||
filterTelevision (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of
|
||||
values to be filtered. ex: {'contentRating':['G'], 'label':['foo']}
|
||||
filterMusic (Dict): Dict containing key 'label' set to a list of values to be filtered.
|
||||
ex: {'label':['foo']}
|
||||
"""
|
||||
# Update friend servers
|
||||
response_filters = ''
|
||||
response_servers = ''
|
||||
user = self.user(user.username if isinstance(user, MyPlexUser) else user)
|
||||
machineId = server.machineIdentifier if isinstance(server, PlexServer) else server
|
||||
sectionIds = self._getSectionIds(machineId, sections)
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
# Determine whether user has access to the shared server.
|
||||
user_servers = [s for s in user.servers if s.machineIdentifier == machineId]
|
||||
if user_servers and sectionIds:
|
||||
serverId = user_servers[0].id
|
||||
params = {'server_id': machineId, 'shared_server': {'library_section_ids': sectionIds}}
|
||||
url = self.FRIENDSERVERS.format(machineId=machineId, serverId=serverId)
|
||||
else:
|
||||
params = {'server_id': machineId, 'shared_server': {'library_section_ids': sectionIds,
|
||||
"invited_id": user.id}}
|
||||
url = self.FRIENDINVITE.format(machineId=machineId)
|
||||
# Remove share sections, add shares to user without shares, or update shares
|
||||
if sectionIds:
|
||||
if removeSections is True:
|
||||
response_servers = self.query(url, self._session.delete, json=params, headers=headers)
|
||||
elif 'invited_id' in params.get('shared_server', ''):
|
||||
response_servers = self.query(url, self._session.post, json=params, headers=headers)
|
||||
else:
|
||||
response_servers = self.query(url, self._session.put, json=params, headers=headers)
|
||||
else:
|
||||
log.warning('Section name, number of section object is required changing library sections')
|
||||
# Update friend filters
|
||||
url = self.FRIENDUPDATE.format(userId=user.id)
|
||||
params = {}
|
||||
if isinstance(allowSync, bool):
|
||||
params['allowSync'] = '1' if allowSync else '0'
|
||||
if isinstance(allowCameraUpload, bool):
|
||||
params['allowCameraUpload'] = '1' if allowCameraUpload else '0'
|
||||
if isinstance(allowChannels, bool):
|
||||
params['allowChannels'] = '1' if allowChannels else '0'
|
||||
if isinstance(filterMovies, dict):
|
||||
params['filterMovies'] = self._filterDictToStr(filterMovies or {}) # '1' if allowChannels else '0'
|
||||
if isinstance(filterTelevision, dict):
|
||||
params['filterTelevision'] = self._filterDictToStr(filterTelevision or {})
|
||||
if isinstance(allowChannels, dict):
|
||||
params['filterMusic'] = self._filterDictToStr(filterMusic or {})
|
||||
if params:
|
||||
url += joinArgs(params)
|
||||
response_filters = self.query(url, self._session.put)
|
||||
return response_servers, response_filters
|
||||
|
||||
def user(self, username):
|
||||
""" Returns the :class:`~myplex.MyPlexUser` that matches the email or username specified.
|
||||
|
||||
Parameters:
|
||||
username (str): Username, email or id of the user to return.
|
||||
"""
|
||||
for user in self.users():
|
||||
# Home users don't have email, username etc.
|
||||
if username.lower() == user.title.lower():
|
||||
return user
|
||||
|
||||
elif (user.username and user.email and user.id and username.lower() in
|
||||
(user.username.lower(), user.email.lower(), str(user.id))):
|
||||
return user
|
||||
|
||||
raise NotFound('Unable to find user %s' % username)
|
||||
|
||||
def users(self):
|
||||
""" Returns a list of all :class:`~plexapi.myplex.MyPlexUser` objects connected to your account.
|
||||
This includes both friends and pending invites. You can reference the user.friend to
|
||||
distinguish between the two.
|
||||
"""
|
||||
friends = [MyPlexUser(self, elem) for elem in self.query(MyPlexUser.key)]
|
||||
requested = [MyPlexUser(self, elem, self.REQUESTED) for elem in self.query(self.REQUESTED)]
|
||||
return friends + requested
|
||||
|
||||
def _getSectionIds(self, server, sections):
|
||||
""" Converts a list of section objects or names to sectionIds needed for library sharing. """
|
||||
if not sections: return []
|
||||
# Get a list of all section ids for looking up each section.
|
||||
allSectionIds = {}
|
||||
machineIdentifier = server.machineIdentifier if isinstance(server, PlexServer) else server
|
||||
url = self.PLEXSERVERS.replace('{machineId}', machineIdentifier)
|
||||
data = self.query(url, self._session.get)
|
||||
for elem in data[0]:
|
||||
allSectionIds[elem.attrib.get('id', '').lower()] = elem.attrib.get('id')
|
||||
allSectionIds[elem.attrib.get('title', '').lower()] = elem.attrib.get('id')
|
||||
allSectionIds[elem.attrib.get('key', '').lower()] = elem.attrib.get('id')
|
||||
log.debug(allSectionIds)
|
||||
# Convert passed in section items to section ids from above lookup
|
||||
sectionIds = []
|
||||
for section in sections:
|
||||
sectionKey = section.key if isinstance(section, LibrarySection) else section
|
||||
sectionIds.append(allSectionIds[sectionKey.lower()])
|
||||
return sectionIds
|
||||
|
||||
def _filterDictToStr(self, filterDict):
|
||||
""" Converts friend filters to a string representation for transport. """
|
||||
values = []
|
||||
for key, vals in filterDict.items():
|
||||
if key not in ('contentRating', 'label'):
|
||||
raise BadRequest('Unknown filter key: %s', key)
|
||||
values.append('%s=%s' % (key, '%2C'.join(vals)))
|
||||
return '|'.join(values)
|
||||
|
||||
def addWebhook(self, url):
|
||||
# copy _webhooks and append url
|
||||
urls = self._webhooks[:] + [url]
|
||||
return self.setWebhooks(urls)
|
||||
|
||||
def deleteWebhook(self, url):
|
||||
urls = copy.copy(self._webhooks)
|
||||
if url not in urls:
|
||||
raise BadRequest('Webhook does not exist: %s' % url)
|
||||
urls.remove(url)
|
||||
return self.setWebhooks(urls)
|
||||
|
||||
def setWebhooks(self, urls):
|
||||
log.info('Setting webhooks: %s' % urls)
|
||||
data = self.query(self.WEBHOOKS, self._session.post, data={'urls[]': urls})
|
||||
self._webhooks = self.listAttrs(data, 'url', etag='webhook')
|
||||
return self._webhooks
|
||||
|
||||
def webhooks(self):
|
||||
data = self.query(self.WEBHOOKS)
|
||||
self._webhooks = self.listAttrs(data, 'url', etag='webhook')
|
||||
return self._webhooks
|
||||
|
||||
def optOut(self, playback=None, library=None):
|
||||
""" Opt in or out of sharing stuff with plex.
|
||||
See: https://www.plex.tv/about/privacy-legal/
|
||||
"""
|
||||
params = {}
|
||||
if playback is not None:
|
||||
params['optOutPlayback'] = int(playback)
|
||||
if library is not None:
|
||||
params['optOutLibraryStats'] = int(library)
|
||||
url = 'https://plex.tv/api/v2/user/privacy'
|
||||
return self.query(url, method=self._session.put, params=params)
|
||||
|
||||
|
||||
class MyPlexUser(PlexObject):
|
||||
""" This object represents non-signed in users such as friends and linked
|
||||
accounts. NOTE: This should not be confused with the :class:`~myplex.MyPlexAccount`
|
||||
which is your specific account. The raw xml for the data presented here
|
||||
can be found at: https://plex.tv/api/users/
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'User'
|
||||
key (str): 'https://plex.tv/api/users/'
|
||||
allowCameraUpload (bool): True if this user can upload images.
|
||||
allowChannels (bool): True if this user has access to channels.
|
||||
allowSync (bool): True if this user can sync.
|
||||
email (str): User's email address (user@gmail.com).
|
||||
filterAll (str): Unknown.
|
||||
filterMovies (str): Unknown.
|
||||
filterMusic (str): Unknown.
|
||||
filterPhotos (str): Unknown.
|
||||
filterTelevision (str): Unknown.
|
||||
home (bool): Unknown.
|
||||
id (int): User's Plex account ID.
|
||||
protected (False): Unknown (possibly SSL enabled?).
|
||||
recommendationsPlaylistId (str): Unknown.
|
||||
restricted (str): Unknown.
|
||||
thumb (str): Link to the users avatar.
|
||||
title (str): Seems to be an aliad for username.
|
||||
username (str): User's username.
|
||||
"""
|
||||
TAG = 'User'
|
||||
key = 'https://plex.tv/api/users/'
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self._data = data
|
||||
self.friend = self._initpath == self.key
|
||||
self.allowCameraUpload = utils.cast(bool, data.attrib.get('allowCameraUpload'))
|
||||
self.allowChannels = utils.cast(bool, data.attrib.get('allowChannels'))
|
||||
self.allowSync = utils.cast(bool, data.attrib.get('allowSync'))
|
||||
self.email = data.attrib.get('email')
|
||||
self.filterAll = data.attrib.get('filterAll')
|
||||
self.filterMovies = data.attrib.get('filterMovies')
|
||||
self.filterMusic = data.attrib.get('filterMusic')
|
||||
self.filterPhotos = data.attrib.get('filterPhotos')
|
||||
self.filterTelevision = data.attrib.get('filterTelevision')
|
||||
self.home = utils.cast(bool, data.attrib.get('home'))
|
||||
self.id = utils.cast(int, data.attrib.get('id'))
|
||||
self.protected = utils.cast(bool, data.attrib.get('protected'))
|
||||
self.recommendationsPlaylistId = data.attrib.get('recommendationsPlaylistId')
|
||||
self.restricted = data.attrib.get('restricted')
|
||||
self.thumb = data.attrib.get('thumb')
|
||||
self.title = data.attrib.get('title', '')
|
||||
self.username = data.attrib.get('username', '')
|
||||
self.servers = self.findItems(data, MyPlexServerShare)
|
||||
|
||||
def get_token(self, machineIdentifier):
|
||||
try:
|
||||
for item in self._server.query(self._server.FRIENDINVITE.format(machineId=machineIdentifier)):
|
||||
if utils.cast(int, item.attrib.get('userID')) == self.id:
|
||||
return item.attrib.get('accessToken')
|
||||
except Exception:
|
||||
log.exception('Failed to get access token for %s' % self.title)
|
||||
|
||||
|
||||
class Section(PlexObject):
|
||||
""" This refers to a shared section. The raw xml for the data presented here
|
||||
can be found at: https://plex.tv/api/servers/{machineId}/shared_servers/{serverId}
|
||||
|
||||
Attributes:
|
||||
TAG (str): section
|
||||
id (int): shared section id
|
||||
sectionKey (str): what key we use for this section
|
||||
title (str): Title of the section
|
||||
sectionId (str): shared section id
|
||||
type (str): movie, tvshow, artist
|
||||
shared (bool): If this section is shared with the user
|
||||
|
||||
"""
|
||||
TAG = 'Section'
|
||||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
# self.id = utils.cast(int, data.attrib.get('id')) # Havnt decided if this should be changed.
|
||||
self.sectionKey = data.attrib.get('key')
|
||||
self.title = data.attrib.get('title')
|
||||
self.sectionId = data.attrib.get('id')
|
||||
self.type = data.attrib.get('type')
|
||||
self.shared = utils.cast(bool, data.attrib.get('shared'))
|
||||
|
||||
|
||||
class MyPlexServerShare(PlexObject):
|
||||
""" Represents a single user's server reference. Used for library sharing.
|
||||
|
||||
Attributes:
|
||||
id (int): id for this share
|
||||
serverId (str): what id plex uses for this.
|
||||
machineIdentifier (str): The servers machineIdentifier
|
||||
name (str): The servers name
|
||||
lastSeenAt (datetime): Last connected to the server?
|
||||
numLibraries (int): Total number of libraries
|
||||
allLibraries (bool): True if all libraries is shared with this user.
|
||||
owned (bool): 1 if the server is owned by the user
|
||||
pending (bool): True if the invite is pending.
|
||||
|
||||
"""
|
||||
TAG = 'Server'
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self._data = data
|
||||
self.id = utils.cast(int, data.attrib.get('id'))
|
||||
self.serverId = utils.cast(int, data.attrib.get('serverId'))
|
||||
self.machineIdentifier = data.attrib.get('machineIdentifier')
|
||||
self.name = data.attrib.get('name')
|
||||
self.lastSeenAt = utils.toDatetime(data.attrib.get('lastSeenAt'))
|
||||
self.numLibraries = utils.cast(int, data.attrib.get('numLibraries'))
|
||||
self.allLibraries = utils.cast(bool, data.attrib.get('allLibraries'))
|
||||
self.owned = utils.cast(bool, data.attrib.get('owned'))
|
||||
self.pending = utils.cast(bool, data.attrib.get('pending'))
|
||||
|
||||
def sections(self):
|
||||
url = MyPlexAccount.FRIENDSERVERS.format(machineId=self.machineIdentifier, serverId=self.id)
|
||||
data = self._server.query(url)
|
||||
sections = []
|
||||
|
||||
for section in data.iter('Section'):
|
||||
if ElementTree.iselement(section):
|
||||
sections.append(Section(self, section, url))
|
||||
|
||||
return sections
|
||||
|
||||
|
||||
class MyPlexResource(PlexObject):
|
||||
""" This object represents resources connected to your Plex server that can provide
|
||||
content such as Plex Media Servers, iPhone or Android clients, etc. The raw xml
|
||||
for the data presented here can be found at:
|
||||
https://plex.tv/api/resources?includeHttps=1&includeRelay=1
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Device'
|
||||
key (str): 'https://plex.tv/api/resources?includeHttps=1&includeRelay=1'
|
||||
accessToken (str): This resources accesstoken.
|
||||
clientIdentifier (str): Unique ID for this resource.
|
||||
connections (list): List of :class:`~myplex.ResourceConnection` objects
|
||||
for this resource.
|
||||
createdAt (datetime): Timestamp this resource first connected to your server.
|
||||
device (str): Best guess on the type of device this is (PS, iPhone, Linux, etc).
|
||||
home (bool): Unknown
|
||||
lastSeenAt (datetime): Timestamp this resource last connected.
|
||||
name (str): Descriptive name of this resource.
|
||||
owned (bool): True if this resource is one of your own (you logged into it).
|
||||
platform (str): OS the resource is running (Linux, Windows, Chrome, etc.)
|
||||
platformVersion (str): Version of the platform.
|
||||
presence (bool): True if the resource is online
|
||||
product (str): Plex product (Plex Media Server, Plex for iOS, Plex Web, etc.)
|
||||
productVersion (str): Version of the product.
|
||||
provides (str): List of services this resource provides (client, server,
|
||||
player, pubsub-player, etc.)
|
||||
synced (bool): Unknown (possibly True if the resource has synced content?)
|
||||
"""
|
||||
TAG = 'Device'
|
||||
key = 'https://plex.tv/api/resources?includeHttps=1&includeRelay=1'
|
||||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
self.name = data.attrib.get('name')
|
||||
self.accessToken = logfilter.add_secret(data.attrib.get('accessToken'))
|
||||
self.product = data.attrib.get('product')
|
||||
self.productVersion = data.attrib.get('productVersion')
|
||||
self.platform = data.attrib.get('platform')
|
||||
self.platformVersion = data.attrib.get('platformVersion')
|
||||
self.device = data.attrib.get('device')
|
||||
self.clientIdentifier = data.attrib.get('clientIdentifier')
|
||||
self.createdAt = utils.toDatetime(data.attrib.get('createdAt'))
|
||||
self.lastSeenAt = utils.toDatetime(data.attrib.get('lastSeenAt'))
|
||||
self.provides = data.attrib.get('provides')
|
||||
self.owned = utils.cast(bool, data.attrib.get('owned'))
|
||||
self.home = utils.cast(bool, data.attrib.get('home'))
|
||||
self.synced = utils.cast(bool, data.attrib.get('synced'))
|
||||
self.presence = utils.cast(bool, data.attrib.get('presence'))
|
||||
self.connections = self.findItems(data, ResourceConnection)
|
||||
self.publicAddressMatches = utils.cast(bool, data.attrib.get('publicAddressMatches'))
|
||||
# This seems to only be available if its not your device (say are shared server)
|
||||
self.httpsRequired = utils.cast(bool, data.attrib.get('httpsRequired'))
|
||||
self.ownerid = utils.cast(int, data.attrib.get('ownerId', 0))
|
||||
self.sourceTitle = data.attrib.get('sourceTitle') # owners plex username.
|
||||
|
||||
def connect(self, ssl=None, timeout=None):
|
||||
""" Returns a new :class:`~server.PlexServer` or :class:`~client.PlexClient` object.
|
||||
Often times there is more than one address specified for a server or client.
|
||||
This function will prioritize local connections before remote and HTTPS before HTTP.
|
||||
After trying to connect to all available addresses for this resource and
|
||||
assuming at least one connection was successful, the PlexServer object is built and returned.
|
||||
|
||||
Parameters:
|
||||
ssl (optional): Set True to only connect to HTTPS connections. Set False to
|
||||
only connect to HTTP connections. Set None (default) to connect to any
|
||||
HTTP or HTTPS connection.
|
||||
|
||||
Raises:
|
||||
:class:`~plexapi.exceptions.NotFound`: When unable to connect to any addresses for this resource.
|
||||
"""
|
||||
# Sort connections from (https, local) to (http, remote)
|
||||
# Only check non-local connections unless we own the resource
|
||||
connections = sorted(self.connections, key=lambda c: c.local, reverse=True)
|
||||
owned_or_unowned_non_local = lambda x: self.owned or (not self.owned and not x.local)
|
||||
https = [c.uri for c in connections if owned_or_unowned_non_local(c)]
|
||||
http = [c.httpuri for c in connections if owned_or_unowned_non_local(c)]
|
||||
cls = PlexServer if 'server' in self.provides else PlexClient
|
||||
# Force ssl, no ssl, or any (default)
|
||||
if ssl is True: connections = https
|
||||
elif ssl is False: connections = http
|
||||
else: connections = https + http
|
||||
# Try connecting to all known resource connections in parellel, but
|
||||
# only return the first server (in order) that provides a response.
|
||||
listargs = [[cls, url, self.accessToken, timeout] for url in connections]
|
||||
log.info('Testing %s resource connections..', len(listargs))
|
||||
results = utils.threaded(_connect, listargs)
|
||||
return _chooseConnection('Resource', self.name, results)
|
||||
|
||||
|
||||
class ResourceConnection(PlexObject):
|
||||
""" Represents a Resource Connection object found within the
|
||||
:class:`~myplex.MyPlexResource` objects.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Connection'
|
||||
address (str): Local IP address
|
||||
httpuri (str): Full local address
|
||||
local (bool): True if local
|
||||
port (int): 32400
|
||||
protocol (str): HTTP or HTTPS
|
||||
uri (str): External address
|
||||
"""
|
||||
TAG = 'Connection'
|
||||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
self.protocol = data.attrib.get('protocol')
|
||||
self.address = data.attrib.get('address')
|
||||
self.port = utils.cast(int, data.attrib.get('port'))
|
||||
self.uri = data.attrib.get('uri')
|
||||
self.local = utils.cast(bool, data.attrib.get('local'))
|
||||
self.httpuri = 'http://%s:%s' % (self.address, self.port)
|
||||
self.relay = utils.cast(bool, data.attrib.get('relay'))
|
||||
|
||||
|
||||
class MyPlexDevice(PlexObject):
|
||||
""" This object represents resources connected to your Plex server that provide
|
||||
playback ability from your Plex Server, iPhone or Android clients, Plex Web,
|
||||
this API, etc. The raw xml for the data presented here can be found at:
|
||||
https://plex.tv/devices.xml
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Device'
|
||||
key (str): 'https://plex.tv/devices.xml'
|
||||
clientIdentifier (str): Unique ID for this resource.
|
||||
connections (list): List of connection URIs for the device.
|
||||
device (str): Best guess on the type of device this is (Linux, iPad, AFTB, etc).
|
||||
id (str): MyPlex ID of the device.
|
||||
model (str): Model of the device (bueller, Linux, x86_64, etc.)
|
||||
name (str): Hostname of the device.
|
||||
platform (str): OS the resource is running (Linux, Windows, Chrome, etc.)
|
||||
platformVersion (str): Version of the platform.
|
||||
product (str): Plex product (Plex Media Server, Plex for iOS, Plex Web, etc.)
|
||||
productVersion (string): Version of the product.
|
||||
provides (str): List of services this resource provides (client, controller,
|
||||
sync-target, player, pubsub-player).
|
||||
publicAddress (str): Public IP address.
|
||||
screenDensity (str): Unknown
|
||||
screenResolution (str): Screen resolution (750x1334, 1242x2208, etc.)
|
||||
token (str): Plex authentication token for the device.
|
||||
vendor (str): Device vendor (ubuntu, etc).
|
||||
version (str): Unknown (1, 2, 1.3.3.3148-b38628e, 1.3.15, etc.)
|
||||
"""
|
||||
TAG = 'Device'
|
||||
key = 'https://plex.tv/devices.xml'
|
||||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
self.name = data.attrib.get('name')
|
||||
self.publicAddress = data.attrib.get('publicAddress')
|
||||
self.product = data.attrib.get('product')
|
||||
self.productVersion = data.attrib.get('productVersion')
|
||||
self.platform = data.attrib.get('platform')
|
||||
self.platformVersion = data.attrib.get('platformVersion')
|
||||
self.device = data.attrib.get('device')
|
||||
self.model = data.attrib.get('model')
|
||||
self.vendor = data.attrib.get('vendor')
|
||||
self.provides = data.attrib.get('provides')
|
||||
self.clientIdentifier = data.attrib.get('clientIdentifier')
|
||||
self.version = data.attrib.get('version')
|
||||
self.id = data.attrib.get('id')
|
||||
self.token = logfilter.add_secret(data.attrib.get('token'))
|
||||
self.screenResolution = data.attrib.get('screenResolution')
|
||||
self.screenDensity = data.attrib.get('screenDensity')
|
||||
self.createdAt = utils.toDatetime(data.attrib.get('createdAt'))
|
||||
self.lastSeenAt = utils.toDatetime(data.attrib.get('lastSeenAt'))
|
||||
self.connections = [connection.attrib.get('uri') for connection in data.iter('Connection')]
|
||||
|
||||
def connect(self, timeout=None):
|
||||
""" Returns a new :class:`~plexapi.client.PlexClient` or :class:`~plexapi.server.PlexServer`
|
||||
Sometimes there is more than one address specified for a server or client.
|
||||
After trying to connect to all available addresses for this client and assuming
|
||||
at least one connection was successful, the PlexClient object is built and returned.
|
||||
|
||||
Raises:
|
||||
:class:`~plexapi.exceptions.NotFound`: When unable to connect to any addresses for this device.
|
||||
"""
|
||||
cls = PlexServer if 'server' in self.provides else PlexClient
|
||||
listargs = [[cls, url, self.token, timeout] for url in self.connections]
|
||||
log.info('Testing %s device connections..', len(listargs))
|
||||
results = utils.threaded(_connect, listargs)
|
||||
return _chooseConnection('Device', self.name, results)
|
||||
|
||||
def delete(self):
|
||||
""" Remove this device from your account. """
|
||||
key = 'https://plex.tv/devices/%s.xml' % self.id
|
||||
self._server.query(key, self._server._session.delete)
|
||||
|
||||
|
||||
def _connect(cls, url, token, timeout, results, i):
|
||||
""" Connects to the specified cls with url and token. Stores the connection
|
||||
information to results[i] in a threadsafe way.
|
||||
"""
|
||||
starttime = time.time()
|
||||
try:
|
||||
device = cls(baseurl=url, token=token, timeout=timeout)
|
||||
runtime = int(time.time() - starttime)
|
||||
results[i] = (url, token, device, runtime)
|
||||
except Exception as err:
|
||||
runtime = int(time.time() - starttime)
|
||||
log.error('%s: %s', url, err)
|
||||
results[i] = (url, token, None, runtime)
|
||||
|
||||
|
||||
def _chooseConnection(ctype, name, results):
|
||||
""" Chooses the first (best) connection from the given _connect results. """
|
||||
# At this point we have a list of result tuples containing (url, token, PlexServer, runtime)
|
||||
# or (url, token, None, runtime) in the case a connection could not be established.
|
||||
for url, token, result, runtime in results:
|
||||
okerr = 'OK' if result else 'ERR'
|
||||
log.info('%s connection %s (%ss): %s?X-Plex-Token=%s', ctype, okerr, runtime, url, token)
|
||||
results = [r[2] for r in results if r and r[2] is not None]
|
||||
if results:
|
||||
log.info('Connecting to %s: %s?X-Plex-Token=%s', ctype, results[0]._baseurl, results[0]._token)
|
||||
return results[0]
|
||||
raise NotFound('Unable to connect to %s: %s' % (ctype.lower(), name))
|
125
lib/plexapi/photo.py
Normal file
125
lib/plexapi/photo.py
Normal file
@@ -0,0 +1,125 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from plexapi import media, utils
|
||||
from plexapi.base import PlexPartialObject
|
||||
from plexapi.exceptions import NotFound
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Photoalbum(PlexPartialObject):
|
||||
""" Represents a photoalbum (collection of photos).
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Directory'
|
||||
TYPE (str): 'photo'
|
||||
addedAt (datetime): Datetime this item was added to the library.
|
||||
art (str): Photo art (/library/metadata/<ratingkey>/art/<artid>)
|
||||
composite (str): Unknown
|
||||
guid (str): Unknown (unique ID)
|
||||
index (sting): Index number of this album.
|
||||
key (str): API URL (/library/metadata/<ratingkey>).
|
||||
librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID.
|
||||
listType (str): Hardcoded as 'photo' (useful for search filters).
|
||||
ratingKey (int): Unique key identifying this item.
|
||||
summary (str): Summary of the photoalbum.
|
||||
thumb (str): URL to thumbnail image.
|
||||
title (str): Photoalbum title. (Trip to Disney World)
|
||||
type (str): Unknown
|
||||
updatedAt (datatime): Datetime this item was updated.
|
||||
"""
|
||||
TAG = 'Directory'
|
||||
TYPE = 'photo'
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self.listType = 'photo'
|
||||
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
|
||||
self.art = data.attrib.get('art')
|
||||
self.composite = data.attrib.get('composite')
|
||||
self.guid = data.attrib.get('guid')
|
||||
self.index = utils.cast(int, data.attrib.get('index'))
|
||||
self.key = data.attrib.get('key')
|
||||
self.librarySectionID = data.attrib.get('librarySectionID')
|
||||
self.ratingKey = data.attrib.get('ratingKey')
|
||||
self.summary = data.attrib.get('summary')
|
||||
self.thumb = data.attrib.get('thumb')
|
||||
self.title = data.attrib.get('title')
|
||||
self.type = data.attrib.get('type')
|
||||
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
|
||||
|
||||
def albums(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.photo.Photoalbum` objects in this album. """
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
return self.fetchItems(key, etag='Directory', **kwargs)
|
||||
|
||||
def album(self, title):
|
||||
""" Returns the :class:`~plexapi.photo.Photoalbum` that matches the specified title. """
|
||||
for album in self.albums():
|
||||
if album.title.lower() == title.lower():
|
||||
return album
|
||||
raise NotFound('Unable to find album: %s' % title)
|
||||
|
||||
def photos(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.photo.Photo` objects in this album. """
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
return self.fetchItems(key, etag='Photo', **kwargs)
|
||||
|
||||
def photo(self, title):
|
||||
""" Returns the :class:`~plexapi.photo.Photo` that matches the specified title. """
|
||||
for photo in self.photos():
|
||||
if photo.title.lower() == title.lower():
|
||||
return photo
|
||||
raise NotFound('Unable to find photo: %s' % title)
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Photo(PlexPartialObject):
|
||||
""" Represents a single photo.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Photo'
|
||||
TYPE (str): 'photo'
|
||||
addedAt (datetime): Datetime this item was added to the library.
|
||||
index (sting): Index number of this photo.
|
||||
key (str): API URL (/library/metadata/<ratingkey>).
|
||||
listType (str): Hardcoded as 'photo' (useful for search filters).
|
||||
media (TYPE): Unknown
|
||||
originallyAvailableAt (datetime): Datetime this photo was added to Plex.
|
||||
parentKey (str): Photoalbum API URL.
|
||||
parentRatingKey (int): Unique key identifying the photoalbum.
|
||||
ratingKey (int): Unique key identifying this item.
|
||||
summary (str): Summary of the photo.
|
||||
thumb (str): URL to thumbnail image.
|
||||
title (str): Photo title.
|
||||
type (str): Unknown
|
||||
updatedAt (datatime): Datetime this item was updated.
|
||||
year (int): Year this photo was taken.
|
||||
"""
|
||||
TAG = 'Photo'
|
||||
TYPE = 'photo'
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self.listType = 'photo'
|
||||
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
|
||||
self.index = utils.cast(int, data.attrib.get('index'))
|
||||
self.key = data.attrib.get('key')
|
||||
self.originallyAvailableAt = utils.toDatetime(
|
||||
data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
|
||||
self.parentKey = data.attrib.get('parentKey')
|
||||
self.parentRatingKey = data.attrib.get('parentRatingKey')
|
||||
self.ratingKey = data.attrib.get('ratingKey')
|
||||
self.summary = data.attrib.get('summary')
|
||||
self.thumb = data.attrib.get('thumb')
|
||||
self.title = data.attrib.get('title')
|
||||
self.type = data.attrib.get('type')
|
||||
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
|
||||
self.year = utils.cast(int, data.attrib.get('year'))
|
||||
self.media = self.findItems(data, media.Media)
|
||||
|
||||
def photoalbum(self):
|
||||
""" Return this photo's :class:`~plexapi.photo.Photoalbum`. """
|
||||
return self.fetchItem(self.parentKey)
|
||||
|
||||
def section(self):
|
||||
""" Returns the :class:`~plexapi.library.LibrarySection` this item belongs to. """
|
||||
return self._server.library.sectionByID(self.photoalbum().librarySectionID)
|
134
lib/plexapi/playlist.py
Normal file
134
lib/plexapi/playlist.py
Normal file
@@ -0,0 +1,134 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from plexapi import utils
|
||||
from plexapi.base import PlexPartialObject, Playable
|
||||
from plexapi.exceptions import BadRequest
|
||||
from plexapi.playqueue import PlayQueue
|
||||
from plexapi.utils import cast, toDatetime
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Playlist(PlexPartialObject, Playable):
|
||||
""" Represents a single Playlist object.
|
||||
# TODO: Document attributes
|
||||
"""
|
||||
TAG = 'Playlist'
|
||||
TYPE = 'playlist'
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
Playable._loadData(self, data)
|
||||
self.addedAt = toDatetime(data.attrib.get('addedAt'))
|
||||
self.composite = data.attrib.get('composite') # url to thumbnail
|
||||
self.duration = cast(int, data.attrib.get('duration'))
|
||||
self.durationInSeconds = cast(int, data.attrib.get('durationInSeconds'))
|
||||
self.guid = data.attrib.get('guid')
|
||||
self.key = data.attrib.get('key')
|
||||
self.key = self.key.replace('/items', '') if self.key else self.key # FIX_BUG_50
|
||||
self.leafCount = cast(int, data.attrib.get('leafCount'))
|
||||
self.playlistType = data.attrib.get('playlistType')
|
||||
self.ratingKey = cast(int, data.attrib.get('ratingKey'))
|
||||
self.smart = cast(bool, data.attrib.get('smart'))
|
||||
self.summary = data.attrib.get('summary')
|
||||
self.title = data.attrib.get('title')
|
||||
self.type = data.attrib.get('type')
|
||||
self.updatedAt = toDatetime(data.attrib.get('updatedAt'))
|
||||
self._items = None # cache for self.items
|
||||
|
||||
def __len__(self): # pragma: no cover
|
||||
return len(self.items())
|
||||
|
||||
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]
|
||||
|
||||
def items(self):
|
||||
""" Returns a list of all items in the playlist. """
|
||||
if self._items is None:
|
||||
key = '%s/items' % self.key
|
||||
items = self.fetchItems(key)
|
||||
self._items = items
|
||||
return self._items
|
||||
|
||||
def addItems(self, items):
|
||||
""" Add items to a playlist. """
|
||||
if not isinstance(items, (list, tuple)):
|
||||
items = [items]
|
||||
ratingKeys = []
|
||||
for item in items:
|
||||
if item.listType != self.playlistType: # pragma: no cover
|
||||
raise BadRequest('Can not mix media types when building a playlist: %s and %s' %
|
||||
(self.playlistType, item.listType))
|
||||
ratingKeys.append(str(item.ratingKey))
|
||||
uuid = items[0].section().uuid
|
||||
ratingKeys = ','.join(ratingKeys)
|
||||
key = '%s/items%s' % (self.key, utils.joinArgs({
|
||||
'uri': 'library://%s/directory//library/metadata/%s' % (uuid, ratingKeys)
|
||||
}))
|
||||
result = self._server.query(key, method=self._server._session.put)
|
||||
self.reload()
|
||||
return result
|
||||
|
||||
def removeItem(self, item):
|
||||
""" Remove a file from a playlist. """
|
||||
key = '%s/items/%s' % (self.key, item.playlistItemID)
|
||||
result = self._server.query(key, method=self._server._session.delete)
|
||||
self.reload()
|
||||
return result
|
||||
|
||||
def moveItem(self, item, after=None):
|
||||
""" Move a to a new position in playlist. """
|
||||
key = '%s/items/%s/move' % (self.key, item.playlistItemID)
|
||||
if after:
|
||||
key += '?after=%s' % after.playlistItemID
|
||||
result = self._server.query(key, method=self._server._session.put)
|
||||
self.reload()
|
||||
return result
|
||||
|
||||
def edit(self, title=None, summary=None):
|
||||
""" Edit playlist. """
|
||||
key = '/library/metadata/%s%s' % (self.ratingKey, utils.joinArgs({'title': title, 'summary': summary}))
|
||||
result = self._server.query(key, method=self._server._session.put)
|
||||
self.reload()
|
||||
return result
|
||||
|
||||
def delete(self):
|
||||
""" Delete playlist. """
|
||||
return self._server.query(self.key, method=self._server._session.delete)
|
||||
|
||||
def playQueue(self, *args, **kwargs):
|
||||
""" Create a playqueue from this playlist. """
|
||||
return PlayQueue.create(self._server, self, *args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def create(cls, server, title, items):
|
||||
""" Create a playlist. """
|
||||
if not isinstance(items, (list, tuple)):
|
||||
items = [items]
|
||||
ratingKeys = []
|
||||
for item in items:
|
||||
if item.listType != items[0].listType: # pragma: no cover
|
||||
raise BadRequest('Can not mix media types when building a playlist')
|
||||
ratingKeys.append(str(item.ratingKey))
|
||||
ratingKeys = ','.join(ratingKeys)
|
||||
uuid = items[0].section().uuid
|
||||
key = '/playlists%s' % utils.joinArgs({
|
||||
'uri': 'library://%s/directory//library/metadata/%s' % (uuid, ratingKeys),
|
||||
'type': items[0].listType,
|
||||
'title': title,
|
||||
'smart': 0
|
||||
})
|
||||
data = server.query(key, method=server._session.post)[0]
|
||||
return cls(server, data, initpath=key)
|
||||
|
||||
def copyToUser(self, user):
|
||||
""" Copy playlist to another user account. """
|
||||
from plexapi.server import PlexServer
|
||||
myplex = self._server.myPlexAccount()
|
||||
user = myplex.user(user)
|
||||
# Get the token for your machine.
|
||||
token = user.get_token(self._server.machineIdentifier)
|
||||
# Login to your server using your friends credentials.
|
||||
user_server = PlexServer(self._server._baseurl, token)
|
||||
return self.create(user_server, self.title, self.items())
|
75
lib/plexapi/playqueue.py
Normal file
75
lib/plexapi/playqueue.py
Normal file
@@ -0,0 +1,75 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from plexapi import utils
|
||||
from plexapi.base import PlexObject
|
||||
|
||||
|
||||
class PlayQueue(PlexObject):
|
||||
""" Control a PlayQueue.
|
||||
|
||||
Attributes:
|
||||
key (str): This is only added to support playMedia
|
||||
identifier (str): com.plexapp.plugins.library
|
||||
initpath (str): Relative url where data was grabbed from.
|
||||
items (list): List of :class:`~plexapi.media.Media` or class:`~plexapi.playlist.Playlist`
|
||||
mediaTagPrefix (str): Fx /system/bundle/media/flags/
|
||||
mediaTagVersion (str): Fx 1485957738
|
||||
playQueueID (str): a id for the playqueue
|
||||
playQueueSelectedItemID (str): playQueueSelectedItemID
|
||||
playQueueSelectedItemOffset (str): playQueueSelectedItemOffset
|
||||
playQueueSelectedMetadataItemID (<type 'str'>): 7
|
||||
playQueueShuffled (bool): True if shuffled
|
||||
playQueueSourceURI (str): Fx library://150425c9-0d99-4242-821e-e5ab81cd2221/item//library/metadata/7
|
||||
playQueueTotalCount (str): How many items in the play queue.
|
||||
playQueueVersion (str): What version the playqueue is.
|
||||
server (:class:`~plexapi.server.PlexServer`): Server you are connected to.
|
||||
size (str): Seems to be a alias for playQueueTotalCount.
|
||||
"""
|
||||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
self.identifier = data.attrib.get('identifier')
|
||||
self.mediaTagPrefix = data.attrib.get('mediaTagPrefix')
|
||||
self.mediaTagVersion = data.attrib.get('mediaTagVersion')
|
||||
self.playQueueID = data.attrib.get('playQueueID')
|
||||
self.playQueueSelectedItemID = data.attrib.get('playQueueSelectedItemID')
|
||||
self.playQueueSelectedItemOffset = data.attrib.get('playQueueSelectedItemOffset')
|
||||
self.playQueueSelectedMetadataItemID = data.attrib.get('playQueueSelectedMetadataItemID')
|
||||
self.playQueueShuffled = utils.cast(bool, data.attrib.get('playQueueShuffled', 0))
|
||||
self.playQueueSourceURI = data.attrib.get('playQueueSourceURI')
|
||||
self.playQueueTotalCount = data.attrib.get('playQueueTotalCount')
|
||||
self.playQueueVersion = data.attrib.get('playQueueVersion')
|
||||
self.size = utils.cast(int, data.attrib.get('size', 0))
|
||||
self.items = self.findItems(data)
|
||||
|
||||
@classmethod
|
||||
def create(cls, server, item, shuffle=0, repeat=0, includeChapters=1, includeRelated=1):
|
||||
""" Create and returns a new :class:`~plexapi.playqueue.PlayQueue`.
|
||||
|
||||
Paramaters:
|
||||
server (:class:`~plexapi.server.PlexServer`): Server you are connected to.
|
||||
item (:class:`~plexapi.media.Media` or class:`~plexapi.playlist.Playlist`): A media or Playlist.
|
||||
shuffle (int, optional): Start the playqueue shuffled.
|
||||
repeat (int, optional): Start the playqueue shuffled.
|
||||
includeChapters (int, optional): include Chapters.
|
||||
includeRelated (int, optional): include Related.
|
||||
"""
|
||||
args = {}
|
||||
args['includeChapters'] = includeChapters
|
||||
args['includeRelated'] = includeRelated
|
||||
args['repeat'] = repeat
|
||||
args['shuffle'] = shuffle
|
||||
if item.type == 'playlist':
|
||||
args['playlistID'] = item.ratingKey
|
||||
args['type'] = item.playlistType
|
||||
else:
|
||||
uuid = item.section().uuid
|
||||
args['key'] = item.key
|
||||
args['type'] = item.listType
|
||||
args['uri'] = 'library://%s/item/%s' % (uuid, item.key)
|
||||
path = '/playQueues%s' % utils.joinArgs(args)
|
||||
data = server.query(path, method=server._session.post)
|
||||
c = cls(server, data, initpath=path)
|
||||
# we manually add a key so we can pass this to playMedia
|
||||
# since the data, does not contain a key.
|
||||
c.key = item.key
|
||||
return c
|
471
lib/plexapi/server.py
Normal file
471
lib/plexapi/server.py
Normal file
@@ -0,0 +1,471 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import requests
|
||||
from requests.status_codes import _codes as codes
|
||||
from plexapi import BASE_HEADERS, CONFIG, TIMEOUT
|
||||
from plexapi import log, logfilter, utils
|
||||
from plexapi.alert import AlertListener
|
||||
from plexapi.base import PlexObject
|
||||
from plexapi.client import PlexClient
|
||||
from plexapi.compat import ElementTree, urlencode
|
||||
from plexapi.exceptions import BadRequest, NotFound
|
||||
from plexapi.library import Library, Hub
|
||||
from plexapi.settings import Settings
|
||||
from plexapi.playlist import Playlist
|
||||
from plexapi.playqueue import PlayQueue
|
||||
from plexapi.utils import cast
|
||||
|
||||
# Need these imports to populate utils.PLEXOBJECTS
|
||||
from plexapi import (audio as _audio, video as _video, # noqa: F401
|
||||
photo as _photo, media as _media, playlist as _playlist) # noqa: F401
|
||||
|
||||
|
||||
class PlexServer(PlexObject):
|
||||
""" This is the main entry point to interacting with a Plex server. It allows you to
|
||||
list connected clients, browse your library sections and perform actions such as
|
||||
emptying trash. If you do not know the auth token required to access your Plex
|
||||
server, or simply want to access your server with your username and password, you
|
||||
can also create an PlexServer instance from :class:`~plexapi.myplex.MyPlexAccount`.
|
||||
|
||||
Parameters:
|
||||
baseurl (str): Base url for to access the Plex Media Server (default: 'http://localhost:32400').
|
||||
token (str): Required Plex authentication token to access the server.
|
||||
session (requests.Session, optional): Use your own session object if you want to
|
||||
cache the http responses from PMS
|
||||
timeout (int): timeout in seconds on initial connect to server (default config.TIMEOUT).
|
||||
|
||||
Attributes:
|
||||
allowCameraUpload (bool): True if server allows camera upload.
|
||||
allowChannelAccess (bool): True if server allows channel access (iTunes?).
|
||||
allowMediaDeletion (bool): True is server allows media to be deleted.
|
||||
allowSharing (bool): True is server allows sharing.
|
||||
allowSync (bool): True is server allows sync.
|
||||
backgroundProcessing (bool): Unknown
|
||||
certificate (bool): True if server has an HTTPS certificate.
|
||||
companionProxy (bool): Unknown
|
||||
diagnostics (bool): Unknown
|
||||
eventStream (bool): Unknown
|
||||
friendlyName (str): Human friendly name for this server.
|
||||
hubSearch (bool): True if `Hub Search <https://www.plex.tv/blog
|
||||
/seek-plex-shall-find-leveling-web-app/>`_ is enabled. I believe this
|
||||
is enabled for everyone
|
||||
machineIdentifier (str): Unique ID for this server (looks like an md5).
|
||||
multiuser (bool): True if `multiusers <https://support.plex.tv/hc/en-us/articles
|
||||
/200250367-Multi-User-Support>`_ are enabled.
|
||||
myPlex (bool): Unknown (True if logged into myPlex?).
|
||||
myPlexMappingState (str): Unknown (ex: mapped).
|
||||
myPlexSigninState (str): Unknown (ex: ok).
|
||||
myPlexSubscription (bool): True if you have a myPlex subscription.
|
||||
myPlexUsername (str): Email address if signed into myPlex (user@example.com)
|
||||
ownerFeatures (list): List of features allowed by the server owner. This may be based
|
||||
on your PlexPass subscription. Features include: camera_upload, cloudsync,
|
||||
content_filter, dvr, hardware_transcoding, home, lyrics, music_videos, pass,
|
||||
photo_autotags, premium_music_metadata, session_bandwidth_restrictions, sync,
|
||||
trailers, webhooks (and maybe more).
|
||||
photoAutoTag (bool): True if photo `auto-tagging <https://support.plex.tv/hc/en-us
|
||||
/articles/234976627-Auto-Tagging-of-Photos>`_ is enabled.
|
||||
platform (str): Platform the server is hosted on (ex: Linux)
|
||||
platformVersion (str): Platform version (ex: '6.1 (Build 7601)', '4.4.0-59-generic').
|
||||
pluginHost (bool): Unknown
|
||||
readOnlyLibraries (bool): Unknown
|
||||
requestParametersInCookie (bool): Unknown
|
||||
streamingBrainVersion (bool): Current `Streaming Brain <https://www.plex.tv/blog
|
||||
/mcstreamy-brain-take-world-two-easy-steps/>`_ version.
|
||||
sync (bool): True if `syncing to a device <https://support.plex.tv/hc/en-us/articles
|
||||
/201053678-Sync-Media-to-a-Device>`_ is enabled.
|
||||
transcoderActiveVideoSessions (int): Number of active video transcoding sessions.
|
||||
transcoderAudio (bool): True if audio transcoding audio is available.
|
||||
transcoderLyrics (bool): True if audio transcoding lyrics is available.
|
||||
transcoderPhoto (bool): True if audio transcoding photos is available.
|
||||
transcoderSubtitles (bool): True if audio transcoding subtitles is available.
|
||||
transcoderVideo (bool): True if audio transcoding video is available.
|
||||
transcoderVideoBitrates (bool): List of video bitrates.
|
||||
transcoderVideoQualities (bool): List of video qualities.
|
||||
transcoderVideoResolutions (bool): List of video resolutions.
|
||||
updatedAt (int): Datetime the server was updated.
|
||||
updater (bool): Unknown
|
||||
version (str): Current Plex version (ex: 1.3.2.3112-1751929)
|
||||
voiceSearch (bool): True if voice search is enabled. (is this Google Voice search?)
|
||||
_baseurl (str): HTTP address of the client.
|
||||
_token (str): Token used to access this client.
|
||||
_session (obj): Requests session object used to access this client.
|
||||
"""
|
||||
key = '/'
|
||||
|
||||
def __init__(self, baseurl=None, token=None, session=None, timeout=None):
|
||||
self._baseurl = baseurl or CONFIG.get('auth.server_baseurl', 'http://localhost:32400')
|
||||
self._token = logfilter.add_secret(token or CONFIG.get('auth.server_token'))
|
||||
self._showSecrets = CONFIG.get('log.show_secrets', '').lower() == 'true'
|
||||
self._session = session or requests.Session()
|
||||
self._library = None # cached library
|
||||
self._settings = None # cached settings
|
||||
self._myPlexAccount = None # cached myPlexAccount
|
||||
data = self.query(self.key, timeout=timeout)
|
||||
super(PlexServer, self).__init__(self, data, self.key)
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self._data = data
|
||||
self.allowCameraUpload = cast(bool, data.attrib.get('allowCameraUpload'))
|
||||
self.allowChannelAccess = cast(bool, data.attrib.get('allowChannelAccess'))
|
||||
self.allowMediaDeletion = cast(bool, data.attrib.get('allowMediaDeletion'))
|
||||
self.allowSharing = cast(bool, data.attrib.get('allowSharing'))
|
||||
self.allowSync = cast(bool, data.attrib.get('allowSync'))
|
||||
self.backgroundProcessing = cast(bool, data.attrib.get('backgroundProcessing'))
|
||||
self.certificate = cast(bool, data.attrib.get('certificate'))
|
||||
self.companionProxy = cast(bool, data.attrib.get('companionProxy'))
|
||||
self.diagnostics = utils.toList(data.attrib.get('diagnostics'))
|
||||
self.eventStream = cast(bool, data.attrib.get('eventStream'))
|
||||
self.friendlyName = data.attrib.get('friendlyName')
|
||||
self.hubSearch = cast(bool, data.attrib.get('hubSearch'))
|
||||
self.machineIdentifier = data.attrib.get('machineIdentifier')
|
||||
self.multiuser = cast(bool, data.attrib.get('multiuser'))
|
||||
self.myPlex = cast(bool, data.attrib.get('myPlex'))
|
||||
self.myPlexMappingState = data.attrib.get('myPlexMappingState')
|
||||
self.myPlexSigninState = data.attrib.get('myPlexSigninState')
|
||||
self.myPlexSubscription = cast(bool, data.attrib.get('myPlexSubscription'))
|
||||
self.myPlexUsername = data.attrib.get('myPlexUsername')
|
||||
self.ownerFeatures = utils.toList(data.attrib.get('ownerFeatures'))
|
||||
self.photoAutoTag = cast(bool, data.attrib.get('photoAutoTag'))
|
||||
self.platform = data.attrib.get('platform')
|
||||
self.platformVersion = data.attrib.get('platformVersion')
|
||||
self.pluginHost = cast(bool, data.attrib.get('pluginHost'))
|
||||
self.readOnlyLibraries = cast(int, data.attrib.get('readOnlyLibraries'))
|
||||
self.requestParametersInCookie = cast(bool, data.attrib.get('requestParametersInCookie'))
|
||||
self.streamingBrainVersion = data.attrib.get('streamingBrainVersion')
|
||||
self.sync = cast(bool, data.attrib.get('sync'))
|
||||
self.transcoderActiveVideoSessions = int(data.attrib.get('transcoderActiveVideoSessions', 0))
|
||||
self.transcoderAudio = cast(bool, data.attrib.get('transcoderAudio'))
|
||||
self.transcoderLyrics = cast(bool, data.attrib.get('transcoderLyrics'))
|
||||
self.transcoderPhoto = cast(bool, data.attrib.get('transcoderPhoto'))
|
||||
self.transcoderSubtitles = cast(bool, data.attrib.get('transcoderSubtitles'))
|
||||
self.transcoderVideo = cast(bool, data.attrib.get('transcoderVideo'))
|
||||
self.transcoderVideoBitrates = utils.toList(data.attrib.get('transcoderVideoBitrates'))
|
||||
self.transcoderVideoQualities = utils.toList(data.attrib.get('transcoderVideoQualities'))
|
||||
self.transcoderVideoResolutions = utils.toList(data.attrib.get('transcoderVideoResolutions'))
|
||||
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
|
||||
self.updater = cast(bool, data.attrib.get('updater'))
|
||||
self.version = data.attrib.get('version')
|
||||
self.voiceSearch = cast(bool, data.attrib.get('voiceSearch'))
|
||||
|
||||
def _headers(self, **kwargs):
|
||||
""" Returns dict containing base headers for all requests to the server. """
|
||||
headers = BASE_HEADERS.copy()
|
||||
if self._token:
|
||||
headers['X-Plex-Token'] = self._token
|
||||
headers.update(kwargs)
|
||||
return headers
|
||||
|
||||
@property
|
||||
def library(self):
|
||||
""" Library to browse or search your media. """
|
||||
if not self._library:
|
||||
try:
|
||||
data = self.query(Library.key)
|
||||
self._library = Library(self, data)
|
||||
except BadRequest:
|
||||
data = self.query('/library/sections/')
|
||||
# Only the owner has access to /library
|
||||
# so just return the library without the data.
|
||||
return Library(self, data)
|
||||
return self._library
|
||||
|
||||
@property
|
||||
def settings(self):
|
||||
""" Returns a list of all server settings. """
|
||||
if not self._settings:
|
||||
data = self.query(Settings.key)
|
||||
self._settings = Settings(self, data)
|
||||
return self._settings
|
||||
|
||||
def account(self):
|
||||
""" Returns the :class:`~plexapi.server.Account` object this server belongs to. """
|
||||
data = self.query(Account.key)
|
||||
return Account(self, data)
|
||||
|
||||
def myPlexAccount(self):
|
||||
""" Returns a :class:`~plexapi.myplex.MyPlexAccount` object using the same
|
||||
token to access this server. If you are not the owner of this PlexServer
|
||||
you're likley to recieve an authentication error calling this.
|
||||
"""
|
||||
if self._myPlexAccount is None:
|
||||
from plexapi.myplex import MyPlexAccount
|
||||
self._myPlexAccount = MyPlexAccount(token=self._token)
|
||||
return self._myPlexAccount
|
||||
|
||||
def _myPlexClientPorts(self):
|
||||
""" Sometimes the PlexServer does not properly advertise port numbers required
|
||||
to connect. This attemps to look up device port number from plex.tv.
|
||||
See issue #126: Make PlexServer.clients() more user friendly.
|
||||
https://github.com/pkkid/python-plexapi/issues/126
|
||||
"""
|
||||
try:
|
||||
ports = {}
|
||||
account = self.myPlexAccount()
|
||||
for device in account.devices():
|
||||
if device.connections and ':' in device.connections[0][6:]:
|
||||
ports[device.clientIdentifier] = device.connections[0].split(':')[-1]
|
||||
return ports
|
||||
except Exception as err:
|
||||
log.warning('Unable to fetch client ports from myPlex: %s', err)
|
||||
return ports
|
||||
|
||||
def clients(self):
|
||||
""" Returns list of all :class:`~plexapi.client.PlexClient` objects connected to server. """
|
||||
items = []
|
||||
ports = None
|
||||
for elem in self.query('/clients'):
|
||||
port = elem.attrib.get('port')
|
||||
if not port:
|
||||
log.warning('%s did not advertise a port, checking plex.tv.', elem.attrib.get('name'))
|
||||
ports = self._myPlexClientPorts() if ports is None else ports
|
||||
port = ports.get(elem.attrib.get('machineIdentifier'))
|
||||
baseurl = 'http://%s:%s' % (elem.attrib['host'], port)
|
||||
items.append(PlexClient(baseurl=baseurl, server=self,
|
||||
token=self._token, data=elem, connect=False))
|
||||
|
||||
return items
|
||||
|
||||
def client(self, name):
|
||||
""" Returns the :class:`~plexapi.client.PlexClient` that matches the specified name.
|
||||
|
||||
Parameters:
|
||||
name (str): Name of the client to return.
|
||||
|
||||
Raises:
|
||||
:class:`~plexapi.exceptions.NotFound`: Unknown client name
|
||||
"""
|
||||
for client in self.clients():
|
||||
if client and client.title == name:
|
||||
return client
|
||||
|
||||
raise NotFound('Unknown client name: %s' % name)
|
||||
|
||||
def createPlaylist(self, title, items):
|
||||
""" Creates and returns a new :class:`~plexapi.playlist.Playlist`.
|
||||
|
||||
Parameters:
|
||||
title (str): Title of the playlist to be created.
|
||||
items (list<Media>): List of media items to include in the playlist.
|
||||
"""
|
||||
return Playlist.create(self, title, items)
|
||||
|
||||
def createPlayQueue(self, item, **kwargs):
|
||||
""" Creates and returns a new :class:`~plexapi.playqueue.PlayQueue`.
|
||||
|
||||
Parameters:
|
||||
item (Media or Playlist): Media or playlist to add to PlayQueue.
|
||||
kwargs (dict): See `~plexapi.playerque.PlayQueue.create`.
|
||||
"""
|
||||
return PlayQueue.create(self, item, **kwargs)
|
||||
|
||||
def downloadDatabases(self, savepath=None, unpack=False):
|
||||
""" Download databases.
|
||||
|
||||
Parameters:
|
||||
savepath (str): Defaults to current working dir.
|
||||
unpack (bool): Unpack the zip file.
|
||||
"""
|
||||
url = self.url('/diagnostics/databases')
|
||||
filepath = utils.download(url, self._token, None, savepath, self._session, unpack=unpack)
|
||||
return filepath
|
||||
|
||||
def downloadLogs(self, savepath=None, unpack=False):
|
||||
""" Download server logs.
|
||||
|
||||
Parameters:
|
||||
savepath (str): Defaults to current working dir.
|
||||
unpack (bool): Unpack the zip file.
|
||||
"""
|
||||
url = self.url('/diagnostics/logs')
|
||||
filepath = utils.download(url, self._token, None, savepath, self._session, unpack=unpack)
|
||||
return filepath
|
||||
|
||||
def check_for_update(self, force=True, download=False):
|
||||
""" Returns a :class:`~plexapi.base.Release` object containing release info.
|
||||
|
||||
Parameters:
|
||||
force (bool): Force server to check for new releases
|
||||
download (bool): Download if a update is available.
|
||||
"""
|
||||
part = '/updater/check?download=%s' % (1 if download else 0)
|
||||
if force:
|
||||
self.query(part, method=self._session.put)
|
||||
return self.fetchItem('/updater/status')
|
||||
|
||||
def isLatest(self):
|
||||
""" Check if the installed version of PMS is the latest. """
|
||||
release = self.check_for_update(force=True)
|
||||
return bool(release.version == self.version)
|
||||
|
||||
def installUpdate(self):
|
||||
""" Install the newest version of Plex Media Server. """
|
||||
# We can add this but dunno how useful this is since it sometimes
|
||||
# requires user action using a gui.
|
||||
part = '/updater/apply'
|
||||
release = self.check_for_update(force=True, download=True)
|
||||
if release and release.version != self.version:
|
||||
# figure out what method this is..
|
||||
return self.query(part, method=self._session.put)
|
||||
|
||||
def history(self):
|
||||
""" Returns a list of media items from watched history. """
|
||||
return self.fetchItems('/status/sessions/history/all')
|
||||
|
||||
def playlists(self):
|
||||
""" Returns a list of all :class:`~plexapi.playlist.Playlist` objects saved on the server. """
|
||||
# TODO: Add sort and type options?
|
||||
# /playlists/all?type=15&sort=titleSort%3Aasc&playlistType=video&smart=0
|
||||
return self.fetchItems('/playlists')
|
||||
|
||||
def playlist(self, title):
|
||||
""" Returns the :class:`~plexapi.client.Playlist` that matches the specified title.
|
||||
|
||||
Parameters:
|
||||
title (str): Title of the playlist to return.
|
||||
|
||||
Raises:
|
||||
:class:`~plexapi.exceptions.NotFound`: Invalid playlist title
|
||||
"""
|
||||
return self.fetchItem('/playlists', title=title)
|
||||
|
||||
def query(self, key, method=None, headers=None, timeout=None, **kwargs):
|
||||
""" Main method used to handle HTTPS requests to the Plex server. This method helps
|
||||
by encoding the response to utf-8 and parsing the returned XML into and
|
||||
ElementTree object. Returns None if no data exists in the response.
|
||||
"""
|
||||
url = self.url(key)
|
||||
method = method or self._session.get
|
||||
timeout = timeout or 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):
|
||||
codename = codes.get(response.status_code)[0]
|
||||
errtext = response.text.replace('\n', ' ')
|
||||
log.warning('BadRequest (%s) %s %s; %s' % (response.status_code, codename, response.url, errtext))
|
||||
raise BadRequest('(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext))
|
||||
data = response.text.encode('utf8')
|
||||
return ElementTree.fromstring(data) if data.strip() else None
|
||||
|
||||
def search(self, query, mediatype=None, limit=None):
|
||||
""" Returns a list of media items or filter categories from the resulting
|
||||
`Hub Search <https://www.plex.tv/blog/seek-plex-shall-find-leveling-web-app/>`_
|
||||
against all items in your Plex library. This searches genres, actors, directors,
|
||||
playlists, as well as all the obvious media titles. It performs spell-checking
|
||||
against your search terms (because KUROSAWA is hard to spell). It also provides
|
||||
contextual search results. So for example, if you search for 'Pernice', it’ll
|
||||
return 'Pernice Brothers' as the artist result, but we’ll also go ahead and
|
||||
return your most-listened to albums and tracks from the artist. If you type
|
||||
'Arnold' you’ll get a result for the actor, but also the most recently added
|
||||
movies he’s in.
|
||||
|
||||
Parameters:
|
||||
query (str): Query to use when searching your library.
|
||||
mediatype (str): Optionally limit your search to the specified media type.
|
||||
limit (int): Optionally limit to the specified number of results per Hub.
|
||||
"""
|
||||
results = []
|
||||
params = {'query': query}
|
||||
if mediatype:
|
||||
params['section'] = utils.SEARCHTYPES[mediatype]
|
||||
if limit:
|
||||
params['limit'] = limit
|
||||
key = '/hubs/search?%s' % urlencode(params)
|
||||
for hub in self.fetchItems(key, Hub):
|
||||
results += hub.items
|
||||
return results
|
||||
|
||||
def sessions(self):
|
||||
""" Returns a list of all active session (currently playing) media objects. """
|
||||
return self.fetchItems('/status/sessions')
|
||||
|
||||
def startAlertListener(self, callback=None):
|
||||
""" Creates a websocket connection to the Plex Server to optionally recieve
|
||||
notifications. These often include messages from Plex about media scans
|
||||
as well as updates to currently running Transcode Sessions.
|
||||
|
||||
NOTE: You need websocket-client installed in order to use this feature.
|
||||
>> pip install websocket-client
|
||||
|
||||
Parameters:
|
||||
callback (func): Callback function to call on recieved messages.
|
||||
|
||||
raises:
|
||||
:class:`~plexapi.exception.Unsupported`: Websocket-client not installed.
|
||||
"""
|
||||
notifier = AlertListener(self, callback)
|
||||
notifier.start()
|
||||
return notifier
|
||||
|
||||
def transcodeImage(self, media, height, width, opacity=100, saturation=100):
|
||||
""" Returns the URL for a transcoded image from the specified media object.
|
||||
Returns None if no media specified (needed if user tries to pass thumb
|
||||
or art directly).
|
||||
|
||||
Parameters:
|
||||
height (int): Height to transcode the image to.
|
||||
width (int): Width to transcode the image to.
|
||||
opacity (int): Opacity of the resulting image (possibly deprecated).
|
||||
saturation (int): Saturating of the resulting image.
|
||||
"""
|
||||
if media:
|
||||
transcode_url = '/photo/:/transcode?height=%s&width=%s&opacity=%s&saturation=%s&url=%s' % (
|
||||
height, width, opacity, saturation, media)
|
||||
return self.url(transcode_url, includeToken=True)
|
||||
|
||||
def url(self, key, includeToken=None):
|
||||
""" Build a URL string with proper token argument. Token will be appended to the URL
|
||||
if either includeToken is True or CONFIG.log.show_secrets is 'true'.
|
||||
"""
|
||||
if self._token and (includeToken or self._showSecrets):
|
||||
delim = '&' if '?' in key else '?'
|
||||
return '%s%s%sX-Plex-Token=%s' % (self._baseurl, key, delim, self._token)
|
||||
return '%s%s' % (self._baseurl, key)
|
||||
|
||||
|
||||
class Account(PlexObject):
|
||||
""" Contains the locally cached MyPlex account information. The properties provided don't
|
||||
match the :class:`~plexapi.myplex.MyPlexAccount` object very well. I believe this exists
|
||||
because access to myplex is not required to get basic plex information. I can't imagine
|
||||
object is terribly useful except unless you were needed this information while offline.
|
||||
|
||||
Parameters:
|
||||
server (:class:`~plexapi.server.PlexServer`): PlexServer this account is connected to (optional)
|
||||
data (ElementTree): Response from PlexServer used to build this object (optional).
|
||||
|
||||
Attributes:
|
||||
authToken (str): Plex authentication token to access the server.
|
||||
mappingError (str): Unknown
|
||||
mappingErrorMessage (str): Unknown
|
||||
mappingState (str): Unknown
|
||||
privateAddress (str): Local IP address of the Plex server.
|
||||
privatePort (str): Local port of the Plex server.
|
||||
publicAddress (str): Public IP address of the Plex server.
|
||||
publicPort (str): Public port of the Plex server.
|
||||
signInState (str): Signin state for this account (ex: ok).
|
||||
subscriptionActive (str): True if the account subscription is active.
|
||||
subscriptionFeatures (str): List of features allowed by the server for this account.
|
||||
This may be based on your PlexPass subscription. Features include: camera_upload,
|
||||
cloudsync, content_filter, dvr, hardware_transcoding, home, lyrics, music_videos,
|
||||
pass, photo_autotags, premium_music_metadata, session_bandwidth_restrictions,
|
||||
sync, trailers, webhooks' (and maybe more).
|
||||
subscriptionState (str): 'Active' if this subscription is active.
|
||||
username (str): Plex account username (user@example.com).
|
||||
"""
|
||||
key = '/myplex/account'
|
||||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
self.authToken = data.attrib.get('authToken')
|
||||
self.username = data.attrib.get('username')
|
||||
self.mappingState = data.attrib.get('mappingState')
|
||||
self.mappingError = data.attrib.get('mappingError')
|
||||
self.mappingErrorMessage = data.attrib.get('mappingErrorMessage')
|
||||
self.signInState = data.attrib.get('signInState')
|
||||
self.publicAddress = data.attrib.get('publicAddress')
|
||||
self.publicPort = data.attrib.get('publicPort')
|
||||
self.privateAddress = data.attrib.get('privateAddress')
|
||||
self.privatePort = data.attrib.get('privatePort')
|
||||
self.subscriptionFeatures = utils.toList(data.attrib.get('subscriptionFeatures'))
|
||||
self.subscriptionActive = cast(bool, data.attrib.get('subscriptionActive'))
|
||||
self.subscriptionState = data.attrib.get('subscriptionState')
|
156
lib/plexapi/settings.py
Normal file
156
lib/plexapi/settings.py
Normal file
@@ -0,0 +1,156 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from collections import defaultdict
|
||||
|
||||
from plexapi import log, utils
|
||||
from plexapi.base import PlexObject
|
||||
from plexapi.compat import quote, string_type
|
||||
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('_'):
|
||||
return self.__dict__[attr]
|
||||
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 list(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('Invalid setting id: %s' % 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 settnig 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(['%s=%s' % (k, v) for k, v in params.items()])
|
||||
url = '%s?%s' % (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 valis values for this setting.
|
||||
"""
|
||||
_bool_cast = lambda x: True if x == 'true' else False
|
||||
_bool_str = lambda x: str(x).lower()
|
||||
TYPES = {
|
||||
'bool': {'type': bool, 'cast': _bool_cast, 'tostr': _bool_str},
|
||||
'double': {'type': float, 'cast': float, 'tostr': string_type},
|
||||
'int': {'type': int, 'cast': int, 'tostr': string_type},
|
||||
'text': {'type': string_type, 'cast': string_type, 'tostr': string_type},
|
||||
}
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self._setValue = None
|
||||
self.id = data.attrib.get('id')
|
||||
self.label = data.attrib.get('label')
|
||||
self.summary = data.attrib.get('summary')
|
||||
self.type = data.attrib.get('type')
|
||||
self.default = self._cast(data.attrib.get('default'))
|
||||
self.value = self._cast(data.attrib.get('value'))
|
||||
self.hidden = utils.cast(bool, data.attrib.get('hidden'))
|
||||
self.advanced = utils.cast(bool, data.attrib.get('advanced'))
|
||||
self.group = data.attrib.get('group')
|
||||
self.enumValues = self._getEnumValues(data)
|
||||
|
||||
def _cast(self, value):
|
||||
""" Cast the specifief value to the type of this setting. """
|
||||
if self.type != 'text':
|
||||
value = utils.cast(self.TYPES.get(self.type)['cast'], value)
|
||||
return value
|
||||
|
||||
def _getEnumValues(self, data):
|
||||
""" Returns a list of dictionary of valis value for this setting. """
|
||||
enumstr = data.attrib.get('enumValues')
|
||||
if not enumstr:
|
||||
return None
|
||||
if ':' in enumstr:
|
||||
return {self._cast(k): v for k, v in [kv.split(':') for kv in enumstr.split('|')]}
|
||||
return enumstr.split('|')
|
||||
|
||||
def set(self, value):
|
||||
""" Set a new value for this setitng. 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('Invalid value for %s: a %s is required, not %s' % (self.id, self.type, badtype))
|
||||
if self.enumValues and value not in self.enumValues:
|
||||
raise BadRequest('Invalid value for %s: %s not in %s' % (self.id, value, 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 '%s=%s' % (self.id, self._value or self.value)
|
42
lib/plexapi/sync.py
Normal file
42
lib/plexapi/sync.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import requests
|
||||
from plexapi import utils
|
||||
from plexapi.exceptions import NotFound
|
||||
|
||||
|
||||
class SyncItem(object):
|
||||
""" Sync Item. This doesn't current work. """
|
||||
def __init__(self, device, data, servers=None):
|
||||
self._device = device
|
||||
self._servers = servers
|
||||
self._loadData(data)
|
||||
|
||||
def _loadData(self, data):
|
||||
self._data = data
|
||||
self.id = utils.cast(int, data.attrib.get('id'))
|
||||
self.version = 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.machineIdentifier = data.find('Server').get('machineIdentifier')
|
||||
self.status = data.find('Status').attrib.copy()
|
||||
self.MediaSettings = data.find('MediaSettings').attrib.copy()
|
||||
self.policy = data.find('Policy').attrib.copy()
|
||||
self.location = data.find('Location').attrib.copy()
|
||||
|
||||
def server(self):
|
||||
server = list(filter(lambda x: x.machineIdentifier == self.machineIdentifier, self._servers))
|
||||
if 0 == len(server):
|
||||
raise NotFound('Unable to find server with uuid %s' % self.machineIdentifier)
|
||||
return server[0]
|
||||
|
||||
def getMedia(self):
|
||||
server = self.server().connect()
|
||||
key = '/sync/items/%s' % self.id
|
||||
return server.fetchItems(key)
|
||||
|
||||
def markAsDone(self, sync_id):
|
||||
server = self.server().connect()
|
||||
url = '/sync/%s/%s/files/%s/downloaded' % (
|
||||
self._device.clientIdentifier, server.machineIdentifier, sync_id)
|
||||
server.query(url, method=requests.put)
|
363
lib/plexapi/utils.py
Normal file
363
lib/plexapi/utils.py
Normal file
@@ -0,0 +1,363 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import requests
|
||||
import time
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from getpass import getpass
|
||||
from threading import Thread
|
||||
from tqdm import tqdm
|
||||
from plexapi import compat
|
||||
from plexapi.exceptions import NotFound
|
||||
|
||||
# Search Types - Plex uses these to filter specific media types when searching.
|
||||
# Library Types - Populated at runtime
|
||||
SEARCHTYPES = {'movie': 1, 'show': 2, 'season': 3, 'episode': 4,
|
||||
'artist': 8, 'album': 9, 'track': 10, 'photo': 14}
|
||||
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:
|
||||
self.secrets.add(secret)
|
||||
return secret
|
||||
|
||||
def filter(self, record):
|
||||
cleanargs = list(record.args)
|
||||
for i in range(len(cleanargs)):
|
||||
if isinstance(cleanargs[i], compat.string_type):
|
||||
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 convery the XML into objects. See
|
||||
buildItem() below for an example.
|
||||
"""
|
||||
etype = getattr(cls, 'STREAMTYPE', cls.TYPE)
|
||||
ehash = '%s.%s' % (cls.TAG, etype) if etype else cls.TAG
|
||||
if ehash in PLEXOBJECTS:
|
||||
raise Exception('Ambiguous PlexObject definition %s(tag=%s, type=%s) with %s' %
|
||||
(cls.__name__, cls.TAG, etype, PLEXOBJECTS[ehash].__name__))
|
||||
PLEXOBJECTS[ehash] = cls
|
||||
return cls
|
||||
|
||||
|
||||
def cast(func, value):
|
||||
""" Cast the specified value to the specified type (returned by func). Currently this
|
||||
only support int, float, bool. Should be extended if needed.
|
||||
|
||||
Parameters:
|
||||
func (func): Calback function to used cast to type (int, bool, float).
|
||||
value (any): value to be cast and returned.
|
||||
"""
|
||||
if value is not None:
|
||||
if func == bool:
|
||||
return bool(int(value))
|
||||
elif func in (int, float):
|
||||
try:
|
||||
return func(value)
|
||||
except ValueError:
|
||||
return float('nan')
|
||||
return func(value)
|
||||
return 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 = compat.ustr(args[key])
|
||||
arglist.append('%s=%s' % (key, compat.quote(value)))
|
||||
return '?%s' % '&'.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 nexted tree of
|
||||
dicts, lists, tuples, functions, classes, etc. The lookup is done recursivley
|
||||
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 (movie, show, season, episode, artist, album, track)
|
||||
|
||||
Raises:
|
||||
NotFound: Unknown libtype
|
||||
"""
|
||||
libtype = compat.ustr(libtype)
|
||||
if libtype in [compat.ustr(v) for v in SEARCHTYPES.values()]:
|
||||
return libtype
|
||||
if SEARCHTYPES.get(libtype) is not None:
|
||||
return SEARCHTYPES[libtype]
|
||||
raise NotFound('Unknown libtype: %s' % libtype)
|
||||
|
||||
|
||||
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 = [], []
|
||||
for args in listargs:
|
||||
args += [results, len(results)]
|
||||
results.append(None)
|
||||
threads.append(Thread(target=callback, args=args))
|
||||
threads[-1].setDaemon(True)
|
||||
threads[-1].start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
return results
|
||||
|
||||
|
||||
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 and value is not None:
|
||||
if format:
|
||||
value = datetime.strptime(value, format)
|
||||
else:
|
||||
value = datetime.fromtimestamp(int(value))
|
||||
return value
|
||||
|
||||
|
||||
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 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 = '/library/parts/%s/indexes/%s/%s' % (part.id, part.indexes.lower(), media.viewOffset)
|
||||
if url:
|
||||
if filename is None:
|
||||
prettyname = media._prettyfilename()
|
||||
filename = 'session_transcode_%s_%s_%s' % (media.usernames[0], prettyname, int(time.time()))
|
||||
url = server.transcodeImage(url, height, width, opacity, saturation)
|
||||
filepath = download(url, filename=filename)
|
||||
info['username'] = {'filepath': filepath, 'url': url}
|
||||
return info
|
||||
|
||||
|
||||
def download(url, token, filename=None, savepath=None, session=None, chunksize=4024,
|
||||
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 evertything 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
|
||||
"""
|
||||
|
||||
from plexapi import log
|
||||
# fetch the data to be saved
|
||||
session = session or requests.Session()
|
||||
headers = {'X-Plex-Token': token}
|
||||
response = session.get(url, headers=headers, stream=True)
|
||||
# make sure the savepath directory exists
|
||||
savepath = savepath or os.getcwd()
|
||||
compat.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: # 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:
|
||||
bar.update(len(chunk))
|
||||
|
||||
if showstatus: # 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 tag_helper(tag, items, locked=True, remove=False):
|
||||
""" Simple tag helper for editing a object. """
|
||||
if not isinstance(items, list):
|
||||
items = [items]
|
||||
data = {}
|
||||
if not remove:
|
||||
for i, item in enumerate(items):
|
||||
tagname = '%s[%s].tag.tag' % (tag, i)
|
||||
data[tagname] = item
|
||||
if remove:
|
||||
tagname = '%s[].tag.tag-' % tag
|
||||
data[tagname] = ','.join(items)
|
||||
data['%s.locked' % tag] = 1 if locked else 0
|
||||
return data
|
||||
|
||||
|
||||
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('Authenticating with Plex.tv as %s..' % 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('Authenticating with Plex.tv as %s..' % config_username)
|
||||
return MyPlexAccount(config_username, config_password)
|
||||
# 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('Authenticating with Plex.tv as %s..' % username)
|
||||
return MyPlexAccount(username, password)
|
||||
|
||||
|
||||
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(' %s: %s' % (index, name))
|
||||
print()
|
||||
# Request choice from the user
|
||||
while True:
|
||||
try:
|
||||
inp = input('%s: ' % 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
|
560
lib/plexapi/video.py
Normal file
560
lib/plexapi/video.py
Normal file
@@ -0,0 +1,560 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from plexapi import media, utils
|
||||
from plexapi.exceptions import BadRequest, NotFound
|
||||
from plexapi.base import Playable, PlexPartialObject
|
||||
|
||||
|
||||
class Video(PlexPartialObject):
|
||||
""" Base class for all video objects including :class:`~plexapi.video.Movie`,
|
||||
:class:`~plexapi.video.Show`, :class:`~plexapi.video.Season`,
|
||||
:class:`~plexapi.video.Episode`.
|
||||
|
||||
Attributes:
|
||||
addedAt (datetime): Datetime this item was added to the library.
|
||||
key (str): API URL (/library/metadata/<ratingkey>).
|
||||
lastViewedAt (datetime): Datetime item was last accessed.
|
||||
librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID.
|
||||
listType (str): Hardcoded as 'audio' (useful for search filters).
|
||||
ratingKey (int): Unique key identifying this item.
|
||||
summary (str): Summary of the artist, track, or album.
|
||||
thumb (str): URL to thumbnail image.
|
||||
title (str): Artist, Album or Track title. (Jason Mraz, We Sing, Lucky, etc.)
|
||||
titleSort (str): Title to use when sorting (defaults to title).
|
||||
type (str): 'artist', 'album', or 'track'.
|
||||
updatedAt (datatime): Datetime this item was updated.
|
||||
viewCount (int): Count of times this item was accessed.
|
||||
"""
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
self._data = data
|
||||
self.listType = 'video'
|
||||
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
|
||||
self.key = data.attrib.get('key', '')
|
||||
self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt'))
|
||||
self.librarySectionID = data.attrib.get('librarySectionID')
|
||||
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.viewCount = utils.cast(int, data.attrib.get('viewCount', 0))
|
||||
|
||||
@property
|
||||
def isWatched(self):
|
||||
""" Returns True if this video is watched. """
|
||||
return bool(self.viewCount > 0) if self.viewCount else False
|
||||
|
||||
@property
|
||||
def thumbUrl(self):
|
||||
""" Return the first first thumbnail url starting on
|
||||
the most specific thumbnail for that item.
|
||||
"""
|
||||
thumb = self.firstAttr('thumb', 'parentThumb', 'granparentThumb')
|
||||
return self._server.url(thumb, includeToken=True) if thumb else None
|
||||
|
||||
@property
|
||||
def artUrl(self):
|
||||
""" Return the first first art url starting on the most specific for that item."""
|
||||
art = self.firstAttr('art', 'grandparentArt')
|
||||
return self._server.url(art, includeToken=True) if art else None
|
||||
|
||||
def url(self, part):
|
||||
""" Returns the full url for something. Typically used for getting a specific image. """
|
||||
return self._server.url(part, includeToken=True) if part else None
|
||||
|
||||
def markWatched(self):
|
||||
""" Mark video as watched. """
|
||||
key = '/:/scrobble?key=%s&identifier=com.plexapp.plugins.library' % self.ratingKey
|
||||
self._server.query(key)
|
||||
self.reload()
|
||||
|
||||
def markUnwatched(self):
|
||||
""" Mark video unwatched. """
|
||||
key = '/:/unscrobble?key=%s&identifier=com.plexapp.plugins.library' % self.ratingKey
|
||||
self._server.query(key)
|
||||
self.reload()
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Movie(Playable, Video):
|
||||
""" Represents a single Movie.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Video'
|
||||
TYPE (str): 'movie'
|
||||
art (str): Key to movie artwork (/library/metadata/<ratingkey>/art/<artid>)
|
||||
audienceRating (float): Audience rating (usually from Rotten Tomatoes).
|
||||
audienceRatingImage (str): Key to audience rating image (rottentomatoes://image.rating.spilled)
|
||||
chapterSource (str): Chapter source (agent; media; mixed).
|
||||
contentRating (str) Content rating (PG-13; NR; TV-G).
|
||||
duration (int): Duration of movie in milliseconds.
|
||||
guid: Plex GUID (com.plexapp.agents.imdb://tt4302938?lang=en).
|
||||
originalTitle (str): Original title, often the foreign title (転々; 엽기적인 그녀).
|
||||
originallyAvailableAt (datetime): Datetime movie was released.
|
||||
primaryExtraKey (str) Primary extra key (/library/metadata/66351).
|
||||
rating (float): Movie rating (7.9; 9.8; 8.1).
|
||||
ratingImage (str): Key to rating image (rottentomatoes://image.rating.rotten).
|
||||
studio (str): Studio that created movie (Di Bonaventura Pictures; 21 Laps Entertainment).
|
||||
tagline (str): Movie tag line (Back 2 Work; Who says men can't change?).
|
||||
userRating (float): User rating (2.0; 8.0).
|
||||
viewOffset (int): View offset in milliseconds.
|
||||
year (int): Year movie was released.
|
||||
collections (List<:class:`~plexapi.media.Collection`>): List of collections this media belongs.
|
||||
countries (List<:class:`~plexapi.media.Country`>): List of countries objects.
|
||||
directors (List<:class:`~plexapi.media.Director`>): List of director objects.
|
||||
fields (List<:class:`~plexapi.media.Field`>): List of field objects.
|
||||
genres (List<:class:`~plexapi.media.Genre`>): List of genre objects.
|
||||
media (List<:class:`~plexapi.media.Media`>): List of media objects.
|
||||
producers (List<:class:`~plexapi.media.Producer`>): List of producers objects.
|
||||
roles (List<:class:`~plexapi.media.Role`>): List of role objects.
|
||||
writers (List<:class:`~plexapi.media.Writer`>): List of writers objects.
|
||||
chapters (List<:class:`~plexapi.media.Chapter`>): List of Chapter objects.
|
||||
similar (List<:class:`~plexapi.media.Similar`>): List of Similar objects.
|
||||
"""
|
||||
TAG = 'Video'
|
||||
TYPE = 'movie'
|
||||
_include = ('?checkFiles=1&includeExtras=1&includeRelated=1'
|
||||
'&includeOnDeck=1&includeChapters=1&includePopularLeaves=1'
|
||||
'&includeConcerts=1&includePreferences=1')
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
Video._loadData(self, data)
|
||||
Playable._loadData(self, data)
|
||||
|
||||
self._details_key = self.key + self._include
|
||||
self.art = data.attrib.get('art')
|
||||
self.audienceRating = utils.cast(float, data.attrib.get('audienceRating'))
|
||||
self.audienceRatingImage = data.attrib.get('audienceRatingImage')
|
||||
self.chapterSource = data.attrib.get('chapterSource')
|
||||
self.contentRating = data.attrib.get('contentRating')
|
||||
self.duration = utils.cast(int, data.attrib.get('duration'))
|
||||
self.guid = data.attrib.get('guid')
|
||||
self.originalTitle = data.attrib.get('originalTitle')
|
||||
self.originallyAvailableAt = utils.toDatetime(
|
||||
data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
|
||||
self.primaryExtraKey = data.attrib.get('primaryExtraKey')
|
||||
self.rating = utils.cast(float, data.attrib.get('rating'))
|
||||
self.ratingImage = data.attrib.get('ratingImage')
|
||||
self.studio = data.attrib.get('studio')
|
||||
self.tagline = data.attrib.get('tagline')
|
||||
self.userRating = utils.cast(float, data.attrib.get('userRating'))
|
||||
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
|
||||
self.year = utils.cast(int, data.attrib.get('year'))
|
||||
self.collections = self.findItems(data, media.Collection)
|
||||
self.countries = self.findItems(data, media.Country)
|
||||
self.directors = self.findItems(data, media.Director)
|
||||
self.fields = self.findItems(data, media.Field)
|
||||
self.genres = self.findItems(data, media.Genre)
|
||||
self.media = self.findItems(data, media.Media)
|
||||
self.producers = self.findItems(data, media.Producer)
|
||||
self.roles = self.findItems(data, media.Role)
|
||||
self.writers = self.findItems(data, media.Writer)
|
||||
self.labels = self.findItems(data, media.Label)
|
||||
self.chapters = self.findItems(data, media.Chapter)
|
||||
self.similar = self.findItems(data, media.Similar)
|
||||
|
||||
@property
|
||||
def actors(self):
|
||||
""" Alias to self.roles. """
|
||||
return self.roles
|
||||
|
||||
@property
|
||||
def locations(self):
|
||||
""" This does not exist in plex xml response but is added to have a common
|
||||
interface to get the location of the Movie/Show/Episode
|
||||
"""
|
||||
return [part.file for part in self.iterParts() if part]
|
||||
|
||||
def subtitleStreams(self):
|
||||
""" Returns a list of :class:`~plexapi.media.SubtitleStream` objects for all MediaParts. """
|
||||
streams = []
|
||||
for elem in self.media:
|
||||
for part in elem.parts:
|
||||
streams += part.subtitleStreams()
|
||||
return streams
|
||||
|
||||
def _prettyfilename(self):
|
||||
# This is just for compat.
|
||||
return self.title
|
||||
|
||||
def download(self, savepath=None, keep_orginal_name=False, **kwargs):
|
||||
""" Download video files to specified directory.
|
||||
|
||||
Parameters:
|
||||
savepath (str): Defaults to current working dir.
|
||||
keep_orginal_name (bool): True to keep the original file name otherwise
|
||||
a friendlier is generated.
|
||||
**kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.getStreamURL()`.
|
||||
"""
|
||||
filepaths = []
|
||||
locations = [i for i in self.iterParts() if i]
|
||||
for location in locations:
|
||||
name = location.file
|
||||
if not keep_orginal_name:
|
||||
title = self.title.replace(' ', '.')
|
||||
name = '%s.%s' % (title, location.container)
|
||||
if kwargs is not None:
|
||||
url = self.getStreamURL(**kwargs)
|
||||
else:
|
||||
self._server.url('%s?download=1' % location.key)
|
||||
filepath = utils.download(url, self._server._token, filename=name,
|
||||
savepath=savepath, session=self._server._session)
|
||||
if filepath:
|
||||
filepaths.append(filepath)
|
||||
return filepaths
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Show(Video):
|
||||
""" Represents a single Show (including all seasons and episodes).
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Directory'
|
||||
TYPE (str): 'show'
|
||||
art (str): Key to show artwork (/library/metadata/<ratingkey>/art/<artid>)
|
||||
banner (str): Key to banner artwork (/library/metadata/<ratingkey>/art/<artid>)
|
||||
childCount (int): Unknown.
|
||||
contentRating (str) Content rating (PG-13; NR; TV-G).
|
||||
duration (int): Duration of show in milliseconds.
|
||||
guid (str): Plex GUID (com.plexapp.agents.imdb://tt4302938?lang=en).
|
||||
index (int): Plex index (?)
|
||||
leafCount (int): Unknown.
|
||||
locations (list<str>): List of locations paths.
|
||||
originallyAvailableAt (datetime): Datetime show was released.
|
||||
rating (float): Show rating (7.9; 9.8; 8.1).
|
||||
studio (str): Studio that created show (Di Bonaventura Pictures; 21 Laps Entertainment).
|
||||
theme (str): Key to theme resource (/library/metadata/<ratingkey>/theme/<themeid>)
|
||||
viewedLeafCount (int): Unknown.
|
||||
year (int): Year the show was released.
|
||||
genres (List<:class:`~plexapi.media.Genre`>): List of genre objects.
|
||||
roles (List<:class:`~plexapi.media.Role`>): List of role objects.
|
||||
similar (List<:class:`~plexapi.media.Similar`>): List of Similar objects.
|
||||
"""
|
||||
TAG = 'Directory'
|
||||
TYPE = 'show'
|
||||
|
||||
def __iter__(self):
|
||||
for season in self.seasons():
|
||||
yield season
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
Video._loadData(self, data)
|
||||
# fix key if loaded from search
|
||||
self.key = self.key.replace('/children', '')
|
||||
self.art = data.attrib.get('art')
|
||||
self.banner = data.attrib.get('banner')
|
||||
self.childCount = utils.cast(int, data.attrib.get('childCount'))
|
||||
self.contentRating = data.attrib.get('contentRating')
|
||||
self.duration = utils.cast(int, data.attrib.get('duration'))
|
||||
self.guid = data.attrib.get('guid')
|
||||
self.index = data.attrib.get('index')
|
||||
self.leafCount = utils.cast(int, data.attrib.get('leafCount'))
|
||||
self.locations = self.listAttrs(data, 'path', etag='Location')
|
||||
self.originallyAvailableAt = utils.toDatetime(
|
||||
data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
|
||||
self.rating = utils.cast(float, data.attrib.get('rating'))
|
||||
self.studio = data.attrib.get('studio')
|
||||
self.theme = data.attrib.get('theme')
|
||||
self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount'))
|
||||
self.year = utils.cast(int, data.attrib.get('year'))
|
||||
self.genres = self.findItems(data, media.Genre)
|
||||
self.roles = self.findItems(data, media.Role)
|
||||
self.labels = self.findItems(data, media.Label)
|
||||
self.similar = self.findItems(data, media.Similar)
|
||||
|
||||
@property
|
||||
def actors(self):
|
||||
""" Alias to self.roles. """
|
||||
return self.roles
|
||||
|
||||
@property
|
||||
def isWatched(self):
|
||||
""" Returns True if this show is fully watched. """
|
||||
return bool(self.viewedLeafCount == self.leafCount)
|
||||
|
||||
def seasons(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.video.Season` objects. """
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
return self.fetchItems(key, **kwargs)
|
||||
|
||||
def season(self, title=None):
|
||||
""" Returns the season with the specified title or number.
|
||||
|
||||
Parameters:
|
||||
title (str or int): Title or Number of the season to return.
|
||||
"""
|
||||
if isinstance(title, int):
|
||||
title = 'Season %s' % title
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
return self.fetchItem(key, etag='Directory', title__iexact=title)
|
||||
|
||||
def episodes(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.video.Episode` objects. """
|
||||
key = '/library/metadata/%s/allLeaves' % self.ratingKey
|
||||
return self.fetchItems(key, **kwargs)
|
||||
|
||||
def episode(self, title=None, season=None, episode=None):
|
||||
""" Find a episode using a title or season and episode.
|
||||
|
||||
Parameters:
|
||||
title (str): Title of the episode to return
|
||||
season (int): Season number (default:None; required if title not specified).
|
||||
episode (int): Episode number (default:None; required if title not specified).
|
||||
|
||||
Raises:
|
||||
BadRequest: If season and episode is missing.
|
||||
NotFound: If the episode is missing.
|
||||
"""
|
||||
if title:
|
||||
key = '/library/metadata/%s/allLeaves' % self.ratingKey
|
||||
return self.fetchItem(key, title__iexact=title)
|
||||
elif season and episode:
|
||||
results = [i for i in self.episodes() if i.seasonNumber == season and i.index == episode]
|
||||
if results:
|
||||
return results[0]
|
||||
raise NotFound('Couldnt find %s S%s E%s' % (self.title, season, episode))
|
||||
raise BadRequest('Missing argument: title or season and episode are required')
|
||||
|
||||
def watched(self):
|
||||
""" Returns list of watched :class:`~plexapi.video.Episode` objects. """
|
||||
return self.episodes(viewCount__gt=0)
|
||||
|
||||
def unwatched(self):
|
||||
""" Returns list of unwatched :class:`~plexapi.video.Episode` objects. """
|
||||
return self.episodes(viewCount=0)
|
||||
|
||||
def get(self, title=None, season=None, episode=None):
|
||||
""" Alias to :func:`~plexapi.video.Show.episode()`. """
|
||||
return self.episode(title, season, episode)
|
||||
|
||||
def download(self, savepath=None, keep_orginal_name=False, **kwargs):
|
||||
""" Download video files to specified directory.
|
||||
|
||||
Parameters:
|
||||
savepath (str): Defaults to current working dir.
|
||||
keep_orginal_name (bool): True to keep the original file name otherwise
|
||||
a friendlier is generated.
|
||||
**kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.getStreamURL()`.
|
||||
"""
|
||||
filepaths = []
|
||||
for episode in self.episodes():
|
||||
filepaths += episode.download(savepath, keep_orginal_name, **kwargs)
|
||||
return filepaths
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Season(Video):
|
||||
""" Represents a single Show Season (including all episodes).
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Directory'
|
||||
TYPE (str): 'season'
|
||||
leafCount (int): Number of episodes in season.
|
||||
index (int): Season number.
|
||||
parentKey (str): Key to this seasons :class:`~plexapi.video.Show`.
|
||||
parentRatingKey (int): Unique key for this seasons :class:`~plexapi.video.Show`.
|
||||
parentTitle (str): Title of this seasons :class:`~plexapi.video.Show`.
|
||||
viewedLeafCount (int): Number of watched episodes in season.
|
||||
"""
|
||||
TAG = 'Directory'
|
||||
TYPE = 'season'
|
||||
|
||||
def __iter__(self):
|
||||
for episode in self.episodes():
|
||||
yield episode
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
Video._loadData(self, data)
|
||||
# fix key if loaded from search
|
||||
self.key = self.key.replace('/children', '')
|
||||
self.leafCount = utils.cast(int, data.attrib.get('leafCount'))
|
||||
self.index = utils.cast(int, data.attrib.get('index'))
|
||||
self.parentKey = data.attrib.get('parentKey')
|
||||
self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey'))
|
||||
self.parentTitle = data.attrib.get('parentTitle')
|
||||
self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount'))
|
||||
|
||||
def __repr__(self):
|
||||
return '<%s>' % ':'.join([p for p in [
|
||||
self.__class__.__name__,
|
||||
self.key.replace('/library/metadata/', '').replace('/children', ''),
|
||||
'%s-s%s' % (self.parentTitle.replace(' ', '-')[:20], self.seasonNumber),
|
||||
] if p])
|
||||
|
||||
@property
|
||||
def isWatched(self):
|
||||
""" Returns True if this season is fully watched. """
|
||||
return bool(self.viewedLeafCount == self.leafCount)
|
||||
|
||||
@property
|
||||
def seasonNumber(self):
|
||||
""" Returns season number. """
|
||||
return self.index
|
||||
|
||||
def episodes(self, **kwargs):
|
||||
""" Returns a list of :class:`~plexapi.video.Episode` objects. """
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
return self.fetchItems(key, **kwargs)
|
||||
|
||||
def episode(self, title=None, episode=None):
|
||||
""" Returns the episode with the given title or number.
|
||||
|
||||
Parameters:
|
||||
title (str): Title of the episode to return.
|
||||
episode (int): Episode number (default:None; required if title not specified).
|
||||
"""
|
||||
if not title and not episode:
|
||||
raise BadRequest('Missing argument, you need to use title or episode.')
|
||||
key = '/library/metadata/%s/children' % self.ratingKey
|
||||
if title:
|
||||
return self.fetchItem(key, title=title)
|
||||
return self.fetchItem(key, seasonNumber=self.index, index=episode)
|
||||
|
||||
def get(self, title=None, episode=None):
|
||||
""" Alias to :func:`~plexapi.video.Season.episode()`. """
|
||||
return self.episode(title, episode)
|
||||
|
||||
def show(self):
|
||||
""" Return this seasons :func:`~plexapi.video.Show`.. """
|
||||
return self.fetchItem(self.parentKey)
|
||||
|
||||
def watched(self):
|
||||
""" Returns list of watched :class:`~plexapi.video.Episode` objects. """
|
||||
return self.episodes(watched=True)
|
||||
|
||||
def unwatched(self):
|
||||
""" Returns list of unwatched :class:`~plexapi.video.Episode` objects. """
|
||||
return self.episodes(watched=False)
|
||||
|
||||
def download(self, savepath=None, keep_orginal_name=False, **kwargs):
|
||||
""" Download video files to specified directory.
|
||||
|
||||
Parameters:
|
||||
savepath (str): Defaults to current working dir.
|
||||
keep_orginal_name (bool): True to keep the original file name otherwise
|
||||
a friendlier is generated.
|
||||
**kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.getStreamURL()`.
|
||||
"""
|
||||
filepaths = []
|
||||
for episode in self.episodes():
|
||||
filepaths += episode.download(savepath, keep_orginal_name, **kwargs)
|
||||
return filepaths
|
||||
|
||||
|
||||
@utils.registerPlexObject
|
||||
class Episode(Playable, Video):
|
||||
""" Represents a single Shows Episode.
|
||||
|
||||
Attributes:
|
||||
TAG (str): 'Video'
|
||||
TYPE (str): 'episode'
|
||||
art (str): Key to episode artwork (/library/metadata/<ratingkey>/art/<artid>)
|
||||
chapterSource (str): Unknown (media).
|
||||
contentRating (str) Content rating (PG-13; NR; TV-G).
|
||||
duration (int): Duration of episode in milliseconds.
|
||||
grandparentArt (str): Key to this episodes :class:`~plexapi.video.Show` artwork.
|
||||
grandparentKey (str): Key to this episodes :class:`~plexapi.video.Show`.
|
||||
grandparentRatingKey (str): Unique key for this episodes :class:`~plexapi.video.Show`.
|
||||
grandparentTheme (str): Key to this episodes :class:`~plexapi.video.Show` theme.
|
||||
grandparentThumb (str): Key to this episodes :class:`~plexapi.video.Show` thumb.
|
||||
grandparentTitle (str): Title of this episodes :class:`~plexapi.video.Show`.
|
||||
guid (str): Plex GUID (com.plexapp.agents.imdb://tt4302938?lang=en).
|
||||
index (int): Episode number.
|
||||
originallyAvailableAt (datetime): Datetime episode was released.
|
||||
parentIndex (str): Season number of episode.
|
||||
parentKey (str): Key to this episodes :class:`~plexapi.video.Season`.
|
||||
parentRatingKey (int): Unique key for this episodes :class:`~plexapi.video.Season`.
|
||||
parentThumb (str): Key to this episodes thumbnail.
|
||||
parentTitle (str): Name of this episode's season
|
||||
title (str): Name of this Episode
|
||||
rating (float): Movie rating (7.9; 9.8; 8.1).
|
||||
viewOffset (int): View offset in milliseconds.
|
||||
year (int): Year episode was released.
|
||||
directors (List<:class:`~plexapi.media.Director`>): List of director objects.
|
||||
media (List<:class:`~plexapi.media.Media`>): List of media objects.
|
||||
writers (List<:class:`~plexapi.media.Writer`>): List of writers objects.
|
||||
"""
|
||||
TAG = 'Video'
|
||||
TYPE = 'episode'
|
||||
_include = ('?checkFiles=1&includeExtras=1&includeRelated=1'
|
||||
'&includeOnDeck=1&includeChapters=1&includePopularLeaves=1'
|
||||
'&includeConcerts=1&includePreferences=1')
|
||||
|
||||
def _loadData(self, data):
|
||||
""" Load attribute values from Plex XML response. """
|
||||
Video._loadData(self, data)
|
||||
Playable._loadData(self, data)
|
||||
self._details_key = self.key + self._include
|
||||
self._seasonNumber = None # cached season number
|
||||
self.art = data.attrib.get('art')
|
||||
self.chapterSource = data.attrib.get('chapterSource')
|
||||
self.contentRating = data.attrib.get('contentRating')
|
||||
self.duration = utils.cast(int, data.attrib.get('duration'))
|
||||
self.grandparentArt = data.attrib.get('grandparentArt')
|
||||
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.guid = data.attrib.get('guid')
|
||||
self.index = utils.cast(int, data.attrib.get('index'))
|
||||
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
|
||||
self.parentIndex = 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.title = data.attrib.get('title')
|
||||
self.rating = utils.cast(float, data.attrib.get('rating'))
|
||||
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
|
||||
self.year = utils.cast(int, data.attrib.get('year'))
|
||||
self.directors = self.findItems(data, media.Director)
|
||||
self.media = self.findItems(data, media.Media)
|
||||
self.writers = self.findItems(data, media.Writer)
|
||||
self.labels = self.findItems(data, media.Label)
|
||||
self.collections = self.findItems(data, media.Collection)
|
||||
self.chapters = self.findItems(data, media.Chapter)
|
||||
|
||||
def __repr__(self):
|
||||
return '<%s>' % ':'.join([p for p in [
|
||||
self.__class__.__name__,
|
||||
self.key.replace('/library/metadata/', '').replace('/children', ''),
|
||||
'%s-%s' % (self.grandparentTitle.replace(' ', '-')[:20], self.seasonEpisode),
|
||||
] if p])
|
||||
|
||||
def _prettyfilename(self):
|
||||
""" Returns a human friendly filename. """
|
||||
return '%s.%s' % (self.grandparentTitle.replace(' ', '.'), self.seasonEpisode)
|
||||
|
||||
@property
|
||||
def locations(self):
|
||||
""" This does not exist in plex xml response but is added to have a common
|
||||
interface to get the location of the Movie/Show
|
||||
"""
|
||||
return [part.file for part in self.iterParts() if part]
|
||||
|
||||
@property
|
||||
def seasonNumber(self):
|
||||
""" Returns this episodes season number. """
|
||||
if self._seasonNumber is None:
|
||||
self._seasonNumber = self.parentIndex if self.parentIndex else self.season().seasonNumber
|
||||
return utils.cast(int, self._seasonNumber)
|
||||
|
||||
@property
|
||||
def seasonEpisode(self):
|
||||
""" Returns the s00e00 string containing the season and episode. """
|
||||
return 's%se%s' % (str(self.seasonNumber).zfill(2), str(self.index).zfill(2))
|
||||
|
||||
def season(self):
|
||||
"""" Return this episodes :func:`~plexapi.video.Season`.. """
|
||||
return self.fetchItem(self.parentKey)
|
||||
|
||||
def show(self):
|
||||
"""" Return this episodes :func:`~plexapi.video.Show`.. """
|
||||
return self.fetchItem(self.grandparentKey)
|
34
lib/tqdm/__init__.py
Normal file
34
lib/tqdm/__init__.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from ._tqdm import tqdm
|
||||
from ._tqdm import trange
|
||||
from ._tqdm_gui import tqdm_gui
|
||||
from ._tqdm_gui import tgrange
|
||||
from ._tqdm_pandas import tqdm_pandas
|
||||
from ._main import main
|
||||
from ._monitor import TMonitor, TqdmSynchronisationWarning
|
||||
from ._version import __version__ # NOQA
|
||||
from ._tqdm import TqdmTypeError, TqdmKeyError, TqdmWarning, \
|
||||
TqdmDeprecationWarning, TqdmExperimentalWarning, \
|
||||
TqdmMonitorWarning
|
||||
|
||||
__all__ = ['tqdm', 'tqdm_gui', 'trange', 'tgrange', 'tqdm_pandas',
|
||||
'tqdm_notebook', 'tnrange', 'main', 'TMonitor',
|
||||
'TqdmTypeError', 'TqdmKeyError',
|
||||
'TqdmWarning', 'TqdmDeprecationWarning',
|
||||
'TqdmExperimentalWarning',
|
||||
'TqdmMonitorWarning', 'TqdmSynchronisationWarning',
|
||||
'__version__']
|
||||
|
||||
|
||||
def tqdm_notebook(*args, **kwargs): # pragma: no cover
|
||||
"""See tqdm._tqdm_notebook.tqdm_notebook for full documentation"""
|
||||
from ._tqdm_notebook import tqdm_notebook as _tqdm_notebook
|
||||
return _tqdm_notebook(*args, **kwargs)
|
||||
|
||||
|
||||
def tnrange(*args, **kwargs): # pragma: no cover
|
||||
"""
|
||||
A shortcut for tqdm_notebook(xrange(*args), **kwargs).
|
||||
On Python3+ range is used instead of xrange.
|
||||
"""
|
||||
from ._tqdm_notebook import tnrange as _tnrange
|
||||
return _tnrange(*args, **kwargs)
|
2
lib/tqdm/__main__.py
Normal file
2
lib/tqdm/__main__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from ._main import main
|
||||
main()
|
207
lib/tqdm/_main.py
Normal file
207
lib/tqdm/_main.py
Normal file
@@ -0,0 +1,207 @@
|
||||
from ._tqdm import tqdm, TqdmTypeError, TqdmKeyError
|
||||
from ._version import __version__ # NOQA
|
||||
import sys
|
||||
import re
|
||||
import logging
|
||||
__all__ = ["main"]
|
||||
|
||||
|
||||
def cast(val, typ):
|
||||
log = logging.getLogger(__name__)
|
||||
log.debug((val, typ))
|
||||
if " or " in typ:
|
||||
for t in typ.split(" or "):
|
||||
try:
|
||||
return cast(val, t)
|
||||
except TqdmTypeError:
|
||||
pass
|
||||
raise TqdmTypeError(val + ' : ' + typ)
|
||||
|
||||
# sys.stderr.write('\ndebug | `val:type`: `' + val + ':' + typ + '`.\n')
|
||||
if typ == 'bool':
|
||||
if (val == 'True') or (val == ''):
|
||||
return True
|
||||
elif val == 'False':
|
||||
return False
|
||||
else:
|
||||
raise TqdmTypeError(val + ' : ' + typ)
|
||||
try:
|
||||
return eval(typ + '("' + val + '")')
|
||||
except:
|
||||
if typ == 'chr':
|
||||
return chr(ord(eval('"' + val + '"')))
|
||||
else:
|
||||
raise TqdmTypeError(val + ' : ' + typ)
|
||||
|
||||
|
||||
def posix_pipe(fin, fout, delim='\n', buf_size=256,
|
||||
callback=lambda int: None # pragma: no cover
|
||||
):
|
||||
"""
|
||||
Params
|
||||
------
|
||||
fin : file with `read(buf_size : int)` method
|
||||
fout : file with `write` (and optionally `flush`) methods.
|
||||
callback : function(int), e.g.: `tqdm.update`
|
||||
"""
|
||||
fp_write = fout.write
|
||||
|
||||
# tmp = ''
|
||||
if not delim:
|
||||
while True:
|
||||
tmp = fin.read(buf_size)
|
||||
|
||||
# flush at EOF
|
||||
if not tmp:
|
||||
getattr(fout, 'flush', lambda: None)() # pragma: no cover
|
||||
return
|
||||
|
||||
fp_write(tmp)
|
||||
callback(len(tmp))
|
||||
# return
|
||||
|
||||
buf = ''
|
||||
# n = 0
|
||||
while True:
|
||||
tmp = fin.read(buf_size)
|
||||
|
||||
# flush at EOF
|
||||
if not tmp:
|
||||
if buf:
|
||||
fp_write(buf)
|
||||
callback(1 + buf.count(delim)) # n += 1 + buf.count(delim)
|
||||
getattr(fout, 'flush', lambda: None)() # pragma: no cover
|
||||
return # n
|
||||
|
||||
while True:
|
||||
try:
|
||||
i = tmp.index(delim)
|
||||
except ValueError:
|
||||
buf += tmp
|
||||
break
|
||||
else:
|
||||
fp_write(buf + tmp[:i + len(delim)])
|
||||
callback(1) # n += 1
|
||||
buf = ''
|
||||
tmp = tmp[i + len(delim):]
|
||||
|
||||
|
||||
# ((opt, type), ... )
|
||||
RE_OPTS = re.compile(r'\n {8}(\S+)\s{2,}:\s*([^,]+)')
|
||||
# better split method assuming no positional args
|
||||
RE_SHLEX = re.compile(r'\s*(?<!\S)--?([^\s=]+)(?:\s*|=|$)')
|
||||
|
||||
# TODO: add custom support for some of the following?
|
||||
UNSUPPORTED_OPTS = ('iterable', 'gui', 'out', 'file')
|
||||
|
||||
# The 8 leading spaces are required for consistency
|
||||
CLI_EXTRA_DOC = r"""
|
||||
Extra CLI Options
|
||||
-----------------
|
||||
name : type, optional
|
||||
TODO: find out why this is needed.
|
||||
delim : chr, optional
|
||||
Delimiting character [default: '\n']. Use '\0' for null.
|
||||
N.B.: on Windows systems, Python converts '\n' to '\r\n'.
|
||||
buf_size : int, optional
|
||||
String buffer size in bytes [default: 256]
|
||||
used when `delim` is specified.
|
||||
bytes : bool, optional
|
||||
If true, will count bytes, ignore `delim`, and default
|
||||
`unit_scale` to True, `unit_divisor` to 1024, and `unit` to 'B'.
|
||||
log : str, optional
|
||||
CRITICAL|FATAL|ERROR|WARN(ING)|[default: 'INFO']|DEBUG|NOTSET.
|
||||
"""
|
||||
|
||||
|
||||
def main(fp=sys.stderr):
|
||||
"""
|
||||
Paramters (internal use only)
|
||||
---------
|
||||
fp : file-like object for tqdm
|
||||
"""
|
||||
try:
|
||||
log = sys.argv.index('--log')
|
||||
except ValueError:
|
||||
logLevel = 'INFO'
|
||||
else:
|
||||
# sys.argv.pop(log)
|
||||
# logLevel = sys.argv.pop(log)
|
||||
logLevel = sys.argv[log + 1]
|
||||
logging.basicConfig(level=getattr(logging, logLevel),
|
||||
format="%(levelname)s:%(module)s:%(lineno)d:%(message)s")
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
d = tqdm.__init__.__doc__ + CLI_EXTRA_DOC
|
||||
|
||||
opt_types = dict(RE_OPTS.findall(d))
|
||||
# opt_types['delim'] = 'chr'
|
||||
|
||||
for o in UNSUPPORTED_OPTS:
|
||||
opt_types.pop(o)
|
||||
|
||||
log.debug(sorted(opt_types.items()))
|
||||
|
||||
# d = RE_OPTS.sub(r' --\1=<\1> : \2', d)
|
||||
split = RE_OPTS.split(d)
|
||||
opt_types_desc = zip(split[1::3], split[2::3], split[3::3])
|
||||
d = ''.join('\n --{0}=<{0}> : {1}{2}'.format(*otd)
|
||||
for otd in opt_types_desc if otd[0] not in UNSUPPORTED_OPTS)
|
||||
|
||||
d = """Usage:
|
||||
tqdm [--help | options]
|
||||
|
||||
Options:
|
||||
-h, --help Print this help and exit
|
||||
-v, --version Print version and exit
|
||||
|
||||
""" + d.strip('\n') + '\n'
|
||||
|
||||
# opts = docopt(d, version=__version__)
|
||||
if any(v in sys.argv for v in ('-v', '--version')):
|
||||
sys.stdout.write(__version__ + '\n')
|
||||
sys.exit(0)
|
||||
elif any(v in sys.argv for v in ('-h', '--help')):
|
||||
sys.stdout.write(d + '\n')
|
||||
sys.exit(0)
|
||||
|
||||
argv = RE_SHLEX.split(' '.join(["tqdm"] + sys.argv[1:]))
|
||||
opts = dict(zip(argv[1::2], argv[2::2]))
|
||||
|
||||
log.debug(opts)
|
||||
opts.pop('log', True)
|
||||
|
||||
tqdm_args = {'file': fp}
|
||||
try:
|
||||
for (o, v) in opts.items():
|
||||
try:
|
||||
tqdm_args[o] = cast(v, opt_types[o])
|
||||
except KeyError as e:
|
||||
raise TqdmKeyError(str(e))
|
||||
log.debug('args:' + str(tqdm_args))
|
||||
except:
|
||||
fp.write('\nError:\nUsage:\n tqdm [--help | options]\n')
|
||||
for i in sys.stdin:
|
||||
sys.stdout.write(i)
|
||||
raise
|
||||
else:
|
||||
buf_size = tqdm_args.pop('buf_size', 256)
|
||||
delim = tqdm_args.pop('delim', '\n')
|
||||
delim_per_char = tqdm_args.pop('bytes', False)
|
||||
if delim_per_char:
|
||||
tqdm_args.setdefault('unit', 'B')
|
||||
tqdm_args.setdefault('unit_scale', True)
|
||||
tqdm_args.setdefault('unit_divisor', 1024)
|
||||
log.debug(tqdm_args)
|
||||
with tqdm(**tqdm_args) as t:
|
||||
posix_pipe(sys.stdin, sys.stdout,
|
||||
'', buf_size, t.update)
|
||||
elif delim == '\n':
|
||||
log.debug(tqdm_args)
|
||||
for i in tqdm(sys.stdin, **tqdm_args):
|
||||
sys.stdout.write(i)
|
||||
else:
|
||||
log.debug(tqdm_args)
|
||||
with tqdm(**tqdm_args) as t:
|
||||
posix_pipe(sys.stdin, sys.stdout,
|
||||
delim, buf_size, t.update)
|
93
lib/tqdm/_monitor.py
Normal file
93
lib/tqdm/_monitor.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from threading import Event, Thread
|
||||
from time import time
|
||||
from warnings import warn
|
||||
__all__ = ["TMonitor", "TqdmSynchronisationWarning"]
|
||||
|
||||
|
||||
class TqdmSynchronisationWarning(RuntimeWarning):
|
||||
"""tqdm multi-thread/-process errors which may cause incorrect nesting
|
||||
but otherwise no adverse effects"""
|
||||
pass
|
||||
|
||||
|
||||
class TMonitor(Thread):
|
||||
"""
|
||||
Monitoring thread for tqdm bars.
|
||||
Monitors if tqdm bars are taking too much time to display
|
||||
and readjusts miniters automatically if necessary.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
tqdm_cls : class
|
||||
tqdm class to use (can be core tqdm or a submodule).
|
||||
sleep_interval : fload
|
||||
Time to sleep between monitoring checks.
|
||||
"""
|
||||
|
||||
# internal vars for unit testing
|
||||
_time = None
|
||||
_event = None
|
||||
|
||||
def __init__(self, tqdm_cls, sleep_interval):
|
||||
Thread.__init__(self)
|
||||
self.daemon = True # kill thread when main killed (KeyboardInterrupt)
|
||||
self.was_killed = Event()
|
||||
self.woken = 0 # last time woken up, to sync with monitor
|
||||
self.tqdm_cls = tqdm_cls
|
||||
self.sleep_interval = sleep_interval
|
||||
if TMonitor._time is not None:
|
||||
self._time = TMonitor._time
|
||||
else:
|
||||
self._time = time
|
||||
if TMonitor._event is not None:
|
||||
self._event = TMonitor._event
|
||||
else:
|
||||
self._event = Event
|
||||
self.start()
|
||||
|
||||
def exit(self):
|
||||
self.was_killed.set()
|
||||
self.join()
|
||||
return self.report()
|
||||
|
||||
def run(self):
|
||||
cur_t = self._time()
|
||||
while True:
|
||||
# After processing and before sleeping, notify that we woke
|
||||
# Need to be done just before sleeping
|
||||
self.woken = cur_t
|
||||
# Sleep some time...
|
||||
self.was_killed.wait(self.sleep_interval)
|
||||
# Quit if killed
|
||||
if self.was_killed.is_set():
|
||||
return
|
||||
# Then monitor!
|
||||
# Acquire lock (to access _instances)
|
||||
with self.tqdm_cls.get_lock():
|
||||
cur_t = self._time()
|
||||
# Check tqdm instances are waiting too long to print
|
||||
instances = self.tqdm_cls._instances.copy()
|
||||
for instance in instances:
|
||||
# Check event in loop to reduce blocking time on exit
|
||||
if self.was_killed.is_set():
|
||||
return
|
||||
# Avoid race by checking that the instance started
|
||||
if not hasattr(instance, 'start_t'): # pragma: nocover
|
||||
continue
|
||||
# Only if mininterval > 1 (else iterations are just slow)
|
||||
# and last refresh exceeded maxinterval
|
||||
if instance.miniters > 1 and \
|
||||
(cur_t - instance.last_print_t) >= \
|
||||
instance.maxinterval:
|
||||
# force bypassing miniters on next iteration
|
||||
# (dynamic_miniters adjusts mininterval automatically)
|
||||
instance.miniters = 1
|
||||
# Refresh now! (works only for manual tqdm)
|
||||
instance.refresh(nolock=True)
|
||||
if instances != self.tqdm_cls._instances: # pragma: nocover
|
||||
warn("Set changed size during iteration" +
|
||||
" (see https://github.com/tqdm/tqdm/issues/481)",
|
||||
TqdmSynchronisationWarning)
|
||||
|
||||
def report(self):
|
||||
return not self.was_killed.is_set()
|
1223
lib/tqdm/_tqdm.py
Normal file
1223
lib/tqdm/_tqdm.py
Normal file
File diff suppressed because it is too large
Load Diff
351
lib/tqdm/_tqdm_gui.py
Normal file
351
lib/tqdm/_tqdm_gui.py
Normal file
@@ -0,0 +1,351 @@
|
||||
"""
|
||||
GUI progressbar decorator for iterators.
|
||||
Includes a default (x)range iterator printing to stderr.
|
||||
|
||||
Usage:
|
||||
>>> from tqdm_gui import tgrange[, tqdm_gui]
|
||||
>>> for i in tgrange(10): #same as: for i in tqdm_gui(xrange(10))
|
||||
... ...
|
||||
"""
|
||||
# future division is important to divide integers and get as
|
||||
# a result precise floating numbers (instead of truncated int)
|
||||
from __future__ import division, absolute_import
|
||||
# import compatibility functions and utilities
|
||||
# import sys
|
||||
from time import time
|
||||
from ._utils import _range
|
||||
# to inherit from the tqdm class
|
||||
from ._tqdm import tqdm, TqdmExperimentalWarning
|
||||
from warnings import warn
|
||||
|
||||
|
||||
__author__ = {"github.com/": ["casperdcl", "lrq3000"]}
|
||||
__all__ = ['tqdm_gui', 'tgrange']
|
||||
|
||||
|
||||
class tqdm_gui(tqdm): # pragma: no cover
|
||||
"""
|
||||
Experimental GUI version of tqdm!
|
||||
"""
|
||||
|
||||
# TODO: @classmethod: write() on GUI?
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
import matplotlib as mpl
|
||||
import matplotlib.pyplot as plt
|
||||
from collections import deque
|
||||
kwargs['gui'] = True
|
||||
|
||||
super(tqdm_gui, self).__init__(*args, **kwargs)
|
||||
|
||||
# Initialize the GUI display
|
||||
if self.disable or not kwargs['gui']:
|
||||
return
|
||||
|
||||
warn('GUI is experimental/alpha', TqdmExperimentalWarning)
|
||||
self.mpl = mpl
|
||||
self.plt = plt
|
||||
self.sp = None
|
||||
|
||||
# Remember if external environment uses toolbars
|
||||
self.toolbar = self.mpl.rcParams['toolbar']
|
||||
self.mpl.rcParams['toolbar'] = 'None'
|
||||
|
||||
self.mininterval = max(self.mininterval, 0.5)
|
||||
self.fig, ax = plt.subplots(figsize=(9, 2.2))
|
||||
# self.fig.subplots_adjust(bottom=0.2)
|
||||
if self.total:
|
||||
self.xdata = []
|
||||
self.ydata = []
|
||||
self.zdata = []
|
||||
else:
|
||||
self.xdata = deque([])
|
||||
self.ydata = deque([])
|
||||
self.zdata = deque([])
|
||||
self.line1, = ax.plot(self.xdata, self.ydata, color='b')
|
||||
self.line2, = ax.plot(self.xdata, self.zdata, color='k')
|
||||
ax.set_ylim(0, 0.001)
|
||||
if self.total:
|
||||
ax.set_xlim(0, 100)
|
||||
ax.set_xlabel('percent')
|
||||
self.fig.legend((self.line1, self.line2), ('cur', 'est'),
|
||||
loc='center right')
|
||||
# progressbar
|
||||
self.hspan = plt.axhspan(0, 0.001,
|
||||
xmin=0, xmax=0, color='g')
|
||||
else:
|
||||
# ax.set_xlim(-60, 0)
|
||||
ax.set_xlim(0, 60)
|
||||
ax.invert_xaxis()
|
||||
ax.set_xlabel('seconds')
|
||||
ax.legend(('cur', 'est'), loc='lower left')
|
||||
ax.grid()
|
||||
# ax.set_xlabel('seconds')
|
||||
ax.set_ylabel((self.unit if self.unit else 'it') + '/s')
|
||||
if self.unit_scale:
|
||||
plt.ticklabel_format(style='sci', axis='y',
|
||||
scilimits=(0, 0))
|
||||
ax.yaxis.get_offset_text().set_x(-0.15)
|
||||
|
||||
# Remember if external environment is interactive
|
||||
self.wasion = plt.isinteractive()
|
||||
plt.ion()
|
||||
self.ax = ax
|
||||
|
||||
def __iter__(self):
|
||||
# TODO: somehow allow the following:
|
||||
# if not self.gui:
|
||||
# return super(tqdm_gui, self).__iter__()
|
||||
iterable = self.iterable
|
||||
if self.disable:
|
||||
for obj in iterable:
|
||||
yield obj
|
||||
return
|
||||
|
||||
# ncols = self.ncols
|
||||
mininterval = self.mininterval
|
||||
maxinterval = self.maxinterval
|
||||
miniters = self.miniters
|
||||
dynamic_miniters = self.dynamic_miniters
|
||||
unit = self.unit
|
||||
unit_scale = self.unit_scale
|
||||
ascii = self.ascii
|
||||
start_t = self.start_t
|
||||
last_print_t = self.last_print_t
|
||||
last_print_n = self.last_print_n
|
||||
n = self.n
|
||||
# dynamic_ncols = self.dynamic_ncols
|
||||
smoothing = self.smoothing
|
||||
avg_time = self.avg_time
|
||||
bar_format = self.bar_format
|
||||
|
||||
plt = self.plt
|
||||
ax = self.ax
|
||||
xdata = self.xdata
|
||||
ydata = self.ydata
|
||||
zdata = self.zdata
|
||||
line1 = self.line1
|
||||
line2 = self.line2
|
||||
|
||||
for obj in iterable:
|
||||
yield obj
|
||||
# Update and print the progressbar.
|
||||
# Note: does not call self.update(1) for speed optimisation.
|
||||
n += 1
|
||||
delta_it = n - last_print_n
|
||||
# check the counter first (avoid calls to time())
|
||||
if delta_it >= miniters:
|
||||
cur_t = time()
|
||||
delta_t = cur_t - last_print_t
|
||||
if delta_t >= mininterval:
|
||||
elapsed = cur_t - start_t
|
||||
# EMA (not just overall average)
|
||||
if smoothing and delta_t:
|
||||
avg_time = delta_t / delta_it \
|
||||
if avg_time is None \
|
||||
else smoothing * delta_t / delta_it + \
|
||||
(1 - smoothing) * avg_time
|
||||
|
||||
# Inline due to multiple calls
|
||||
total = self.total
|
||||
# instantaneous rate
|
||||
y = delta_it / delta_t
|
||||
# overall rate
|
||||
z = n / elapsed
|
||||
# update line data
|
||||
xdata.append(n * 100.0 / total if total else cur_t)
|
||||
ydata.append(y)
|
||||
zdata.append(z)
|
||||
|
||||
# Discard old values
|
||||
# xmin, xmax = ax.get_xlim()
|
||||
# if (not total) and elapsed > xmin * 1.1:
|
||||
if (not total) and elapsed > 66:
|
||||
xdata.popleft()
|
||||
ydata.popleft()
|
||||
zdata.popleft()
|
||||
|
||||
ymin, ymax = ax.get_ylim()
|
||||
if y > ymax or z > ymax:
|
||||
ymax = 1.1 * y
|
||||
ax.set_ylim(ymin, ymax)
|
||||
ax.figure.canvas.draw()
|
||||
|
||||
if total:
|
||||
line1.set_data(xdata, ydata)
|
||||
line2.set_data(xdata, zdata)
|
||||
try:
|
||||
poly_lims = self.hspan.get_xy()
|
||||
except AttributeError:
|
||||
self.hspan = plt.axhspan(0, 0.001, xmin=0,
|
||||
xmax=0, color='g')
|
||||
poly_lims = self.hspan.get_xy()
|
||||
poly_lims[0, 1] = ymin
|
||||
poly_lims[1, 1] = ymax
|
||||
poly_lims[2] = [n / total, ymax]
|
||||
poly_lims[3] = [poly_lims[2, 0], ymin]
|
||||
if len(poly_lims) > 4:
|
||||
poly_lims[4, 1] = ymin
|
||||
self.hspan.set_xy(poly_lims)
|
||||
else:
|
||||
t_ago = [cur_t - i for i in xdata]
|
||||
line1.set_data(t_ago, ydata)
|
||||
line2.set_data(t_ago, zdata)
|
||||
|
||||
ax.set_title(self.format_meter(
|
||||
n, total, elapsed, 0,
|
||||
self.desc, ascii, unit, unit_scale,
|
||||
1 / avg_time if avg_time else None, bar_format),
|
||||
fontname="DejaVu Sans Mono", fontsize=11)
|
||||
plt.pause(1e-9)
|
||||
|
||||
# If no `miniters` was specified, adjust automatically
|
||||
# to the maximum iteration rate seen so far.
|
||||
if dynamic_miniters:
|
||||
if maxinterval and delta_t > maxinterval:
|
||||
# Set miniters to correspond to maxinterval
|
||||
miniters = delta_it * maxinterval / delta_t
|
||||
elif mininterval and delta_t:
|
||||
# EMA-weight miniters to converge
|
||||
# towards the timeframe of mininterval
|
||||
miniters = smoothing * delta_it * mininterval \
|
||||
/ delta_t + (1 - smoothing) * miniters
|
||||
else:
|
||||
miniters = smoothing * delta_it + \
|
||||
(1 - smoothing) * miniters
|
||||
|
||||
# Store old values for next call
|
||||
last_print_n = n
|
||||
last_print_t = cur_t
|
||||
|
||||
# Closing the progress bar.
|
||||
# Update some internal variables for close().
|
||||
self.last_print_n = last_print_n
|
||||
self.n = n
|
||||
self.close()
|
||||
|
||||
def update(self, n=1):
|
||||
# if not self.gui:
|
||||
# return super(tqdm_gui, self).close()
|
||||
if self.disable:
|
||||
return
|
||||
|
||||
if n < 0:
|
||||
n = 1
|
||||
self.n += n
|
||||
|
||||
delta_it = self.n - self.last_print_n # should be n?
|
||||
if delta_it >= self.miniters:
|
||||
# We check the counter first, to reduce the overhead of time()
|
||||
cur_t = time()
|
||||
delta_t = cur_t - self.last_print_t
|
||||
if delta_t >= self.mininterval:
|
||||
elapsed = cur_t - self.start_t
|
||||
# EMA (not just overall average)
|
||||
if self.smoothing and delta_t:
|
||||
self.avg_time = delta_t / delta_it \
|
||||
if self.avg_time is None \
|
||||
else self.smoothing * delta_t / delta_it + \
|
||||
(1 - self.smoothing) * self.avg_time
|
||||
|
||||
# Inline due to multiple calls
|
||||
total = self.total
|
||||
ax = self.ax
|
||||
|
||||
# instantaneous rate
|
||||
y = delta_it / delta_t
|
||||
# smoothed rate
|
||||
z = self.n / elapsed
|
||||
# update line data
|
||||
self.xdata.append(self.n * 100.0 / total
|
||||
if total else cur_t)
|
||||
self.ydata.append(y)
|
||||
self.zdata.append(z)
|
||||
|
||||
# Discard old values
|
||||
if (not total) and elapsed > 66:
|
||||
self.xdata.popleft()
|
||||
self.ydata.popleft()
|
||||
self.zdata.popleft()
|
||||
|
||||
ymin, ymax = ax.get_ylim()
|
||||
if y > ymax or z > ymax:
|
||||
ymax = 1.1 * y
|
||||
ax.set_ylim(ymin, ymax)
|
||||
ax.figure.canvas.draw()
|
||||
|
||||
if total:
|
||||
self.line1.set_data(self.xdata, self.ydata)
|
||||
self.line2.set_data(self.xdata, self.zdata)
|
||||
try:
|
||||
poly_lims = self.hspan.get_xy()
|
||||
except AttributeError:
|
||||
self.hspan = self.plt.axhspan(0, 0.001, xmin=0,
|
||||
xmax=0, color='g')
|
||||
poly_lims = self.hspan.get_xy()
|
||||
poly_lims[0, 1] = ymin
|
||||
poly_lims[1, 1] = ymax
|
||||
poly_lims[2] = [self.n / total, ymax]
|
||||
poly_lims[3] = [poly_lims[2, 0], ymin]
|
||||
if len(poly_lims) > 4:
|
||||
poly_lims[4, 1] = ymin
|
||||
self.hspan.set_xy(poly_lims)
|
||||
else:
|
||||
t_ago = [cur_t - i for i in self.xdata]
|
||||
self.line1.set_data(t_ago, self.ydata)
|
||||
self.line2.set_data(t_ago, self.zdata)
|
||||
|
||||
ax.set_title(self.format_meter(
|
||||
self.n, total, elapsed, 0,
|
||||
self.desc, self.ascii, self.unit, self.unit_scale,
|
||||
1 / self.avg_time if self.avg_time else None,
|
||||
self.bar_format),
|
||||
fontname="DejaVu Sans Mono", fontsize=11)
|
||||
self.plt.pause(1e-9)
|
||||
|
||||
# If no `miniters` was specified, adjust automatically to the
|
||||
# maximum iteration rate seen so far.
|
||||
# e.g.: After running `tqdm.update(5)`, subsequent
|
||||
# calls to `tqdm.update()` will only cause an update after
|
||||
# at least 5 more iterations.
|
||||
if self.dynamic_miniters:
|
||||
if self.maxinterval and delta_t > self.maxinterval:
|
||||
self.miniters = self.miniters * self.maxinterval \
|
||||
/ delta_t
|
||||
elif self.mininterval and delta_t:
|
||||
self.miniters = self.smoothing * delta_it \
|
||||
* self.mininterval / delta_t + \
|
||||
(1 - self.smoothing) * self.miniters
|
||||
else:
|
||||
self.miniters = self.smoothing * delta_it + \
|
||||
(1 - self.smoothing) * self.miniters
|
||||
|
||||
# Store old values for next call
|
||||
self.last_print_n = self.n
|
||||
self.last_print_t = cur_t
|
||||
|
||||
def close(self):
|
||||
# if not self.gui:
|
||||
# return super(tqdm_gui, self).close()
|
||||
if self.disable:
|
||||
return
|
||||
|
||||
self.disable = True
|
||||
|
||||
self._instances.remove(self)
|
||||
|
||||
# Restore toolbars
|
||||
self.mpl.rcParams['toolbar'] = self.toolbar
|
||||
# Return to non-interactive mode
|
||||
if not self.wasion:
|
||||
self.plt.ioff()
|
||||
if not self.leave:
|
||||
self.plt.close(self.fig)
|
||||
|
||||
|
||||
def tgrange(*args, **kwargs):
|
||||
"""
|
||||
A shortcut for tqdm_gui(xrange(*args), **kwargs).
|
||||
On Python3+ range is used instead of xrange.
|
||||
"""
|
||||
return tqdm_gui(_range(*args), **kwargs)
|
236
lib/tqdm/_tqdm_notebook.py
Normal file
236
lib/tqdm/_tqdm_notebook.py
Normal file
@@ -0,0 +1,236 @@
|
||||
"""
|
||||
IPython/Jupyter Notebook progressbar decorator for iterators.
|
||||
Includes a default (x)range iterator printing to stderr.
|
||||
|
||||
Usage:
|
||||
>>> from tqdm_notebook import tnrange[, tqdm_notebook]
|
||||
>>> for i in tnrange(10): #same as: for i in tqdm_notebook(xrange(10))
|
||||
... ...
|
||||
"""
|
||||
# future division is important to divide integers and get as
|
||||
# a result precise floating numbers (instead of truncated int)
|
||||
from __future__ import division, absolute_import
|
||||
# import compatibility functions and utilities
|
||||
import sys
|
||||
from ._utils import _range
|
||||
# to inherit from the tqdm class
|
||||
from ._tqdm import tqdm
|
||||
|
||||
|
||||
if True: # pragma: no cover
|
||||
# import IPython/Jupyter base widget and display utilities
|
||||
try: # IPython 4.x
|
||||
import ipywidgets
|
||||
IPY = 4
|
||||
except ImportError: # IPython 3.x / 2.x
|
||||
IPY = 32
|
||||
import warnings
|
||||
with warnings.catch_warnings():
|
||||
ipy_deprecation_msg = "The `IPython.html` package" \
|
||||
" has been deprecated"
|
||||
warnings.filterwarnings('error',
|
||||
message=".*" + ipy_deprecation_msg + ".*")
|
||||
try:
|
||||
import IPython.html.widgets as ipywidgets
|
||||
except Warning as e:
|
||||
if ipy_deprecation_msg not in str(e):
|
||||
raise
|
||||
warnings.simplefilter('ignore')
|
||||
try:
|
||||
import IPython.html.widgets as ipywidgets # NOQA
|
||||
except ImportError:
|
||||
pass
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try: # IPython 4.x / 3.x
|
||||
if IPY == 32:
|
||||
from IPython.html.widgets import IntProgress, HBox, HTML
|
||||
IPY = 3
|
||||
else:
|
||||
from ipywidgets import IntProgress, HBox, HTML
|
||||
except ImportError:
|
||||
try: # IPython 2.x
|
||||
from IPython.html.widgets import IntProgressWidget as IntProgress
|
||||
from IPython.html.widgets import ContainerWidget as HBox
|
||||
from IPython.html.widgets import HTML
|
||||
IPY = 2
|
||||
except ImportError:
|
||||
IPY = 0
|
||||
|
||||
try:
|
||||
from IPython.display import display # , clear_output
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# HTML encoding
|
||||
try: # Py3
|
||||
from html import escape
|
||||
except ImportError: # Py2
|
||||
from cgi import escape
|
||||
|
||||
|
||||
__author__ = {"github.com/": ["lrq3000", "casperdcl", "alexanderkuk"]}
|
||||
__all__ = ['tqdm_notebook', 'tnrange']
|
||||
|
||||
|
||||
class tqdm_notebook(tqdm):
|
||||
"""
|
||||
Experimental IPython/Jupyter Notebook widget using tqdm!
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def status_printer(_, total=None, desc=None):
|
||||
"""
|
||||
Manage the printing of an IPython/Jupyter Notebook progress bar widget.
|
||||
"""
|
||||
# Fallback to text bar if there's no total
|
||||
# DEPRECATED: replaced with an 'info' style bar
|
||||
# if not total:
|
||||
# return super(tqdm_notebook, tqdm_notebook).status_printer(file)
|
||||
|
||||
# fp = file
|
||||
|
||||
# Prepare IPython progress bar
|
||||
if total:
|
||||
pbar = IntProgress(min=0, max=total)
|
||||
else: # No total? Show info style bar with no progress tqdm status
|
||||
pbar = IntProgress(min=0, max=1)
|
||||
pbar.value = 1
|
||||
pbar.bar_style = 'info'
|
||||
if desc:
|
||||
pbar.description = desc
|
||||
# Prepare status text
|
||||
ptext = HTML()
|
||||
# Only way to place text to the right of the bar is to use a container
|
||||
container = HBox(children=[pbar, ptext])
|
||||
display(container)
|
||||
|
||||
def print_status(s='', close=False, bar_style=None, desc=None):
|
||||
# Note: contrary to native tqdm, s='' does NOT clear bar
|
||||
# goal is to keep all infos if error happens so user knows
|
||||
# at which iteration the loop failed.
|
||||
|
||||
# Clear previous output (really necessary?)
|
||||
# clear_output(wait=1)
|
||||
|
||||
# Get current iteration value from format_meter string
|
||||
if total:
|
||||
# n = None
|
||||
if s:
|
||||
npos = s.find(r'/|/') # cause we use bar_format=r'{n}|...'
|
||||
# Check that n can be found in s (else n > total)
|
||||
if npos >= 0:
|
||||
n = int(s[:npos]) # get n from string
|
||||
s = s[npos + 3:] # remove from string
|
||||
|
||||
# Update bar with current n value
|
||||
if n is not None:
|
||||
pbar.value = n
|
||||
|
||||
# Print stats
|
||||
if s: # never clear the bar (signal: s='')
|
||||
s = s.replace('||', '') # remove inesthetical pipes
|
||||
s = escape(s) # html escape special characters (like '?')
|
||||
ptext.value = s
|
||||
|
||||
# Change bar style
|
||||
if bar_style:
|
||||
# Hack-ish way to avoid the danger bar_style being overriden by
|
||||
# success because the bar gets closed after the error...
|
||||
if not (pbar.bar_style == 'danger' and bar_style == 'success'):
|
||||
pbar.bar_style = bar_style
|
||||
|
||||
# Special signal to close the bar
|
||||
if close and pbar.bar_style != 'danger': # hide only if no error
|
||||
try:
|
||||
container.close()
|
||||
except AttributeError:
|
||||
container.visible = False
|
||||
|
||||
# Update description
|
||||
if desc:
|
||||
pbar.description = desc
|
||||
|
||||
return print_status
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
# Setup default output
|
||||
if kwargs.get('file', sys.stderr) is sys.stderr:
|
||||
kwargs['file'] = sys.stdout # avoid the red block in IPython
|
||||
|
||||
# Remove the bar from the printed string, only print stats
|
||||
if not kwargs.get('bar_format', None):
|
||||
kwargs['bar_format'] = r'{n}/|/{l_bar}{r_bar}'
|
||||
|
||||
# Initialize parent class + avoid printing by using gui=True
|
||||
kwargs['gui'] = True
|
||||
super(tqdm_notebook, self).__init__(*args, **kwargs)
|
||||
if self.disable or not kwargs['gui']:
|
||||
return
|
||||
|
||||
# Delete first pbar generated from super() (wrong total and text)
|
||||
# DEPRECATED by using gui=True
|
||||
# self.sp('', close=True)
|
||||
# Replace with IPython progress bar display (with correct total)
|
||||
self.sp = self.status_printer(self.fp, self.total, self.desc)
|
||||
self.desc = None # trick to place description before the bar
|
||||
|
||||
# Print initial bar state
|
||||
if not self.disable:
|
||||
self.sp(self.__repr__()) # same as self.refresh without clearing
|
||||
|
||||
def __iter__(self, *args, **kwargs):
|
||||
try:
|
||||
for obj in super(tqdm_notebook, self).__iter__(*args, **kwargs):
|
||||
# return super(tqdm...) will not catch exception
|
||||
yield obj
|
||||
# NB: except ... [ as ...] breaks IPython async KeyboardInterrupt
|
||||
except:
|
||||
self.sp(bar_style='danger')
|
||||
raise
|
||||
|
||||
def update(self, *args, **kwargs):
|
||||
try:
|
||||
super(tqdm_notebook, self).update(*args, **kwargs)
|
||||
except Exception as exc:
|
||||
# cannot catch KeyboardInterrupt when using manual tqdm
|
||||
# as the interrupt will most likely happen on another statement
|
||||
self.sp(bar_style='danger')
|
||||
raise exc
|
||||
|
||||
def close(self, *args, **kwargs):
|
||||
super(tqdm_notebook, self).close(*args, **kwargs)
|
||||
# If it was not run in a notebook, sp is not assigned, check for it
|
||||
if hasattr(self, 'sp'):
|
||||
# Try to detect if there was an error or KeyboardInterrupt
|
||||
# in manual mode: if n < total, things probably got wrong
|
||||
if self.total and self.n < self.total:
|
||||
self.sp(bar_style='danger')
|
||||
else:
|
||||
if self.leave:
|
||||
self.sp(bar_style='success')
|
||||
else:
|
||||
self.sp(close=True)
|
||||
|
||||
def moveto(self, *args, **kwargs):
|
||||
# void -> avoid extraneous `\n` in IPython output cell
|
||||
return
|
||||
|
||||
def set_description(self, desc=None, **_):
|
||||
"""
|
||||
Set/modify description of the progress bar.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
desc : str, optional
|
||||
"""
|
||||
self.sp(desc=desc)
|
||||
|
||||
|
||||
def tnrange(*args, **kwargs):
|
||||
"""
|
||||
A shortcut for tqdm_notebook(xrange(*args), **kwargs).
|
||||
On Python3+ range is used instead of xrange.
|
||||
"""
|
||||
return tqdm_notebook(_range(*args), **kwargs)
|
46
lib/tqdm/_tqdm_pandas.py
Normal file
46
lib/tqdm/_tqdm_pandas.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import sys
|
||||
|
||||
__author__ = "github.com/casperdcl"
|
||||
__all__ = ['tqdm_pandas']
|
||||
|
||||
|
||||
def tqdm_pandas(tclass, *targs, **tkwargs):
|
||||
"""
|
||||
Registers the given `tqdm` instance with
|
||||
`pandas.core.groupby.DataFrameGroupBy.progress_apply`.
|
||||
It will even close() the `tqdm` instance upon completion.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
tclass : tqdm class you want to use (eg, tqdm, tqdm_notebook, etc)
|
||||
targs and tkwargs : arguments for the tqdm instance
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import pandas as pd
|
||||
>>> import numpy as np
|
||||
>>> from tqdm import tqdm, tqdm_pandas
|
||||
>>>
|
||||
>>> df = pd.DataFrame(np.random.randint(0, 100, (100000, 6)))
|
||||
>>> tqdm_pandas(tqdm, leave=True) # can use tqdm_gui, optional kwargs, etc
|
||||
>>> # Now you can use `progress_apply` instead of `apply`
|
||||
>>> df.groupby(0).progress_apply(lambda x: x**2)
|
||||
|
||||
References
|
||||
----------
|
||||
https://stackoverflow.com/questions/18603270/
|
||||
progress-indicator-during-pandas-operations-python
|
||||
"""
|
||||
from tqdm import TqdmDeprecationWarning
|
||||
|
||||
if isinstance(tclass, type) or (getattr(tclass, '__name__', '').startswith(
|
||||
'tqdm_')): # delayed adapter case
|
||||
TqdmDeprecationWarning("""\
|
||||
Please use `tqdm.pandas(...)` instead of `tqdm_pandas(tqdm, ...)`.
|
||||
""", fp_write=getattr(tkwargs.get('file', None), 'write', sys.stderr.write))
|
||||
tclass.pandas(*targs, **tkwargs)
|
||||
else:
|
||||
TqdmDeprecationWarning("""\
|
||||
Please use `tqdm.pandas(...)` instead of `tqdm_pandas(tqdm(...))`.
|
||||
""", fp_write=getattr(tclass.fp, 'write', sys.stderr.write))
|
||||
type(tclass).pandas(deprecated_t=tclass)
|
215
lib/tqdm/_utils.py
Normal file
215
lib/tqdm/_utils.py
Normal file
@@ -0,0 +1,215 @@
|
||||
import os
|
||||
import subprocess
|
||||
from platform import system as _curos
|
||||
CUR_OS = _curos()
|
||||
IS_WIN = CUR_OS in ['Windows', 'cli']
|
||||
IS_NIX = (not IS_WIN) and any(
|
||||
CUR_OS.startswith(i) for i in
|
||||
['CYGWIN', 'MSYS', 'Linux', 'Darwin', 'SunOS', 'FreeBSD', 'NetBSD'])
|
||||
|
||||
|
||||
# Py2/3 compat. Empty conditional to avoid coverage
|
||||
if True: # pragma: no cover
|
||||
try:
|
||||
_range = xrange
|
||||
except NameError:
|
||||
_range = range
|
||||
|
||||
try:
|
||||
_unich = unichr
|
||||
except NameError:
|
||||
_unich = chr
|
||||
|
||||
try:
|
||||
_unicode = unicode
|
||||
except NameError:
|
||||
_unicode = str
|
||||
|
||||
try:
|
||||
if IS_WIN:
|
||||
import colorama
|
||||
colorama.init()
|
||||
else:
|
||||
colorama = None
|
||||
except ImportError:
|
||||
colorama = None
|
||||
|
||||
try:
|
||||
from weakref import WeakSet
|
||||
except ImportError:
|
||||
WeakSet = set
|
||||
|
||||
try:
|
||||
_basestring = basestring
|
||||
except NameError:
|
||||
_basestring = str
|
||||
|
||||
try: # py>=2.7,>=3.1
|
||||
from collections import OrderedDict as _OrderedDict
|
||||
except ImportError:
|
||||
try: # older Python versions with backported ordereddict lib
|
||||
from ordereddict import OrderedDict as _OrderedDict
|
||||
except ImportError: # older Python versions without ordereddict lib
|
||||
# Py2.6,3.0 compat, from PEP 372
|
||||
from collections import MutableMapping
|
||||
|
||||
class _OrderedDict(dict, MutableMapping):
|
||||
# Methods with direct access to underlying attributes
|
||||
def __init__(self, *args, **kwds):
|
||||
if len(args) > 1:
|
||||
raise TypeError('expected at 1 argument, got %d',
|
||||
len(args))
|
||||
if not hasattr(self, '_keys'):
|
||||
self._keys = []
|
||||
self.update(*args, **kwds)
|
||||
|
||||
def clear(self):
|
||||
del self._keys[:]
|
||||
dict.clear(self)
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
if key not in self:
|
||||
self._keys.append(key)
|
||||
dict.__setitem__(self, key, value)
|
||||
|
||||
def __delitem__(self, key):
|
||||
dict.__delitem__(self, key)
|
||||
self._keys.remove(key)
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._keys)
|
||||
|
||||
def __reversed__(self):
|
||||
return reversed(self._keys)
|
||||
|
||||
def popitem(self):
|
||||
if not self:
|
||||
raise KeyError
|
||||
key = self._keys.pop()
|
||||
value = dict.pop(self, key)
|
||||
return key, value
|
||||
|
||||
def __reduce__(self):
|
||||
items = [[k, self[k]] for k in self]
|
||||
inst_dict = vars(self).copy()
|
||||
inst_dict.pop('_keys', None)
|
||||
return self.__class__, (items,), inst_dict
|
||||
|
||||
# Methods with indirect access via the above methods
|
||||
setdefault = MutableMapping.setdefault
|
||||
update = MutableMapping.update
|
||||
pop = MutableMapping.pop
|
||||
keys = MutableMapping.keys
|
||||
values = MutableMapping.values
|
||||
items = MutableMapping.items
|
||||
|
||||
def __repr__(self):
|
||||
pairs = ', '.join(map('%r: %r'.__mod__, self.items()))
|
||||
return '%s({%s})' % (self.__class__.__name__, pairs)
|
||||
|
||||
def copy(self):
|
||||
return self.__class__(self)
|
||||
|
||||
@classmethod
|
||||
def fromkeys(cls, iterable, value=None):
|
||||
d = cls()
|
||||
for key in iterable:
|
||||
d[key] = value
|
||||
return d
|
||||
|
||||
|
||||
def _is_utf(encoding):
|
||||
try:
|
||||
u'\u2588\u2589'.encode(encoding)
|
||||
except UnicodeEncodeError: # pragma: no cover
|
||||
return False
|
||||
except Exception: # pragma: no cover
|
||||
try:
|
||||
return encoding.lower().startswith('utf-') or ('U8' == encoding)
|
||||
except:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
def _supports_unicode(fp):
|
||||
try:
|
||||
return _is_utf(fp.encoding)
|
||||
except AttributeError:
|
||||
return False
|
||||
|
||||
|
||||
def _environ_cols_wrapper(): # pragma: no cover
|
||||
"""
|
||||
Return a function which gets width and height of console
|
||||
(linux,osx,windows,cygwin).
|
||||
"""
|
||||
_environ_cols = None
|
||||
if IS_WIN:
|
||||
_environ_cols = _environ_cols_windows
|
||||
if _environ_cols is None:
|
||||
_environ_cols = _environ_cols_tput
|
||||
if IS_NIX:
|
||||
_environ_cols = _environ_cols_linux
|
||||
return _environ_cols
|
||||
|
||||
|
||||
def _environ_cols_windows(fp): # pragma: no cover
|
||||
try:
|
||||
from ctypes import windll, create_string_buffer
|
||||
import struct
|
||||
from sys import stdin, stdout
|
||||
|
||||
io_handle = -12 # assume stderr
|
||||
if fp == stdin:
|
||||
io_handle = -10
|
||||
elif fp == stdout:
|
||||
io_handle = -11
|
||||
|
||||
h = windll.kernel32.GetStdHandle(io_handle)
|
||||
csbi = create_string_buffer(22)
|
||||
res = windll.kernel32.GetConsoleScreenBufferInfo(h, csbi)
|
||||
if res:
|
||||
(_bufx, _bufy, _curx, _cury, _wattr, left, _top, right, _bottom,
|
||||
_maxx, _maxy) = struct.unpack("hhhhHhhhhhh", csbi.raw)
|
||||
# nlines = bottom - top + 1
|
||||
return right - left # +1
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _environ_cols_tput(*_): # pragma: no cover
|
||||
"""cygwin xterm (windows)"""
|
||||
try:
|
||||
import shlex
|
||||
cols = int(subprocess.check_call(shlex.split('tput cols')))
|
||||
# rows = int(subprocess.check_call(shlex.split('tput lines')))
|
||||
return cols
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _environ_cols_linux(fp): # pragma: no cover
|
||||
|
||||
try:
|
||||
from termios import TIOCGWINSZ
|
||||
from fcntl import ioctl
|
||||
from array import array
|
||||
except ImportError:
|
||||
return None
|
||||
else:
|
||||
try:
|
||||
return array('h', ioctl(fp, TIOCGWINSZ, '\0' * 8))[1]
|
||||
except:
|
||||
try:
|
||||
from os.environ import get
|
||||
except ImportError:
|
||||
return None
|
||||
else:
|
||||
return int(get('COLUMNS', 1)) - 1
|
||||
|
||||
|
||||
def _term_move_up(): # pragma: no cover
|
||||
return '' if (os.name == 'nt') and (colorama is None) else '\x1b[A'
|
59
lib/tqdm/_version.py
Normal file
59
lib/tqdm/_version.py
Normal file
@@ -0,0 +1,59 @@
|
||||
# Definition of the version number
|
||||
import os
|
||||
from io import open as io_open
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
# major, minor, patch, -extra
|
||||
version_info = 4, 21, 0
|
||||
|
||||
# Nice string for the version
|
||||
__version__ = '.'.join(map(str, version_info))
|
||||
|
||||
|
||||
# auto -extra based on commit hash (if not tagged as release)
|
||||
scriptdir = os.path.dirname(__file__)
|
||||
gitdir = os.path.abspath(os.path.join(scriptdir, "..", ".git"))
|
||||
if os.path.isdir(gitdir): # pragma: nocover
|
||||
extra = None
|
||||
# Open config file to check if we are in tqdm project
|
||||
with io_open(os.path.join(gitdir, "config"), 'r') as fh_config:
|
||||
if 'tqdm' in fh_config.read():
|
||||
# Open the HEAD file
|
||||
with io_open(os.path.join(gitdir, "HEAD"), 'r') as fh_head:
|
||||
extra = fh_head.readline().strip()
|
||||
# in a branch => HEAD points to file containing last commit
|
||||
if 'ref:' in extra:
|
||||
# reference file path
|
||||
ref_file = extra[5:]
|
||||
branch_name = ref_file.rsplit('/', 1)[-1]
|
||||
|
||||
ref_file_path = os.path.abspath(os.path.join(gitdir, ref_file))
|
||||
# check that we are in git folder
|
||||
# (by stripping the git folder from the ref file path)
|
||||
if os.path.relpath(
|
||||
ref_file_path, gitdir).replace('\\', '/') != ref_file:
|
||||
# out of git folder
|
||||
extra = None
|
||||
else:
|
||||
# open the ref file
|
||||
with io_open(ref_file_path, 'r') as fh_branch:
|
||||
commit_hash = fh_branch.readline().strip()
|
||||
extra = commit_hash[:8]
|
||||
if branch_name != "master":
|
||||
extra += '.' + branch_name
|
||||
|
||||
# detached HEAD mode, already have commit hash
|
||||
else:
|
||||
extra = extra[:8]
|
||||
|
||||
# Append commit hash (and branch) to version string if not tagged
|
||||
if extra is not None:
|
||||
try:
|
||||
with io_open(os.path.join(gitdir, "refs", "tags",
|
||||
'v' + __version__)) as fdv:
|
||||
if fdv.readline().strip()[:8] != extra[:8]:
|
||||
__version__ += '-' + extra
|
||||
except Exception as e:
|
||||
if "No such file" not in str(e):
|
||||
raise
|
94
lib/tqdm/tests/tests_main.py
Normal file
94
lib/tqdm/tests/tests_main.py
Normal file
@@ -0,0 +1,94 @@
|
||||
import sys
|
||||
import subprocess
|
||||
from tqdm import main, TqdmKeyError, TqdmTypeError
|
||||
|
||||
from tests_tqdm import with_setup, pretest, posttest, _range, closing, \
|
||||
UnicodeIO, StringIO
|
||||
|
||||
|
||||
def _sh(*cmd, **kwargs):
|
||||
return subprocess.Popen(cmd, stdout=subprocess.PIPE,
|
||||
**kwargs).communicate()[0].decode('utf-8')
|
||||
|
||||
|
||||
# WARNING: this should be the last test as it messes with sys.stdin, argv
|
||||
@with_setup(pretest, posttest)
|
||||
def test_main():
|
||||
"""Test command line pipes"""
|
||||
ls_out = _sh('ls').replace('\r\n', '\n')
|
||||
ls = subprocess.Popen('ls', stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT)
|
||||
res = _sh(sys.executable, '-c', 'from tqdm import main; main()',
|
||||
stdin=ls.stdout, stderr=subprocess.STDOUT)
|
||||
ls.wait()
|
||||
|
||||
# actual test:
|
||||
|
||||
assert (ls_out in res.replace('\r\n', '\n'))
|
||||
|
||||
# semi-fake test which gets coverage:
|
||||
_SYS = sys.stdin, sys.argv
|
||||
|
||||
with closing(StringIO()) as sys.stdin:
|
||||
sys.argv = ['', '--desc', 'Test CLI-delims',
|
||||
'--ascii', 'True', '--delim', r'\0', '--buf_size', '64']
|
||||
sys.stdin.write('\0'.join(map(str, _range(int(1e3)))))
|
||||
sys.stdin.seek(0)
|
||||
main()
|
||||
|
||||
IN_DATA_LIST = map(str, _range(int(1e3)))
|
||||
sys.stdin = IN_DATA_LIST
|
||||
sys.argv = ['', '--desc', 'Test CLI pipes',
|
||||
'--ascii', 'True', '--unit_scale', 'True']
|
||||
import tqdm.__main__ # NOQA
|
||||
|
||||
IN_DATA = '\0'.join(IN_DATA_LIST)
|
||||
with closing(StringIO()) as sys.stdin:
|
||||
sys.stdin.write(IN_DATA)
|
||||
sys.stdin.seek(0)
|
||||
sys.argv = ['', '--ascii', '--bytes', '--unit_scale', 'False']
|
||||
with closing(UnicodeIO()) as fp:
|
||||
main(fp=fp)
|
||||
assert (str(len(IN_DATA)) in fp.getvalue())
|
||||
|
||||
sys.stdin = IN_DATA_LIST
|
||||
sys.argv = ['', '-ascii', '--unit_scale', 'False',
|
||||
'--desc', 'Test CLI errors']
|
||||
main()
|
||||
|
||||
sys.argv = ['', '-ascii', '-unit_scale', '--bad_arg_u_ment', 'foo']
|
||||
try:
|
||||
main()
|
||||
except TqdmKeyError as e:
|
||||
if 'bad_arg_u_ment' not in str(e):
|
||||
raise
|
||||
else:
|
||||
raise TqdmKeyError('bad_arg_u_ment')
|
||||
|
||||
sys.argv = ['', '-ascii', '-unit_scale', 'invalid_bool_value']
|
||||
try:
|
||||
main()
|
||||
except TqdmTypeError as e:
|
||||
if 'invalid_bool_value' not in str(e):
|
||||
raise
|
||||
else:
|
||||
raise TqdmTypeError('invalid_bool_value')
|
||||
|
||||
sys.argv = ['', '-ascii', '--total', 'invalid_int_value']
|
||||
try:
|
||||
main()
|
||||
except TqdmTypeError as e:
|
||||
if 'invalid_int_value' not in str(e):
|
||||
raise
|
||||
else:
|
||||
raise TqdmTypeError('invalid_int_value')
|
||||
|
||||
for i in ('-h', '--help', '-v', '--version'):
|
||||
sys.argv = ['', i]
|
||||
try:
|
||||
main()
|
||||
except SystemExit:
|
||||
pass
|
||||
|
||||
# clean up
|
||||
sys.stdin, sys.argv = _SYS
|
207
lib/tqdm/tests/tests_pandas.py
Normal file
207
lib/tqdm/tests/tests_pandas.py
Normal file
@@ -0,0 +1,207 @@
|
||||
from nose.plugins.skip import SkipTest
|
||||
|
||||
from tqdm import tqdm
|
||||
from tests_tqdm import with_setup, pretest, posttest, StringIO, closing
|
||||
|
||||
|
||||
@with_setup(pretest, posttest)
|
||||
def test_pandas_series():
|
||||
"""Test pandas.Series.progress_apply and .progress_map"""
|
||||
try:
|
||||
from numpy.random import randint
|
||||
import pandas as pd
|
||||
except ImportError:
|
||||
raise SkipTest
|
||||
|
||||
with closing(StringIO()) as our_file:
|
||||
tqdm.pandas(file=our_file, leave=True, ascii=True)
|
||||
|
||||
series = pd.Series(randint(0, 50, (123,)))
|
||||
res1 = series.progress_apply(lambda x: x + 10)
|
||||
res2 = series.apply(lambda x: x + 10)
|
||||
assert res1.equals(res2)
|
||||
|
||||
res3 = series.progress_map(lambda x: x + 10)
|
||||
res4 = series.map(lambda x: x + 10)
|
||||
assert res3.equals(res4)
|
||||
|
||||
expects = ['100%', '123/123']
|
||||
for exres in expects:
|
||||
our_file.seek(0)
|
||||
if our_file.getvalue().count(exres) < 2:
|
||||
our_file.seek(0)
|
||||
raise AssertionError(
|
||||
"\nExpected:\n{0}\nIn:\n{1}\n".format(
|
||||
exres + " at least twice.", our_file.read()))
|
||||
|
||||
|
||||
@with_setup(pretest, posttest)
|
||||
def test_pandas_data_frame():
|
||||
"""Test pandas.DataFrame.progress_apply and .progress_applymap"""
|
||||
try:
|
||||
from numpy.random import randint
|
||||
import pandas as pd
|
||||
except ImportError:
|
||||
raise SkipTest
|
||||
|
||||
with closing(StringIO()) as our_file:
|
||||
tqdm.pandas(file=our_file, leave=True, ascii=True)
|
||||
df = pd.DataFrame(randint(0, 50, (100, 200)))
|
||||
|
||||
def task_func(x):
|
||||
return x + 1
|
||||
|
||||
# applymap
|
||||
res1 = df.progress_applymap(task_func)
|
||||
res2 = df.applymap(task_func)
|
||||
assert res1.equals(res2)
|
||||
|
||||
# apply
|
||||
for axis in [0, 1]:
|
||||
res3 = df.progress_apply(task_func, axis=axis)
|
||||
res4 = df.apply(task_func, axis=axis)
|
||||
assert res3.equals(res4)
|
||||
|
||||
our_file.seek(0)
|
||||
if our_file.read().count('100%') < 3:
|
||||
our_file.seek(0)
|
||||
raise AssertionError("\nExpected:\n{0}\nIn:\n{1}\n".format(
|
||||
'100% at least three times', our_file.read()))
|
||||
|
||||
# apply_map, apply axis=0, apply axis=1
|
||||
expects = ['20000/20000', '200/200', '100/100']
|
||||
for exres in expects:
|
||||
our_file.seek(0)
|
||||
if our_file.getvalue().count(exres) < 1:
|
||||
our_file.seek(0)
|
||||
raise AssertionError(
|
||||
"\nExpected:\n{0}\nIn:\n {1}\n".format(
|
||||
exres + " at least once.", our_file.read()))
|
||||
|
||||
|
||||
@with_setup(pretest, posttest)
|
||||
def test_pandas_groupby_apply():
|
||||
"""Test pandas.DataFrame.groupby(...).progress_apply"""
|
||||
try:
|
||||
from numpy.random import randint
|
||||
import pandas as pd
|
||||
except ImportError:
|
||||
raise SkipTest
|
||||
|
||||
with closing(StringIO()) as our_file:
|
||||
tqdm.pandas(file=our_file, leave=False, ascii=True)
|
||||
|
||||
df = pd.DataFrame(randint(0, 50, (500, 3)))
|
||||
df.groupby(0).progress_apply(lambda x: None)
|
||||
|
||||
dfs = pd.DataFrame(randint(0, 50, (500, 3)), columns=list('abc'))
|
||||
dfs.groupby(['a']).progress_apply(lambda x: None)
|
||||
|
||||
our_file.seek(0)
|
||||
|
||||
# don't expect final output since no `leave` and
|
||||
# high dynamic `miniters`
|
||||
nexres = '100%|##########|'
|
||||
if nexres in our_file.read():
|
||||
our_file.seek(0)
|
||||
raise AssertionError("\nDid not expect:\n{0}\nIn:{1}\n".format(
|
||||
nexres, our_file.read()))
|
||||
|
||||
with closing(StringIO()) as our_file:
|
||||
tqdm.pandas(file=our_file, leave=True, ascii=True)
|
||||
|
||||
dfs = pd.DataFrame(randint(0, 50, (500, 3)), columns=list('abc'))
|
||||
dfs.loc[0] = [2, 1, 1]
|
||||
dfs['d'] = 100
|
||||
|
||||
expects = ['500/500', '1/1', '4/4', '2/2']
|
||||
dfs.groupby(dfs.index).progress_apply(lambda x: None)
|
||||
dfs.groupby('d').progress_apply(lambda x: None)
|
||||
dfs.groupby(dfs.columns, axis=1).progress_apply(lambda x: None)
|
||||
dfs.groupby([2, 2, 1, 1], axis=1).progress_apply(lambda x: None)
|
||||
|
||||
our_file.seek(0)
|
||||
if our_file.read().count('100%') < 4:
|
||||
our_file.seek(0)
|
||||
raise AssertionError("\nExpected:\n{0}\nIn:\n{1}\n".format(
|
||||
'100% at least four times', our_file.read()))
|
||||
|
||||
for exres in expects:
|
||||
our_file.seek(0)
|
||||
if our_file.getvalue().count(exres) < 1:
|
||||
our_file.seek(0)
|
||||
raise AssertionError(
|
||||
"\nExpected:\n{0}\nIn:\n {1}\n".format(
|
||||
exres + " at least once.", our_file.read()))
|
||||
|
||||
|
||||
@with_setup(pretest, posttest)
|
||||
def test_pandas_leave():
|
||||
"""Test pandas with `leave=True`"""
|
||||
try:
|
||||
from numpy.random import randint
|
||||
import pandas as pd
|
||||
except ImportError:
|
||||
raise SkipTest
|
||||
|
||||
with closing(StringIO()) as our_file:
|
||||
df = pd.DataFrame(randint(0, 100, (1000, 6)))
|
||||
tqdm.pandas(file=our_file, leave=True, ascii=True)
|
||||
df.groupby(0).progress_apply(lambda x: None)
|
||||
|
||||
our_file.seek(0)
|
||||
|
||||
exres = '100%|##########| 100/100'
|
||||
if exres not in our_file.read():
|
||||
our_file.seek(0)
|
||||
raise AssertionError(
|
||||
"\nExpected:\n{0}\nIn:{1}\n".format(exres, our_file.read()))
|
||||
|
||||
|
||||
@with_setup(pretest, posttest)
|
||||
def test_pandas_apply_args_deprecation():
|
||||
"""Test warning info in
|
||||
`pandas.Dataframe(Series).progress_apply(func, *args)`"""
|
||||
try:
|
||||
from numpy.random import randint
|
||||
from tqdm import tqdm_pandas
|
||||
import pandas as pd
|
||||
except ImportError:
|
||||
raise SkipTest
|
||||
|
||||
with closing(StringIO()) as our_file:
|
||||
tqdm_pandas(tqdm(file=our_file, leave=False, ascii=True, ncols=20))
|
||||
df = pd.DataFrame(randint(0, 50, (500, 3)))
|
||||
df.progress_apply(lambda x: None, 1) # 1 shall cause a warning
|
||||
# Check deprecation message
|
||||
res = our_file.getvalue()
|
||||
assert all([i in res for i in (
|
||||
"TqdmDeprecationWarning", "not supported",
|
||||
"keyword arguments instead")])
|
||||
|
||||
|
||||
@with_setup(pretest, posttest)
|
||||
def test_pandas_deprecation():
|
||||
"""Test bar object instance as argument deprecation"""
|
||||
try:
|
||||
from numpy.random import randint
|
||||
from tqdm import tqdm_pandas
|
||||
import pandas as pd
|
||||
except ImportError:
|
||||
raise SkipTest
|
||||
|
||||
with closing(StringIO()) as our_file:
|
||||
tqdm_pandas(tqdm(file=our_file, leave=False, ascii=True, ncols=20))
|
||||
df = pd.DataFrame(randint(0, 50, (500, 3)))
|
||||
df.groupby(0).progress_apply(lambda x: None)
|
||||
# Check deprecation message
|
||||
assert "TqdmDeprecationWarning" in our_file.getvalue()
|
||||
assert "instead of `tqdm_pandas(tqdm(...))`" in our_file.getvalue()
|
||||
|
||||
with closing(StringIO()) as our_file:
|
||||
tqdm_pandas(tqdm, file=our_file, leave=False, ascii=True, ncols=20)
|
||||
df = pd.DataFrame(randint(0, 50, (500, 3)))
|
||||
df.groupby(0).progress_apply(lambda x: None)
|
||||
# Check deprecation message
|
||||
assert "TqdmDeprecationWarning" in our_file.getvalue()
|
||||
assert "instead of `tqdm_pandas(tqdm, ...)`" in our_file.getvalue()
|
336
lib/tqdm/tests/tests_perf.py
Normal file
336
lib/tqdm/tests/tests_perf.py
Normal file
@@ -0,0 +1,336 @@
|
||||
from __future__ import print_function, division
|
||||
|
||||
from nose.plugins.skip import SkipTest
|
||||
|
||||
from contextlib import contextmanager
|
||||
|
||||
import sys
|
||||
from time import sleep, time
|
||||
|
||||
from tqdm import trange
|
||||
from tqdm import tqdm
|
||||
|
||||
from tests_tqdm import with_setup, pretest, posttest, StringIO, closing, _range
|
||||
|
||||
# Use relative/cpu timer to have reliable timings when there is a sudden load
|
||||
try:
|
||||
from time import process_time
|
||||
except ImportError:
|
||||
from time import clock
|
||||
process_time = clock
|
||||
|
||||
|
||||
def get_relative_time(prevtime=0):
|
||||
return process_time() - prevtime
|
||||
|
||||
|
||||
def cpu_sleep(t):
|
||||
"""Sleep the given amount of cpu time"""
|
||||
start = process_time()
|
||||
while (process_time() - start) < t:
|
||||
pass
|
||||
|
||||
|
||||
def checkCpuTime(sleeptime=0.2):
|
||||
"""Check if cpu time works correctly"""
|
||||
if checkCpuTime.passed:
|
||||
return True
|
||||
# First test that sleeping does not consume cputime
|
||||
start1 = process_time()
|
||||
sleep(sleeptime)
|
||||
t1 = process_time() - start1
|
||||
|
||||
# secondly check by comparing to cpusleep (where we actually do something)
|
||||
start2 = process_time()
|
||||
cpu_sleep(sleeptime)
|
||||
t2 = process_time() - start2
|
||||
|
||||
if abs(t1) < 0.0001 and (t1 < t2 / 10):
|
||||
return True
|
||||
raise SkipTest
|
||||
|
||||
|
||||
checkCpuTime.passed = False
|
||||
|
||||
|
||||
@contextmanager
|
||||
def relative_timer():
|
||||
start = process_time()
|
||||
|
||||
def elapser():
|
||||
return process_time() - start
|
||||
|
||||
yield lambda: elapser()
|
||||
spent = process_time() - start
|
||||
|
||||
def elapser(): # NOQA
|
||||
return spent
|
||||
|
||||
|
||||
def retry_on_except(n=3):
|
||||
def wrapper(fn):
|
||||
def test_inner():
|
||||
for i in range(1, n + 1):
|
||||
try:
|
||||
checkCpuTime()
|
||||
fn()
|
||||
except SkipTest:
|
||||
if i >= n:
|
||||
raise
|
||||
else:
|
||||
return
|
||||
|
||||
test_inner.__doc__ = fn.__doc__
|
||||
return test_inner
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class MockIO(StringIO):
|
||||
"""Wraps StringIO to mock a file with no I/O"""
|
||||
|
||||
def write(self, data):
|
||||
return
|
||||
|
||||
|
||||
def simple_progress(iterable=None, total=None, file=sys.stdout, desc='',
|
||||
leave=False, miniters=1, mininterval=0.1, width=60):
|
||||
"""Simple progress bar reproducing tqdm's major features"""
|
||||
n = [0] # use a closure
|
||||
start_t = [time()]
|
||||
last_n = [0]
|
||||
last_t = [0]
|
||||
if iterable is not None:
|
||||
total = len(iterable)
|
||||
|
||||
def format_interval(t):
|
||||
mins, s = divmod(int(t), 60)
|
||||
h, m = divmod(mins, 60)
|
||||
if h:
|
||||
return '{0:d}:{1:02d}:{2:02d}'.format(h, m, s)
|
||||
else:
|
||||
return '{0:02d}:{1:02d}'.format(m, s)
|
||||
|
||||
def update_and_print(i=1):
|
||||
n[0] += i
|
||||
if (n[0] - last_n[0]) >= miniters:
|
||||
last_n[0] = n[0]
|
||||
|
||||
if (time() - last_t[0]) >= mininterval:
|
||||
last_t[0] = time() # last_t[0] == current time
|
||||
|
||||
spent = last_t[0] - start_t[0]
|
||||
spent_fmt = format_interval(spent)
|
||||
rate = n[0] / spent if spent > 0 else 0
|
||||
if 0.0 < rate < 1.0:
|
||||
rate_fmt = "%.2fs/it" % (1.0 / rate)
|
||||
else:
|
||||
rate_fmt = "%.2fit/s" % rate
|
||||
|
||||
frac = n[0] / total
|
||||
percentage = int(frac * 100)
|
||||
eta = (total - n[0]) / rate if rate > 0 else 0
|
||||
eta_fmt = format_interval(eta)
|
||||
|
||||
# bar = "#" * int(frac * width)
|
||||
barfill = " " * int((1.0 - frac) * width)
|
||||
bar_length, frac_bar_length = divmod(int(frac * width * 10), 10)
|
||||
bar = '#' * bar_length
|
||||
frac_bar = chr(48 + frac_bar_length) if frac_bar_length \
|
||||
else ' '
|
||||
|
||||
file.write("\r%s %i%%|%s%s%s| %i/%i [%s<%s, %s]" %
|
||||
(desc, percentage, bar, frac_bar, barfill, n[0],
|
||||
total, spent_fmt, eta_fmt, rate_fmt))
|
||||
|
||||
if n[0] == total and leave:
|
||||
file.write("\n")
|
||||
file.flush()
|
||||
|
||||
def update_and_yield():
|
||||
for elt in iterable:
|
||||
yield elt
|
||||
update_and_print()
|
||||
|
||||
update_and_print(0)
|
||||
if iterable is not None:
|
||||
return update_and_yield()
|
||||
else:
|
||||
return update_and_print
|
||||
|
||||
|
||||
@with_setup(pretest, posttest)
|
||||
@retry_on_except()
|
||||
def test_iter_overhead():
|
||||
"""Test overhead of iteration based tqdm"""
|
||||
|
||||
total = int(1e6)
|
||||
|
||||
with closing(MockIO()) as our_file:
|
||||
a = 0
|
||||
with trange(total, file=our_file) as t:
|
||||
with relative_timer() as time_tqdm:
|
||||
for i in t:
|
||||
a += i
|
||||
assert (a == (total * total - total) / 2.0)
|
||||
|
||||
a = 0
|
||||
with relative_timer() as time_bench:
|
||||
for i in _range(total):
|
||||
a += i
|
||||
our_file.write(a)
|
||||
|
||||
# Compute relative overhead of tqdm against native range()
|
||||
if time_tqdm() > 9 * time_bench():
|
||||
raise AssertionError('trange(%g): %f, range(%g): %f' %
|
||||
(total, time_tqdm(), total, time_bench()))
|
||||
|
||||
|
||||
@with_setup(pretest, posttest)
|
||||
@retry_on_except()
|
||||
def test_manual_overhead():
|
||||
"""Test overhead of manual tqdm"""
|
||||
|
||||
total = int(1e6)
|
||||
|
||||
with closing(MockIO()) as our_file:
|
||||
with tqdm(total=total * 10, file=our_file, leave=True) as t:
|
||||
a = 0
|
||||
with relative_timer() as time_tqdm:
|
||||
for i in _range(total):
|
||||
a += i
|
||||
t.update(10)
|
||||
|
||||
a = 0
|
||||
with relative_timer() as time_bench:
|
||||
for i in _range(total):
|
||||
a += i
|
||||
our_file.write(a)
|
||||
|
||||
# Compute relative overhead of tqdm against native range()
|
||||
if time_tqdm() > 10 * time_bench():
|
||||
raise AssertionError('tqdm(%g): %f, range(%g): %f' %
|
||||
(total, time_tqdm(), total, time_bench()))
|
||||
|
||||
|
||||
@with_setup(pretest, posttest)
|
||||
@retry_on_except()
|
||||
def test_iter_overhead_hard():
|
||||
"""Test overhead of iteration based tqdm (hard)"""
|
||||
|
||||
total = int(1e5)
|
||||
|
||||
with closing(MockIO()) as our_file:
|
||||
a = 0
|
||||
with trange(total, file=our_file, leave=True, miniters=1,
|
||||
mininterval=0, maxinterval=0) as t:
|
||||
with relative_timer() as time_tqdm:
|
||||
for i in t:
|
||||
a += i
|
||||
assert (a == (total * total - total) / 2.0)
|
||||
|
||||
a = 0
|
||||
with relative_timer() as time_bench:
|
||||
for i in _range(total):
|
||||
a += i
|
||||
our_file.write(("%i" % a) * 40)
|
||||
|
||||
# Compute relative overhead of tqdm against native range()
|
||||
try:
|
||||
assert (time_tqdm() < 60 * time_bench())
|
||||
except AssertionError:
|
||||
raise AssertionError('trange(%g): %f, range(%g): %f' %
|
||||
(total, time_tqdm(), total, time_bench()))
|
||||
|
||||
|
||||
@with_setup(pretest, posttest)
|
||||
@retry_on_except()
|
||||
def test_manual_overhead_hard():
|
||||
"""Test overhead of manual tqdm (hard)"""
|
||||
|
||||
total = int(1e5)
|
||||
|
||||
with closing(MockIO()) as our_file:
|
||||
t = tqdm(total=total * 10, file=our_file, leave=True, miniters=1,
|
||||
mininterval=0, maxinterval=0)
|
||||
a = 0
|
||||
with relative_timer() as time_tqdm:
|
||||
for i in _range(total):
|
||||
a += i
|
||||
t.update(10)
|
||||
|
||||
a = 0
|
||||
with relative_timer() as time_bench:
|
||||
for i in _range(total):
|
||||
a += i
|
||||
our_file.write(("%i" % a) * 40)
|
||||
|
||||
# Compute relative overhead of tqdm against native range()
|
||||
try:
|
||||
assert (time_tqdm() < 100 * time_bench())
|
||||
except AssertionError:
|
||||
raise AssertionError('tqdm(%g): %f, range(%g): %f' %
|
||||
(total, time_tqdm(), total, time_bench()))
|
||||
|
||||
|
||||
@with_setup(pretest, posttest)
|
||||
@retry_on_except()
|
||||
def test_iter_overhead_simplebar_hard():
|
||||
"""Test overhead of iteration based tqdm vs simple progress bar (hard)"""
|
||||
|
||||
total = int(1e4)
|
||||
|
||||
with closing(MockIO()) as our_file:
|
||||
a = 0
|
||||
with trange(total, file=our_file, leave=True, miniters=1,
|
||||
mininterval=0, maxinterval=0) as t:
|
||||
with relative_timer() as time_tqdm:
|
||||
for i in t:
|
||||
a += i
|
||||
assert (a == (total * total - total) / 2.0)
|
||||
|
||||
a = 0
|
||||
s = simple_progress(_range(total), file=our_file, leave=True,
|
||||
miniters=1, mininterval=0)
|
||||
with relative_timer() as time_bench:
|
||||
for i in s:
|
||||
a += i
|
||||
|
||||
# Compute relative overhead of tqdm against native range()
|
||||
try:
|
||||
assert (time_tqdm() < 2.5 * time_bench())
|
||||
except AssertionError:
|
||||
raise AssertionError('trange(%g): %f, simple_progress(%g): %f' %
|
||||
(total, time_tqdm(), total, time_bench()))
|
||||
|
||||
|
||||
@with_setup(pretest, posttest)
|
||||
@retry_on_except()
|
||||
def test_manual_overhead_simplebar_hard():
|
||||
"""Test overhead of manual tqdm vs simple progress bar (hard)"""
|
||||
|
||||
total = int(1e4)
|
||||
|
||||
with closing(MockIO()) as our_file:
|
||||
t = tqdm(total=total * 10, file=our_file, leave=True, miniters=1,
|
||||
mininterval=0, maxinterval=0)
|
||||
a = 0
|
||||
with relative_timer() as time_tqdm:
|
||||
for i in _range(total):
|
||||
a += i
|
||||
t.update(10)
|
||||
|
||||
simplebar_update = simple_progress(
|
||||
total=total, file=our_file, leave=True, miniters=1, mininterval=0)
|
||||
a = 0
|
||||
with relative_timer() as time_bench:
|
||||
for i in _range(total):
|
||||
a += i
|
||||
simplebar_update(10)
|
||||
|
||||
# Compute relative overhead of tqdm against native range()
|
||||
try:
|
||||
assert (time_tqdm() < 2.5 * time_bench())
|
||||
except AssertionError:
|
||||
raise AssertionError('tqdm(%g): %f, simple_progress(%g): %f' %
|
||||
(total, time_tqdm(), total, time_bench()))
|
164
lib/tqdm/tests/tests_synchronisation.py
Normal file
164
lib/tqdm/tests/tests_synchronisation.py
Normal file
@@ -0,0 +1,164 @@
|
||||
from __future__ import division
|
||||
from tqdm import tqdm
|
||||
from tests_tqdm import with_setup, pretest, posttest, StringIO, closing
|
||||
from tests_tqdm import DiscreteTimer, cpu_timify
|
||||
|
||||
from time import sleep
|
||||
from threading import Event
|
||||
from tqdm import TMonitor
|
||||
|
||||
|
||||
class FakeSleep(object):
|
||||
"""Wait until the discrete timer reached the required time"""
|
||||
def __init__(self, dtimer):
|
||||
self.dtimer = dtimer
|
||||
|
||||
def sleep(self, t):
|
||||
end = t + self.dtimer.t
|
||||
while self.dtimer.t < end:
|
||||
sleep(0.0000001) # sleep a bit to interrupt (instead of pass)
|
||||
|
||||
|
||||
class FakeTqdm(object):
|
||||
_instances = []
|
||||
|
||||
|
||||
def make_create_fake_sleep_event(sleep):
|
||||
def wait(self, timeout=None):
|
||||
if timeout is not None:
|
||||
sleep(timeout)
|
||||
return self.is_set()
|
||||
|
||||
def create_fake_sleep_event():
|
||||
event = Event()
|
||||
event.wait = wait
|
||||
return event
|
||||
|
||||
return create_fake_sleep_event
|
||||
|
||||
|
||||
@with_setup(pretest, posttest)
|
||||
def test_monitor_thread():
|
||||
"""Test dummy monitoring thread"""
|
||||
maxinterval = 10
|
||||
|
||||
# Setup a discrete timer
|
||||
timer = DiscreteTimer()
|
||||
TMonitor._time = timer.time
|
||||
# And a fake sleeper
|
||||
sleeper = FakeSleep(timer)
|
||||
TMonitor._event = make_create_fake_sleep_event(sleeper.sleep)
|
||||
|
||||
# Instanciate the monitor
|
||||
monitor = TMonitor(FakeTqdm, maxinterval)
|
||||
# Test if alive, then killed
|
||||
assert monitor.report()
|
||||
monitor.exit()
|
||||
timer.sleep(maxinterval * 2) # need to go out of the sleep to die
|
||||
assert not monitor.report()
|
||||
# assert not monitor.is_alive() # not working dunno why, thread not killed
|
||||
del monitor
|
||||
|
||||
|
||||
@with_setup(pretest, posttest)
|
||||
def test_monitoring_and_cleanup():
|
||||
"""Test for stalled tqdm instance and monitor deletion"""
|
||||
# Note: should fix miniters for these tests, else with dynamic_miniters
|
||||
# it's too complicated to handle with monitoring update and maxinterval...
|
||||
maxinterval = 2
|
||||
|
||||
total = 1000
|
||||
# Setup a discrete timer
|
||||
timer = DiscreteTimer()
|
||||
# And a fake sleeper
|
||||
sleeper = FakeSleep(timer)
|
||||
# Setup TMonitor to use the timer
|
||||
TMonitor._time = timer.time
|
||||
TMonitor._event = make_create_fake_sleep_event(sleeper.sleep)
|
||||
# Set monitor interval
|
||||
tqdm.monitor_interval = maxinterval
|
||||
with closing(StringIO()) as our_file:
|
||||
with tqdm(total=total, file=our_file, miniters=500, mininterval=0.1,
|
||||
maxinterval=maxinterval) as t:
|
||||
cpu_timify(t, timer)
|
||||
# Do a lot of iterations in a small timeframe
|
||||
# (smaller than monitor interval)
|
||||
timer.sleep(maxinterval / 2) # monitor won't wake up
|
||||
t.update(500)
|
||||
# check that our fixed miniters is still there
|
||||
assert t.miniters == 500
|
||||
# Then do 1 it after monitor interval, so that monitor kicks in
|
||||
timer.sleep(maxinterval * 2)
|
||||
t.update(1)
|
||||
# Wait for the monitor to get out of sleep's loop and update tqdm..
|
||||
timeend = timer.time()
|
||||
while not (t.monitor.woken >= timeend and t.miniters == 1):
|
||||
timer.sleep(1) # Force monitor to wake up if it woken too soon
|
||||
sleep(0.000001) # sleep to allow interrupt (instead of pass)
|
||||
assert t.miniters == 1 # check that monitor corrected miniters
|
||||
# Note: at this point, there may be a race condition: monitor saved
|
||||
# current woken time but timer.sleep() happen just before monitor
|
||||
# sleep. To fix that, either sleep here or increase time in a loop
|
||||
# to ensure that monitor wakes up at some point.
|
||||
|
||||
# Try again but already at miniters = 1 so nothing will be done
|
||||
timer.sleep(maxinterval * 2)
|
||||
t.update(2)
|
||||
timeend = timer.time()
|
||||
while not (t.monitor.woken >= timeend):
|
||||
timer.sleep(1) # Force monitor to wake up if it woken too soon
|
||||
sleep(0.000001)
|
||||
# Wait for the monitor to get out of sleep's loop and update tqdm..
|
||||
assert t.miniters == 1 # check that monitor corrected miniters
|
||||
|
||||
# Check that class var monitor is deleted if no instance left
|
||||
tqdm.monitor_interval = 10
|
||||
assert tqdm.monitor is None
|
||||
|
||||
|
||||
@with_setup(pretest, posttest)
|
||||
def test_monitoring_multi():
|
||||
"""Test on multiple bars, one not needing miniters adjustment"""
|
||||
# Note: should fix miniters for these tests, else with dynamic_miniters
|
||||
# it's too complicated to handle with monitoring update and maxinterval...
|
||||
maxinterval = 2
|
||||
|
||||
total = 1000
|
||||
# Setup a discrete timer
|
||||
timer = DiscreteTimer()
|
||||
# And a fake sleeper
|
||||
sleeper = FakeSleep(timer)
|
||||
# Setup TMonitor to use the timer
|
||||
TMonitor._time = timer.time
|
||||
TMonitor._event = make_create_fake_sleep_event(sleeper.sleep)
|
||||
# Set monitor interval
|
||||
tqdm.monitor_interval = maxinterval
|
||||
with closing(StringIO()) as our_file:
|
||||
with tqdm(total=total, file=our_file, miniters=500, mininterval=0.1,
|
||||
maxinterval=maxinterval) as t1:
|
||||
# Set high maxinterval for t2 so monitor does not need to adjust it
|
||||
with tqdm(total=total, file=our_file, miniters=500, mininterval=0.1,
|
||||
maxinterval=1E5) as t2:
|
||||
cpu_timify(t1, timer)
|
||||
cpu_timify(t2, timer)
|
||||
# Do a lot of iterations in a small timeframe
|
||||
timer.sleep(maxinterval / 2)
|
||||
t1.update(500)
|
||||
t2.update(500)
|
||||
assert t1.miniters == 500
|
||||
assert t2.miniters == 500
|
||||
# Then do 1 it after monitor interval, so that monitor kicks in
|
||||
timer.sleep(maxinterval * 2)
|
||||
t1.update(1)
|
||||
t2.update(1)
|
||||
# Wait for the monitor to get out of sleep and update tqdm
|
||||
timeend = timer.time()
|
||||
while not (t1.monitor.woken >= timeend and t1.miniters == 1):
|
||||
timer.sleep(1)
|
||||
sleep(0.000001)
|
||||
assert t1.miniters == 1 # check that monitor corrected miniters
|
||||
assert t2.miniters == 500 # check that t2 was not adjusted
|
||||
|
||||
# Check that class var monitor is deleted if no instance left
|
||||
tqdm.monitor_interval = 10
|
||||
assert tqdm.monitor is None
|
1541
lib/tqdm/tests/tests_tqdm.py
Normal file
1541
lib/tqdm/tests/tests_tqdm.py
Normal file
File diff suppressed because it is too large
Load Diff
12
lib/tqdm/tests/tests_version.py
Normal file
12
lib/tqdm/tests/tests_version.py
Normal file
@@ -0,0 +1,12 @@
|
||||
import re
|
||||
|
||||
|
||||
def test_version():
|
||||
"""Test version string"""
|
||||
from tqdm import __version__
|
||||
version_parts = re.split('[.-]', __version__)
|
||||
assert 3 <= len(version_parts) # must have at least Major.minor.patch
|
||||
try:
|
||||
map(int, version_parts[:3])
|
||||
except ValueError:
|
||||
raise TypeError('Version Major.minor.patch must be 3 integers')
|
@@ -648,7 +648,7 @@ def dbcheck():
|
||||
'CREATE TABLE IF NOT EXISTS newsletter_log (id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp INTEGER, '
|
||||
'newsletter_id INTEGER, agent_id INTEGER, agent_name TEXT, notify_action TEXT, '
|
||||
'subject_text TEXT, body_text TEXT, message_text TEXT, start_date TEXT, end_date TEXT, '
|
||||
'uuid TEXT UNIQUE, success INTEGER DEFAULT 0)'
|
||||
'start_time INTEGER, end_time INTEGER, uuid TEXT UNIQUE, success INTEGER DEFAULT 0)'
|
||||
)
|
||||
|
||||
# recently_added table :: This table keeps record of recently added items
|
||||
@@ -1477,6 +1477,18 @@ def dbcheck():
|
||||
'UPDATE notify_log SET success = 1'
|
||||
)
|
||||
|
||||
# Upgrade newsletter_log table from earlier versions
|
||||
try:
|
||||
c_db.execute('SELECT start_time FROM newsletter_log')
|
||||
except sqlite3.OperationalError:
|
||||
logger.debug(u"Altering database. Updating database table newsletter_log.")
|
||||
c_db.execute(
|
||||
'ALTER TABLE newsletter_log ADD COLUMN start_time INTEGER'
|
||||
)
|
||||
c_db.execute(
|
||||
'ALTER TABLE newsletter_log ADD COLUMN end_time INTEGER'
|
||||
)
|
||||
|
||||
# Upgrade library_sections table from earlier versions (remove UNIQUE constraint on section_id)
|
||||
try:
|
||||
result = c_db.execute('SELECT SQL FROM sqlite_master WHERE type="table" AND name="library_sections"').fetchone()
|
||||
|
@@ -304,6 +304,7 @@ NOTIFICATION_PARAMETERS = [
|
||||
{'name': 'Server Version', 'type': 'str', 'value': 'server_version', 'description': 'The current version of your Plex Server.'},
|
||||
{'name': 'Server ID', 'type': 'str', 'value': 'server_machine_id', 'description': 'The unique identifier for your Plex Server.'},
|
||||
{'name': 'Action', 'type': 'str', 'value': 'action', 'description': 'The action that triggered the notification.'},
|
||||
{'name': 'Week Number', 'type': 'int', 'value': 'week_number', 'description': 'The week number of the year when the notfication was triggered.'},
|
||||
{'name': 'Datestamp', 'type': 'str', 'value': 'datestamp', 'description': 'The date (in date format) when the notification was triggered.'},
|
||||
{'name': 'Timestamp', 'type': 'str', 'value': 'timestamp', 'description': 'The time (in time format) when the notification was triggered.'},
|
||||
{'name': 'Unix Time', 'type': 'int', 'value': 'unixtime', 'description': 'The unix timestamp when the notification was triggered.'},
|
||||
@@ -520,7 +521,9 @@ NEWSLETTER_PARAMETERS = [
|
||||
{'name': 'Server Name', 'type': 'str', 'value': 'server_name', 'description': 'The name of your Plex Server.'},
|
||||
{'name': 'Start Date', 'type': 'str', 'value': 'start_date', 'description': 'The start date of the newesletter.'},
|
||||
{'name': 'End Date', 'type': 'str', 'value': 'end_date', 'description': 'The end date of the newesletter.'},
|
||||
{'name': 'Newsletter Days', 'type': 'int', 'value': 'newsletter_days', 'description': 'The past number of days included in the newsletter.'},
|
||||
{'name': 'Week Number', 'type': 'int', 'value': 'week_number', 'description': 'The week number of the year.'},
|
||||
{'name': 'Newsletter Time Frame', 'type': 'int', 'value': 'newsletter_time_frame', 'description': 'The time frame included in the newsletter.'},
|
||||
{'name': 'Newsletter Time Frame Units', 'type': 'str', 'value': 'newsletter_time_frame_units', 'description': 'The time frame units included in the newsletter.'},
|
||||
{'name': 'Newsletter URL', 'type': 'str', 'value': 'newsletter_url', 'description': 'The self-hosted URL to the newsletter.'},
|
||||
{'name': 'Newsletter UUID', 'type': 'str', 'value': 'newsletter_uuid', 'description': 'The unique identifier for the newsletter.'},
|
||||
]
|
||||
|
@@ -731,9 +731,9 @@ def upload_to_imgur(img_data, img_title='', rating_key='', fallback=''):
|
||||
delete_hash = imgur_response_data.get('deletehash', '')
|
||||
else:
|
||||
if err_msg:
|
||||
logger.error(u"Tautulli Helpers :: Unable to upload image '{}' to Imgur: {}".format(img_title, err_msg))
|
||||
logger.error(u"Tautulli Helpers :: Unable to upload image '{}' ({}) to Imgur: {}".format(img_title, fallback, err_msg))
|
||||
else:
|
||||
logger.error(u"Tautulli Helpers :: Unable to upload image '{}' to Imgur.".format(img_title))
|
||||
logger.error(u"Tautulli Helpers :: Unable to upload image '{}' ({}) to Imgur.".format(img_title, fallback))
|
||||
|
||||
if req_msg:
|
||||
logger.debug(u"Tautulli Helpers :: Request response: {}".format(req_msg))
|
||||
@@ -755,9 +755,9 @@ def delete_from_imgur(delete_hash, img_title='', fallback=''):
|
||||
return True
|
||||
else:
|
||||
if err_msg:
|
||||
logger.error(u"Tautulli Helpers :: Unable to delete image '{}' from Imgur: {}".format(img_title, err_msg))
|
||||
logger.error(u"Tautulli Helpers :: Unable to delete image '{}' ({}) from Imgur: {}".format(img_title, fallback, err_msg))
|
||||
else:
|
||||
logger.error(u"Tautulli Helpers :: Unable to delete image '{}' from Imgur.".format(img_title))
|
||||
logger.error(u"Tautulli Helpers :: Unable to delete image '{}' ({}) from Imgur.".format(img_title, fallback))
|
||||
return False
|
||||
|
||||
|
||||
|
@@ -102,6 +102,8 @@ def notify(newsletter_id=None, notify_action=None, **kwargs):
|
||||
message=newsletter_agent.message_formatted,
|
||||
start_date=newsletter_agent.start_date.format('YYYY-MM-DD'),
|
||||
end_date=newsletter_agent.end_date.format('YYYY-MM-DD'),
|
||||
start_time=newsletter_agent.start_time,
|
||||
end_time=newsletter_agent.end_time,
|
||||
newsletter_uuid=newsletter_agent.uuid)
|
||||
|
||||
# Send the notification
|
||||
@@ -112,7 +114,8 @@ def notify(newsletter_id=None, notify_action=None, **kwargs):
|
||||
return True
|
||||
|
||||
|
||||
def set_notify_state(newsletter, notify_action, subject, body, message, start_date, end_date, newsletter_uuid):
|
||||
def set_notify_state(newsletter, notify_action, subject, body, message,
|
||||
start_date, end_date, start_time, end_time, newsletter_uuid):
|
||||
|
||||
if newsletter and notify_action:
|
||||
db = database.MonitorDatabase()
|
||||
@@ -128,7 +131,9 @@ def set_notify_state(newsletter, notify_action, subject, body, message, start_da
|
||||
'body_text': body,
|
||||
'message_text': message,
|
||||
'start_date': start_date,
|
||||
'end_date': end_date}
|
||||
'end_date': end_date,
|
||||
'start_time': start_time,
|
||||
'end_time': end_time}
|
||||
|
||||
db.upsert(table_name='newsletter_log', key_dict=keys, value_dict=values)
|
||||
return db.last_insert_id()
|
||||
|
@@ -296,7 +296,8 @@ def generate_newsletter_uuid():
|
||||
class Newsletter(object):
|
||||
NAME = ''
|
||||
_DEFAULT_CONFIG = {'custom_cron': 0,
|
||||
'last_days': 7,
|
||||
'time_frame': 7,
|
||||
'time_frame_units': 'days',
|
||||
'formatted': 1,
|
||||
'notifier_id': 0}
|
||||
_DEFAULT_EMAIL_CONFIG = EMAIL().return_default_config()
|
||||
@@ -324,7 +325,7 @@ class Newsletter(object):
|
||||
pass
|
||||
|
||||
if self.end_date is None:
|
||||
self.end_date = arrow.now().ceil('day')
|
||||
self.end_date = arrow.now()
|
||||
|
||||
if start_date:
|
||||
try:
|
||||
@@ -333,7 +334,10 @@ class Newsletter(object):
|
||||
pass
|
||||
|
||||
if self.start_date is None:
|
||||
self.start_date = self.end_date.shift(days=-self.config['last_days']+1).floor('day')
|
||||
if self.config['time_frame_units'] == 'days':
|
||||
self.start_date = self.end_date.shift(days=-self.config['time_frame']+1).floor('day')
|
||||
else:
|
||||
self.start_date = self.end_date.shift(hours=-self.config['time_frame']).floor('hour')
|
||||
|
||||
self.end_time = self.end_date.timestamp
|
||||
self.start_time = self.start_date.timestamp
|
||||
@@ -412,6 +416,10 @@ class Newsletter(object):
|
||||
def send(self):
|
||||
self.newsletter = self.generate_newsletter()
|
||||
|
||||
if not self._has_data():
|
||||
logger.warn(u"Tautulli Newsletters :: %s newsletter has no data. Newsletter not sent." % self.NAME)
|
||||
return False
|
||||
|
||||
self._save()
|
||||
return self._send()
|
||||
|
||||
@@ -438,10 +446,6 @@ class Newsletter(object):
|
||||
% (self.NAME, newsletter_file, e))
|
||||
|
||||
def _send(self):
|
||||
if not self._has_data():
|
||||
logger.warn(u"Tautulli Newsletters :: %s newsletter has no data. Newsletter not sent." % self.NAME)
|
||||
return False
|
||||
|
||||
if self.config['formatted']:
|
||||
if self.email_config['notifier_id']:
|
||||
return send_notification(
|
||||
@@ -477,7 +481,9 @@ class Newsletter(object):
|
||||
'server_name': plexpy.CONFIG.PMS_NAME,
|
||||
'start_date': self.start_date.format(date_format),
|
||||
'end_date': self.end_date.format(date_format),
|
||||
'newsletter_days': self.config['last_days'],
|
||||
'week_number': self.start_date.isocalendar()[1],
|
||||
'newsletter_time_frame': self.config['time_frame'],
|
||||
'newsletter_time_frame_units': self.config['time_frame_units'],
|
||||
'newsletter_url': base_url.rstrip('/') + plexpy.HTTP_ROOT + 'newsletter/' + self.uuid,
|
||||
'newsletter_uuid': self.uuid
|
||||
}
|
||||
@@ -527,14 +533,7 @@ class Newsletter(object):
|
||||
return self._return_config_options()
|
||||
|
||||
def _return_config_options(self):
|
||||
config_options = [
|
||||
{'label': 'Number of Days',
|
||||
'value': self.config['last_days'],
|
||||
'name': 'newsletter_config_last_days',
|
||||
'description': 'The past number of days to include in the newsletter.',
|
||||
'input_type': 'number'
|
||||
}
|
||||
]
|
||||
config_options = []
|
||||
|
||||
return config_options
|
||||
|
||||
@@ -695,8 +694,15 @@ class RecentlyAdded(Newsletter):
|
||||
|
||||
if self.is_preview or plexpy.CONFIG.NEWSLETTER_SELF_HOSTED:
|
||||
for item in movies + shows + albums:
|
||||
if item['media_type'] == 'album':
|
||||
height = 150
|
||||
fallback = 'cover'
|
||||
else:
|
||||
height = 225
|
||||
fallback = 'poster'
|
||||
|
||||
item['thumb_hash'] = set_hash_image_info(
|
||||
img=item['thumb'], width=150, height=225, fallback='poster')
|
||||
img=item['thumb'], width=150, height=height, fallback=fallback)
|
||||
|
||||
if item['art']:
|
||||
item['art_hash'] = set_hash_image_info(
|
||||
@@ -711,9 +717,16 @@ class RecentlyAdded(Newsletter):
|
||||
else:
|
||||
# Upload posters and art to Imgur
|
||||
for item in movies + shows + albums:
|
||||
if item['media_type'] == 'album':
|
||||
height = 150
|
||||
fallback = 'cover'
|
||||
else:
|
||||
height = 225
|
||||
fallback = 'poster'
|
||||
|
||||
imgur_info = get_imgur_info(
|
||||
img=item['thumb'], rating_key=item['rating_key'], title=item['title'],
|
||||
width=150, height=225, fallback='poster')
|
||||
width=150, height=height, fallback=fallback)
|
||||
|
||||
item['poster_url'] = imgur_info.get('imgur_url') or common.ONLINE_POSTER_THUMB
|
||||
|
||||
|
@@ -252,10 +252,10 @@ def notify_custom_conditions(notifier_id=None, parameters=None):
|
||||
values = [unicode(v).lower() for v in values]
|
||||
|
||||
elif parameter_type == 'int':
|
||||
values = [int(v) for v in values]
|
||||
values = [helpers.cast_to_int(v) for v in values]
|
||||
|
||||
elif parameter_type == 'float':
|
||||
values = [float(v) for v in values]
|
||||
values = [helpers.cast_to_float(v) for v in values]
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(u"Tautulli NotificationHandler :: Unable to cast condition '%s', values '%s', to type '%s'."
|
||||
@@ -268,10 +268,10 @@ def notify_custom_conditions(notifier_id=None, parameters=None):
|
||||
parameter_value = unicode(parameter_value).lower()
|
||||
|
||||
elif parameter_type == 'int':
|
||||
parameter_value = int(parameter_value)
|
||||
parameter_value = helpers.cast_to_int(parameter_value)
|
||||
|
||||
elif parameter_type == 'float':
|
||||
parameter_value = float(parameter_value)
|
||||
parameter_value = helpers.cast_to_float(parameter_value)
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(u"Tautulli NotificationHandler :: Unable to cast parameter '%s', value '%s', to type '%s'."
|
||||
@@ -710,6 +710,7 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
|
||||
'server_platform': plexpy.CONFIG.PMS_PLATFORM,
|
||||
'server_version': plexpy.CONFIG.PMS_VERSION,
|
||||
'action': notify_action.lstrip('on_'),
|
||||
'week_number': arrow.now().isocalendar()[1],
|
||||
'datestamp': arrow.now().format(date_format),
|
||||
'timestamp': arrow.now().format(time_format),
|
||||
'unixtime': int(time.time()),
|
||||
|
@@ -2933,6 +2933,8 @@ class SCRIPTS(Notifier):
|
||||
'.sh': ''
|
||||
}
|
||||
|
||||
self.arg_overrides = ('python2', 'python3', 'python', 'pythonw', 'php', 'ruby', 'perl')
|
||||
|
||||
def list_scripts(self):
|
||||
scriptdir = self.config['script_folder']
|
||||
scripts = {'': ''}
|
||||
@@ -2994,12 +2996,12 @@ class SCRIPTS(Notifier):
|
||||
return False
|
||||
|
||||
if error:
|
||||
err = '\n '.join([helpers.sanitize(l) for l in error.splitlines()])
|
||||
err = '\n '.join([l for l in error.splitlines()])
|
||||
logger.error(u"Tautulli Notifiers :: Script error: \n %s" % err)
|
||||
return False
|
||||
|
||||
if output:
|
||||
out = '\n '.join([helpers.sanitize(l) for l in output.splitlines()])
|
||||
out = '\n '.join([l for l in output.splitlines()])
|
||||
logger.debug(u"Tautulli Notifiers :: Script returned: \n %s" % out)
|
||||
|
||||
if not self.script_killed:
|
||||
@@ -3055,7 +3057,7 @@ class SCRIPTS(Notifier):
|
||||
|
||||
# Allow overrides for shitty systems
|
||||
if prefix and script_args:
|
||||
if script_args[0] in ('python2', 'python', 'pythonw', 'php', 'ruby', 'perl'):
|
||||
if script_args[0] in self.arg_overrides:
|
||||
script[0] = script_args[0]
|
||||
del script_args[0]
|
||||
|
||||
|
@@ -1454,6 +1454,8 @@ class PmsConnect(object):
|
||||
|
||||
# Get the transcode details
|
||||
if session.getElementsByTagName('TranscodeSession'):
|
||||
transcode_session = True
|
||||
|
||||
transcode_info = session.getElementsByTagName('TranscodeSession')[0]
|
||||
|
||||
transcode_progress = helpers.get_xml_attr(transcode_info, 'progress')
|
||||
@@ -1482,6 +1484,8 @@ class PmsConnect(object):
|
||||
'throttled': '1' if helpers.get_xml_attr(transcode_info, 'throttled') == '1' else '0' # Keep for backwards compatibility
|
||||
}
|
||||
else:
|
||||
transcode_session = False
|
||||
|
||||
transcode_details = {'transcode_key': '',
|
||||
'transcode_throttled': 0,
|
||||
'transcode_progress': 0,
|
||||
@@ -1621,14 +1625,6 @@ class PmsConnect(object):
|
||||
'stream_subtitle_decision': ''
|
||||
}
|
||||
|
||||
# Generate a combined transcode decision value
|
||||
if video_details['stream_video_decision'] == 'transcode' or audio_details['stream_audio_decision'] == 'transcode':
|
||||
transcode_decision = 'transcode'
|
||||
elif video_details['stream_video_decision'] == 'copy' or audio_details['stream_audio_decision'] == 'copy':
|
||||
transcode_decision = 'copy'
|
||||
else:
|
||||
transcode_decision = 'direct play'
|
||||
|
||||
# Get the bif thumbnail
|
||||
indexes = helpers.get_xml_attr(stream_media_parts_info, 'indexes')
|
||||
view_offset = helpers.get_xml_attr(session, 'viewOffset')
|
||||
@@ -1659,7 +1655,6 @@ class PmsConnect(object):
|
||||
'stream_video_width': helpers.get_xml_attr(stream_media_info, 'width'),
|
||||
'stream_duration': helpers.get_xml_attr(stream_media_info, 'duration') or helpers.get_xml_attr(session, 'duration'),
|
||||
'stream_container_decision': 'direct play' if sync_id else helpers.get_xml_attr(stream_media_parts_info, 'decision').replace('directplay', 'direct play'),
|
||||
'transcode_decision': transcode_decision,
|
||||
'optimized_version': int(helpers.get_xml_attr(stream_media_info, 'proxyType') == '42'),
|
||||
'optimized_version_title': helpers.get_xml_attr(stream_media_info, 'title'),
|
||||
'synced_version': 1 if sync_id else 0,
|
||||
@@ -1795,7 +1790,7 @@ class PmsConnect(object):
|
||||
next((p for p in source_media_part_streams if p['type'] == '3'), source_subtitle_details))
|
||||
|
||||
# Overrides for live sessions
|
||||
if metadata_details.get('live') and transcode_decision == 'transcode':
|
||||
if metadata_details.get('live') and transcode_session:
|
||||
stream_details['stream_container_decision'] = 'transcode'
|
||||
stream_details['stream_container'] = transcode_details['transcode_container']
|
||||
|
||||
@@ -1809,6 +1804,16 @@ class PmsConnect(object):
|
||||
stream_details['stream_audio_channel_layout'] = common.AUDIO_CHANNELS.get(
|
||||
transcode_details['transcode_audio_channels'], transcode_details['transcode_audio_channels'])
|
||||
|
||||
# Generate a combined transcode decision value
|
||||
if video_details['stream_video_decision'] == 'transcode' or audio_details['stream_audio_decision'] == 'transcode':
|
||||
transcode_decision = 'transcode'
|
||||
elif video_details['stream_video_decision'] == 'copy' or audio_details['stream_audio_decision'] == 'copy':
|
||||
transcode_decision = 'copy'
|
||||
else:
|
||||
transcode_decision = 'direct play'
|
||||
|
||||
stream_details['transcode_decision'] = transcode_decision
|
||||
|
||||
# Get the quality profile
|
||||
if media_type in ('movie', 'episode', 'clip') and 'stream_bitrate' in stream_details:
|
||||
if sync_id:
|
||||
|
@@ -1,2 +1,2 @@
|
||||
PLEXPY_BRANCH = "beta"
|
||||
PLEXPY_RELEASE_VERSION = "v2.1.0-beta"
|
||||
PLEXPY_RELEASE_VERSION = "v2.1.1-beta"
|
||||
|
@@ -2310,13 +2310,13 @@ class WebInterface(object):
|
||||
try:
|
||||
temp_loglevel_and_time = l.split(' - ', 1)
|
||||
loglvl = temp_loglevel_and_time[1].split(' ::', 1)[0].strip()
|
||||
msg = unicode(l.split(' : ', 1)[1].replace('\n', ''), 'utf-8')
|
||||
msg = helpers.sanitize(unicode(l.split(' : ', 1)[1].replace('\n', ''), 'utf-8'))
|
||||
fa([temp_loglevel_and_time[0], loglvl, msg])
|
||||
except IndexError:
|
||||
# Add traceback message to previous msg.
|
||||
tl = (len(filt) - 1)
|
||||
n = len(l) - len(l.lstrip(' '))
|
||||
ll = ' ' * (2 * n) + unicode(l[n:], 'utf-8')
|
||||
ll = ' ' * (2 * n) + helpers.sanitize(unicode(l[n:], 'utf-8'))
|
||||
filt[tl][2] += '<br>' + ll
|
||||
continue
|
||||
|
||||
@@ -5521,7 +5521,8 @@ class WebInterface(object):
|
||||
"friendly_name": "",
|
||||
"cron": "0 0 * * 1",
|
||||
"active": 1
|
||||
"config": {"last_days": 7,
|
||||
"config": {"time_frame": 7,
|
||||
"time_frame_units": "days",
|
||||
"incl_libraries": [1, 2]
|
||||
},
|
||||
"email_config": {...},
|
||||
|
Reference in New Issue
Block a user