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": "", + "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(''); + } else { + $(td).html(''); + } + }, + "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 +
    +
    +
    +
    +
    +
    + + Exports for + ${data['section_name']} + + +
    +
    + % if _session['user_group'] == 'admin': +
    + +
    + % endif +
    +
    +
    +
    + + + + + + + + + + + +
    Exported AtMedia TypeRating KeyFilenameDownload
    +
    +
    +
    +
    +
    @@ -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 +
    + +
    % endif
    @@ -629,6 +635,8 @@ DOCUMENTATION :: END
    % endif + <%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 name="headerIncludes()"> - - <%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": "", + "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": "", + "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
    - Media Info for + Media Info for ${data['section_name']} @@ -306,6 +308,86 @@ DOCUMENTATION :: END
    +
    +
    +
    +
    +
    +
    + + Collections for + ${data['section_name']} + + +
    +
    + % if _session['user_group'] == 'admin': +
    + +
    + % endif +
    +
    +
    +
    + + + + + + + + + + +
    TitleCollection ModeCollection SortItems
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + Playlists for + ${data['section_name']} + + +
    +
    + % if _session['user_group'] == 'admin': +
    + +
    + % endif +
    +
    +
    +
    + + + + + + + + + + +
    TitleItemsDurationSmart
    +
    +
    +
    +
    +
    @@ -442,6 +524,8 @@ DOCUMENTATION :: END + +
    + % elif media_type == 'playlist': + <% e = 'even' if loop.index % 2 == 0 else 'odd' %> +
    +  ${loop.index + 1} + + % if child['media_type'] == 'movie': +   + + ${child['title']} + + (${child['year']}) + % elif child['media_type'] == 'episode': +   + + ${child['grandparent_title']} + - + + ${child['title']} + + (S${child['parent_media_index']} · E${child['media_index']}) + % elif child['media_type'] == 'track': +   + + ${child['title']} + - + + ${child['grandparent_title']} + + (${child['parent_title']}) + % endif + + + <% f = 'h:mm:ss' if cast_to_int(child['duration']) >= 3600000 else 'm:ss' %> + + +
    % 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:
    -
      +
        % for child in data['children_list']: % if child['rating_key']: % if data['children_type'] == 'track' or media_type == 'playlist': diff --git a/data/interfaces/default/js/tables/collections_table.js b/data/interfaces/default/js/tables/collections_table.js index 3f0aaae2..03375056 100644 --- a/data/interfaces/default/js/tables/collections_table.js +++ b/data/interfaces/default/js/tables/collections_table.js @@ -24,7 +24,7 @@ collections_table_options = { "data": "title", "createdCell": function (td, cellData, rowData, row, col) { if (cellData !== '') { - $(td).html('' + cellData + ''); + $(td).html('' + cellData + ''); } }, "width": "50%", @@ -35,7 +35,17 @@ collections_table_options = { "data": "collectionMode", "createdCell": function (td, cellData, rowData, row, col) { if (cellData !== '') { - $(td).html(cellData); + var mode = ''; + if (cellData === -1) { + mode = 'Library default'; + } else if (cellData === 0) { + mode = 'Hide collection'; + } else if (cellData === 1) { + mode = 'Hide items in this collection'; + } else if (cellData === 2) { + mode = 'Show this collection and its items'; + } + $(td).html(mode); } }, "width": "20%", @@ -46,7 +56,13 @@ collections_table_options = { "data": "collectionSort", "createdCell": function (td, cellData, rowData, row, col) { if (cellData !== '') { - $(td).html(cellData); + var sort = ''; + if (cellData === 0) { + sort = 'Release date'; + } else if (cellData === 1) { + sort = 'Alphabetical'; + } + $(td).html(sort); } }, "width": "20%", diff --git a/data/interfaces/default/js/tables/playlists_table.js b/data/interfaces/default/js/tables/playlists_table.js index 4ed4216a..b5859b05 100644 --- a/data/interfaces/default/js/tables/playlists_table.js +++ b/data/interfaces/default/js/tables/playlists_table.js @@ -24,10 +24,14 @@ playlists_table_options = { "data": "title", "createdCell": function (td, cellData, rowData, row, col) { if (cellData !== '') { - $(td).html('' + cellData + ''); + var smart = ''; + if (rowData['smart']) { + smart = ' ' + } + $(td).html('' + smart + cellData + ''); } }, - "width": "50%", + "width": "60%", "className": "no-wrap" }, { @@ -51,27 +55,23 @@ playlists_table_options = { }, "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(); + + // Create the tooltips. + $('body').tooltip({ + selector: '[data-toggle="tooltip"]', + container: 'body' + }); }, "preDrawCallback": function(settings) { var msg = "  Fetching rows..."; showMsg(msg, false, false, 0); + $('[data-toggle="tooltip"]').tooltip('destroy'); }, "rowCallback": function (row, rowData, rowIndex) { } diff --git a/data/interfaces/default/library.html b/data/interfaces/default/library.html index 0870acf0..ab757cfe 100644 --- a/data/interfaces/default/library.html +++ b/data/interfaces/default/library.html @@ -335,10 +335,10 @@ DOCUMENTATION :: END - + - + @@ -375,10 +375,9 @@ DOCUMENTATION :: END
        TitleCollection Title Collection Mode Collection SortItemsCollection Items
        - - - - + + + diff --git a/plexpy/libraries.py b/plexpy/libraries.py index d248b0c7..9168128e 100644 --- a/plexpy/libraries.py +++ b/plexpy/libraries.py @@ -153,12 +153,20 @@ def get_collections(section_id): collections_list = [] for collection in collections: + collection_mode = collection.collectionMode + if collection_mode is None: + collection_mode = -1 + + collection_sort = collection.collectionSort + if collection_sort is None: + collection_sort = 0 + collection_dict = { 'addedAt': helpers.datetime_to_iso(collection.addedAt), 'art': collection.art, 'childCount': collection.childCount, - 'collectionMode': collection.collectionMode, - 'collectionSort': collection.collectionSort, + 'collectionMode': helpers.cast_to_int(collection_mode), + 'collectionSort': helpers.cast_to_int(collection_sort), 'contentRating': collection.contentRating, 'guid': collection.guid, 'librarySectionID': collection.librarySectionID, @@ -181,7 +189,8 @@ def get_collections(section_id): def get_collections_list(section_id=None, **kwargs): if not section_id: - default_return = {'recordsTotal': 0, + default_return = {'recordsFiltered': 0, + 'recordsTotal': 0, 'draw': 0, 'data': 'null', 'error': 'Unable to execute database query.'} @@ -189,7 +198,8 @@ def get_collections_list(section_id=None, **kwargs): collections = get_collections(section_id) - data = {'recordsTotal': len(collections), + data = {'recordsFiltered': len(collections), + 'recordsTotal': len(collections), 'data': collections, 'draw': kwargs.get('draw', 0) } @@ -237,7 +247,8 @@ def get_playlists(section_id): def get_playlists_list(section_id=None, **kwargs): if not section_id: - default_return = {'recordsTotal': 0, + default_return = {'recordsFiltered': 0, + 'recordsTotal': 0, 'draw': 0, 'data': 'null', 'error': 'Unable to execute database query.'} @@ -245,7 +256,8 @@ def get_playlists_list(section_id=None, **kwargs): playlists = get_playlists(section_id) - data = {'recordsTotal': len(playlists), + data = {'recordsFiltered': len(playlists), + 'recordsTotal': len(playlists), 'data': playlists, 'draw': kwargs.get('draw', 0) } From f151bb1451730ff21e0f81ffbf6237dbf306b7b1 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Wed, 30 Sep 2020 22:09:19 -0700 Subject: [PATCH 111/134] Add datatables processing to collections and playlists tables --- .../default/js/tables/collections_table.js | 4 +- plexpy/helpers.py | 51 +++++++++++++++++ plexpy/libraries.py | 51 +++++++++++++---- plexpy/webserve.py | 57 +++++-------------- 4 files changed, 107 insertions(+), 56 deletions(-) diff --git a/data/interfaces/default/js/tables/collections_table.js b/data/interfaces/default/js/tables/collections_table.js index 03375056..e0ea9547 100644 --- a/data/interfaces/default/js/tables/collections_table.js +++ b/data/interfaces/default/js/tables/collections_table.js @@ -21,10 +21,10 @@ collections_table_options = { "columnDefs": [ { "targets": [0], - "data": "title", + "data": "titleSort", "createdCell": function (td, cellData, rowData, row, col) { if (cellData !== '') { - $(td).html('' + cellData + ''); + $(td).html('' + rowData['title'] + ''); } }, "width": "50%", diff --git a/plexpy/helpers.py b/plexpy/helpers.py index e839d6e8..96e6c94f 100644 --- a/plexpy/helpers.py +++ b/plexpy/helpers.py @@ -581,6 +581,57 @@ def process_json_kwargs(json_kwargs): return params +def process_datatable_rows(rows, json_data, default_sort, sort_keys=None): + if sort_keys is None: + sort_keys = {} + + results = [] + + total_count = len(rows) + + # Search results + search_value = json_data['search']['value'].lower() + if search_value: + searchable_columns = [d['data'] for d in json_data['columns'] if d['searchable']] + for row in rows: + for k, v in row.items(): + if k in searchable_columns and search_value in v.lower(): + results.append(row) + break + else: + results = rows + + filtered_count = len(results) + + # Sort results + results = sorted(results, key=lambda k: k[default_sort].lower()) + sort_order = json_data['order'] + for order in reversed(sort_order): + sort_key = json_data['columns'][int(order['column'])]['data'] + reverse = True if order['dir'] == 'desc' else False + results = sorted(results, key=lambda k: sort_helper(k, sort_key, sort_keys), reverse=reverse) + + # Paginate results + results = results[json_data['start']:(json_data['start'] + json_data['length'])] + + data = { + 'results': results, + 'total_count': total_count, + 'filtered_count': filtered_count + } + + return data + + +def sort_helper(k, sort_key, sort_keys): + v = k[sort_key] + if sort_key in sort_keys: + v = sort_keys[sort_key].get(k[sort_key], v) + if isinstance(v, str): + v = v.lower() + return v + + def sanitize_out(*dargs, **dkwargs): """ Helper decorator that sanitized the output """ diff --git a/plexpy/libraries.py b/plexpy/libraries.py index 9168128e..b211eb17 100644 --- a/plexpy/libraries.py +++ b/plexpy/libraries.py @@ -193,16 +193,36 @@ def get_collections_list(section_id=None, **kwargs): 'recordsTotal': 0, 'draw': 0, 'data': 'null', - 'error': 'Unable to execute database query.'} + 'error': 'Unable to get collections: missing section_id.'} return default_return collections = get_collections(section_id) - data = {'recordsFiltered': len(collections), - 'recordsTotal': len(collections), - 'data': collections, - 'draw': kwargs.get('draw', 0) - } + # Get datatables JSON data + json_data = helpers.process_json_kwargs(json_kwargs=kwargs['json_data']) + + sort_keys = { + 'collectionMode': { + -1: 'Library Default', + 0: 'Hide collection', + 1: 'Hide items in this collection', + 2: 'Show this collection and its items' + }, + 'collectionSort': { + 0: 'Release date', + 1: 'Alphabetical' + } + } + + results = helpers.process_datatable_rows( + collections, json_data, default_sort='titleSort', sort_keys=sort_keys) + + data = { + 'recordsFiltered': results['filtered_count'], + 'recordsTotal': results['total_count'], + 'data': results['results'], + 'draw': int(json_data['draw']) + } return data @@ -251,16 +271,23 @@ def get_playlists_list(section_id=None, **kwargs): 'recordsTotal': 0, 'draw': 0, 'data': 'null', - 'error': 'Unable to execute database query.'} + 'error': 'Unable to get playlists: missing section_id.'} return default_return playlists = get_playlists(section_id) - data = {'recordsFiltered': len(playlists), - 'recordsTotal': len(playlists), - 'data': playlists, - 'draw': kwargs.get('draw', 0) - } + # Get datatables JSON data + json_data = helpers.process_json_kwargs(json_kwargs=kwargs['json_data']) + + results = helpers.process_datatable_rows( + playlists, json_data, default_sort='title') + + data = { + 'recordsFiltered': results['filtered_count'], + 'recordsTotal': results['total_count'], + 'data': results['results'], + 'draw': int(json_data['draw']) + } return data diff --git a/plexpy/webserve.py b/plexpy/webserve.py index d692f26a..6dce962f 100644 --- a/plexpy/webserve.py +++ b/plexpy/webserve.py @@ -846,9 +846,9 @@ class WebInterface(object): @cherrypy.expose @cherrypy.tools.json_out() @requireAuth(member_of("admin")) - @addtoapi() + @addtoapi("get_collections_table") def get_collections_list(self, section_id=None, **kwargs): - """ Get the data on the Tautulli media info tables. + """ Get the data on the Tautulli collections tables. ``` Required parameters: @@ -869,36 +869,23 @@ class WebInterface(object): # Check if datatables json_data was received. # If not, then build the minimal amount of json data for a query if not kwargs.get('json_data'): - # Alias 'title' to 'sort_title' - if kwargs.get('order_column') == 'title': - kwargs['order_column'] = 'sort_title' - # TODO: Find some one way to automatically get the columns - dt_columns = [("added_at", True, False), - ("sort_title", True, True), - ("container", True, True), - ("bitrate", True, True), - ("video_codec", True, True), - ("video_resolution", True, True), - ("video_framerate", True, True), - ("audio_codec", True, True), - ("audio_channels", True, True), - ("file_size", True, False), - ("last_played", True, False), - ("play_count", True, False)] - kwargs['json_data'] = build_datatables_json(kwargs, dt_columns, "sort_title") + dt_columns = [("titleSort", True, True), + ("collectionMode", True, True), + ("collectionSort", True, True), + ("childCount", True, False)] + kwargs['json_data'] = build_datatables_json(kwargs, dt_columns, "titleSort") - result = libraries.get_collections_list(section_id=section_id, - kwargs=kwargs) + result = libraries.get_collections_list(section_id=section_id, **kwargs) return result @cherrypy.expose @cherrypy.tools.json_out() @requireAuth(member_of("admin")) - @addtoapi() + @addtoapi("get_playlists_table") def get_playlists_list(self, section_id=None, **kwargs): - """ Get the data on the Tautulli media info tables. + """ Get the data on the Tautulli playlists tables. ``` Required parameters: @@ -919,27 +906,13 @@ class WebInterface(object): # Check if datatables json_data was received. # If not, then build the minimal amount of json data for a query if not kwargs.get('json_data'): - # Alias 'title' to 'sort_title' - if kwargs.get('order_column') == 'title': - kwargs['order_column'] = 'sort_title' - # TODO: Find some one way to automatically get the columns - dt_columns = [("added_at", True, False), - ("sort_title", True, True), - ("container", True, True), - ("bitrate", True, True), - ("video_codec", True, True), - ("video_resolution", True, True), - ("video_framerate", True, True), - ("audio_codec", True, True), - ("audio_channels", True, True), - ("file_size", True, False), - ("last_played", True, False), - ("play_count", True, False)] - kwargs['json_data'] = build_datatables_json(kwargs, dt_columns, "sort_title") + dt_columns = [("title", True, True), + ("leafCount", True, True), + ("duration", True, True)] + kwargs['json_data'] = build_datatables_json(kwargs, dt_columns, "title") - result = libraries.get_playlists_list(section_id=section_id, - kwargs=kwargs) + result = libraries.get_playlists_list(section_id=section_id, **kwargs) return result From f5c99f712a80accb0adf95a37ab5e327555aaf3e Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Wed, 30 Sep 2020 22:10:06 -0700 Subject: [PATCH 112/134] Add get_collections_table and get_playlists_table to API docs --- API.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/API.md b/API.md index c1ecb6ff..8159b2e4 100644 --- a/API.md +++ b/API.md @@ -708,6 +708,26 @@ Returns: ``` +### get_collections_table +Get the data on the Tautulli collections tables. + +``` +Required parameters: + section_id (str): The id of the Plex library section, OR + +Optional parameters: + None + +Returns: + json: + {"draw": 1, + "recordsTotal": 5, + "data": + [...] + } +``` + + ### get_date_formats Get the date and time formats used by Tautulli. @@ -1672,6 +1692,26 @@ Returns: ``` +### get_playlists_table +Get the data on the Tautulli playlists tables. + +``` +Required parameters: + section_id (str): The id of the Plex library section, OR + +Optional parameters: + None + +Returns: + json: + {"draw": 1, + "recordsTotal": 5, + "data": + [...] + } +``` + + ### get_plays_by_date Get graph data by date. From 2c360b6472577079e3031af1f95b00c5b1a3b8cc Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Wed, 30 Sep 2020 22:41:28 -0700 Subject: [PATCH 113/134] Fix searching in collections and playlist table --- plexpy/helpers.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/plexpy/helpers.py b/plexpy/helpers.py index 96e6c94f..b632c41a 100644 --- a/plexpy/helpers.py +++ b/plexpy/helpers.py @@ -595,7 +595,12 @@ def process_datatable_rows(rows, json_data, default_sort, sort_keys=None): searchable_columns = [d['data'] for d in json_data['columns'] if d['searchable']] for row in rows: for k, v in row.items(): - if k in searchable_columns and search_value in v.lower(): + if k in sort_keys: + value = sort_keys[k].get(v, v) + else: + value = v + value = str(value).lower() + if k in searchable_columns and search_value in value: results.append(row) break else: From 15e928ecf2e303290c25a9a86f01923d84c7becb Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Thu, 1 Oct 2020 09:57:01 -0700 Subject: [PATCH 114/134] Add items suffix to collections and playlist table --- data/interfaces/default/info.html | 24 ++++++++++++------- data/interfaces/default/js/script.js | 13 ++++++++++ .../default/js/tables/collections_table.js | 6 ++++- .../default/js/tables/playlists_table.js | 6 ++++- plexpy/common.py | 3 +++ plexpy/pmsconnect.py | 2 +- 6 files changed, 43 insertions(+), 11 deletions(-) diff --git a/data/interfaces/default/info.html b/data/interfaces/default/info.html index cd20164b..30e78013 100644 --- a/data/interfaces/default/info.html +++ b/data/interfaces/default/info.html @@ -303,6 +303,19 @@ DOCUMENTATION :: END % endif % endif +
        + % if data['media_type'] in ('collection', 'playlist') and data['children_count']: + <% + if data['media_type'] == 'collection': + suffix = MEDIA_TYPE_HEADERS[data['sub_media_type']] + elif data['media_type'] == 'playlist': + suffix = MEDIA_TYPE_HEADERS[data['playlist_type']] + if data['children_count'] == 1: + suffix = suffix[:-1] + %> + Items ${data['children_count']} ${suffix} + % endif +
        % if data['directors']: Directed by ${data['directors'][0]} @@ -328,11 +341,6 @@ DOCUMENTATION :: END Year ${data['year']} % endif
        -
        - % if data['duration']: - Items ${data['children_count']} ${'track' if data['playlist_type'] == 'audio' else 'video'}${'s' if data['children_count'] > 1 else ''} - % endif -
        % if data['duration']: Runtime ${data['duration']} @@ -459,7 +467,7 @@ DOCUMENTATION :: END
        -
          Loading movies list...
        +
          Loading collection items...
        + @@ -978,7 +979,7 @@ DOCUMENTATION :: END } }; export_table = $('#export_table-RK-${data["rating_key"]}').DataTable(export_table_options); - export_table.column(2).visible(false); + export_table.columns([2, 7]).visible(false); var colvis = new $.fn.dataTable.ColVis(export_table, { buttonText: ' Select columns', buttonClass: 'btn btn-dark' }); $(colvis.button()).appendTo('#button-bar-export'); diff --git a/data/interfaces/default/js/tables/export_table.js b/data/interfaces/default/js/tables/export_table.js index 9ad0cae8..5050abd7 100644 --- a/data/interfaces/default/js/tables/export_table.js +++ b/data/interfaces/default/js/tables/export_table.js @@ -118,6 +118,17 @@ export_table_options = { }, { "targets": [7], + "data": "custom_fields", + "createdCell": function (td, cellData, rowData, row, col) { + if (cellData !== '') { + $(td).html(cellData.replace(/,/g, ', ')); + } + }, + "width": "6%", + "className": "datatable-wrap" + }, + { + "targets": [8], "data": "file_size", "createdCell": function (td, cellData, rowData, row, col) { if (cellData !== '' && cellData !== null) { @@ -128,7 +139,7 @@ export_table_options = { "className": "no-wrap" }, { - "targets": [8], + "targets": [9], "data": "complete", "createdCell": function (td, cellData, rowData, row, col) { if (cellData === 1 && rowData['exists']) { @@ -145,7 +156,7 @@ export_table_options = { "className": "export_download" }, { - "targets": [9], + "targets": [10], "data": null, "createdCell": function (td, cellData, rowData, row, col) { if (rowData['complete'] !== 0) { diff --git a/data/interfaces/default/library.html b/data/interfaces/default/library.html index 85f94e75..6e7582fc 100644 --- a/data/interfaces/default/library.html +++ b/data/interfaces/default/library.html @@ -443,6 +443,7 @@ DOCUMENTATION :: END + @@ -688,6 +689,7 @@ DOCUMENTATION :: END } }; export_table = $('#export_table-SID-${data["section_id"]}').DataTable(export_table_options); + export_table.columns([7]).visible(false); var colvis = new $.fn.dataTable.ColVis(export_table, { buttonText: ' Select columns', buttonClass: 'btn btn-dark' }); $(colvis.button()).appendTo('#button-bar-export'); From 28c745c19c6ece4a7ebaa1e73580b92f8bb637ab Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Fri, 2 Oct 2020 00:22:19 -0700 Subject: [PATCH 124/134] Patch DataTables ColVis to fix dropdown extending past bottom of page --- data/interfaces/default/css/dataTables.colVis.css | 2 +- data/interfaces/default/js/dataTables.colVis.js | 13 +++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/data/interfaces/default/css/dataTables.colVis.css b/data/interfaces/default/css/dataTables.colVis.css index 94bcdd39..dcbfa76f 100644 --- a/data/interfaces/default/css/dataTables.colVis.css +++ b/data/interfaces/default/css/dataTables.colVis.css @@ -71,7 +71,7 @@ ul.ColVis_collection { list-style: none; width: 150px; padding: 8px 8px 4px 8px; - margin: 10px 0px 0px 0px; + margin: 10px 0px 10px 0px; background-color: #444; overflow: hidden; z-index: 2002; diff --git a/data/interfaces/default/js/dataTables.colVis.js b/data/interfaces/default/js/dataTables.colVis.js index cc13cd8c..1a2df816 100644 --- a/data/interfaces/default/js/dataTables.colVis.js +++ b/data/interfaces/default/js/dataTables.colVis.js @@ -790,6 +790,9 @@ ColVis.prototype = { oStyle.top = oPos.top+"px"; oStyle.left = iDivX+"px"; + var iDocWidth = $(document).width(); + var iDocHeight = $(document).height(); + document.body.appendChild( nBackground ); document.body.appendChild( nHidden ); document.body.appendChild( this.dom.catcher ); @@ -819,12 +822,17 @@ ColVis.prototype = { var iDivWidth = $(nHidden).outerWidth(); var iDivHeight = $(nHidden).outerHeight(); - var iDocWidth = $(document).width(); + var iDivMarginTop = parseInt($(nHidden).css("marginTop"), 10); + var iDivMarginBottom = parseInt($(nHidden).css("marginBottom"), 10); if ( iLeft + iDivWidth > iDocWidth ) { nHidden.style.left = (iDocWidth-iDivWidth)+"px"; } + if ( iDivY + iDivHeight > iDocHeight ) + { + nHidden.style.top = (oPos.top - iDivHeight - iDivMarginTop - iDivMarginBottom)+"px"; + } } this.s.hidden = false; @@ -846,7 +854,8 @@ ColVis.prototype = { this.s.hidden = true; $(this.dom.collection).animate({"opacity": 0}, that.s.iOverlayFade, function (e) { - this.style.display = "none"; + // this.style.display = "none"; + document.body.removeChild( this ); } ); $(this.dom.background).animate({"opacity": 0}, that.s.iOverlayFade, function (e) { From 60cadb1e115070b00cf78aad18ec8c4a2e9f6a68 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Fri, 2 Oct 2020 09:56:30 -0700 Subject: [PATCH 125/134] Go to export tab after exporting --- data/interfaces/default/export_modal.html | 1 + 1 file changed, 1 insertion(+) diff --git a/data/interfaces/default/export_modal.html b/data/interfaces/default/export_modal.html index 37da7804..7695cc3b 100644 --- a/data/interfaces/default/export_modal.html +++ b/data/interfaces/default/export_modal.html @@ -213,6 +213,7 @@ DOCUMENTATION :: END async: true, success: function (data) { if (data.result === 'success') { + $("a[href=#tabs-export]").click(); redrawExportTable(); showMsg(' ' + data.message, false, true, 5000); } else { From fc39f1521d3d1914de11a9b4ca213a47def8e44a Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Fri, 2 Oct 2020 10:13:41 -0700 Subject: [PATCH 126/134] Change library and user tabs to nav pills --- data/interfaces/default/css/tautulli.css | 20 ++++---------------- data/interfaces/default/graphs.html | 2 +- data/interfaces/default/library.html | 22 +++++++++++----------- data/interfaces/default/user.html | 6 +++--- 4 files changed, 19 insertions(+), 31 deletions(-) diff --git a/data/interfaces/default/css/tautulli.css b/data/interfaces/default/css/tautulli.css index bec5ce2d..e5cc6e4e 100644 --- a/data/interfaces/default/css/tautulli.css +++ b/data/interfaces/default/css/tautulli.css @@ -2119,6 +2119,10 @@ a:hover .item-children-poster { width: 60px; margin-right: 20px; } +.nav-list { + list-style: none; + padding: 0; +} #new_title h3 { color: #f9be03; font-size: 14px; @@ -2198,26 +2202,10 @@ li.advanced-setting { .user-info-nav { margin-top: 15px; } -.user-info-nav > .active > a { - color: #cc7b19; -} .nav-tabs > .active > a:hover, .nav-tabs > .active > a:focus { color: #e9a049; } -.user-info-nav a:hover { - color: #e9a049; - text-decoration: none; -} -.user-info-nav ul { - list-style: none; - padding: 0; -} -.user-info-nav li { - float: left; - margin-left: 10px; - margin-right: 10px; -} .user-overview-stats-wrapper { } .user-overview-stats-wrapper ul { diff --git a/data/interfaces/default/graphs.html b/data/interfaces/default/graphs.html index c74fa590..43e83ef3 100644 --- a/data/interfaces/default/graphs.html +++ b/data/interfaces/default/graphs.html @@ -40,7 +40,7 @@ -
        +
        + % if _session['user_group'] == 'admin':
        % if config['get_file_sizes'] and data['section_id'] in config['get_file_sizes_hold']['section_ids']:
        - % else: - + % endif
        @@ -456,6 +455,7 @@ DOCUMENTATION :: END
        + % endif
        @@ -897,11 +897,11 @@ DOCUMENTATION :: END var hash = document.location.hash; var prefix = "tab_"; if (hash) { - $('.user-info-nav a[href='+hash.replace(prefix,"")+']').tab('show').trigger('show.bs.tab'); + $('.nav-list a[href=' + hash.replace(prefix, "") + ']').tab('show').trigger('show.bs.tab'); } // Change hash for page-reload - $('.user-info-nav a').on('shown.bs.tab', function (e) { + $('.nav-list a').on('shown.bs.tab', function (e) { window.location.hash = e.target.hash.replace("#", "#" + prefix); }); diff --git a/data/interfaces/default/user.html b/data/interfaces/default/user.html index 6ea5a555..ee7fb017 100644 --- a/data/interfaces/default/user.html +++ b/data/interfaces/default/user.html @@ -67,7 +67,7 @@ DOCUMENTATION :: END % endif
        - % if data['media_type'] == 'movie' or data['live']: -
        TitleItemsDurationSmartPlaylist TitlePlaylist ItemsPlaylist Duration
        File Format Metadata Level Media Info LevelCustom Fields File Size Download DeleteFile Format Metadata Level Media Info LevelCustom Fields File Size Download Delete
        - - - - - - - - - - - - - - - - - - -
        DeleteDateUserIP AddressPlatformProductPlayerTitleStartedPausedStoppedDuration
        +
    % endif - % if not data['live'] and _session['user_group'] == 'admin': -
    -
    -
    - Metadata Exports for ${data['title']} -
    -
    -
    - +
    + % if history_type: +
    +
    +
    +
    +
    +
    + % if data['media_type'] in ('artist', 'album', 'track'): + Play History for ${data['title']} + % else: + Watch History for ${data['title']} + % endif +
    +
    + % if _session['user_group'] == 'admin': + +
    +   +
    + % if source == 'history': + + % endif + % if data.get('tvmaze_id') or data.get('themoviedb_id') or data.get('musicbrainz_id'): +
    + +
    + % endif + % if data.get('poster_url'): +
    + % if data['media_type'] == 'artist' or data['media_type'] == 'album' or data['media_type'] == 'track': + + % else: + + % endif + + +
    + % endif + % if not data['live']: +
    + +
    + % endif + % endif +
    + +
    +
    +
    +
    +
    + + + + + + + + + + + + + + + + + + + +
    DeleteDateUserIP AddressPlatformProductPlayerTitleStartedPausedStoppedDuration
    +
    +
    -
    - -
    -
    -
    - - - - - - - - - - - - - - - - - -
    Exported AtMedia TypeRating KeyFilenameFile FormatMetadata LevelMedia Info LevelCustom FieldsFile SizeDownloadDelete
    + % endif + % if not data['live'] and _session['user_group'] == 'admin': +
    +
    +
    +
    +
    +
    + Metadata Exports for ${data['title']} +
    +
    +
    + +
    +
    + +
    +
    +
    +
    +
    + + + + + + + + + + + + + + + + + +
    Exported AtMedia TypeRating KeyFilenameFile FormatMetadata LevelMedia Info LevelCustom FieldsFile SizeDownloadDelete
    +
    +
    +
    +
    + % endif
    - % endif
    @@ -818,57 +850,61 @@ DOCUMENTATION :: END } % endif -% if data['media_type'] not in ('photo_album', 'photo', 'clip', 'collection', 'playlist'): +% if data['media_type'] in ('movie', 'show', 'season', 'episode', 'artist', 'album', 'track'): % endif % if data.get('poster_url'): From 5e8b9465715464681995db4c53657f455c2b4759 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Fri, 2 Oct 2020 10:27:45 -0700 Subject: [PATCH 128/134] Improve nav pills css --- data/interfaces/default/css/tautulli.css | 6 +++++- data/interfaces/default/info.html | 2 +- data/interfaces/default/library.html | 2 +- data/interfaces/default/user.html | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/data/interfaces/default/css/tautulli.css b/data/interfaces/default/css/tautulli.css index e5cc6e4e..3dd6cef2 100644 --- a/data/interfaces/default/css/tautulli.css +++ b/data/interfaces/default/css/tautulli.css @@ -2123,6 +2123,9 @@ a:hover .item-children-poster { list-style: none; padding: 0; } +.nav-list.nav-pills > li > a { + margin-bottom: 0; +} #new_title h3 { color: #f9be03; font-size: 14px; @@ -2196,11 +2199,12 @@ li.advanced-setting { .user-info-username { font-size: 24px; color: #eee; - padding-top: 27px; + padding-top: 15px; padding-left: 105px; } .user-info-nav { margin-top: 15px; + padding-left: 105px; } .nav-tabs > .active > a:hover, .nav-tabs > .active > a:focus { diff --git a/data/interfaces/default/info.html b/data/interfaces/default/info.html index 167f4d4e..ef8fb57b 100644 --- a/data/interfaces/default/info.html +++ b/data/interfaces/default/info.html @@ -525,7 +525,7 @@ DOCUMENTATION :: END % if history_type:
    -
    diff --git a/data/interfaces/default/js/tables/playlists_table.js b/data/interfaces/default/js/tables/playlists_table.js index 1942060d..25474040 100644 --- a/data/interfaces/default/js/tables/playlists_table.js +++ b/data/interfaces/default/js/tables/playlists_table.js @@ -28,7 +28,13 @@ playlists_table_options = { if (rowData['smart']) { smart = ' ' } - $(td).html('' + smart + cellData + ''); + var breadcrumb = ''; + if (rowData['userID']) { + breadcrumb = '&user_id=' + rowData['userID']; + } else if (rowData['librarySectionID']) { + breadcrumb = '§ion_id=' + rowData['librarySectionID']; + } + $(td).html('' + smart + cellData + ''); } }, "width": "60%", diff --git a/data/interfaces/default/library.html b/data/interfaces/default/library.html index 61da8457..55233e2a 100644 --- a/data/interfaces/default/library.html +++ b/data/interfaces/default/library.html @@ -331,12 +331,12 @@ DOCUMENTATION :: END Export collections
    + % endif
    - % endif
    @@ -379,12 +379,12 @@ DOCUMENTATION :: END Export playlists + % endif
    - % endif
    @@ -546,9 +546,9 @@ DOCUMENTATION :: END - + + @@ -420,6 +471,8 @@ DOCUMENTATION :: END $.fn.dataTable.tables({ visible: true, api: true }).columns.adjust(); }); + $(".inactive-user-tooltip").tooltip(); + function loadHistoryTable(media_type) { // Build watch history table history_table_options.ajax = { @@ -451,6 +504,49 @@ DOCUMENTATION :: END }); } + $('a[href="#tabs-history"]').on('shown.bs.tab', function() { + if (typeof(history_table) === 'undefined') { + var media_type = getLocalStorage('user_' + user_id + '-history_media_type', 'all'); + $('#history-' + media_type).prop('checked', true); + $('#history-' + media_type).closest('label').addClass('active'); + loadHistoryTable(media_type); + } + }); + + $("#refresh-history-list").click(function () { + history_table.draw(); + }); + + function loadPlaylistsTable() { + // Build playlists table + playlists_table_options.ajax = { + url: 'get_playlists_list', + type: 'POST', + data: function ( d ) { + return { + json_data: JSON.stringify( d ), + user_id: user_id + }; + } + }; + playlists_table = $('#playlists_table-SID-${data["user_id"]}').DataTable(playlists_table_options); + + var colvis = new $.fn.dataTable.ColVis(playlists_table, { buttonText: ' Select columns', buttonClass: 'btn btn-dark' }); + $(colvis.button()).appendTo('#button-bar-playlists'); + + clearSearchButton('playlists_table-SID-${data["user_id"]}', playlists_table); + } + + $('a[href="#tabs-playlists"]').on('shown.bs.tab', function() { + if (typeof(playlists_table) === 'undefined') { + loadPlaylistsTable(); + } + }); + + $("#refresh-playlists-table").click(function () { + playlists_table.draw(); + }); + function loadSyncTable() { // Build user sync table sync_table_options.ajax = { @@ -466,6 +562,16 @@ DOCUMENTATION :: END clearSearchButton('sync_table-UID-${data["user_id"]}', sync_table); } + $('a[href="#tabs-synceditems"]').on('shown.bs.tab', function() { + if (typeof(sync_table) === 'undefined') { + loadSyncTable(user_id); + } + }); + + $("#refresh-syncs-list").click(function() { + sync_table.ajax.reload(); + }); + function loadIPAddressTable() { // Build user IP table user_ip_table_options.ajax = { @@ -483,6 +589,16 @@ DOCUMENTATION :: END clearSearchButton('user_ip_table-UID-${data["user_id"]}', user_ip_table); } + $('a[href="#tabs-ipaddresses"]').on('shown.bs.tab', function() { + if (typeof(user_ip_table) === 'undefined') { + loadIPAddressTable(user_id); + } + }); + + $("#refresh-ip-address-list").click(function () { + user_ip_table.draw(); + }); + function loadLoginTable() { // Build user login table login_log_table_options.ajax = { @@ -504,51 +620,16 @@ DOCUMENTATION :: END clearSearchButton('login_log_table-UID-${data["user_id"]}', login_log_table); } - $('a[href="#tabs-history"]').on('shown.bs.tab', function() { - if (typeof(history_table) === 'undefined') { - var media_type = getLocalStorage('user_' + user_id + '-history_media_type', 'all'); - $('#history-' + media_type).prop('checked', true); - $('#history-' + media_type).closest('label').addClass('active'); - loadHistoryTable(media_type); - } - }); - - $('a[href="#tabs-synceditems"]').on('shown.bs.tab', function() { - if (typeof(sync_table) === 'undefined') { - loadSyncTable(user_id); - } - }); - - $('a[href="#tabs-ipaddresses"]').on('shown.bs.tab', function() { - if (typeof(user_ip_table) === 'undefined') { - loadIPAddressTable(user_id); - } - }); - $('a[href="#tabs-tautullilogins"]').on('shown.bs.tab', function() { if (typeof(login_log_table) === 'undefined') { loadLoginTable(user_id); } }); - $("#refresh-history-list").click(function () { - history_table.draw(); - }); - - $("#refresh-syncs-list").click(function() { - sync_table.ajax.reload(); - }); - - $("#refresh-ip-address-list").click(function () { - user_ip_table.draw(); - }); - $("#refresh-login-list").click(function () { login_log_table.draw(); }); - $(".inactive-user-tooltip").tooltip(); - function recentlyWatched() { // Populate recently watched $.ajax({ diff --git a/plexpy/libraries.py b/plexpy/libraries.py index 27dff12b..d30f71d6 100644 --- a/plexpy/libraries.py +++ b/plexpy/libraries.py @@ -142,7 +142,7 @@ def has_library_type(section_type): return bool(result) -def get_collections(section_id): +def get_collections(section_id=None): plex = Plex(plexpy.CONFIG.PMS_URL, session.get_session_user_token()) library = plex.get_library(section_id) @@ -196,7 +196,7 @@ def get_collections_list(section_id=None, **kwargs): 'error': 'Unable to get collections: missing section_id.'} return default_return - collections = get_collections(section_id) + collections = get_collections(section_id=section_id) # Get datatables JSON data json_data = helpers.process_json_kwargs(json_kwargs=kwargs['json_data']) @@ -230,20 +230,21 @@ def get_collections_list(section_id=None, **kwargs): return data -def get_playlists(section_id): - plex = Plex(plexpy.CONFIG.PMS_URL, session.get_session_user_token()) - - library = Libraries().get_details(section_id=section_id) - - if library['section_type'] == 'artist': - playlist_type = 'audio' - elif library['section_type'] == 'photo': - playlist_type = 'photo' +def get_playlists(section_id=None, user_id=None): + if user_id and not session.get_session_user_id(): + import users + user_tokens = users.Users().get_tokens(user_id=user_id) + plex_token = user_tokens['server_token'] else: - playlist_type = 'video' + plex_token = session.get_session_user_token() - playlists = plex.plex.fetchItems( - '/playlists?type=15&playlistType={}§ionID={}'.format(playlist_type, section_id)) + plex = Plex(plexpy.CONFIG.PMS_URL, plex_token) + + if user_id: + playlists = plex.plex.playlists() + else: + library = plex.get_library(section_id) + playlists = library.playlist() playlists_list = [] for playlist in playlists: @@ -254,22 +255,22 @@ def get_playlists(section_id): 'guid': playlist.guid, 'leafCount': playlist.leafCount, 'librarySectionID': section_id, - 'librarySectionTitle': library['section_name'], 'playlistType': playlist.playlistType, 'ratingKey': playlist.ratingKey, 'smart': playlist.smart, 'summary': playlist.summary, 'title': playlist.title, 'type': playlist.type, - 'updatedAt': helpers.datetime_to_iso(playlist.updatedAt) + 'updatedAt': helpers.datetime_to_iso(playlist.updatedAt), + 'userID': user_id } playlists_list.append(playlist_dict) return playlists_list -def get_playlists_list(section_id=None, **kwargs): - if not section_id: +def get_playlists_list(section_id=None, user_id=None, **kwargs): + if not section_id and not user_id: default_return = {'recordsFiltered': 0, 'recordsTotal': 0, 'draw': 0, @@ -277,7 +278,7 @@ def get_playlists_list(section_id=None, **kwargs): 'error': 'Unable to get playlists: missing section_id.'} return default_return - playlists = get_playlists(section_id) + playlists = get_playlists(section_id=section_id, user_id=user_id) # Get datatables JSON data json_data = helpers.process_json_kwargs(json_kwargs=kwargs['json_data']) diff --git a/plexpy/webserve.py b/plexpy/webserve.py index 84e203c0..36a9bd94 100644 --- a/plexpy/webserve.py +++ b/plexpy/webserve.py @@ -852,7 +852,7 @@ class WebInterface(object): ``` Required parameters: - section_id (str): The id of the Plex library section, OR + section_id (str): The id of the Plex library section Optional parameters: None @@ -884,12 +884,13 @@ class WebInterface(object): @cherrypy.tools.json_out() @requireAuth() @addtoapi("get_playlists_table") - def get_playlists_list(self, section_id=None, **kwargs): + def get_playlists_list(self, section_id=None, user_id=None, **kwargs): """ Get the data on the Tautulli playlists tables. ``` Required parameters: - section_id (str): The id of the Plex library section, OR + section_id (str): The section id of the Plex library, OR + user_id (str): The user id of the Plex user Optional parameters: None @@ -912,7 +913,9 @@ class WebInterface(object): ("duration", True, True)] kwargs['json_data'] = build_datatables_json(kwargs, dt_columns, "title") - result = libraries.get_playlists_list(section_id=section_id, **kwargs) + result = libraries.get_playlists_list(section_id=section_id, + user_id=user_id, + **kwargs) return result @@ -4372,7 +4375,7 @@ class WebInterface(object): @cherrypy.expose @requireAuth() - def info(self, rating_key=None, guid=None, source=None, section_id=None, **kwargs): + def info(self, rating_key=None, guid=None, source=None, section_id=None, user_id=None, **kwargs): if rating_key and not str(rating_key).isdigit(): raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT) @@ -4383,6 +4386,12 @@ class WebInterface(object): "pms_web_url": plexpy.CONFIG.PMS_WEB_URL } + if user_id: + user_data = users.Users() + user_info = user_data.get_details(user_id=user_id) + else: + user_info = {} + # Try to get metadata from the Plex server first if rating_key: pms_connect = pmsconnect.PmsConnect() @@ -4405,7 +4414,7 @@ class WebInterface(object): raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT) return serve_template(templatename="info.html", metadata=metadata, title="Info", - config=config, source=source) + config=config, source=source, user_info=user_info) else: if get_session_user_id(): raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT) From ea9904bd564d84c1e100861dfb2926da0220acc4 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Fri, 2 Oct 2020 12:54:07 -0700 Subject: [PATCH 132/134] Add playlist export for users --- data/interfaces/default/export_modal.html | 9 +- data/interfaces/default/info.html | 5 +- data/interfaces/default/library.html | 10 +- data/interfaces/default/user.html | 106 +++++++++++++++++++++- plexpy/__init__.py | 2 +- plexpy/exporter.py | 102 ++++++++++++++------- plexpy/webserve.py | 22 +++-- 7 files changed, 201 insertions(+), 55 deletions(-) diff --git a/data/interfaces/default/export_modal.html b/data/interfaces/default/export_modal.html index 7695cc3b..37d7fc36 100644 --- a/data/interfaces/default/export_modal.html +++ b/data/interfaces/default/export_modal.html @@ -25,10 +25,11 @@ DOCUMENTATION :: END @@ -888,7 +886,7 @@ DOCUMENTATION :: END section_id: $(this).data('section_id'), media_type: $(this).data('media_type'), sub_media_type: $(this).data('sub_media_type'), - library_export: $(this).data('library_export') + export_type: $(this).data('export_type') }, cache: false, async: true, diff --git a/data/interfaces/default/user.html b/data/interfaces/default/user.html index 3e490c39..7765295d 100644 --- a/data/interfaces/default/user.html +++ b/data/interfaces/default/user.html @@ -73,6 +73,9 @@ DOCUMENTATION :: END
  • Profile
  • History
  • Playlists
  • + % if _session['user_group'] == 'admin': +
  • Export
  • + % endif
  • Synced Items
  • IP Addresses
  • Tautulli Logins
  • @@ -227,11 +230,10 @@ DOCUMENTATION :: END
    % if _session['user_group'] == 'admin': - <% playlist_sub_media_type = {'movie': 'video', 'show': 'video', 'artist': 'audio', 'photo': 'photo'} %>
    @@ -260,6 +262,53 @@ DOCUMENTATION :: END
    + % if _session['user_group'] == 'admin': +
    +
    +
    +
    +
    +
    + + Metadata Exports for + ${data['friendly_name']} + + +
    +
    +
    + +
    +
    +
    +
    +
    + + + + + + + + + + + + + + + + + +
    Exported AtMedia TypeRating KeyFilenameFile FormatMetadata LevelMedia Info LevelCustom FieldsFile SizeDownloadDelete
    +
    +
    +
    +
    +
    + % endif
    @@ -443,6 +492,8 @@ DOCUMENTATION :: END
    + <%def name="javascriptIncludes()"> @@ -463,6 +514,7 @@ DOCUMENTATION :: END + @@ -725,6 +777,37 @@ DOCUMENTATION :: END % if _session['user_group'] == 'admin':