diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 69be6381..c7ddeeb8 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -59,6 +59,12 @@ jobs: python -m pip install --upgrade --target=./Contents/Libraries/Shared -r \ requirements.txt --no-warn-script-location + - name: Install npm packages + working-directory: Themerr-plex.bundle + run: | + npm install + mv ./node_modules ./Contents/Resources/web + - name: Build plist working-directory: Themerr-plex.bundle env: @@ -89,6 +95,7 @@ jobs: !**/Themerr-plex.bundle/Dockerfile !**/Themerr-plex.bundle/docs !**/Themerr-plex.bundle/scripts + !**/Themerr-plex.bundle/tests - name: Package Release shell: bash diff --git a/.github/workflows/localize.yml b/.github/workflows/localize.yml new file mode 100644 index 00000000..5bd3480d --- /dev/null +++ b/.github/workflows/localize.yml @@ -0,0 +1,75 @@ +--- +name: localize + +on: + push: + branches: [nightly] + paths: # prevents workflow from running unless these files change + - '.github/workflows/localize.yml' + - 'Contents/Strings/Themerr-plex.po' + - 'Contents/Code/**.py' + - 'Contents/Resources/web/templates/**' + workflow_dispatch: + +jobs: + localize: + name: Update Localization + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Install Python 2.7 + uses: LizardByte/.github/actions/setup_python2@nightly + + - name: Set up Python Dependencies + run: | + python -m pip install --upgrade pip setuptools + python -m pip install -r requirements.txt + + - name: Update Strings + run: | + python ./scripts/_locale.py --extract + + - name: git diff + run: | + # disable the pager + git config --global pager.diff false + + # print the git diff + git diff Contents/Scripts/plugger.po + + # set the variable with minimal output, replacing `\t` with ` ` + OUTPUT=$(git diff --numstat Contents/Strings/themerr-plex.po | sed -e "s#\t# #g") + echo "git_diff=${OUTPUT}" >> $GITHUB_ENV + + - name: git reset + if: ${{ env.git_diff == '1 1 Contents/Scripts/plugger.po' }} # only run if more than 1 line changed + run: | + git reset --hard + + - name: Get current date + id: date + run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT + + - name: Create/Update Pull Request + uses: peter-evans/create-pull-request@v4 + with: + add-paths: | + Contents/Scripts/*.po + token: ${{ secrets.GH_BOT_TOKEN }} # must trigger PR tests + commit-message: New localization template + branch: localize/update + delete-branch: true + base: nightly + title: New Babel Updates + body: | + Update report + - Updated ${{ steps.date.outputs.date }} + - Auto-generated by [create-pull-request][1] + + [1]: https://github.com/peter-evans/create-pull-request + labels: | + babel + l10n diff --git a/.gitignore b/.gitignore index da380237..0eaec38e 100644 --- a/.gitignore +++ b/.gitignore @@ -168,3 +168,7 @@ plexhints-temp # Remove python modules Contents/Libraries/Shared/ + +# npm +node_modules/ +package-lock.json diff --git a/Contents/Code/__init__.py b/Contents/Code/__init__.py index 30be7a3c..7b53bc7b 100644 --- a/Contents/Code/__init__.py +++ b/Contents/Code/__init__.py @@ -2,7 +2,6 @@ # standard imports import re -import sys # plex debugging try: @@ -27,16 +26,11 @@ from typing import Optional # local imports -if sys.version_info.major < 3: - from default_prefs import default_prefs - from constants import issue_url_games, issue_url_movies - from plex_api_helper import add_themes, get_plex_item, plex_listener - from youtube_dl_helper import process_youtube -else: - from .default_prefs import default_prefs - from .constants import issue_url_games, issue_url_movies - from .plex_api_helper import add_themes, get_plex_item, plex_listener - from .youtube_dl_helper import process_youtube +from default_prefs import default_prefs +from constants import contributes_to, issue_url_games, issue_url_movies +from plex_api_helper import add_themes, get_plex_item, plex_listener +from youtube_dl_helper import process_youtube +from webapp import start_server def ValidatePrefs(): @@ -112,7 +106,7 @@ def Start(): for more information. First preferences are validated using the ``ValidatePrefs()`` method. Then the ``plex_api_helper.plex_listener()`` - method is started to handle updating theme songs for the new Plex Movie agent. + method is started to handle updating theme songs for the new Plex Movie agent. Finally, the web server is started. Examples -------- @@ -124,12 +118,13 @@ def Start(): if prefs_valid.header == 'Error': Log.Warn('Themerr-plex plug-in preferences are not valid.') - Log.Debug('Themerr-plex plug-in started.') - # start watching plex plex_listener() Log.Debug('plex_listener started, watching for activity from new Plex Movie agent.') + start_server() # start the web server if it is not running + Log.Debug('plug-in started.') + @handler(prefix='/music/themerr-plex', name='Themerr-plex', thumb='attribution.png') def main(): @@ -194,12 +189,7 @@ class Themerr(Agent.Movies): primary_provider = False fallback_agent = False accepts_from = [] - contributes_to = [ - 'com.plexapp.agents.imdb', - 'com.plexapp.agents.themoviedb', - # 'com.plexapp.agents.thetvdb', # not available as movie agent - 'dev.lizardbyte.retroarcher-plex' - ] + contributes_to = contributes_to @staticmethod def search(results, media, lang, manual): diff --git a/Contents/Code/constants.py b/Contents/Code/constants.py index 55b28439..79cc983a 100644 --- a/Contents/Code/constants.py +++ b/Contents/Code/constants.py @@ -1,3 +1,30 @@ +# -*- coding: utf-8 -*- + +# standard imports +import os + +# plex debugging +try: + import plexhints # noqa: F401 +except ImportError: + pass +else: # the code is running outside of Plex + from plexhints.core_kit import Core # core kit + +app_support_directory = Core.app_support_path +plugin_identifier = 'dev.lizardbyte.themerr-plex' +plugin_support_directory = os.path.join(app_support_directory, 'Plug-in Support') +plugin_support_data_directory = os.path.join(plugin_support_directory, 'Data') +themerr_data_directory = os.path.join(plugin_support_data_directory, plugin_identifier, 'DataItems') + +contributes_to = [ + 'tv.plex.agents.movie', + 'com.plexapp.agents.imdb', + 'com.plexapp.agents.themoviedb', + # 'com.plexapp.agents.thetvdb', # not available as movie agent + 'dev.lizardbyte.retroarcher-plex' +] + guid_map = dict( imdb='imdb', tmdb='themoviedb', diff --git a/Contents/Code/default_prefs.py b/Contents/Code/default_prefs.py index 33ae7200..b597425d 100644 --- a/Contents/Code/default_prefs.py +++ b/Contents/Code/default_prefs.py @@ -5,5 +5,9 @@ int_plexapi_upload_retries_max='3', int_plexapi_upload_threads='3', str_youtube_user='', - str_youtube_passwd='' + str_youtube_passwd='', + enum_locale='en', + str_http_host='0.0.0.0', + int_http_port='9494', + bool_log_werkzeug_messages='False' ) diff --git a/Contents/Code/plex_api_helper.py b/Contents/Code/plex_api_helper.py index 2108fb8b..03dd4e1d 100644 --- a/Contents/Code/plex_api_helper.py +++ b/Contents/Code/plex_api_helper.py @@ -4,7 +4,6 @@ import hashlib import os import shutil -import sys import time import threading @@ -14,7 +13,6 @@ except ImportError: pass else: # the code is running outside of Plex - from plexhints.core_kit import Core # core kit from plexhints.log_kit import Log # log kit from plexhints.parse_kit import JSON # parse kit from plexhints.prefs_kit import Prefs # prefs kit @@ -22,7 +20,7 @@ # imports from Libraries\Shared from future.moves import queue import requests -from typing import Optional +from typing import Optional, Tuple import urllib3 from urllib3.exceptions import InsecureRequestWarning from plexapi.alert import AlertListener @@ -31,12 +29,8 @@ from plexapi.utils import reverseSearchType # local imports -if sys.version_info.major < 3: - from constants import guid_map, issue_url_movies - from youtube_dl_helper import process_youtube -else: - from .constants import guid_map, issue_url_movies - from .youtube_dl_helper import process_youtube +from constants import app_support_directory, guid_map, issue_url_movies +from youtube_dl_helper import process_youtube plex = None @@ -45,7 +39,6 @@ processing_completed = [] # constants -app_support_directory = Core.app_support_path metadata_movie_directory = os.path.join(app_support_directory, 'Metadata', 'Movies') @@ -141,6 +134,35 @@ def add_themes(rating_key, theme_files=None, theme_urls=None): return uploaded +def get_theme_upload_path(plex_item): + # type: (any) -> str + """ + Get the path to the theme upload directory. + + Get the hashed path of the theme upload directory for the item specified by the ``plex_item``. + + Parameters + ---------- + plex_item : any + The item to get the theme upload path for. + + Returns + ------- + str + The path to the theme upload directory. + + Examples + -------- + >>> get_theme_upload_path(plex_item=...) + "...bundle/Uploads/themes..." + """ + guid = plex_item.guid + full_hash = hashlib.sha1(guid).hexdigest() + theme_upload_path = os.path.join( + metadata_movie_directory, full_hash[0], full_hash[1:] + '.bundle', 'Uploads', 'themes') + return theme_upload_path + + def remove_uploaded_themes(plex_item): # type: (any) -> None """ @@ -163,10 +185,7 @@ def remove_uploaded_themes(plex_item): >>> remove_uploaded_themes(plex_item=...) ... """ - guid = plex_item.guid - full_hash = hashlib.sha1(guid).hexdigest() - theme_upload_path = os.path.join( - metadata_movie_directory, full_hash[0], full_hash[1:] + '.bundle', 'Uploads', 'themes') + theme_upload_path = get_theme_upload_path(plex_item=plex_item) if os.path.isdir(theme_upload_path): shutil.rmtree(path=theme_upload_path, ignore_errors=True, onerror=remove_uploaded_themes_error_handler) @@ -234,6 +253,50 @@ def upload_theme(plex_item, filepath=None, url=None): return False +def get_database_id(item): + # type: (any) -> Tuple[Optional[str], Optional[str]] + agent = None + database_id = None + imdb_id = None # if this gets set we need to do additional processing + + if item.guids: # guids is a blank list for items from legacy agents, only available for new agent items + agent = 'tv.plex.agents.movie' + for guid in item.guids: + split_guid = guid.id.split('://') + database = guid_map[split_guid[0]] + database_id = split_guid[1] + + if database == 'igdb': + imdb_id = database_id + database_id = None + + if database == 'themoviedb': + imdb_id = None # reset this as we won't need to process it + break + elif item.guid: + split_guid = item.guid.split('://') + agent = split_guid[0] + if agent == 'dev.lizardbyte.retroarcher-plex': + # dev.lizardbyte.retroarcher-plex://{igdb-1638}{platform-4}{(USA)}?lang=en + database_id = item.guid.split('igdb-')[1].split('}')[0] + elif agent == 'com.plexapp.agents.themoviedb': + # com.plexapp.agents.themoviedb://363088?lang=en + database_id = item.guid.split('://')[1].split('?')[0] + elif agent == 'com.plexapp.agents.imdb': + # com.plexapp.agents.imdb://tt0113189?lang=en + imdb_id = item.guid.split('://')[1].split('?')[0] + + if imdb_id: + themerr_url = 'https://app.lizardbyte.dev/ThemerrDB/%s/%s/%s.json' % ('movies', 'imdb', imdb_id) + themerr_response = requests.get(url=themerr_url) + + if themerr_response.status_code == requests.codes.ok: + themerr_json = themerr_response.json() + database_id = themerr_json['id'] + + return agent, database_id + + def get_plex_item(rating_key): # type: (int) -> any """ diff --git a/Contents/Code/webapp.py b/Contents/Code/webapp.py new file mode 100644 index 00000000..4e2f7b8c --- /dev/null +++ b/Contents/Code/webapp.py @@ -0,0 +1,340 @@ +# -*- coding: utf-8 -*- + +# future imports +from __future__ import division # fix float division for python2 + +# standard imports +import json +import logging +import os +from threading import Thread + +# plex debugging +try: + import plexhints # noqa: F401 +except ImportError: + pass +else: # the code is running outside of Plex + from plexhints.core_kit import Core # core kit + from plexhints.log_kit import Log # log kit + from plexhints.prefs_kit import Prefs # prefs kit + +# lib imports +import flask +from flask import Flask, Response, render_template, request, send_from_directory +from flask_babel import Babel +import polib +from werkzeug.utils import secure_filename + +# local imports +from constants import contributes_to, issue_url_games, issue_url_movies, plugin_identifier +from plex_api_helper import get_database_id, get_theme_upload_path, setup_plexapi + +bundle_path = Core.bundle_path +if bundle_path.endswith('test.bundle'): + # use current directory instead, to allow for testing outside of Plex + bundle_path = os.getcwd() + +# setup flask app +app = Flask( + import_name=__name__, + root_path=os.path.join(bundle_path, 'Contents', 'Resources', 'web'), + static_folder=os.path.join(bundle_path, 'Contents', 'Resources', 'web'), + template_folder=os.path.join(bundle_path, 'Contents', 'Resources', 'web', 'templates') + ) + +# remove extra lines rendered jinja templates +app.jinja_env.trim_blocks = True +app.jinja_env.lstrip_blocks = True + +# localization +babel = Babel( + app=app, + default_locale='en', + default_timezone='UTC', + default_domain='themerr-plex', + configure_jinja=True +) + +app.config['BABEL_TRANSLATION_DIRECTORIES'] = os.path.join(bundle_path, 'Contents', 'Strings') + +# setup logging for flask +Log.Info('Adding flask log handlers to plex plugin logger') + +# get the plugin logger +plugin_logger = logging.getLogger(plugin_identifier) + +# replace the app.logger handlers with the plugin logger handlers +app.logger.handlers = plugin_logger.handlers +app.logger.setLevel(plugin_logger.level) + +# test message +app.logger.info('flask app logger test message') + +try: + Prefs['bool_log_werkzeug_messages'] +except KeyError: + # this fails when building docs + pass +else: + if Prefs['bool_log_werkzeug_messages']: + # get the werkzeug logger + werkzeug_logger = logging.getLogger('werkzeug') + + # replace the werkzeug logger handlers with the plugin logger handlers + werkzeug_logger.handlers = plugin_logger.handlers + + # use the same log level as the plugin logger + werkzeug_logger.setLevel(plugin_logger.level) + + # test message + werkzeug_logger.info('werkzeug logger test message') + + +# mime type map +mime_type_map = { + 'gif': 'image/gif', + 'ico': 'image/vnd.microsoft.icon', + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'png': 'image/png', + 'svg': 'image/svg+xml', +} + + +@babel.localeselector +def get_locale(): + # type: () -> str + """ + Get the locale from the config. + + Get the locale specified in the config. This does not need to be called as it is done so automatically by `babel`. + + Returns + ------- + str + The locale. + + See Also + -------- + pyra.locales.get_locale : Use this function instead. + + Examples + -------- + >>> get_locale() + en + """ + return Prefs['enum_locale'] + + +def start_server(): + # use threading to start the flask app... or else web server seems to be killed after a couple of minutes + flask_thread = Thread( + target=app.run, + kwargs=dict( + host=Prefs['str_http_host'], + port=Prefs['int_http_port'], + debug=False, + use_reloader=False # reloader doesn't work when running in a separate thread + ) + ) + + # start flask application + flask_thread.start() + + +def stop_server(): + # stop flask server + # todo - this doesn't work + request.environ.get('werkzeug.server.shutdown') + + +@app.route('/', methods=["GET"]) +@app.route('/home', methods=["GET"]) +def home(): + # type: () -> render_template + """ + Serve the webapp home page. + + This page serves the Themerr completion report for supported Plex libraries. + + Returns + ------- + render_template + The rendered page. + + Notes + ----- + The following routes trigger this function. + + - `/` + - `/home` + + Examples + -------- + >>> home() + """ + # get all Plex items from supported metadata agents + plex_server = setup_plexapi() + plex_library = plex_server.library + + sections = plex_library.sections() + + items = dict() + + for section in sections: + if section.agent not in contributes_to: + # todo - there is a small chance that a library with an unsupported agent could still have + # a individual items that was matched with a supported agent... + continue # skip unsupported metadata agents + + # get all items in the section + all_items = section.all() + + # get all items in the section with theme songs + items_with_themes = section.all(theme__exists=True) + + # add each section to the items dict + items[section.key] = dict( + title=section.title, + agent=section.agent, + items=[], + percent_complete=int(len(items_with_themes) / len(all_items) * 100) if len(items_with_themes) else 0 + ) + + for item in all_items: + # build the issue url + database_info = get_database_id(item=item) + item_agent = database_info[0] + database_id = database_info[1] + + item_issue_url = None + if item_agent == 'dev.lizardbyte.retroarcher-plex': + issue_url = issue_url_games + elif item_agent in contributes_to: + issue_url = issue_url_movies + else: + issue_url = None + + if issue_url: + issue_title = '%s (%s)' % (item.title, item.year) + item_issue_url = issue_url % (issue_title, database_id) + + theme_status = 'missing' # default status + issue_action = 'add' # default action + + if item.theme: + theme_status = 'complete' + issue_action = 'edit' + + theme_upload_path = get_theme_upload_path(plex_item=item) + if not os.path.isdir(theme_upload_path) or not os.listdir(theme_upload_path): + theme_status = 'error' + issue_action = 'add' + + items[section.key]['items'].append(dict( + title=item.title, + agent=item_agent, + database_id=database_id, + issue_action=issue_action, + issue_url=item_issue_url, + theme=True if item.theme else False, + theme_status=theme_status, + year=item.year, + )) + + return render_template('home.html', title='Home', items=items) + + +@app.route("/", methods=["GET"]) +def image(img): + # type: (str) -> flask.send_from_directory + """ + Get image from static/images directory. + + Returns + ------- + flask.send_from_directory + The image. + + Notes + ----- + The following routes trigger this function. + + - `/favicon.ico` + + Examples + -------- + >>> image('favicon.ico') + """ + directory = os.path.join(app.static_folder, 'images') + filename = os.path.basename(secure_filename(filename=img)) # sanitize the input + + if os.path.isfile(os.path.join(directory, filename)): + file_extension = filename.rsplit('.', 1)[-1] + if file_extension in mime_type_map: + return send_from_directory(directory=directory, filename=filename, mimetype=mime_type_map[file_extension]) + else: + return Response(response='Invalid file type', status=400, mimetype='text/plain') + else: + return Response(response='Image not found', status=404, mimetype='text/plain') + + +@app.route('/status', methods=["GET"]) +def status(): + # type: () -> dict + """ + Check the status of Themerr-plex. + + This can be used to test if the plugin is still running. It could be used as part of a healthcheck for Docker, + and may have many other uses in the future. + + Returns + ------- + dict + A dictionary of the status. + + Examples + -------- + >>> status() + """ + web_status = {'result': 'success', 'message': 'Ok'} + return web_status + + +@app.route("/translations", methods=["GET"]) +def translations(): + # type: () -> Response + """ + Serve the translations. + + Returns + ------- + Response + The translations. + + Examples + -------- + >>> translations() + """ + locale = get_locale() + + po_files = [ + '%s/%s/LC_MESSAGES/plugger.po' % (app.config['BABEL_TRANSLATION_DIRECTORIES'], locale), # selected locale + '%s/plugger.po' % app.config['BABEL_TRANSLATION_DIRECTORIES'], # fallback to default domain + ] + + for po_file in po_files: + if os.path.isfile(po_file): + po = polib.pofile(po_file) + + # convert the po to json + data = dict() + for entry in po: + if entry.msgid: + data[entry.msgid] = entry.msgstr + Log.Debug('Translation: %s -> %s' % (entry.msgid, entry.msgstr)) + + return Response(response=json.dumps(data), + status=200, + mimetype='application/json') diff --git a/Contents/Code/youtube_dl_helper.py b/Contents/Code/youtube_dl_helper.py index 7a51d2e7..ef803038 100644 --- a/Contents/Code/youtube_dl_helper.py +++ b/Contents/Code/youtube_dl_helper.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- -# standard imports - # plex debugging try: import plexhints # noqa: F401 diff --git a/Contents/DefaultPrefs.json b/Contents/DefaultPrefs.json index 8ac594af..f496a37a 100644 --- a/Contents/DefaultPrefs.json +++ b/Contents/DefaultPrefs.json @@ -48,5 +48,42 @@ "default": "", "option": "hidden", "secure": "true" + }, + { + "id": "enum_locale", + "type": "enum", + "label": "Web UI Locale", + "default": "en", + "values": [ + "de", + "en", + "en_GB", + "en_US", + "es", + "fr", + "it", + "ru" + ] + }, + { + "id": "str_http_host", + "type": "text", + "label": "Web UI Host Address (requires Plex Media Server restart)", + "default": "0.0.0.0", + "secure": "false" + }, + { + "id": "int_http_port", + "type": "text", + "label": "Web UI Port (requires Plex Media Server restart)", + "default": "9494", + "secure": "false" + }, + { + "id": "bool_log_werkzeug_messages", + "type": "bool", + "label": "Log all web server messages (requires Plex Media Server restart)", + "default": "False", + "secure": "false" } ] diff --git a/Contents/Resources/favicon.ico b/Contents/Resources/favicon.ico deleted file mode 100644 index 79620bf5..00000000 Binary files a/Contents/Resources/favicon.ico and /dev/null differ diff --git a/Contents/Resources/icon-default.png b/Contents/Resources/icon-default.png index 162dee10..3d006184 100644 Binary files a/Contents/Resources/icon-default.png and b/Contents/Resources/icon-default.png differ diff --git a/Contents/Resources/web/css/custom.css b/Contents/Resources/web/css/custom.css new file mode 100644 index 00000000..5a147761 --- /dev/null +++ b/Contents/Resources/web/css/custom.css @@ -0,0 +1,165 @@ +body { + padding-top: 80px; +} + +/*Apply to elements that serve as anchors*/ +.offset-anchor { + border-top: 80px solid transparent; + margin: -80px 0 0; + -webkit-background-clip: padding-box; + -moz-background-clip: padding; + background-clip: padding-box; +} + +/*Because offset-anchor causes sections to overlap the bottom of previous ones,*/ +/*we need to put content higher so links aren't blocked by the transparent border.*/ +.container { + position: relative; + /*z-index: 1;*/ +} + +.navbar { + min-height: 80px; + border-bottom: 1px solid white; +} + +.navbar-brand { + font-size: 1.75rem; +} + +.nav-link { + font-size: 1.25rem; +} + +.nav-link-sm { + font-size: 1rem; +} + +.bg-dark { + --bs-bg-opacity: 1; + background-color: #151515 !important; +} + +.bg-dark-gray { + --bs-bg-opacity: 1; + background-color: #303436 !important; +} + +.carousel-overlay-title { + position: absolute; + top: 20vh; + right: 10vw; + padding: 1rem; + text-shadow: 2px 2px 5px black; + user-select: none; + z-index: 1; +} + +.carousel-overlay-subtitle { + position: absolute; + top: 30vh; + right: 10vw; + padding: 1rem; + text-shadow: 2px 2px 5px black; + user-select: none; + z-index: 1; +} + +.carousel-item.active, .carousel-item .view { + height: 50vh !important; +} + +.carousel-item img { + object-fit: cover; + width:100% !important; + height: 100% !important; +} + +.content h1 { + font-size: 55px; +} + +@media screen and (max-width: 320px) { + .content h1 { + font-size: 30px; + } +} + +@media screen and (min-width: 321px) and (max-width: 768px) { + .content h1 { + font-size: 30px; + } +} + +.content h5 { + font-size: 23px; + margin-left: 80px; + margin-right: 80px; +} + +@media screen and (max-width: 320px) { + .content h5 { + font-size: 18px; + margin-left: 20px; + margin-right: 20px + } +} + +@media screen and (min-width: 321px) and (max-width: 768px) { + .content h5 { + font-size: 18px; + margin-left: 20px; + margin-right: 20px; + } +} + +.feature { + display: inline-flex; + align-items: center; + justify-content: center; + height: 3rem; + width: 3rem; + font-size: 1.5rem; +} + +.widgetbot { + display: flex; + align-items: center; + justify-content: space-around; +} + +.feedback { + display: flex; + align-items: center; + justify-content: space-around; +} + +iframe.feedback { + width: 100vw; + min-height: calc(100vh - 80px - 72px); /* subtract navbar and footer height */ +} + +@media screen and (min-width: 321px) and (max-width: 768px) { + iframe.feedback { + min-height: calc(100vh - 80px - 93px); /* subtract navbar and footer height */ + } +} + +@media screen and (max-width: 320px) { + iframe.feedback { + min-height: calc(100vh - 80px - 114px); /* subtract navbar and footer height */ + } +} + +/*card image hover*/ +.hover-zoom { + overflow: hidden; +} + +.hover-zoom img { + transition: all 1.5s ease; +} + +.hover-zoom:hover img { + transform: scale(1.1); +} diff --git a/Contents/Resources/web/images/favicon.ico b/Contents/Resources/web/images/favicon.ico new file mode 100644 index 00000000..4fc332ec Binary files /dev/null and b/Contents/Resources/web/images/favicon.ico differ diff --git a/Contents/Resources/web/images/icon.png b/Contents/Resources/web/images/icon.png new file mode 100644 index 00000000..3d006184 Binary files /dev/null and b/Contents/Resources/web/images/icon.png differ diff --git a/Contents/Resources/web/js/translations.js b/Contents/Resources/web/js/translations.js new file mode 100644 index 00000000..fe1a239b --- /dev/null +++ b/Contents/Resources/web/js/translations.js @@ -0,0 +1,32 @@ +let translations = null + +let getTranslation = function(string) { + // download translations + if (translations === null) { + $.ajax({ + async: false, + url: "/translations/", + type: "GET", + dataType: "json", + success: function (result) { + translations = result + } + }) + } + + if (translations) { + try { + if (translations[string]) { + return translations[string] + } else { + return string + } + } catch (err) { + return string + } + } + else { + // could not download translations + return string + } +} diff --git a/Contents/Resources/web/templates/base.html b/Contents/Resources/web/templates/base.html new file mode 100644 index 00000000..7a0a6ad2 --- /dev/null +++ b/Contents/Resources/web/templates/base.html @@ -0,0 +1,47 @@ + + + + {% if title %} + Themerr-plex - {{ title }} + {% else %} + Themerr-plex + {% endif %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% block modals %}{% endblock %} +
+ + {% include 'navbar.html' %} + + + {% block content %}{% endblock %} +
+ {% block scripts %}{% endblock %} + + + diff --git a/Contents/Resources/web/templates/home.html b/Contents/Resources/web/templates/home.html new file mode 100644 index 00000000..197bb41b --- /dev/null +++ b/Contents/Resources/web/templates/home.html @@ -0,0 +1,128 @@ +{% extends 'base.html' %} +{% block modals %} +{% endblock modals %} + +{% block content %} +
+
+ + {# constants #} + {% set title_column_width = 'col-8' %} + {% set year_column_width = 'col-2' %} + {% set contribute_column_width = 'col-2' %} + + {% for section in items %} + +
+
+
+ +

{{ items[section]['title'] }}

+ +

+
+
+ + + {% if items[section]['percent_complete'] == 100 %} + {% set progress_bar_color = 'bg-success' %} + {% elif items[section]['percent_complete'] > 50 %} + {% set progress_bar_color = 'bg-warning' %} + {% else %} + {% set progress_bar_color = 'bg-danger' %} + {% endif %} +
+
+
+
+ {{ items[section]['percent_complete'] }}% +
+ +
+
+
+
+
+ + +
+
+
+ + + + + + + + + {% for item in items[section]['items'] %} + {% if item['theme_status'] == 'complete' %} + {% set theme_status_color = 'bg-success' %} + {% elif item['theme_status'] == 'missing' %} + {% set theme_status_color = 'bg-danger' %} + {% else %} + {% set theme_status_color = 'bg-danger' %} + {% endif %} + + {% if item['issue_action'] == 'add' %} + {% set contribute_button_color = 'btn-warning' %} + {% set contribute_button_text = _('Add') %} + {% elif item['issue_action'] == 'edit' %} + {% set contribute_button_color = 'btn-info' %} + {% set contribute_button_text = _('Edit') %} + {% else %} + {% set contribute_button_color = 'btn-danger' %} + {% endif %} + + + + + + {% endfor %} + +
{{ items[section]['title'] }}
{{ _('Title') }}{{ _('Year') }}{{ _('Contribute') }}
{{ item['title'] }}{{ item['year'] }} + {% if item['issue_action'] %} + {{ contribute_button_text }} + {% endif %} +
+
+
+
+ + +
+ {% endfor %} + +
+
+{% endblock content %} + +{% block scripts %} + + +{% endblock scripts %} diff --git a/Contents/Resources/web/templates/navbar.html b/Contents/Resources/web/templates/navbar.html new file mode 100644 index 00000000..d29ef6a9 --- /dev/null +++ b/Contents/Resources/web/templates/navbar.html @@ -0,0 +1,64 @@ + diff --git a/Contents/Resources/web/templates/translations.html b/Contents/Resources/web/templates/translations.html new file mode 100644 index 00000000..516f9176 --- /dev/null +++ b/Contents/Resources/web/templates/translations.html @@ -0,0 +1 @@ +{# This template is used to extract translations used in javascript files #} diff --git a/Contents/Strings/themerr-plex.po b/Contents/Strings/themerr-plex.po new file mode 100644 index 00000000..f69b5a21 --- /dev/null +++ b/Contents/Strings/themerr-plex.po @@ -0,0 +1,64 @@ +# Translations template for Themerr-plex. +# Copyright (C) 2023 Themerr-plex +# This file is distributed under the same license as the Themerr-plex +# project. +# FIRST AUTHOR , 2023. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: Themerr-plex v0\n" +"Report-Msgid-Bugs-To: github.com/themerr-plex\n" +"POT-Creation-Date: 2023-08-05 13:12-0400\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.9.1\n" + +#: Contents/Resources/web/templates/home.html:65 +msgid "Title" +msgstr "" + +#: Contents/Resources/web/templates/home.html:66 +msgid "Year" +msgstr "" + +#: Contents/Resources/web/templates/home.html:67 +msgid "Contribute" +msgstr "" + +#: Contents/Resources/web/templates/home.html:81 +msgid "Add" +msgstr "" + +#: Contents/Resources/web/templates/home.html:84 +msgid "Edit" +msgstr "" + +#: Contents/Resources/web/templates/navbar.html:23 +msgid "Donate" +msgstr "" + +#: Contents/Resources/web/templates/navbar.html:28 +msgid "GitHub Sponsors" +msgstr "" + +#: Contents/Resources/web/templates/navbar.html:44 +msgid "Support" +msgstr "" + +#: Contents/Resources/web/templates/navbar.html:49 +msgid "Docs" +msgstr "" + +#: Contents/Resources/web/templates/navbar.html:53 +msgid "Support Center" +msgstr "" + +#: Contents/Resources/web/templates/navbar.html:59 +msgid "Home" +msgstr "" + diff --git a/Dockerfile b/Dockerfile index 5400ceed..bdcb9513 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,6 +17,7 @@ RUN <<_DEPS set -e apt-get update -y apt-get install -y --no-install-recommends \ + npm=8.5.* \ python2=2.7.18* \ python-pip=20.3.4* apt-get clean @@ -48,6 +49,14 @@ python2 -m pip --no-python-version-warning --disable-pip-version-check install - python2 ./scripts/build_plist.py _BUILD +# setup npm and dependencies +RUN <<_NPM +#!/bin/bash +set -e +npm install +mv ./node_modules ./Contents/Resources/web +_NPM + # clean RUN <<_CLEAN #!/bin/bash diff --git a/README.rst b/README.rst index 6bd387fd..5a2c076f 100644 --- a/README.rst +++ b/README.rst @@ -10,7 +10,7 @@ Themerr-plex is a metadata agent plug-in for Plex Media Player. The plug-in adds This plugin contributes to the following metadata agents. - - Plex Movie + - Plex Movie - `tv.plex.agents.movie` - Plex Movie (Legacy) - `com.plexapp.agents.imdb` - The Movie Database - `com.plexapp.agents.themoviedb` - `RetroArcher `_ - `dev.lizardbyte.retroarcher-plex` diff --git a/crowdin.yml b/crowdin.yml new file mode 100644 index 00000000..e9165198 --- /dev/null +++ b/crowdin.yml @@ -0,0 +1,22 @@ +--- +"base_path": "." +"base_url": "https://api.crowdin.com" # optional (for Crowdin Enterprise only) +"preserve_hierarchy": false # flatten tree on crowdin +"pull_request_labels": [ + "crowdin", + "l10n" +] + +"files": [ + { + "source": "/Contents/Strings/*.po", + "translation": "/Contents/Strings/%two_letters_code%/LC_MESSAGES/%original_file_name%", + "languages_mapping": { + "two_letters_code": { + # map non-two letter codes here, left side is crowdin designation, right side is babel designation + "en-GB": "en_GB", + "en-US": "en_US" + } + } + } +] diff --git a/docs/source/about/usage.rst b/docs/source/about/usage.rst index 70b780d8..f4adee06 100644 --- a/docs/source/about/usage.rst +++ b/docs/source/about/usage.rst @@ -22,6 +22,34 @@ Minimal setup is required to use Themerr-plex. In addition to the installation, .. Attention:: It may take several minutes after completing a metadata refresh for a theme song to be available. +Web UI +------ + +A web interface is provided by the plugin. Currently the web ui only provides a couple of end points. + +/ +^ + +This endpoint will display a report showing the theme song status for each item in a library supported by Themerr-plex. +A supported library is any that has the default agent as one supported by Themerr-plex. + +The report provides an easy means to contribute to `ThemerrDB `_ by providing +`Add/Edit` buttons for items that can be added to ThemerrDB. + +/status +^^^^^^^ + +An endpoint that provides a JSON response. If a valid response is returned, Themerr-plex is running. + +**Example Response** + +.. code-block:: json + + { + "message":"Ok", + "result":"success" + } + Preferences ----------- @@ -103,3 +131,40 @@ Description Default None + +Web UI Locale +^^^^^^^^^^^^^ + +Description + The localization value to use for translations. + +Default + ``en`` + +Web UI Host Address +^^^^^^^^^^^^^^^^^^^ + +Description + The host address to bind the Web UI to. + +Default + ``0.0.0.0`` + +Web UI Port +^^^^^^^^^^^ + +Description + The port to bind the Web UI to. + +Default + ``9494`` + +Log all web server messages +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Description + If set to ``True``, all web server messages will be logged. This will include logging requests and status codes when + requesting any resource. It is recommended to keep this disabled unless debugging. + +Default + ``False`` diff --git a/docs/source/code_docs/webapp.rst b/docs/source/code_docs/webapp.rst new file mode 100644 index 00000000..1b5893d2 --- /dev/null +++ b/docs/source/code_docs/webapp.rst @@ -0,0 +1,9 @@ +:github_url: https://github.com/LizardByte/Themerr-plex/tree/nightly/Contents/Code/webapp.py + +.. include:: ../global.rst + +:modname:`webapp` +---------------------------- +.. automodule:: Code.webapp + :members: + :show-inheritance: diff --git a/docs/source/conf.py b/docs/source/conf.py index c2c6a65c..74148f9e 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -72,8 +72,8 @@ # -- Options for HTML output ------------------------------------------------- # images -html_favicon = os.path.join(root_dir, 'Contents', 'Resources', 'favicon.ico') -html_logo = os.path.join(root_dir, 'Contents', 'Resources', 'attribution.png') +html_favicon = os.path.join(root_dir, 'Contents', 'Resources', 'web', 'images', 'favicon.ico') +html_logo = os.path.join(root_dir, 'Contents', 'Resources', 'icon-default.png') # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, diff --git a/docs/source/contributing/build.rst b/docs/source/contributing/build.rst index 1334c980..20d01130 100644 --- a/docs/source/contributing/build.rst +++ b/docs/source/contributing/build.rst @@ -36,6 +36,26 @@ Build Plist python ./scripts/build_plist.py +npm dependencies +---------------- +Install nodejs and npm. Downloads available `here `_. + +Install npm dependencies. + .. code-block:: bash + + npm install + +Move modules directory. + Linux/macOS + .. code-block:: bash + + mv ./node_modules ./Contents/Resources/web + + Windows + .. code-block:: batch + + move .\node_modules .\Contents\Resources\web + Remote Build ------------ It may be beneficial to build remotely in some cases. This will enable easier building on different operating systems. diff --git a/docs/source/contributing/testing.rst b/docs/source/contributing/testing.rst index 45b74946..0e415d96 100644 --- a/docs/source/contributing/testing.rst +++ b/docs/source/contributing/testing.rst @@ -5,7 +5,7 @@ Testing Flake8 ------ -Themerr-plex uses `Flake8 `_ for enforcing consistent code styling. Flake is included +Themerr-plex uses `Flake8 `_ for enforcing consistent code styling. Flake8 is included in the ``requirements-dev.txt``. The config file for flake8 is ``.flake8``. This is already included in the root of the repo and should not be modified. diff --git a/docs/source/toc.rst b/docs/source/toc.rst index 38f5c908..96d1765e 100644 --- a/docs/source/toc.rst +++ b/docs/source/toc.rst @@ -25,4 +25,5 @@ code_docs/main code_docs/plex_api_helper + code_docs/webapp code_docs/youtube_dl_helper diff --git a/package.json b/package.json new file mode 100644 index 00000000..5412b30c --- /dev/null +++ b/package.json @@ -0,0 +1,8 @@ +{ + "dependencies": { + "@fontsource/open-sans": "4.5.13", + "@fortawesome/fontawesome-free": "6.2.0", + "bootstrap": "5.2.2", + "jquery": "3.7.0" + } +} diff --git a/requirements.txt b/requirements.txt index ea494ae1..e628e77f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,12 @@ # these requirements must support python 2.7 # it is doubtful that Plex will ever update to Python 3+ +flask==1.1.4;python_version<"3" +flask-babel==2.0.0;python_version<"3" future==0.18.3 +polib==1.2.0;python_version<"3" requests==2.27.1;python_version<"3" # 2.27 is last version supporting Python 2.7 typing==3.10.0.0 +werkzeug==1.0.1;python_version<"3" # youtube_dl is not capable or willing to create a new release so have to install from git # youtube_dl==2021.12.17 diff --git a/scripts/_locale.py b/scripts/_locale.py new file mode 100644 index 00000000..af1c4460 --- /dev/null +++ b/scripts/_locale.py @@ -0,0 +1,123 @@ +# coding=utf-8 +""" +.. + _locale.py + +Functions related to building, initializing, updating, and compiling localization translations. +""" +# standard imports +import argparse +import os +import subprocess + +project_name = 'Themerr-plex' + +script_dir = os.path.dirname(os.path.abspath(__file__)) +root_dir = os.path.dirname(script_dir) +locale_dir = os.path.join(root_dir, 'Contents', 'Strings') + +# target locales +target_locales = [ + 'de', # Deutsch + 'en', # English + 'en_GB', # English (United Kingdom) + 'en_US', # English (United States) + 'es', # español + 'fr', # français + 'it', # italiano + 'ru', # русский +] + + +def babel_extract(): + """Executes `pybabel extract` in subprocess.""" + commands = [ + 'pybabel', + 'extract', + '-F', os.path.join(script_dir, 'babel.cfg'), + '-o', os.path.join(locale_dir, '%s.po' % project_name.lower()), + '--sort-by-file', + '--msgid-bugs-address=github.com/%s' % project_name.lower(), + '--copyright-holder=%s' % project_name, + '--project=%s' % project_name, + '--version=v0', + '--add-comments=NOTE', + './Contents/Resources/web' + ] + + print(commands) + subprocess.check_output(args=commands, cwd=root_dir) + + +def babel_init(locale_code): + # type: (str) -> None + """Executes `pybabel init` in subprocess. + + :param locale_code: str - locale code + """ + commands = [ + 'pybabel', + 'init', + '-i', os.path.join(locale_dir, '%s.po' % project_name.lower()), + '-d', locale_dir, + '-D', project_name.lower(), + '-l', locale_code + ] + + print(commands) + subprocess.check_output(args=commands, cwd=root_dir) + + +def babel_update(): + """Executes `pybabel update` in subprocess.""" + commands = [ + 'pybabel', + 'update', + '-i', os.path.join(locale_dir, '%s.po' % project_name.lower()), + '-d', locale_dir, + '-D', project_name.lower(), + '--update-header-comment' + ] + + print(commands) + subprocess.check_output(args=commands, cwd=root_dir) + + +def babel_compile(): + """Executes `pybabel compile` in subprocess.""" + commands = [ + 'pybabel', + 'compile', + '-d', locale_dir, + '-D', project_name.lower() + ] + + print(commands) + subprocess.check_output(args=commands, cwd=root_dir) + + +if __name__ == '__main__': + # Set up and gather command line arguments + parser = argparse.ArgumentParser( + description='Script helps update locale translations. Translations must be done manually.') + + parser.add_argument('--extract', action='store_true', help='Extract messages from python files and templates.') + parser.add_argument('--init', action='store_true', help='Initialize any new locales specified in target locales.') + parser.add_argument('--update', action='store_true', help='Update existing locales.') + parser.add_argument('--compile', action='store_true', help='Compile translated locales.') + + args = parser.parse_args() + + if args.extract: + babel_extract() + + if args.init: + for locale_id in target_locales: + if not os.path.isdir(os.path.join(locale_dir, locale_id)): + babel_init(locale_code=locale_id) + + if args.update: + babel_update() + + if args.compile: + babel_compile() diff --git a/scripts/babel.cfg b/scripts/babel.cfg new file mode 100644 index 00000000..759e805a --- /dev/null +++ b/scripts/babel.cfg @@ -0,0 +1,2 @@ +[python: **.py] +[jinja2: **/templates/**.html] diff --git a/tests/conftest.py b/tests/conftest.py index dbdff526..100b5ccb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,6 +12,7 @@ # local imports from Code import Themerr + from Code import webapp else: raise Exception('Contents directory not found') @@ -20,3 +21,18 @@ def agent(): # type: () -> Agent return Themerr() + + +@pytest.fixture +def test_client(scope='function'): + """Create a test client for testing webapp endpoints""" + app = webapp.app + app.config['TESTING'] = True + + client = app.test_client() + + # Create a test client using the Flask application configured for testing + with client as test_client: + # Establish an application context + with app.app_context(): + yield test_client # this is where the testing happens! diff --git a/tests/functional/__init__.py b/tests/functional/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/functional/test_webapp.py b/tests/functional/test_webapp.py new file mode 100644 index 00000000..87a8ded6 --- /dev/null +++ b/tests/functional/test_webapp.py @@ -0,0 +1,41 @@ +# lib imports +import pytest + + +def test_home(test_client): + """ + WHEN the '/' page is requested (GET) + THEN check that the response is valid + + Repeat for '/home' + """ + try: + response = test_client.get('/') + except AttributeError: + pytest.skip("cannot access Plex token/server") + else: + assert response.status_code == 200 + + response = test_client.get('/home') + assert response.status_code == 200 + + +def test_favicon(test_client): + """ + WHEN the '/favicon.ico' file is requested (GET) + THEN check that the response is valid + THEN check the content type is 'image/vnd.microsoft.icon' + """ + response = test_client.get('favicon.ico') + assert response.status_code == 200 + assert response.content_type == 'image/vnd.microsoft.icon' + + +def test_status(test_client): + """ + WHEN the '/status' page is requested (GET) + THEN check that the response is valid + """ + response = test_client.get('/status') + assert response.status_code == 200 + assert response.content_type == 'application/json'