503 lines
20 KiB
Python
503 lines
20 KiB
Python
#local media assets agent
|
|
import os, string, hashlib, base64, re, plistlib, unicodedata
|
|
import config
|
|
import helpers
|
|
import localmedia
|
|
import audiohelpers
|
|
import videohelpers
|
|
|
|
from mutagen import File
|
|
from mutagen.mp4 import MP4
|
|
from mutagen.id3 import ID3
|
|
from mutagen.flac import FLAC
|
|
from mutagen.flac import Picture
|
|
from mutagen.oggvorbis import OggVorbis
|
|
|
|
PERSONAL_MEDIA_IDENTIFIER = "com.plexapp.agents.none"
|
|
|
|
GENERIC_ARTIST_NAMES = ['various artists', '[unknown artist]', 'soundtrack', 'ost', 'original sound track', 'original soundtrack', 'original broadway cast']
|
|
|
|
#####################################################################################################################
|
|
|
|
@expose
|
|
def ReadTags(f):
|
|
try:
|
|
return dict(File(f, easy=True))
|
|
except Exception, e:
|
|
Log('Error reading tags from file: %s' % f)
|
|
return {}
|
|
|
|
#####################################################################################################################
|
|
|
|
class localMediaMovie(Agent.Movies):
|
|
name = 'Local Media Assets Extended (Movies)'
|
|
languages = [Locale.Language.NoLanguage]
|
|
primary_provider = False
|
|
persist_stored_files = False
|
|
contributes_to = ['com.plexapp.agents.imdb', 'com.plexapp.agents.none']
|
|
|
|
def search(self, results, media, lang):
|
|
results.Append(MetadataSearchResult(id = 'null', score = 100))
|
|
|
|
def update(self, metadata, media, lang):
|
|
|
|
# Clear out the title to ensure stale data doesn't clobber other agents' contributions.
|
|
metadata.title = None
|
|
|
|
part = media.items[0].parts[0]
|
|
path = os.path.dirname(part.file)
|
|
|
|
# Look for local media.
|
|
try: localmedia.findAssets(metadata, media.title, [path], 'movie', media.items[0].parts)
|
|
except Exception, e:
|
|
Log('Error finding media for movie %s: %s' % (media.title, str(e)))
|
|
|
|
# Look for subtitles
|
|
for item in media.items:
|
|
for part in item.parts:
|
|
localmedia.findSubtitles(part)
|
|
|
|
# If there is an appropriate VideoHelper, use it.
|
|
video_helper = videohelpers.VideoHelpers(part.file)
|
|
if video_helper:
|
|
video_helper.process_metadata(metadata)
|
|
|
|
#####################################################################################################################
|
|
|
|
def FindUniqueSubdirs(dirs):
|
|
final_dirs = {}
|
|
for dir in dirs:
|
|
final_dirs[dir] = True
|
|
try:
|
|
parent = os.path.split(dir)[0]
|
|
final_dirs[parent] = True
|
|
try: final_dirs[os.path.split(parent)[0]] = True
|
|
except: pass
|
|
except: pass
|
|
|
|
if final_dirs.has_key(''):
|
|
del final_dirs['']
|
|
return final_dirs
|
|
|
|
class localMediaTV(Agent.TV_Shows):
|
|
name = 'Local Media Assets Extended (TV)'
|
|
languages = [Locale.Language.NoLanguage]
|
|
primary_provider = False
|
|
persist_stored_files = False
|
|
contributes_to = ['com.plexapp.agents.thetvdb', 'com.plexapp.agents.thetvdbdvdorder', 'com.plexapp.agents.none']
|
|
|
|
def search(self, results, media, lang):
|
|
results.Append(MetadataSearchResult(id = 'null', score = 100))
|
|
|
|
def update(self, metadata, media, lang):
|
|
|
|
# Clear out the title to ensure stale data doesn't clobber other agents' contributions.
|
|
metadata.title = None
|
|
|
|
# Look for media, collect directories.
|
|
dirs = {}
|
|
for s in media.seasons:
|
|
Log('Creating season %s', s)
|
|
metadata.seasons[s].index = int(s)
|
|
for e in media.seasons[s].episodes:
|
|
|
|
# Make sure metadata exists, and find sidecar media.
|
|
episodeMetadata = metadata.seasons[s].episodes[e]
|
|
episodeMedia = media.seasons[s].episodes[e].items[0]
|
|
dir = os.path.dirname(episodeMedia.parts[0].file)
|
|
dirs[dir] = True
|
|
|
|
try: localmedia.findAssets(episodeMetadata, media.title, [dir], 'episode', episodeMedia.parts)
|
|
except Exception, e:
|
|
Log('Error finding media for episode: %s' % str(e))
|
|
|
|
# Figure out the directories we should be looking in.
|
|
try: dirs = FindUniqueSubdirs(dirs)
|
|
except: dirs = []
|
|
|
|
# Look for show images.
|
|
Log("Looking for show media for %s.", metadata.title)
|
|
try: localmedia.findAssets(metadata, media.title, dirs, 'show')
|
|
except: Log("Error finding show media.")
|
|
|
|
# Look for season images.
|
|
for s in metadata.seasons:
|
|
Log('Looking for season media for %s season %s.', metadata.title, s)
|
|
try: localmedia.findAssets(metadata.seasons[s], media.title, dirs, 'season')
|
|
except: Log("Error finding season media for season %s" % s)
|
|
|
|
# Look for subtitles for each episode.
|
|
for s in media.seasons:
|
|
# If we've got a date based season, ignore it for now, otherwise it'll collide with S/E folders/XML and PMS
|
|
# prefers date-based (why?)
|
|
if int(s) < 1900 or metadata.guid.startswith(PERSONAL_MEDIA_IDENTIFIER):
|
|
for e in media.seasons[s].episodes:
|
|
for i in media.seasons[s].episodes[e].items:
|
|
|
|
# Look for subtitles.
|
|
for part in i.parts:
|
|
localmedia.findSubtitles(part)
|
|
|
|
# If there is an appropriate VideoHelper, use it.
|
|
video_helper = videohelpers.VideoHelpers(part.file)
|
|
if video_helper:
|
|
video_helper.process_metadata(metadata, episode = metadata.seasons[s].episodes[e])
|
|
else:
|
|
# Whack it in case we wrote it.
|
|
#del metadata.seasons[s]
|
|
pass
|
|
|
|
#####################################################################################################################
|
|
|
|
class localMediaArtistCommon(object):
|
|
name = 'Local Media Assets Extended (Artists)'
|
|
languages = [Locale.Language.NoLanguage]
|
|
primary_provider = False
|
|
persist_stored_files = False
|
|
|
|
def update(self, metadata, media, lang):
|
|
|
|
# Clear out the title to ensure stale data doesn't clobber other agents' contributions.
|
|
metadata.title = None
|
|
if shouldFindExtras():
|
|
extra_type_map = getExtraTypeMap()
|
|
|
|
artist_file_dirs = []
|
|
artist_extras = {}
|
|
|
|
metadata.genres.clear()
|
|
album_genres = []
|
|
# First look for track extras.
|
|
checked_tag = False
|
|
for album in media.children:
|
|
for track in album.children:
|
|
part = helpers.unicodize(track.items[0].parts[0].file)
|
|
findTrackExtra(part, extra_type_map, artist_extras)
|
|
artist_file_dirs.append(os.path.dirname(part))
|
|
|
|
audio_helper = audiohelpers.AudioHelpers(part)
|
|
if media.title.lower() not in GENERIC_ARTIST_NAMES:
|
|
if audio_helper and hasattr(audio_helper, 'get_track_genres'):
|
|
genres = audio_helper.get_track_genres()
|
|
for genre in genres:
|
|
if genre not in album_genres:
|
|
album_genres.append(genre)
|
|
|
|
# Look for artist sort field from first track.
|
|
# TODO maybe analyse all tracks and only add title_sort if they are the same.
|
|
if checked_tag == False:
|
|
checked_tag = True
|
|
if audio_helper and hasattr(audio_helper, 'get_artist_sort_title'):
|
|
artist_sort_title = audio_helper.get_artist_sort_title()
|
|
if artist_sort_title and hasattr(metadata, 'title_sort'):
|
|
metadata.title_sort = artist_sort_title
|
|
|
|
for genre in album_genres:
|
|
metadata.genres.add(genre)
|
|
|
|
# Now go through this artist's directories looking for additional extras and local art.
|
|
checked_artist_path = False
|
|
for artist_file_dir in set(artist_file_dirs):
|
|
path = helpers.unicodize(artist_file_dir)
|
|
findArtistExtras(path, extra_type_map, artist_extras, media.title)
|
|
|
|
# if name of parent dir is same as artist name, look for art there
|
|
parentdir = os.path.split(os.path.abspath(path[:-1]))[0]
|
|
name_parentdir = os.path.basename(parentdir)
|
|
if normalizeArtist(name_parentdir) == normalizeArtist(media.title) and checked_artist_path is False:
|
|
checked_artist_path = True
|
|
path_files = {}
|
|
for p in os.listdir(parentdir):
|
|
path_files[p.lower()] = p
|
|
|
|
# Look for posters and art
|
|
valid_posters = []
|
|
valid_art = []
|
|
for ext in config.ART_EXTS:
|
|
for name in config.POSTER_FILES:
|
|
file = (name + '.' + ext).lower()
|
|
if file in path_files.keys():
|
|
data = Core.storage.load(os.path.join(parentdir, path_files[file]))
|
|
poster_name = hashlib.md5(data).hexdigest()
|
|
valid_posters.append(poster_name)
|
|
if poster_name not in metadata.posters:
|
|
metadata.posters[poster_name] = Proxy.Media(data)
|
|
for name in config.ART_FILES:
|
|
file = (name + '.' + ext).lower()
|
|
if file in path_files.keys():
|
|
data = Core.storage.load(os.path.join(parentdir, path_files[file]))
|
|
art_name = hashlib.md5(data).hexdigest()
|
|
valid_art.append(art_name)
|
|
if art_name not in metadata.art:
|
|
metadata.art[art_name] = Proxy.Media(data)
|
|
|
|
for extra in sorted(artist_extras.values(), key = lambda v: (getExtraSortOrder()[type(v)], v.title)):
|
|
metadata.extras.add(extra)
|
|
|
|
|
|
class localMediaArtistLegacy(localMediaArtistCommon, Agent.Artist):
|
|
contributes_to = ['com.plexapp.agents.discogs', 'com.plexapp.agents.lastfm', 'com.plexapp.agents.plexmusic', 'com.plexapp.agents.none']
|
|
|
|
def search(self, results, media, lang):
|
|
results.Append(MetadataSearchResult(id = 'null', name=media.artist, score = 100))
|
|
|
|
|
|
class localMediaArtistModern(localMediaArtistCommon, Agent.Artist):
|
|
version = 2
|
|
contributes_to = ['com.plexapp.agents.plexmusic']
|
|
|
|
def search(self, results, tree, hints, lang='en', manual=False):
|
|
results.add(SearchResult(id='null', type='artist', parentName=hints.artist, score=100))
|
|
|
|
def update(self, metadata, media, lang='en', child_guid=None):
|
|
super(localMediaArtistModern, self).update(metadata, media, lang)
|
|
|
|
|
|
class localMediaAlbum(Agent.Album):
|
|
name = 'Local Media Assets Extended (Albums)'
|
|
languages = [Locale.Language.NoLanguage]
|
|
primary_provider = False
|
|
persist_stored_files = False
|
|
contributes_to = ['com.plexapp.agents.discogs', 'com.plexapp.agents.lastfm', 'com.plexapp.agents.plexmusic', 'com.plexapp.agents.none']
|
|
|
|
def search(self, results, media, lang):
|
|
results.Append(MetadataSearchResult(id = 'null', score = 100))
|
|
|
|
def update(self, metadata, media, lang):
|
|
|
|
find_extras = shouldFindExtras()
|
|
extra_type_map = getExtraTypeMap() if find_extras else None
|
|
updateAlbum(metadata, media, lang, find_extras, artist_extras=[], extra_type_map=extra_type_map)
|
|
|
|
|
|
def updateAlbum(metadata, media, lang, find_extras=False, artist_extras={}, extra_type_map=None):
|
|
|
|
# Clear out the title to ensure stale data doesn't clobber other agents' contributions.
|
|
metadata.title = None
|
|
|
|
# clear out genres for this album so we will get genres for all tracks in audio_helper.process_metadata(metadata)
|
|
metadata.genres.clear()
|
|
|
|
valid_posters = []
|
|
path = None
|
|
for track in media.tracks:
|
|
for item in media.tracks[track].items:
|
|
for part in item.parts:
|
|
filename = helpers.unicodize(part.file)
|
|
path = os.path.dirname(filename)
|
|
(file_root, fext) = os.path.splitext(filename)
|
|
|
|
path_files = {}
|
|
for p in os.listdir(path):
|
|
path_files[p.lower()] = p
|
|
|
|
# Look for posters
|
|
poster_files = config.POSTER_FILES + [ os.path.basename(file_root), helpers.splitPath(path)[-1] ]
|
|
for ext in config.ART_EXTS:
|
|
for name in poster_files:
|
|
file = (name + '.' + ext).lower()
|
|
if file in path_files.keys():
|
|
data = Core.storage.load(os.path.join(path, path_files[file]))
|
|
poster_name = hashlib.md5(data).hexdigest()
|
|
valid_posters.append(poster_name)
|
|
|
|
if poster_name not in metadata.posters:
|
|
metadata.posters[poster_name] = Proxy.Media(data)
|
|
Log('Local asset image added (poster): ' + file + ', for file: ' + filename)
|
|
else:
|
|
Log('Skipping local poster since its already added')
|
|
for name in config.ART_FILES:
|
|
file = (name + '.' + ext).lower()
|
|
if file in path_files.keys():
|
|
data = Core.storage.load(os.path.join(path, path_files[file]))
|
|
art_name = hashlib.md5(data).hexdigest()
|
|
if art_name not in metadata.art:
|
|
metadata.art[art_name] = Proxy.Media(data)
|
|
Log('Local asset image added (art): ' + file + ', for file: ' + filename)
|
|
else:
|
|
Log('Skipping local art since its already added')
|
|
|
|
# If there is an appropriate AudioHelper, use it.
|
|
audio_helper = audiohelpers.AudioHelpers(part.file)
|
|
if audio_helper != None:
|
|
try:
|
|
valid_posters = valid_posters + audio_helper.process_metadata(metadata)
|
|
|
|
# Album sort title.
|
|
if hasattr(audio_helper, 'get_album_sort_title'):
|
|
album_sort_title = audio_helper.get_album_sort_title()
|
|
if album_sort_title and hasattr(metadata, 'title_sort'):
|
|
metadata.title_sort = album_sort_title
|
|
|
|
if hasattr(audio_helper, 'get_track_sort_title'):
|
|
track_sort_title = audio_helper.get_track_sort_title()
|
|
track_key = media.tracks[track].guid or track
|
|
if track_sort_title and hasattr(metadata.tracks[track_key], 'title_sort'):
|
|
metadata.tracks[track_key].title_sort = track_sort_title
|
|
except:
|
|
pass
|
|
|
|
# Look for a video extra for this track.
|
|
if find_extras:
|
|
track_video = findTrackExtra(helpers.unicodize(part.file), extra_type_map)
|
|
if track_video is not None:
|
|
track_key = media.tracks[track].guid or track
|
|
metadata.tracks[track_key].extras.add(track_video)
|
|
|
|
metadata.posters.validate_keys(valid_posters)
|
|
|
|
def findTrackExtra(file_path, extra_type_map, artist_extras={}):
|
|
|
|
# Look for music videos for this track of the format: "track file name - pretty name (optional) - type (optional).ext"
|
|
file_name = os.path.basename(file_path)
|
|
file_root, file_ext = os.path.splitext(file_name)
|
|
track_videos = []
|
|
for video in [f for f in os.listdir(os.path.dirname(file_path))
|
|
if os.path.splitext(f)[1][1:].lower() in config.VIDEO_EXTS
|
|
and helpers.unicodize(f).lower().startswith(file_root.lower())]:
|
|
|
|
video_file, ext = os.path.splitext(video)
|
|
name_components = video_file.split('-')
|
|
extra_type = MusicVideoObject
|
|
if len(name_components) > 1:
|
|
type_component = re.sub(r'[ ._]+', '', name_components[-1].lower())
|
|
if type_component in extra_type_map:
|
|
extra_type = extra_type_map[type_component]
|
|
name_components.pop(-1)
|
|
|
|
# Use the video file name for the title unless we have a prettier one.
|
|
pretty_title = '-'.join(name_components).strip()
|
|
if len(pretty_title) - len(file_root) > 0:
|
|
pretty_title = pretty_title.replace(file_root, '')
|
|
if pretty_title.startswith(file_ext):
|
|
pretty_title = pretty_title[len(file_ext):]
|
|
pretty_title = re.sub(r'^[- ]+', '', pretty_title)
|
|
|
|
track_video = extra_type(title=pretty_title, file=os.path.join(os.path.dirname(file_path), video))
|
|
artist_extras[video] = track_video
|
|
|
|
if extra_type in [MusicVideoObject, LyricMusicVideoObject]:
|
|
Log('Found video %s for track: %s from file: %s' % (pretty_title, file_name, os.path.join(os.path.dirname(file_path), video)))
|
|
track_videos.append(track_video)
|
|
else:
|
|
Log('Skipping track video %s (only regular music videos allowed on tracks)' % video)
|
|
|
|
if len(track_videos) > 0:
|
|
track_videos = sorted(track_videos, key = lambda v: (getExtraSortOrder()[type(v)], v.title))
|
|
return track_videos[0]
|
|
else:
|
|
return None
|
|
|
|
|
|
def findArtistExtras(path, extra_type_map, artist_extras, artist_name):
|
|
|
|
# Look for other videos in this directory.
|
|
for video in [f for f in os.listdir(path)
|
|
if os.path.splitext(f)[1][1:].lower() in config.VIDEO_EXTS
|
|
and f not in artist_extras]:
|
|
|
|
if video not in artist_extras:
|
|
Log('Found artist video: %s' % video)
|
|
extra = parseArtistExtra(os.path.join(path, video), extra_type_map, artist_name)
|
|
if extra is not None:
|
|
artist_extras[video] = extra
|
|
|
|
# Look for artist videos in the custom path if present.
|
|
artist_name = normalizeArtist(artist_name)
|
|
music_video_path = Prefs['music_video_path']
|
|
if music_video_path is not None and len(music_video_path) > 0:
|
|
if not os.path.exists(music_video_path):
|
|
Log('The specified local music video path doesn\'t exist: %s' % music_video_path)
|
|
return
|
|
else:
|
|
local_files = [f for f in os.listdir(music_video_path)
|
|
if (os.path.splitext(f)[1][1:].lower() in config.VIDEO_EXTS or os.path.isdir(os.path.join(music_video_path, f)))
|
|
and normalizeArtist(os.path.basename(f)).startswith(artist_name)
|
|
and f not in artist_extras]
|
|
for local_file in local_files:
|
|
|
|
# Go ahead and add files directly in the specific path matching the "artist - title - type (optional).ext" convention.
|
|
if os.path.isfile(os.path.join(music_video_path, local_file)) and local_file not in artist_extras:
|
|
Log('Found artist video: %s' % local_file)
|
|
extra = parseArtistExtra(os.path.join(music_video_path, local_file), extra_type_map, artist_name)
|
|
if extra is not None:
|
|
artist_extras[local_file] = extra
|
|
|
|
# Also add all the videos in the "local video root/artist" directory if we found one.
|
|
elif os.path.isdir(os.path.join(music_video_path, local_file)) and normalizeArtist(os.path.basename(local_file)) == artist_name:
|
|
for artist_dir_file in [f for f in os.listdir(os.path.join(music_video_path, local_file))
|
|
if os.path.splitext(f)[1][1:].lower() in config.VIDEO_EXTS
|
|
and f not in artist_extras]:
|
|
if artist_dir_file not in artist_extras:
|
|
Log('Found artist video: %s' % artist_dir_file)
|
|
extra = parseArtistExtra(os.path.join(music_video_path, local_file, artist_dir_file), extra_type_map, artist_name)
|
|
if extra is not None:
|
|
artist_extras[artist_dir_file] = extra
|
|
|
|
|
|
def parseArtistExtra(path, extra_type_map, artist_name):
|
|
|
|
video_file, ext = os.path.splitext(os.path.basename(path))
|
|
name_components = video_file.split('-')
|
|
|
|
# Set the type and whack the type component from the name if we found one.
|
|
if len(name_components) > 1 and name_components[-1].lower().strip() in extra_type_map:
|
|
extra_type = extra_type_map[name_components.pop(-1).lower().strip()]
|
|
else:
|
|
extra_type = MusicVideoObject
|
|
|
|
# Only return concerts if we're new enough.
|
|
if extra_type in [ConcertVideoObject] and not Util.VersionAtLeast(Platform.ServerVersion, 0,9,12,2):
|
|
Log('Found concert, but skipping, not new enough server.')
|
|
return None
|
|
|
|
# Whack the artist name if it's the first component and we have more than one.
|
|
if len(name_components) > 1 and normalizeArtist(name_components[0]) == artist_name:
|
|
name_components.pop(0)
|
|
|
|
return extra_type(title='-'.join(name_components), file=helpers.unicodize(path))
|
|
|
|
|
|
def normalizeArtist(artist_name):
|
|
try:
|
|
u_artist_name = helpers.unicodize(artist_name)
|
|
ret = ''
|
|
for i in range(len(u_artist_name)):
|
|
if not unicodedata.category(u_artist_name[i]).startswith('P'):
|
|
ret += u_artist_name[i]
|
|
ret = ret.replace(' ', '').lower()
|
|
if len(ret) > 0:
|
|
return ret
|
|
else:
|
|
return artist_name
|
|
except Exception, e:
|
|
Log('Error normalizing artist: %s' % e)
|
|
return artist_name
|
|
|
|
|
|
def shouldFindExtras():
|
|
# Determine whether we should look for video extras.
|
|
try:
|
|
v = ConcertVideoObject()
|
|
if Util.VersionAtLeast(Platform.ServerVersion, 0,9,12,0):
|
|
find_extras = True
|
|
else:
|
|
find_extras = False
|
|
Log('Not adding extras: Server v0.9.12.0+ required')
|
|
except NameError, e:
|
|
Log('Not adding extras: Framework v2.6.2+ required')
|
|
find_extras = False
|
|
return find_extras
|
|
|
|
|
|
def getExtraTypeMap():
|
|
return {'video' : MusicVideoObject,
|
|
'live' : LiveMusicVideoObject,
|
|
'lyrics' : LyricMusicVideoObject,
|
|
'behindthescenes' : BehindTheScenesObject,
|
|
'interview' : InterviewObject,
|
|
'concert' : ConcertVideoObject }
|
|
|
|
def getExtraSortOrder():
|
|
return {MusicVideoObject : 0, LyricMusicVideoObject : 1, ConcertVideoObject : 2, LiveMusicVideoObject : 3, BehindTheScenesObject : 4, InterviewObject : 5}
|