From c324cf69ed80280af21a4d69ee822620f2725134 Mon Sep 17 00:00:00 2001
From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com>
Date: Mon, 3 Aug 2020 10:24:15 -0700
Subject: [PATCH 001/134] Merge custom plexapi 3.6.0-tautulli
---
lib/plexapi/audio.py | 37 ++++++++++++++-
lib/plexapi/library.py | 9 +++-
lib/plexapi/media.py | 101 +++++++++++++++++++++++++++++++++++++----
lib/plexapi/photo.py | 20 +++++++-
lib/plexapi/utils.py | 3 +-
lib/plexapi/video.py | 19 +++++++-
6 files changed, 175 insertions(+), 14 deletions(-)
diff --git a/lib/plexapi/audio.py b/lib/plexapi/audio.py
index b70aa93c..61890a23 100644
--- a/lib/plexapi/audio.py
+++ b/lib/plexapi/audio.py
@@ -36,6 +36,8 @@ class Audio(PlexPartialObject):
self.key = data.attrib.get('key')
self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt'))
self.librarySectionID = data.attrib.get('librarySectionID')
+ self.librarySectionKey = data.attrib.get('librarySectionKey')
+ self.librarySectionTitle = data.attrib.get('librarySectionTitle')
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
self.summary = data.attrib.get('summary')
self.thumb = data.attrib.get('thumb')
@@ -120,17 +122,26 @@ class Artist(Audio):
TAG = 'Directory'
TYPE = 'artist'
+ _include = ('?checkFiles=1&includeExtras=1&includeRelated=1'
+ '&includeOnDeck=1&includeChapters=1&includePopularLeaves=1'
+ '&includeMarkers=1&includeConcerts=1&includePreferences=1'
+ '&indcludeBandwidths=1&includeLoudnessRamps=1')
+
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
Audio._loadData(self, data)
+ self._details_key = self.key + self._include
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.fields = self.findItems(data, media.Field)
self.genres = self.findItems(data, media.Genre)
self.similar = self.findItems(data, media.Similar)
self.collections = self.findItems(data, media.Collection)
+ self.moods = self.findItems(data, media.Mood)
+ self.styles = self.findItems(data, media.Style)
def __iter__(self):
for album in self.albums():
@@ -217,17 +228,26 @@ class Album(Audio):
""" Load attribute values from Plex XML response. """
Audio._loadData(self, data)
self.art = data.attrib.get('art')
+ self.guid = data.attrib.get('guid')
+ self.leafCount = utils.cast(int, data.attrib.get('leafCount'))
+ self.loudnessAnalysisVersion = utils.cast(int, data.attrib.get('loudnessAnalysisVersion'))
self.key = self.key.replace('/children', '') # fixes bug #50
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
+ self.parentGuid = data.attrib.get('parentGuid')
self.parentKey = data.attrib.get('parentKey')
self.parentRatingKey = data.attrib.get('parentRatingKey')
self.parentThumb = data.attrib.get('parentThumb')
self.parentTitle = data.attrib.get('parentTitle')
+ self.rating = utils.cast(float, data.attrib.get('rating'))
self.studio = data.attrib.get('studio')
+ self.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.collections = self.findItems(data, media.Collection)
+ self.fields = self.findItems(data, media.Field)
+ self.genres = self.findItems(data, media.Genre)
self.labels = self.findItems(data, media.Label)
+ self.moods = self.findItems(data, media.Mood)
+ self.styles = self.findItems(data, media.Style)
def track(self, title):
""" Returns the :class:`~plexapi.audio.Track` that matches the specified title.
@@ -312,20 +332,28 @@ class Track(Audio, Playable):
TAG = 'Track'
TYPE = 'track'
+ _include = ('?checkFiles=1&includeExtras=1&includeRelated=1'
+ '&includeOnDeck=1&includeChapters=1&includePopularLeaves=1'
+ '&includeMarkers=1&includeConcerts=1&includePreferences=1'
+ '&indcludeBandwidths=1&includeLoudnessRamps=1')
+
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
Audio._loadData(self, data)
Playable._loadData(self, data)
+ self._details_key = self.key + self._include
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.grandparentGuid = data.attrib.get('grandparentGuid')
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.parentGuid = data.attrib.get('parentGuid')
self.parentIndex = data.attrib.get('parentIndex')
self.parentKey = data.attrib.get('parentKey')
self.parentRatingKey = data.attrib.get('parentRatingKey')
@@ -351,6 +379,13 @@ class Track(Audio, Playable):
""" Return this track's :class:`~plexapi.audio.Artist`. """
return self.fetchItem(self.grandparentKey)
+ @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 Artist
+ """
+ return [part.file for part in self.iterParts() if part]
+
def _defaultSyncTitle(self):
""" Returns str, default title for a new syncItem. """
return '%s - %s - %s' % (self.grandparentTitle, self.parentTitle, self.title)
diff --git a/lib/plexapi/library.py b/lib/plexapi/library.py
index e7798459..335534f7 100644
--- a/lib/plexapi/library.py
+++ b/lib/plexapi/library.py
@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
-from plexapi import X_PLEX_CONTAINER_SIZE, log, utils
+from plexapi import X_PLEX_CONTAINER_SIZE, log, utils, media
from plexapi.base import PlexObject
from plexapi.compat import quote, quote_plus, unquote, urlencode
from plexapi.exceptions import BadRequest, NotFound
@@ -1092,9 +1092,15 @@ class Collections(PlexObject):
def _loadData(self, data):
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
self._details_key = "/library/metadata/%s%s" % (self.ratingKey, self._include)
+ self.contentRating = data.attrib.get('contentRating')
+ self.guid = data.attrib.get('guid')
self.key = data.attrib.get('key')
+ self.librarySectionID = data.attrib.get('librarySectionID')
+ self.librarySectionKey = data.attrib.get('librarySectionKey')
+ self.librarySectionTitle = data.attrib.get('librarySectionTitle')
self.type = data.attrib.get('type')
self.title = data.attrib.get('title')
+ self.titleSort = data.attrib.get('titleSort')
self.subtype = data.attrib.get('subtype')
self.summary = data.attrib.get('summary')
self.index = utils.cast(int, data.attrib.get('index'))
@@ -1106,6 +1112,7 @@ class Collections(PlexObject):
self.maxYear = utils.cast(int, data.attrib.get('maxYear'))
self.collectionMode = data.attrib.get('collectionMode')
self.collectionSort = data.attrib.get('collectionSort')
+ self.fields = self.findItems(data, media.Field)
@property
def children(self):
diff --git a/lib/plexapi/media.py b/lib/plexapi/media.py
index 50252e4f..17a3f274 100644
--- a/lib/plexapi/media.py
+++ b/lib/plexapi/media.py
@@ -5,7 +5,7 @@ import xml
from plexapi import compat, log, settings, utils
from plexapi.base import PlexObject
from plexapi.exceptions import BadRequest
-from plexapi.utils import cast
+from plexapi.utils import cast, SEARCHTYPES
@utils.registerPlexObject
@@ -45,6 +45,7 @@ class Media(PlexObject):
self.aspectRatio = cast(float, data.attrib.get('aspectRatio'))
self.audioChannels = cast(int, data.attrib.get('audioChannels'))
self.audioCodec = data.attrib.get('audioCodec')
+ self.audioProfile = data.attrib.get('videoProfile')
self.bitrate = cast(int, data.attrib.get('bitrate'))
self.container = data.attrib.get('container')
self.duration = cast(int, data.attrib.get('duration'))
@@ -60,6 +61,16 @@ class Media(PlexObject):
self.videoResolution = data.attrib.get('videoResolution')
self.width = cast(int, data.attrib.get('width'))
self.parts = self.findItems(data, MediaPart)
+ self.proxyType = cast(int, data.attrib.get('proxyType'))
+ self.optimizedVersion = self.proxyType == SEARCHTYPES['optimizedVersion']
+
+ # For Photo only
+ self.aperture = data.attrib.get('aperture')
+ self.exposure = data.attrib.get('exposure')
+ self.iso = cast(int, data.attrib.get('iso'))
+ self.lens = data.attrib.get('lens')
+ self.make = data.attrib.get('make')
+ self.model = data.attrib.get('model')
def delete(self):
part = self._initpath + '/media/%s' % self.id
@@ -96,26 +107,34 @@ class MediaPart(PlexObject):
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
self._data = data
+ self.audioProfile = data.attrib.get('audioProfile')
self.container = data.attrib.get('container')
+ self.deepAnalysisVersion = cast(int, data.attrib.get('deepAnalysisVersion'))
self.duration = cast(int, data.attrib.get('duration'))
self.file = data.attrib.get('file')
+ self.has64bitOffsets = cast(bool, data.attrib.get('has64bitOffsets'))
+ self.hasThumbnail = cast(bool, data.attrib.get('hasThumbnail'))
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.decision = data.attrib.get('decision')
self.optimizedForStreaming = cast(bool, data.attrib.get('optimizedForStreaming'))
+ self.requiredBandwidths = data.attrib.get('requiredBandwidths')
self.syncItemId = cast(int, data.attrib.get('syncItemId'))
self.syncState = data.attrib.get('syncState')
self.videoProfile = data.attrib.get('videoProfile')
self.streams = self._buildStreams(data)
self.exists = cast(bool, data.attrib.get('exists'))
self.accessible = cast(bool, data.attrib.get('accessible'))
+
+ # For Photo only
+ self.orientation = cast(int, data.attrib.get('orientation'))
def _buildStreams(self, data):
streams = []
for elem in data:
- for cls in (VideoStream, AudioStream, SubtitleStream):
+ for cls in (VideoStream, AudioStream, SubtitleStream, LyricStream):
if elem.attrib.get('streamType') == str(cls.STREAMTYPE):
streams.append(cls(self._server, elem, self._initpath))
return streams
@@ -132,6 +151,10 @@ class MediaPart(PlexObject):
""" Returns a list of :class:`~plexapi.media.SubtitleStream` objects in this MediaPart. """
return [stream for stream in self.streams if stream.streamType == SubtitleStream.STREAMTYPE]
+ def lyricStreams(self):
+ """ Returns a list of :class:`~plexapi.media.LyricStream` objects in this MediaPart. """
+ return [stream for stream in self.streams if stream.streamType == LyricStream.STREAMTYPE]
+
def setDefaultAudioStream(self, stream):
""" Set the default :class:`~plexapi.media.AudioStream` for this MediaPart.
@@ -177,7 +200,8 @@ class MediaPartStream(PlexObject):
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`).
+ 2=:class:`~plexapi.media.AudioStream`, 3=:class:`~plexapi.media.SubtitleStream`,
+ 4=:class:`~plexapi.media.LyricStream`).
type (int): Alias for streamType.
"""
@@ -186,18 +210,22 @@ class MediaPartStream(PlexObject):
self._data = data
self.codec = data.attrib.get('codec')
self.codecID = data.attrib.get('codecID')
+ self.default = cast(bool, data.attrib.get('selected', '0'))
+ self.displayTitle = data.attrib.get('displayTitle')
+ self.extendedDisplayTitle = data.attrib.get('extendedDisplayTitle')
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.title = data.attrib.get('title')
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}
+ STREAMCLS = {1: VideoStream, 2: AudioStream, 3: SubtitleStream, 4: LyricStream}
stype = cast(int, data.attrib.get('streamType'))
cls = STREAMCLS.get(stype, MediaPartStream)
return cls(server, data, initpath)
@@ -236,18 +264,25 @@ class VideoStream(MediaPartStream):
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.chromaLocation = data.attrib.get('chromaLocation')
self.chromaSubsampling = data.attrib.get('chromaSubsampling')
+ self.codedHeight = data.attrib.get('codedHeight')
+ self.codedWidth = data.attrib.get('codedWidth')
+ self.colorPrimaries = data.attrib.get('colorPrimaries')
+ self.colorRange = data.attrib.get('colorRange')
self.colorSpace = data.attrib.get('colorSpace')
+ self.colorTrc = data.attrib.get('colorTrc')
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.hasScalingMatrix = cast(bool, data.attrib.get('hasScalingMatrix'))
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.requiredBandwidths = data.attrib.get('requiredBandwidths')
self.scanType = data.attrib.get('scanType')
- self.title = data.attrib.get('title')
+ self.streamIdentifier = cast(int, data.attrib.get('streamIdentifier'))
self.width = cast(int, data.attrib.get('width'))
@@ -281,8 +316,20 @@ class AudioStream(MediaPartStream):
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.profile = data.attrib.get('profile')
+ self.requiredBandwidths = data.attrib.get('requiredBandwidths')
self.samplingRate = cast(int, data.attrib.get('samplingRate'))
- self.title = data.attrib.get('title')
+
+ # For Track only
+ self.albumGain = cast(float, data.attrib.get('albumGain'))
+ self.albumPeak = cast(float, data.attrib.get('albumPeak'))
+ self.albumRange = cast(float, data.attrib.get('albumRange'))
+ self.endRamp = data.attrib.get('endRamp')
+ self.gain = cast(float, data.attrib.get('gain'))
+ self.loudness = cast(float, data.attrib.get('loudness'))
+ self.lra = cast(float, data.attrib.get('lra'))
+ self.peak = cast(float, data.attrib.get('peak'))
+ self.startRamp = data.attrib.get('startRamp')
@utils.registerPlexObject
@@ -303,10 +350,35 @@ class SubtitleStream(MediaPartStream):
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
super(SubtitleStream, self)._loadData(data)
+ self.container = data.attrib.get('container')
self.forced = cast(bool, data.attrib.get('forced', '0'))
self.format = data.attrib.get('format')
self.key = data.attrib.get('key')
- self.title = data.attrib.get('title')
+ self.requiredBandwidths = data.attrib.get('requiredBandwidths')
+
+
+@utils.registerPlexObject
+class LyricStream(MediaPartStream):
+ """ Respresents a lyric stream within a :class:`~plexapi.media.MediaPart`.
+
+ Attributes:
+ TAG (str): 'Stream'
+ STREAMTYPE (int): 4
+ format (str): Lyric format (ex: lrc).
+ key (str): Key of this subtitle stream (ex: /library/streams/212284).
+ title (str): Title of this lyric stream.
+ """
+ TAG = 'Stream'
+ STREAMTYPE = 4
+
+ def _loadData(self, data):
+ """ Load attribute values from Plex XML response. """
+ super(LyricStream, self)._loadData(data)
+ self.format = data.attrib.get('format')
+ self.key = data.attrib.get('key')
+ self.minLines = cast(int, data.attrib.get('minLines'))
+ self.provider = data.attrib.get('provider')
+ self.timed = cast(bool, data.attrib.get('timed', '0'))
@utils.registerPlexObject
@@ -601,6 +673,18 @@ class Mood(MediaTag):
FILTER = 'mood'
+@utils.registerPlexObject
+class Style(MediaTag):
+ """ Represents a single Style media tag.
+
+ Attributes:
+ TAG (str): 'Style'
+ FILTER (str): 'style'
+ """
+ TAG = 'Style'
+ FILTER = 'style'
+
+
@utils.registerPlexObject
class Poster(PlexObject):
""" Represents a Poster.
@@ -689,6 +773,7 @@ class Chapter(PlexObject):
self.filter = data.attrib.get('filter') # I couldn't filter on it anyways
self.tag = data.attrib.get('tag')
self.title = self.tag
+ self.thumb = data.attrib.get('thumb')
self.index = cast(int, data.attrib.get('index'))
self.start = cast(int, data.attrib.get('startTimeOffset'))
self.end = cast(int, data.attrib.get('endTimeOffset'))
diff --git a/lib/plexapi/photo.py b/lib/plexapi/photo.py
index 66c6d561..d1ac4f47 100644
--- a/lib/plexapi/photo.py
+++ b/lib/plexapi/photo.py
@@ -40,6 +40,8 @@ class Photoalbum(PlexPartialObject):
self.index = utils.cast(int, data.attrib.get('index'))
self.key = data.attrib.get('key')
self.librarySectionID = data.attrib.get('librarySectionID')
+ self.librarySectionKey = data.attrib.get('librarySectionKey')
+ self.librarySectionTitle = data.attrib.get('librarySectionTitle')
self.ratingKey = data.attrib.get('ratingKey')
self.summary = data.attrib.get('summary')
self.thumb = data.attrib.get('thumb')
@@ -99,16 +101,32 @@ class Photo(PlexPartialObject):
TYPE = 'photo'
METADATA_TYPE = 'photo'
+ _include = ('?checkFiles=1&includeExtras=1&includeRelated=1'
+ '&includeOnDeck=1&includeChapters=1&includePopularLeaves=1'
+ '&includeMarkers=1&includeConcerts=1&includePreferences=1'
+ '&indcludeBandwidths=1&includeLoudnessRamps=1')
+
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
+ self.key = data.attrib.get('key')
+ self._details_key = self.key + self._include
self.listType = 'photo'
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
+ self.createdAtAccuracy = data.attrib.get('createdAtAccuracy')
+ self.createdAtTZOffset = utils.cast(int, data.attrib.get('createdAtTZOffset'))
+ 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.librarySectionKey = data.attrib.get('librarySectionKey')
+ self.librarySectionTitle = data.attrib.get('librarySectionTitle')
self.originallyAvailableAt = utils.toDatetime(
data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
+ self.parentGuid = data.attrib.get('parentGuid')
+ self.parentIndex = utils.cast(int, data.attrib.get('parentIndex'))
self.parentKey = data.attrib.get('parentKey')
self.parentRatingKey = data.attrib.get('parentRatingKey')
+ self.parentThumb = data.attrib.get('parentThumb')
+ self.parentTitle = data.attrib.get('parentTitle')
self.ratingKey = data.attrib.get('ratingKey')
self.summary = data.attrib.get('summary')
self.thumb = data.attrib.get('thumb')
diff --git a/lib/plexapi/utils.py b/lib/plexapi/utils.py
index a23719d5..344622b9 100644
--- a/lib/plexapi/utils.py
+++ b/lib/plexapi/utils.py
@@ -23,7 +23,8 @@ log = logging.getLogger('plexapi')
# Library Types - Populated at runtime
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, 'userPlaylistItem': 1001}
+ 'playlist': 15, 'playlistFolder': 16, 'collection': 18,
+ 'optimizedVersion': 42, 'userPlaylistItem': 1001}
PLEXOBJECTS = {}
diff --git a/lib/plexapi/video.py b/lib/plexapi/video.py
index 2dcc73a7..c78dc793 100644
--- a/lib/plexapi/video.py
+++ b/lib/plexapi/video.py
@@ -35,6 +35,8 @@ class Video(PlexPartialObject):
self.key = data.attrib.get('key', '')
self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt'))
self.librarySectionID = data.attrib.get('librarySectionID')
+ self.librarySectionKey = data.attrib.get('librarySectionKey')
+ self.librarySectionTitle = data.attrib.get('librarySectionTitle')
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
self.summary = data.attrib.get('summary')
self.thumb = data.attrib.get('thumb')
@@ -276,7 +278,8 @@ class Movie(Playable, Video):
METADATA_TYPE = 'movie'
_include = ('?checkFiles=1&includeExtras=1&includeRelated=1'
'&includeOnDeck=1&includeChapters=1&includePopularLeaves=1'
- '&includeConcerts=1&includePreferences=1')
+ '&includeConcerts=1&includePreferences=1'
+ '&indcludeBandwidths=1')
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
@@ -415,6 +418,7 @@ class Show(Video):
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.fields = self.findItems(data, media.Field)
self.genres = self.findItems(data, media.Genre)
self.roles = self.findItems(data, media.Role)
self.labels = self.findItems(data, media.Label)
@@ -527,12 +531,19 @@ class Season(Video):
Video._loadData(self, data)
# fix key if loaded from search
self.key = self.key.replace('/children', '')
+ self.art = data.attrib.get('art')
+ self.guid = data.attrib.get('guid')
self.leafCount = utils.cast(int, data.attrib.get('leafCount'))
self.index = utils.cast(int, data.attrib.get('index'))
+ self.parentGuid = data.attrib.get('parentGuid')
+ self.parentIndex = data.attrib.get('parentIndex')
self.parentKey = data.attrib.get('parentKey')
self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey'))
+ self.parentTheme = data.attrib.get('parentTheme')
+ self.parentThumb = data.attrib.get('parentThumb')
self.parentTitle = data.attrib.get('parentTitle')
self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount'))
+ self.fields = self.findItems(data, media.Field)
def __repr__(self):
return '<%s>' % ':'.join([p for p in [
@@ -644,7 +655,8 @@ class Episode(Playable, Video):
_include = ('?checkFiles=1&includeExtras=1&includeRelated=1'
'&includeOnDeck=1&includeChapters=1&includePopularLeaves=1'
- '&includeConcerts=1&includePreferences=1')
+ '&includeMarkers=1&includeConcerts=1&includePreferences=1'
+ '&indcludeBandwidths=1')
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
@@ -657,6 +669,7 @@ class Episode(Playable, Video):
self.contentRating = data.attrib.get('contentRating')
self.duration = utils.cast(int, data.attrib.get('duration'))
self.grandparentArt = data.attrib.get('grandparentArt')
+ self.grandparentGuid = data.attrib.get('grandparentGuid')
self.grandparentKey = data.attrib.get('grandparentKey')
self.grandparentRatingKey = utils.cast(int, data.attrib.get('grandparentRatingKey'))
self.grandparentTheme = data.attrib.get('grandparentTheme')
@@ -665,6 +678,7 @@ class Episode(Playable, Video):
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.parentGuid = data.attrib.get('parentGuid')
self.parentIndex = data.attrib.get('parentIndex')
self.parentKey = data.attrib.get('parentKey')
self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey'))
@@ -675,6 +689,7 @@ class Episode(Playable, Video):
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.fields = self.findItems(data, media.Field)
self.media = self.findItems(data, media.Media)
self.writers = self.findItems(data, media.Writer)
self.labels = self.findItems(data, media.Label)
From 0ff363b6eed042631c70d3c6974e08425233231d Mon Sep 17 00:00:00 2001
From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com>
Date: Mon, 3 Aug 2020 10:24:36 -0700
Subject: [PATCH 002/134] Add export helper functions
---
plexpy/helpers.py | 95 +++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 95 insertions(+)
diff --git a/plexpy/helpers.py b/plexpy/helpers.py
index fee9ae53..10acf6c1 100644
--- a/plexpy/helpers.py
+++ b/plexpy/helpers.py
@@ -242,6 +242,14 @@ def iso_to_datetime(iso):
return arrow.get(iso).datetime
+def datetime_to_iso(dt, to_date=False):
+ if isinstance(dt, datetime.datetime):
+ if to_date:
+ dt = dt.date()
+ return dt.isoformat()
+ return dt
+
+
def human_duration(s, sig='dhms'):
hd = ''
@@ -382,6 +390,13 @@ def cleanTitle(title):
return title
+def clean_filename(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
+
+
def split_path(f):
"""
Split a path into components, starting with the drive letter (if any). Given
@@ -1153,6 +1168,86 @@ def bool_true(value, return_none=False):
return False
+def get_attrs_to_dict(obj, attrs):
+ d = {}
+
+ for attr, sub in attrs.items():
+ if isinstance(obj, dict):
+ value = obj.get(attr, None)
+ else:
+ value = getattr(obj, attr, None)
+
+ if callable(value):
+ value = value()
+
+ if isinstance(sub, str):
+ if isinstance(value, list):
+ value = [getattr(o, sub, None) for o in value]
+ else:
+ value = getattr(value, sub, None)
+ elif isinstance(sub, dict):
+ if isinstance(value, list):
+ value = [get_attrs_to_dict(o, sub) for o in value]
+ else:
+ value = get_attrs_to_dict(value, sub)
+ elif callable(sub):
+ if isinstance(value, list):
+ value = [sub(o) for o in value]
+ else:
+ value = sub(value)
+
+ d[attr] = value
+
+ return d
+
+
+def flatten_dict(obj):
+ return flatten_tree(flatten_keys(obj))
+
+
+def flatten_keys(obj, key='', sep='.'):
+ if isinstance(obj, list):
+ new_obj = [flatten_keys(o, key=key) for o in obj]
+ elif isinstance(obj, dict):
+ new_key = key + sep if key else ''
+ new_obj = {new_key + k: flatten_keys(v, key=new_key + k) for k, v in obj.items()}
+ else:
+ new_obj = obj
+
+ return new_obj
+
+
+def flatten_tree(obj, key=''):
+ if isinstance(obj, list):
+ new_rows = []
+
+ for o in obj:
+ if isinstance(o, dict):
+ new_rows.extend(flatten_tree(o))
+ else:
+ new_rows.append({key: o})
+
+ elif isinstance(obj, dict):
+ common_keys = {}
+ all_rows = [[common_keys]]
+
+ for k, v in obj.items():
+ if isinstance(v, list):
+ all_rows.append(flatten_tree(v, k))
+ elif isinstance(v, dict):
+ common_keys.update(*flatten_tree(v))
+ else:
+ common_keys[k] = v
+
+ new_rows = [{k: v for r in row for k, v in r.items()}
+ for row in zip_longest(*all_rows, fillvalue={})]
+
+ else:
+ new_rows = []
+
+ return new_rows
+
+
def page(endpoint, *args, **kwargs):
endpoints = {
'pms_image_proxy': pms_image_proxy,
From c102020698c1aba68b94da956512d4f33706c2ff Mon Sep 17 00:00:00 2001
From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com>
Date: Mon, 3 Aug 2020 10:35:17 -0700
Subject: [PATCH 003/134] Add metadata export function
---
plexpy/exporter.py | 916 +++++++++++++++++++++++++++++++++++++++++++++
plexpy/plex.py | 42 +++
plexpy/webserve.py | 14 +
3 files changed, 972 insertions(+)
create mode 100644 plexpy/exporter.py
create mode 100644 plexpy/plex.py
diff --git a/plexpy/exporter.py b/plexpy/exporter.py
new file mode 100644
index 00000000..8055b19b
--- /dev/null
+++ b/plexpy/exporter.py
@@ -0,0 +1,916 @@
+# -*- coding: utf-8 -*-
+
+# This file is part of Tautulli.
+#
+# Tautulli is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Tautulli is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Tautulli. If not, see .
+
+from __future__ import unicode_literals
+
+import csv
+import json
+import os
+
+from functools import partial
+from io import open
+from multiprocessing.dummy import Pool as ThreadPool
+
+import plexpy
+if plexpy.PYTHON2:
+ import helpers
+ import logger
+ from plex import Plex
+else:
+ from plexpy import helpers
+ from plexpy import logger
+ from plexpy.plex import Plex
+
+
+MOVIE_ATTRS = {
+ 'addedAt': helpers.datetime_to_iso,
+ 'art': None,
+ 'audienceRating': None,
+ 'audienceRatingImage': None,
+ 'chapters': {
+ 'id': None,
+ 'tag': None,
+ 'index': None,
+ 'start': None,
+ 'end': None,
+ 'thumb': None
+ },
+ 'chapterSource': None,
+ 'collections': {
+ 'id': None,
+ 'tag': None
+ },
+ 'contentRating': None,
+ 'countries': {
+ 'id': None,
+ 'tag': None
+ },
+ 'directors': {
+ 'id': None,
+ 'tag': None
+ },
+ 'duration': None,
+ 'fields': {
+ 'name': None,
+ 'locked': None
+ },
+ 'genres': {
+ 'id': None,
+ 'tag': None
+ },
+ 'guid': None,
+ 'key': None,
+ 'labels': {
+ 'id': None,
+ 'tag': None
+ },
+ 'lastViewedAt': helpers.datetime_to_iso,
+ 'librarySectionID': None,
+ 'librarySectionKey': None,
+ 'librarySectionTitle': None,
+ 'locations': None,
+ 'media': {
+ 'aspectRatio': None,
+ 'audioChannels': None,
+ 'audioCodec': None,
+ 'audioProfile': None,
+ 'bitrate': None,
+ 'container': None,
+ 'duration': None,
+ 'height': None,
+ 'id': None,
+ 'has64bitOffsets': None,
+ 'optimizedForStreaming': None,
+ 'optimizedVersion': None,
+ 'target': None,
+ 'title': None,
+ 'videoCodec': None,
+ 'videoFrameRate': None,
+ 'videoProfile': None,
+ 'videoResolution': None,
+ 'width': None,
+ 'parts': {
+ 'accessible': None,
+ 'audioProfile': None,
+ 'container': None,
+ 'deepAnalysisVersion': None,
+ 'duration': None,
+ 'exists': None,
+ 'file': None,
+ 'has64bitOffsets': None,
+ 'id': None,
+ 'indexes': None,
+ 'key': None,
+ 'size': None,
+ 'optimizedForStreaming': None,
+ 'requiredBandwidths': lambda e: [int(b) for b in e.split(',')] if e else None,
+ 'syncItemId': None,
+ 'syncState': None,
+ 'videoProfile': None,
+ 'videoStreams': {
+ 'codec': None,
+ 'codecID': None,
+ 'displayTitle': None,
+ 'extendedDisplayTitle': None,
+ 'id': None,
+ 'index': None,
+ 'language': None,
+ 'languageCode': None,
+ 'selected': None,
+ 'streamType': None,
+ 'title': None,
+ 'type': None,
+ 'bitDepth': None,
+ 'bitrate': None,
+ 'cabac': None,
+ 'chromaLocation': None,
+ 'chromaSubsampling': None,
+ 'colorPrimaries': None,
+ 'colorRange': None,
+ 'colorSpace': None,
+ 'colorTrc': None,
+ 'duration': None,
+ 'frameRate': None,
+ 'frameRateMode': None,
+ 'hasScalingMatrix': None,
+ 'height': None,
+ 'level': None,
+ 'profile': None,
+ 'refFrames': None,
+ 'requiredBandwidths': lambda e: [int(b) for b in e.split(',')] if e else None,
+ 'scanType': None,
+ 'streamIdentifier': None,
+ 'width': None
+ },
+ 'audioStreams': {
+ 'codec': None,
+ 'codecID': None,
+ 'displayTitle': None,
+ 'extendedDisplayTitle': None,
+ 'id': None,
+ 'index': None,
+ 'language': None,
+ 'languageCode': None,
+ 'selected': None,
+ 'streamType': None,
+ 'title': None,
+ 'type': None,
+ 'audioChannelLayout': None,
+ 'bitDepth': None,
+ 'bitrate': None,
+ 'bitrateMode': None,
+ 'channels': None,
+ 'dialogNorm': None,
+ 'duration': None,
+ 'profile': None,
+ 'requiredBandwidths': lambda e: [int(b) for b in e.split(',')] if e else None,
+ 'samplingRate': None
+ },
+ 'subtitleStreams': {
+ 'codec': None,
+ 'codecID': None,
+ 'displayTitle': None,
+ 'extendedDisplayTitle': None,
+ 'id': None,
+ 'index': None,
+ 'language': None,
+ 'languageCode': None,
+ 'requiredBandwidths': lambda e: [int(b) for b in e.split(',')] if e else None,
+ 'selected': None,
+ 'streamType': None,
+ 'title': None,
+ 'type': None,
+ 'forced': None,
+ 'format': None,
+ 'key': None
+ }
+ }
+ },
+ 'originallyAvailableAt': partial(helpers.datetime_to_iso, to_date=True),
+ 'originalTitle': None,
+ 'producers': {
+ 'id': None,
+ 'tag': None
+ },
+ 'rating': None,
+ 'ratingImage': None,
+ 'ratingKey': None,
+ 'roles': {
+ 'id': None,
+ 'tag': None,
+ 'role': None,
+ 'thumb': None
+ },
+ 'studio': None,
+ 'summary': None,
+ 'tagline': None,
+ 'thumb': None,
+ 'title': None,
+ 'titleSort': None,
+ 'type': None,
+ 'updatedAt': helpers.datetime_to_iso,
+ 'userRating': None,
+ 'viewCount': None,
+ 'writers': {
+ 'id': None,
+ 'tag': None
+ },
+ 'year': None
+}
+
+SHOW_ATTRS = {
+ 'addedAt': helpers.datetime_to_iso,
+ 'art': None,
+ 'banner': None,
+ 'childCount': None,
+ 'collections': {
+ 'id': None,
+ 'tag': None
+ },
+ 'contentRating': None,
+ 'duration': None,
+ 'fields': {
+ 'name': None,
+ 'locked': None
+ },
+ 'genres': {
+ 'id': None,
+ 'tag': None
+ },
+ 'guid': None,
+ 'index': None,
+ 'key': None,
+ 'labels': {
+ 'id': None,
+ 'tag': None
+ },
+ 'lastViewedAt': helpers.datetime_to_iso,
+ 'leafCount': None,
+ 'librarySectionID': None,
+ 'librarySectionKey': None,
+ 'librarySectionTitle': None,
+ 'locations': None,
+ 'originallyAvailableAt': partial(helpers.datetime_to_iso, to_date=True),
+ 'rating': None,
+ 'ratingKey': None,
+ 'roles': {
+ 'id': None,
+ 'tag': None,
+ 'role': None,
+ 'thumb': None
+ },
+ 'studio': None,
+ 'summary': None,
+ 'theme': None,
+ 'thumb': None,
+ 'title': None,
+ 'titleSort': None,
+ 'type': None,
+ 'updatedAt': helpers.datetime_to_iso,
+ 'userRating': None,
+ 'viewCount': None,
+ 'viewedLeafCount': None,
+ 'year': None,
+ 'seasons': lambda e: helpers.get_attrs_to_dict(e, MEDIA_TYPES[e.type])
+}
+
+SEASON_ATTRS = {
+ 'addedAt': helpers.datetime_to_iso,
+ 'art': None,
+ 'fields': {
+ 'name': None,
+ 'locked': None
+ },
+ 'guid': None,
+ 'index': None,
+ 'key': None,
+ 'lastViewedAt': helpers.datetime_to_iso,
+ 'leafCount': None,
+ 'librarySectionID': None,
+ 'librarySectionKey': None,
+ 'librarySectionTitle': None,
+ 'parentGuid': None,
+ 'parentIndex': None,
+ 'parentKey': None,
+ 'parentRatingKey': None,
+ 'parentTheme': None,
+ 'parentThumb': None,
+ 'parentTitle': None,
+ 'ratingKey': None,
+ 'summary': None,
+ 'thumb': None,
+ 'title': None,
+ 'titleSort': None,
+ 'type': None,
+ 'updatedAt': helpers.datetime_to_iso,
+ 'userRating': None,
+ 'viewCount': None,
+ 'viewedLeafCount': None,
+ 'episodes': lambda e: helpers.get_attrs_to_dict(e, MEDIA_TYPES[e.type])
+}
+
+EPISODE_ATTRS = {
+ 'addedAt': helpers.datetime_to_iso,
+ 'art': None,
+ 'chapterSource': None,
+ 'contentRating': None,
+ 'directors': {
+ 'id': None,
+ 'tag': None
+ },
+ 'duration': None,
+ 'fields': {
+ 'name': None,
+ 'locked': None
+ },
+ 'grandparentArt': None,
+ 'grandparentGuid': None,
+ 'grandparentKey': None,
+ 'grandparentRatingKey': None,
+ 'grandparentTheme': None,
+ 'grandparentThumb': None,
+ 'grandparentTitle': None,
+ 'guid': None,
+ 'index': None,
+ 'key': None,
+ 'lastViewedAt': helpers.datetime_to_iso,
+ 'librarySectionID': None,
+ 'librarySectionKey': None,
+ 'librarySectionTitle': None,
+ 'locations': None,
+ 'media': {
+ 'aspectRatio': None,
+ 'audioChannels': None,
+ 'audioCodec': None,
+ 'audioProfile': None,
+ 'bitrate': None,
+ 'container': None,
+ 'duration': None,
+ 'height': None,
+ 'id': None,
+ 'has64bitOffsets': None,
+ 'optimizedForStreaming': None,
+ 'optimizedVersion': None,
+ 'target': None,
+ 'title': None,
+ 'videoCodec': None,
+ 'videoFrameRate': None,
+ 'videoProfile': None,
+ 'videoResolution': None,
+ 'width': None,
+ 'parts': {
+ 'accessible': None,
+ 'audioProfile': None,
+ 'container': None,
+ 'deepAnalysisVersion': None,
+ 'duration': None,
+ 'exists': None,
+ 'file': None,
+ 'has64bitOffsets': None,
+ 'id': None,
+ 'indexes': None,
+ 'key': None,
+ 'size': None,
+ 'optimizedForStreaming': None,
+ 'requiredBandwidths': lambda e: [int(b) for b in e.split(',')] if e else None,
+ 'syncItemId': None,
+ 'syncState': None,
+ 'videoProfile': None,
+ 'videoStreams': {
+ 'codec': None,
+ 'codecID': None,
+ 'displayTitle': None,
+ 'extendedDisplayTitle': None,
+ 'id': None,
+ 'index': None,
+ 'language': None,
+ 'languageCode': None,
+ 'selected': None,
+ 'streamType': None,
+ 'title': None,
+ 'type': None,
+ 'bitDepth': None,
+ 'bitrate': None,
+ 'cabac': None,
+ 'chromaLocation': None,
+ 'chromaSubsampling': None,
+ 'colorPrimaries': None,
+ 'colorRange': None,
+ 'colorSpace': None,
+ 'colorTrc': None,
+ 'duration': None,
+ 'frameRate': None,
+ 'frameRateMode': None,
+ 'hasScalingMatrix': None,
+ 'height': None,
+ 'level': None,
+ 'profile': None,
+ 'refFrames': None,
+ 'requiredBandwidths': lambda e: [int(b) for b in e.split(',')] if e else None,
+ 'scanType': None,
+ 'streamIdentifier': None,
+ 'width': None
+ },
+ 'audioStreams': {
+ 'codec': None,
+ 'codecID': None,
+ 'displayTitle': None,
+ 'extendedDisplayTitle': None,
+ 'id': None,
+ 'index': None,
+ 'language': None,
+ 'languageCode': None,
+ 'selected': None,
+ 'streamType': None,
+ 'title': None,
+ 'type': None,
+ 'audioChannelLayout': None,
+ 'bitDepth': None,
+ 'bitrate': None,
+ 'bitrateMode': None,
+ 'channels': None,
+ 'dialogNorm': None,
+ 'duration': None,
+ 'profile': None,
+ 'requiredBandwidths': lambda e: [int(b) for b in e.split(',')] if e else None,
+ 'samplingRate': None
+ },
+ 'subtitleStreams': {
+ 'codec': None,
+ 'codecID': None,
+ 'displayTitle': None,
+ 'extendedDisplayTitle': None,
+ 'id': None,
+ 'index': None,
+ 'language': None,
+ 'languageCode': None,
+ 'requiredBandwidths': lambda e: [int(b) for b in e.split(',')] if e else None,
+ 'selected': None,
+ 'streamType': None,
+ 'title': None,
+ 'type': None,
+ 'forced': None,
+ 'format': None,
+ 'key': None
+ }
+ }
+ },
+ 'originallyAvailableAt': partial(helpers.datetime_to_iso, to_date=True),
+ 'parentGuid': None,
+ 'parentIndex': None,
+ 'parentKey': None,
+ 'parentRatingKey': None,
+ 'parentThumb': None,
+ 'parentTitle': None,
+ 'rating': None,
+ 'ratingKey': None,
+ 'summary': None,
+ 'thumb': None,
+ 'title': None,
+ 'titleSort': None,
+ 'type': None,
+ 'updatedAt': helpers.datetime_to_iso,
+ 'userRating': None,
+ 'viewCount': None,
+ 'writers': {
+ 'id': None,
+ 'tag': None
+ },
+ 'year': None
+}
+
+ARTIST_ATTRS = {
+ 'addedAt': helpers.datetime_to_iso,
+ 'art': None,
+ 'collections': {
+ 'id': None,
+ 'tag': None
+ },
+ 'countries': {
+ 'id': None,
+ 'tag': None
+ },
+ 'fields': {
+ 'name': None,
+ 'locked': None
+ },
+ 'genres': {
+ 'id': None,
+ 'tag': None
+ },
+ 'guid': None,
+ 'index': None,
+ 'key': None,
+ 'lastViewedAt': helpers.datetime_to_iso,
+ 'librarySectionID': None,
+ 'librarySectionKey': None,
+ 'librarySectionTitle': None,
+ 'locations': None,
+ 'moods': {
+ 'id': None,
+ 'tag': None
+ },
+ 'rating': None,
+ 'ratingKey': None,
+ 'styles': {
+ 'id': None,
+ 'tag': None
+ },
+ 'summary': None,
+ 'thumb': None,
+ 'title': None,
+ 'titleSort': None,
+ 'type': None,
+ 'updatedAt': helpers.datetime_to_iso,
+ 'userRating': None,
+ 'viewCount': None,
+ 'albums': lambda e: helpers.get_attrs_to_dict(e, MEDIA_TYPES[e.type])
+}
+
+ALBUM_ATTRS = {
+ 'addedAt': helpers.datetime_to_iso,
+ 'art': None,
+ 'collections': {
+ 'id': None,
+ 'tag': None
+ },
+ 'fields': {
+ 'name': None,
+ 'locked': None
+ },
+ 'genres': {
+ 'id': None,
+ 'tag': None
+ },
+ 'guid': None,
+ 'index': None,
+ 'key': None,
+ 'labels': {
+ 'id': None,
+ 'tag': None
+ },
+ 'lastViewedAt': helpers.datetime_to_iso,
+ 'leafCount': None,
+ 'librarySectionID': None,
+ 'librarySectionKey': None,
+ 'librarySectionTitle': None,
+ 'loudnessAnalysisVersion': None,
+ 'moods': {
+ 'id': None,
+ 'tag': None
+ },
+ 'originallyAvailableAt': partial(helpers.datetime_to_iso, to_date=True),
+ 'parentGuid': None,
+ 'parentKey': None,
+ 'parentRatingKey': None,
+ 'parentThumb': None,
+ 'parentTitle': None,
+ 'rating': None,
+ 'ratingKey': None,
+ 'styles': {
+ 'id': None,
+ 'tag': None
+ },
+ 'summary': None,
+ 'thumb': None,
+ 'title': None,
+ 'titleSort': None,
+ 'type': None,
+ 'updatedAt': helpers.datetime_to_iso,
+ 'userRating': None,
+ 'viewCount': None,
+ 'viewedLeafCount': None,
+ 'tracks': lambda e: helpers.get_attrs_to_dict(e, MEDIA_TYPES[e.type])
+}
+
+TRACK_ATTRS = {
+ 'addedAt': helpers.datetime_to_iso,
+ 'art': None,
+ 'duration': None,
+ 'grandparentArt': None,
+ 'grandparentGuid': None,
+ 'grandparentKey': None,
+ 'grandparentRatingKey': None,
+ 'grandparentThumb': None,
+ 'grandparentTitle': None,
+ 'guid': None,
+ 'index': None,
+ 'key': None,
+ 'lastViewedAt': helpers.datetime_to_iso,
+ 'librarySectionID': None,
+ 'librarySectionKey': None,
+ 'librarySectionTitle': None,
+ 'media': {
+ 'audioChannels': None,
+ 'audioCodec': None,
+ 'audioProfile': None,
+ 'bitrate': None,
+ 'container': None,
+ 'duration': None,
+ 'id': None,
+ 'title': None,
+ 'parts': {
+ 'accessible': None,
+ 'audioProfile': None,
+ 'container': None,
+ 'deepAnalysisVersion': None,
+ 'duration': None,
+ 'exists': None,
+ 'file': None,
+ 'hasThumbnail': None,
+ 'id': None,
+ 'key': None,
+ 'size': None,
+ 'requiredBandwidths': lambda e: [int(b) for b in e.split(',')] if e else None,
+ 'syncItemId': None,
+ 'syncState': None,
+ 'audioStreams': {
+ 'codec': None,
+ 'codecID': None,
+ 'displayTitle': None,
+ 'extendedDisplayTitle': None,
+ 'id': None,
+ 'index': None,
+ 'selected': None,
+ 'streamType': None,
+ 'title': None,
+ 'type': None,
+ 'albumGain': None,
+ 'albumPeak': None,
+ 'albumRange': None,
+ 'audioChannelLayout': None,
+ 'bitrate': None,
+ 'channels': None,
+ 'duration': None,
+ 'endRamp': None,
+ 'gain': None,
+ 'loudness': None,
+ 'lra': None,
+ 'peak': None,
+ 'requiredBandwidths': lambda e: [int(b) for b in e.split(',')] if e else None,
+ 'samplingRate': None,
+ 'startRamp': None,
+ },
+ 'lyricStreams': {
+ 'codec': None,
+ 'codecID': None,
+ 'displayTitle': None,
+ 'extendedDisplayTitle': None,
+ 'id': None,
+ 'index': None,
+ 'minLines': None,
+ 'provider': None,
+ 'streamType': None,
+ 'timed': None,
+ 'title': None,
+ 'type': None,
+ 'format': None,
+ 'key': None
+ }
+ }
+ },
+ 'moods': {
+ 'id': None,
+ 'tag': None
+ },
+ 'originalTitle': None,
+ 'parentGuid': None,
+ 'parentIndex': None,
+ 'parentKey': None,
+ 'parentRatingKey': None,
+ 'parentThumb': None,
+ 'parentTitle': None,
+ 'ratingCount': None,
+ 'ratingKey': None,
+ 'summary': None,
+ 'thumb': None,
+ 'title': None,
+ 'titleSort': None,
+ 'type': None,
+ 'updatedAt': helpers.datetime_to_iso,
+ 'userRating': None,
+ 'viewCount': None,
+ 'year': None,
+}
+
+PHOTO_ALBUM_ATTRS = {
+ # For some reason photos needs to be first,
+ # otherwise the photo album ratingKey gets
+ # clobbered by the first photo's ratingKey
+ 'photos': lambda e: helpers.get_attrs_to_dict(e, MEDIA_TYPES[e.type]),
+ 'addedAt': helpers.datetime_to_iso,
+ 'art': None,
+ 'composite': None,
+ 'guid': None,
+ 'index': None,
+ 'key': None,
+ 'librarySectionID': None,
+ 'librarySectionKey': None,
+ 'librarySectionTitle': None,
+ 'ratingKey': None,
+ 'summary': None,
+ 'thumb': None,
+ 'title': None,
+ 'type': None,
+ 'updatedAt': helpers.datetime_to_iso
+}
+
+PHOTO_ATTRS = {
+ 'addedAt': helpers.datetime_to_iso,
+ 'createdAtAccuracy': None,
+ 'createdAtTZOffset': None,
+ 'guid': None,
+ 'index': None,
+ 'key': None,
+ 'librarySectionID': None,
+ 'librarySectionKey': None,
+ 'librarySectionTitle': None,
+ 'originallyAvailableAt': partial(helpers.datetime_to_iso, to_date=True),
+ 'parentGuid': None,
+ 'parentIndex': None,
+ 'parentKey': None,
+ 'parentRatingKey': None,
+ 'parentThumb': None,
+ 'parentTitle': None,
+ 'ratingKey': None,
+ 'summary': None,
+ 'thumb': None,
+ 'title': None,
+ 'type': None,
+ 'updatedAt': helpers.datetime_to_iso,
+ 'year': None,
+ 'media': {
+ 'aperture': None,
+ 'aspectRatio': None,
+ 'container': None,
+ 'height': None,
+ 'id': None,
+ 'iso': None,
+ 'lens': None,
+ 'make': None,
+ 'model': None,
+ 'width': None,
+ 'parts': {
+ 'accessible': None,
+ 'container': None,
+ 'exists': None,
+ 'file': None,
+ 'id': None,
+ 'key': None,
+ 'size': None
+ }
+ },
+ 'tag': {
+ 'id': None,
+ 'tag': None,
+ 'title': None
+ }
+}
+
+COLLECTION_ATTRS = {
+ 'addedAt': helpers.datetime_to_iso,
+ 'childCount': None,
+ 'collectionMode': None,
+ 'collectionSort': None,
+ 'contentRating': None,
+ 'fields': {
+ 'name': None,
+ 'locked': None
+ },
+ 'guid': None,
+ 'index': None,
+ 'key': None,
+ 'librarySectionID': None,
+ 'librarySectionKey': None,
+ 'librarySectionTitle': None,
+ 'maxYear': None,
+ 'minYear': None,
+ 'ratingKey': None,
+ 'subtype': None,
+ 'summary': None,
+ 'thumb': None,
+ 'title': None,
+ 'type': None,
+ 'updatedAt': helpers.datetime_to_iso,
+ 'children': lambda e: helpers.get_attrs_to_dict(e, MEDIA_TYPES[e.type])
+}
+
+PLAYLIST_ATTRS = {
+ 'addedAt': helpers.datetime_to_iso,
+ 'composite': None,
+ 'duration': None,
+ 'guid': None,
+ 'key': None,
+ 'leafCount': None,
+ 'playlistType': None,
+ 'ratingKey': None,
+ 'smart': None,
+ 'summary': None,
+ 'title': None,
+ 'type': None,
+ 'updatedAt': helpers.datetime_to_iso,
+ 'items': lambda e: helpers.get_attrs_to_dict(e, MEDIA_TYPES[e.type])
+}
+
+MEDIA_TYPES = {
+ 'movie': MOVIE_ATTRS,
+ 'show': SHOW_ATTRS,
+ 'season': SEASON_ATTRS,
+ 'episode': EPISODE_ATTRS,
+ 'artist': ARTIST_ATTRS,
+ 'album': ALBUM_ATTRS,
+ 'track': TRACK_ATTRS,
+ 'photo album': PHOTO_ALBUM_ATTRS,
+ 'photo': PHOTO_ATTRS,
+ 'collection': COLLECTION_ATTRS,
+ 'playlist': PLAYLIST_ATTRS
+}
+
+
+def export(section_id=None, rating_key=None, output_format='json'):
+ timestamp = helpers.timestamp()
+
+ if not section_id and not rating_key:
+ logger.error("Tautulli Exporter :: Export called but no section_id or rating_key provided.")
+ return
+ elif section_id and not str(section_id).isdigit():
+ logger.error("Tautulli Exporter :: Export called with invalid section_id '%s'.", section_id)
+ return
+ elif rating_key and not str(rating_key).isdigit():
+ logger.error("Tautulli Exporter :: Export called with invalid rating_key '%s'.", rating_key)
+ return
+ elif output_format not in ('json', 'csv'):
+ logger.error("Tautulli Exporter :: Export called but invalid output_format '%s' provided.", output_format)
+ return
+
+ plex = Plex(plexpy.CONFIG.PMS_URL, plexpy.CONFIG.PMS_TOKEN)
+
+ if section_id:
+ logger.debug("Tautulli Exporter :: Exporting called with section_id %s", section_id)
+
+ library = plex.get_library(section_id)
+ media_type = library.type
+ library_title = library.title
+ filename = 'Library - {} [{}].{}.{}'.format(
+ library_title, section_id, helpers.timestamp_to_YMDHMS(timestamp), output_format)
+ items = library.all()
+
+ elif rating_key:
+ logger.debug("Tautulli Exporter :: Exporting called with rating_key %s", rating_key)
+
+ item = plex.get_item(helpers.cast_to_int(rating_key))
+ media_type = item.type
+
+ if media_type in ('season', 'episode', 'album', 'track'):
+ item_title = item._defaultSyncTitle()
+ else:
+ item_title = item.title
+
+ if media_type == 'photo' and item.TAG == 'Directory':
+ media_type = 'photo album'
+
+ filename = '{} - {} [{}].{}.{}'.format(
+ media_type.title(), item_title, rating_key, helpers.timestamp_to_YMDHMS(timestamp), output_format)
+
+ items = [item]
+
+ else:
+ return
+
+ filename = helpers.clean_filename(filename)
+ filepath = os.path.join(plexpy.CONFIG.CACHE_DIR, filename)
+ logger.info("Tautulli Exporter :: Starting export for '%s'...", filename)
+
+ attrs = MEDIA_TYPES[media_type]
+ part = partial(helpers.get_attrs_to_dict, attrs=attrs)
+
+ with ThreadPool(processes=4) as pool:
+ result = pool.map(part, items)
+
+ if output_format == 'json':
+ with open(filepath, 'w', encoding='utf-8') as outfile:
+ json.dump(result, outfile, indent=4, ensure_ascii=False, sort_keys=True)
+
+ elif output_format == 'csv':
+ flatten_result = helpers.flatten_dict(result)
+ flatten_attrs = helpers.flatten_dict(attrs)
+ with open(filepath, 'w', encoding='utf-8', newline='') as outfile:
+ writer = csv.DictWriter(outfile, sorted(flatten_attrs[0].keys()))
+ writer.writeheader()
+ writer.writerows(flatten_result)
+
+ logger.info("Tautulli Exporter :: Successfully exported to '%s'", filepath)
diff --git a/plexpy/plex.py b/plexpy/plex.py
new file mode 100644
index 00000000..b46f0722
--- /dev/null
+++ b/plexpy/plex.py
@@ -0,0 +1,42 @@
+# -*- coding: utf-8 -*-
+
+# This file is part of Tautulli.
+#
+# Tautulli is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Tautulli is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Tautulli. If not, see .
+
+from __future__ import unicode_literals
+from future.builtins import object
+from future.builtins import str
+
+from plexapi.server import PlexServer
+
+import plexpy
+if plexpy.PYTHON2:
+ import logger
+else:
+ from plexpy import logger
+
+
+class Plex(object):
+ def __init__(self, url, token):
+ self.plex = PlexServer(url, token)
+
+ def get_library(self, section_id):
+ return self.plex.library.sectionByID(str(section_id))
+
+ def get_library_items(self, section_id):
+ return self.get_library(str(section_id)).all()
+
+ def get_item(self, rating_key):
+ return self.plex.fetchItem(rating_key)
diff --git a/plexpy/webserve.py b/plexpy/webserve.py
index 47bb7912..aa439417 100644
--- a/plexpy/webserve.py
+++ b/plexpy/webserve.py
@@ -48,6 +48,7 @@ if plexpy.PYTHON2:
import config
import database
import datafactory
+ import exporter
import graphs
import helpers
import http_handler
@@ -81,6 +82,7 @@ else:
from plexpy import config
from plexpy import database
from plexpy import datafactory
+ from plexpy import exporter
from plexpy import graphs
from plexpy import helpers
from plexpy import http_handler
@@ -6414,3 +6416,15 @@ class WebInterface(object):
status['message'] = 'Database not ok'
return status
+
+ @cherrypy.expose
+ @cherrypy.tools.json_out()
+ @requireAuth(member_of("admin"))
+ @addtoapi()
+ def export_metadata(self, section_id=None, rating_key=None, output_format='json', **kwargs):
+ threading.Thread(target=exporter.export,
+ kwargs={'section_id': section_id,
+ 'rating_key': rating_key,
+ 'output_format': output_format}).start()
+ return {'result': 'success',
+ 'message': 'Metadata export has started. Check the logs to monitor any problems.'}
From 546867681132247b643da580bbbca6e1cb9062dc Mon Sep 17 00:00:00 2001
From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com>
Date: Mon, 3 Aug 2020 13:24:16 -0700
Subject: [PATCH 004/134] Add table to list exported items
---
data/interfaces/default/css/tautulli.css | 15 +++
.../default/js/tables/export_table.js | 100 +++++++++++++++++
data/interfaces/default/library.html | 73 +++++++++++++
plexpy/__init__.py | 7 ++
plexpy/exporter.py | 103 ++++++++++++++++++
plexpy/webserve.py | 59 ++++++++++
6 files changed, 357 insertions(+)
create mode 100644 data/interfaces/default/js/tables/export_table.js
diff --git a/data/interfaces/default/css/tautulli.css b/data/interfaces/default/css/tautulli.css
index 61b1af65..a34b4777 100644
--- a/data/interfaces/default/css/tautulli.css
+++ b/data/interfaces/default/css/tautulli.css
@@ -473,6 +473,21 @@ fieldset[disabled] .btn-bright.active {
background-color: #ac2925;
border-color: #761c19;
}
+.btn-dark.btn-download:hover {
+ color: #fff;
+ background-color: #449d44;
+ border-color: #398439;
+}
+.btn-dark.btn-download.active {
+ color: #fff;
+ background-color: #449d44;
+ border-color: #398439;
+}
+.btn-dark.btn-download.active:hover {
+ color: #fff;
+ background-color: #398439;
+ border-color: #255625;
+}
.btn-group select {
margin-top: 0;
}
diff --git a/data/interfaces/default/js/tables/export_table.js b/data/interfaces/default/js/tables/export_table.js
new file mode 100644
index 00000000..cde8c815
--- /dev/null
+++ b/data/interfaces/default/js/tables/export_table.js
@@ -0,0 +1,100 @@
+var date_format = 'YYYY-MM-DD';
+var time_format = 'hh:mm a';
+
+$.ajax({
+ url: 'get_date_formats',
+ type: 'GET',
+ success: function (data) {
+ date_format = data.date_format;
+ time_format = data.time_format;
+ }
+});
+
+export_table_options = {
+ "destroy": true,
+ "language": {
+ "search": "Search: ",
+ "lengthMenu": "Show _MENU_ entries per page",
+ "info": "Showing _START_ to _END_ of _TOTAL_ library items",
+ "infoEmpty": "Showing 0 to 0 of 0 entries",
+ "infoFiltered": "(filtered from _MAX_ total entries) ",
+ "emptyTable": "No data in table",
+ "loadingRecords": ' Loading items...'
+ },
+ "pagingType": "full_numbers",
+ "stateSave": true,
+ "stateDuration": 0,
+ "processing": false,
+ "serverSide": true,
+ "pageLength": 25,
+ "order": [0, 'desc'],
+ "autoWidth": false,
+ "scrollX": true,
+ "columnDefs": [
+ {
+ "targets": [0],
+ "data": "timestamp",
+ "createdCell": function (td, cellData, rowData, row, col) {
+ if (cellData !== '') {
+ $(td).html(moment(cellData, "X").format(date_format + ' ' + time_format));
+ }
+ },
+ "width": "10%",
+ "className": "no-wrap"
+ },
+ {
+ "targets": [1],
+ "data": "media_type_title",
+ "createdCell": function (td, cellData, rowData, row, col) {
+ if (cellData !== '') {
+ $(td).html(cellData);
+ }
+ },
+ "width": "10%",
+ "className": "no-wrap"
+ },
+ {
+ "targets": [2],
+ "data": "rating_key",
+ "createdCell": function (td, cellData, rowData, row, col) {
+ if (cellData !== '') {
+ $(td).html(cellData);
+ }
+ },
+ "width": "10%",
+ "className": "no-wrap"
+ },
+ {
+ "targets": [3],
+ "data": "filename",
+ "createdCell": function (td, cellData, rowData, row, col) {
+ if (cellData !== '') {
+ $(td).html(cellData);
+ }
+ },
+ "width": "60%",
+ "className": "no-wrap"
+ },
+ {
+ "targets": [4],
+ "data": "complete",
+ "createdCell": function (td, cellData, rowData, row, col) {
+ if (cellData === 1) {
+ $(td).html(' Download ');
+ } else {
+ $(td).html(' Processing ');
+ }
+ },
+ "width": "10%"
+ }
+ ],
+ "drawCallback": function (settings) {
+ // Jump to top of page
+ //$('html,body').scrollTop(0);
+ $('#ajaxMsg').fadeOut();
+ },
+ "preDrawCallback": function(settings) {
+ var msg = " Fetching rows...";
+ showMsg(msg, false, false, 0)
+ }
+};
diff --git a/data/interfaces/default/library.html b/data/interfaces/default/library.html
index 99a2deaf..9c0eef45 100644
--- a/data/interfaces/default/library.html
+++ b/data/interfaces/default/library.html
@@ -93,6 +93,7 @@ DOCUMENTATION :: END
% if _session['user_group'] == 'admin':
% if data['section_id'] != LIVE_TV_SECTION_ID:
Media Info
+ Export
% endif
% endif
@@ -305,6 +306,47 @@ DOCUMENTATION :: END
+
+
+
+
+
+
+
+
+
+ Exported At
+ Media Type
+ Rating Key
+ Filename
+ Download
+
+
+
+
+
+
+
+
+
@@ -387,6 +429,7 @@ DOCUMENTATION :: END
+
\ No newline at end of file
diff --git a/data/interfaces/default/info.html b/data/interfaces/default/info.html
index 4c938dea..a3cc5060 100644
--- a/data/interfaces/default/info.html
+++ b/data/interfaces/default/info.html
@@ -510,6 +510,12 @@ DOCUMENTATION :: END
% endif
+
+
+ Export Metadata
+
+
% endif
Refresh history
@@ -629,6 +635,8 @@ DOCUMENTATION :: END
% endif
+
+
%def>
<%def name="javascriptIncludes()">
@@ -770,7 +778,7 @@ DOCUMENTATION :: END
data: {
rating_key: rating_key,
notifier_id: $('#send-notification-notifier option:selected').val()
- },
+ },
async: true,
success: function (data) {
if (data.result === 'success') {
@@ -832,6 +840,19 @@ DOCUMENTATION :: END
return '
';
}
});
+
+ $("#toggle-export-modal").click(function() {
+ $.ajax({
+ url: 'export_metadata_modal',
+ data: { rating_key: $(this).data('id') },
+ cache: false,
+ async: true,
+ complete: function(xhr, status) {
+ $("#export-modal").html(xhr.responseText);
+ }
+ });
+ });
+
% if data.get('poster_url'):
+
% if data['live']:
% if data.get('poster_url'):
+
diff --git a/data/interfaces/default/settings.html b/data/interfaces/default/settings.html
index 004294bf..2ab096e2 100644
--- a/data/interfaces/default/settings.html
+++ b/data/interfaces/default/settings.html
@@ -17,8 +17,6 @@
%def>
<%def name="headerIncludes()">
-
-
%def>
<%def name="body()">
@@ -1987,7 +1985,6 @@ Rating: {rating}/10 --> Rating: /10
<%def name="javascriptIncludes()">
-
% endif
-% if data['media_type'] != 'collection':
+% if data['media_type'] not in ('collection', 'playlist'):
% endif
-% if data['media_type'] in ('show', 'season', 'artist', 'album', 'collection'):
+% if data['media_type'] in ('show', 'season', 'artist', 'album', 'collection', 'playlist'):
+ <% f = 'h:mm:ss' if cast_to_int(child['duration']) >= 3600000 else 'm:ss' %>
+
- % else:
-
-
${child['media_index']}
-
${child['title']}
- % if child['original_title']:
- - ${child['original_title']}
- % endif
-
-
-
-
-
- % endif
% endif
% endif
diff --git a/data/interfaces/default/js/script.js b/data/interfaces/default/js/script.js
index 57f0dee5..e6ca1577 100644
--- a/data/interfaces/default/js/script.js
+++ b/data/interfaces/default/js/script.js
@@ -330,25 +330,6 @@ function humanTime(seconds) {
}
}
-function humanTimeClean(seconds) {
- var text;
- if (seconds >= 86400) {
- text = Math.floor(moment.duration(seconds, 'seconds').asDays()) + ' days ' + Math.floor(moment.duration((
- seconds % 86400), 'seconds').asHours()) + ' hrs ' + Math.floor(moment.duration(
- ((seconds % 86400) % 3600), 'seconds').asMinutes()) + ' mins';
- return text;
- } else if (seconds >= 3600) {
- text = Math.floor(moment.duration((seconds % 86400), 'seconds').asHours()) + ' hrs ' + Math.floor(moment.duration(
- ((seconds % 86400) % 3600), 'seconds').asMinutes()) + ' mins';
- return text;
- } else if (seconds >= 60) {
- text = Math.floor(moment.duration(((seconds % 86400) % 3600), 'seconds').asMinutes()) + ' mins';
- return text;
- } else {
- text = '0';
- return text;
- }
-}
String.prototype.toProperCase = function () {
return this.replace(/\w\S*/g, function (txt) {
return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase();
@@ -372,6 +353,57 @@ function millisecondsToMinutes(ms, roundToMinute) {
}
}
}
+
+function humanDuration(ms, sig='dhms', units='ms') {
+ var factors = {
+ d: 86400000,
+ h: 3600000,
+ m: 60000,
+ s: 1000,
+ ms: 1
+ }
+
+ ms = parseInt(ms);
+ var d, h, m, s;
+
+ if (ms > 0) {
+ ms = ms * factors[units];
+
+ h = ms % factors['d'];
+ d = Math.trunc(ms / factors['d']);
+
+ m = h % factors['h'];
+ h = Math.trunc(h / factors['h']);
+
+ s = m % factors['m'];
+ m = Math.trunc(m / factors['m']);
+
+ ms = s % factors['s'];
+ s = Math.trunc(s / factors['s']);
+
+ var hd_list = [];
+ if (sig >= 'd' && d > 0) {
+ d = (sig === 'd' && h >= 12) ? d + 1 : d;
+ hd_list.push(d.toString() + ' day' + ((d > 1) ? 's' : ''));
+ }
+ if (sig >= 'dh' && h > 0) {
+ h = (sig === 'dh' && m >= 30) ? h + 1 : h;
+ hd_list.push(h.toString() + ' hr' + ((h > 1) ? 's' : ''));
+ }
+ if (sig >= 'dhm' && m > 0) {
+ m = (sig === 'dhm' && s >= 30) ? m + 1 : m;
+ hd_list.push(m.toString() + ' min' + ((m > 1) ? 's' : ''));
+ }
+ if (sig >= 'dhms' && s > 0) {
+ hd_list.push(s.toString() + ' sec' + ((s > 1) ? 's' : ''));
+ }
+
+ return hd_list.join(' ')
+ } else {
+ return '0'
+ }
+}
+
// Our countdown plugin takes a callback, a duration, and an optional message
$.fn.countdown = function (callback, duration, message) {
// If no message is provided, we use an empty string
diff --git a/data/interfaces/default/js/tables/libraries.js b/data/interfaces/default/js/tables/libraries.js
index 34239c3d..a768f3e0 100644
--- a/data/interfaces/default/js/tables/libraries.js
+++ b/data/interfaces/default/js/tables/libraries.js
@@ -192,7 +192,7 @@ libraries_list_table_options = {
"data": "duration",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== null && cellData !== '') {
- $(td).html(humanTimeClean(cellData));
+ $(td).html(humanDuration(cellData, 'dhm', 's'));
}
},
"searchable": false,
diff --git a/data/interfaces/default/js/tables/users.js b/data/interfaces/default/js/tables/users.js
index da2fc08e..0eb4485d 100644
--- a/data/interfaces/default/js/tables/users.js
+++ b/data/interfaces/default/js/tables/users.js
@@ -212,7 +212,7 @@ users_list_table_options = {
"data": "duration",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== null && cellData !== '') {
- $(td).html(humanTimeClean(cellData));
+ $(td).html(humanDuration(cellData, 'dhm', 's'));
}
},
"searchable": false,
diff --git a/plexpy/pmsconnect.py b/plexpy/pmsconnect.py
index 62b359b2..576f209f 100644
--- a/plexpy/pmsconnect.py
+++ b/plexpy/pmsconnect.py
@@ -173,6 +173,22 @@ class PmsConnect(object):
return request
+ def get_playlist_items(self, rating_key='', output_format=''):
+ """
+ Return metadata for items of the requested playlist.
+
+ Parameters required: rating_key { Plex ratingKey }
+ Optional parameters: output_format { dict, json }
+
+ Output: array
+ """
+ uri = '/playlists/' + rating_key + '/items'
+ request = self.request_handler.make_request(uri=uri,
+ request_type='GET',
+ output_format=output_format)
+
+ return request
+
def get_recently_added(self, start='0', count='0', output_format=''):
"""
Return list of recently added items.
@@ -654,6 +670,8 @@ class PmsConnect(object):
metadata_main_list = a.getElementsByTagName('Track')
elif a.getElementsByTagName('Photo'):
metadata_main_list = a.getElementsByTagName('Photo')
+ elif a.getElementsByTagName('Playlist'):
+ metadata_main_list = a.getElementsByTagName('Playlist')
else:
logger.debug("Tautulli Pmsconnect :: Metadata failed")
return {}
@@ -1251,6 +1269,26 @@ class PmsConnect(object):
'live': int(helpers.get_xml_attr(metadata_main, 'live') == '1')
}
+ elif metadata_type == 'playlist':
+ metadata = {'media_type': metadata_type,
+ 'section_id': section_id,
+ 'library_name': library_name,
+ 'rating_key': helpers.get_xml_attr(metadata_main, 'ratingKey'),
+ 'guid': helpers.get_xml_attr(metadata_main, 'guid'),
+ 'title': helpers.get_xml_attr(metadata_main, 'title'),
+ 'summary': helpers.get_xml_attr(metadata_main, 'summary'),
+ 'duration': helpers.get_xml_attr(metadata_main, 'duration'),
+ 'composite': helpers.get_xml_attr(metadata_main, 'composite'),
+ 'thumb': helpers.get_xml_attr(metadata_main, 'composite'),
+ 'added_at': helpers.get_xml_attr(metadata_main, 'addedAt'),
+ 'updated_at': helpers.get_xml_attr(metadata_main, 'updatedAt'),
+ 'last_viewed_at': helpers.get_xml_attr(metadata_main, 'lastViewedAt'),
+ 'children_count': helpers.cast_to_int(helpers.get_xml_attr(metadata_main, 'leafCount')),
+ 'smart': helpers.cast_to_int(helpers.get_xml_attr(metadata_main, 'smart')),
+ 'playlist_type': helpers.get_xml_attr(metadata_main, 'playlistType'),
+ 'live': int(helpers.get_xml_attr(metadata_main, 'live') == '1')
+ }
+
elif metadata_type == 'clip':
metadata = {'media_type': metadata_type,
'section_id': section_id,
@@ -2241,13 +2279,15 @@ class PmsConnect(object):
logger.warn("Tautulli Pmsconnect :: Failed to terminate session: %s." % msg)
return msg
- def get_item_children(self, rating_key='', get_grandchildren=False):
+ def get_item_children(self, rating_key='', media_type=None, get_grandchildren=False):
"""
Return processed and validated children list.
Output: array
"""
- if get_grandchildren:
+ if media_type == 'playlist':
+ children_data = self.get_playlist_items(rating_key, output_format='xml')
+ elif get_grandchildren:
children_data = self.get_metadata_grandchildren(rating_key, output_format='xml')
else:
children_data = self.get_metadata_children(rating_key, output_format='xml')
diff --git a/plexpy/webserve.py b/plexpy/webserve.py
index e0d3fd8d..1ab1d04f 100644
--- a/plexpy/webserve.py
+++ b/plexpy/webserve.py
@@ -4341,13 +4341,14 @@ class WebInterface(object):
@cherrypy.expose
@requireAuth()
- def get_item_children(self, rating_key='', **kwargs):
+ def get_item_children(self, rating_key='', media_type=None, **kwargs):
pms_connect = pmsconnect.PmsConnect()
- result = pms_connect.get_item_children(rating_key=rating_key)
+ result = pms_connect.get_item_children(rating_key=rating_key, media_type=media_type)
if result:
- return serve_template(templatename="info_children_list.html", data=result, title="Children List")
+ return serve_template(templatename="info_children_list.html", data=result,
+ media_type=media_type, title="Children List")
else:
logger.warn("Unable to retrieve data for get_item_children.")
return serve_template(templatename="info_children_list.html", data=None, title="Children List")
From 84207effabe3a6c4ea41ecf00b2ae8a1da1c2770 Mon Sep 17 00:00:00 2001
From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com>
Date: Wed, 30 Sep 2020 15:44:23 -0700
Subject: [PATCH 108/134] Initial collections and playlists table to library
page
---
.../default/js/tables/collections_table.js | 78 ++++++++++
.../default/js/tables/playlists_table.js | 78 ++++++++++
data/interfaces/default/library.html | 146 +++++++++++++++++-
plexpy/libraries.py | 113 ++++++++++++++
plexpy/webserve.py | 100 ++++++++++++
5 files changed, 514 insertions(+), 1 deletion(-)
create mode 100644 data/interfaces/default/js/tables/collections_table.js
create mode 100644 data/interfaces/default/js/tables/playlists_table.js
diff --git a/data/interfaces/default/js/tables/collections_table.js b/data/interfaces/default/js/tables/collections_table.js
new file mode 100644
index 00000000..3f0aaae2
--- /dev/null
+++ b/data/interfaces/default/js/tables/collections_table.js
@@ -0,0 +1,78 @@
+collections_table_options = {
+ "destroy": true,
+ "language": {
+ "search": "Search: ",
+ "lengthMenu": "Show _MENU_ entries per page",
+ "info": "Showing _START_ to _END_ of _TOTAL_ export items",
+ "infoEmpty": "Showing 0 to 0 of 0 entries",
+ "infoFiltered": "(filtered from _MAX_ total entries) ",
+ "emptyTable": "No data in table",
+ "loadingRecords": ' Loading items...'
+ },
+ "pagingType": "full_numbers",
+ "stateSave": true,
+ "stateDuration": 0,
+ "processing": false,
+ "serverSide": true,
+ "pageLength": 25,
+ "order": [0, 'asc'],
+ "autoWidth": false,
+ "scrollX": true,
+ "columnDefs": [
+ {
+ "targets": [0],
+ "data": "title",
+ "createdCell": function (td, cellData, rowData, row, col) {
+ if (cellData !== '') {
+ $(td).html('' + cellData + ' ');
+ }
+ },
+ "width": "50%",
+ "className": "no-wrap"
+ },
+ {
+ "targets": [1],
+ "data": "collectionMode",
+ "createdCell": function (td, cellData, rowData, row, col) {
+ if (cellData !== '') {
+ $(td).html(cellData);
+ }
+ },
+ "width": "20%",
+ "className": "no-wrap"
+ },
+ {
+ "targets": [2],
+ "data": "collectionSort",
+ "createdCell": function (td, cellData, rowData, row, col) {
+ if (cellData !== '') {
+ $(td).html(cellData);
+ }
+ },
+ "width": "20%",
+ "className": "no-wrap"
+ },
+ {
+ "targets": [3],
+ "data": "childCount",
+ "createdCell": function (td, cellData, rowData, row, col) {
+ if (cellData !== '') {
+ $(td).html(cellData);
+ }
+ },
+ "width": "10%",
+ "className": "no-wrap"
+ }
+ ],
+ "drawCallback": function (settings) {
+ // Jump to top of page
+ //$('html,body').scrollTop(0);
+ $('#ajaxMsg').fadeOut();
+ },
+ "preDrawCallback": function(settings) {
+ var msg = " Fetching rows...";
+ showMsg(msg, false, false, 0);
+ },
+ "rowCallback": function (row, rowData, rowIndex) {
+ }
+};
\ No newline at end of file
diff --git a/data/interfaces/default/js/tables/playlists_table.js b/data/interfaces/default/js/tables/playlists_table.js
new file mode 100644
index 00000000..a11f1452
--- /dev/null
+++ b/data/interfaces/default/js/tables/playlists_table.js
@@ -0,0 +1,78 @@
+playlists_table_options = {
+ "destroy": true,
+ "language": {
+ "search": "Search: ",
+ "lengthMenu": "Show _MENU_ entries per page",
+ "info": "Showing _START_ to _END_ of _TOTAL_ export items",
+ "infoEmpty": "Showing 0 to 0 of 0 entries",
+ "infoFiltered": "(filtered from _MAX_ total entries) ",
+ "emptyTable": "No data in table",
+ "loadingRecords": ' Loading items...'
+ },
+ "pagingType": "full_numbers",
+ "stateSave": true,
+ "stateDuration": 0,
+ "processing": false,
+ "serverSide": true,
+ "pageLength": 25,
+ "order": [0, 'asc'],
+ "autoWidth": false,
+ "scrollX": true,
+ "columnDefs": [
+ {
+ "targets": [0],
+ "data": "title",
+ "createdCell": function (td, cellData, rowData, row, col) {
+ if (cellData !== '') {
+ $(td).html('' + cellData + ' ');
+ }
+ },
+ "width": "50%",
+ "className": "no-wrap"
+ },
+ {
+ "targets": [1],
+ "data": "leafCount",
+ "createdCell": function (td, cellData, rowData, row, col) {
+ if (cellData !== '') {
+ $(td).html(cellData);
+ }
+ },
+ "width": "20%",
+ "className": "no-wrap"
+ },
+ {
+ "targets": [2],
+ "data": "duration",
+ "createdCell": function (td, cellData, rowData, row, col) {
+ if (cellData !== '') {
+ $(td).html(humanDuration(cellData, 'dhm'));
+ }
+ },
+ "width": "20%",
+ "className": "no-wrap"
+ },
+ {
+ "targets": [3],
+ "data": "smart",
+ "createdCell": function (td, cellData, rowData, row, col) {
+ if (cellData !== '') {
+ $(td).html(cellData);
+ }
+ },
+ "width": "10%",
+ "className": "no-wrap"
+ }
+ ],
+ "drawCallback": function (settings) {
+ // Jump to top of page
+ //$('html,body').scrollTop(0);
+ $('#ajaxMsg').fadeOut();
+ },
+ "preDrawCallback": function(settings) {
+ var msg = " Fetching rows...";
+ showMsg(msg, false, false, 0);
+ },
+ "rowCallback": function (row, rowData, rowIndex) {
+ }
+};
\ No newline at end of file
diff --git a/data/interfaces/default/library.html b/data/interfaces/default/library.html
index 3391946d..0870acf0 100644
--- a/data/interfaces/default/library.html
+++ b/data/interfaces/default/library.html
@@ -93,6 +93,8 @@ DOCUMENTATION :: END
% if _session['user_group'] == 'admin':
% if data['section_id'] != LIVE_TV_SECTION_ID:
Media Info
+ Collections
+ Playlists
Export
% endif
% endif
@@ -259,7 +261,7 @@ DOCUMENTATION :: END
+
+
+
+
+
+
+
+
+
+ Title
+ Collection Mode
+ Collection Sort
+ Items
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Title
+ Items
+ Duration
+ Smart
+
+
+
+
+
+
+
+
+
@@ -442,6 +524,8 @@ DOCUMENTATION :: END
+
+
+ % elif media_type == 'playlist':
+ <% e = 'even' if loop.index % 2 == 0 else 'odd' %>
+
% endif
% endif
% endfor
+
% endif
% endif
diff --git a/data/interfaces/default/js/tables/playlists_table.js b/data/interfaces/default/js/tables/playlists_table.js
index a11f1452..4ed4216a 100644
--- a/data/interfaces/default/js/tables/playlists_table.js
+++ b/data/interfaces/default/js/tables/playlists_table.js
@@ -24,7 +24,7 @@ playlists_table_options = {
"data": "title",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
- $(td).html('
' + cellData + ' ');
+ $(td).html('
' + cellData + ' ');
}
},
"width": "50%",
diff --git a/plexpy/pmsconnect.py b/plexpy/pmsconnect.py
index 576f209f..05248ab7 100644
--- a/plexpy/pmsconnect.py
+++ b/plexpy/pmsconnect.py
@@ -31,6 +31,7 @@ if plexpy.PYTHON2:
import common
import helpers
import http_handler
+ import libraries
import logger
import plextv
import session
@@ -40,6 +41,7 @@ else:
from plexpy import common
from plexpy import helpers
from plexpy import http_handler
+ from plexpy import libraries
from plexpy import logger
from plexpy import plextv
from plexpy import session
@@ -610,7 +612,7 @@ class PmsConnect(object):
return output
- def get_metadata_details(self, rating_key='', sync_id='', plex_guid='',
+ def get_metadata_details(self, rating_key='', sync_id='', plex_guid='', section_id='',
skip_cache=False, cache_key=None, return_cache=False, media_info=True):
"""
Return processed and validated metadata list for requested item.
@@ -687,9 +689,13 @@ class PmsConnect(object):
if metadata_main.nodeName == 'Directory' and metadata_type == 'photo':
metadata_type = 'photo_album'
- section_id = helpers.get_xml_attr(a, 'librarySectionID')
+ section_id = helpers.get_xml_attr(a, 'librarySectionID') or section_id
library_name = helpers.get_xml_attr(a, 'librarySectionTitle')
+ if not library_name and section_id:
+ library_data = libraries.Libraries().get_details(section_id)
+ library_name = library_data['section_name']
+
directors = []
writers = []
actors = []
diff --git a/plexpy/webserve.py b/plexpy/webserve.py
index 609c77dd..d692f26a 100644
--- a/plexpy/webserve.py
+++ b/plexpy/webserve.py
@@ -4399,7 +4399,7 @@ class WebInterface(object):
@cherrypy.expose
@requireAuth()
- def info(self, rating_key=None, guid=None, source=None, **kwargs):
+ def info(self, rating_key=None, guid=None, source=None, section_id=None, **kwargs):
if rating_key and not str(rating_key).isdigit():
raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT)
@@ -4413,7 +4413,7 @@ class WebInterface(object):
# Try to get metadata from the Plex server first
if rating_key:
pms_connect = pmsconnect.PmsConnect()
- metadata = pms_connect.get_metadata_details(rating_key=rating_key)
+ metadata = pms_connect.get_metadata_details(rating_key=rating_key, section_id=section_id)
# If the item is not found on the Plex server, get the metadata from history
if not metadata and source == 'history':
From 1061c334ae47d4f44dfd4b46cdd0b887dd083604 Mon Sep 17 00:00:00 2001
From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com>
Date: Wed, 30 Sep 2020 21:01:14 -0700
Subject: [PATCH 110/134] Improve look of collections and playlists tables
---
data/interfaces/default/css/tautulli.css | 2 ++
data/interfaces/default/info.html | 2 +-
.../default/info_children_list.html | 2 +-
.../default/js/tables/collections_table.js | 22 +++++++++++++---
.../default/js/tables/playlists_table.js | 26 +++++++++----------
data/interfaces/default/library.html | 11 ++++----
plexpy/libraries.py | 24 ++++++++++++-----
7 files changed, 59 insertions(+), 30 deletions(-)
diff --git a/data/interfaces/default/css/tautulli.css b/data/interfaces/default/css/tautulli.css
index dea647c9..bec5ce2d 100644
--- a/data/interfaces/default/css/tautulli.css
+++ b/data/interfaces/default/css/tautulli.css
@@ -1968,6 +1968,8 @@ a:hover .summary-poster-face-track .summary-poster-face-overlay span {
list-style: none;
margin: 0;
overflow: auto;
+}
+.item-children-instance.max-height {
max-height: 875px;
}
.item-children-instance li {
diff --git a/data/interfaces/default/info.html b/data/interfaces/default/info.html
index 08bd0df6..cd20164b 100644
--- a/data/interfaces/default/info.html
+++ b/data/interfaces/default/info.html
@@ -189,7 +189,7 @@ DOCUMENTATION :: END
% if data['media_type'] == 'playlist' and data['smart']:
-
+
% endif
% if _session['user_group'] == 'admin':
diff --git a/data/interfaces/default/info_children_list.html b/data/interfaces/default/info_children_list.html
index d63a1f0b..f6af0c37 100644
--- a/data/interfaces/default/info_children_list.html
+++ b/data/interfaces/default/info_children_list.html
@@ -32,7 +32,7 @@ DOCUMENTATION :: END
%>
% if data['children_count'] > 0: