mirror of
https://github.com/Tautulli/Tautulli.git
synced 2025-07-08 06:00:51 -07:00
2403 lines
95 KiB
Python
2403 lines
95 KiB
Python
# -*- 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 <http://www.gnu.org/licenses/>.
|
|
|
|
from __future__ import unicode_literals
|
|
from future.builtins import str
|
|
from backports import csv
|
|
|
|
import json
|
|
import os
|
|
import requests
|
|
import shutil
|
|
import threading
|
|
|
|
from functools import partial, reduce
|
|
from io import open
|
|
from multiprocessing.dummy import Pool as ThreadPool
|
|
|
|
import plexpy
|
|
if plexpy.PYTHON2:
|
|
import database
|
|
import datatables
|
|
import helpers
|
|
import logger
|
|
import users
|
|
from plex import Plex
|
|
else:
|
|
from plexpy import database
|
|
from plexpy import datatables
|
|
from plexpy import helpers
|
|
from plexpy import logger
|
|
from plexpy import users
|
|
from plexpy.plex import Plex
|
|
|
|
|
|
class Export(object):
|
|
# True/False for allowed (thumb, art) image export
|
|
MEDIA_TYPES = {
|
|
'movie': (True, True),
|
|
'show': (True, True),
|
|
'season': (True, True),
|
|
'episode': (False, False),
|
|
'artist': (True, True),
|
|
'album': (True, True),
|
|
'track': (False, False),
|
|
'photoalbum': (False, False),
|
|
'photo': (False, False),
|
|
'clip': (False, False),
|
|
'collection': (True, True),
|
|
'playlist': (True, True)
|
|
}
|
|
PLURAL_MEDIA_TYPES = {
|
|
'movie': 'movies',
|
|
'show': 'shows',
|
|
'season': 'seasons',
|
|
'episode': 'episodes',
|
|
'artist': 'artists',
|
|
'album': 'albums',
|
|
'track': 'tracks',
|
|
'photoalbum': 'photoalbums',
|
|
'photo': 'photos',
|
|
'clip': 'clips',
|
|
'collection': 'collections',
|
|
'children': 'children',
|
|
'playlist': 'playlists',
|
|
'item': 'items'
|
|
}
|
|
CHILD_MEDIA_TYPES = {
|
|
'movie': [],
|
|
'show': ['season'],
|
|
'season': ['episode'],
|
|
'episode': [],
|
|
'artist': ['album'],
|
|
'album': ['track'],
|
|
'track': [],
|
|
'photoalbum': ['photoalbum', 'photo', 'clip'],
|
|
'photo': [],
|
|
'clip': [],
|
|
'collection': ['children'],
|
|
'playlist': ['item']
|
|
}
|
|
METADATA_LEVELS = (0, 1, 2, 3, 9)
|
|
MEDIA_INFO_LEVELS = (0, 1, 2, 3, 9)
|
|
IMAGE_LEVELS = (0, 1, 2, 9)
|
|
FILE_FORMATS = ('csv', 'json', 'xml', 'm3u8')
|
|
EXPORT_TYPES = ('all', 'collection', 'playlist')
|
|
|
|
def __init__(self, section_id=None, user_id=None, rating_key=None, file_format='csv',
|
|
metadata_level=1, media_info_level=1,
|
|
thumb_level=0, art_level=0,
|
|
custom_fields='', export_type='all', individual_files=False):
|
|
self.section_id = helpers.cast_to_int(section_id) or None
|
|
self.user_id = helpers.cast_to_int(user_id) or None
|
|
self.rating_key = helpers.cast_to_int(rating_key) or None
|
|
self.file_format = str(file_format).lower()
|
|
self.metadata_level = helpers.cast_to_int(metadata_level)
|
|
self.media_info_level = helpers.cast_to_int(media_info_level)
|
|
self.thumb_level = helpers.cast_to_int(thumb_level)
|
|
self.art_level = helpers.cast_to_int(art_level)
|
|
self.custom_fields = custom_fields.replace(' ', '')
|
|
self._custom_fields = {}
|
|
self.export_type = str(export_type).lower() or 'all'
|
|
self.individual_files = individual_files
|
|
|
|
self.timestamp = helpers.timestamp()
|
|
|
|
self.media_type = None
|
|
self.obj = None
|
|
self.obj_title = None
|
|
|
|
self.directory = None
|
|
self.filename = None
|
|
self.title = None
|
|
self.export_id = None
|
|
self.file_size = 0
|
|
self.exported_thumb = False
|
|
self.exported_art = False
|
|
|
|
self.total_items = 0
|
|
self.exported_items = 0
|
|
self.success = False
|
|
|
|
# Reset export options for m3u8
|
|
if self.file_format == 'm3u8':
|
|
self.metadata_level = 1
|
|
self.media_info_level = 1
|
|
self.thumb_level = 0
|
|
self.art_level = 0
|
|
self.custom_fields = ''
|
|
|
|
def return_attrs(self, media_type, flatten=False):
|
|
# o: current object
|
|
# e: element in object attribute value list
|
|
|
|
def movie_attrs():
|
|
_movie_attrs = {
|
|
'addedAt': helpers.datetime_to_iso,
|
|
'art': None,
|
|
'artFile': lambda o: self.get_image(o, 'art'),
|
|
'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,
|
|
'durationHuman': lambda o: helpers.human_duration(getattr(o, 'duration', 0)),
|
|
'fields': {
|
|
'name': None,
|
|
'locked': None
|
|
},
|
|
'genres': {
|
|
'id': None,
|
|
'tag': None
|
|
},
|
|
'guid': None,
|
|
'guids': {
|
|
'id': 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,
|
|
'hdr': lambda o: self.get_any_hdr(o, 'movie'),
|
|
'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,
|
|
'sizeHuman': lambda o: helpers.human_file_size(getattr(o, 'size', 0)),
|
|
'optimizedForStreaming': None,
|
|
'packetLength': None,
|
|
'requiredBandwidths': None,
|
|
'syncItemId': None,
|
|
'syncState': None,
|
|
'videoProfile': None,
|
|
'videoStreams': {
|
|
'codec': None,
|
|
'codecID': None,
|
|
'default': 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,
|
|
'DOVIBLCompatID': None,
|
|
'DOVIBLPresent': None,
|
|
'DOVIELPresent': None,
|
|
'DOVILevel': None,
|
|
'DOVIPresent': None,
|
|
'DOVIProfile': None,
|
|
'DOVIRPUPresent': None,
|
|
'DOVIVersion': None,
|
|
'duration': None,
|
|
'frameRate': None,
|
|
'frameRateMode': None,
|
|
'hasScalingMatrix': None,
|
|
'hdr': lambda o: helpers.is_hdr(getattr(o, 'bitDepth', 0), getattr(o, 'colorSpace', '')),
|
|
'height': None,
|
|
'level': None,
|
|
'pixelAspectRatio': None,
|
|
'pixelFormat': None,
|
|
'profile': None,
|
|
'refFrames': None,
|
|
'requiredBandwidths': None,
|
|
'scanType': None,
|
|
'streamIdentifier': None,
|
|
'width': None
|
|
},
|
|
'audioStreams': {
|
|
'codec': None,
|
|
'codecID': None,
|
|
'default': 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': None,
|
|
'samplingRate': None,
|
|
'streamIdentifier': None
|
|
},
|
|
'subtitleStreams': {
|
|
'codec': None,
|
|
'codecID': None,
|
|
'default': None,
|
|
'displayTitle': None,
|
|
'extendedDisplayTitle': None,
|
|
'id': None,
|
|
'index': None,
|
|
'language': None,
|
|
'languageCode': None,
|
|
'requiredBandwidths': None,
|
|
'selected': None,
|
|
'streamType': None,
|
|
'title': None,
|
|
'type': None,
|
|
'forced': None,
|
|
'format': None,
|
|
'headerCompression': None,
|
|
'key': None,
|
|
'transient': 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,
|
|
'thumbFile': lambda o: self.get_image(o, 'thumb'),
|
|
'title': None,
|
|
'titleSort': None,
|
|
'type': None,
|
|
'updatedAt': helpers.datetime_to_iso,
|
|
'userRating': None,
|
|
'viewCount': None,
|
|
'writers': {
|
|
'id': None,
|
|
'tag': None
|
|
},
|
|
'year': None
|
|
}
|
|
return _movie_attrs
|
|
|
|
def show_attrs():
|
|
_show_attrs = {
|
|
'addedAt': helpers.datetime_to_iso,
|
|
'art': None,
|
|
'artFile': lambda o: self.get_image(o, 'art'),
|
|
'banner': None,
|
|
'childCount': None,
|
|
'collections': {
|
|
'id': None,
|
|
'tag': None
|
|
},
|
|
'contentRating': None,
|
|
'duration': None,
|
|
'durationHuman': lambda o: helpers.human_duration(getattr(o, 'duration', 0)),
|
|
'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,
|
|
'thumbFile': lambda o: self.get_image(o, 'thumb'),
|
|
'title': None,
|
|
'titleSort': None,
|
|
'type': None,
|
|
'updatedAt': helpers.datetime_to_iso,
|
|
'userRating': None,
|
|
'viewCount': None,
|
|
'viewedLeafCount': None,
|
|
'year': None,
|
|
'seasons': lambda e: self.export_obj(e)
|
|
}
|
|
return _show_attrs
|
|
|
|
def season_attrs():
|
|
_season_attrs = {
|
|
'addedAt': helpers.datetime_to_iso,
|
|
'art': None,
|
|
'artFile': lambda o: self.get_image(o, 'art'),
|
|
'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,
|
|
'thumbFile': lambda o: self.get_image(o, 'thumb'),
|
|
'title': None,
|
|
'titleSort': None,
|
|
'type': None,
|
|
'updatedAt': helpers.datetime_to_iso,
|
|
'userRating': None,
|
|
'viewCount': None,
|
|
'viewedLeafCount': None,
|
|
'episodes': lambda e: self.export_obj(e)
|
|
}
|
|
return _season_attrs
|
|
|
|
def episode_attrs():
|
|
_episode_attrs = {
|
|
'addedAt': helpers.datetime_to_iso,
|
|
'art': None,
|
|
'artFile': lambda o: self.get_image(o, 'art'),
|
|
'chapterSource': None,
|
|
'contentRating': None,
|
|
'directors': {
|
|
'id': None,
|
|
'tag': None
|
|
},
|
|
'duration': None,
|
|
'durationHuman': lambda o: helpers.human_duration(getattr(o, 'duration', 0)),
|
|
'fields': {
|
|
'name': None,
|
|
'locked': None
|
|
},
|
|
'grandparentArt': None,
|
|
'grandparentGuid': None,
|
|
'grandparentKey': None,
|
|
'grandparentRatingKey': None,
|
|
'grandparentTheme': None,
|
|
'grandparentThumb': None,
|
|
'grandparentTitle': None,
|
|
'guid': None,
|
|
'hasIntroMarker': None,
|
|
'index': None,
|
|
'key': None,
|
|
'lastViewedAt': helpers.datetime_to_iso,
|
|
'librarySectionID': None,
|
|
'librarySectionKey': None,
|
|
'librarySectionTitle': None,
|
|
'locations': None,
|
|
'markers': {
|
|
'end': None,
|
|
'start': None,
|
|
'type': 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,
|
|
'hdr': lambda o: self.get_any_hdr(o, 'episode'),
|
|
'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,
|
|
'sizeHuman': lambda o: helpers.human_file_size(getattr(o, 'size', 0)),
|
|
'optimizedForStreaming': None,
|
|
'packetLength': None,
|
|
'requiredBandwidths': None,
|
|
'syncItemId': None,
|
|
'syncState': None,
|
|
'videoProfile': None,
|
|
'videoStreams': {
|
|
'codec': None,
|
|
'codecID': None,
|
|
'default': 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,
|
|
'DOVIBLCompatID': None,
|
|
'DOVIBLPresent': None,
|
|
'DOVIELPresent': None,
|
|
'DOVILevel': None,
|
|
'DOVIPresent': None,
|
|
'DOVIProfile': None,
|
|
'DOVIRPUPresent': None,
|
|
'DOVIVersion': None,
|
|
'duration': None,
|
|
'frameRate': None,
|
|
'frameRateMode': None,
|
|
'hasScalingMatrix': None,
|
|
'hdr': lambda o: helpers.is_hdr(getattr(o, 'bitDepth', 0), getattr(o, 'colorSpace', '')),
|
|
'height': None,
|
|
'level': None,
|
|
'pixelAspectRatio': None,
|
|
'pixelFormat': None,
|
|
'profile': None,
|
|
'refFrames': None,
|
|
'requiredBandwidths': None,
|
|
'scanType': None,
|
|
'streamIdentifier': None,
|
|
'width': None
|
|
},
|
|
'audioStreams': {
|
|
'codec': None,
|
|
'codecID': None,
|
|
'default': 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': None,
|
|
'samplingRate': None,
|
|
'streamIdentifier': None
|
|
},
|
|
'subtitleStreams': {
|
|
'codec': None,
|
|
'codecID': None,
|
|
'default': None,
|
|
'displayTitle': None,
|
|
'extendedDisplayTitle': None,
|
|
'id': None,
|
|
'index': None,
|
|
'language': None,
|
|
'languageCode': None,
|
|
'requiredBandwidths': None,
|
|
'selected': None,
|
|
'streamType': None,
|
|
'title': None,
|
|
'type': None,
|
|
'forced': None,
|
|
'format': None,
|
|
'headerCompression': None,
|
|
'key': None,
|
|
'transient': 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,
|
|
'thumbFile': lambda o: self.get_image(o, 'thumb'),
|
|
'title': None,
|
|
'titleSort': None,
|
|
'type': None,
|
|
'updatedAt': helpers.datetime_to_iso,
|
|
'userRating': None,
|
|
'viewCount': None,
|
|
'writers': {
|
|
'id': None,
|
|
'tag': None
|
|
},
|
|
'year': None
|
|
}
|
|
return _episode_attrs
|
|
|
|
def artist_attrs():
|
|
_artist_attrs = {
|
|
'addedAt': helpers.datetime_to_iso,
|
|
'art': None,
|
|
'artFile': lambda o: self.get_image(o, 'art'),
|
|
'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,
|
|
'thumbFile': lambda o: self.get_image(o, 'thumb'),
|
|
'title': None,
|
|
'titleSort': None,
|
|
'type': None,
|
|
'updatedAt': helpers.datetime_to_iso,
|
|
'userRating': None,
|
|
'viewCount': None,
|
|
'albums': lambda e: self.export_obj(e)
|
|
}
|
|
return _artist_attrs
|
|
|
|
def album_attrs():
|
|
_album_attrs = {
|
|
'addedAt': helpers.datetime_to_iso,
|
|
'art': None,
|
|
'artFile': lambda o: self.get_image(o, 'art'),
|
|
'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,
|
|
'thumbFile': lambda o: self.get_image(o, 'thumb'),
|
|
'title': None,
|
|
'titleSort': None,
|
|
'type': None,
|
|
'updatedAt': helpers.datetime_to_iso,
|
|
'userRating': None,
|
|
'viewCount': None,
|
|
'viewedLeafCount': None,
|
|
'tracks': lambda e: self.export_obj(e)
|
|
}
|
|
return _album_attrs
|
|
|
|
def track_attrs():
|
|
_track_attrs = {
|
|
'addedAt': helpers.datetime_to_iso,
|
|
'art': None,
|
|
'duration': None,
|
|
'durationHuman': lambda o: helpers.human_duration(getattr(o, 'duration', 0)),
|
|
'fields': {
|
|
'name': None,
|
|
'locked': 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,
|
|
'locations': 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,
|
|
'sizeHuman': lambda o: helpers.human_file_size(getattr(o, 'size', 0)),
|
|
'requiredBandwidths': None,
|
|
'syncItemId': None,
|
|
'syncState': None,
|
|
'audioStreams': {
|
|
'codec': None,
|
|
'codecID': None,
|
|
'default': 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': None,
|
|
'samplingRate': None,
|
|
'startRamp': None,
|
|
},
|
|
'lyricStreams': {
|
|
'codec': None,
|
|
'codecID': None,
|
|
'default': 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,
|
|
}
|
|
return _track_attrs
|
|
|
|
def photo_album_attrs():
|
|
_photo_album_attrs = {
|
|
'addedAt': helpers.datetime_to_iso,
|
|
'art': None,
|
|
'composite': None,
|
|
'fields': {
|
|
'name': None,
|
|
'locked': None
|
|
},
|
|
'guid': None,
|
|
'index': None,
|
|
'key': None,
|
|
'librarySectionID': None,
|
|
'librarySectionKey': None,
|
|
'librarySectionTitle': None,
|
|
'ratingKey': None,
|
|
'summary': None,
|
|
'thumb': None,
|
|
'title': None,
|
|
'titleSort': None,
|
|
'type': lambda e: 'photoalbum' if e == 'photo' else e,
|
|
'updatedAt': helpers.datetime_to_iso,
|
|
'photoalbums': lambda o: [self.export_obj(e) for e in getattr(o, 'albums')()],
|
|
'photos': lambda e: self.export_obj(e),
|
|
'clips': lambda e: self.export_obj(e)
|
|
}
|
|
return _photo_album_attrs
|
|
|
|
def photo_attrs():
|
|
_photo_attrs = {
|
|
'addedAt': helpers.datetime_to_iso,
|
|
'createdAtAccuracy': None,
|
|
'createdAtTZOffset': None,
|
|
'fields': {
|
|
'name': None,
|
|
'locked': None
|
|
},
|
|
'guid': None,
|
|
'index': None,
|
|
'key': None,
|
|
'librarySectionID': None,
|
|
'librarySectionKey': None,
|
|
'librarySectionTitle': None,
|
|
'locations': 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,
|
|
'titleSort': 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,
|
|
'sizeHuman': lambda o: helpers.human_file_size(getattr(o, 'size', 0)),
|
|
}
|
|
},
|
|
'tag': {
|
|
'id': None,
|
|
'tag': None,
|
|
'title': None
|
|
}
|
|
}
|
|
return _photo_attrs
|
|
|
|
def collection_attrs():
|
|
_collection_attrs = {
|
|
'addedAt': helpers.datetime_to_iso,
|
|
'art': None,
|
|
'artFile': lambda o: self.get_image(o, 'art'),
|
|
'childCount': None,
|
|
'collectionMode': None,
|
|
'collectionSort': None,
|
|
'contentRating': None,
|
|
'fields': {
|
|
'name': None,
|
|
'locked': None
|
|
},
|
|
'guid': None,
|
|
'index': None,
|
|
'key': None,
|
|
'labels': {
|
|
'id': None,
|
|
'tag': None
|
|
},
|
|
'librarySectionID': None,
|
|
'librarySectionKey': None,
|
|
'librarySectionTitle': None,
|
|
'maxYear': None,
|
|
'minYear': None,
|
|
'ratingKey': None,
|
|
'subtype': None,
|
|
'summary': None,
|
|
'thumb': None,
|
|
'thumbFile': lambda o: self.get_image(o, 'thumb'),
|
|
'title': None,
|
|
'titleSort': None,
|
|
'type': None,
|
|
'updatedAt': helpers.datetime_to_iso,
|
|
'children': lambda e: self.export_obj(e)
|
|
}
|
|
return _collection_attrs
|
|
|
|
def playlist_attrs():
|
|
_playlist_attrs = {
|
|
'addedAt': helpers.datetime_to_iso,
|
|
'composite': None,
|
|
'duration': None,
|
|
'durationHuman': lambda o: helpers.human_duration(getattr(o, 'duration', 0)),
|
|
'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: self.export_obj(e)
|
|
}
|
|
return _playlist_attrs
|
|
|
|
_media_types = {
|
|
'movie': movie_attrs,
|
|
'show': show_attrs,
|
|
'season': season_attrs,
|
|
'episode': episode_attrs,
|
|
'artist': artist_attrs,
|
|
'album': album_attrs,
|
|
'track': track_attrs,
|
|
'photoalbum': photo_album_attrs,
|
|
'photo': photo_attrs,
|
|
'clip': episode_attrs, # Assume clip is the same as an episode
|
|
'collection': collection_attrs,
|
|
'playlist': playlist_attrs,
|
|
}
|
|
|
|
media_attrs = _media_types[media_type]()
|
|
|
|
if flatten:
|
|
media_attrs = helpers.flatten_dict(media_attrs)[0]
|
|
|
|
return media_attrs
|
|
|
|
def return_levels(self, media_type, reverse_map=False):
|
|
def movie_levels():
|
|
_media_type = 'movie'
|
|
_metadata_levels = {
|
|
1: [
|
|
'ratingKey', 'title', 'titleSort', 'originalTitle', 'originallyAvailableAt', 'year', 'addedAt',
|
|
'rating', 'ratingImage', 'audienceRating', 'audienceRatingImage', 'userRating', 'contentRating',
|
|
'studio', 'tagline', 'summary', 'guid', 'duration', 'durationHuman', 'type'
|
|
],
|
|
2: [
|
|
'directors.tag', 'writers.tag', 'producers.tag', 'roles.tag', 'roles.role',
|
|
'countries.tag', 'genres.tag', 'collections.tag', 'labels.tag',
|
|
'fields.name', 'fields.locked', 'guids.id'
|
|
],
|
|
3: [
|
|
'art', 'thumb', 'key', 'chapterSource',
|
|
'chapters.tag', 'chapters.index', 'chapters.start', 'chapters.end', 'chapters.thumb',
|
|
'updatedAt', 'lastViewedAt', 'viewCount'
|
|
],
|
|
9: self._get_all_metadata_attrs(_media_type)
|
|
}
|
|
_media_info_levels = {
|
|
1: [
|
|
'locations', 'media.aspectRatio', 'media.audioChannels', 'media.audioCodec', 'media.audioProfile',
|
|
'media.bitrate', 'media.container', 'media.duration', 'media.height', 'media.width',
|
|
'media.videoCodec', 'media.videoFrameRate', 'media.videoProfile', 'media.videoResolution',
|
|
'media.optimizedVersion', 'media.hdr'
|
|
],
|
|
2: [
|
|
'media.parts.accessible', 'media.parts.exists', 'media.parts.file', 'media.parts.duration',
|
|
'media.parts.container', 'media.parts.indexes', 'media.parts.size', 'media.parts.sizeHuman',
|
|
'media.parts.audioProfile', 'media.parts.videoProfile',
|
|
'media.parts.optimizedForStreaming', 'media.parts.deepAnalysisVersion'
|
|
],
|
|
3: [
|
|
'media.parts.videoStreams.codec', 'media.parts.videoStreams.bitrate',
|
|
'media.parts.videoStreams.language', 'media.parts.videoStreams.languageCode',
|
|
'media.parts.videoStreams.title', 'media.parts.videoStreams.displayTitle',
|
|
'media.parts.videoStreams.extendedDisplayTitle', 'media.parts.videoStreams.hdr',
|
|
'media.parts.videoStreams.bitDepth', 'media.parts.videoStreams.colorSpace',
|
|
'media.parts.videoStreams.frameRate', 'media.parts.videoStreams.level',
|
|
'media.parts.videoStreams.profile', 'media.parts.videoStreams.refFrames',
|
|
'media.parts.videoStreams.scanType', 'media.parts.videoStreams.default',
|
|
'media.parts.videoStreams.height', 'media.parts.videoStreams.width',
|
|
'media.parts.audioStreams.codec', 'media.parts.audioStreams.bitrate',
|
|
'media.parts.audioStreams.language', 'media.parts.audioStreams.languageCode',
|
|
'media.parts.audioStreams.title', 'media.parts.audioStreams.displayTitle',
|
|
'media.parts.audioStreams.extendedDisplayTitle', 'media.parts.audioStreams.bitDepth',
|
|
'media.parts.audioStreams.channels', 'media.parts.audioStreams.audioChannelLayout',
|
|
'media.parts.audioStreams.profile', 'media.parts.audioStreams.samplingRate',
|
|
'media.parts.audioStreams.default',
|
|
'media.parts.subtitleStreams.codec', 'media.parts.subtitleStreams.format',
|
|
'media.parts.subtitleStreams.language', 'media.parts.subtitleStreams.languageCode',
|
|
'media.parts.subtitleStreams.title', 'media.parts.subtitleStreams.displayTitle',
|
|
'media.parts.subtitleStreams.extendedDisplayTitle', 'media.parts.subtitleStreams.forced',
|
|
'media.parts.subtitleStreams.default'
|
|
],
|
|
9: [
|
|
'locations', 'media'
|
|
]
|
|
}
|
|
return _metadata_levels, _media_info_levels
|
|
|
|
def show_levels():
|
|
_media_type = 'show'
|
|
_metadata_levels = {
|
|
1: [
|
|
'ratingKey', 'title', 'titleSort', 'originallyAvailableAt', 'year', 'addedAt',
|
|
'rating', 'userRating', 'contentRating',
|
|
'studio', 'summary', 'guid', 'duration', 'durationHuman', 'type', 'childCount',
|
|
'seasons'
|
|
],
|
|
2: [
|
|
'roles.tag', 'roles.role',
|
|
'genres.tag', 'collections.tag', 'labels.tag',
|
|
'fields.name', 'fields.locked'
|
|
],
|
|
3: [
|
|
'art', 'thumb', 'banner', 'theme', 'key',
|
|
'updatedAt', 'lastViewedAt', 'viewCount'
|
|
],
|
|
9: self._get_all_metadata_attrs(_media_type)
|
|
}
|
|
_media_info_levels = {}
|
|
return _metadata_levels, _media_info_levels
|
|
|
|
def season_levels():
|
|
_media_type = 'season'
|
|
_metadata_levels = {
|
|
1: [
|
|
'ratingKey', 'title', 'titleSort', 'addedAt',
|
|
'userRating',
|
|
'summary', 'guid', 'type', 'index',
|
|
'parentTitle', 'parentRatingKey', 'parentGuid',
|
|
'episodes'
|
|
],
|
|
2: [
|
|
'fields.name', 'fields.locked'
|
|
],
|
|
3: [
|
|
'art', 'thumb', 'key',
|
|
'updatedAt', 'lastViewedAt', 'viewCount',
|
|
'parentKey', 'parentTheme', 'parentThumb'
|
|
],
|
|
9: self._get_all_metadata_attrs(_media_type)
|
|
}
|
|
_media_info_levels = {}
|
|
return _metadata_levels, _media_info_levels
|
|
|
|
def episode_levels():
|
|
_media_type = 'episode'
|
|
_metadata_levels = {
|
|
1: [
|
|
'ratingKey', 'title', 'titleSort', 'originallyAvailableAt', 'year', 'addedAt',
|
|
'rating', 'userRating', 'contentRating',
|
|
'summary', 'guid', 'duration', 'durationHuman', 'type', 'index',
|
|
'parentTitle', 'parentRatingKey', 'parentGuid', 'parentIndex',
|
|
'grandparentTitle', 'grandparentRatingKey', 'grandparentGuid', 'hasIntroMarker'
|
|
],
|
|
2: [
|
|
'directors.tag', 'writers.tag',
|
|
'fields.name', 'fields.locked',
|
|
'markers.type', 'markers.start', 'markers.end'
|
|
],
|
|
3: [
|
|
'art', 'thumb', 'key', 'chapterSource',
|
|
'updatedAt', 'lastViewedAt', 'viewCount',
|
|
'parentThumb', 'parentKey',
|
|
'grandparentArt', 'grandparentThumb', 'grandparentTheme', 'grandparentKey'
|
|
],
|
|
9: self._get_all_metadata_attrs(_media_type)
|
|
}
|
|
_media_info_levels = {
|
|
1: [
|
|
'locations', 'media.aspectRatio', 'media.audioChannels', 'media.audioCodec', 'media.audioProfile',
|
|
'media.bitrate', 'media.container', 'media.duration', 'media.height', 'media.width',
|
|
'media.videoCodec', 'media.videoFrameRate', 'media.videoProfile', 'media.videoResolution',
|
|
'media.optimizedVersion', 'media.hdr'
|
|
],
|
|
2: [
|
|
'media.parts.accessible', 'media.parts.exists', 'media.parts.file', 'media.parts.duration',
|
|
'media.parts.container', 'media.parts.indexes', 'media.parts.size', 'media.parts.sizeHuman',
|
|
'media.parts.audioProfile', 'media.parts.videoProfile',
|
|
'media.parts.optimizedForStreaming', 'media.parts.deepAnalysisVersion'
|
|
],
|
|
3: [
|
|
'media.parts.videoStreams.codec', 'media.parts.videoStreams.bitrate',
|
|
'media.parts.videoStreams.language', 'media.parts.videoStreams.languageCode',
|
|
'media.parts.videoStreams.title', 'media.parts.videoStreams.displayTitle',
|
|
'media.parts.videoStreams.extendedDisplayTitle', 'media.parts.videoStreams.hdr',
|
|
'media.parts.videoStreams.bitDepth', 'media.parts.videoStreams.colorSpace',
|
|
'media.parts.videoStreams.frameRate', 'media.parts.videoStreams.level',
|
|
'media.parts.videoStreams.profile', 'media.parts.videoStreams.refFrames',
|
|
'media.parts.videoStreams.scanType', 'media.parts.videoStreams.default',
|
|
'media.parts.videoStreams.height', 'media.parts.videoStreams.width',
|
|
'media.parts.audioStreams.codec', 'media.parts.audioStreams.bitrate',
|
|
'media.parts.audioStreams.language', 'media.parts.audioStreams.languageCode',
|
|
'media.parts.audioStreams.title', 'media.parts.audioStreams.displayTitle',
|
|
'media.parts.audioStreams.extendedDisplayTitle', 'media.parts.audioStreams.bitDepth',
|
|
'media.parts.audioStreams.channels', 'media.parts.audioStreams.audioChannelLayout',
|
|
'media.parts.audioStreams.profile', 'media.parts.audioStreams.samplingRate',
|
|
'media.parts.audioStreams.default',
|
|
'media.parts.subtitleStreams.codec', 'media.parts.subtitleStreams.format',
|
|
'media.parts.subtitleStreams.language', 'media.parts.subtitleStreams.languageCode',
|
|
'media.parts.subtitleStreams.title', 'media.parts.subtitleStreams.displayTitle',
|
|
'media.parts.subtitleStreams.extendedDisplayTitle', 'media.parts.subtitleStreams.forced',
|
|
'media.parts.subtitleStreams.default'
|
|
],
|
|
9: [
|
|
'locations', 'media'
|
|
]
|
|
}
|
|
return _metadata_levels, _media_info_levels
|
|
|
|
def artist_levels():
|
|
_media_type = 'artist'
|
|
_metadata_levels = {
|
|
1: [
|
|
'ratingKey', 'title', 'titleSort', 'addedAt',
|
|
'rating', 'userRating',
|
|
'summary', 'guid', 'type',
|
|
'albums'
|
|
],
|
|
2: [
|
|
'collections.tag', 'genres.tag', 'countries.tag', 'moods.tag', 'styles.tag',
|
|
'fields.name', 'fields.locked'
|
|
],
|
|
3: [
|
|
'art', 'thumb', 'key',
|
|
'updatedAt', 'lastViewedAt', 'viewCount'
|
|
],
|
|
9: self._get_all_metadata_attrs(_media_type)
|
|
}
|
|
_media_info_levels = {}
|
|
return _metadata_levels, _media_info_levels
|
|
|
|
def album_levels():
|
|
_media_type = 'album'
|
|
_metadata_levels = {
|
|
1: [
|
|
'ratingKey', 'title', 'titleSort', 'originallyAvailableAt', 'addedAt',
|
|
'rating', 'userRating',
|
|
'summary', 'guid', 'type', 'index',
|
|
'parentTitle', 'parentRatingKey', 'parentGuid',
|
|
'tracks'
|
|
],
|
|
2: [
|
|
'collections.tag', 'genres.tag', 'labels.tag', 'moods.tag', 'styles.tag',
|
|
'fields.name', 'fields.locked'
|
|
],
|
|
3: [
|
|
'art', 'thumb', 'key',
|
|
'updatedAt', 'lastViewedAt', 'viewCount',
|
|
'parentKey', 'parentThumb'
|
|
],
|
|
9: self._get_all_metadata_attrs(_media_type)
|
|
}
|
|
_media_info_levels = {}
|
|
return _metadata_levels, _media_info_levels
|
|
|
|
def track_levels():
|
|
_media_type = 'track'
|
|
_metadata_levels = {
|
|
1: [
|
|
'ratingKey', 'title', 'titleSort', 'originalTitle', 'year', 'addedAt',
|
|
'userRating', 'ratingCount',
|
|
'summary', 'guid', 'duration', 'durationHuman', 'type', 'index',
|
|
'parentTitle', 'parentRatingKey', 'parentGuid', 'parentIndex',
|
|
'grandparentTitle', 'grandparentRatingKey', 'grandparentGuid'
|
|
],
|
|
2: [
|
|
'moods.tag', 'writers.tag',
|
|
'fields.name', 'fields.locked'
|
|
],
|
|
3: [
|
|
'art', 'thumb', 'key',
|
|
'updatedAt', 'lastViewedAt', 'viewCount',
|
|
'parentThumb', 'parentKey',
|
|
'grandparentArt', 'grandparentThumb', 'grandparentKey'
|
|
],
|
|
9: self._get_all_metadata_attrs(_media_type)
|
|
}
|
|
_media_info_levels = {
|
|
1: [
|
|
'locations', 'media.audioChannels', 'media.audioCodec',
|
|
'media.audioProfile',
|
|
'media.bitrate', 'media.container', 'media.duration'
|
|
],
|
|
2: [
|
|
'media.parts.accessible', 'media.parts.exists', 'media.parts.file', 'media.parts.duration',
|
|
'media.parts.container', 'media.parts.size', 'media.parts.sizeHuman',
|
|
'media.parts.audioProfile',
|
|
'media.parts.deepAnalysisVersion', 'media.parts.hasThumbnail'
|
|
],
|
|
3: [
|
|
'media.parts.audioStreams.codec', 'media.parts.audioStreams.bitrate',
|
|
'media.parts.audioStreams.title', 'media.parts.audioStreams.displayTitle',
|
|
'media.parts.audioStreams.extendedDisplayTitle',
|
|
'media.parts.audioStreams.channels', 'media.parts.audioStreams.audioChannelLayout',
|
|
'media.parts.audioStreams.samplingRate',
|
|
'media.parts.audioStreams.default',
|
|
'media.parts.audioStreams.albumGain', 'media.parts.audioStreams.albumPeak',
|
|
'media.parts.audioStreams.albumRange',
|
|
'media.parts.audioStreams.loudness', 'media.parts.audioStreams.gain',
|
|
'media.parts.audioStreams.lra', 'media.parts.audioStreams.peak',
|
|
'media.parts.audioStreams.startRamp', 'media.parts.audioStreams.endRamp',
|
|
'media.parts.lyricStreams.codec', 'media.parts.lyricStreams.format',
|
|
'media.parts.lyricStreams.title', 'media.parts.lyricStreams.displayTitle',
|
|
'media.parts.lyricStreams.extendedDisplayTitle',
|
|
'media.parts.lyricStreams.default', 'media.parts.lyricStreams.minLines',
|
|
'media.parts.lyricStreams.provider', 'media.parts.lyricStreams.timed',
|
|
],
|
|
9: [
|
|
'locations', 'media'
|
|
]
|
|
}
|
|
return _metadata_levels, _media_info_levels
|
|
|
|
def photo_album_levels():
|
|
_media_type = 'photoalbum'
|
|
_metadata_levels = {
|
|
1: [
|
|
'ratingKey', 'title', 'titleSort', 'addedAt',
|
|
'summary', 'guid', 'type', 'index',
|
|
'photoalbums', 'photos', 'clips'
|
|
],
|
|
2: [
|
|
'fields.name', 'fields.locked'
|
|
],
|
|
3: [
|
|
'art', 'thumb', 'key',
|
|
'updatedAt'
|
|
],
|
|
9: self._get_all_metadata_attrs(_media_type)
|
|
}
|
|
_media_info_levels = {}
|
|
return _metadata_levels, _media_info_levels
|
|
|
|
def photo_levels():
|
|
_media_type = 'photo'
|
|
_metadata_levels = {
|
|
1: [
|
|
'ratingKey', 'title', 'titleSort', 'year', 'originallyAvailableAt', 'addedAt',
|
|
'summary', 'guid', 'type', 'index',
|
|
'parentTitle', 'parentRatingKey', 'parentGuid', 'parentIndex',
|
|
'createdAtAccuracy', 'createdAtTZOffset'
|
|
],
|
|
2: [
|
|
'tag.tag', 'tag.title'
|
|
],
|
|
3: [
|
|
'thumb', 'key',
|
|
'updatedAt',
|
|
'parentThumb', 'parentKey'
|
|
],
|
|
9: self._get_all_metadata_attrs(_media_type)
|
|
}
|
|
_media_info_levels = {
|
|
1: [
|
|
'locations', 'media.aspectRatio', 'media.aperture',
|
|
'media.container', 'media.height', 'media.width',
|
|
'media.iso', 'media.lens', 'media.make', 'media.model'
|
|
],
|
|
2: [
|
|
'media.parts.accessible', 'media.parts.exists', 'media.parts.file',
|
|
'media.parts.container', 'media.parts.size', 'media.parts.sizeHuman'
|
|
],
|
|
3: [
|
|
],
|
|
9: [
|
|
'locations', 'media'
|
|
]
|
|
}
|
|
return _metadata_levels, _media_info_levels
|
|
|
|
def collection_levels():
|
|
_media_type = 'collection'
|
|
_metadata_levels = {
|
|
1: [
|
|
'ratingKey', 'title', 'titleSort', 'minYear', 'maxYear', 'addedAt',
|
|
'contentRating',
|
|
'summary', 'guid', 'type', 'subtype', 'childCount',
|
|
'collectionMode', 'collectionSort',
|
|
'children'
|
|
],
|
|
2: [
|
|
'labels.tag',
|
|
'fields.name', 'fields.locked'
|
|
],
|
|
3: [
|
|
'art', 'thumb', 'key',
|
|
'updatedAt'
|
|
],
|
|
9: self._get_all_metadata_attrs(_media_type)
|
|
}
|
|
_media_info_levels = {}
|
|
return _metadata_levels, _media_info_levels
|
|
|
|
def playlist_levels():
|
|
_media_type = 'playlist'
|
|
_metadata_levels = {
|
|
1: [
|
|
'ratingKey', 'title', 'addedAt',
|
|
'summary', 'guid', 'type', 'duration', 'durationHuman',
|
|
'playlistType', 'smart',
|
|
'items'
|
|
],
|
|
2: [
|
|
],
|
|
3: [
|
|
'composite', 'key',
|
|
'updatedAt'
|
|
],
|
|
9: self._get_all_metadata_attrs(_media_type)
|
|
}
|
|
_media_info_levels = {}
|
|
return _metadata_levels, _media_info_levels
|
|
|
|
_media_types = {
|
|
'movie': movie_levels,
|
|
'show': show_levels,
|
|
'season': season_levels,
|
|
'episode': episode_levels,
|
|
'artist': artist_levels,
|
|
'album': album_levels,
|
|
'track': track_levels,
|
|
'photoalbum': photo_album_levels,
|
|
'photo': photo_levels,
|
|
'clip': episode_levels, # Assume clip is the same as an episode
|
|
'collection': collection_levels,
|
|
'playlist': playlist_levels
|
|
}
|
|
|
|
metadata_levels, media_info_levels = _media_types[media_type]()
|
|
|
|
if reverse_map:
|
|
metadata_levels = {attr: level for level, attrs in reversed(sorted(metadata_levels.items()))
|
|
for attr in attrs}
|
|
media_info_levels = {attr: level for level, attrs in reversed(sorted(media_info_levels.items()))
|
|
for attr in attrs}
|
|
|
|
return metadata_levels, media_info_levels
|
|
|
|
def return_attrs_level_map(self, media_type, prefix=''):
|
|
media_attrs = self.return_attrs(media_type, flatten=True)
|
|
metadata_levels, media_info_levels = self.return_levels(media_type, reverse_map=True)
|
|
|
|
child_media_types = [self.PLURAL_MEDIA_TYPES[m] for m in self.CHILD_MEDIA_TYPES[media_type]]
|
|
|
|
metadata_levels_map = {}
|
|
media_info_levels_map = {}
|
|
|
|
for attr in media_attrs:
|
|
# Skip the main child attribute
|
|
if attr in child_media_types:
|
|
continue
|
|
|
|
metadata_level = metadata_levels.get(
|
|
attr, max(self.METADATA_LEVELS) if not self.is_media_info_attr(attr) else None)
|
|
media_info_level = media_info_levels.get(
|
|
attr, max(self.MEDIA_INFO_LEVELS) if self.is_media_info_attr(attr) else None)
|
|
|
|
if metadata_level is not None:
|
|
metadata_levels_map[prefix + attr] = metadata_level
|
|
elif media_info_level is not None:
|
|
media_info_levels_map[prefix + attr] = media_info_level
|
|
|
|
return metadata_levels_map, media_info_levels_map
|
|
|
|
def export(self):
|
|
msg = ''
|
|
if not self.section_id and not self.user_id and not self.rating_key:
|
|
msg = "Export called but no section_id, user_id, or rating_key provided."
|
|
elif self.metadata_level not in self.METADATA_LEVELS:
|
|
msg = "Export called with invalid metadata_level '{}'.".format(self.metadata_level)
|
|
elif self.media_info_level not in self.MEDIA_INFO_LEVELS:
|
|
msg = "Export called with invalid media_info_level '{}'.".format(self.media_info_level)
|
|
elif self.thumb_level not in self.IMAGE_LEVELS:
|
|
msg = "Export called with invalid thumb_level '{}'.".format(self.thumb_level)
|
|
elif self.art_level not in self.IMAGE_LEVELS:
|
|
msg = "Export called with invalid art_level '{}'.".format(self.art_level)
|
|
elif self.file_format not in self.FILE_FORMATS:
|
|
msg = "Export called with invalid file_format '{}'.".format(self.file_format)
|
|
elif self.export_type not in self.EXPORT_TYPES:
|
|
msg = "Export called with invalid export_type '{}'.".format(self.export_type)
|
|
elif self.user_id and self.export_type != 'playlist':
|
|
msg = "Export called with invalid export_type '{}'. " \
|
|
"Only export_type 'playlist' is allowed for user export."
|
|
elif self.individual_files and self.rating_key:
|
|
msg = "Individual file export is only allowed for library or user export."
|
|
|
|
if msg:
|
|
logger.error("Tautulli Exporter :: %s", msg)
|
|
return msg
|
|
|
|
if self.user_id:
|
|
user_data = users.Users()
|
|
user_info = user_data.get_details(user_id=self.user_id)
|
|
user_tokens = user_data.get_tokens(user_id=self.user_id)
|
|
plex_token = user_tokens['server_token']
|
|
else:
|
|
plex_token = plexpy.CONFIG.PMS_TOKEN
|
|
|
|
plex = Plex(plexpy.CONFIG.PMS_URL, plex_token)
|
|
|
|
if self.rating_key:
|
|
logger.debug(
|
|
"Tautulli Exporter :: Export called with rating_key %s, "
|
|
"metadata_level %d, media_info_level %d, thumb_level %s, art_level %s, "
|
|
"file_format %s",
|
|
self.rating_key, self.metadata_level, self.media_info_level,
|
|
self.thumb_level, self.art_level, self.file_format)
|
|
|
|
self.obj = plex.get_item(self.rating_key)
|
|
self.media_type = self._media_type(self.obj)
|
|
|
|
if self.media_type != 'playlist':
|
|
self.section_id = self.obj.librarySectionID
|
|
|
|
if self.media_type in ('season', 'episode', 'album', 'track'):
|
|
self.obj_title = self.obj._defaultSyncTitle()
|
|
else:
|
|
self.obj_title = self.obj.title
|
|
|
|
elif self.user_id:
|
|
logger.debug(
|
|
"Tautulli Exporter :: Export called with user_id %s, "
|
|
"metadata_level %d, media_info_level %d, thumb_level %s, art_level %s, "
|
|
"export_type %s, file_format %s",
|
|
self.user_id, self.metadata_level, self.media_info_level,
|
|
self.thumb_level, self.art_level, self.export_type, self.file_format)
|
|
|
|
self.obj = plex.plex
|
|
self.media_type = self.export_type
|
|
|
|
self.obj_title = user_info['username']
|
|
|
|
elif self.section_id:
|
|
logger.debug(
|
|
"Tautulli Exporter :: Export called with section_id %s, "
|
|
"metadata_level %d, media_info_level %d, thumb_level %s, art_level %s, "
|
|
"export_type %s, file_format %s",
|
|
self.section_id, self.metadata_level, self.media_info_level,
|
|
self.thumb_level, self.art_level, self.export_type, self.file_format)
|
|
|
|
self.obj = plex.get_library(str(self.section_id))
|
|
if self.export_type == 'all':
|
|
self.media_type = self.obj.type
|
|
else:
|
|
self.media_type = self.export_type
|
|
|
|
self.obj_title = self.obj.title
|
|
|
|
else:
|
|
msg = "Export called but no section_id, user_id, or rating_key provided."
|
|
logger.error("Tautulli Exporter :: %s", msg)
|
|
return msg
|
|
|
|
if self.media_type not in self.MEDIA_TYPES:
|
|
msg = "Cannot export media type '{}'.".format(self.media_type)
|
|
logger.error("Tautulli Exporter :: %s", msg)
|
|
return msg
|
|
|
|
if not self.MEDIA_TYPES[self.media_type][0]:
|
|
self.thumb_level = 0
|
|
if not self.MEDIA_TYPES[self.media_type][1]:
|
|
self.art_level = 0
|
|
|
|
self._process_custom_fields()
|
|
|
|
self.directory = self._filename(directory=True)
|
|
self.filename = self._filename()
|
|
self.title = self._filename(extension=False)
|
|
|
|
self.export_id = self.add_export()
|
|
if not self.export_id:
|
|
msg = "Failed to export '{}'.".format(self.directory)
|
|
logger.error("Tautulli Exporter :: %s", msg)
|
|
return msg
|
|
|
|
threading.Thread(target=self._real_export).start()
|
|
|
|
return True
|
|
|
|
def add_export(self):
|
|
keys = {
|
|
'timestamp': self.timestamp,
|
|
'section_id': self.section_id,
|
|
'user_id': self.user_id,
|
|
'rating_key': self.rating_key,
|
|
'media_type': self.media_type
|
|
}
|
|
|
|
values = {
|
|
'title': self.title,
|
|
'file_format': self.file_format,
|
|
'metadata_level': self.metadata_level,
|
|
'media_info_level': self.media_info_level,
|
|
'thumb_level': self.thumb_level,
|
|
'art_level': self.art_level,
|
|
'custom_fields': self.custom_fields,
|
|
'individual_files': self.individual_files
|
|
}
|
|
|
|
db = database.MonitorDatabase()
|
|
try:
|
|
db.upsert(table_name='exports', key_dict=keys, value_dict=values)
|
|
return db.last_insert_id()
|
|
except Exception as e:
|
|
logger.error("Tautulli Exporter :: Unable to save export to database: %s", e)
|
|
return False
|
|
|
|
def set_export_state(self):
|
|
if self.success:
|
|
complete = 1
|
|
else:
|
|
complete = -1
|
|
|
|
keys = {
|
|
'id': self.export_id
|
|
}
|
|
values = {
|
|
'thumb_level': self.thumb_level,
|
|
'art_level': self.art_level,
|
|
'complete': complete,
|
|
'file_size': self.file_size
|
|
}
|
|
|
|
db = database.MonitorDatabase()
|
|
db.upsert(table_name='exports', key_dict=keys, value_dict=values)
|
|
|
|
def set_export_progress(self):
|
|
keys = {
|
|
'id': self.export_id
|
|
}
|
|
values = {
|
|
'total_items': self.total_items,
|
|
'exported_items': self.exported_items
|
|
}
|
|
|
|
db = database.MonitorDatabase()
|
|
db.upsert(table_name='exports', key_dict=keys, value_dict=values)
|
|
|
|
def _real_export(self):
|
|
logger.info("Tautulli Exporter :: Starting export for '%s'...", self.title)
|
|
|
|
if self.rating_key:
|
|
items = [self.obj]
|
|
elif self.user_id:
|
|
# Only playlists export allowed for users
|
|
items = self.obj.playlists()
|
|
else:
|
|
method = getattr(self.obj, self.export_type)
|
|
items = method()
|
|
|
|
self.total_items = len(items)
|
|
logger.info("Tautulli Exporter :: Exporting %d item(s).", self.total_items)
|
|
|
|
pool = ThreadPool(processes=plexpy.CONFIG.EXPORT_THREADS)
|
|
items = [ExportObject(self, item) for item in items]
|
|
|
|
try:
|
|
result = pool.map(self._do_export, items)
|
|
|
|
if self.individual_files:
|
|
for item, item_result in zip(items, result):
|
|
self._save_file([item_result], obj=item)
|
|
self._exported_images(item.title)
|
|
|
|
else:
|
|
self._save_file(result, obj=self)
|
|
self._exported_images(self.title)
|
|
|
|
self.thumb_level = self.thumb_level or 10 if self.exported_thumb else 0
|
|
self.art_level = self.art_level or 10 if self.exported_art else 0
|
|
|
|
self.file_size += sum(item.file_size for item in items)
|
|
|
|
self.success = True
|
|
|
|
dirpath = get_export_dirpath(self.directory)
|
|
logger.info("Tautulli Exporter :: Successfully exported to '%s'", dirpath)
|
|
|
|
except Exception as e:
|
|
logger.exception("Tautulli Exporter :: Failed to export '%s': %s", self.title, e)
|
|
|
|
finally:
|
|
pool.close()
|
|
pool.join()
|
|
self.set_export_state()
|
|
|
|
def _do_export(self, item):
|
|
result = item._export_obj()
|
|
self.exported_items += 1
|
|
self.set_export_progress()
|
|
return result
|
|
|
|
def _save_file(self, result, obj=None):
|
|
filename = obj.filename
|
|
dirpath = get_export_dirpath(self.directory)
|
|
filepath = os.path.join(dirpath, filename)
|
|
|
|
if not os.path.exists(dirpath):
|
|
os.makedirs(dirpath)
|
|
|
|
if self.file_format == 'csv':
|
|
csv_data = helpers.flatten_dict(result)
|
|
csv_headers = sorted(set().union(*csv_data), key=helpers.sort_attrs)
|
|
# Move ratingKey, title, and titleSort to front of headers
|
|
for key in ('titleSort', 'title', 'ratingKey'):
|
|
csv_headers = helpers.move_to_front(csv_headers, key)
|
|
with open(filepath, 'w', encoding='utf-8', newline='') as outfile:
|
|
writer = csv.DictWriter(outfile, csv_headers)
|
|
writer.writeheader()
|
|
writer.writerows(csv_data)
|
|
|
|
elif self.file_format == 'json':
|
|
json_data = json.dumps(helpers.sort_obj(result), indent=4, ensure_ascii=False)
|
|
with open(filepath, 'w', encoding='utf-8') as outfile:
|
|
outfile.write(json_data)
|
|
|
|
elif self.file_format == 'xml':
|
|
xml_data = self.data_to_xml(result, obj)
|
|
with open(filepath, 'w', encoding='utf-8') as outfile:
|
|
outfile.write(xml_data)
|
|
|
|
elif self.file_format == 'm3u8':
|
|
m3u8_data = self.data_to_m3u8(result, obj)
|
|
with open(filepath, 'w', encoding='utf-8') as outfile:
|
|
outfile.write(m3u8_data)
|
|
|
|
self.file_size += os.path.getsize(filepath)
|
|
|
|
def _exported_images(self, title):
|
|
images_dirpath = get_export_dirpath(self.directory, images_directory=title)
|
|
|
|
if os.path.exists(images_dirpath):
|
|
for f in os.listdir(images_dirpath):
|
|
if f.endswith('.thumb.jpg'):
|
|
self.exported_thumb = True
|
|
elif f.endswith('.art.jpg'):
|
|
self.exported_art = True
|
|
|
|
def _media_type(self, obj):
|
|
return 'photoalbum' if self.is_photoalbum(obj) else obj.type
|
|
|
|
def _filename(self, obj=None, directory=False, extension=True):
|
|
if obj:
|
|
media_type = self._media_type(obj)
|
|
if media_type in ('season', 'episode', 'album', 'track'):
|
|
title = obj._defaultSyncTitle()
|
|
else:
|
|
title = obj.title
|
|
filename = '{} - {} [{}]'.format(
|
|
media_type.capitalize(), title, obj.ratingKey)
|
|
|
|
elif self.rating_key:
|
|
filename = '{} - {} [{}]'.format(
|
|
self.media_type.capitalize(), self.obj_title, self.rating_key)
|
|
|
|
elif self.user_id:
|
|
filename = 'User - {} - {} [{}]'.format(
|
|
self.obj_title, self.export_type.capitalize(), self.user_id)
|
|
|
|
elif self.section_id:
|
|
filename = 'Library - {} - {} [{}]'.format(
|
|
self.obj_title, self.export_type.capitalize(), self.section_id)
|
|
|
|
else:
|
|
filename = 'Export - Unknown'
|
|
|
|
filename = helpers.clean_filename(filename)
|
|
if directory:
|
|
return format_export_directory(filename, self.timestamp)
|
|
elif extension:
|
|
return format_export_filename(filename, self.file_format)
|
|
return filename
|
|
|
|
def _process_custom_fields(self):
|
|
if self.custom_fields:
|
|
logger.debug("Tautulli Exporter :: Processing custom fields: %s", self.custom_fields)
|
|
|
|
for field in self.custom_fields.split(','):
|
|
field = field.strip()
|
|
if not field:
|
|
continue
|
|
|
|
media_type, field = self._parse_custom_field(self.media_type, field)
|
|
|
|
if media_type in self._custom_fields:
|
|
self._custom_fields[media_type].add(field)
|
|
else:
|
|
self._custom_fields[media_type] = {field}
|
|
|
|
def _parse_custom_field(self, media_type, field):
|
|
for child_media_type in self.CHILD_MEDIA_TYPES.get(media_type, []):
|
|
plural_key = self.PLURAL_MEDIA_TYPES[child_media_type]
|
|
if field.startswith(plural_key + '.'):
|
|
media_type, field = field.split('.', maxsplit=1)
|
|
media_type, field = self._parse_custom_field(child_media_type, field)
|
|
|
|
return media_type, field
|
|
|
|
def _get_all_metadata_attrs(self, media_type):
|
|
exclude_attrs = ('locations', 'media', 'artFile', 'thumbFile')
|
|
all_attrs = self.return_attrs(media_type)
|
|
return [attr for attr in all_attrs if attr not in exclude_attrs]
|
|
|
|
def _get_export_attrs(self, media_type):
|
|
media_attrs = self.return_attrs(media_type)
|
|
metadata_level_attrs, media_info_level_attrs = self.return_levels(media_type)
|
|
|
|
export_attrs_list = []
|
|
export_attrs_set = set()
|
|
|
|
for level, attrs in metadata_level_attrs.items():
|
|
if level <= self.metadata_level:
|
|
export_attrs_set.update(attrs)
|
|
|
|
for level, attrs in media_info_level_attrs.items():
|
|
if level <= self.media_info_level:
|
|
export_attrs_set.update(attrs)
|
|
|
|
if self.thumb_level:
|
|
if 'thumbFile' in media_attrs and self.MEDIA_TYPES[media_type][0]:
|
|
export_attrs_set.add('thumbFile')
|
|
if self.art_level:
|
|
if 'artFile' in media_attrs and self.MEDIA_TYPES[media_type][1]:
|
|
export_attrs_set.add('artFile')
|
|
|
|
if media_type in self._custom_fields:
|
|
export_attrs_set.update(self._custom_fields[media_type])
|
|
|
|
for child_media_type in self.CHILD_MEDIA_TYPES.get(media_type):
|
|
if child_media_type in self._custom_fields:
|
|
export_attrs_set.add(self.PLURAL_MEDIA_TYPES[child_media_type])
|
|
|
|
if media_type != self.media_type:
|
|
if self.media_type == 'collection' and 'children' in self._custom_fields:
|
|
export_attrs_set.update(self._custom_fields['children'])
|
|
elif self.media_type == 'playlist' and 'item' in self._custom_fields:
|
|
export_attrs_set.update(self._custom_fields['item'])
|
|
|
|
for attr in export_attrs_set:
|
|
try:
|
|
value = helpers.get_dict_value_by_path(media_attrs, attr)
|
|
except (KeyError, TypeError):
|
|
logger.warn("Tautulli Exporter :: Unknown export attribute '%s', skipping...", attr)
|
|
continue
|
|
|
|
export_attrs_list.append(value)
|
|
|
|
return reduce(helpers.dict_merge, export_attrs_list, {})
|
|
|
|
@staticmethod
|
|
def is_media_info_attr(attr):
|
|
return attr.startswith('media.') or attr == 'locations'
|
|
|
|
@staticmethod
|
|
def is_photoalbum(obj):
|
|
return obj.type == 'photo' and obj.TAG == 'Directory'
|
|
|
|
def data_to_xml(self, data, obj):
|
|
xml_metadata = {obj.media_type: helpers.sort_obj(data), 'title': obj.title, 'type': obj.media_type}
|
|
if obj.rating_key:
|
|
xml_metadata['ratingKey'] = obj.rating_key
|
|
if obj.user_id:
|
|
xml_metadata['userID'] = obj.user_id
|
|
if obj.section_id:
|
|
xml_metadata['sectionID'] = obj.section_id
|
|
|
|
return helpers.dict_to_xml(xml_metadata, root_node='export', indent=4)
|
|
|
|
def data_to_m3u8(self, data, obj):
|
|
items = self._get_m3u8_items(data)
|
|
|
|
m3u8_metadata = {'title': obj.title, 'type': obj.media_type}
|
|
if obj.rating_key:
|
|
m3u8_metadata['ratingKey'] = obj.rating_key
|
|
if obj.user_id:
|
|
m3u8_metadata['userID'] = obj.user_id
|
|
if obj.section_id:
|
|
m3u8_metadata['sectionID'] = obj.section_id
|
|
|
|
m3u8 = '#EXTM3U\n'
|
|
m3u8 += '# Playlist: {title}\n# {metadata}\n\n'.format(title=obj.title, metadata=json.dumps(m3u8_metadata))
|
|
m3u8_item_template = '# {metadata}\n#EXTINF:{duration},{title}\n{location}\n'
|
|
m3u8_items = []
|
|
|
|
for item in items:
|
|
m3u8_values = {
|
|
'duration': item.pop('duration'),
|
|
'title': item.pop('title'),
|
|
'location': item.pop('location'),
|
|
'metadata': json.dumps(item)
|
|
}
|
|
m3u8_items.append(m3u8_item_template.format(**m3u8_values))
|
|
|
|
m3u8 = m3u8 + '\n'.join(m3u8_items)
|
|
|
|
return m3u8
|
|
|
|
def _get_m3u8_items(self, data):
|
|
items = []
|
|
|
|
for d in data:
|
|
if d.get('locations', []):
|
|
if 'grandparentTitle' in d:
|
|
full_title = '{} - {}'.format(d.get('originalTitle') or d['grandparentTitle'], d['title'])
|
|
else:
|
|
full_title = d['title']
|
|
metadata = {
|
|
'type': d['type'],
|
|
'ratingKey': d['ratingKey'],
|
|
'duration': d.get('duration', 5),
|
|
'title': full_title,
|
|
'location': '\n'.join(d['locations']) # Add all locations
|
|
}
|
|
items.append(metadata)
|
|
|
|
for child_media_type in self.CHILD_MEDIA_TYPES[d['type']]:
|
|
child_locations = self._get_m3u8_items(d[self.PLURAL_MEDIA_TYPES[child_media_type]])
|
|
items.extend(child_locations)
|
|
|
|
return items
|
|
|
|
def _export_obj(self):
|
|
pass
|
|
|
|
def export_obj(self, obj):
|
|
# Reload ~plexapi.base.PlexPartialObject
|
|
if hasattr(obj, 'isPartialObject') and obj.isPartialObject():
|
|
obj = obj.reload()
|
|
|
|
media_type = self._media_type(obj)
|
|
export_attrs = self._get_export_attrs(media_type)
|
|
return helpers.get_attrs_to_dict(obj, attrs=export_attrs)
|
|
|
|
def get_any_hdr(self, item, media_type):
|
|
root = self.return_attrs(media_type)['media']
|
|
attrs = helpers.get_dict_value_by_path(root, 'parts.videoStreams.hdr')
|
|
media = helpers.get_attrs_to_dict(item, attrs)
|
|
return any(vs.get('hdr') for p in media.get('parts', []) for vs in p.get('videoStreams', []))
|
|
|
|
def get_image(self, item, image):
|
|
media_type = item.type
|
|
rating_key = item.ratingKey
|
|
|
|
export_image = True
|
|
if self.thumb_level == 1 or self.art_level == 1:
|
|
posters = item.arts() if image == 'art' else item.posters()
|
|
export_image = any(poster.selected and poster.ratingKey.startswith('upload://')
|
|
for poster in posters)
|
|
elif self.thumb_level == 2 or self.art_level == 2:
|
|
export_image = any(field.locked and field.name == image
|
|
for field in item.fields)
|
|
elif self.thumb_level == 9 or self.art_level == 9:
|
|
export_image = True
|
|
|
|
if not export_image and image + 'File' in self._custom_fields.get(media_type, set()):
|
|
export_image = True
|
|
|
|
if not export_image:
|
|
return
|
|
|
|
image_url = None
|
|
if image == 'thumb':
|
|
image_url = item.thumbUrl
|
|
elif image == 'art':
|
|
image_url = item.artUrl
|
|
|
|
if not image_url:
|
|
return
|
|
|
|
r = requests.get(image_url, stream=True)
|
|
if r.status_code != 200:
|
|
return
|
|
|
|
if media_type in ('season', 'episode', 'album', 'track'):
|
|
item_title = item._defaultSyncTitle()
|
|
else:
|
|
item_title = item.title
|
|
|
|
dirpath = get_export_dirpath(self.directory, images_directory=self.title)
|
|
filename = helpers.clean_filename('{} [{}].{}.jpg'.format(item_title, rating_key, image))
|
|
filepath = os.path.join(dirpath, filename)
|
|
|
|
if not os.path.exists(dirpath):
|
|
os.makedirs(dirpath)
|
|
|
|
with open(filepath, 'wb') as outfile:
|
|
for chunk in r:
|
|
outfile.write(chunk)
|
|
|
|
self.file_size += os.path.getsize(filepath)
|
|
|
|
return os.path.join(os.path.basename(dirpath), filename)
|
|
|
|
|
|
class ExportObject(Export):
|
|
def __init__(self, export, obj):
|
|
super(ExportObject, self).__init__()
|
|
self.__dict__.update(export.__dict__)
|
|
|
|
self.obj = obj
|
|
self.rating_key = self.obj.ratingKey
|
|
self.filename = self._filename(obj=self.obj)
|
|
self.title = self._filename(obj=self.obj, extension=False)
|
|
|
|
def _export_obj(self):
|
|
result = self.export_obj(self.obj)
|
|
self.obj = None # Clear the object to prevent memory leak
|
|
return result
|
|
|
|
|
|
def get_export(export_id):
|
|
db = database.MonitorDatabase()
|
|
result = db.select_single('SELECT timestamp, title, file_format, thumb_level, art_level, '
|
|
'individual_files, complete '
|
|
'FROM exports WHERE id = ?',
|
|
[export_id])
|
|
|
|
if result:
|
|
if result['individual_files']:
|
|
result['filename'] = None
|
|
result['exists'] = check_export_exists(result['title'], result['timestamp'])
|
|
else:
|
|
result['filename'] = '{}.{}'.format(result['title'], result['file_format'])
|
|
result['exists'] = check_export_exists(result['title'], result['timestamp'], result['filename'])
|
|
|
|
return result
|
|
|
|
|
|
def delete_export(export_id):
|
|
if str(export_id).isdigit():
|
|
deleted = True
|
|
|
|
result = get_export(export_id=export_id)
|
|
if result and check_export_exists(result['title'], result['timestamp']): # Only check if folder exists
|
|
dirpath = get_export_dirpath(result['title'], result['timestamp'])
|
|
logger.info("Tautulli Exporter :: Deleting export '%s'.", dirpath)
|
|
try:
|
|
shutil.rmtree(dirpath, ignore_errors=True)
|
|
except OSError as e:
|
|
logger.error("Tautulli Exporter :: Failed to delete export '%s': %s", dirpath, e)
|
|
deleted = False
|
|
|
|
if deleted:
|
|
logger.info("Tautulli Exporter :: Deleting export_id %s from the database.", export_id)
|
|
db = database.MonitorDatabase()
|
|
result = db.action('DELETE FROM exports WHERE id = ?', args=[export_id])
|
|
|
|
return deleted
|
|
else:
|
|
return False
|
|
|
|
|
|
def delete_all_exports():
|
|
logger.info("Tautulli Exporter :: Deleting all exports from the export directory.")
|
|
|
|
export_dir = plexpy.CONFIG.EXPORT_DIR
|
|
try:
|
|
shutil.rmtree(export_dir, ignore_errors=True)
|
|
except OSError as e:
|
|
logger.error("Tautulli Exporter :: Failed to delete export directory '%s': %s", export_dir, e)
|
|
|
|
if not os.path.exists(export_dir):
|
|
os.makedirs(export_dir)
|
|
|
|
database.delete_exports()
|
|
return True
|
|
|
|
|
|
def cancel_exports():
|
|
db = database.MonitorDatabase()
|
|
db.action('UPDATE exports SET complete = -1 WHERE complete = 0')
|
|
|
|
|
|
def get_export_datatable(section_id=None, user_id=None, rating_key=None, kwargs=None):
|
|
default_return = {'recordsFiltered': 0,
|
|
'recordsTotal': 0,
|
|
'draw': 0,
|
|
'data': 'null',
|
|
'error': 'Unable to execute database query.'}
|
|
|
|
data_tables = datatables.DataTables()
|
|
|
|
custom_where = []
|
|
if section_id:
|
|
custom_where.append(['exports.section_id', section_id])
|
|
if user_id:
|
|
custom_where.append(['exports.user_id', user_id])
|
|
if rating_key:
|
|
custom_where.append(['exports.rating_key', rating_key])
|
|
|
|
columns = ['exports.id AS export_id',
|
|
'exports.timestamp',
|
|
'exports.section_id',
|
|
'exports.user_id',
|
|
'exports.rating_key',
|
|
'exports.media_type',
|
|
'CASE WHEN exports.media_type = "photoalbum" THEN "Photo Album" ELSE '
|
|
'UPPER(SUBSTR(exports.media_type, 1, 1)) || SUBSTR(exports.media_type, 2) END '
|
|
'AS media_type_title',
|
|
'exports.title',
|
|
'exports.file_format',
|
|
'exports.metadata_level',
|
|
'exports.media_info_level',
|
|
'exports.thumb_level',
|
|
'exports.art_level',
|
|
'exports.custom_fields',
|
|
'exports.individual_files',
|
|
'exports.file_size',
|
|
'exports.complete',
|
|
'exports.total_items',
|
|
'exports.exported_items'
|
|
]
|
|
try:
|
|
query = data_tables.ssp_query(table_name='exports',
|
|
columns=columns,
|
|
custom_where=custom_where,
|
|
group_by=[],
|
|
join_types=[],
|
|
join_tables=[],
|
|
join_evals=[],
|
|
kwargs=kwargs)
|
|
except Exception as e:
|
|
logger.warn("Tautulli Exporter :: Unable to execute database query for get_export_datatable: %s.", e)
|
|
return default_return
|
|
|
|
result = query['result']
|
|
|
|
rows = []
|
|
for item in result:
|
|
if item['individual_files']:
|
|
filename = None
|
|
exists = check_export_exists(item['title'], item['timestamp'])
|
|
else:
|
|
filename = format_export_filename(item['title'], item['file_format'])
|
|
exists = check_export_exists(item['title'], item['timestamp'], filename)
|
|
|
|
row = {'export_id': item['export_id'],
|
|
'timestamp': item['timestamp'],
|
|
'section_id': item['section_id'],
|
|
'user_id': item['user_id'],
|
|
'rating_key': item['rating_key'],
|
|
'media_type': item['media_type'],
|
|
'media_type_title': item['media_type_title'],
|
|
'title': item['title'],
|
|
'filename': filename,
|
|
'file_format': item['file_format'],
|
|
'metadata_level': item['metadata_level'],
|
|
'media_info_level': item['media_info_level'],
|
|
'thumb_level': item['thumb_level'],
|
|
'art_level': item['art_level'],
|
|
'custom_fields': item['custom_fields'],
|
|
'individual_files': item['individual_files'],
|
|
'file_size': item['file_size'],
|
|
'complete': item['complete'],
|
|
'exported_items': item['exported_items'],
|
|
'total_items': item['total_items'],
|
|
'exists': exists
|
|
}
|
|
|
|
rows.append(row)
|
|
|
|
result = {'recordsFiltered': query['filteredCount'],
|
|
'recordsTotal': query['totalCount'],
|
|
'data': rows,
|
|
'draw': query['draw']
|
|
}
|
|
|
|
return result
|
|
|
|
|
|
def format_export_directory(title, timestamp):
|
|
return '{}.{}'.format(title, helpers.timestamp_to_YMDHMS(timestamp))
|
|
|
|
|
|
def format_export_filename(title, file_format):
|
|
return '{}.{}'.format(title, file_format)
|
|
|
|
|
|
def get_export_dirpath(title, timestamp=None, images_directory=None):
|
|
if timestamp:
|
|
title = format_export_directory(title, timestamp)
|
|
dirpath = os.path.join(plexpy.CONFIG.EXPORT_DIR, title)
|
|
if images_directory:
|
|
dirpath = os.path.join(dirpath, '{}.images'.format(images_directory))
|
|
return dirpath
|
|
|
|
|
|
def get_export_filepath(title, timestamp, filename):
|
|
dirpath = get_export_dirpath(title, timestamp)
|
|
return os.path.join(dirpath, filename)
|
|
|
|
|
|
def check_export_exists(title, timestamp, filename=None):
|
|
if filename:
|
|
return os.path.isfile(get_export_filepath(title, timestamp, filename))
|
|
return os.path.isdir(get_export_dirpath(title, timestamp))
|
|
|
|
|
|
def get_custom_fields(media_type, sub_media_type=None):
|
|
custom_fields = {
|
|
'metadata_fields': [],
|
|
'media_info_fields': []
|
|
}
|
|
|
|
collection_sub_media_types = {'movie', 'show', 'artist', 'album', 'photoalbum'}
|
|
playlist_sub_media_types = {'video', 'audio', 'photo'}
|
|
sub_media_type = {s.strip().lower() for s in sub_media_type.split(',')}
|
|
|
|
export = Export()
|
|
|
|
if media_type not in export.MEDIA_TYPES:
|
|
return custom_fields
|
|
elif media_type == 'collection' and not sub_media_type.issubset(collection_sub_media_types):
|
|
return custom_fields
|
|
elif media_type == 'playlist' and not sub_media_type.issubset(playlist_sub_media_types):
|
|
return custom_fields
|
|
|
|
sub_media_types = list(sub_media_type.difference(playlist_sub_media_types))
|
|
if media_type == 'playlist' and 'video' in sub_media_type:
|
|
sub_media_types += ['movie', 'episode']
|
|
elif media_type == 'playlist' and 'audio' in sub_media_type:
|
|
sub_media_types += ['track']
|
|
elif media_type == 'playlist' and 'photo' in sub_media_type:
|
|
sub_media_types += ['photo']
|
|
|
|
metadata_levels_map, media_info_levels_map = export.return_attrs_level_map(media_type)
|
|
|
|
for sub_media_type in sub_media_types:
|
|
for child_media_type in export.CHILD_MEDIA_TYPES[media_type]:
|
|
prefix = ''
|
|
|
|
while child_media_type:
|
|
if child_media_type in ('children', 'item'):
|
|
fields_child_media_type = sub_media_type
|
|
else:
|
|
fields_child_media_type = child_media_type
|
|
|
|
prefix = prefix + export.PLURAL_MEDIA_TYPES[child_media_type] + '.'
|
|
|
|
child_metadata_levels_map, child_media_info_levels_map = export.return_attrs_level_map(
|
|
fields_child_media_type, prefix=prefix)
|
|
|
|
metadata_levels_map.update(child_metadata_levels_map)
|
|
media_info_levels_map.update(child_media_info_levels_map)
|
|
|
|
if child_media_type == 'photoalbum':
|
|
# Don't recurse photoalbum again
|
|
break
|
|
|
|
child_media_type = export.CHILD_MEDIA_TYPES.get(fields_child_media_type)
|
|
if child_media_type:
|
|
child_media_type = child_media_type[0]
|
|
|
|
custom_fields['metadata_fields'] = [{'field': attr, 'level': level}
|
|
for attr, level in sorted(metadata_levels_map.items()) if level]
|
|
custom_fields['media_info_fields'] = [{'field': attr, 'level': level}
|
|
for attr, level in sorted(media_info_levels_map.items()) if level]
|
|
|
|
return custom_fields
|
|
|
|
|
|
def build_export_docs():
|
|
export = Export()
|
|
|
|
section_head = '### <a id="{anchor}">{section}</a>\n\n'
|
|
section_details = '<details>\n' \
|
|
'<summary><strong>{field_type} Fields</strong></summary><br>\n\n' \
|
|
'{table}\n' \
|
|
'</details>'
|
|
|
|
table_head = '| {field_type} Field | Level 0 | Level 1 | Level 2 | Level 3 | Level 9 |\n' \
|
|
'| :--- | :---: | :---: | :---: | :---: | :---: |\n'
|
|
table_row = '| `{attr}` | {level0} | {level1} | {level2} | {level3} | {level9} |'
|
|
|
|
def _child_rows(_media_type):
|
|
child_table_rows = []
|
|
for child_media_type in export.CHILD_MEDIA_TYPES[_media_type]:
|
|
child_plural_media_type = export.PLURAL_MEDIA_TYPES[child_media_type]
|
|
if child_media_type == 'photoalbum':
|
|
child_section_title = 'Photo Albums'
|
|
else:
|
|
child_section_title = child_plural_media_type.capitalize()
|
|
child_text = u'\u2713<br>Includes [{}](#{}) Level {{}}'.format(child_section_title, child_media_type)
|
|
child_row = {
|
|
'attr': child_plural_media_type,
|
|
'level0': '',
|
|
'level1': child_text.format(1),
|
|
'level2': child_text.format(2),
|
|
'level3': child_text.format(3),
|
|
'level9': child_text.format(9),
|
|
}
|
|
child_table_rows.append(table_row.format(**child_row))
|
|
return child_table_rows
|
|
|
|
contents = []
|
|
sections = []
|
|
|
|
for media_type, (thumb, art) in export.MEDIA_TYPES.items():
|
|
if media_type == 'photoalbum':
|
|
section_title = 'Photo Albums'
|
|
else:
|
|
section_title = export.PLURAL_MEDIA_TYPES[media_type].capitalize()
|
|
|
|
details = []
|
|
table_child_rows = _child_rows(media_type)
|
|
|
|
metadata_levels_map, media_info_levels_map = export.return_attrs_level_map(media_type)
|
|
|
|
# Metadata Fields table
|
|
table_rows = []
|
|
for attr, level in sorted(metadata_levels_map.items(), key=helpers.sort_attrs):
|
|
if thumb and attr == 'thumbFile' or art and attr == 'artFile':
|
|
text = 'Refer to [Image Exports](#image-export)'
|
|
row = {
|
|
'attr': attr,
|
|
'level0': text,
|
|
'level1': '',
|
|
'level2': '',
|
|
'level3': '',
|
|
'level9': ''
|
|
}
|
|
else:
|
|
row = {
|
|
'attr': attr,
|
|
'level0': u'\u2713' if level <= 0 else '',
|
|
'level1': u'\u2713' if level <= 1 else '',
|
|
'level2': u'\u2713' if level <= 2 else '',
|
|
'level3': u'\u2713' if level <= 3 else '',
|
|
'level9': u'\u2713' if level <= 9 else ''
|
|
}
|
|
table_rows.append(table_row.format(**row))
|
|
table_rows += table_child_rows
|
|
metadata_table = table_head.format(field_type='Metadata') + '\n'.join(table_rows)
|
|
details.append(section_details.format(field_type='Metadata', table=metadata_table))
|
|
|
|
# Media Info Fields table
|
|
table_rows = []
|
|
for attr, level in sorted(media_info_levels_map.items(), key=helpers.sort_attrs):
|
|
row = {
|
|
'attr': attr,
|
|
'level0': u'\u2713' if level <= 0 else '',
|
|
'level1': u'\u2713' if level <= 1 else '',
|
|
'level2': u'\u2713' if level <= 2 else '',
|
|
'level3': u'\u2713' if level <= 3 else '',
|
|
'level9': u'\u2713' if level <= 9 else ''
|
|
}
|
|
table_rows.append(table_row.format(**row))
|
|
table_rows += table_child_rows
|
|
media_info_table = table_head.format(field_type='Media Info') + '\n'.join(table_rows)
|
|
details.append(section_details.format(field_type='Media Info', table=media_info_table))
|
|
|
|
section = section_head.format(anchor=media_type, section=section_title) + '\n\n'.join(details)
|
|
|
|
if media_type == 'collection':
|
|
section += '\n\n* <a id="children">**Note:**</a> `children` can be [Movies](#movie) or [Shows](#show) ' \
|
|
'depending on the collection.'
|
|
elif media_type == 'playlist':
|
|
section += '\n\n* <a id="item">**Note:**</a> `items` can be [Movies](#movie), [Episodes](#episode), ' \
|
|
'[Tracks](#track), or [Photos](#photo) depending on the playlist.'
|
|
|
|
sections.append(section)
|
|
|
|
docs = '\n\n\n'.join(sections)
|
|
return helpers.sanitize(docs)
|