Merge branch 'master' into new_hubs

This commit is contained in:
blacktwin 2020-04-12 22:40:15 -04:00 committed by GitHub
commit 4cbccabee8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 519 additions and 40 deletions

View file

@ -10,8 +10,6 @@ services:
- docker
python:
- 2.7
- 3.4
- 3.6
env:
@ -23,8 +21,10 @@ env:
before_install:
- pip install --upgrade pip
- pip install --upgrade setuptools
- pip install --upgrade pytest pytest-cov coveralls
install:
- pip install .
- python -c "import plexapi; print('installation ok')"
- pip install --upgrade pytest pytest-cov coveralls
- pip install -r requirements_dev.txt
- '[ -z "${PLEXAPI_AUTH_MYPLEX_USERNAME}" ] && PYTHONPATH="$PWD:$PYTHONPATH" python -u tools/plex-bootstraptest.py
--destination plex --advertise-ip=127.0.0.1 --bootstrap-timeout 540 --docker-tag $PLEX_CONTAINER_TAG --unclaimed ||

View file

@ -6,9 +6,9 @@ from plexapi import log
class AlertListener(threading.Thread):
""" Creates a websocket connection to the PlexServer to optionally recieve alert notifications.
""" Creates a websocket connection to the PlexServer to optionally receive alert notifications.
These often include messages from Plex about media scans as well as updates to currently running
Transcode Sessions. This class implements threading.Thread, therfore to start monitoring
Transcode Sessions. This class implements threading.Thread, therefore to start monitoring
alerts you must call .start() on the object once it's created. When calling
`PlexServer.startAlertListener()`, the thread will be started for you.
@ -26,9 +26,9 @@ class AlertListener(threading.Thread):
Parameters:
server (:class:`~plexapi.server.PlexServer`): PlexServer this listener is connected to.
callback (func): Callback function to call on recieved messages. The callback function
callback (func): Callback function to call on received messages. The callback function
will be sent a single argument 'data' which will contain a dictionary of data
recieved from the server. :samp:`def my_callback(data): ...`
received from the server. :samp:`def my_callback(data): ...`
"""
key = '/:/websockets/notifications'
@ -48,15 +48,21 @@ class AlertListener(threading.Thread):
self._ws.run_forever()
def stop(self):
""" Stop the AlertListener thread. Once the notifier is stopped, it cannot be diractly
""" Stop the AlertListener thread. Once the notifier is stopped, it cannot be directly
started again. You must call :func:`plexapi.server.PlexServer.startAlertListener()`
from a PlexServer instance.
"""
log.info('Stopping AlertListener.')
self._ws.close()
def _onMessage(self, ws, message):
""" Called when websocket message is recieved. """
def _onMessage(self, *args):
""" Called when websocket message is received.
In earlier releases, websocket-client returned a tuple of two parameters: a websocket.app.WebSocketApp
object and the message as a STR. Current releases appear to only return the message.
We are assuming the last argument in the tuple is the message.
This is to support compatibility with current and previous releases of websocket-client.
"""
message = args[-1]
try:
data = json.loads(message)['NotificationContainer']
log.debug('Alert: %s %s %s', *data)
@ -65,6 +71,12 @@ class AlertListener(threading.Thread):
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. """
def _onError(self, *args): # pragma: no cover
""" Called when websocket error is received.
In earlier releases, websocket-client returned a tuple of two parameters: a websocket.app.WebSocketApp
object and the error. Current releases appear to only return the error.
We are assuming the last argument in the tuple is the message.
This is to support compatibility with current and previous releases of websocket-client.
"""
err = args[-1]
log.error('AlertListener Error: %s' % err)

View file

@ -132,6 +132,8 @@ class PlexObject(object):
* __regex: Value matches the specified regular expression.
* __startswith: Value starts with specified arg.
"""
if ekey is None:
raise BadRequest('ekey was not provided')
if isinstance(ekey, int):
ekey = '/library/metadata/%s' % ekey
for elem in self._server.query(ekey):
@ -145,6 +147,8 @@ class PlexObject(object):
and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details
on how this is used.
"""
if ekey is None:
raise BadRequest('ekey was not provided')
data = self._server.query(ekey)
items = self.findItems(data, cls, ekey, **kwargs)
librarySectionID = data.attrib.get('librarySectionID')
@ -429,7 +433,6 @@ class PlexPartialObject(PlexObject):
"""
return self._server.history(maxresults=maxresults, mindate=mindate, ratingKey=self.ratingKey)
# The photo tag cant be built atm. TODO
# def arts(self):
# part = '%s/arts' % self.key
@ -582,7 +585,7 @@ class Playable(object):
time, state)
self._server.query(key)
self.reload()
def updateTimeline(self, time, state='stopped', duration=None):
""" Set the timeline progress for this video.

View file

@ -7,7 +7,7 @@ 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.exceptions import BadRequest, Unauthorized, Unsupported
from plexapi.playqueue import PlayQueue
@ -162,8 +162,11 @@ class PlexClient(PlexObject):
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))
message = '(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext)
if response.status_code == 401:
raise Unauthorized(message)
else:
raise BadRequest(message)
data = response.text.encode('utf8')
return ElementTree.fromstring(data) if data.strip() else None
@ -204,10 +207,13 @@ class PlexClient(PlexObject):
return query(key, headers=headers)
except ElementTree.ParseError:
# Workaround for players which don't return valid XML on successful commands
# - Plexamp: `b'OK'`
# - Plexamp, Plex for Android: `b'OK'`
# - Plex for Samsung: `b'<?xml version="1.0"?><Response code="200" status="OK">'`
if self.product in (
'Plexamp',
'Plex for Android (TV)',
'Plex for Android (Mobile)',
'Plex for Samsung',
):
return
raise
@ -469,10 +475,15 @@ class PlexClient(PlexObject):
if hasattr(media, "playlistType"):
mediatype = media.playlistType
elif media.listType == "audio":
mediatype = "music"
else:
mediatype = "video"
if isinstance(media, PlayQueue):
mediatype = media.items[0].listType
else:
mediatype = media.listType
# mediatype must be in ["video", "music", "photo"]
if mediatype == "audio":
mediatype = "music"
if self.product != 'OpenPHT':
try:

View file

@ -25,9 +25,9 @@ except ImportError:
from urllib import quote
try:
from urllib.parse import quote_plus
from urllib.parse import quote_plus, quote
except ImportError:
from urllib import quote_plus
from urllib import quote_plus, quote
try:
from urllib.parse import unquote

View file

@ -26,6 +26,6 @@ class Unsupported(PlexApiException):
pass
class Unauthorized(PlexApiException):
""" Invalid username or password. """
class Unauthorized(BadRequest):
""" Invalid username/password or token. """
pass

147
plexapi/gdm.py Normal file
View file

@ -0,0 +1,147 @@
"""
Support for discovery using GDM (Good Day Mate), multicast protocol by Plex.
# Licensed Apache 2.0
# From https://github.com/home-assistant/netdisco/netdisco/gdm.py
Inspired by
hippojay's plexGDM:
https://github.com/hippojay/script.plexbmc.helper/resources/lib/plexgdm.py
iBaa's PlexConnect: https://github.com/iBaa/PlexConnect/PlexAPI.py
"""
import socket
import struct
class GDM:
"""Base class to discover GDM services."""
def __init__(self):
self.entries = []
self.last_scan = None
def scan(self, scan_for_clients=False):
"""Scan the network."""
self.update(scan_for_clients)
def all(self):
"""Return all found entries.
Will scan for entries if not scanned recently.
"""
self.scan()
return list(self.entries)
def find_by_content_type(self, value):
"""Return a list of entries that match the content_type."""
self.scan()
return [entry for entry in self.entries
if value in entry['data']['Content_Type']]
def find_by_data(self, values):
"""Return a list of entries that match the search parameters."""
self.scan()
return [entry for entry in self.entries
if all(item in entry['data'].items()
for item in values.items())]
def update(self, scan_for_clients):
"""Scan for new GDM services.
Examples of the dict list assigned to self.entries by this function:
Server:
[{'data': {
'Content-Type': 'plex/media-server',
'Host': '53f4b5b6023d41182fe88a99b0e714ba.plex.direct',
'Name': 'myfirstplexserver',
'Port': '32400',
'Resource-Identifier': '646ab0aa8a01c543e94ba975f6fd6efadc36b7',
'Updated-At': '1585769946',
'Version': '1.18.8.2527-740d4c206',
},
'from': ('10.10.10.100', 32414)}]
Clients:
[{'data': {'Content-Type': 'plex/media-player',
'Device-Class': 'stb',
'Name': 'plexamp',
'Port': '36000',
'Product': 'Plexamp',
'Protocol': 'plex',
'Protocol-Capabilities': 'timeline,playback,playqueues,playqueues-creation',
'Protocol-Version': '1',
'Resource-Identifier': 'b6e57a3f-e0f8-494f-8884-f4b58501467e',
'Version': '1.1.0',
},
'from': ('10.10.10.101', 32412)}]
"""
gdm_msg = 'M-SEARCH * HTTP/1.0'.encode('ascii')
gdm_timeout = 1
self.entries = []
known_responses = []
# setup socket for discovery -> multicast message
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(gdm_timeout)
# Set the time-to-live for messages for local network
sock.setsockopt(socket.IPPROTO_IP,
socket.IP_MULTICAST_TTL,
struct.pack("B", gdm_timeout))
if scan_for_clients:
# setup socket for broadcast to Plex clients
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
gdm_ip = '255.255.255.255'
gdm_port = 32412
else:
# setup socket for multicast to Plex server(s)
gdm_ip = '239.0.0.250'
gdm_port = 32414
try:
# Send data to the multicast group
sock.sendto(gdm_msg, (gdm_ip, gdm_port))
# Look for responses from all recipients
while True:
try:
bdata, host = sock.recvfrom(1024)
data = bdata.decode('utf-8')
if '200 OK' in data.splitlines()[0]:
ddata = {k: v.strip() for (k, v) in (
line.split(':') for line in
data.splitlines() if ':' in line)}
identifier = ddata.get('Resource-Identifier')
if identifier and identifier in known_responses:
continue
known_responses.append(identifier)
self.entries.append({'data': ddata,
'from': host})
except socket.timeout:
break
finally:
sock.close()
def main():
"""Test GDM discovery."""
from pprint import pprint
gdm = GDM()
pprint("Scanning GDM for servers...")
gdm.scan()
pprint(gdm.entries)
pprint("Scanning GDM for clients...")
gdm.scan(scan_for_clients=True)
pprint(gdm.entries)
if __name__ == "__main__":
main()

View file

@ -349,6 +349,121 @@ class TranscodeSession(PlexObject):
self.width = cast(int, data.attrib.get('width'))
@utils.registerPlexObject
class TranscodeJob(PlexObject):
""" Represents an Optimizing job.
TrancodeJobs are the process for optimizing conversions.
Active or paused optimization items. Usually one item as a time"""
TAG = 'TranscodeJob'
def _loadData(self, data):
self._data = data
self.generatorID = data.attrib.get('generatorID')
self.key = data.attrib.get('key')
self.progress = data.attrib.get('progress')
self.ratingKey = data.attrib.get('ratingKey')
self.size = data.attrib.get('size')
self.targetTagID = data.attrib.get('targetTagID')
self.thumb = data.attrib.get('thumb')
self.title = data.attrib.get('title')
self.type = data.attrib.get('type')
@utils.registerPlexObject
class Optimized(PlexObject):
""" Represents a Optimized item.
Optimized items are optimized and queued conversions items."""
TAG = 'Item'
def _loadData(self, data):
self._data = data
self.id = data.attrib.get('id')
self.composite = data.attrib.get('composite')
self.title = data.attrib.get('title')
self.type = data.attrib.get('type')
self.target = data.attrib.get('target')
self.targetTagID = data.attrib.get('targetTagID')
def remove(self):
""" Remove an Optimized item"""
key = '%s/%s' % (self._initpath, self.id)
self._server.query(key, method=self._server._session.delete)
def rename(self, title):
""" Rename an Optimized item"""
key = '%s/%s?Item[title]=%s' % (self._initpath, self.id, title)
self._server.query(key, method=self._server._session.put)
def reprocess(self, ratingKey):
""" Reprocess a removed Conversion item that is still a listed Optimize item"""
key = '%s/%s/%s/enable' % (self._initpath, self.id, ratingKey)
self._server.query(key, method=self._server._session.put)
@utils.registerPlexObject
class Conversion(PlexObject):
""" Represents a Conversion item.
Conversions are items queued for optimization or being actively optimized."""
TAG = 'Video'
def _loadData(self, data):
self._data = data
self.addedAt = data.attrib.get('addedAt')
self.art = data.attrib.get('art')
self.chapterSource = data.attrib.get('chapterSource')
self.contentRating = data.attrib.get('contentRating')
self.duration = data.attrib.get('duration')
self.generatorID = data.attrib.get('generatorID')
self.generatorType = data.attrib.get('generatorType')
self.guid = data.attrib.get('guid')
self.key = data.attrib.get('key')
self.lastViewedAt = data.attrib.get('lastViewedAt')
self.librarySectionID = data.attrib.get('librarySectionID')
self.librarySectionKey = data.attrib.get('librarySectionKey')
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
self.originallyAvailableAt = data.attrib.get('originallyAvailableAt')
self.playQueueItemID = data.attrib.get('playQueueItemID')
self.playlistID = data.attrib.get('playlistID')
self.primaryExtraKey = data.attrib.get('primaryExtraKey')
self.rating = data.attrib.get('rating')
self.ratingKey = data.attrib.get('ratingKey')
self.studio = data.attrib.get('studio')
self.summary = data.attrib.get('summary')
self.tagline = data.attrib.get('tagline')
self.target = data.attrib.get('target')
self.thumb = data.attrib.get('thumb')
self.title = data.attrib.get('title')
self.type = data.attrib.get('type')
self.updatedAt = data.attrib.get('updatedAt')
self.userID = data.attrib.get('userID')
self.username = data.attrib.get('username')
self.viewOffset = data.attrib.get('viewOffset')
self.year = data.attrib.get('year')
def remove(self):
""" Remove Conversion from queue """
key = '/playlists/%s/items/%s/%s/disable' % (self.playlistID, self.generatorID, self.ratingKey)
self._server.query(key, method=self._server._session.put)
def move(self, after):
""" Move Conversion items position in queue
after (int): Positional integer to move item
-1 Active conversion
OR
Use another conversion items playQueueItemID to move in front of
Example:
Move 5th conversion Item to active conversion
conversions[4].move('-1')
Move 4th conversion Item to 2nd in conversion queue
conversions[3].move(conversions[1].playQueueItemID)
"""
key = '%s/items/%s/move?after=%s' % (self._initpath, self.playQueueItemID, after)
self._server.query(key, method=self._server._session.put)
class MediaTag(PlexObject):
""" Base class for media tags used for filtering and searching your library
items or navigating the metadata of media items in your library. Tags are

