2019-06-11 05:29:42 +10:00
|
|
|
#!/usr/bin/env python
|
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
from logging import getLogger
|
|
|
|
|
|
|
|
from ..utils import cast
|
|
|
|
from ..downloadutils import DownloadUtils as DU
|
|
|
|
from .. import utils, variables as v, app, path_ops, clientinfo
|
2019-08-25 21:46:47 +10:00
|
|
|
from .. import plex_functions as PF
|
2019-06-11 05:29:42 +10:00
|
|
|
|
|
|
|
LOG = getLogger('PLEX.api')
|
|
|
|
|
|
|
|
|
|
|
|
class Media(object):
|
2019-09-08 02:39:28 +10:00
|
|
|
def optimized_for_streaming(self):
|
2019-06-11 05:29:42 +10:00
|
|
|
"""
|
|
|
|
Returns True if the item's 'optimizedForStreaming' is set, False other-
|
|
|
|
wise
|
|
|
|
"""
|
|
|
|
return cast(bool, self.xml[0].get('optimizedForStreaming')) or False
|
|
|
|
|
|
|
|
def _from_part_or_media(self, key):
|
|
|
|
"""
|
|
|
|
Retrieves XML data 'key' first from the active part. If unsuccessful,
|
|
|
|
tries to retrieve the data from the Media response part.
|
|
|
|
|
|
|
|
If all fails, None is returned.
|
|
|
|
"""
|
|
|
|
return self.xml[0][self.part].get(key, self.xml[0].get(key))
|
|
|
|
|
2020-12-21 03:06:57 +11:00
|
|
|
def _from_stream_or_part(self, key):
|
|
|
|
"""
|
|
|
|
Retrieves XML data 'key' first from the very first stream. If
|
|
|
|
unsuccessful, tries to retrieve the data from the active part.
|
|
|
|
|
|
|
|
If all fails, None is returned.
|
|
|
|
"""
|
|
|
|
try:
|
|
|
|
value = self.xml[0][self.part][0].get(key)
|
|
|
|
except IndexError:
|
|
|
|
value = None
|
|
|
|
if value is None:
|
|
|
|
value = self.xml[0][self.part].get(key)
|
|
|
|
return value
|
|
|
|
|
2021-02-08 07:42:47 +11:00
|
|
|
def intro_markers(self):
|
|
|
|
"""
|
|
|
|
Returns a list of tuples with floats (startTimeOffset, endTimeOffset)
|
|
|
|
in Koditime or an empty list.
|
|
|
|
Each entry represents an (episode) intro that Plex detected and that
|
|
|
|
can be skipped
|
|
|
|
"""
|
|
|
|
self._scan_children()
|
|
|
|
return self._intro_markers
|
|
|
|
|
2019-06-11 05:29:42 +10:00
|
|
|
def video_codec(self):
|
|
|
|
"""
|
|
|
|
Returns the video codec and resolution for the child and part selected.
|
|
|
|
If any data is not found on a part-level, the Media-level data is
|
|
|
|
returned.
|
|
|
|
If that also fails (e.g. for old trailers, None is returned)
|
|
|
|
|
|
|
|
Output:
|
|
|
|
{
|
|
|
|
'videocodec': xxx, e.g. 'h264'
|
|
|
|
'resolution': xxx, e.g. '720' or '1080'
|
|
|
|
'height': xxx, e.g. '816'
|
|
|
|
'width': xxx, e.g. '1920'
|
|
|
|
'aspectratio': xxx, e.g. '1.78'
|
|
|
|
'bitrate': xxx, e.g. '10642'
|
|
|
|
'container': xxx e.g. 'mkv',
|
|
|
|
'bitDepth': xxx e.g. '8', '10'
|
|
|
|
}
|
|
|
|
"""
|
|
|
|
answ = {
|
|
|
|
'videocodec': self._from_part_or_media('videoCodec'),
|
|
|
|
'resolution': self._from_part_or_media('videoResolution'),
|
|
|
|
'height': self._from_part_or_media('height'),
|
|
|
|
'width': self._from_part_or_media('width'),
|
|
|
|
'aspectratio': self._from_part_or_media('aspectratio'),
|
|
|
|
'bitrate': self._from_part_or_media('bitrate'),
|
|
|
|
'container': self._from_part_or_media('container'),
|
|
|
|
}
|
|
|
|
try:
|
|
|
|
answ['bitDepth'] = self.xml[0][self.part][self.mediastream].get('bitDepth')
|
|
|
|
except (TypeError, AttributeError, KeyError, IndexError):
|
|
|
|
answ['bitDepth'] = None
|
|
|
|
return answ
|
|
|
|
|
2020-12-21 03:06:57 +11:00
|
|
|
def audio_codec(self):
|
|
|
|
"""
|
|
|
|
Returns the audio codec. If any data is not found on a part-level, the
|
|
|
|
Media-level data is returned. If that also fails (e.g. for old trailers,
|
|
|
|
None is returned)
|
|
|
|
"""
|
|
|
|
return {
|
|
|
|
'bitrate': cast(int, self._from_stream_or_part('bitrate')),
|
|
|
|
'samplingrate': cast(int, self._from_stream_or_part('samplingRate')),
|
|
|
|
'channels': cast(int, self._from_stream_or_part('channels')),
|
|
|
|
'gain': cast(float, self._from_stream_or_part('gain'))
|
|
|
|
}
|
|
|
|
|
2021-01-25 02:35:27 +11:00
|
|
|
def picture_codec(self):
|
|
|
|
"""
|
|
|
|
Returns the exif metadata of pictures. This does NOT seem to be used
|
|
|
|
reliably by Kodi skins! (e.g. not at all)
|
|
|
|
"""
|
|
|
|
return {
|
|
|
|
'exif:CameraMake': self.xml[0].get('make'), # e.g. 'Canon'
|
|
|
|
'exif:CameraModel': self.xml[0].get('model'), # e.g. 'Canon XYZ'
|
|
|
|
'exif:DateTime': self.xml.get('originallyAvailableAt', '').replace('-', ':') or None, # e.g. '2017-11-05'
|
|
|
|
'exif:Height': self.xml[0].get('height'), # e.g. '2160'
|
|
|
|
'exif:Width': self.xml[0].get('width'), # e.g. '3240'
|
|
|
|
'exif:Orientation': self.xml[0][self.part].get('orientation'), # e.g. '1'
|
|
|
|
'exif:FocalLength': self.xml[0].get('focalLength'), # TO BE VALIDATED
|
|
|
|
'exif:ExposureTime': self.xml[0].get('exposure'), # e.g. '1/1000'
|
|
|
|
'exif:ApertureFNumber': self.xml[0].get('aperture'), # e.g. 'f/5.0'
|
|
|
|
'exif:ISOequivalent': self.xml[0].get('iso'), # e.g. '1600'
|
|
|
|
# missing on Kodi side: lens, e.g. "EF50mm f/1.8 II"
|
|
|
|
}
|
|
|
|
|
2019-06-11 05:29:42 +10:00
|
|
|
def mediastreams(self):
|
|
|
|
"""
|
|
|
|
Returns the media streams for metadata purposes
|
|
|
|
|
|
|
|
Output: each track contains a dictionaries
|
|
|
|
{
|
|
|
|
'video': videotrack-list, 'codec', 'height', 'width',
|
|
|
|
'aspect', 'video3DFormat'
|
|
|
|
'audio': audiotrack-list, 'codec', 'channels',
|
|
|
|
'language'
|
|
|
|
'subtitle': list of subtitle languages (or "Unknown")
|
|
|
|
}
|
|
|
|
"""
|
|
|
|
videotracks = []
|
|
|
|
audiotracks = []
|
|
|
|
subtitlelanguages = []
|
|
|
|
try:
|
|
|
|
# Sometimes, aspectratio is on the "toplevel"
|
|
|
|
aspect = cast(float, self.xml[0].get('aspectRatio'))
|
|
|
|
except IndexError:
|
|
|
|
# There is no stream info at all, returning empty
|
|
|
|
return {
|
|
|
|
'video': videotracks,
|
|
|
|
'audio': audiotracks,
|
|
|
|
'subtitle': subtitlelanguages
|
|
|
|
}
|
|
|
|
# Loop over parts
|
|
|
|
for child in self.xml[0]:
|
|
|
|
container = child.get('container')
|
|
|
|
# Loop over Streams
|
|
|
|
for stream in child:
|
|
|
|
media_type = int(stream.get('streamType', 999))
|
|
|
|
track = {}
|
|
|
|
if media_type == 1: # Video streams
|
|
|
|
if 'codec' in stream.attrib:
|
|
|
|
track['codec'] = stream.get('codec').lower()
|
|
|
|
if "msmpeg4" in track['codec']:
|
|
|
|
track['codec'] = "divx"
|
|
|
|
elif "mpeg4" in track['codec']:
|
|
|
|
pass
|
|
|
|
elif "h264" in track['codec']:
|
|
|
|
if container in ("mp4", "mov", "m4v"):
|
|
|
|
track['codec'] = "avc1"
|
|
|
|
track['height'] = cast(int, stream.get('height'))
|
|
|
|
track['width'] = cast(int, stream.get('width'))
|
|
|
|
# track['Video3DFormat'] = item.get('Video3DFormat')
|
|
|
|
track['aspect'] = cast(float,
|
|
|
|
stream.get('aspectRatio') or aspect)
|
|
|
|
track['duration'] = self.runtime()
|
|
|
|
track['video3DFormat'] = None
|
|
|
|
videotracks.append(track)
|
|
|
|
elif media_type == 2: # Audio streams
|
|
|
|
if 'codec' in stream.attrib:
|
|
|
|
track['codec'] = stream.get('codec').lower()
|
|
|
|
if ("dca" in track['codec'] and
|
|
|
|
"ma" in stream.get('profile', '').lower()):
|
|
|
|
track['codec'] = "dtshd_ma"
|
|
|
|
track['channels'] = cast(int, stream.get('channels'))
|
|
|
|
# 'unknown' if we cannot get language
|
|
|
|
track['language'] = stream.get('languageCode',
|
|
|
|
utils.lang(39310).lower())
|
|
|
|
audiotracks.append(track)
|
|
|
|
elif media_type == 3: # Subtitle streams
|
|
|
|
# 'unknown' if we cannot get language
|
|
|
|
subtitlelanguages.append(
|
|
|
|
stream.get('languageCode', utils.lang(39310)).lower())
|
|
|
|
return {
|
|
|
|
'video': videotracks,
|
|
|
|
'audio': audiotracks,
|
|
|
|
'subtitle': subtitlelanguages
|
|
|
|
}
|
|
|
|
|
|
|
|
def mediastream_number(self):
|
|
|
|
"""
|
|
|
|
Returns the Media stream as an int (mostly 0). Will let the user choose
|
|
|
|
if several media streams are present for a PMS item (if settings are
|
|
|
|
set accordingly)
|
|
|
|
|
|
|
|
Returns None if the user aborted selection (leaving self.mediastream at
|
|
|
|
its default of None)
|
|
|
|
"""
|
|
|
|
# How many streams do we have?
|
|
|
|
count = 0
|
|
|
|
for entry in self.xml.iterfind('./Media'):
|
|
|
|
count += 1
|
|
|
|
if (count > 1 and (
|
|
|
|
(self.plex_type != v.PLEX_TYPE_CLIP and
|
2021-06-05 22:54:13 +10:00
|
|
|
utils.settings('firstVideoStream') == 'false')
|
2019-06-11 05:29:42 +10:00
|
|
|
or
|
|
|
|
(self.plex_type == v.PLEX_TYPE_CLIP and
|
|
|
|
utils.settings('bestTrailer') == 'false'))):
|
|
|
|
# Several streams/files available.
|
|
|
|
dialoglist = []
|
|
|
|
for entry in self.xml.iterfind('./Media'):
|
|
|
|
# Get additional info (filename / languages)
|
|
|
|
if 'file' in entry[0].attrib:
|
|
|
|
option = entry[0].get('file')
|
|
|
|
option = path_ops.basename(option)
|
|
|
|
else:
|
|
|
|
option = self.title() or ''
|
|
|
|
# Languages of audio streams
|
|
|
|
languages = []
|
|
|
|
for stream in entry[0]:
|
|
|
|
if (cast(int, stream.get('streamType')) == 1 and
|
|
|
|
'language' in stream.attrib):
|
|
|
|
language = stream.get('language')
|
|
|
|
languages.append(language)
|
|
|
|
languages = ', '.join(languages)
|
|
|
|
if languages:
|
|
|
|
if option:
|
|
|
|
option = '%s (%s): ' % (option, languages)
|
|
|
|
else:
|
|
|
|
option = '%s: ' % languages
|
|
|
|
else:
|
|
|
|
option = '%s ' % option
|
|
|
|
if 'videoResolution' in entry.attrib:
|
|
|
|
res = entry.get('videoResolution')
|
|
|
|
option = '%s%sp ' % (option, res)
|
|
|
|
if 'videoCodec' in entry.attrib:
|
|
|
|
codec = entry.get('videoCodec')
|
|
|
|
option = '%s%s' % (option, codec)
|
|
|
|
option = option.strip() + ' - '
|
|
|
|
if 'audioProfile' in entry.attrib:
|
|
|
|
profile = entry.get('audioProfile')
|
|
|
|
option = '%s%s ' % (option, profile)
|
|
|
|
if 'audioCodec' in entry.attrib:
|
|
|
|
codec = entry.get('audioCodec')
|
|
|
|
option = '%s%s ' % (option, codec)
|
|
|
|
option = cast(str, option.strip())
|
|
|
|
dialoglist.append(option)
|
|
|
|
media = utils.dialog('select', 'Select stream', dialoglist)
|
|
|
|
LOG.info('User chose media stream number: %s', media)
|
|
|
|
if media == -1:
|
|
|
|
LOG.info('User cancelled media stream selection')
|
|
|
|
return
|
|
|
|
else:
|
|
|
|
media = 0
|
|
|
|
self.mediastream = media
|
|
|
|
return media
|
|
|
|
|
|
|
|
def transcode_video_path(self, action, quality=None):
|
|
|
|
"""
|
|
|
|
|
|
|
|
To be called on a VIDEO level of PMS xml response!
|
|
|
|
|
|
|
|
Transcode Video support; returns the URL to get a media started
|
|
|
|
|
|
|
|
Input:
|
2019-08-25 21:46:47 +10:00
|
|
|
action 'DirectPlay'
|
|
|
|
'DirectStream'
|
|
|
|
'Transcode'
|
2019-06-11 05:29:42 +10:00
|
|
|
|
|
|
|
quality: {
|
|
|
|
'videoResolution': e.g. '1024x768',
|
|
|
|
'videoQuality': e.g. '60',
|
|
|
|
'maxVideoBitrate': e.g. '2000' (in kbits)
|
|
|
|
}
|
|
|
|
(one or several of these options)
|
|
|
|
Output:
|
|
|
|
final URL to pull in PMS transcoder
|
|
|
|
|
|
|
|
TODO: mediaIndex
|
|
|
|
"""
|
|
|
|
if self.mediastream is None and self.mediastream_number() is None:
|
|
|
|
return
|
2019-08-25 21:46:47 +10:00
|
|
|
headers = clientinfo.getXArgsDeviceInfo()
|
|
|
|
if action == v.PLAYBACK_METHOD_DIRECT_PLAY:
|
2019-06-11 05:29:42 +10:00
|
|
|
path = self.xml[self.mediastream][self.part].get('key')
|
|
|
|
# e.g. Trailers already feature an '?'!
|
2019-08-25 21:46:47 +10:00
|
|
|
return utils.extend_url(app.CONN.server + path, headers)
|
|
|
|
# Direct Streaming and Transcoding
|
|
|
|
arguments = PF.transcoding_arguments(path=self.path_and_plex_id(),
|
|
|
|
media=self.mediastream,
|
|
|
|
part=self.part,
|
|
|
|
playmethod=action,
|
|
|
|
args=quality)
|
|
|
|
headers.update(arguments)
|
2019-06-11 05:29:42 +10:00
|
|
|
# Path/key to VIDEO item of xml PMS response is needed, not part
|
|
|
|
path = self.xml.get('key')
|
|
|
|
transcode_path = app.CONN.server + \
|
|
|
|
'/video/:/transcode/universal/start.m3u8'
|
2019-08-25 21:46:47 +10:00
|
|
|
return utils.extend_url(transcode_path, headers)
|
2019-06-11 05:29:42 +10:00
|
|
|
|
|
|
|
def cache_external_subs(self):
|
|
|
|
"""
|
|
|
|
Downloads external subtitles temporarily to Kodi and returns a list
|
|
|
|
of their paths
|
|
|
|
"""
|
|
|
|
externalsubs = []
|
|
|
|
try:
|
|
|
|
mediastreams = self.xml[0][self.part]
|
|
|
|
except (TypeError, KeyError, IndexError):
|
2019-10-06 00:39:01 +10:00
|
|
|
return externalsubs
|
2019-06-11 05:29:42 +10:00
|
|
|
for stream in mediastreams:
|
|
|
|
# Since plex returns all possible tracks together, have to pull
|
|
|
|
# only external subtitles - only for these a 'key' exists
|
2019-10-06 00:39:01 +10:00
|
|
|
if int(stream.get('streamType')) != 3 or 'key' not in stream.attrib:
|
|
|
|
# Not a subtitle or not not an external subtitle
|
2019-06-11 05:29:42 +10:00
|
|
|
continue
|
2020-09-19 23:21:02 +10:00
|
|
|
try:
|
|
|
|
path = self.download_external_subtitles(
|
|
|
|
'{server}%s' % stream.get('key'),
|
|
|
|
stream.get('displayTitle'),
|
|
|
|
stream.get('codec'))
|
|
|
|
except IOError:
|
|
|
|
# Catch "IOError: [Errno 22] invalid mode ('wb') or filename"
|
|
|
|
# Due to stream.get('displayTitle') returning chars that our
|
|
|
|
# OS is not supporting, e.g. "српски језик (SRT External)"
|
|
|
|
path = self.download_external_subtitles(
|
|
|
|
'{server}%s' % stream.get('key'),
|
|
|
|
stream.get('languageCode', 'Unknown'),
|
|
|
|
stream.get('codec'))
|
2019-10-06 00:39:01 +10:00
|
|
|
if path:
|
2019-06-11 05:29:42 +10:00
|
|
|
externalsubs.append(path)
|
|
|
|
LOG.info('Found external subs: %s', externalsubs)
|
|
|
|
return externalsubs
|
|
|
|
|
|
|
|
@staticmethod
|
2019-10-06 00:39:01 +10:00
|
|
|
def download_external_subtitles(url, filename, extension):
|
2019-06-11 05:29:42 +10:00
|
|
|
"""
|
|
|
|
One cannot pass the subtitle language for ListItems. Workaround; will
|
|
|
|
download the subtitle at url to the Kodi PKC directory in a temp dir
|
|
|
|
|
|
|
|
Returns the path to the downloaded subtitle or None
|
|
|
|
"""
|
2019-10-06 00:39:01 +10:00
|
|
|
path = path_ops.create_unique_path(v.EXTERNAL_SUBTITLE_TEMP_PATH,
|
|
|
|
filename,
|
|
|
|
extension)
|
2019-06-11 05:29:42 +10:00
|
|
|
response = DU().downloadUrl(url, return_response=True)
|
|
|
|
try:
|
|
|
|
response.status_code
|
|
|
|
except AttributeError:
|
|
|
|
LOG.error('Could not temporarily download subtitle %s', url)
|
|
|
|
return
|
|
|
|
else:
|
|
|
|
LOG.debug('Writing temp subtitle to %s', path)
|
2020-12-24 01:29:27 +11:00
|
|
|
with open(path, 'wb') as f:
|
2019-10-06 00:39:01 +10:00
|
|
|
f.write(response.content)
|
2019-06-11 05:29:42 +10:00
|
|
|
return path
|
|
|
|
|
|
|
|
def validate_playurl(self, path, typus, force_check=False, folder=False,
|
|
|
|
omit_check=False):
|
|
|
|
"""
|
|
|
|
Returns a valid path for Kodi, e.g. with '\' substituted to '\\' in
|
|
|
|
Unicode. Returns None if this is not possible
|
|
|
|
|
|
|
|
path : Unicode
|
|
|
|
typus : Plex type from PMS xml
|
|
|
|
force_check : Will always try to check validity of path
|
|
|
|
Will also skip confirmation dialog if path not found
|
|
|
|
folder : Set to True if path is a folder
|
2021-05-02 22:10:33 +10:00
|
|
|
omit_check : Will entirely omit validity check if True. Will
|
|
|
|
be superseded by force_check!
|
2019-06-11 05:29:42 +10:00
|
|
|
"""
|
|
|
|
if path is None:
|
|
|
|
return
|
|
|
|
typus = v.REMAP_TYPE_FROM_PLEXTYPE[typus]
|
|
|
|
if app.SYNC.remap_path:
|
|
|
|
path = path.replace(getattr(app.SYNC, 'remapSMB%sOrg' % typus),
|
|
|
|
getattr(app.SYNC, 'remapSMB%sNew' % typus),
|
|
|
|
1)
|
|
|
|
# There might be backslashes left over:
|
|
|
|
path = path.replace('\\', '/')
|
|
|
|
elif app.SYNC.replace_smb_path:
|
|
|
|
if path.startswith('\\\\'):
|
|
|
|
path = 'smb:' + path.replace('\\', '/')
|
|
|
|
if app.SYNC.escape_path:
|
2020-06-08 03:27:20 +10:00
|
|
|
path = utils.escape_path(path, app.SYNC.escape_path_safe_chars)
|
2021-05-02 22:10:33 +10:00
|
|
|
if force_check:
|
|
|
|
pass
|
|
|
|
elif omit_check:
|
|
|
|
return path
|
|
|
|
elif not app.SYNC.check_media_file_existence:
|
|
|
|
return path
|
|
|
|
elif app.SYNC.path_verified:
|
2019-06-11 05:29:42 +10:00
|
|
|
return path
|
|
|
|
|
|
|
|
# exist() needs a / or \ at the end to work for directories
|
|
|
|
if not folder:
|
|
|
|
# files
|
|
|
|
check = path_ops.exists(path)
|
|
|
|
else:
|
|
|
|
# directories
|
|
|
|
if "\\" in path:
|
|
|
|
if not path.endswith('\\'):
|
|
|
|
# Add the missing backslash
|
|
|
|
check = path_ops.exists(path + "\\")
|
|
|
|
else:
|
|
|
|
check = path_ops.exists(path)
|
|
|
|
else:
|
|
|
|
if not path.endswith('/'):
|
|
|
|
check = path_ops.exists(path + "/")
|
|
|
|
else:
|
|
|
|
check = path_ops.exists(path)
|
|
|
|
if not check:
|
|
|
|
if force_check is False:
|
|
|
|
# Validate the path is correct with user intervention
|
|
|
|
if self.ask_to_validate(path):
|
|
|
|
app.APP.stop_threads(block=False)
|
|
|
|
path = None
|
|
|
|
app.SYNC.path_verified = True
|
|
|
|
else:
|
|
|
|
path = None
|
|
|
|
elif not force_check:
|
|
|
|
# Only set the flag if we were not force-checking the path
|
|
|
|
app.SYNC.path_verified = True
|
|
|
|
return path
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
def ask_to_validate(url):
|
|
|
|
"""
|
|
|
|
Displays a YESNO dialog box:
|
|
|
|
Kodi can't locate file: <url>. Please verify the path.
|
|
|
|
You may need to verify your network credentials in the
|
|
|
|
add-on settings or use different Plex paths. Stop syncing?
|
|
|
|
|
|
|
|
Returns True if sync should stop, else False
|
|
|
|
"""
|
|
|
|
LOG.warn('Cannot access file: %s', url)
|
|
|
|
# Kodi cannot locate the file #s. Please verify your PKC settings. Stop
|
|
|
|
# syncing?
|
|
|
|
return utils.yesno_dialog(utils.lang(29999), utils.lang(39031) % url)
|