Source code for Code

# -*- coding: utf-8 -*-

# standard imports
from datetime import datetime
import os
import sys

# plex debugging
try:
    import plexhints  # noqa: F401
except ImportError:
    pass
else:  # the code is running outside of Plex
    from plexhints import plexhints_setup, update_sys_path
    plexhints_setup()  # read the plugin plist file and determine if plexhints should use elevated policy or not
    update_sys_path()  # when running outside plex, append the path

    from plexhints.agent_kit import Agent, Media  # agent kit
    from plexhints.constant_kit import CACHE_1DAY  # constant kit
    from plexhints.decorator_kit import handler  # decorator kit
    from plexhints.extras_kit import InterviewObject, OtherObject, TrailerObject  # extras kit
    from plexhints.locale_kit import Locale  # locale kit
    from plexhints.log_kit import Log  # log kit
    from plexhints.model_kit import Movie  # model kit
    from plexhints.network_kit import HTTP  # network kit
    from plexhints.object_kit import MessageContainer, MetadataSearchResult, SearchResult  # object kit
    from plexhints.parse_kit import JSON  # parse kit
    from plexhints.prefs_kit import Prefs  # prefs kit
    from plexhints.proxy_kit import Proxy  # proxy kit
    from plexhints.resource_kit import Resource  # resource kit
    from plexhints.util_kit import String  # util kit


# imports from Libraries\Shared
import requests
from typing import Optional

# local imports
if sys.version_info.major < 3:
    from default_prefs import default_prefs
    import helpers
    import igdb_helpers
    from platform_map import platform_map
else:
    from .default_prefs import default_prefs
    from . import helpers
    from . import igdb_helpers
    from .platform_map import platform_map


# create the plugin menu under applications
@handler(prefix='/applications/retroarcher', name='RetroArcher')  # todo try different thumbs
def main():
    # since plex removed menu's nothing else is needed here
    pass