View file

@ -6,9 +6,9 @@ import requests
from plexapi import (BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_ENABLE_FAST_CONNECT,
X_PLEX_IDENTIFIER, log, logfilter, utils)
from plexapi.base import PlexObject
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
from plexapi.client import PlexClient
from plexapi.compat import ElementTree
from plexapi.exceptions import BadRequest, NotFound
from plexapi.library import LibrarySection
from plexapi.server import PlexServer
from plexapi.sync import SyncItem, SyncList
@ -182,7 +182,11 @@ class MyPlexAccount(PlexObject):
if response.status_code not in (200, 201, 204): # pragma: no cover
codename = codes.get(response.status_code)[0]
errtext = response.text.replace('\n', ' ')
raise BadRequest('(%s) %s %s; %s' % (response.status_code, codename, response.url, errtext))
message = '(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext)
if response.status_code == 401:
raise Unauthorized(message)
else:
raise BadRequest(message)
data = response.text.encode('utf8')
return ElementTree.fromstring(data) if data.strip() else None

View file

@ -7,12 +7,13 @@ 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.exceptions import BadRequest, NotFound, Unauthorized
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
from plexapi.media import Optimized, Conversion
# Need these imports to populate utils.PLEXOBJECTS
from plexapi import (audio as _audio, video as _video, # noqa: F401
@ -372,6 +373,36 @@ class PlexServer(PlexObject):
"""
return self.fetchItem('/playlists', title=title)
def optimizedItems(self, removeAll=None):
""" Returns list of all :class:`~plexapi.media.Optimized` objects connected to server. """
if removeAll is True:
key = '/playlists/generators?type=42'
self.query(key, method=self._server._session.delete)
else:
backgroundProcessing = self.fetchItem('/playlists?type=42')
return self.fetchItems('%s/items' % backgroundProcessing.key, cls=Optimized)
def optimizedItem(self, optimizedID):
""" Returns single queued optimized item :class:`~plexapi.media.Video` object.
Allows for using optimized item ID to connect back to source item.
"""
backgroundProcessing = self.fetchItem('/playlists?type=42')
return self.fetchItem('%s/items/%s/items' % (backgroundProcessing.key, optimizedID))
def conversions(self, pause=None):
""" Returns list of all :class:`~plexapi.media.Conversion` objects connected to server. """
if pause is True:
self.query('/:/prefs?BackgroundQueueIdlePaused=1', method=self._server._session.put)
elif pause is False:
self.query('/:/prefs?BackgroundQueueIdlePaused=0', method=self._server._session.put)
else:
return self.fetchItems('/playQueues/1', cls=Conversion)
def currentBackgroundProcess(self):
""" Returns list of all :class:`~plexapi.media.TranscodeJob` objects running or paused on server. """
return self.fetchItems('/status/sessions/background')
def query(self, key, method=None, headers=None, 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
@ -386,8 +417,11 @@ class PlexServer(PlexObject):
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))
message = '(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext)
if response.status_code == 401:
raise Unauthorized(message)
else:
raise BadRequest(message)
data = response.text.encode('utf8')
return ElementTree.fromstring(data) if data.strip() else None
@ -481,6 +515,25 @@ class PlexServer(PlexObject):
self.refreshSynclist()
self.refreshContent()
def _allowMediaDeletion(self, toggle=False):
""" Toggle allowMediaDeletion.
Parameters:
toggle (bool): True enables Media Deletion
False or None disable Media Deletion (Default)
"""
if self.allowMediaDeletion and toggle is False:
log.debug('Plex is currently allowed to delete media. Toggling off.')
elif self.allowMediaDeletion and toggle is True:
log.debug('Plex is currently allowed to delete media. Toggle set to allow, exiting.')
raise BadRequest('Plex is currently allowed to delete media. Toggle set to allow, exiting.')
elif self.allowMediaDeletion is None and toggle is True:
log.debug('Plex is currently not allowed to delete media. Toggle set to allow.')
else:
log.debug('Plex is currently not allowed to delete media. Toggle set to not allow, exiting.')
raise BadRequest('Plex is currently not allowed to delete media. Toggle set to not allow, exiting.')
value = 1 if toggle is True else 0
return self.query('/:/prefs?allowMediaDeletion=%s' % value, self._session.put)
class Account(PlexObject):
""" Contains the locally cached MyPlex account information. The properties provided don't

View file

@ -1,10 +1,9 @@
# -*- coding: utf-8 -*-
import os
from plexapi import media, utils
from plexapi.base import Playable, PlexPartialObject
from plexapi.compat import quote_plus
from plexapi.exceptions import BadRequest, NotFound
from plexapi.base import Playable, PlexPartialObject
from plexapi.compat import quote_plus, urlencode
import os
class Video(PlexPartialObject):
@ -127,6 +126,82 @@ class Video(PlexPartialObject):
return self.fetchItems('%s/posters' % self.key, cls=media.Poster)
def optimize(self, title=None, target="", targetTagID=None, locationID=-1, policyScope='all',
policyValue="", policyUnwatched=0, videoQuality=None, deviceProfile=None):
""" Optimize item
locationID (int): -1 in folder with orginal items
2 library path
target (str): custom quality name.
if none provided use "Custom: {deviceProfile}"
targetTagID (int): Default quality settings
1 Mobile
2 TV
3 Original Quality
deviceProfile (str): Android, IOS, Universal TV, Universal Mobile, Windows Phone,
Windows, Xbox One
Example:
Optimize for Mobile
item.optimize(targetTagID="Mobile") or item.optimize(targetTagID=1")
Optimize for Android 10 MBPS 1080p
item.optimize(deviceProfile="Android", videoQuality=10)
Optimize for IOS Original Quality
item.optimize(deviceProfile="IOS", videoQuality=-1)
* see sync.py VIDEO_QUALITIES for additional information for using videoQuality
"""
tagValues = [1, 2, 3]
tagKeys = ["Mobile", "TV", "Original Quality"]
tagIDs = tagKeys + tagValues
if targetTagID not in tagIDs and (deviceProfile is None or videoQuality is None):
raise BadRequest('Unexpected or missing quality profile.')
if isinstance(targetTagID, str):
tagIndex = tagKeys.index(targetTagID)
targetTagID = tagValues[tagIndex]
if title is None:
title = self.title
backgroundProcessing = self.fetchItem('/playlists?type=42')
key = '%s/items?' % backgroundProcessing.key
params = {
'Item[type]': 42,
'Item[target]': target,
'Item[targetTagID]': targetTagID if targetTagID else '',
'Item[locationID]': locationID,
'Item[Policy][scope]': policyScope,
'Item[Policy][value]': policyValue,
'Item[Policy][unwatched]': policyUnwatched
}
if deviceProfile:
params['Item[Device][profile]'] = deviceProfile
if videoQuality:
from plexapi.sync import MediaSettings
mediaSettings = MediaSettings.createVideo(videoQuality)
params['Item[MediaSettings][videoQuality]'] = mediaSettings.videoQuality
params['Item[MediaSettings][videoResolution]'] = mediaSettings.videoResolution
params['Item[MediaSettings][maxVideoBitrate]'] = mediaSettings.maxVideoBitrate
params['Item[MediaSettings][audioBoost]'] = ''
params['Item[MediaSettings][subtitleSize]'] = ''
params['Item[MediaSettings][musicBitrate]'] = ''
params['Item[MediaSettings][photoQuality]'] = ''
titleParam = {'Item[title]': title}
section = self._server.library.sectionByID(self.librarySectionID)
params['Item[Location][uri]'] = 'library://' + section.uuid + '/item/' + \
quote_plus(self.key + '?includeExternalMedia=1')
data = key + urlencode(params) + '&' + urlencode(titleParam)
return self._server.query(data, method=self._server._session.put)
def sync(self, videoQuality, client=None, clientId=None, limit=None, unwatched=False, title=None):
""" Add current video (movie, tv-show, season or episode) as sync item for specified device.
See :func:`plexapi.myplex.MyPlexAccount.sync()` for possible exceptions.
@ -506,7 +581,7 @@ class Season(Video):
def show(self):
""" Return this seasons :func:`~plexapi.video.Show`.. """
return self.fetchItem(self.parentKey)
return self.fetchItem(int(self.parentRatingKey))
def watched(self):
""" Returns list of watched :class:`~plexapi.video.Episode` objects. """
@ -647,7 +722,7 @@ class Episode(Playable, Video):
def show(self):
"""" Return this episodes :func:`~plexapi.video.Show`.. """
return self.fetchItem(self.grandparentKey)
return self.fetchItem(int(self.grandparentRatingKey))
def _defaultSyncTitle(self):
""" Returns str, default title for a new syncItem. """

View file

@ -4,4 +4,5 @@
#---------------------------------------------------------
requests
tqdm
websocket-client==0.48.0
websocket-client
mock; python_version < '3.3'

View file

@ -14,7 +14,7 @@ requests
sphinx
sphinxcontrib-napoleon
tqdm
websocket-client==0.48.0
websocket-client
# Installing sphinx-rtd-theme directly from github above is used until a point release
# above 0.4.3 is released. https://github.com/readthedocs/sphinx_rtd_theme/issues/739

View file

@ -269,3 +269,41 @@ def test_server_downloadLogs(tmpdir, plex):
def test_server_downloadDatabases(tmpdir, plex):
plex.downloadDatabases(savepath=str(tmpdir), unpack=True)
assert len(tmpdir.listdir()) > 1
def test_server_allowMediaDeletion(account):
plex = PlexServer(utils.SERVER_BASEURL, account.authenticationToken)
# Check server current allowMediaDeletion setting
if plex.allowMediaDeletion:
# If allowed then test disallowed
plex._allowMediaDeletion(False)
time.sleep(1)
plex = PlexServer(utils.SERVER_BASEURL, account.authenticationToken)
assert plex.allowMediaDeletion is None
# Test redundant toggle
with pytest.raises(BadRequest):
plex._allowMediaDeletion(False)
plex._allowMediaDeletion(True)
time.sleep(1)
plex = PlexServer(utils.SERVER_BASEURL, account.authenticationToken)
assert plex.allowMediaDeletion is True
# Test redundant toggle
with pytest.raises(BadRequest):
plex._allowMediaDeletion(True)
else:
# If disallowed then test allowed
plex._allowMediaDeletion(True)
time.sleep(1)
plex = PlexServer(utils.SERVER_BASEURL, account.authenticationToken)
assert plex.allowMediaDeletion is True
# Test redundant toggle
with pytest.raises(BadRequest):
plex._allowMediaDeletion(True)
plex._allowMediaDeletion(False)
time.sleep(1)
plex = PlexServer(utils.SERVER_BASEURL, account.authenticationToken)
assert plex.allowMediaDeletion is None
# Test redundant toggle
with pytest.raises(BadRequest):
plex._allowMediaDeletion(False)

View file

@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
import pytest
from datetime import datetime
from time import sleep
from plexapi.exceptions import BadRequest, NotFound
from . import conftest as utils
@ -685,4 +686,22 @@ def test_video_exists_accessible(movie, episode):
episode.reload()
assert episode.media[0].parts[0].exists is True
assert episode.media[0].parts[0].accessible is True
@pytest.mark.skip(reason='broken? assert len(plex.conversions()) == 1 may fail on some builds')
def test_video_optimize(movie, plex):
plex.optimizedItems(removeAll=True)
movie.optimize(targetTagID=1)
plex.conversions(pause=True)
sleep(1)
assert len(plex.optimizedItems()) == 1
assert len(plex.conversions()) == 1
conversion = plex.conversions()[0]
conversion.remove()
assert len(plex.conversions()) == 0
assert len(plex.optimizedItems()) == 1
optimized = plex.optimizedItems()[0]
video = plex.optimizedItem(optimizedID=optimized.id)
assert movie.key == video.key
plex.optimizedItems(removeAll=True)
assert len(plex.optimizedItems()) == 0

View file

@ -124,13 +124,14 @@ def create_section(server, section, opts):
start = time.time()
bar = tqdm(desc='Scanning section ' + section['name'], total=expected_media_count)
notifier = server.startAlertListener(alert_callback)
time.sleep(3)
add_library_section(server, section)
while bar.n < bar.total:
if runtime >= 120:
print('Metadata scan taking too long, but will continue anyway..')
break
time.sleep(3)
runtime = time.time() - start
runtime = int(time.time() - start)
bar.close()
notifier.stop()