2016-03-21 04:26:02 +00:00
|
|
|
# -*- coding: utf-8 -*-
|
2020-11-16 01:54:48 +00:00
|
|
|
import base64
|
2020-12-13 20:08:38 +00:00
|
|
|
import functools
|
2017-08-13 05:58:08 +00:00
|
|
|
import logging
|
|
|
|
import os
|
|
|
|
import re
|
2021-11-20 22:16:58 +00:00
|
|
|
import string
|
2017-08-13 05:58:08 +00:00
|
|
|
import time
|
2021-11-20 22:16:58 +00:00
|
|
|
import unicodedata
|
2020-12-13 20:08:38 +00:00
|
|
|
import warnings
|
2017-08-13 05:58:08 +00:00
|
|
|
import zipfile
|
2022-05-17 02:46:10 +00:00
|
|
|
from collections import deque
|
2020-08-07 20:34:57 +00:00
|
|
|
from datetime import datetime
|
2017-08-13 05:50:40 +00:00
|
|
|
from getpass import getpass
|
2020-04-15 22:30:00 +00:00
|
|
|
from threading import Event, Thread
|
2020-05-12 21:15:16 +00:00
|
|
|
from urllib.parse import quote
|
2020-04-15 22:30:00 +00:00
|
|
|
|
|
|
|
import requests
|
2020-12-06 23:56:26 +00:00
|
|
|
from plexapi.exceptions import BadRequest, NotFound
|
2020-04-27 19:12:18 +00:00
|
|
|
|
|
|
|
try:
|
|
|
|
from tqdm import tqdm
|
|
|
|
except ImportError:
|
|
|
|
tqdm = None
|
2016-03-17 04:51:20 +00:00
|
|
|
|
2019-08-22 12:25:38 +00:00
|
|
|
log = logging.getLogger('plexapi')
|
2019-07-26 11:16:55 +00:00
|
|
|
|
2016-03-31 20:52:48 +00:00
|
|
|
# Search Types - Plex uses these to filter specific media types when searching.
|
2022-07-21 03:07:39 +00:00
|
|
|
SEARCHTYPES = {
|
|
|
|
'movie': 1,
|
|
|
|
'show': 2,
|
|
|
|
'season': 3,
|
|
|
|
'episode': 4,
|
|
|
|
'trailer': 5,
|
|
|
|
'comic': 6,
|
|
|
|
'person': 7,
|
|
|
|
'artist': 8,
|
|
|
|
'album': 9,
|
|
|
|
'track': 10,
|
|
|
|
'picture': 11,
|
|
|
|
'clip': 12,
|
|
|
|
'photo': 13,
|
|
|
|
'photoalbum': 14,
|
|
|
|
'playlist': 15,
|
|
|
|
'playlistFolder': 16,
|
|
|
|
'collection': 18,
|
|
|
|
'optimizedVersion': 42,
|
|
|
|
'userPlaylistItem': 1001,
|
|
|
|
}
|
|
|
|
# Tag Types - Plex uses these to filter specific tags when searching.
|
|
|
|
TAGTYPES = {
|
|
|
|
'tag': 0,
|
|
|
|
'genre': 1,
|
|
|
|
'collection': 2,
|
|
|
|
'director': 4,
|
|
|
|
'writer': 5,
|
|
|
|
'role': 6,
|
|
|
|
'producer': 7,
|
|
|
|
'country': 8,
|
|
|
|
'chapter': 9,
|
|
|
|
'review': 10,
|
|
|
|
'label': 11,
|
|
|
|
'marker': 12,
|
|
|
|
'mediaProcessingTarget': 42,
|
|
|
|
'make': 200,
|
|
|
|
'model': 201,
|
|
|
|
'aperture': 202,
|
|
|
|
'exposure': 203,
|
|
|
|
'iso': 204,
|
|
|
|
'lens': 205,
|
|
|
|
'device': 206,
|
|
|
|
'autotag': 207,
|
|
|
|
'mood': 300,
|
|
|
|
'style': 301,
|
|
|
|
'format': 302,
|
|
|
|
'similar': 305,
|
|
|
|
'concert': 306,
|
|
|
|
'banner': 311,
|
|
|
|
'poster': 312,
|
|
|
|
'art': 313,
|
|
|
|
'guid': 314,
|
|
|
|
'ratingImage': 316,
|
|
|
|
'theme': 317,
|
|
|
|
'studio': 318,
|
|
|
|
'network': 319,
|
|
|
|
'place': 400,
|
|
|
|
}
|
|
|
|
# Plex Objects - Populated at runtime
|
2017-02-13 02:55:55 +00:00
|
|
|
PLEXOBJECTS = {}
|
2016-12-16 00:17:02 +00:00
|
|
|
|
|
|
|
|
2017-01-26 06:33:01 +00:00
|
|
|
class SecretsFilter(logging.Filter):
|
|
|
|
""" Logging filter to hide secrets. """
|
2017-07-17 23:14:16 +00:00
|
|
|
|
2017-01-26 06:33:01 +00:00
|
|
|
def __init__(self, secrets=None):
|
|
|
|
self.secrets = secrets or set()
|
|
|
|
|
|
|
|
def add_secret(self, secret):
|
2017-02-13 06:37:23 +00:00
|
|
|
if secret is not None:
|
|
|
|
self.secrets.add(secret)
|
|
|
|
return secret
|
2017-01-26 06:33:01 +00:00
|
|
|
|
|
|
|
def filter(self, record):
|
|
|
|
cleanargs = list(record.args)
|
|
|
|
for i in range(len(cleanargs)):
|
2020-05-12 21:15:16 +00:00
|
|
|
if isinstance(cleanargs[i], str):
|
2017-01-26 06:44:55 +00:00
|
|
|
for secret in self.secrets:
|
|
|
|
cleanargs[i] = cleanargs[i].replace(secret, '<hidden>')
|
2017-01-26 06:33:01 +00:00
|
|
|
record.args = tuple(cleanargs)
|
|
|
|
return True
|
2014-12-29 03:21:58 +00:00
|
|
|
|
2016-03-17 04:51:20 +00:00
|
|
|
|
2017-02-13 02:55:55 +00:00
|
|
|
def registerPlexObject(cls):
|
2017-02-04 19:46:51 +00:00
|
|
|
""" Registry of library types we may come across when parsing XML. This allows us to
|
2022-02-27 03:26:08 +00:00
|
|
|
define a few helper functions to dynamically convert the XML into objects. See
|
2017-02-04 19:46:51 +00:00
|
|
|
buildItem() below for an example.
|
2016-12-16 00:17:02 +00:00
|
|
|
"""
|
2021-01-24 20:48:38 +00:00
|
|
|
etype = getattr(cls, 'STREAMTYPE', getattr(cls, 'TAGTYPE', cls.TYPE))
|
2017-02-13 02:55:55 +00:00
|
|
|
ehash = '%s.%s' % (cls.TAG, etype) if etype else cls.TAG
|
2022-07-21 03:03:20 +00:00
|
|
|
if getattr(cls, '_SESSIONTYPE', None):
|
|
|
|
ehash = '%s.%s' % (ehash, 'session')
|
2017-02-13 02:55:55 +00:00
|
|
|
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
|
2017-02-04 19:46:51 +00:00
|
|
|
return cls
|
2017-01-09 14:21:54 +00:00
|
|
|
|
2014-12-29 03:21:58 +00:00
|
|
|
|
2016-03-17 05:14:31 +00:00
|
|
|
def cast(func, value):
|
2017-02-03 06:29:19 +00:00
|
|
|
""" Cast the specified value to the specified type (returned by func). Currently this
|
2020-03-11 05:11:36 +00:00
|
|
|
only support str, int, float, bool. Should be extended if needed.
|
2017-01-03 22:58:35 +00:00
|
|
|
|
2017-01-26 05:25:13 +00:00
|
|
|
Parameters:
|
2022-02-27 03:26:08 +00:00
|
|
|
func (func): Callback function to used cast to type (int, bool, float).
|
2017-01-26 05:25:13 +00:00
|
|
|
value (any): value to be cast and returned.
|
2016-12-16 23:38:08 +00:00
|
|
|
"""
|
2017-02-04 17:43:50 +00:00
|
|
|
if value is not None:
|
2017-02-03 15:25:11 +00:00
|
|
|
if func == bool:
|
2020-04-15 22:30:00 +00:00
|
|
|
if value in (1, True, "1", "true"):
|
|
|
|
return True
|
|
|
|
elif value in (0, False, "0", "false"):
|
|
|
|
return False
|
|
|
|
else:
|
|
|
|
raise ValueError(value)
|
|
|
|
|
2017-02-03 15:25:11 +00:00
|
|
|
elif func in (int, float):
|
|
|
|
try:
|
|
|
|
return func(value)
|
|
|
|
except ValueError:
|
|
|
|
return float('nan')
|
|
|
|
return func(value)
|
|
|
|
return value
|
2016-03-17 05:14:31 +00:00
|
|
|
|
|
|
|
|
|
|
|
def joinArgs(args):
|
2017-01-26 05:25:13 +00:00
|
|
|
""" Returns a query string (uses for HTTP URLs) where only the value is URL encoded.
|
|
|
|
Example return value: '?genre=action&type=1337'.
|
2016-12-16 23:38:08 +00:00
|
|
|
|
2017-01-26 05:25:13 +00:00
|
|
|
Parameters:
|
|
|
|
args (dict): Arguments to include in query string.
|
2016-12-16 23:38:08 +00:00
|
|
|
"""
|
2016-12-16 00:17:02 +00:00
|
|
|
if not args:
|
|
|
|
return ''
|
2016-03-17 05:14:31 +00:00
|
|
|
arglist = []
|
2016-12-16 00:17:02 +00:00
|
|
|
for key in sorted(args, key=lambda x: x.lower()):
|
2020-05-12 21:15:16 +00:00
|
|
|
value = str(args[key])
|
|
|
|
arglist.append('%s=%s' % (key, quote(value, safe='')))
|
2016-03-17 05:14:31 +00:00
|
|
|
return '?%s' % '&'.join(arglist)
|
|
|
|
|
|
|
|
|
2017-02-23 06:33:30 +00:00
|
|
|
def lowerFirst(s):
|
|
|
|
return s[0].lower() + s[1:]
|
|
|
|
|
|
|
|
|
2017-02-01 21:32:00 +00:00
|
|
|
def rget(obj, attrstr, default=None, delim='.'): # pragma: no cover
|
2022-02-27 03:26:08 +00:00
|
|
|
""" Returns the value at the specified attrstr location within a nested tree of
|
2020-07-18 12:45:41 +00:00
|
|
|
dicts, lists, tuples, functions, classes, etc. The lookup is done recursively
|
2017-01-26 05:25:13 +00:00
|
|
|
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.
|
|
|
|
"""
|
2016-03-24 06:20:08 +00:00
|
|
|
try:
|
|
|
|
parts = attrstr.split(delim, 1)
|
|
|
|
attr = parts[0]
|
|
|
|
attrstr = parts[1] if len(parts) == 2 else None
|
2016-12-16 00:17:02 +00:00
|
|
|
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)
|
2016-03-24 06:20:08 +00:00
|
|
|
return value
|
2017-10-25 16:34:59 +00:00
|
|
|
except: # noqa: E722
|
2016-03-24 06:20:08 +00:00
|
|
|
return default
|
2016-12-16 00:17:02 +00:00
|
|
|
|
2016-03-24 06:20:08 +00:00
|
|
|
|
2016-03-21 04:26:02 +00:00
|
|
|
def searchType(libtype):
|
2017-01-26 05:25:13 +00:00
|
|
|
""" Returns the integer value of the library string type.
|
2016-12-16 23:38:08 +00:00
|
|
|
|
2017-01-26 05:25:13 +00:00
|
|
|
Parameters:
|
2022-07-21 03:07:39 +00:00
|
|
|
libtype (str): LibType to lookup (See :data:`~plexapi.utils.SEARCHTYPES`)
|
2021-06-18 21:21:43 +00:00
|
|
|
|
2017-01-26 05:25:13 +00:00
|
|
|
Raises:
|
2020-11-23 20:20:56 +00:00
|
|
|
:exc:`~plexapi.exceptions.NotFound`: Unknown libtype
|
2016-12-16 23:38:08 +00:00
|
|
|
"""
|
2020-05-12 21:15:16 +00:00
|
|
|
libtype = str(libtype)
|
|
|
|
if libtype in [str(v) for v in SEARCHTYPES.values()]:
|
2016-03-31 20:52:48 +00:00
|
|
|
return libtype
|
2016-04-13 02:55:45 +00:00
|
|
|
if SEARCHTYPES.get(libtype) is not None:
|
|
|
|
return SEARCHTYPES[libtype]
|
|
|
|
raise NotFound('Unknown libtype: %s' % libtype)
|
2016-03-17 04:51:20 +00:00
|
|
|
|
|
|
|
|
2021-06-18 21:21:43 +00:00
|
|
|
def reverseSearchType(libtype):
|
|
|
|
""" Returns the string value of the library type.
|
|
|
|
|
|
|
|
Parameters:
|
|
|
|
libtype (int): Integer value of the library type.
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
:exc:`~plexapi.exceptions.NotFound`: Unknown libtype
|
|
|
|
"""
|
|
|
|
if libtype in SEARCHTYPES:
|
|
|
|
return libtype
|
|
|
|
libtype = int(libtype)
|
|
|
|
for k, v in SEARCHTYPES.items():
|
|
|
|
if libtype == v:
|
|
|
|
return k
|
|
|
|
raise NotFound('Unknown libtype: %s' % libtype)
|
|
|
|
|
|
|
|
|
2022-07-21 03:07:39 +00:00
|
|
|
def tagType(tag):
|
|
|
|
""" Returns the integer value of the library tag type.
|
|
|
|
|
|
|
|
Parameters:
|
|
|
|
tag (str): Tag to lookup (See :data:`~plexapi.utils.TAGTYPES`)
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
:exc:`~plexapi.exceptions.NotFound`: Unknown tag
|
|
|
|
"""
|
|
|
|
tag = str(tag)
|
|
|
|
if tag in [str(v) for v in TAGTYPES.values()]:
|
|
|
|
return tag
|
|
|
|
if TAGTYPES.get(tag) is not None:
|
|
|
|
return TAGTYPES[tag]
|
|
|
|
raise NotFound('Unknown tag: %s' % tag)
|
|
|
|
|
|
|
|
|
|
|
|
def reverseTagType(tag):
|
|
|
|
""" Returns the string value of the library tag type.
|
|
|
|
|
|
|
|
Parameters:
|
|
|
|
tag (int): Integer value of the library tag type.
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
:exc:`~plexapi.exceptions.NotFound`: Unknown tag
|
|
|
|
"""
|
|
|
|
if tag in TAGTYPES:
|
|
|
|
return tag
|
|
|
|
tag = int(tag)
|
|
|
|
for k, v in TAGTYPES.items():
|
|
|
|
if tag == v:
|
|
|
|
return k
|
|
|
|
raise NotFound('Unknown tag: %s' % tag)
|
|
|
|
|
|
|
|
|
2016-04-02 06:19:32 +00:00
|
|
|
def threaded(callback, listargs):
|
2020-12-13 20:36:43 +00:00
|
|
|
""" Returns the result of <callback> for each set of `*args` in listargs. Each call
|
2018-09-14 18:28:35 +00:00
|
|
|
to <callback> is called concurrently in their own separate threads.
|
2016-12-16 23:38:08 +00:00
|
|
|
|
2017-01-26 05:25:13 +00:00
|
|
|
Parameters:
|
2020-12-13 20:36:43 +00:00
|
|
|
callback (func): Callback function to apply to each set of `*args`.
|
|
|
|
listargs (list): List of lists; `*args` to pass each thread.
|
2016-12-16 23:38:08 +00:00
|
|
|
"""
|
2016-04-02 06:19:32 +00:00
|
|
|
threads, results = [], []
|
2018-09-14 18:28:35 +00:00
|
|
|
job_is_done_event = Event()
|
2016-04-02 06:19:32 +00:00
|
|
|
for args in listargs:
|
|
|
|
args += [results, len(results)]
|
|
|
|
results.append(None)
|
2018-09-14 18:28:35 +00:00
|
|
|
threads.append(Thread(target=callback, args=args, kwargs=dict(job_is_done_event=job_is_done_event)))
|
2022-05-17 02:46:21 +00:00
|
|
|
threads[-1].daemon = True
|
2016-04-02 06:19:32 +00:00
|
|
|
threads[-1].start()
|
2018-09-14 18:28:35 +00:00
|
|
|
while not job_is_done_event.is_set():
|
2021-02-24 17:55:53 +00:00
|
|
|
if all(not t.is_alive() for t in threads):
|
2018-09-14 18:28:35 +00:00
|
|
|
break
|
|
|
|
time.sleep(0.05)
|
|
|
|
|
|
|
|
return [r for r in results if r is not None]
|
2016-04-02 06:19:32 +00:00
|
|
|
|
|
|
|
|
2014-12-29 03:21:58 +00:00
|
|
|
def toDatetime(value, format=None):
|
2017-01-26 05:25:13 +00:00
|
|
|
""" Returns a datetime object from the specified value.
|
2016-12-16 23:38:08 +00:00
|
|
|
|
2017-01-26 05:25:13 +00:00
|
|
|
Parameters:
|
|
|
|
value (str): value to return as a datetime
|
|
|
|
format (str): Format to pass strftime (optional; if value is a str).
|
2016-12-16 23:38:08 +00:00
|
|
|
"""
|
2017-02-04 17:43:50 +00:00
|
|
|
if value and value is not None:
|
2016-12-16 00:17:02 +00:00
|
|
|
if format:
|
2019-07-26 10:11:00 +00:00
|
|
|
try:
|
|
|
|
value = datetime.strptime(value, format)
|
|
|
|
except ValueError:
|
2019-07-26 15:09:39 +00:00
|
|
|
log.info('Failed to parse %s to datetime, defaulting to None', value)
|
2019-07-26 10:11:00 +00:00
|
|
|
return None
|
2016-12-16 00:17:02 +00:00
|
|
|
else:
|
2019-01-19 22:23:42 +00:00
|
|
|
# https://bugs.python.org/issue30684
|
|
|
|
# And platform support for before epoch seems to be flaky.
|
2021-05-10 23:11:16 +00:00
|
|
|
# Also limit to max 32-bit integer
|
|
|
|
value = min(max(int(value), 86400), 2**31 - 1)
|
2019-01-21 21:14:53 +00:00
|
|
|
value = datetime.fromtimestamp(int(value))
|
2014-12-29 03:21:58 +00:00
|
|
|
return value
|
2017-01-09 14:21:54 +00:00
|
|
|
|
|
|
|
|
2020-06-14 18:21:46 +00:00
|
|
|
def millisecondToHumanstr(milliseconds):
|
2020-06-14 02:12:01 +00:00
|
|
|
""" Returns human readable time duration from milliseconds.
|
2020-06-14 18:21:46 +00:00
|
|
|
HH:MM:SS:MMMM
|
2020-06-14 02:12:01 +00:00
|
|
|
|
|
|
|
Parameters:
|
|
|
|
milliseconds (str,int): time duration in milliseconds.
|
|
|
|
"""
|
2020-06-14 18:21:46 +00:00
|
|
|
milliseconds = int(milliseconds)
|
2020-08-07 20:31:54 +00:00
|
|
|
r = datetime.utcfromtimestamp(milliseconds / 1000)
|
2020-06-14 18:21:46 +00:00
|
|
|
f = r.strftime("%H:%M:%S.%f")
|
|
|
|
return f[:-2]
|
2020-06-14 02:12:01 +00:00
|
|
|
|
|
|
|
|
2017-02-03 16:39:46 +00:00
|
|
|
def toList(value, itemcast=None, delim=','):
|
|
|
|
""" Returns a list of strings from the specified value.
|
2017-02-18 00:51:06 +00:00
|
|
|
|
2017-02-03 16:39:46 +00:00
|
|
|
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 != '']
|
|
|
|
|
|
|
|
|
2021-11-20 22:16:58 +00:00
|
|
|
def cleanFilename(filename, replace='_'):
|
|
|
|
whitelist = "-_.()[] {}{}".format(string.ascii_letters, string.digits)
|
|
|
|
cleaned_filename = unicodedata.normalize('NFKD', filename).encode('ASCII', 'ignore').decode()
|
|
|
|
cleaned_filename = ''.join(c if c in whitelist else replace for c in cleaned_filename)
|
|
|
|
return cleaned_filename
|
|
|
|
|
|
|
|
|
2017-10-26 17:51:46 +00:00
|
|
|
def downloadSessionImages(server, filename=None, height=150, width=150,
|
|
|
|
opacity=100, saturation=100): # pragma: no cover
|
2017-02-20 04:04:27 +00:00
|
|
|
""" Helper to download a bif image or thumb.url from plex.server.sessions.
|
2017-02-18 00:51:06 +00:00
|
|
|
|
|
|
|
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:
|
2017-02-20 04:04:27 +00:00
|
|
|
{'hellowlol': {'filepath': '<filepath>', 'url': 'http://<url>'},
|
|
|
|
{'<username>': {filepath, url}}, ...
|
2017-02-18 00:51:06 +00:00
|
|
|
"""
|
|
|
|
info = {}
|
|
|
|
for media in server.sessions():
|
|
|
|
url = None
|
|
|
|
for part in media.iterParts():
|
|
|
|
if media.thumb:
|
|
|
|
url = media.thumb
|
2017-02-27 04:59:46 +00:00
|
|
|
if part.indexes: # always use bif images if available.
|
2017-02-18 20:56:40 +00:00
|
|
|
url = '/library/parts/%s/indexes/%s/%s' % (part.id, part.indexes.lower(), media.viewOffset)
|
2017-02-18 00:51:06 +00:00
|
|
|
if url:
|
|
|
|
if filename is None:
|
2017-02-20 04:04:27 +00:00
|
|
|
prettyname = media._prettyfilename()
|
|
|
|
filename = 'session_transcode_%s_%s_%s' % (media.usernames[0], prettyname, int(time.time()))
|
2017-02-27 04:59:46 +00:00
|
|
|
url = server.transcodeImage(url, height, width, opacity, saturation)
|
|
|
|
filepath = download(url, filename=filename)
|
|
|
|
info['username'] = {'filepath': filepath, 'url': url}
|
2017-02-18 00:51:06 +00:00
|
|
|
return info
|
|
|
|
|
|
|
|
|
2018-01-05 02:44:35 +00:00
|
|
|
def download(url, token, filename=None, savepath=None, session=None, chunksize=4024,
|
2017-08-18 21:23:40 +00:00
|
|
|
unpack=False, mocked=False, showstatus=False):
|
2017-02-02 03:53:05 +00:00
|
|
|
""" 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.
|
2018-01-05 02:44:35 +00:00
|
|
|
token (str): Plex auth token to include in headers.
|
2017-02-02 03:53:05 +00:00
|
|
|
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.
|
2022-05-16 20:39:42 +00:00
|
|
|
mocked (bool): Helper to do everything except write the file.
|
2017-05-27 02:35:33 +00:00
|
|
|
unpack (bool): Unpack the zip file.
|
2017-09-29 21:55:41 +00:00
|
|
|
showstatus(bool): Display a progressbar.
|
2017-10-25 16:34:59 +00:00
|
|
|
|
2017-01-09 14:21:54 +00:00
|
|
|
Example:
|
|
|
|
>>> download(a_episode.getStreamURL(), a_episode.location)
|
|
|
|
/path/to/file
|
|
|
|
"""
|
2017-02-27 04:59:46 +00:00
|
|
|
# fetch the data to be saved
|
2017-01-09 14:21:54 +00:00
|
|
|
session = session or requests.Session()
|
2018-01-05 02:44:35 +00:00
|
|
|
headers = {'X-Plex-Token': token}
|
|
|
|
response = session.get(url, headers=headers, stream=True)
|
2017-02-27 04:59:46 +00:00
|
|
|
# make sure the savepath directory exists
|
|
|
|
savepath = savepath or os.getcwd()
|
2020-05-12 21:15:16 +00:00
|
|
|
os.makedirs(savepath, exist_ok=True)
|
2017-09-29 21:55:41 +00:00
|
|
|
|
2017-02-27 04:59:46 +00:00
|
|
|
# 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
|
2017-09-29 21:55:41 +00:00
|
|
|
|
2017-02-27 04:59:46 +00:00
|
|
|
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]
|
2017-09-29 21:55:41 +00:00
|
|
|
|
2017-02-27 04:59:46 +00:00
|
|
|
# check this is a mocked download (testing)
|
|
|
|
if mocked:
|
|
|
|
log.debug('Mocked download %s', fullpath)
|
2017-01-09 14:21:54 +00:00
|
|
|
return fullpath
|
2017-09-29 21:55:41 +00:00
|
|
|
|
2017-02-27 04:59:46 +00:00
|
|
|
# save the file to disk
|
|
|
|
log.info('Downloading: %s', fullpath)
|
2020-04-27 19:12:18 +00:00
|
|
|
if showstatus and tqdm: # pragma: no cover
|
2017-09-29 21:55:41 +00:00
|
|
|
total = int(response.headers.get('content-length', 0))
|
2017-09-29 23:32:27 +00:00
|
|
|
bar = tqdm(unit='B', unit_scale=True, total=total, desc=filename)
|
2017-09-26 19:58:56 +00:00
|
|
|
|
2017-02-27 04:59:46 +00:00
|
|
|
with open(fullpath, 'wb') as handle:
|
|
|
|
for chunk in response.iter_content(chunk_size=chunksize):
|
|
|
|
handle.write(chunk)
|
2020-04-27 19:12:18 +00:00
|
|
|
if showstatus and tqdm:
|
2017-08-15 03:40:28 +00:00
|
|
|
bar.update(len(chunk))
|
2017-09-26 18:11:19 +00:00
|
|
|
|
2020-04-27 19:12:18 +00:00
|
|
|
if showstatus and tqdm: # pragma: no cover
|
2017-09-26 18:11:19 +00:00
|
|
|
bar.close()
|
2017-02-27 04:59:46 +00:00
|
|
|
# check we want to unzip the contents
|
|
|
|
if fullpath.endswith('zip') and unpack:
|
|
|
|
with zipfile.ZipFile(fullpath, 'r') as handle:
|
|
|
|
handle.extractall(savepath)
|
2017-09-26 19:58:56 +00:00
|
|
|
|
2017-02-27 04:59:46 +00:00
|
|
|
return fullpath
|
2017-07-16 20:46:03 +00:00
|
|
|
|
|
|
|
|
2017-10-25 22:01:42 +00:00
|
|
|
def getMyPlexAccount(opts=None): # pragma: no cover
|
2017-08-13 05:50:40 +00:00
|
|
|
""" 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)
|
2020-12-04 17:37:19 +00:00
|
|
|
config_token = CONFIG.get('auth.server_token')
|
|
|
|
if config_token:
|
|
|
|
print('Authenticating with Plex.tv with token')
|
|
|
|
return MyPlexAccount(token=config_token)
|
2017-08-13 05:50:40 +00:00
|
|
|
# 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)
|
2017-08-15 03:40:28 +00:00
|
|
|
|
|
|
|
|
2020-12-16 04:41:04 +00:00
|
|
|
def createMyPlexDevice(headers, account, timeout=10): # pragma: no cover
|
2022-07-21 02:48:52 +00:00
|
|
|
""" Helper function to create a new MyPlexDevice. Returns a new MyPlexDevice instance.
|
2020-12-06 23:56:26 +00:00
|
|
|
|
|
|
|
Parameters:
|
|
|
|
headers (dict): Provide the X-Plex- headers for the new device.
|
|
|
|
A unique X-Plex-Client-Identifier is required.
|
2020-12-16 04:41:04 +00:00
|
|
|
account (MyPlexAccount): The Plex account to create the device on.
|
2020-12-06 23:56:26 +00:00
|
|
|
timeout (int): Timeout in seconds to wait for device login.
|
|
|
|
"""
|
|
|
|
from plexapi.myplex import MyPlexPinLogin
|
|
|
|
|
|
|
|
if 'X-Plex-Client-Identifier' not in headers:
|
|
|
|
raise BadRequest('The X-Plex-Client-Identifier header is required.')
|
|
|
|
|
|
|
|
clientIdentifier = headers['X-Plex-Client-Identifier']
|
|
|
|
|
|
|
|
pinlogin = MyPlexPinLogin(headers=headers)
|
|
|
|
pinlogin.run(timeout=timeout)
|
2020-12-16 04:41:04 +00:00
|
|
|
account.link(pinlogin.pin)
|
2020-12-06 23:56:26 +00:00
|
|
|
pinlogin.waitForLogin()
|
|
|
|
|
2020-12-16 04:41:04 +00:00
|
|
|
return account.device(clientId=clientIdentifier)
|
2020-12-06 23:56:26 +00:00
|
|
|
|
|
|
|
|
2022-07-21 02:48:52 +00:00
|
|
|
def plexOAuth(headers, forwardUrl=None, timeout=120): # pragma: no cover
|
|
|
|
""" Helper function for Plex OAuth login. Returns a new MyPlexAccount instance.
|
|
|
|
|
|
|
|
Parameters:
|
|
|
|
headers (dict): Provide the X-Plex- headers for the new device.
|
|
|
|
A unique X-Plex-Client-Identifier is required.
|
|
|
|
forwardUrl (str, optional): The url to redirect the client to after login.
|
|
|
|
timeout (int, optional): Timeout in seconds to wait for device login. Default 120 seconds.
|
|
|
|
"""
|
|
|
|
from plexapi.myplex import MyPlexAccount, MyPlexPinLogin
|
|
|
|
|
|
|
|
if 'X-Plex-Client-Identifier' not in headers:
|
|
|
|
raise BadRequest('The X-Plex-Client-Identifier header is required.')
|
|
|
|
|
|
|
|
pinlogin = MyPlexPinLogin(headers=headers, oauth=True)
|
|
|
|
print('Login to Plex at the following url:')
|
|
|
|
print(pinlogin.oauthUrl(forwardUrl))
|
|
|
|
pinlogin.run(timeout=timeout)
|
|
|
|
pinlogin.waitForLogin()
|
|
|
|
|
|
|
|
if pinlogin.token:
|
|
|
|
print('Login successful!')
|
|
|
|
return MyPlexAccount(token=pinlogin.token)
|
|
|
|
else:
|
|
|
|
print('Login failed.')
|
|
|
|
|
|
|
|
|
2017-10-25 16:34:59 +00:00
|
|
|
def choose(msg, items, attr): # pragma: no cover
|
2017-08-15 03:40:28 +00:00
|
|
|
""" 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:
|
2017-09-26 18:11:19 +00:00
|
|
|
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)]
|
|
|
|
|
2017-08-15 03:40:28 +00:00
|
|
|
except (ValueError, IndexError):
|
|
|
|
pass
|
2020-03-17 18:00:41 +00:00
|
|
|
|
2020-04-13 02:35:22 +00:00
|
|
|
|
2020-03-17 18:00:41 +00:00
|
|
|
def getAgentIdentifier(section, agent):
|
|
|
|
""" Return the full agent identifier from a short identifier, name, or confirm full identifier. """
|
|
|
|
agents = []
|
|
|
|
for ag in section.agents():
|
|
|
|
identifiers = [ag.identifier, ag.shortIdentifier, ag.name]
|
|
|
|
if agent in identifiers:
|
|
|
|
return ag.identifier
|
|
|
|
agents += identifiers
|
2022-02-27 03:26:08 +00:00
|
|
|
raise NotFound('Could not find "%s" in agents list (%s)' %
|
2020-03-17 18:00:41 +00:00
|
|
|
(agent, ', '.join(agents)))
|
2020-11-16 01:54:48 +00:00
|
|
|
|
|
|
|
|
|
|
|
def base64str(text):
|
|
|
|
return base64.b64encode(text.encode('utf-8')).decode('utf-8')
|
2020-12-13 20:08:38 +00:00
|
|
|
|
|
|
|
|
2021-02-16 00:57:16 +00:00
|
|
|
def deprecated(message, stacklevel=2):
|
2020-12-13 20:08:38 +00:00
|
|
|
def decorator(func):
|
|
|
|
"""This is a decorator which can be used to mark functions
|
|
|
|
as deprecated. It will result in a warning being emitted
|
|
|
|
when the function is used."""
|
|
|
|
@functools.wraps(func)
|
|
|
|
def wrapper(*args, **kwargs):
|
|
|
|
msg = 'Call to deprecated function or method "%s", %s.' % (func.__name__, message)
|
2021-02-16 00:51:37 +00:00
|
|
|
warnings.warn(msg, category=DeprecationWarning, stacklevel=stacklevel)
|
2020-12-13 20:08:38 +00:00
|
|
|
log.warning(msg)
|
|
|
|
return func(*args, **kwargs)
|
|
|
|
return wrapper
|
|
|
|
return decorator
|
2022-05-17 02:46:10 +00:00
|
|
|
|
|
|
|
|
|
|
|
def iterXMLBFS(root, tag=None):
|
|
|
|
""" Iterate through an XML tree using a breadth-first search.
|
|
|
|
If tag is specified, only return nodes with that tag.
|
|
|
|
"""
|
|
|
|
queue = deque([root])
|
|
|
|
while queue:
|
|
|
|
node = queue.popleft()
|
|
|
|
if tag is None or node.tag == tag:
|
|
|
|
yield node
|
|
|
|
queue.extend(list(node))
|