Merge branch 'develop' into translations
This commit is contained in:
commit
f8fe6b6659
10 changed files with 693 additions and 563 deletions
|
@ -39,6 +39,7 @@ import xml.etree.ElementTree as etree
|
||||||
from re import compile as re_compile, sub
|
from re import compile as re_compile, sub
|
||||||
from json import dumps
|
from json import dumps
|
||||||
from urllib import urlencode, quote_plus, unquote
|
from urllib import urlencode, quote_plus, unquote
|
||||||
|
from os import path as os_path
|
||||||
|
|
||||||
import xbmcgui
|
import xbmcgui
|
||||||
from xbmc import sleep, executebuiltin
|
from xbmc import sleep, executebuiltin
|
||||||
|
@ -2186,13 +2187,38 @@ class API():
|
||||||
# Several streams/files available.
|
# Several streams/files available.
|
||||||
dialoglist = []
|
dialoglist = []
|
||||||
for entry in self.item.findall('./Media'):
|
for entry in self.item.findall('./Media'):
|
||||||
dialoglist.append(
|
# Get additional info (filename / languages)
|
||||||
"%sp %s - %s (%s)"
|
filename = None
|
||||||
% (entry.attrib.get('videoResolution', 'unknown'),
|
if 'file' in entry[0].attrib:
|
||||||
entry.attrib.get('videoCodec', 'unknown'),
|
filename = os_path.basename(entry[0].attrib['file'])
|
||||||
entry.attrib.get('audioProfile', 'unknown'),
|
# Languages of audio streams
|
||||||
entry.attrib.get('audioCodec', 'unknown'))
|
languages = []
|
||||||
)
|
for stream in entry[0]:
|
||||||
|
if (stream.attrib['streamType'] == '1' and
|
||||||
|
'language' in stream.attrib):
|
||||||
|
languages.append(stream.attrib['language'])
|
||||||
|
languages = ', '.join(languages)
|
||||||
|
if filename:
|
||||||
|
option = tryEncode(filename)
|
||||||
|
if languages:
|
||||||
|
if option:
|
||||||
|
option = '%s (%s): ' % (option, tryEncode(languages))
|
||||||
|
else:
|
||||||
|
option = '%s: ' % tryEncode(languages)
|
||||||
|
if 'videoResolution' in entry.attrib:
|
||||||
|
option = '%s%sp ' % (option,
|
||||||
|
entry.attrib.get('videoResolution'))
|
||||||
|
if 'videoCodec' in entry.attrib:
|
||||||
|
option = '%s%s' % (option,
|
||||||
|
entry.attrib.get('videoCodec'))
|
||||||
|
option = option.strip() + ' - '
|
||||||
|
if 'audioProfile' in entry.attrib:
|
||||||
|
option = '%s%s ' % (option,
|
||||||
|
entry.attrib.get('audioProfile'))
|
||||||
|
if 'audioCodec' in entry.attrib:
|
||||||
|
option = '%s%s ' % (option,
|
||||||
|
entry.attrib.get('audioCodec'))
|
||||||
|
dialoglist.append(option)
|
||||||
media = xbmcgui.Dialog().select('Select stream', dialoglist)
|
media = xbmcgui.Dialog().select('Select stream', dialoglist)
|
||||||
else:
|
else:
|
||||||
media = 0
|
media = 0
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
import logging
|
from logging import getLogger
|
||||||
from urllib import urlencode
|
from urllib import urlencode
|
||||||
from ast import literal_eval
|
from ast import literal_eval
|
||||||
from urlparse import urlparse, parse_qsl
|
from urlparse import urlparse, parse_qsl
|
||||||
|
@ -12,7 +12,9 @@ from variables import PLEX_TO_KODI_TIMEFACTOR
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
|
|
||||||
log = logging.getLogger("PLEX."+__name__)
|
log = getLogger("PLEX."+__name__)
|
||||||
|
|
||||||
|
CONTAINERSIZE = int(settings('limitindex'))
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
|
|
||||||
|
@ -141,7 +143,7 @@ def GetPlexMetadata(key):
|
||||||
return xml
|
return xml
|
||||||
|
|
||||||
|
|
||||||
def GetAllPlexChildren(key, containerSize=None):
|
def GetAllPlexChildren(key):
|
||||||
"""
|
"""
|
||||||
Returns a list (raw xml API dump) of all Plex children for the key.
|
Returns a list (raw xml API dump) of all Plex children for the key.
|
||||||
(e.g. /library/metadata/194853/children pointing to a season)
|
(e.g. /library/metadata/194853/children pointing to a season)
|
||||||
|
@ -149,11 +151,10 @@ def GetAllPlexChildren(key, containerSize=None):
|
||||||
Input:
|
Input:
|
||||||
key Key to a Plex item, e.g. 12345
|
key Key to a Plex item, e.g. 12345
|
||||||
"""
|
"""
|
||||||
url = "{server}/library/metadata/%s/children?" % key
|
return DownloadChunks("{server}/library/metadata/%s/children?" % key)
|
||||||
return DownloadChunks(url, containerSize)
|
|
||||||
|
|
||||||
|
|
||||||
def GetPlexSectionResults(viewId, args=None, containerSize=None):
|
def GetPlexSectionResults(viewId, args=None):
|
||||||
"""
|
"""
|
||||||
Returns a list (XML API dump) of all Plex items in the Plex
|
Returns a list (XML API dump) of all Plex items in the Plex
|
||||||
section with key = viewId.
|
section with key = viewId.
|
||||||
|
@ -166,38 +167,23 @@ def GetPlexSectionResults(viewId, args=None, containerSize=None):
|
||||||
url = "{server}/library/sections/%s/all?" % viewId
|
url = "{server}/library/sections/%s/all?" % viewId
|
||||||
if args:
|
if args:
|
||||||
url += urlencode(args) + '&'
|
url += urlencode(args) + '&'
|
||||||
return DownloadChunks(url, containerSize)
|
return DownloadChunks(url)
|
||||||
|
|
||||||
|
|
||||||
def DownloadChunks(url, containerSize):
|
def DownloadChunks(url):
|
||||||
"""
|
"""
|
||||||
Downloads PMS url in chunks of containerSize (int).
|
Downloads PMS url in chunks of CONTAINERSIZE.
|
||||||
If containerSize is None: ONE xml is fetched directly
|
|
||||||
|
|
||||||
url MUST end with '?' (if no other url encoded args are present) or '&'
|
url MUST end with '?' (if no other url encoded args are present) or '&'
|
||||||
|
|
||||||
Returns a stitched-together xml or None.
|
Returns a stitched-together xml or None.
|
||||||
"""
|
"""
|
||||||
if containerSize is None:
|
|
||||||
# Get rid of '?' or '&' at the end of url
|
|
||||||
xml = downloadutils.DownloadUtils().downloadUrl(url[:-1])
|
|
||||||
if xml == 401:
|
|
||||||
return 401
|
|
||||||
try:
|
|
||||||
xml.attrib
|
|
||||||
except AttributeError:
|
|
||||||
# Nope, not an XML, abort
|
|
||||||
log.error("Error getting url %s" % url[:-1])
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
return xml
|
|
||||||
|
|
||||||
xml = None
|
xml = None
|
||||||
pos = 0
|
pos = 0
|
||||||
errorCounter = 0
|
errorCounter = 0
|
||||||
while errorCounter < 10:
|
while errorCounter < 10:
|
||||||
args = {
|
args = {
|
||||||
'X-Plex-Container-Size': containerSize,
|
'X-Plex-Container-Size': CONTAINERSIZE,
|
||||||
'X-Plex-Container-Start': pos
|
'X-Plex-Container-Start': pos
|
||||||
}
|
}
|
||||||
xmlpart = downloadutils.DownloadUtils().downloadUrl(
|
xmlpart = downloadutils.DownloadUtils().downloadUrl(
|
||||||
|
@ -208,33 +194,32 @@ def DownloadChunks(url, containerSize):
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
log.error('Error while downloading chunks: %s'
|
log.error('Error while downloading chunks: %s'
|
||||||
% (url + urlencode(args)))
|
% (url + urlencode(args)))
|
||||||
pos += containerSize
|
pos += CONTAINERSIZE
|
||||||
errorCounter += 1
|
errorCounter += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Very first run: starting xml (to retain data in xml's root!)
|
# Very first run: starting xml (to retain data in xml's root!)
|
||||||
if xml is None:
|
if xml is None:
|
||||||
xml = deepcopy(xmlpart)
|
xml = deepcopy(xmlpart)
|
||||||
if len(xmlpart) < containerSize:
|
if len(xmlpart) < CONTAINERSIZE:
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
pos += containerSize
|
pos += CONTAINERSIZE
|
||||||
continue
|
continue
|
||||||
# Build answer xml - containing the entire library
|
# Build answer xml - containing the entire library
|
||||||
for child in xmlpart:
|
for child in xmlpart:
|
||||||
xml.append(child)
|
xml.append(child)
|
||||||
# Done as soon as we don't receive a full complement of items
|
# Done as soon as we don't receive a full complement of items
|
||||||
if len(xmlpart) < containerSize:
|
if len(xmlpart) < CONTAINERSIZE:
|
||||||
break
|
break
|
||||||
pos += containerSize
|
pos += CONTAINERSIZE
|
||||||
if errorCounter == 10:
|
if errorCounter == 10:
|
||||||
log.error('Fatal error while downloading chunks for %s' % url)
|
log.error('Fatal error while downloading chunks for %s' % url)
|
||||||
return None
|
return None
|
||||||
return xml
|
return xml
|
||||||
|
|
||||||
|
|
||||||
def GetAllPlexLeaves(viewId, lastViewedAt=None, updatedAt=None,
|
def GetAllPlexLeaves(viewId, lastViewedAt=None, updatedAt=None):
|
||||||
containerSize=None):
|
|
||||||
"""
|
"""
|
||||||
Returns a list (raw XML API dump) of all Plex subitems for the key.
|
Returns a list (raw XML API dump) of all Plex subitems for the key.
|
||||||
(e.g. /library/sections/2/allLeaves pointing to all TV shows)
|
(e.g. /library/sections/2/allLeaves pointing to all TV shows)
|
||||||
|
@ -245,7 +230,6 @@ def GetAllPlexLeaves(viewId, lastViewedAt=None, updatedAt=None,
|
||||||
since that point of time until now.
|
since that point of time until now.
|
||||||
updatedAt Unix timestamp; only retrieves PMS items updated
|
updatedAt Unix timestamp; only retrieves PMS items updated
|
||||||
by the PMS since that point of time until now.
|
by the PMS since that point of time until now.
|
||||||
containerSize Number of items simultaneously fetched from PMS
|
|
||||||
|
|
||||||
If lastViewedAt and updatedAt=None, ALL PMS items are returned.
|
If lastViewedAt and updatedAt=None, ALL PMS items are returned.
|
||||||
|
|
||||||
|
@ -265,14 +249,13 @@ def GetAllPlexLeaves(viewId, lastViewedAt=None, updatedAt=None,
|
||||||
url += '?' + '&'.join(args) + '&'
|
url += '?' + '&'.join(args) + '&'
|
||||||
else:
|
else:
|
||||||
url += '?'
|
url += '?'
|
||||||
return DownloadChunks(url, containerSize)
|
return DownloadChunks(url)
|
||||||
|
|
||||||
|
|
||||||
def GetPlexOnDeck(viewId, containerSize=None):
|
def GetPlexOnDeck(viewId):
|
||||||
"""
|
"""
|
||||||
"""
|
"""
|
||||||
url = "{server}/library/sections/%s/onDeck?" % viewId
|
return DownloadChunks("{server}/library/sections/%s/onDeck?" % viewId)
|
||||||
return DownloadChunks(url, containerSize)
|
|
||||||
|
|
||||||
|
|
||||||
def GetPlexCollections(mediatype):
|
def GetPlexCollections(mediatype):
|
||||||
|
|
|
@ -791,9 +791,7 @@ def browse_plex(key=None, plex_section_id=None):
|
||||||
if key:
|
if key:
|
||||||
xml = downloadutils.DownloadUtils().downloadUrl('{server}%s' % key)
|
xml = downloadutils.DownloadUtils().downloadUrl('{server}%s' % key)
|
||||||
else:
|
else:
|
||||||
xml = GetPlexSectionResults(
|
xml = GetPlexSectionResults(plex_section_id)
|
||||||
plex_section_id,
|
|
||||||
containerSize=int(settings('limitindex')))
|
|
||||||
try:
|
try:
|
||||||
xml[0].attrib
|
xml[0].attrib
|
||||||
except (ValueError, AttributeError, IndexError, TypeError):
|
except (ValueError, AttributeError, IndexError, TypeError):
|
||||||
|
|
|
@ -9,8 +9,7 @@ from datetime import datetime
|
||||||
from xbmc import sleep
|
from xbmc import sleep
|
||||||
|
|
||||||
import artwork
|
import artwork
|
||||||
from utils import tryEncode, tryDecode, settings, window, kodiSQL, \
|
from utils import tryEncode, tryDecode, window, kodiSQL, CatchExceptions
|
||||||
CatchExceptions
|
|
||||||
import plexdb_functions as plexdb
|
import plexdb_functions as plexdb
|
||||||
import kodidb_functions as kodidb
|
import kodidb_functions as kodidb
|
||||||
|
|
||||||
|
@ -1259,14 +1258,6 @@ class TVShows(Items):
|
||||||
|
|
||||||
class Music(Items):
|
class Music(Items):
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
Items.__init__(self)
|
|
||||||
|
|
||||||
self.directstream = settings('streamMusic') == "true"
|
|
||||||
self.enableimportsongrating = settings('enableImportSongRating') == "true"
|
|
||||||
self.enableexportsongrating = settings('enableExportSongRating') == "true"
|
|
||||||
self.enableupdatesongrating = settings('enableUpdateSongRating') == "true"
|
|
||||||
|
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
"""
|
"""
|
||||||
OVERWRITE this method, because we need to open another DB.
|
OVERWRITE this method, because we need to open another DB.
|
||||||
|
@ -1305,7 +1296,7 @@ class Music(Items):
|
||||||
name, sortname = API.getTitle()
|
name, sortname = API.getTitle()
|
||||||
# musicBrainzId = API.getProvider('MusicBrainzArtist')
|
# musicBrainzId = API.getProvider('MusicBrainzArtist')
|
||||||
musicBrainzId = None
|
musicBrainzId = None
|
||||||
genres = API.joinList(API.getGenres())
|
genres = ' / '.join(API.getGenres())
|
||||||
bio = API.getPlot()
|
bio = API.getPlot()
|
||||||
|
|
||||||
# Associate artwork
|
# Associate artwork
|
||||||
|
@ -1344,31 +1335,32 @@ class Music(Items):
|
||||||
|
|
||||||
# Process the artist
|
# Process the artist
|
||||||
if v.KODIVERSION >= 16:
|
if v.KODIVERSION >= 16:
|
||||||
query = ' '.join((
|
query = '''
|
||||||
|
UPDATE artist
|
||||||
"UPDATE artist",
|
SET strGenres = ?, strBiography = ?, strImage = ?,
|
||||||
"SET strGenres = ?, strBiography = ?, strImage = ?, strFanart = ?,",
|
strFanart = ?, lastScraped = ?
|
||||||
"lastScraped = ?",
|
WHERE idArtist = ?
|
||||||
"WHERE idArtist = ?"
|
'''
|
||||||
))
|
|
||||||
kodicursor.execute(query, (genres, bio, thumb, fanart,
|
kodicursor.execute(query, (genres, bio, thumb, fanart,
|
||||||
lastScraped, artistid))
|
lastScraped, artistid))
|
||||||
else:
|
else:
|
||||||
query = ' '.join((
|
query = '''
|
||||||
|
UPDATE artist
|
||||||
"UPDATE artist",
|
SET strGenres = ?, strBiography = ?, strImage = ?,
|
||||||
"SET strGenres = ?, strBiography = ?, strImage = ?, strFanart = ?,",
|
strFanart = ?, lastScraped = ?, dateAdded = ?
|
||||||
"lastScraped = ?, dateAdded = ?",
|
WHERE idArtist = ?
|
||||||
"WHERE idArtist = ?"
|
'''
|
||||||
))
|
|
||||||
kodicursor.execute(query, (genres, bio, thumb, fanart, lastScraped,
|
kodicursor.execute(query, (genres, bio, thumb, fanart, lastScraped,
|
||||||
dateadded, artistid))
|
dateadded, artistid))
|
||||||
|
|
||||||
# Update artwork
|
# Update artwork
|
||||||
artwork.addArtwork(artworks, artistid, "artist", kodicursor)
|
artwork.addArtwork(artworks, artistid, v.KODI_TYPE_ARTIST, kodicursor)
|
||||||
|
|
||||||
@CatchExceptions(warnuser=True)
|
@CatchExceptions(warnuser=True)
|
||||||
def add_updateAlbum(self, item, viewtag=None, viewid=None):
|
def add_updateAlbum(self, item, viewtag=None, viewid=None, children=None):
|
||||||
|
"""
|
||||||
|
children: list of child xml's, so in this case songs
|
||||||
|
"""
|
||||||
kodicursor = self.kodicursor
|
kodicursor = self.kodicursor
|
||||||
plex_db = self.plex_db
|
plex_db = self.plex_db
|
||||||
artwork = self.artwork
|
artwork = self.artwork
|
||||||
|
@ -1396,21 +1388,21 @@ class Music(Items):
|
||||||
# musicBrainzId = API.getProvider('MusicBrainzAlbum')
|
# musicBrainzId = API.getProvider('MusicBrainzAlbum')
|
||||||
musicBrainzId = None
|
musicBrainzId = None
|
||||||
year = API.getYear()
|
year = API.getYear()
|
||||||
genres = API.getGenres()
|
self.genres = API.getGenres()
|
||||||
genre = API.joinList(genres)
|
self.genre = ' / '.join(self.genres)
|
||||||
bio = API.getPlot()
|
bio = API.getPlot()
|
||||||
rating = userdata['UserRating']
|
rating = userdata['UserRating']
|
||||||
studio = API.getMusicStudio()
|
studio = API.getMusicStudio()
|
||||||
# artists = item['AlbumArtists']
|
|
||||||
# if not artists:
|
|
||||||
# artists = item['ArtistItems']
|
|
||||||
# artistname = []
|
|
||||||
# for artist in artists:
|
|
||||||
# artistname.append(artist['Name'])
|
|
||||||
artistname = item.attrib.get('parentTitle')
|
artistname = item.attrib.get('parentTitle')
|
||||||
if not artistname:
|
if not artistname:
|
||||||
artistname = item.attrib.get('originalTitle')
|
artistname = item.attrib.get('originalTitle')
|
||||||
|
# See if we have a compilation - Plex does NOT feature a compilation
|
||||||
|
# flag for albums
|
||||||
|
self.compilation = 0
|
||||||
|
for child in children:
|
||||||
|
if child.attrib.get('originalTitle') is not None:
|
||||||
|
self.compilation = 1
|
||||||
|
break
|
||||||
# Associate artwork
|
# Associate artwork
|
||||||
artworks = API.getAllArtwork(parentInfo=True)
|
artworks = API.getAllArtwork(parentInfo=True)
|
||||||
thumb = artworks['Primary']
|
thumb = artworks['Primary']
|
||||||
|
@ -1442,56 +1434,54 @@ class Music(Items):
|
||||||
# Process the album info
|
# Process the album info
|
||||||
if v.KODIVERSION >= 17:
|
if v.KODIVERSION >= 17:
|
||||||
# Kodi Krypton
|
# Kodi Krypton
|
||||||
query = ' '.join((
|
query = '''
|
||||||
|
UPDATE album
|
||||||
"UPDATE album",
|
SET strArtists = ?, iYear = ?, strGenres = ?, strReview = ?,
|
||||||
"SET strArtists = ?, iYear = ?, strGenres = ?, strReview = ?, strImage = ?,",
|
strImage = ?, iUserrating = ?, lastScraped = ?,
|
||||||
"iUserrating = ?, lastScraped = ?, strReleaseType = ?, "
|
strReleaseType = ?, strLabel = ?, bCompilation = ?
|
||||||
"strLabel = ? ",
|
WHERE idAlbum = ?
|
||||||
"WHERE idAlbum = ?"
|
'''
|
||||||
))
|
kodicursor.execute(query, (artistname, year, self.genre, bio,
|
||||||
kodicursor.execute(query, (artistname, year, genre, bio, thumb,
|
thumb, rating, lastScraped,
|
||||||
rating, lastScraped, "album", studio,
|
v.KODI_TYPE_ALBUM, studio,
|
||||||
albumid))
|
self.compilation, albumid))
|
||||||
elif v.KODIVERSION == 16:
|
elif v.KODIVERSION == 16:
|
||||||
# Kodi Jarvis
|
# Kodi Jarvis
|
||||||
query = ' '.join((
|
query = '''
|
||||||
|
UPDATE album
|
||||||
"UPDATE album",
|
SET strArtists = ?, iYear = ?, strGenres = ?, strReview = ?,
|
||||||
"SET strArtists = ?, iYear = ?, strGenres = ?, strReview = ?, strImage = ?,",
|
strImage = ?, iRating = ?, lastScraped = ?,
|
||||||
"iRating = ?, lastScraped = ?, strReleaseType = ?, "
|
strReleaseType = ?, strLabel = ?, bCompilation = ?
|
||||||
"strLabel = ? ",
|
WHERE idAlbum = ?
|
||||||
"WHERE idAlbum = ?"
|
'''
|
||||||
))
|
kodicursor.execute(query, (artistname, year, self.genre, bio,
|
||||||
kodicursor.execute(query, (artistname, year, genre, bio, thumb,
|
thumb, rating, lastScraped,
|
||||||
rating, lastScraped, "album", studio,
|
v.KODI_TYPE_ALBUM, studio,
|
||||||
albumid))
|
self.compilation, albumid))
|
||||||
elif v.KODIVERSION == 15:
|
elif v.KODIVERSION == 15:
|
||||||
# Kodi Isengard
|
# Kodi Isengard
|
||||||
query = ' '.join((
|
query = '''
|
||||||
|
UPDATE album
|
||||||
"UPDATE album",
|
SET strArtists = ?, iYear = ?, strGenres = ?, strReview = ?,
|
||||||
"SET strArtists = ?, iYear = ?, strGenres = ?, strReview = ?, strImage = ?,",
|
strImage = ?, iRating = ?, lastScraped = ?, dateAdded = ?,
|
||||||
"iRating = ?, lastScraped = ?, dateAdded = ?, "
|
strReleaseType = ?, strLabel = ?
|
||||||
"strReleaseType = ?, strLabel = ? ",
|
WHERE idAlbum = ?
|
||||||
"WHERE idAlbum = ?"
|
'''
|
||||||
))
|
kodicursor.execute(query, (artistname, year, self.genre, bio,
|
||||||
kodicursor.execute(query, (artistname, year, genre, bio, thumb,
|
thumb, rating, lastScraped, dateadded,
|
||||||
rating, lastScraped, dateadded,
|
v.KODI_TYPE_ALBUM, studio, albumid))
|
||||||
"album", studio, albumid))
|
|
||||||
else:
|
else:
|
||||||
# Kodi Helix
|
# Kodi Helix
|
||||||
query = ' '.join((
|
query = '''
|
||||||
|
UPDATE album
|
||||||
"UPDATE album",
|
SET strArtists = ?, iYear = ?, strGenres = ?, strReview = ?,
|
||||||
"SET strArtists = ?, iYear = ?, strGenres = ?, strReview = ?, strImage = ?,",
|
strImage = ?, iRating = ?, lastScraped = ?, dateAdded = ?,
|
||||||
"iRating = ?, lastScraped = ?, dateAdded = ?, "
|
strLabel = ?
|
||||||
"strLabel = ? ",
|
WHERE idAlbum = ?
|
||||||
"WHERE idAlbum = ?"
|
'''
|
||||||
))
|
kodicursor.execute(query, (artistname, year, self.genre, bio,
|
||||||
kodicursor.execute(query, (artistname, year, genre, bio, thumb,
|
thumb, rating, lastScraped, dateadded,
|
||||||
rating, lastScraped, dateadded, studio,
|
studio, albumid))
|
||||||
albumid))
|
|
||||||
|
|
||||||
# Associate the parentid for plex reference
|
# Associate the parentid for plex reference
|
||||||
parentId = item.attrib.get('parentRatingKey')
|
parentId = item.attrib.get('parentRatingKey')
|
||||||
|
@ -1505,7 +1495,7 @@ class Music(Items):
|
||||||
artist = GetPlexMetadata(parentId)
|
artist = GetPlexMetadata(parentId)
|
||||||
# Item may not be an artist, verification necessary.
|
# Item may not be an artist, verification necessary.
|
||||||
if artist is not None and artist != 401:
|
if artist is not None and artist != 401:
|
||||||
if artist[0].attrib.get('type') == "artist":
|
if artist[0].attrib.get('type') == v.PLEX_TYPE_ARTIST:
|
||||||
# Update with the parentId, for remove reference
|
# Update with the parentId, for remove reference
|
||||||
plex_db.addReference(parentId,
|
plex_db.addReference(parentId,
|
||||||
v.PLEX_TYPE_ARTIST,
|
v.PLEX_TYPE_ARTIST,
|
||||||
|
@ -1539,29 +1529,26 @@ class Music(Items):
|
||||||
% (artistname, artistid))
|
% (artistname, artistid))
|
||||||
|
|
||||||
# Add artist to album
|
# Add artist to album
|
||||||
query = (
|
query = '''
|
||||||
'''
|
|
||||||
INSERT OR REPLACE INTO album_artist(idArtist, idAlbum, strArtist)
|
INSERT OR REPLACE INTO album_artist(idArtist, idAlbum, strArtist)
|
||||||
|
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?)
|
||||||
'''
|
'''
|
||||||
)
|
|
||||||
kodicursor.execute(query, (artistid, albumid, artistname))
|
kodicursor.execute(query, (artistid, albumid, artistname))
|
||||||
# Update discography
|
# Update discography
|
||||||
query = (
|
query = '''
|
||||||
'''
|
|
||||||
INSERT OR REPLACE INTO discography(idArtist, strAlbum, strYear)
|
INSERT OR REPLACE INTO discography(idArtist, strAlbum, strYear)
|
||||||
|
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?)
|
||||||
'''
|
'''
|
||||||
)
|
|
||||||
kodicursor.execute(query, (artistid, name, year))
|
kodicursor.execute(query, (artistid, name, year))
|
||||||
# Update plex reference with parentid
|
# Update plex reference with parentid
|
||||||
plex_db.updateParentId(artistId, albumid)
|
plex_db.updateParentId(artistId, albumid)
|
||||||
# Add genres
|
# Add genres
|
||||||
self.kodi_db.addMusicGenres(albumid, genres, "album")
|
self.kodi_db.addMusicGenres(albumid, self.genres, v.KODI_TYPE_ALBUM)
|
||||||
# Update artwork
|
# Update artwork
|
||||||
artwork.addArtwork(artworks, albumid, "album", kodicursor)
|
artwork.addArtwork(artworks, albumid, v.KODI_TYPE_ALBUM, kodicursor)
|
||||||
|
# Add all children - all tracks
|
||||||
|
for child in children:
|
||||||
|
self.add_updateSong(child, viewtag, viewid)
|
||||||
|
|
||||||
@CatchExceptions(warnuser=True)
|
@CatchExceptions(warnuser=True)
|
||||||
def add_updateSong(self, item, viewtag=None, viewid=None):
|
def add_updateSong(self, item, viewtag=None, viewid=None):
|
||||||
|
@ -1601,9 +1588,22 @@ class Music(Items):
|
||||||
title, sorttitle = API.getTitle()
|
title, sorttitle = API.getTitle()
|
||||||
# musicBrainzId = API.getProvider('MusicBrainzTrackId')
|
# musicBrainzId = API.getProvider('MusicBrainzTrackId')
|
||||||
musicBrainzId = None
|
musicBrainzId = None
|
||||||
genres = API.getGenres()
|
try:
|
||||||
genre = API.joinList(genres)
|
genres = self.genres
|
||||||
|
genre = self.genre
|
||||||
|
except AttributeError:
|
||||||
|
# No parent album - hence no genre information from Plex
|
||||||
|
genres = None
|
||||||
|
genre = None
|
||||||
|
try:
|
||||||
|
if self.compilation == 0:
|
||||||
artists = item.attrib.get('grandparentTitle')
|
artists = item.attrib.get('grandparentTitle')
|
||||||
|
else:
|
||||||
|
artists = item.attrib.get('originalTitle')
|
||||||
|
except AttributeError:
|
||||||
|
# compilation not set
|
||||||
|
artists = item.attrib.get('originalTitle',
|
||||||
|
item.attrib.get('grandparentTitle'))
|
||||||
tracknumber = int(item.attrib.get('index', 0))
|
tracknumber = int(item.attrib.get('index', 0))
|
||||||
disc = int(item.attrib.get('parentIndex', 1))
|
disc = int(item.attrib.get('parentIndex', 1))
|
||||||
if disc == 1:
|
if disc == 1:
|
||||||
|
@ -1613,9 +1613,13 @@ class Music(Items):
|
||||||
year = API.getYear()
|
year = API.getYear()
|
||||||
resume, duration = API.getRuntime()
|
resume, duration = API.getRuntime()
|
||||||
rating = userdata['UserRating']
|
rating = userdata['UserRating']
|
||||||
|
|
||||||
hasEmbeddedCover = False
|
|
||||||
comment = None
|
comment = None
|
||||||
|
# Moods
|
||||||
|
moods = []
|
||||||
|
for entry in item:
|
||||||
|
if entry.tag == 'Mood':
|
||||||
|
moods.append(entry.attrib['tag'])
|
||||||
|
mood = ' / '.join(moods)
|
||||||
|
|
||||||
# GET THE FILE AND PATH #####
|
# GET THE FILE AND PATH #####
|
||||||
doIndirect = not self.directpath
|
doIndirect = not self.directpath
|
||||||
|
@ -1653,16 +1657,18 @@ class Music(Items):
|
||||||
kodicursor.execute(query, (path, '123', pathid))
|
kodicursor.execute(query, (path, '123', pathid))
|
||||||
|
|
||||||
# Update the song entry
|
# Update the song entry
|
||||||
query = ' '.join((
|
query = '''
|
||||||
"UPDATE song",
|
UPDATE song
|
||||||
"SET idAlbum = ?, strArtists = ?, strGenres = ?, strTitle = ?, iTrack = ?,",
|
SET idAlbum = ?, strArtists = ?, strGenres = ?, strTitle = ?,
|
||||||
"iDuration = ?, iYear = ?, strFilename = ?, iTimesPlayed = ?, lastplayed = ?,",
|
iTrack = ?, iDuration = ?, iYear = ?, strFilename = ?,
|
||||||
"rating = ?, comment = ?",
|
iTimesPlayed = ?, lastplayed = ?, rating = ?, comment = ?,
|
||||||
"WHERE idSong = ?"
|
mood = ?
|
||||||
))
|
WHERE idSong = ?
|
||||||
|
'''
|
||||||
kodicursor.execute(query, (albumid, artists, genre, title, track,
|
kodicursor.execute(query, (albumid, artists, genre, title, track,
|
||||||
duration, year, filename, playcount,
|
duration, year, filename, playcount,
|
||||||
dateplayed, rating, comment, songid))
|
dateplayed, rating, comment, mood,
|
||||||
|
songid))
|
||||||
|
|
||||||
# Update the checksum in plex table
|
# Update the checksum in plex table
|
||||||
plex_db.updateReference(itemid, checksum)
|
plex_db.updateReference(itemid, checksum)
|
||||||
|
@ -1685,7 +1691,9 @@ class Music(Items):
|
||||||
if album_name:
|
if album_name:
|
||||||
log.info("Creating virtual music album for song: %s."
|
log.info("Creating virtual music album for song: %s."
|
||||||
% itemid)
|
% itemid)
|
||||||
albumid = self.kodi_db.addAlbum(album_name, API.getProvider('MusicBrainzAlbum'))
|
albumid = self.kodi_db.addAlbum(
|
||||||
|
album_name,
|
||||||
|
API.getProvider('MusicBrainzAlbum'))
|
||||||
plex_db.addReference("%salbum%s" % (itemid, albumid),
|
plex_db.addReference("%salbum%s" % (itemid, albumid),
|
||||||
v.PLEX_TYPE_ALBUM,
|
v.PLEX_TYPE_ALBUM,
|
||||||
albumid,
|
albumid,
|
||||||
|
@ -1713,54 +1721,51 @@ class Music(Items):
|
||||||
except TypeError:
|
except TypeError:
|
||||||
# No album found, create a single's album
|
# No album found, create a single's album
|
||||||
log.info("Failed to add album. Creating singles.")
|
log.info("Failed to add album. Creating singles.")
|
||||||
kodicursor.execute("select coalesce(max(idAlbum),0) from album")
|
kodicursor.execute(
|
||||||
|
"select coalesce(max(idAlbum),0) from album")
|
||||||
albumid = kodicursor.fetchone()[0] + 1
|
albumid = kodicursor.fetchone()[0] + 1
|
||||||
if v.KODIVERSION >= 16:
|
if v.KODIVERSION >= 16:
|
||||||
# Kodi Jarvis
|
# Kodi Jarvis
|
||||||
query = (
|
query = '''
|
||||||
'''
|
INSERT INTO album(
|
||||||
INSERT INTO album(idAlbum, strGenres, iYear, strReleaseType)
|
idAlbum, strGenres, iYear, strReleaseType)
|
||||||
|
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
'''
|
'''
|
||||||
)
|
kodicursor.execute(query,
|
||||||
kodicursor.execute(query, (albumid, genre, year, "single"))
|
(albumid, genre, year, "single"))
|
||||||
elif v.KODIVERSION == 15:
|
elif v.KODIVERSION == 15:
|
||||||
# Kodi Isengard
|
# Kodi Isengard
|
||||||
query = (
|
query = '''
|
||||||
'''
|
INSERT INTO album(
|
||||||
INSERT INTO album(idAlbum, strGenres, iYear, dateAdded, strReleaseType)
|
idAlbum, strGenres, iYear, dateAdded,
|
||||||
|
strReleaseType)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
'''
|
'''
|
||||||
)
|
kodicursor.execute(query, (albumid, genre, year,
|
||||||
kodicursor.execute(query, (albumid, genre, year, dateadded, "single"))
|
dateadded, "single"))
|
||||||
else:
|
else:
|
||||||
# Kodi Helix
|
# Kodi Helix
|
||||||
query = (
|
query = '''
|
||||||
'''
|
INSERT INTO album(
|
||||||
INSERT INTO album(idAlbum, strGenres, iYear, dateAdded)
|
idAlbum, strGenres, iYear, dateAdded)
|
||||||
|
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
'''
|
'''
|
||||||
)
|
kodicursor.execute(query, (albumid, genre, year,
|
||||||
kodicursor.execute(query, (albumid, genre, year, dateadded))
|
dateadded))
|
||||||
|
|
||||||
# Create the song entry
|
# Create the song entry
|
||||||
query = (
|
query = '''
|
||||||
'''
|
|
||||||
INSERT INTO song(
|
INSERT INTO song(
|
||||||
idSong, idAlbum, idPath, strArtists, strGenres, strTitle, iTrack,
|
idSong, idAlbum, idPath, strArtists, strGenres, strTitle,
|
||||||
iDuration, iYear, strFileName, strMusicBrainzTrackID, iTimesPlayed, lastplayed,
|
iTrack, iDuration, iYear, strFileName,
|
||||||
rating, iStartOffset, iEndOffset)
|
strMusicBrainzTrackID, iTimesPlayed, lastplayed,
|
||||||
|
rating, iStartOffset, iEndOffset, mood)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
'''
|
'''
|
||||||
)
|
|
||||||
kodicursor.execute(
|
kodicursor.execute(
|
||||||
query, (songid, albumid, pathid, artists, genre, title, track,
|
query, (songid, albumid, pathid, artists, genre, title, track,
|
||||||
duration, year, filename, musicBrainzId, playcount,
|
duration, year, filename, musicBrainzId, playcount,
|
||||||
dateplayed, rating, 0, 0))
|
dateplayed, rating, 0, 0, mood))
|
||||||
|
|
||||||
# Create the reference in plex table
|
# Create the reference in plex table
|
||||||
plex_db.addReference(itemid,
|
plex_db.addReference(itemid,
|
||||||
|
@ -1773,14 +1778,11 @@ class Music(Items):
|
||||||
view_id=viewid)
|
view_id=viewid)
|
||||||
|
|
||||||
# Link song to album
|
# Link song to album
|
||||||
query = (
|
query = '''
|
||||||
'''
|
|
||||||
INSERT OR REPLACE INTO albuminfosong(
|
INSERT OR REPLACE INTO albuminfosong(
|
||||||
idAlbumInfoSong, idAlbumInfo, iTrack, strTitle, iDuration)
|
idAlbumInfoSong, idAlbumInfo, iTrack, strTitle, iDuration)
|
||||||
|
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
'''
|
'''
|
||||||
)
|
|
||||||
kodicursor.execute(query, (songid, albumid, track, title, duration))
|
kodicursor.execute(query, (songid, albumid, track, title, duration))
|
||||||
|
|
||||||
# Link song to artists
|
# Link song to artists
|
||||||
|
@ -1808,29 +1810,27 @@ class Music(Items):
|
||||||
finally:
|
finally:
|
||||||
if v.KODIVERSION >= 17:
|
if v.KODIVERSION >= 17:
|
||||||
# Kodi Krypton
|
# Kodi Krypton
|
||||||
query = (
|
query = '''
|
||||||
'''
|
INSERT OR REPLACE INTO song_artist(
|
||||||
INSERT OR REPLACE INTO song_artist(idArtist, idSong, idRole, iOrder, strArtist)
|
idArtist, idSong, idRole, iOrder, strArtist)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
'''
|
'''
|
||||||
)
|
kodicursor.execute(query, (artistid, songid, 1, index,
|
||||||
kodicursor.execute(query,(artistid, songid, 1, index, artist_name))
|
artist_name))
|
||||||
# May want to look into only doing this once?
|
# May want to look into only doing this once?
|
||||||
query = (
|
query = '''
|
||||||
'''
|
|
||||||
INSERT OR REPLACE INTO role(idRole, strRole)
|
INSERT OR REPLACE INTO role(idRole, strRole)
|
||||||
VALUES (?, ?)
|
VALUES (?, ?)
|
||||||
'''
|
'''
|
||||||
)
|
|
||||||
kodicursor.execute(query, (1, 'Composer'))
|
kodicursor.execute(query, (1, 'Composer'))
|
||||||
else:
|
else:
|
||||||
query = (
|
query = '''
|
||||||
'''
|
INSERT OR REPLACE INTO song_artist(
|
||||||
INSERT OR REPLACE INTO song_artist(idArtist, idSong, iOrder, strArtist)
|
idArtist, idSong, iOrder, strArtist)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
'''
|
'''
|
||||||
)
|
kodicursor.execute(query, (artistid, songid, index,
|
||||||
kodicursor.execute(query, (artistid, songid, index, artist_name))
|
artist_name))
|
||||||
|
|
||||||
# Verify if album artist exists
|
# Verify if album artist exists
|
||||||
album_artists = []
|
album_artists = []
|
||||||
|
@ -1852,31 +1852,28 @@ class Music(Items):
|
||||||
artist_edb = plex_db.getItem_byId(artist_eid)
|
artist_edb = plex_db.getItem_byId(artist_eid)
|
||||||
artistid = artist_edb[0]
|
artistid = artist_edb[0]
|
||||||
finally:
|
finally:
|
||||||
query = (
|
query = '''
|
||||||
'''
|
INSERT OR REPLACE INTO album_artist(
|
||||||
INSERT OR REPLACE INTO album_artist(idArtist, idAlbum, strArtist)
|
idArtist, idAlbum, strArtist)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?)
|
||||||
'''
|
'''
|
||||||
)
|
|
||||||
kodicursor.execute(query, (artistid, albumid, artist_name))
|
kodicursor.execute(query, (artistid, albumid, artist_name))
|
||||||
# Update discography
|
# Update discography
|
||||||
if item.get('Album'):
|
if item.get('Album'):
|
||||||
query = (
|
query = '''
|
||||||
'''
|
INSERT OR REPLACE INTO discography(
|
||||||
INSERT OR REPLACE INTO discography(idArtist, strAlbum, strYear)
|
idArtist, strAlbum, strYear)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?)
|
||||||
'''
|
'''
|
||||||
)
|
|
||||||
kodicursor.execute(query, (artistid, item['Album'], 0))
|
kodicursor.execute(query, (artistid, item['Album'], 0))
|
||||||
# else:
|
# else:
|
||||||
if False:
|
if False:
|
||||||
album_artists = " / ".join(album_artists)
|
album_artists = " / ".join(album_artists)
|
||||||
query = ' '.join((
|
query = '''
|
||||||
|
SELECT strArtists
|
||||||
"SELECT strArtists",
|
FROM album
|
||||||
"FROM album",
|
WHERE idAlbum = ?
|
||||||
"WHERE idAlbum = ?"
|
'''
|
||||||
))
|
|
||||||
kodicursor.execute(query, (albumid,))
|
kodicursor.execute(query, (albumid,))
|
||||||
result = kodicursor.fetchone()
|
result = kodicursor.fetchone()
|
||||||
if result and result[0] != album_artists:
|
if result and result[0] != album_artists:
|
||||||
|
@ -1895,18 +1892,16 @@ class Music(Items):
|
||||||
kodicursor.execute(query, (album_artists, albumid))
|
kodicursor.execute(query, (album_artists, albumid))
|
||||||
|
|
||||||
# Add genres
|
# Add genres
|
||||||
self.kodi_db.addMusicGenres(songid, genres, "song")
|
if genres:
|
||||||
|
self.kodi_db.addMusicGenres(songid, genres, v.KODI_TYPE_SONG)
|
||||||
|
|
||||||
# Update artwork
|
# Update artwork
|
||||||
allart = API.getAllArtwork(parentInfo=True)
|
allart = API.getAllArtwork(parentInfo=True)
|
||||||
if hasEmbeddedCover:
|
artwork.addArtwork(allart, songid, v.KODI_TYPE_SONG, kodicursor)
|
||||||
allart["Primary"] = "image://music@" + artwork.single_urlencode( playurl )
|
|
||||||
artwork.addArtwork(allart, songid, "song", kodicursor)
|
|
||||||
|
|
||||||
# if item.get('AlbumId') is None:
|
|
||||||
if item.get('parentKey') is None:
|
if item.get('parentKey') is None:
|
||||||
# Update album artwork
|
# Update album artwork
|
||||||
artwork.addArtwork(allart, albumid, "album", kodicursor)
|
artwork.addArtwork(allart, albumid, v.KODI_TYPE_ALBUM, kodicursor)
|
||||||
|
|
||||||
def remove(self, itemid):
|
def remove(self, itemid):
|
||||||
# Remove kodiid, fileid, pathid, plex reference
|
# Remove kodiid, fileid, pathid, plex reference
|
||||||
|
|
1
resources/lib/library_sync/__init__.py
Normal file
1
resources/lib/library_sync/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
# Dummy file to make this directory a package.
|
88
resources/lib/library_sync/fanart.py
Normal file
88
resources/lib/library_sync/fanart.py
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from logging import getLogger
|
||||||
|
from threading import Thread
|
||||||
|
from Queue import Empty
|
||||||
|
|
||||||
|
from xbmc import sleep
|
||||||
|
|
||||||
|
from utils import ThreadMethodsAdditionalStop, ThreadMethods, window, \
|
||||||
|
ThreadMethodsAdditionalSuspend
|
||||||
|
import plexdb_functions as plexdb
|
||||||
|
import itemtypes
|
||||||
|
import variables as v
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
log = getLogger("PLEX."+__name__)
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
|
||||||
|
@ThreadMethodsAdditionalSuspend('suspend_LibraryThread')
|
||||||
|
@ThreadMethodsAdditionalStop('plex_shouldStop')
|
||||||
|
@ThreadMethods
|
||||||
|
class Process_Fanart_Thread(Thread):
|
||||||
|
"""
|
||||||
|
Threaded download of additional fanart in the background
|
||||||
|
|
||||||
|
Input:
|
||||||
|
queue Queue.Queue() object that you will need to fill with
|
||||||
|
dicts of the following form:
|
||||||
|
{
|
||||||
|
'plex_id': the Plex id as a string
|
||||||
|
'plex_type': the Plex media type, e.g. 'movie'
|
||||||
|
'refresh': True/False if True, will overwrite any 3rd party
|
||||||
|
fanart. If False, will only get missing
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
def __init__(self, queue):
|
||||||
|
self.queue = queue
|
||||||
|
Thread.__init__(self)
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""
|
||||||
|
Catch all exceptions and log them
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self.__run()
|
||||||
|
except Exception as e:
|
||||||
|
log.error('Exception %s' % e)
|
||||||
|
import traceback
|
||||||
|
log.error("Traceback:\n%s" % traceback.format_exc())
|
||||||
|
|
||||||
|
def __run(self):
|
||||||
|
"""
|
||||||
|
Do the work
|
||||||
|
"""
|
||||||
|
log.debug("---===### Starting FanartSync ###===---")
|
||||||
|
threadStopped = self.threadStopped
|
||||||
|
threadSuspended = self.threadSuspended
|
||||||
|
queue = self.queue
|
||||||
|
while not threadStopped():
|
||||||
|
# In the event the server goes offline
|
||||||
|
while threadSuspended() or window('plex_dbScan'):
|
||||||
|
# Set in service.py
|
||||||
|
if threadStopped():
|
||||||
|
# Abort was requested while waiting. We should exit
|
||||||
|
log.info("---===### Stopped FanartSync ###===---")
|
||||||
|
return
|
||||||
|
sleep(1000)
|
||||||
|
# grabs Plex item from queue
|
||||||
|
try:
|
||||||
|
item = queue.get(block=False)
|
||||||
|
except Empty:
|
||||||
|
sleep(200)
|
||||||
|
continue
|
||||||
|
|
||||||
|
log.debug('Get additional fanart for Plex id %s' % item['plex_id'])
|
||||||
|
with getattr(itemtypes,
|
||||||
|
v.ITEMTYPE_FROM_PLEXTYPE[item['plex_type']])() as cls:
|
||||||
|
result = cls.getfanart(item['plex_id'],
|
||||||
|
refresh=item['refresh'])
|
||||||
|
if result is True:
|
||||||
|
log.debug('Done getting fanart for Plex id %s'
|
||||||
|
% item['plex_id'])
|
||||||
|
with plexdb.Get_Plex_DB() as plex_db:
|
||||||
|
plex_db.set_fanart_synched(item['plex_id'])
|
||||||
|
queue.task_done()
|
||||||
|
log.debug("---===### Stopped FanartSync ###===---")
|
140
resources/lib/library_sync/get_metadata.py
Normal file
140
resources/lib/library_sync/get_metadata.py
Normal file
|
@ -0,0 +1,140 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from logging import getLogger
|
||||||
|
from threading import Thread
|
||||||
|
from Queue import Empty
|
||||||
|
|
||||||
|
from xbmc import sleep
|
||||||
|
|
||||||
|
from utils import ThreadMethodsAdditionalStop, ThreadMethods, window
|
||||||
|
from PlexFunctions import GetPlexMetadata, GetAllPlexChildren
|
||||||
|
import sync_info
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
log = getLogger("PLEX."+__name__)
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
|
||||||
|
@ThreadMethodsAdditionalStop('suspend_LibraryThread')
|
||||||
|
@ThreadMethods
|
||||||
|
class Threaded_Get_Metadata(Thread):
|
||||||
|
"""
|
||||||
|
Threaded download of Plex XML metadata for a certain library item.
|
||||||
|
Fills the out_queue with the downloaded etree XML objects
|
||||||
|
|
||||||
|
Input:
|
||||||
|
queue Queue.Queue() object that you'll need to fill up
|
||||||
|
with Plex itemIds
|
||||||
|
out_queue Queue() object where this thread will store
|
||||||
|
the downloaded metadata XMLs as etree objects
|
||||||
|
"""
|
||||||
|
def __init__(self, queue, out_queue):
|
||||||
|
self.queue = queue
|
||||||
|
self.out_queue = out_queue
|
||||||
|
Thread.__init__(self)
|
||||||
|
|
||||||
|
def terminate_now(self):
|
||||||
|
"""
|
||||||
|
Needed to terminate this thread, because there might be items left in
|
||||||
|
the queue which could cause other threads to hang
|
||||||
|
"""
|
||||||
|
while not self.queue.empty():
|
||||||
|
# Still try because remaining item might have been taken
|
||||||
|
try:
|
||||||
|
self.queue.get(block=False)
|
||||||
|
except Empty:
|
||||||
|
sleep(10)
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
self.queue.task_done()
|
||||||
|
if self.threadStopped():
|
||||||
|
# Shutdown from outside requested; purge out_queue as well
|
||||||
|
while not self.out_queue.empty():
|
||||||
|
# Still try because remaining item might have been taken
|
||||||
|
try:
|
||||||
|
self.out_queue.get(block=False)
|
||||||
|
except Empty:
|
||||||
|
sleep(10)
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
self.out_queue.task_done()
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""
|
||||||
|
Catch all exceptions and log them
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self.__run()
|
||||||
|
except Exception as e:
|
||||||
|
log.error('Exception %s' % e)
|
||||||
|
import traceback
|
||||||
|
log.error("Traceback:\n%s" % traceback.format_exc())
|
||||||
|
|
||||||
|
def __run(self):
|
||||||
|
"""
|
||||||
|
Do the work
|
||||||
|
"""
|
||||||
|
log.debug('Starting get metadata thread')
|
||||||
|
# cache local variables because it's faster
|
||||||
|
queue = self.queue
|
||||||
|
out_queue = self.out_queue
|
||||||
|
threadStopped = self.threadStopped
|
||||||
|
while threadStopped() is False:
|
||||||
|
# grabs Plex item from queue
|
||||||
|
try:
|
||||||
|
item = queue.get(block=False)
|
||||||
|
# Empty queue
|
||||||
|
except Empty:
|
||||||
|
sleep(20)
|
||||||
|
continue
|
||||||
|
# Download Metadata
|
||||||
|
xml = GetPlexMetadata(item['itemId'])
|
||||||
|
if xml is None:
|
||||||
|
# Did not receive a valid XML - skip that item for now
|
||||||
|
log.error("Could not get metadata for %s. Skipping that item "
|
||||||
|
"for now" % item['itemId'])
|
||||||
|
# Increase BOTH counters - since metadata won't be processed
|
||||||
|
with sync_info.LOCK:
|
||||||
|
sync_info.GET_METADATA_COUNT += 1
|
||||||
|
sync_info.PROCESS_METADATA_COUNT += 1
|
||||||
|
queue.task_done()
|
||||||
|
continue
|
||||||
|
elif xml == 401:
|
||||||
|
log.error('HTTP 401 returned by PMS. Too much strain? '
|
||||||
|
'Cancelling sync for now')
|
||||||
|
window('plex_scancrashed', value='401')
|
||||||
|
# Kill remaining items in queue (for main thread to cont.)
|
||||||
|
queue.task_done()
|
||||||
|
break
|
||||||
|
|
||||||
|
item['XML'] = xml
|
||||||
|
if item.get('get_children') is True:
|
||||||
|
children_xml = GetAllPlexChildren(item['itemId'])
|
||||||
|
try:
|
||||||
|
children_xml[0].attrib
|
||||||
|
except (TypeError, IndexError, AttributeError):
|
||||||
|
log.error('Could not get children for Plex id %s'
|
||||||
|
% item['itemId'])
|
||||||
|
else:
|
||||||
|
item['children'] = []
|
||||||
|
for child in children_xml:
|
||||||
|
child_xml = GetPlexMetadata(child.attrib['ratingKey'])
|
||||||
|
try:
|
||||||
|
child_xml[0].attrib
|
||||||
|
except (TypeError, IndexError, AttributeError):
|
||||||
|
log.error('Could not get child for Plex id %s'
|
||||||
|
% child.attrib['ratingKey'])
|
||||||
|
else:
|
||||||
|
item['children'].append(child_xml[0])
|
||||||
|
|
||||||
|
# place item into out queue
|
||||||
|
out_queue.put(item)
|
||||||
|
# Keep track of where we are at
|
||||||
|
with sync_info.LOCK:
|
||||||
|
sync_info.GET_METADATA_COUNT += 1
|
||||||
|
# signals to queue job is done
|
||||||
|
queue.task_done()
|
||||||
|
# Empty queue in case PKC was shut down (main thread hangs otherwise)
|
||||||
|
self.terminate_now()
|
||||||
|
log.debug('Get metadata thread terminated')
|
104
resources/lib/library_sync/process_metadata.py
Normal file
104
resources/lib/library_sync/process_metadata.py
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from logging import getLogger
|
||||||
|
from threading import Thread
|
||||||
|
from Queue import Empty
|
||||||
|
|
||||||
|
from xbmc import sleep
|
||||||
|
|
||||||
|
from utils import ThreadMethodsAdditionalStop, ThreadMethods
|
||||||
|
import itemtypes
|
||||||
|
import sync_info
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
log = getLogger("PLEX."+__name__)
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
|
||||||
|
@ThreadMethodsAdditionalStop('suspend_LibraryThread')
|
||||||
|
@ThreadMethods
|
||||||
|
class Threaded_Process_Metadata(Thread):
|
||||||
|
"""
|
||||||
|
Not yet implemented for more than 1 thread - if ever. Only to be called by
|
||||||
|
ONE thread!
|
||||||
|
Processes the XML metadata in the queue
|
||||||
|
|
||||||
|
Input:
|
||||||
|
queue: Queue.Queue() object that you'll need to fill up with
|
||||||
|
the downloaded XML eTree objects
|
||||||
|
item_type: as used to call functions in itemtypes.py e.g. 'Movies' =>
|
||||||
|
itemtypes.Movies()
|
||||||
|
"""
|
||||||
|
def __init__(self, queue, item_type):
|
||||||
|
self.queue = queue
|
||||||
|
self.item_type = item_type
|
||||||
|
Thread.__init__(self)
|
||||||
|
|
||||||
|
def terminate_now(self):
|
||||||
|
"""
|
||||||
|
Needed to terminate this thread, because there might be items left in
|
||||||
|
the queue which could cause other threads to hang
|
||||||
|
"""
|
||||||
|
while not self.queue.empty():
|
||||||
|
# Still try because remaining item might have been taken
|
||||||
|
try:
|
||||||
|
self.queue.get(block=False)
|
||||||
|
except Empty:
|
||||||
|
sleep(10)
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
self.queue.task_done()
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""
|
||||||
|
Catch all exceptions and log them
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self.__run()
|
||||||
|
except Exception as e:
|
||||||
|
log.error('Exception %s' % e)
|
||||||
|
import traceback
|
||||||
|
log.error("Traceback:\n%s" % traceback.format_exc())
|
||||||
|
|
||||||
|
def __run(self):
|
||||||
|
"""
|
||||||
|
Do the work
|
||||||
|
"""
|
||||||
|
log.debug('Processing thread started')
|
||||||
|
# Constructs the method name, e.g. itemtypes.Movies
|
||||||
|
item_fct = getattr(itemtypes, self.item_type)
|
||||||
|
# cache local variables because it's faster
|
||||||
|
queue = self.queue
|
||||||
|
threadStopped = self.threadStopped
|
||||||
|
with item_fct() as item_class:
|
||||||
|
while threadStopped() is False:
|
||||||
|
# grabs item from queue
|
||||||
|
try:
|
||||||
|
item = queue.get(block=False)
|
||||||
|
except Empty:
|
||||||
|
sleep(20)
|
||||||
|
continue
|
||||||
|
# Do the work
|
||||||
|
item_method = getattr(item_class, item['method'])
|
||||||
|
if item.get('children') is not None:
|
||||||
|
item_method(item['XML'][0],
|
||||||
|
viewtag=item['viewName'],
|
||||||
|
viewid=item['viewId'],
|
||||||
|
children=item['children'])
|
||||||
|
else:
|
||||||
|
item_method(item['XML'][0],
|
||||||
|
viewtag=item['viewName'],
|
||||||
|
viewid=item['viewId'])
|
||||||
|
# Keep track of where we are at
|
||||||
|
try:
|
||||||
|
log.debug('found child: %s'
|
||||||
|
% item['children'].attrib)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
with sync_info.LOCK:
|
||||||
|
sync_info.PROCESS_METADATA_COUNT += 1
|
||||||
|
sync_info.PROCESSING_VIEW_NAME = item['title']
|
||||||
|
queue.task_done()
|
||||||
|
self.terminate_now()
|
||||||
|
log.debug('Processing thread terminated')
|
81
resources/lib/library_sync/sync_info.py
Normal file
81
resources/lib/library_sync/sync_info.py
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from logging import getLogger
|
||||||
|
from threading import Thread, Lock
|
||||||
|
|
||||||
|
from xbmc import sleep
|
||||||
|
|
||||||
|
from utils import ThreadMethodsAdditionalStop, ThreadMethods, language as lang
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
log = getLogger("PLEX."+__name__)
|
||||||
|
|
||||||
|
GET_METADATA_COUNT = 0
|
||||||
|
PROCESS_METADATA_COUNT = 0
|
||||||
|
PROCESSING_VIEW_NAME = ''
|
||||||
|
LOCK = Lock()
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
|
||||||
|
@ThreadMethodsAdditionalStop('suspend_LibraryThread')
|
||||||
|
@ThreadMethods
|
||||||
|
class Threaded_Show_Sync_Info(Thread):
|
||||||
|
"""
|
||||||
|
Threaded class to show the Kodi statusbar of the metadata download.
|
||||||
|
|
||||||
|
Input:
|
||||||
|
dialog xbmcgui.DialogProgressBG() object to show progress
|
||||||
|
total: Total number of items to get
|
||||||
|
"""
|
||||||
|
def __init__(self, dialog, total, item_type):
|
||||||
|
self.total = total
|
||||||
|
self.dialog = dialog
|
||||||
|
self.item_type = item_type
|
||||||
|
Thread.__init__(self)
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""
|
||||||
|
Catch all exceptions and log them
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self.__run()
|
||||||
|
except Exception as e:
|
||||||
|
log.error('Exception %s' % e)
|
||||||
|
import traceback
|
||||||
|
log.error("Traceback:\n%s" % traceback.format_exc())
|
||||||
|
|
||||||
|
def __run(self):
|
||||||
|
"""
|
||||||
|
Do the work
|
||||||
|
"""
|
||||||
|
log.debug('Show sync info thread started')
|
||||||
|
# cache local variables because it's faster
|
||||||
|
total = self.total
|
||||||
|
dialog = self.dialog
|
||||||
|
threadStopped = self.threadStopped
|
||||||
|
dialog.create("%s: Sync %s: %s items"
|
||||||
|
% (lang(29999), self.item_type, str(total)),
|
||||||
|
"Starting")
|
||||||
|
|
||||||
|
total = 2 * total
|
||||||
|
totalProgress = 0
|
||||||
|
while threadStopped() is False:
|
||||||
|
with LOCK:
|
||||||
|
get_progress = GET_METADATA_COUNT
|
||||||
|
process_progress = PROCESS_METADATA_COUNT
|
||||||
|
viewName = PROCESSING_VIEW_NAME
|
||||||
|
totalProgress = get_progress + process_progress
|
||||||
|
try:
|
||||||
|
percentage = int(float(totalProgress) / float(total)*100.0)
|
||||||
|
except ZeroDivisionError:
|
||||||
|
percentage = 0
|
||||||
|
dialog.update(percentage,
|
||||||
|
message="%s downloaded. %s processed: %s"
|
||||||
|
% (get_progress,
|
||||||
|
process_progress,
|
||||||
|
viewName))
|
||||||
|
# Sleep for x milliseconds
|
||||||
|
sleep(200)
|
||||||
|
dialog.close()
|
||||||
|
log.debug('Show sync info thread terminated')
|
|
@ -3,7 +3,7 @@
|
||||||
###############################################################################
|
###############################################################################
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from threading import Thread, Lock
|
from threading import Thread
|
||||||
import Queue
|
import Queue
|
||||||
from random import shuffle
|
from random import shuffle
|
||||||
|
|
||||||
|
@ -28,6 +28,10 @@ import variables as v
|
||||||
from PlexFunctions import GetPlexMetadata, GetAllPlexLeaves, scrobble, \
|
from PlexFunctions import GetPlexMetadata, GetAllPlexLeaves, scrobble, \
|
||||||
GetPlexSectionResults, GetAllPlexChildren, GetPMSStatus
|
GetPlexSectionResults, GetAllPlexChildren, GetPMSStatus
|
||||||
import PlexAPI
|
import PlexAPI
|
||||||
|
from library_sync.get_metadata import Threaded_Get_Metadata
|
||||||
|
from library_sync.process_metadata import Threaded_Process_Metadata
|
||||||
|
import library_sync.sync_info as sync_info
|
||||||
|
from library_sync.fanart import Process_Fanart_Thread
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
|
|
||||||
|
@ -36,282 +40,6 @@ log = logging.getLogger("PLEX."+__name__)
|
||||||
###############################################################################
|
###############################################################################
|
||||||
|
|
||||||
|
|
||||||
@ThreadMethodsAdditionalStop('suspend_LibraryThread')
|
|
||||||
@ThreadMethods
|
|
||||||
class ThreadedGetMetadata(Thread):
|
|
||||||
"""
|
|
||||||
Threaded download of Plex XML metadata for a certain library item.
|
|
||||||
Fills the out_queue with the downloaded etree XML objects
|
|
||||||
|
|
||||||
Input:
|
|
||||||
queue Queue.Queue() object that you'll need to fill up
|
|
||||||
with Plex itemIds
|
|
||||||
out_queue Queue() object where this thread will store
|
|
||||||
the downloaded metadata XMLs as etree objects
|
|
||||||
lock Lock(), used for counting where we are
|
|
||||||
"""
|
|
||||||
def __init__(self, queue, out_queue, lock, processlock):
|
|
||||||
self.queue = queue
|
|
||||||
self.out_queue = out_queue
|
|
||||||
self.lock = lock
|
|
||||||
self.processlock = processlock
|
|
||||||
Thread.__init__(self)
|
|
||||||
|
|
||||||
def terminateNow(self):
|
|
||||||
while not self.queue.empty():
|
|
||||||
# Still try because remaining item might have been taken
|
|
||||||
try:
|
|
||||||
self.queue.get(block=False)
|
|
||||||
except Queue.Empty:
|
|
||||||
xbmc.sleep(10)
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
self.queue.task_done()
|
|
||||||
if self.threadStopped():
|
|
||||||
# Shutdown from outside requested; purge out_queue as well
|
|
||||||
while not self.out_queue.empty():
|
|
||||||
# Still try because remaining item might have been taken
|
|
||||||
try:
|
|
||||||
self.out_queue.get(block=False)
|
|
||||||
except Queue.Empty:
|
|
||||||
xbmc.sleep(10)
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
self.out_queue.task_done()
|
|
||||||
|
|
||||||
def run(self):
|
|
||||||
# cache local variables because it's faster
|
|
||||||
queue = self.queue
|
|
||||||
out_queue = self.out_queue
|
|
||||||
lock = self.lock
|
|
||||||
processlock = self.processlock
|
|
||||||
threadStopped = self.threadStopped
|
|
||||||
global getMetadataCount
|
|
||||||
global processMetadataCount
|
|
||||||
while threadStopped() is False:
|
|
||||||
# grabs Plex item from queue
|
|
||||||
try:
|
|
||||||
updateItem = queue.get(block=False)
|
|
||||||
# Empty queue
|
|
||||||
except Queue.Empty:
|
|
||||||
xbmc.sleep(10)
|
|
||||||
continue
|
|
||||||
# Download Metadata
|
|
||||||
plexXML = GetPlexMetadata(updateItem['itemId'])
|
|
||||||
if plexXML is None:
|
|
||||||
# Did not receive a valid XML - skip that item for now
|
|
||||||
log.warn("Could not get metadata for %s. Skipping that item "
|
|
||||||
"for now" % updateItem['itemId'])
|
|
||||||
# Increase BOTH counters - since metadata won't be processed
|
|
||||||
with lock:
|
|
||||||
getMetadataCount += 1
|
|
||||||
with processlock:
|
|
||||||
processMetadataCount += 1
|
|
||||||
queue.task_done()
|
|
||||||
continue
|
|
||||||
elif plexXML == 401:
|
|
||||||
log.warn('HTTP 401 returned by PMS. Too much strain? '
|
|
||||||
'Cancelling sync for now')
|
|
||||||
window('plex_scancrashed', value='401')
|
|
||||||
# Kill remaining items in queue (for main thread to cont.)
|
|
||||||
queue.task_done()
|
|
||||||
break
|
|
||||||
|
|
||||||
updateItem['XML'] = plexXML
|
|
||||||
# place item into out queue
|
|
||||||
out_queue.put(updateItem)
|
|
||||||
# Keep track of where we are at
|
|
||||||
with lock:
|
|
||||||
getMetadataCount += 1
|
|
||||||
# signals to queue job is done
|
|
||||||
queue.task_done()
|
|
||||||
# Empty queue in case PKC was shut down (main thread hangs otherwise)
|
|
||||||
self.terminateNow()
|
|
||||||
log.debug('Download thread terminated')
|
|
||||||
|
|
||||||
|
|
||||||
@ThreadMethodsAdditionalStop('suspend_LibraryThread')
|
|
||||||
@ThreadMethods
|
|
||||||
class ThreadedProcessMetadata(Thread):
|
|
||||||
"""
|
|
||||||
Not yet implemented - if ever. Only to be called by ONE thread!
|
|
||||||
Processes the XML metadata in the queue
|
|
||||||
|
|
||||||
Input:
|
|
||||||
queue: Queue.Queue() object that you'll need to fill up with
|
|
||||||
the downloaded XML eTree objects
|
|
||||||
itemType: as used to call functions in itemtypes.py
|
|
||||||
e.g. 'Movies' => itemtypes.Movies()
|
|
||||||
lock: Lock(), used for counting where we are
|
|
||||||
"""
|
|
||||||
def __init__(self, queue, itemType, lock):
|
|
||||||
self.queue = queue
|
|
||||||
self.lock = lock
|
|
||||||
self.itemType = itemType
|
|
||||||
Thread.__init__(self)
|
|
||||||
|
|
||||||
def terminateNow(self):
|
|
||||||
while not self.queue.empty():
|
|
||||||
# Still try because remaining item might have been taken
|
|
||||||
try:
|
|
||||||
self.queue.get(block=False)
|
|
||||||
except Queue.Empty:
|
|
||||||
xbmc.sleep(10)
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
self.queue.task_done()
|
|
||||||
|
|
||||||
def run(self):
|
|
||||||
# Constructs the method name, e.g. itemtypes.Movies
|
|
||||||
itemFkt = getattr(itemtypes, self.itemType)
|
|
||||||
# cache local variables because it's faster
|
|
||||||
queue = self.queue
|
|
||||||
lock = self.lock
|
|
||||||
threadStopped = self.threadStopped
|
|
||||||
global processMetadataCount
|
|
||||||
global processingViewName
|
|
||||||
with itemFkt() as item:
|
|
||||||
while threadStopped() is False:
|
|
||||||
# grabs item from queue
|
|
||||||
try:
|
|
||||||
updateItem = queue.get(block=False)
|
|
||||||
except Queue.Empty:
|
|
||||||
xbmc.sleep(10)
|
|
||||||
continue
|
|
||||||
# Do the work
|
|
||||||
plexitem = updateItem['XML']
|
|
||||||
method = updateItem['method']
|
|
||||||
viewName = updateItem['viewName']
|
|
||||||
viewId = updateItem['viewId']
|
|
||||||
title = updateItem['title']
|
|
||||||
itemSubFkt = getattr(item, method)
|
|
||||||
# Get the one child entry in the xml and process
|
|
||||||
for child in plexitem:
|
|
||||||
itemSubFkt(child,
|
|
||||||
viewtag=viewName,
|
|
||||||
viewid=viewId)
|
|
||||||
# Keep track of where we are at
|
|
||||||
with lock:
|
|
||||||
processMetadataCount += 1
|
|
||||||
processingViewName = title
|
|
||||||
# signals to queue job is done
|
|
||||||
queue.task_done()
|
|
||||||
# Empty queue in case PKC was shut down (main thread hangs otherwise)
|
|
||||||
self.terminateNow()
|
|
||||||
log.debug('Processing thread terminated')
|
|
||||||
|
|
||||||
|
|
||||||
@ThreadMethodsAdditionalStop('suspend_LibraryThread')
|
|
||||||
@ThreadMethods
|
|
||||||
class ThreadedShowSyncInfo(Thread):
|
|
||||||
"""
|
|
||||||
Threaded class to show the Kodi statusbar of the metadata download.
|
|
||||||
|
|
||||||
Input:
|
|
||||||
dialog xbmcgui.DialogProgressBG() object to show progress
|
|
||||||
locks = [downloadLock, processLock] Locks() to the other threads
|
|
||||||
total: Total number of items to get
|
|
||||||
"""
|
|
||||||
def __init__(self, dialog, locks, total, itemType):
|
|
||||||
self.locks = locks
|
|
||||||
self.total = total
|
|
||||||
self.dialog = dialog
|
|
||||||
self.itemType = itemType
|
|
||||||
Thread.__init__(self)
|
|
||||||
|
|
||||||
def run(self):
|
|
||||||
# cache local variables because it's faster
|
|
||||||
total = self.total
|
|
||||||
dialog = self.dialog
|
|
||||||
threadStopped = self.threadStopped
|
|
||||||
downloadLock = self.locks[0]
|
|
||||||
processLock = self.locks[1]
|
|
||||||
dialog.create("%s: Sync %s: %s items"
|
|
||||||
% (lang(29999), self.itemType, str(total)),
|
|
||||||
"Starting")
|
|
||||||
global getMetadataCount
|
|
||||||
global processMetadataCount
|
|
||||||
global processingViewName
|
|
||||||
total = 2 * total
|
|
||||||
totalProgress = 0
|
|
||||||
while threadStopped() is False:
|
|
||||||
with downloadLock:
|
|
||||||
getMetadataProgress = getMetadataCount
|
|
||||||
with processLock:
|
|
||||||
processMetadataProgress = processMetadataCount
|
|
||||||
viewName = processingViewName
|
|
||||||
totalProgress = getMetadataProgress + processMetadataProgress
|
|
||||||
try:
|
|
||||||
percentage = int(float(totalProgress) / float(total)*100.0)
|
|
||||||
except ZeroDivisionError:
|
|
||||||
percentage = 0
|
|
||||||
dialog.update(percentage,
|
|
||||||
message="%s downloaded. %s processed: %s"
|
|
||||||
% (getMetadataProgress,
|
|
||||||
processMetadataProgress,
|
|
||||||
viewName))
|
|
||||||
# Sleep for x milliseconds
|
|
||||||
xbmc.sleep(200)
|
|
||||||
dialog.close()
|
|
||||||
log.debug('Dialog Infobox thread terminated')
|
|
||||||
|
|
||||||
|
|
||||||
@ThreadMethodsAdditionalSuspend('suspend_LibraryThread')
|
|
||||||
@ThreadMethodsAdditionalStop('plex_shouldStop')
|
|
||||||
@ThreadMethods
|
|
||||||
class ProcessFanartThread(Thread):
|
|
||||||
"""
|
|
||||||
Threaded download of additional fanart in the background
|
|
||||||
|
|
||||||
Input:
|
|
||||||
queue Queue.Queue() object that you will need to fill with
|
|
||||||
dicts of the following form:
|
|
||||||
{
|
|
||||||
'plex_id': the Plex id as a string
|
|
||||||
'plex_type': the Plex media type, e.g. 'movie'
|
|
||||||
'refresh': True/False if True, will overwrite any 3rd party
|
|
||||||
fanart. If False, will only get missing
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
def __init__(self, queue):
|
|
||||||
self.queue = queue
|
|
||||||
Thread.__init__(self)
|
|
||||||
|
|
||||||
def run(self):
|
|
||||||
threadStopped = self.threadStopped
|
|
||||||
threadSuspended = self.threadSuspended
|
|
||||||
queue = self.queue
|
|
||||||
log.info("---===### Starting FanartSync ###===---")
|
|
||||||
while not threadStopped():
|
|
||||||
# In the event the server goes offline
|
|
||||||
while threadSuspended() or window('plex_dbScan'):
|
|
||||||
# Set in service.py
|
|
||||||
if threadStopped():
|
|
||||||
# Abort was requested while waiting. We should exit
|
|
||||||
log.info("---===### Stopped FanartSync ###===---")
|
|
||||||
return
|
|
||||||
xbmc.sleep(1000)
|
|
||||||
# grabs Plex item from queue
|
|
||||||
try:
|
|
||||||
item = queue.get(block=False)
|
|
||||||
except Queue.Empty:
|
|
||||||
xbmc.sleep(200)
|
|
||||||
continue
|
|
||||||
|
|
||||||
log.debug('Get additional fanart for Plex id %s' % item['plex_id'])
|
|
||||||
with getattr(itemtypes,
|
|
||||||
v.ITEMTYPE_FROM_PLEXTYPE[item['plex_type']])() as cls:
|
|
||||||
result = cls.getfanart(item['plex_id'],
|
|
||||||
refresh=item['refresh'])
|
|
||||||
if result is True:
|
|
||||||
log.debug('Done getting fanart for Plex id %s'
|
|
||||||
% item['plex_id'])
|
|
||||||
with plexdb.Get_Plex_DB() as plex_db:
|
|
||||||
plex_db.set_fanart_synched(item['plex_id'])
|
|
||||||
queue.task_done()
|
|
||||||
log.info("---===### Stopped FanartSync ###===---")
|
|
||||||
|
|
||||||
|
|
||||||
@ThreadMethodsAdditionalSuspend('suspend_LibraryThread')
|
@ThreadMethodsAdditionalSuspend('suspend_LibraryThread')
|
||||||
@ThreadMethodsAdditionalStop('plex_shouldStop')
|
@ThreadMethodsAdditionalStop('plex_shouldStop')
|
||||||
@ThreadMethods
|
@ThreadMethods
|
||||||
|
@ -330,7 +58,7 @@ class LibrarySync(Thread):
|
||||||
self.sessionKeys = []
|
self.sessionKeys = []
|
||||||
self.fanartqueue = Queue.Queue()
|
self.fanartqueue = Queue.Queue()
|
||||||
if settings('FanartTV') == 'true':
|
if settings('FanartTV') == 'true':
|
||||||
self.fanartthread = ProcessFanartThread(self.fanartqueue)
|
self.fanartthread = Process_Fanart_Thread(self.fanartqueue)
|
||||||
# How long should we wait at least to process new/changed PMS items?
|
# How long should we wait at least to process new/changed PMS items?
|
||||||
self.saftyMargin = int(settings('backgroundsync_saftyMargin'))
|
self.saftyMargin = int(settings('backgroundsync_saftyMargin'))
|
||||||
|
|
||||||
|
@ -346,7 +74,6 @@ class LibrarySync(Thread):
|
||||||
self.enableMusic = settings('enableMusic') == "true"
|
self.enableMusic = settings('enableMusic') == "true"
|
||||||
self.enableBackgroundSync = settings(
|
self.enableBackgroundSync = settings(
|
||||||
'enableBackgroundSync') == "true"
|
'enableBackgroundSync') == "true"
|
||||||
self.limitindex = int(settings('limitindex'))
|
|
||||||
|
|
||||||
# Init for replacing paths
|
# Init for replacing paths
|
||||||
window('remapSMB', value=settings('remapSMB'))
|
window('remapSMB', value=settings('remapSMB'))
|
||||||
|
@ -422,8 +149,7 @@ class LibrarySync(Thread):
|
||||||
if not view.attrib['type'] == mediatype:
|
if not view.attrib['type'] == mediatype:
|
||||||
continue
|
continue
|
||||||
libraryId = view.attrib['key']
|
libraryId = view.attrib['key']
|
||||||
items = GetAllPlexLeaves(libraryId,
|
items = GetAllPlexLeaves(libraryId)
|
||||||
containerSize=self.limitindex)
|
|
||||||
if items in (None, 401):
|
if items in (None, 401):
|
||||||
log.error("Could not download section %s"
|
log.error("Could not download section %s"
|
||||||
% view.attrib['key'])
|
% view.attrib['key'])
|
||||||
|
@ -468,9 +194,7 @@ class LibrarySync(Thread):
|
||||||
# Let the PMS process this first!
|
# Let the PMS process this first!
|
||||||
xbmc.sleep(1000)
|
xbmc.sleep(1000)
|
||||||
# Get PMS items to find the item we just changed
|
# Get PMS items to find the item we just changed
|
||||||
items = GetAllPlexLeaves(libraryId,
|
items = GetAllPlexLeaves(libraryId, lastViewedAt=timestamp)
|
||||||
lastViewedAt=timestamp,
|
|
||||||
containerSize=self.limitindex)
|
|
||||||
# Toggle watched state back
|
# Toggle watched state back
|
||||||
scrobble(plexId, 'unwatched')
|
scrobble(plexId, 'unwatched')
|
||||||
if items in (None, 401):
|
if items in (None, 401):
|
||||||
|
@ -704,8 +428,8 @@ class LibrarySync(Thread):
|
||||||
viewid=folderid,
|
viewid=folderid,
|
||||||
delete=True)
|
delete=True)
|
||||||
# Added new playlist
|
# Added new playlist
|
||||||
if (foldername not in playlists and
|
if (foldername not in playlists and mediatype in
|
||||||
mediatype in (v.PLEX_TYPE_MOVIE, v.PLEX_TYPE_SHOW)):
|
(v.PLEX_TYPE_MOVIE, v.PLEX_TYPE_SHOW)):
|
||||||
playlistXSP(mediatype,
|
playlistXSP(mediatype,
|
||||||
foldername,
|
foldername,
|
||||||
folderid,
|
folderid,
|
||||||
|
@ -730,8 +454,8 @@ class LibrarySync(Thread):
|
||||||
else:
|
else:
|
||||||
# Validate the playlist exists or recreate it
|
# Validate the playlist exists or recreate it
|
||||||
if mediatype != v.PLEX_TYPE_ARTIST:
|
if mediatype != v.PLEX_TYPE_ARTIST:
|
||||||
if (foldername not in playlists and
|
if (foldername not in playlists and mediatype in
|
||||||
mediatype in (v.PLEX_TYPE_MOVIE, v.PLEX_TYPE_SHOW)):
|
(v.PLEX_TYPE_MOVIE, v.PLEX_TYPE_SHOW)):
|
||||||
playlistXSP(mediatype,
|
playlistXSP(mediatype,
|
||||||
foldername,
|
foldername,
|
||||||
folderid,
|
folderid,
|
||||||
|
@ -781,7 +505,8 @@ class LibrarySync(Thread):
|
||||||
|
|
||||||
for view in sections:
|
for view in sections:
|
||||||
itemType = view.attrib['type']
|
itemType = view.attrib['type']
|
||||||
if itemType in (v.PLEX_TYPE_MOVIE, v.PLEX_TYPE_SHOW, v.PLEX_TYPE_PHOTO): # NOT artist for now
|
if (itemType in
|
||||||
|
(v.PLEX_TYPE_MOVIE, v.PLEX_TYPE_SHOW, v.PLEX_TYPE_PHOTO)):
|
||||||
self.sorted_views.append(view.attrib['title'])
|
self.sorted_views.append(view.attrib['title'])
|
||||||
log.debug('Sorted views: %s' % self.sorted_views)
|
log.debug('Sorted views: %s' % self.sorted_views)
|
||||||
|
|
||||||
|
@ -863,7 +588,8 @@ class LibrarySync(Thread):
|
||||||
with itemtypes.Music() as music:
|
with itemtypes.Music() as music:
|
||||||
music.remove(item['plex_id'])
|
music.remove(item['plex_id'])
|
||||||
|
|
||||||
def GetUpdatelist(self, xml, itemType, method, viewName, viewId):
|
def GetUpdatelist(self, xml, itemType, method, viewName, viewId,
|
||||||
|
get_children=False):
|
||||||
"""
|
"""
|
||||||
THIS METHOD NEEDS TO BE FAST! => e.g. no API calls
|
THIS METHOD NEEDS TO BE FAST! => e.g. no API calls
|
||||||
|
|
||||||
|
@ -876,6 +602,8 @@ class LibrarySync(Thread):
|
||||||
see itemtypes.py
|
see itemtypes.py
|
||||||
viewName: Name of the Plex view (e.g. 'My TV shows')
|
viewName: Name of the Plex view (e.g. 'My TV shows')
|
||||||
viewId: Id/Key of Plex library (e.g. '1')
|
viewId: Id/Key of Plex library (e.g. '1')
|
||||||
|
get_children: will get Plex children of the item if True,
|
||||||
|
e.g. for music albums
|
||||||
|
|
||||||
Output: self.updatelist, self.allPlexElementsId
|
Output: self.updatelist, self.allPlexElementsId
|
||||||
self.updatelist APPENDED(!!) list itemids (Plex Keys as
|
self.updatelist APPENDED(!!) list itemids (Plex Keys as
|
||||||
|
@ -910,7 +638,8 @@ class LibrarySync(Thread):
|
||||||
'viewName': viewName,
|
'viewName': viewName,
|
||||||
'viewId': viewId,
|
'viewId': viewId,
|
||||||
'title': item.attrib.get('title', 'Missing Title'),
|
'title': item.attrib.get('title', 'Missing Title'),
|
||||||
'mediaType': item.attrib.get('type')
|
'mediaType': item.attrib.get('type'),
|
||||||
|
'get_children': get_children
|
||||||
})
|
})
|
||||||
self.just_processed[itemId] = now
|
self.just_processed[itemId] = now
|
||||||
return
|
return
|
||||||
|
@ -936,7 +665,8 @@ class LibrarySync(Thread):
|
||||||
'viewName': viewName,
|
'viewName': viewName,
|
||||||
'viewId': viewId,
|
'viewId': viewId,
|
||||||
'title': item.attrib.get('title', 'Missing Title'),
|
'title': item.attrib.get('title', 'Missing Title'),
|
||||||
'mediaType': item.attrib.get('type')
|
'mediaType': item.attrib.get('type'),
|
||||||
|
'get_children': get_children
|
||||||
})
|
})
|
||||||
self.just_processed[itemId] = now
|
self.just_processed[itemId] = now
|
||||||
else:
|
else:
|
||||||
|
@ -955,7 +685,8 @@ class LibrarySync(Thread):
|
||||||
'viewName': viewName,
|
'viewName': viewName,
|
||||||
'viewId': viewId,
|
'viewId': viewId,
|
||||||
'title': item.attrib.get('title', 'Missing Title'),
|
'title': item.attrib.get('title', 'Missing Title'),
|
||||||
'mediaType': item.attrib.get('type')
|
'mediaType': item.attrib.get('type'),
|
||||||
|
'get_children': get_children
|
||||||
})
|
})
|
||||||
self.just_processed[itemId] = now
|
self.just_processed[itemId] = now
|
||||||
|
|
||||||
|
@ -980,49 +711,38 @@ class LibrarySync(Thread):
|
||||||
log.info("Starting sync threads")
|
log.info("Starting sync threads")
|
||||||
getMetadataQueue = Queue.Queue()
|
getMetadataQueue = Queue.Queue()
|
||||||
processMetadataQueue = Queue.Queue(maxsize=100)
|
processMetadataQueue = Queue.Queue(maxsize=100)
|
||||||
getMetadataLock = Lock()
|
|
||||||
processMetadataLock = Lock()
|
|
||||||
# To keep track
|
# To keep track
|
||||||
global getMetadataCount
|
sync_info.GET_METADATA_COUNT = 0
|
||||||
getMetadataCount = 0
|
sync_info.PROCESS_METADATA_COUNT = 0
|
||||||
global processMetadataCount
|
sync_info.PROCESSING_VIEW_NAME = ''
|
||||||
processMetadataCount = 0
|
|
||||||
global processingViewName
|
|
||||||
processingViewName = ''
|
|
||||||
# Populate queue: GetMetadata
|
# Populate queue: GetMetadata
|
||||||
for updateItem in self.updatelist:
|
for updateItem in self.updatelist:
|
||||||
getMetadataQueue.put(updateItem)
|
getMetadataQueue.put(updateItem)
|
||||||
# Spawn GetMetadata threads for downloading
|
# Spawn GetMetadata threads for downloading
|
||||||
threads = []
|
threads = []
|
||||||
for i in range(min(self.syncThreadNumber, itemNumber)):
|
for i in range(min(self.syncThreadNumber, itemNumber)):
|
||||||
thread = ThreadedGetMetadata(getMetadataQueue,
|
thread = Threaded_Get_Metadata(getMetadataQueue,
|
||||||
processMetadataQueue,
|
processMetadataQueue)
|
||||||
getMetadataLock,
|
|
||||||
processMetadataLock)
|
|
||||||
thread.setDaemon(True)
|
thread.setDaemon(True)
|
||||||
thread.start()
|
thread.start()
|
||||||
threads.append(thread)
|
threads.append(thread)
|
||||||
log.info("%s download threads spawned" % len(threads))
|
log.info("%s download threads spawned" % len(threads))
|
||||||
# Spawn one more thread to process Metadata, once downloaded
|
# Spawn one more thread to process Metadata, once downloaded
|
||||||
thread = ThreadedProcessMetadata(processMetadataQueue,
|
thread = Threaded_Process_Metadata(processMetadataQueue,
|
||||||
itemType,
|
itemType)
|
||||||
processMetadataLock)
|
|
||||||
thread.setDaemon(True)
|
thread.setDaemon(True)
|
||||||
thread.start()
|
thread.start()
|
||||||
threads.append(thread)
|
threads.append(thread)
|
||||||
log.info("Processing thread spawned")
|
|
||||||
# Start one thread to show sync progress ONLY for new PMS items
|
# Start one thread to show sync progress ONLY for new PMS items
|
||||||
if self.new_items_only is True and window('dbSyncIndicator') == 'true':
|
if self.new_items_only is True and window('dbSyncIndicator') == 'true':
|
||||||
dialog = xbmcgui.DialogProgressBG()
|
dialog = xbmcgui.DialogProgressBG()
|
||||||
thread = ThreadedShowSyncInfo(
|
thread = sync_info.Threaded_Show_Sync_Info(
|
||||||
dialog,
|
dialog,
|
||||||
[getMetadataLock, processMetadataLock],
|
|
||||||
itemNumber,
|
itemNumber,
|
||||||
itemType)
|
itemType)
|
||||||
thread.setDaemon(True)
|
thread.setDaemon(True)
|
||||||
thread.start()
|
thread.start()
|
||||||
threads.append(thread)
|
threads.append(thread)
|
||||||
log.info("Kodi Infobox thread spawned")
|
|
||||||
|
|
||||||
# Wait until finished
|
# Wait until finished
|
||||||
getMetadataQueue.join()
|
getMetadataQueue.join()
|
||||||
|
@ -1083,8 +803,7 @@ class LibrarySync(Thread):
|
||||||
# Get items per view
|
# Get items per view
|
||||||
viewId = view['id']
|
viewId = view['id']
|
||||||
viewName = view['name']
|
viewName = view['name']
|
||||||
all_plexmovies = GetPlexSectionResults(
|
all_plexmovies = GetPlexSectionResults(viewId, args=None)
|
||||||
viewId, args=None, containerSize=self.limitindex)
|
|
||||||
if all_plexmovies is None:
|
if all_plexmovies is None:
|
||||||
log.info("Couldnt get section items, aborting for view.")
|
log.info("Couldnt get section items, aborting for view.")
|
||||||
continue
|
continue
|
||||||
|
@ -1127,8 +846,7 @@ class LibrarySync(Thread):
|
||||||
return
|
return
|
||||||
xml = GetAllPlexLeaves(viewId,
|
xml = GetAllPlexLeaves(viewId,
|
||||||
lastViewedAt=lastViewedAt,
|
lastViewedAt=lastViewedAt,
|
||||||
updatedAt=updatedAt,
|
updatedAt=updatedAt)
|
||||||
containerSize=self.limitindex)
|
|
||||||
# Return if there are no items in PMS reply - it's faster
|
# Return if there are no items in PMS reply - it's faster
|
||||||
try:
|
try:
|
||||||
xml[0].attrib
|
xml[0].attrib
|
||||||
|
@ -1178,8 +896,7 @@ class LibrarySync(Thread):
|
||||||
# Get items per view
|
# Get items per view
|
||||||
viewId = view['id']
|
viewId = view['id']
|
||||||
viewName = view['name']
|
viewName = view['name']
|
||||||
allPlexTvShows = GetPlexSectionResults(
|
allPlexTvShows = GetPlexSectionResults(viewId)
|
||||||
viewId, containerSize=self.limitindex)
|
|
||||||
if allPlexTvShows is None:
|
if allPlexTvShows is None:
|
||||||
log.error("Error downloading show xml for view %s" % viewId)
|
log.error("Error downloading show xml for view %s" % viewId)
|
||||||
continue
|
continue
|
||||||
|
@ -1206,8 +923,7 @@ class LibrarySync(Thread):
|
||||||
if self.threadStopped():
|
if self.threadStopped():
|
||||||
return False
|
return False
|
||||||
# Grab all seasons to tvshow from PMS
|
# Grab all seasons to tvshow from PMS
|
||||||
seasons = GetAllPlexChildren(
|
seasons = GetAllPlexChildren(tvShowId)
|
||||||
tvShowId, containerSize=self.limitindex)
|
|
||||||
if seasons is None:
|
if seasons is None:
|
||||||
log.error("Error download season xml for show %s" % tvShowId)
|
log.error("Error download season xml for show %s" % tvShowId)
|
||||||
continue
|
continue
|
||||||
|
@ -1232,8 +948,7 @@ class LibrarySync(Thread):
|
||||||
if self.threadStopped():
|
if self.threadStopped():
|
||||||
return False
|
return False
|
||||||
# Grab all episodes to tvshow from PMS
|
# Grab all episodes to tvshow from PMS
|
||||||
episodes = GetAllPlexLeaves(
|
episodes = GetAllPlexLeaves(view['id'])
|
||||||
view['id'], containerSize=self.limitindex)
|
|
||||||
if episodes is None:
|
if episodes is None:
|
||||||
log.error("Error downloading episod xml for view %s"
|
log.error("Error downloading episod xml for view %s"
|
||||||
% view.get('name'))
|
% view.get('name'))
|
||||||
|
@ -1297,12 +1012,17 @@ class LibrarySync(Thread):
|
||||||
}
|
}
|
||||||
|
|
||||||
# Process artist, then album and tracks last to minimize overhead
|
# Process artist, then album and tracks last to minimize overhead
|
||||||
|
# Each album needs to be processed directly with its songs
|
||||||
|
# Remaining songs without album will be processed last
|
||||||
for kind in (v.PLEX_TYPE_ARTIST,
|
for kind in (v.PLEX_TYPE_ARTIST,
|
||||||
v.PLEX_TYPE_ALBUM,
|
v.PLEX_TYPE_ALBUM,
|
||||||
v.PLEX_TYPE_SONG):
|
v.PLEX_TYPE_SONG):
|
||||||
if self.threadStopped():
|
if self.threadStopped():
|
||||||
return False
|
return False
|
||||||
log.debug("Start processing music %s" % kind)
|
log.debug("Start processing music %s" % kind)
|
||||||
|
self.allKodiElementsId = {}
|
||||||
|
self.allPlexElementsId = {}
|
||||||
|
self.updatelist = []
|
||||||
if self.ProcessMusic(views,
|
if self.ProcessMusic(views,
|
||||||
kind,
|
kind,
|
||||||
urlArgs[kind],
|
urlArgs[kind],
|
||||||
|
@ -1326,10 +1046,8 @@ class LibrarySync(Thread):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def ProcessMusic(self, views, kind, urlArgs, method):
|
def ProcessMusic(self, views, kind, urlArgs, method):
|
||||||
self.allKodiElementsId = {}
|
# For albums, we need to look at the album's songs simultaneously
|
||||||
self.allPlexElementsId = {}
|
get_children = True if kind == v.PLEX_TYPE_ALBUM else False
|
||||||
self.updatelist = []
|
|
||||||
|
|
||||||
# Get a list of items already existing in Kodi db
|
# Get a list of items already existing in Kodi db
|
||||||
if self.compare:
|
if self.compare:
|
||||||
with plexdb.Get_Plex_DB() as plex_db:
|
with plexdb.Get_Plex_DB() as plex_db:
|
||||||
|
@ -1340,17 +1058,13 @@ class LibrarySync(Thread):
|
||||||
# Yet empty/nothing yet synched
|
# Yet empty/nothing yet synched
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
for view in views:
|
for view in views:
|
||||||
if self.threadStopped():
|
if self.threadStopped():
|
||||||
return False
|
return False
|
||||||
# Get items per view
|
# Get items per view
|
||||||
viewId = view['id']
|
itemsXML = GetPlexSectionResults(view['id'], args=urlArgs)
|
||||||
viewName = view['name']
|
|
||||||
itemsXML = GetPlexSectionResults(
|
|
||||||
viewId, args=urlArgs, containerSize=self.limitindex)
|
|
||||||
if itemsXML is None:
|
if itemsXML is None:
|
||||||
log.error("Error downloading xml for view %s" % viewId)
|
log.error("Error downloading xml for view %s" % view['id'])
|
||||||
continue
|
continue
|
||||||
elif itemsXML == 401:
|
elif itemsXML == 401:
|
||||||
return False
|
return False
|
||||||
|
@ -1358,9 +1072,9 @@ class LibrarySync(Thread):
|
||||||
self.GetUpdatelist(itemsXML,
|
self.GetUpdatelist(itemsXML,
|
||||||
'Music',
|
'Music',
|
||||||
method,
|
method,
|
||||||
viewName,
|
view['name'],
|
||||||
viewId)
|
view['id'],
|
||||||
|
get_children=get_children)
|
||||||
if self.compare:
|
if self.compare:
|
||||||
# Manual sync, process deletes
|
# Manual sync, process deletes
|
||||||
with itemtypes.Music() as Music:
|
with itemtypes.Music() as Music:
|
||||||
|
|
Loading…
Reference in a new issue