[docs]def ValidatePrefs(): # type: () -> MessageContainer """ Validate plug-in preferences. This function is called when the user modifies their preferences. The developer can check the newly provided values to ensure they are correct (e.g. attempting a login to validate a username and password), and optionally return a ``MessageContainer`` to display any error information to the user. See the archived Plex documentation `Predefined functions <https://web.archive.org/web/https://dev.plexapp.com/docs/channels/basics.html#predefined-functions>`_ for more information. Returns ------- MessageContainer Success or Error message dependeing on results of validation. Examples -------- >>> ValidatePrefs() ... """ error_message = '' # start with a blank error message for key in default_prefs: try: Prefs[key] except KeyError: Log.Critical("Setting '%s' missing from 'DefaultPrefs.json'" % key) error_message += "Setting '%s' missing from 'DefaultPrefs.json'<br/>" % key else: # test all types except 'str_' as string cannot fail if key.startswith('int_'): try: int(Prefs[key]) except ValueError: Log.Error("Setting '%s' must be an integer; Value '%s'" % (key, Prefs[key])) error_message += "Setting '%s' must be an integer; Value '%s'<br/>" % (key, Prefs[key]) elif key.startswith('bool_') or key.startswith('scanner_'): if Prefs[key] is not True and Prefs[key] is not False: Log.Error("Setting '%s' must be True or False; Value '%s'" % (key, Prefs[key])) error_message += "Setting '%s' must be True or False; Value '%s'<br/>" % (key, Prefs[key]) if key.startswith('dir_'): if Prefs[key]: if not os.path.isdir(Prefs[key]): Log.Error("Setting '%s' directory does not exist; Value '%s'" % (key, Prefs[key])) error_message += "Setting '%s' directory does not exist; Value '%s'<br/>" % (key, Prefs[key]) else: Log.Error("Setting '%s' directory is blank; Value '%s'" % (key, Prefs[key])) error_message += "Setting '%s' directory is blank; Value '%s'<br/>" % (key, Prefs[key]) if key.startswith('url_'): url = Prefs[key] if url: try: status_code = requests.get(url).status_code if status_code != 200: Log.Error("Setting '%s' url returned a non 200 status code; Value '%s'" % (key, Prefs[key])) error_message += "Setting '%s' url returned a non 200 status code; Value '%s'<br/>" % ( key, Prefs[key]) except Exception as e: Log.Error("Setting '%s' url returned an exception; Exception '%s'" % (key, e)) error_message += "Setting '%s' url returned an exception; Exception '%s'<br/>" % (key, e) else: Log.Error("Setting '%s' url is blank; Value '%s'" % (key, Prefs[key])) error_message += "Setting '%s' url is blank; Value '%s'<br/>" % (key, Prefs[key]) if error_message != '': return MessageContainer(header='Error', message=error_message) else: Log.Info("DefaultPrefs.json is valid") return MessageContainer(header='Success', message='RetroArcher - Provided preference values are ok')
[docs]def SetRating(key, rating): # type: (str, float) -> None """ This function is called when the user sets the rating of a metadata item returned by the plug-in. The `key` argument will be equal to the value of the item’s ``rating_key`` attribute. See the archived Plex documentation `Predefined functions <https://web.archive.org/web/https://dev.plexapp.com/docs/channels/basics.html#predefined-functions>`_ for more information. Parameters ---------- key : str This will be equal to the value of the item’s rating_key attribute. rating : float A float between 0 and 10 specifying the item’s rating. Examples -------- >>> SetRating(key='123456', rating=8.8) ... """ Log.Debug('User rated item (rating key: %s) with rating of %s' % (key, rating))
# todo - possibly rate the item on IGDB
[docs]def Start(): # type: () -> None """ Start the plug-in. This function is called when the plug-in first starts. It can be used to perform extra initialisation tasks such as configuring the environment and setting default attributes. See the archived Plex documentation `Predefined functions <https://web.archive.org/web/https://dev.plexapp.com/docs/channels/basics.html#predefined-functions>`_ for more information. Examples -------- >>> Start() ... """ # validate prefs prefs_valid = ValidatePrefs() if prefs_valid.header == 'Error': Log.Warn('RetroArcher Metadata agent preferences are not valid.') # set cache time HTTP.CacheTime = CACHE_1DAY Log.Debug('RetroArcher Metadata agent started.')
[docs]class RetroArcher(Agent.Movies): """ Class representing the RetroArcher Plex Movie Agent. This class defines the metadata agent. See the archived Plex documentation `Defining an agent class <https://web.archive.org/web/https://dev.plexapp.com/docs/agents/basics.html#defining-an-agent-class>`_ for more information. References ---------- name : str A string defining the name of the agent for display in the GUI. languages : list A list of strings defining the languages supported by the agent. These values should be taken from the constants defined in the `Locale <https://web.archive.org/web/https://dev.plexapp.com/docs/api/localekit.html#module-Locale>`_ API. primary_provider : bool A boolean value defining whether the agent is a primary metadata provider or not. Primary providers can be selected as the main source of metadata for a particular media type. If an agent is secondary (``primary_provider`` is set to ``False``) it will only be able to contribute to data provided by another primary agent. fallback_agent : Optional[str] A string containing the identifier of another agent to use as a fallback. If none of the matches returned by an agent are a close enough match to the given set of hints, this fallback agent will be called to attempt to find a better match. accepts_from : Optional[list] A list of strings containing the identifiers of agents that can contribute secondary data to primary data provided by this agent. contributes_to : Optional[list] A list of strings containing the identifiers of primary agents that the agent can contribute secondary data to. Methods ------- search: Search for an item. update: Add or update metadata for an item. Examples -------- >>> RetroArcher() ... """ name = 'RetroArcher' languages = [ Locale.Language.English ] primary_provider = True fallback_agent = False accepts_from = [ 'com.plexapp.agents.localmedia' ] contributes_to = None
[docs] @staticmethod def search(results, media, lang, manual): # type: (SearchResult, Media.Movie, str, bool) -> Optional[SearchResult] """ Search for an item. When the media server needs an agent to perform a search, it calls the agent’s ``search`` method. See the archived Plex documentation `Searching for results to provide matches for media <https://web.archive.org/web/https://dev.plexapp.com/docs/agents/search.html>`_ for more information. Parameters ---------- results : SearchResult An empty container that the developer should populate with potential matches. media : Media.Movie An object containing hints to be used when performing the search. lang : str A string identifying the user’s currently selected language. This will be one of the constants added to the agent’s ``languages`` attribute. manual : bool A boolean value identifying whether the search was issued automatically during scanning, or manually by the user (in order to fix an incorrect match). Returns ------- Optional[SearchResult] The search result object, if the search was successful. Examples -------- >>> RetroArcher().search(results=..., media=..., lang='en', manual=True) ... """ Log.Debug('Searching with arguments: {results=%s, media=%s, lang=%s, manual=%s' % (results, media, lang, manual)) # media.name example = 'Driver You Are the Wheelman Usa V1 1' # media.title example = 'Driver - You Are the Wheelman (USA) (v1.1)' if not media.title: # cannot search if no title Log.Error('Cannot search since "media.title" is empty: %s' % media.title) return if media.title.startswith('clear-cache'): Log.Info('Clearing HTTP cache.') HTTP.ClearCache() # Clear Plex HTTP cache manually by searching a title named "clear-cache" # get the fullpath and media filename fullpath, media_filename = helpers.get_media_fullpath(media=media) # get the game version game_version = helpers.get_game_version(media_filename=media_filename) # get the game platform game_platform = helpers.get_game_platform(path=fullpath) # get the game name game_name = helpers.get_game_name(media=media, media_filename=media_filename) # get the igdb id platform_id = platform_map[game_platform]['systemIds']['igdb'] # download platform data (including list of all games for that platform) # use plex JSON kit since they handle cache automatically platform_data = JSON.ObjectFromURL(url='https://db.lizardbyte.dev/platforms/%s.json' % platform_id) for game in platform_data['games']: result_name = game['name'].encode('utf-8') result_year = None try: for release_date in game['release_dates']: try: if release_date['platform'] == platform_id and\ (result_year is None or release_date['y'] < result_year): result_year = release_date['y'] except KeyError: Log.Info('Game {%s} is missing release year for one or more regions on platform {%s}. ' 'Contribute at https://www.igdb.com' % (result_name, game_platform)) except KeyError: Log.Info('Game {%s} is missing release dates on platform {%s}. Contribute at https://www.igdb.com' % (result_name, game_platform)) result_score = int(String.LevenshteinRatio(first=game_name, second=result_name) * 100) try: # https://api-docs.igdb.com/#images result_thumb = 'https:%s' % game['cover']['url'].replace('/t_thumb/', '/t_cover_big/') except KeyError: result_thumb = None results.Append(MetadataSearchResult( id='{igdb-%s}{platform-%s}{%s}' % (game['id'], platform_id, game_version), # need many variables to build this in the update function # cannot do the following or all versions with igdb id get combined to a single entry # id = 'igdb-%s' % game['id], name=result_name, year=result_year, score=result_score, lang=lang, # no lang to get from db thumb=result_thumb )) # sort the results first by year, then by score results.Sort(attr='year') results.Sort(attr='score', descending=True) return results
[docs] @staticmethod def update(metadata, media, lang, force): # type: (Movie, Media.Movie, str, bool) -> Optional[Movie] """ Update metadata for an item. Once an item has been successfully matched, it is added to the update queue. As the framework processes queued items, it calls the ``update`` method of the relevant agents. See the archived Plex documentation `Adding metadata to media <https://web.archive.org/web/https://dev.plexapp.com/docs/agents/update.html>`_ for more information. Parameters ---------- metadata : object A pre-initialized metadata object if this is the first time the item is being updated, or the existing metadata object if the item is being refreshed. media : object An object containing information about the media hierarchy in the database. lang : str A string identifying which language should be used for the metadata. This will be one of the constants defined in the agent’s ``languages`` attribute. force : bool A boolean value identifying whether the user forced a full refresh of the metadata. If this argument is ``True``, all metadata should be refreshed, regardless of whether it has been populated previously. Examples -------- >>> RetroArcher().update(metadata=..., media=..., lang='en', force=True) ... """ Log.Debug('Updating with arguments: {metadata=%s, media=%s, lang=%s, force=%s' % (metadata, media, lang, force)) # parameters id_list = helpers.get_list_of_substrings(string_subject=metadata.id, string1='{', string2='}') if 'igdb-' not in id_list[0] or 'platform-' not in id_list[1]: Log.Critical('This item has a problem with the id, please rematch the item to correct the issue. Exiting.') return igdb_id = int(id_list[0].split('-', 1)[-1]) igdb_platform_id = int(id_list[1].split('-', 1)[-1]) game_version = id_list[2] Log.Info('IGDB id: %s' % igdb_id) Log.Info('IGDB platform-id: %s' % igdb_platform_id) Log.Info('Game version: %s' % game_version) # get the platform_name from the platform_id platform_name = None for platform, platform_data in platform_map.items(): if platform_data['systemIds']['igdb'] == igdb_platform_id: platform_name = platform Log.Info('Game platform: %s' % platform_name) break if not platform_name: # platform not found Log.Critical('Platform name not found. Exiting.') return # download game data # use plex JSON kit since they handle cache automatically game = JSON.ObjectFromURL(url='https://db.lizardbyte.dev/games/%s.json' % igdb_id) # setup missing data list missing_details = [] # title title = "%s [%s] %s" % (game['name'], platform_name, game_version) metadata.title = title.strip() Log.Info('Title: %s' % metadata.title) # summary try: metadata.summary = game['summary'] Log.Info('Summary: %s' % metadata.summary) except KeyError: metadata.summary = None missing_details.append('summary') # critic rating try: metadata.rating = game['aggregated_rating'] / 10 # critic rating if metadata.rating >= 5.0: rating_image = 'rating_up.png' else: rating_image = 'rating_down.png' # todo - image doesn't work # metadata.rating_image = '/:/plugins/dev.lizardbyte.retroarcher-plex/resources/%s' % rating_image # metadata.rating_image = R(rating_image) metadata.rating_image = Resource.ExternalPath(rating_image) Log.Info('Rating: %s' % metadata.rating) except KeyError: metadata.rating = None metadata.rating_image = None missing_details.append('aggregated_rating') # audience rating try: metadata.audience_rating = game['rating'] / 10 # audience rating if metadata.audience_rating >= 5.0: rating_image = 'rating_up.png' else: rating_image = 'rating_down.png' # todo - image doesn't work # metadata.audience_rating_image = '/:/plugins/dev.lizardbyte.retroarcher-plex/resources/%s' % rating_image # metadata.audience_rating_image = R(rating_image) metadata.audience_rating_image = Resource.ExternalPath(rating_image) Log.Info('Audience rating: %s' % metadata.audience_rating) except KeyError: metadata.audience_rating = None metadata.audience_rating_image = None missing_details.append('rating') # studio try: for company in game['involved_companies']: if company['developer'] is True: try: metadata.studio = company['company']['name'] Log.Info('Studio: %s' % metadata.studio) break except KeyError: missing_details.append('involved_companies/company_name') except KeyError: metadata.studio = None missing_details.append('involved_companies') # release_date # todo - add a way to get release date for specific version of game... for now just use earliest release date try: date = None for release_date in game['release_dates']: if release_date['platform'] == igdb_platform_id: if date: if release_date['date'] < date: # use the earliest release date on that platform metadata.year = release_date['y'] date = release_date['date'] else: metadata.year = release_date['y'] date = release_date['date'] metadata.originally_available_at = datetime.utcfromtimestamp(date) if not date: try: metadata.originally_available_at = datetime.utcfromtimestamp(game['first_release_date']) metadata.year = datetime.utcfromtimestamp(game['first_release_date']).year except KeyError: missing_details.append('first_release_date') Log.Info('Year: %s' % metadata.year) Log.Info('Originally available at: %s' % metadata.originally_available_at) except KeyError: metadata.year = None metadata.originally_available_at = None missing_details.append('release_dates/platform') # age ratings # use plex JSON kit since they handle cache automatically age_ratings_enums = JSON.ObjectFromURL(url='https://db.lizardbyte.dev/enums/age_ratings.json') preferred_age_rating_category_id = 1 # set default to ESRB for key, category in age_ratings_enums['category'].items(): if category == Prefs['enum_PreferredRatingSystem']: preferred_age_rating_category_id = int(key) rating_test_order = range(1, len(age_ratings_enums['category'])) # test in IGDB order (ESRB, PEGI, etc, etc.) rating_test_order.remove(preferred_age_rating_category_id) # remove the preferred rating category rating_test_order.insert(0, preferred_age_rating_category_id) # add the preferred rating category at the start try: found_rating = False for category_id in rating_test_order: if found_rating: break for age_rating in game['age_ratings']: metadata.content_rating = age_ratings_enums['rating'][str(age_rating['rating'])] metadata.content_rating_age = age_ratings_enums['rating_age'][str(age_rating['rating'])] if age_rating['category'] == category_id: found_rating = True # break out of the upper loop Log.Info('Content rating: %s' % metadata.content_rating) Log.Info('Content rating age: %s' % metadata.content_rating_age) break # break out of the lower loop except KeyError: metadata.content_rating = None metadata.content_rating_age = None missing_details.append('age_ratings') # posters try: poster_image = 'https:%s' % game['cover']['url'].replace('/t_thumb/', '/t_original/') metadata.posters[id_list[0]] = Proxy.Media(HTTP.Request(poster_image), sort_order=0) Log.Info('Setting poster image to: %s' % poster_image) except KeyError: missing_details.append('cover') # art art_keys = ['artworks', 'screenshots'] # add both artworks and screenshots to metadata.arts art_index = 0 for key in art_keys: try: for artwork in game[key]: art_image = 'https:%s' % artwork['url'].replace('/t_thumb/', '/t_original/') metadata.art[art_image] = Proxy.Media(HTTP.Request(art_image), sort_order=art_index) Log.Info('Adding art image: %s' % art_image) art_index += 1 except KeyError: missing_details.append(key) # genres metadata.genres.clear() genre_keys = [ ('Genre', 'genres'), ('Theme', 'themes'), ('Game Mode', 'game_modes'), ('Player Perspective', 'player_perspectives') ] for key in genre_keys: try: for genre in game[key[1]]: metadata.genres.add('%s: %s' % (key[0], genre['name'])) except KeyError: missing_details.append(key[1]) # genres (multiplayer modes) try: for version in game['multiplayer_modes']: # this is a list if version['platform'] == igdb_platform_id: for mode in version: # this is a dict try: if version[mode] is False: pass # skip these elif version[mode] is True: metadata.genres.add('Multiplayer Mode: %s' % igdb_helpers.multiplayer_mode[mode]) elif version[mode] > 0: metadata.genres.add('Multiplayer Mode: %s: %s' % (igdb_helpers.multiplayer_mode[mode], version[mode])) except KeyError: pass # skip any items not in our dictionary except KeyError: missing_details.append('multiplayer_modes') # genres (platform) metadata.genres.add("Platform: %s" % platform_name) Log.Info('Genres: %s' % metadata.genres) # collections metadata.collections.clear() try: metadata.collections.add(game['collection']['name']) except KeyError: missing_details.append('collection') # collections (franchises) try: for franchise in game['franchises']: # this is a list if franchise['name'] not in metadata.collections: metadata.collections.add(franchise['name']) except KeyError: missing_details.append('franchises') # collection (platform) if Prefs['bool_PlatformAsCollection'] is True: metadata.collections.add("Platform: %s" % platform_name) Log.Info('Collections: %s' % metadata.collections) # extras / videos extra_type_map = { 'trailer': TrailerObject, 'teaser': TrailerObject, 'gameplay': OtherObject, 'interview': InterviewObject } # todo - clear existing extra objects try: for video in game['videos']: igdb_video_name = video['name'] video_data = JSON.ObjectFromURL(url='https://db.lizardbyte.dev/videos/%s.json' % video['video_id']) video_url = 'https://www.youtube.com/watch?v=%s' % video_data['id'] video_title = video_data['snippet']['title'] video_thumbs = video_data['snippet']['thumbnails'] # iterate over a copy of the original dictionary, while modifying the original # https://stackoverflow.com/a/33815594 for thumb_key, thumb_data in dict(video_thumbs).items(): if thumb_data is None: del video_thumbs[thumb_key] # sort the thumbs by size # https://www.geeksforgeeks.org/python-sort-nested-dictionary-by-key/ video_thumbs = sorted(video_data['snippet']['thumbnails'].items(), key=lambda x: x[1]['width'], reverse=True) video_thumb = video_thumbs[0][-1]['url'] extra_method = OtherObject # set the default type, then try to match a better type for extra_type in extra_type_map: if extra_type in igdb_video_name.lower() or extra_type in video_title.lower(): extra_method = extra_type_map[extra_type] break metadata.extras.add(extra_method(title=video_title, url=video_url, thumb=video_thumb)) Log.Info('Adding extra: %s' % video_title) Log.Info('Extra video url: %s' % video_url) except KeyError: missing_details.append('videos') # actors (characters) character_enums = JSON.ObjectFromURL(url='https://db.lizardbyte.dev/enums/characters.json') metadata.roles.clear() images_first = [True, False] try: for image_order in images_first: for character in game['characters']: try: character['mug_shot']['url'] except KeyError: has_image = False else: has_image = True if has_image == image_order: # create the role object role = metadata.roles.new() role.name = character['name'] role.photo = None # reset the image try: gender = character_enums['gender'][str(character['gender'])] except KeyError: gender = None try: species = character_enums['species'][str(character['species'])] except KeyError: species = None if gender and species: role.role = '%s | %s' % (gender, species) elif gender: role.role = '%s' % gender elif species: role.role = '%s' % species Log.Info('Adding character named "%s" as "%s"' % (role.name, role.role)) if has_image: role.photo = 'https:%s' % character['mug_shot']['url'].replace('/t_thumb/', '/t_original/') Log.Info('Set character image to: %s' % role.photo) except KeyError: missing_details.append('characters') # clear these metadata.directors.clear() metadata.producers.clear() # log the missing data if missing_details: Log.Info('Game {%s} is missing the following metadata: %s. ' 'Contribute at https://www.igdb.com' % (metadata.title, missing_details)) return metadata