diff --git a/.dockerignore b/.dockerignore index 20f90261..12160c90 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,7 +5,6 @@ .* # ignore directories -docs/ tests/ # ignore venv when building locally diff --git a/.flake8 b/.flake8 index 2d028b2d..2ea73951 100644 --- a/.flake8 +++ b/.flake8 @@ -1,7 +1,6 @@ [flake8] filename = - *.py, - *.pys + *.py max-line-length = 120 extend-exclude = venv/ diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 2406092a..cd39ccc9 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -3,7 +3,7 @@ name: CI on: pull_request: - branches: [master] + branches: [master, dev/standalone] # TODO: remove dev/standalone ... temporarily allow PRs to this branch types: [opened, synchronize, reopened] push: branches: [master] @@ -37,176 +37,69 @@ jobs: build: needs: - setup_release - runs-on: ubuntu-20.04 + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [windows-2019, ubuntu-20.04, macos-12] + architecture: [x64] + include: # additional runs + - os: windows-2019 + architecture: x86 steps: - name: Checkout uses: actions/checkout@v4 - with: - path: Themerr-plex.bundle - submodules: recursive - - name: Set up Python - uses: LizardByte/setup-python-action@v2024.609.5111 + - name: Setup Python + uses: actions/setup-python@v5 with: - python-version: '2.7' + python-version: '3.12' + architecture: ${{ matrix.architecture }} - - name: Patch third-party deps - if: false # disabled - shell: bash - working-directory: Themerr-plex.bundle/third-party + - name: Setup Python Dependencies run: | - patch_dir=${{ github.workspace }}/Themerr-plex.bundle/patches - - # youtube-dl patches - pushd youtube-dl - git apply -v "${patch_dir}/youtube_dl-compat.patch" - popd - - - name: Set up Python Dependencies - shell: bash - working-directory: Themerr-plex.bundle - run: | - echo "Installing Requirements" - python --version - python -m pip --no-python-version-warning --disable-pip-version-check install --upgrade pip setuptools - - # install dev requirements - python -m pip install --upgrade \ - -r requirements-build.txt \ - -r requirements-dev.txt - - python -m pip install --upgrade --target=./Contents/Libraries/Shared \ - -r requirements.txt --no-warn-script-location + python -m pip install --upgrade pip setuptools wheel + python -m pip install -r requirements-dev.txt - name: Compile Locale Translations - working-directory: Themerr-plex.bundle run: | python ./scripts/_locale.py --compile - name: Install npm packages - working-directory: Themerr-plex.bundle + shell: bash run: | + # install node_modules npm install - mv ./node_modules ./Contents/Resources/web - - name: Build plist - shell: bash - working-directory: Themerr-plex.bundle - env: - BUILD_VERSION: ${{ needs.setup_release.outputs.release_tag }} + # move node_modules directory to web directory + mv -f ./node_modules/ ./web/ + + - name: Compile Docs + working-directory: docs + run: | + make html + + - name: Build pyinstaller package run: | - python ./scripts/build_plist.py + python ./scripts/build.py - name: Package Release - shell: bash run: | - 7z \ - "-xr!*.git*" \ - "-xr!*.pyc" \ - "-xr!__pycache__" \ - "-xr!plexhints*" \ - "-xr!Themerr-plex.bundle/.*" \ - "-xr!Themerr-plex.bundle/cache.sqlite" \ - "-xr!Themerr-plex.bundle/codecov.yml" \ - "-xr!Themerr-plex.bundle/crowdin.yml" \ - "-xr!Themerr-plex.bundle/DOCKER_README.md" \ - "-xr!Themerr-plex.bundle/Dockerfile" \ - "-xr!Themerr-plex.bundle/docs" \ - "-xr!Themerr-plex.bundle/patches" \ - "-xr!Themerr-plex.bundle/scripts" \ - "-xr!Themerr-plex.bundle/tests" \ - a "./Themerr-plex.bundle.zip" "Themerr-plex.bundle" + 7z a "./Themerr-plex_${{ runner.os }}_${{ matrix.architecture }}.zip" "dist" mkdir artifacts - mv ./Themerr-plex.bundle.zip ./artifacts/ + mv "./Themerr-plex_${{ runner.os }}_${{ matrix.architecture }}.zip" ./artifacts/ - name: Upload Artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v4 # https://github.com/actions/upload-artifact with: - name: Themerr-plex.bundle + name: Themerr-plex_${{ runner.os }}_${{ matrix.architecture }} if-no-files-found: error # 'warn' or 'ignore' are also available, defaults to `warn` path: | ${{ github.workspace }}/artifacts - - name: Create/Update GitHub Release - if: ${{ needs.setup_release.outputs.publish_release == 'true' }} - uses: LizardByte/create-release-action@v2024.919.143026 - with: - allowUpdates: true - body: ${{ needs.setup_release.outputs.release_body }} - generateReleaseNotes: ${{ needs.setup_release.outputs.release_generate_release_notes }} - name: ${{ needs.setup_release.outputs.release_tag }} - prerelease: true - tag: ${{ needs.setup_release.outputs.release_tag }} - token: ${{ secrets.GH_BOT_TOKEN }} - - pytest: - needs: [build] - strategy: - fail-fast: false - matrix: - os: [windows-latest, ubuntu-latest, macos-latest] - - runs-on: ${{ matrix.os }} - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Download artifacts - uses: actions/download-artifact@v4 - with: - name: Themerr-plex.bundle - - - name: Extract artifacts zip - shell: bash - run: | - # extract zip - 7z x Themerr-plex.bundle.zip -o. - - # move all files from "Themerr-plex.bundle" to root, with no target directory - cp -r ./Themerr-plex.bundle/. . - - # remove zip - rm Themerr-plex.bundle.zip - - - name: Set up Python - uses: LizardByte/setup-python-action@v2024.609.5111 - with: - python-version: '2.7' - - - name: Bootstrap Plex server - env: - PLEXAPI_PLEXAPI_TIMEOUT: "60" - id: bootstrap - uses: LizardByte/plexhints@v2024.809.14117 - with: - additional_server_queries: >- - put|/system/agents/com.plexapp.agents.imdb/config/1?order=com.plexapp.agents.imdb%2Cdev.lizardbyte.themerr-plex - put|/system/agents/com.plexapp.agents.themoviedb/config/1?order=com.plexapp.agents.themoviedb%2Cdev.lizardbyte.themerr-plex - put|/system/agents/com.plexapp.agents.themoviedb/config/2?order=com.plexapp.agents.themoviedb%2Cdev.lizardbyte.themerr-plex - put|/system/agents/com.plexapp.agents.thetvdb/config/2?order=com.plexapp.agents.thetvdb%2Cdev.lizardbyte.themerr-plex - get|/:/plugins/dev.lizardbyte.themerr-plex/prefs/set?bool_overwrite_plex_provided_themes=true - plugin_bundles_to_install: >- - Themerr-plex.bundle - without_music: true - without_photos: true - - - name: Install python dependencies - shell: bash - run: | - python -m pip --no-python-version-warning --disable-pip-version-check install --upgrade \ - pip setuptools wheel - python -m pip --no-python-version-warning --disable-pip-version-check install --no-build-isolation \ - -r requirements-dev.txt - - name: Test with pytest - env: - PLEX_PLUGIN_LOG_PATH: ${{ steps.bootstrap.outputs.PLEX_PLUGIN_LOG_PATH }} - PLEXAPI_AUTH_SERVER_BASEURL: ${{ steps.bootstrap.outputs.PLEX_SERVER_BASEURL }} - PLEXAPI_AUTH_SERVER_TOKEN: ${{ steps.bootstrap.outputs.PLEXTOKEN }} - PLEXAPI_PLEXAPI_TIMEOUT: "60" - PLEXTOKEN: ${{ steps.bootstrap.outputs.PLEXTOKEN }} id: test shell: bash run: | @@ -215,30 +108,25 @@ jobs: --tb=native \ --verbose \ --color=yes \ - --cov=Contents/Code \ + --cov=src \ tests - - name: Debug log file - if: always() - shell: bash - run: | - echo "Debugging log file" - if [[ "${{ runner.os }}" == "Windows" ]]; then - log_file=$(cygpath.exe -u \ - "${{ steps.bootstrap.outputs.PLEX_PLUGIN_LOG_PATH }}/dev.lizardbyte.themerr-plex.log") - else - log_file="${{ steps.bootstrap.outputs.PLEX_PLUGIN_LOG_PATH }}/dev.lizardbyte.themerr-plex.log" - fi - cat "${log_file}" - - name: Upload coverage - # any except canceled or skipped - if: >- - always() && - (steps.test.outcome == 'success' || steps.test.outcome == 'failure') && - startsWith(github.repository, 'LizardByte/') uses: codecov/codecov-action@v4 with: fail_ci_if_error: true - flags: ${{ runner.os }} + flags: "${{ runner.os }}-${{ matrix.architecture }}" token: ${{ secrets.CODECOV_TOKEN }} + verbose: true + + - name: Create/Update GitHub Release + if: ${{ needs.setup_release.outputs.publish_release == 'true' }} + uses: LizardByte/create-release-action@v2024.919.143026 + with: + allowUpdates: true + body: ${{ needs.setup_release.outputs.release_body }} + generateReleaseNotes: ${{ needs.setup_release.outputs.release_generate_release_notes }} + name: ${{ needs.setup_release.outputs.release_tag }} + prerelease: true + tag: ${{ needs.setup_release.outputs.release_tag }} + token: ${{ secrets.GH_BOT_TOKEN }} diff --git a/.github/workflows/localize.yml b/.github/workflows/localize.yml index c2b0bc6a..9c5c14a4 100644 --- a/.github/workflows/localize.yml +++ b/.github/workflows/localize.yml @@ -6,9 +6,9 @@ on: branches: [master] 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/**' + - 'locale/themerr-plex.po' + - 'src/**.py' + - 'web/templates/**' workflow_dispatch: jobs: @@ -19,18 +19,16 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - with: - submodules: recursive - - name: Set up Python - uses: LizardByte/setup-python-action@v2024.609.5111 + - name: Install Python + uses: actions/setup-python@v5 # https://github.com/actions/setup-python with: - python-version: '2.7' + python-version: '3.12' - - name: Set up Python Dependencies + - name: Setup Python Dependencies run: | - python -m pip install --upgrade pip setuptools requests - python -m pip install -r requirements.txt # requests is required to install python-plexapi + python -m pip install --upgrade pip setuptools wheel + python -m pip install -r requirements.txt - name: Update Strings run: | @@ -42,14 +40,14 @@ jobs: git config --global pager.diff false # print the git diff - git diff Contents/Strings/themerr-plex.po + git diff locale/themerr-plex.po # set the variable with minimal output, replacing `\t` with ` ` - OUTPUT=$(git diff --numstat Contents/Strings/themerr-plex.po | sed -e "s#\t# #g") + OUTPUT=$(git diff --numstat locale/themerr-plex.po | sed -e "s#\t# #g") echo "git_diff=${OUTPUT}" >> $GITHUB_ENV - name: git reset - if: ${{ env.git_diff == '1 1 Contents/Strings/themerr-plex.po' }} # only run if more than 1 line changed + if: ${{ env.git_diff == '1 1 locale/themerr-plex.po' }} # only run if more than 1 line changed run: | git reset --hard @@ -61,7 +59,7 @@ jobs: uses: peter-evans/create-pull-request@v7 with: add-paths: | - Contents/Strings/*.po + locale/*.po token: ${{ secrets.GH_BOT_TOKEN }} # must trigger PR tests commit-message: "chore(l10n): new babel updates" branch: localize/update diff --git a/.github/workflows/python-flake8.yml b/.github/workflows/python-flake8.yml index 61e23f74..015a8598 100644 --- a/.github/workflows/python-flake8.yml +++ b/.github/workflows/python-flake8.yml @@ -26,12 +26,11 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 # https://github.com/actions/setup-python with: - python-version: '3.10' + python-version: '3.12' - name: Install dependencies run: | - # pin flake8 before v6.0.0 due to removal of support for type comments (required for Python 2.7 type hints) - python -m pip install --upgrade pip setuptools "flake8<6" + python -m pip install --upgrade pip setuptools flake8 - name: Test with flake8 run: | diff --git a/.gitignore b/.gitignore index 0eaec38e..302d8d8f 100644 --- a/.gitignore +++ b/.gitignore @@ -101,15 +101,7 @@ ipython_config.py # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control #poetry.lock -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/#use-with-ide -.pdm.toml - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +# PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ # Celery stuff @@ -159,16 +151,9 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. .idea/ -# Remove the agent Info.plist since we are building it -Contents/Info.plist - -# Remove plexhints files -plexhints-temp -*cache.sqlite - -# Remove python modules -Contents/Libraries/Shared/ - # npm node_modules/ -package-lock.json +*package-lock.json + +# project files and directories +config/ diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index b338a870..00000000 --- a/.gitmodules +++ /dev/null @@ -1,4 +0,0 @@ -[submodule "third-party/youtube-dl"] - path = third-party/youtube-dl - url = https://github.com/ytdl-org/youtube-dl.git - branch = master diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 9f854f07..e369c477 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -8,21 +8,16 @@ version: 2 # Set the version of Python build: - os: ubuntu-20.04 + os: ubuntu-22.04 tools: - python: "2.7" + python: "3.12" jobs: pre_build: - - python ./scripts/build_plist.py + - python ./scripts/_locale.py --compile post_build: - rstcheck -r . # lint rst files # - rstfmt --check --diff -w 120 . # check rst formatting -# submodules required to include youtube-dl -submodules: - include: all - recursive: true - # Build documentation in the docs/ directory with Sphinx sphinx: builder: html @@ -34,5 +29,4 @@ formats: all python: install: - - requirements: requirements.txt # plugin requirements - - requirements: requirements-dev.txt # docs requirements + - requirements: requirements-dev.txt diff --git a/Contents/Code/__init__.py b/Contents/Code/__init__.py deleted file mode 100644 index f56d677d..00000000 --- a/Contents/Code/__init__.py +++ /dev/null @@ -1,397 +0,0 @@ -# -*- coding: utf-8 -*- - -# standard imports -import inspect -import re - -# 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.decorator_kit import handler # decorator kit - from plexhints.locale_kit import Locale - from plexhints.log_kit import Log # log kit - from plexhints.model_kit import MetadataModel # model kit - from plexhints.object_kit import MessageContainer, MetadataSearchResult, SearchResult # object kit - from plexhints.prefs_kit import Prefs # prefs kit - -# imports from Libraries\Shared -from typing import Optional, Union - -try: - # get the original Python builtins module - python_builtins = inspect.getmodule(object) - - # get the Sandbox instance - sandbox = inspect.stack()[1][0].f_locals["self"] - - # bypass RestrictedPython - getattr(sandbox, "_core").loader.compile = lambda src, name, _=False: python_builtins.compile(src, name, "exec") - - # restore Python builtins - sandbox.environment.update(python_builtins.vars(python_builtins)) -except Exception as e: - Log.Exception("Failed to bypass RestrictedPython: {}".format(e)) - -# local imports -from default_prefs import default_prefs -from constants import contributes_to, version -from plex_api_helper import plex_listener, start_queue_threads, update_plex_item -import migration_helper -from scheduled_tasks import setup_scheduling -from webapp import start_server - -# variables -last_prefs = dict() - - -def copy_prefs(): - # type: () -> None - """ - Copy the current preferences to the last preferences. - - This function is used to copy the current preferences to the last preferences. This is useful to determine if the - preferences have changed. - - Examples - -------- - >>> copy_prefs() - """ - global last_prefs - last_prefs = dict() - - for key in default_prefs: - try: - last_prefs[key] = Prefs[key] - except KeyError: - pass # this was already logged - - -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 - `_ - for more information. - - Returns - ------- - MessageContainer - Success or Error message dependeing on results of validation. - - Examples - -------- - >>> ValidatePrefs() - ... - """ - global last_prefs - - # todo - validate username and password - 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'
" % 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'
" % (key, Prefs[key]) - elif key.startswith('bool_'): - 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'
" % (key, Prefs[key]) - - # special cases - int_greater_than_zero = [ - 'int_plexapi_plexapi_timeout', - 'int_plexapi_upload_threads' - ] - for test in int_greater_than_zero: - if key == test and int(Prefs[key]) <= 0: - Log.Error("Setting '%s' must be greater than 0; Value '%s'" % (key, Prefs[key])) - error_message += "Setting '%s' must be greater than 0; Value '%s'
" % (key, Prefs[key]) - - # restart webserver if required - requires_restart = [ - 'str_webapp_http_host', - 'int_webapp_http_port', - 'bool_webapp_log_werkzeug_messages' - ] - - if key in requires_restart: - try: - if last_prefs[key] != Prefs[key]: - Log.Info('Changing this setting ({}) requires a Plex Media Server restart.'.format(key)) - except KeyError: - pass - - copy_prefs() # since validate prefs runs on startup, this will have already run at least once - - # perform migrations - migration_object = migration_helper.MigrationHelper() - for key in default_prefs: - migration_key_prefix = 'bool_migrate_' - if key.startswith(migration_key_prefix): - migration = key.replace(migration_key_prefix, '') - migrated = migration_object.get_migration_status(key=migration) - if Prefs[key] and not migrated: - migration_object.perform_migration(key=migration) - - if error_message != '': - return MessageContainer(header='Error', message=error_message) - else: - Log.Info("DefaultPrefs.json is valid") - return MessageContainer(header='Success', message='Themerr-plex - Provided preference values are ok') - - -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 - `_ - for more information. - - Preferences are validated, then additional threads are started for the web server, queue, plex listener, and - scheduled tasks. - - Examples - -------- - >>> Start() - ... - """ - Log.Info('Themerr-plex, version: {}'.format(version)) - - # validate prefs - prefs_valid = ValidatePrefs() - if prefs_valid.header == 'Error': - Log.Warn('Themerr-plex plug-in preferences are not valid.') - - start_server() # start the web server - Log.Debug('web server started.') - - start_queue_threads() # start queue threads - Log.Debug('queue threads started.') - - if Prefs['bool_plex_movie_support'] or Prefs['bool_plex_series_support']: - plex_listener() # start watching plex - Log.Debug('plex_listener started, watching for activity from new Plex agents.') - - setup_scheduling() # start scheduled tasks - Log.Debug('scheduled tasks started.') - - Log.Debug('plug-in started.') - - -@handler(prefix='/applications/themerr-plex', name='Themerr-plex ({})'.format(version), thumb='icon-default.png') -def main(): - """ - Create the main plug-in ``handler``. - - This is responsible for displaying the plug-in in the plug-ins menu. Since we are using the ``@handler`` decorator, - and since Plex removed menu's from plug-ins, this method does not need to perform any other function. - """ - pass - - -class Themerr(object): - """ - Class representing the Themerr-plex Agent. - - This class defines the metadata agent. See the archived Plex documentation - `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 - `_ - API. - primary_provider : py:class:`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 - -------- - >>> Themerr() - ... - """ - name = 'Themerr-plex' - languages = [ - Locale.Language.English - ] - primary_provider = False - fallback_agent = False - accepts_from = [] - contributes_to = contributes_to - - def __init__(self, *args, **kwargs): - super(Themerr, self).__init__(*args, **kwargs) - self.agent_type = "movies" if isinstance(self, Agent.Movies) else "tv_shows" - - def search(self, results, media, lang, manual): - # type: (SearchResult, Union[Media.Movie, Media.TV_Show], 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 - `_ - for more information. - - Parameters - ---------- - results : SearchResult - An empty container that the developer should populate with potential matches. - media : Union[Media.Movie, Media.TV_Show] - 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 : py:class:`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 - -------- - >>> Themerr().search(results=..., media=..., lang='en', manual=True) - ... - """ - Log.Debug('Searching with arguments: {results=%s, media=%s, lang=%s, manual=%s' % - (results, media, lang, manual)) - - if media.primary_metadata is None or media.primary_agent is None: - Log.Error('Search is being called in a primary agent.') - return - - Log.Debug('Primary agent: %s' % media.primary_agent) - Log.Debug('media.primary_metadata.id: %s' % media.primary_metadata.id) - - # the media_id will be used to create the url path, replacing `-` with `/` - if media.primary_agent == 'dev.lizardbyte.retroarcher-plex': - media_id = 'games-%s' % re.search(r'((igdb)-(\d+))', media.primary_metadata.id).group(1) - else: - media_id = '{}-{}-{}'.format( - self.agent_type, - media.primary_agent.rsplit('.', 1)[-1], - media.primary_metadata.id - ) - # e.g. = 'movies-imdb-tt0113189' - # e.g. = 'movies-themoviedb-710' - - results.Append(MetadataSearchResult( - id=media_id, - name=media.primary_metadata.title, - year=getattr(media.primary_metadata, 'year', None), # TV Shows don't have a year attribute - score=100, - lang=lang, # no lang to get from db - thumb=None # no point in adding thumb since plex won't show it anywhere - )) - - # sort the results first by year, then by score - results.Sort(attr='year') - results.Sort(attr='score', descending=True) - return results - - @staticmethod - def update(metadata, media, lang, force): - # type: (MetadataModel, Union[Media.Movie, Media.TV_Show], str, bool) -> MetadataModel - """ - 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 - `_ - for more information. - - Parameters - ---------- - metadata : MetadataModel - 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 : Union[Media.Movie, Media.TV_Show] - 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 : py:class:`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. - - Returns - ------- - MetadataModel - The metadata object. - - Examples - -------- - >>> Themerr().update(metadata=..., media=..., lang='en', force=True) - ... - """ - Log.Debug('Updating with arguments: {metadata=%s, media=%s, lang=%s, force=%s' % - (metadata, media, lang, force)) - - rating_key = int(media.id) # rating key of plex item - update_plex_item(rating_key=rating_key) - - return metadata - - -class ThemerrMovies(Themerr, Agent.Movies): - agent_type_verbose = "Movies" - - -class ThemerrTvShows(Themerr, Agent.TV_Shows): - agent_type_verbose = "TV" diff --git a/Contents/Code/constants.py b/Contents/Code/constants.py deleted file mode 100644 index 98e28b9f..00000000 --- a/Contents/Code/constants.py +++ /dev/null @@ -1,144 +0,0 @@ -# -*- coding: utf-8 -*- - -# standard imports -import plistlib -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 - -# get plugin directory from core kit -plugin_directory = Core.bundle_path -if plugin_directory.endswith('test.bundle'): - # use current directory instead, to allow for testing outside of Plex - if os.path.basename(os.getcwd()) == 'docs': - # use parent directory if current directory is 'docs' - plugin_directory = os.path.dirname(os.getcwd()) - else: - plugin_directory = os.getcwd() - -# get identifier and version from Info.plist file -info_file_path = os.path.join(plugin_directory, 'Contents', 'Info.plist') -try: - info_plist = plistlib.readPlist(pathOrFile=info_file_path) -except IOError: - info_plist = dict( - CFBundleIdentifier='dev.lizardbyte.themerr-plex', - PlexBundleVersion='0.0.0' - ) -plugin_identifier = info_plist['CFBundleIdentifier'] -version = info_plist['PlexBundleVersion'] - -app_support_directory = Core.app_support_path -metadata_base_directory = os.path.join(app_support_directory, 'Metadata') -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', # new movie agent - 'tv.plex.agents.series', # new tv show agent - 'com.plexapp.agents.imdb', # legacy movie agent - 'com.plexapp.agents.themoviedb', # legacy movie and tv show agent - 'com.plexapp.agents.thetvdb', # legacy tv show agent - 'dev.lizardbyte.retroarcher-plex' # retroarcher plugin -] - -guid_map = dict( - imdb='imdb', - tmdb='themoviedb', - tvdb='thetvdb' -) - -metadata_type_map = dict( - album='Albums', - artist='Artists', - collection='Collections', - movie='Movies', - show='TV Shows' -) - -# the explicit IPv4 address is used because `localhost` can resolve to ::1, which `websocket` rejects -plex_url = 'http://127.0.0.1:32400' -plex_token = os.environ.get('PLEXTOKEN') - -plex_section_type_settings_map = dict( - album=9, - artist=8, - movie=1, - photo=13, - show=2, -) - -# issue url constants -base_url = 'https://github.com/LizardByte/ThemerrDB/issues/new?assignees=' -issue_label = 'request-theme' -issue_template = 'theme.yml' -url_name = 'database_url' -title_prefix = dict( - games='[GAME]: ', - game_collections='[GAME COLLECTION]: ', - game_franchises='[GAME FRANCHISE]: ', - movies='[MOVIE]: ', - movie_collections='[MOVIE COLLECTION]: ', - tv_shows='[TV SHOW]: ', -) -url_prefix = dict( - games='https://www.igdb.com/games/', - game_collections='https://www.igdb.com/collections/', - game_franchises='https://www.igdb.com/franchises/', - movies='https://www.themoviedb.org/movie/', - movie_collections='https://www.themoviedb.org/collection/', - tv_shows='https://www.themoviedb.org/tv/', -) - -# two additional strings to fill in later, item title and item url -issue_urls = dict( - games='{}&labels={}&template={}&title={}{}&{}={}{}'.format( - base_url, issue_label, issue_template, title_prefix['games'], '{}', url_name, url_prefix['games'], '{}'), - game_collections='{}&labels={}&template={}&title={}{}&{}={}{}'.format( - base_url, issue_label, issue_template, title_prefix['game_collections'], '{}', url_name, - url_prefix['game_collections'], '{}'), - game_franchises='{}&labels={}&template={}&title={}{}&{}={}{}'.format( - base_url, issue_label, issue_template, title_prefix['game_franchises'], '{}', url_name, - url_prefix['game_franchises'], '{}'), - movies='{}&labels={}&template={}&title={}{}&{}={}{}'.format( - base_url, issue_label, issue_template, title_prefix['movies'], '{}', url_name, url_prefix['movies'], '{}'), - movie_collections='{}&labels={}&template={}&title={}{}&{}={}{}'.format( - base_url, issue_label, issue_template, title_prefix['movie_collections'], '{}', url_name, - url_prefix['movie_collections'], '{}'), - tv_shows='{}&labels={}&template={}&title={}{}&{}={}{}'.format( - base_url, issue_label, issue_template, title_prefix['tv_shows'], '{}', url_name, url_prefix['tv_shows'], '{}'), -) - -media_type_dict = dict( - art=dict( - method=lambda item: item.uploadArt, - type='art', - name='art', - themerr_data_key='art_url', - remove_pref='bool_remove_unused_art', - plex_field='art', - ), - posters=dict( - method=lambda item: item.uploadPoster, - type='posters', - name='poster', - themerr_data_key='poster_url', - remove_pref='bool_remove_unused_posters', - plex_field='thumb', - ), - themes=dict( - method=lambda item: item.uploadTheme, - type='themes', - name='theme', - themerr_data_key='youtube_theme_url', - remove_pref='bool_remove_unused_theme_songs', - plex_field='theme', - ), -) diff --git a/Contents/Code/default_prefs.py b/Contents/Code/default_prefs.py deleted file mode 100644 index abc561ac..00000000 --- a/Contents/Code/default_prefs.py +++ /dev/null @@ -1,30 +0,0 @@ -# -*- coding: utf-8 -*- - -default_prefs = dict( - bool_plex_movie_support='True', - bool_plex_series_support='True', - bool_overwrite_plex_provided_themes='False', - bool_prefer_mp4a_codec='True', - bool_remove_unused_theme_songs='True', - bool_remove_unused_art='False', - bool_remove_unused_posters='False', - bool_auto_update_items='True', - bool_auto_update_movie_themes='True', - bool_auto_update_tv_themes='True', - bool_auto_update_collection_themes='True', - bool_update_collection_metadata_plex_movie='False', - bool_update_collection_metadata_legacy='True', - int_update_themes_interval='60', - int_update_database_cache_interval='60', - int_plexapi_plexapi_timeout='180', - int_plexapi_upload_retries_max='3', - int_plexapi_upload_threads='3', - str_youtube_cookies='', - enum_webapp_locale='en', - str_webapp_http_host='0.0.0.0', - int_webapp_http_port='9494', - bool_webapp_log_werkzeug_messages='False', - bool_migrate_locked_themes='False', - bool_migrate_locked_collection_fields='False', - bool_ignore_locked_fields='False', -) diff --git a/Contents/Code/lizardbyte_db_helper.py b/Contents/Code/lizardbyte_db_helper.py deleted file mode 100644 index 9fc99912..00000000 --- a/Contents/Code/lizardbyte_db_helper.py +++ /dev/null @@ -1,74 +0,0 @@ -# -*- coding: utf-8 -*- - -# plex debugging -try: - import plexhints # noqa: F401 -except ImportError: - pass -else: # the code is running outside of Plex - from plexhints.log_kit import Log # log kit - from plexhints.parse_kit import JSON # parse kit - -# imports from Libraries\Shared -from typing import Optional, Tuple - -collection_types = dict( - game_collections=dict( - db_base_url='https://db.lizardbyte.dev/collections' - ), - game_franchises=dict( - db_base_url='https://db.lizardbyte.dev/franchises' - ), -) - - -def get_igdb_id_from_collection(search_query, collection_type=None): - # type: (str, Optional[str]) -> Optional[Tuple[int, str]] - """ - Search for a collection by name. - - Match a collection by name against the LizardByte db (clone of IGDB), to get the collection ID. - - Parameters - ---------- - search_query : str - Collection name to search for. - collection_type : Optional[str] - Collection type to search for. Valid values are 'game_collections' and 'game_franchises'. If not provided, will - first search for 'game_collections', then 'game_franchises', returning the first match. - - Returns - ------- - Optional[Tuple[int, str]] - Tuple of ``id`` and ``collection_type`` if found, otherwise None. - - Examples - -------- - >>> get_igdb_id_from_collection(search_query='James Bond', collection_type='game_collections') - 326 - >>> get_igdb_id_from_collection(search_query='James Bond', collection_type='game_franchises') - 37 - """ - Log.Debug('Searching LizardByte db for collection: {}'.format(search_query)) - - if collection_type is None: - collection_types_list = ['game_collections', 'game_franchises'] - else: - collection_types_list = [collection_type] - - for collection_type in collection_types_list: - try: - db_base_url = collection_types[collection_type]['db_base_url'] - except KeyError: - Log.Error('Invalid collection type: {}'.format(collection_type)) - else: - url = '{}/all.json'.format(db_base_url) - try: - collection_data = JSON.ObjectFromURL(url=url, headers=dict(Accept='application/json'), - cacheTime=0, errors='strict') - except ValueError as e: - Log.Error('Error getting collection data: {}'.format(e)) - else: - for _ in collection_data: - if search_query.lower() == collection_data[_]['name'].lower(): - return collection_data[_]['id'], collection_type diff --git a/Contents/Code/migration_helper.py b/Contents/Code/migration_helper.py deleted file mode 100644 index fe1938e8..00000000 --- a/Contents/Code/migration_helper.py +++ /dev/null @@ -1,301 +0,0 @@ -# -*- coding: utf-8 -*- - -# standard imports -import json -import os -from threading import Lock - -# 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 - -# imports from Libraries\Shared -from requests.exceptions import ReadTimeout -from typing import Optional - -# local imports -from constants import themerr_data_directory -import plex_api_helper - - -class MigrationHelper: - """ - Helper class to perform migrations. - - Attributes - ---------- - migration_status_file : str - The path to the migration status file. - migration_status_file_lock : Lock - The lock for the migration status file. - - Methods - ------- - _validate_migration_key(key, raise_exception=False) - Validate the given migration key. - get_migration_status(key) - Get the migration status for the given key. - set_migration_status(key) - Update the migration status file. - perform_migration(key) - Perform the migration for the given key, if it has not already been performed. - migrate_locked_themes() - Unlock all locked themes. - """ - # Define the migration keys as class attributes for dot notation access - LOCKED_THEMES = 'locked_themes' - LOCKED_COLLECTION_FIELDS = 'locked_collection_fields' - - def __init__(self): - self.migration_status_file = os.path.join(themerr_data_directory, 'migration_status.json') - self.migration_status_file_lock = Lock() - - # Map keys to their respective functions - self.migration_functions = { - self.LOCKED_THEMES: self.migrate_locked_themes, - self.LOCKED_COLLECTION_FIELDS: self.migrate_locked_collection_fields, - } - - def _validate_migration_key(self, key, raise_exception=False): - # type: (str, bool) -> bool - """ - Validate the given migration key. - - Ensure the given key has a corresponding class attribute and function. - - Parameters - ---------- - key : str - The key to validate. - raise_exception : py:class:`bool` - Whether to raise an exception if the key is invalid. - - Returns - ------- - py:class:`bool` - Whether the key is valid. - - Raises - ------ - AttributeError - If the key is invalid and raise_exception is True. - """ - # Ensure the key is a class attribute - upper_key = key.upper() - if not hasattr(self, upper_key): - Log.Error('{} key is not a class attribute'.format(upper_key)) - if raise_exception: - raise AttributeError('{} key is not a class attribute'.format(upper_key)) - return False - - # ensure the class attribute value is the same and lowercase - if getattr(self, upper_key) != key: - Log.Error('{} key is not the same as the class attribute value'.format(key)) - if raise_exception: - raise AttributeError('{} key is not the same as the class attribute value'.format(key)) - return False - - # Ensure the key has a corresponding function - if not self.migration_functions.get(key): - Log.Error('{} key does not have a corresponding function'.format(key)) - if raise_exception: - raise AttributeError('{} key does not have a corresponding function'.format(key)) - return False - - # if we made it this far, the key is valid - return True - - def get_migration_status(self, key): - # type: (str) -> Optional[bool] - """ - Get the migration status for the given key. - - Parameters - ---------- - key : str - The key to get the migration status for. - - Returns - ------- - Optional[py:class:`bool`] - The migration status for the given key, or None if the key is not found. - - Examples - -------- - >>> MigrationHelper().get_migration_status(key=self.LOCKED_THEMES) - True - """ - # validate - self._validate_migration_key(key=key, raise_exception=True) - - with self.migration_status_file_lock: - if os.path.isfile(self.migration_status_file): - migration_status = json.loads( - s=str(Core.storage.load(filename=self.migration_status_file, binary=False))) - else: - migration_status = {} - - return migration_status.get(key) - - def set_migration_status(self, key): - # type: (str) -> None - """ - Update the migration status file. - - Parameters - ---------- - key : str - The key to update in the migration status file. - - Examples - -------- - >>> MigrationHelper().set_migration_status(key=self.LOCKED_THEMES) - """ - # validate - self._validate_migration_key(key=key, raise_exception=True) - - Log.Debug('Updating migration status file: {}'.format(key)) - with self.migration_status_file_lock: - if os.path.isfile(self.migration_status_file): - migration_status = json.loads( - s=str(Core.storage.load(filename=self.migration_status_file, binary=False))) - else: - migration_status = {} - - if not migration_status.get(key): - migration_status[key] = True - Core.storage.save(filename=self.migration_status_file, data=json.dumps(migration_status), binary=False) - - @staticmethod - def migrate_locked_themes(): - """ - Unlock all locked themes. - - Prior to v0.3.0, themes uploaded by Themerr-plex were locked which leads to an issue in v0.3.0 and newer, since - Themerr-plex will not update locked themes. Additionally, there was no way to know if a theme was added by - Themerr-plex or not until v0.3.0, so this migration will unlock all themes. - """ - plex = plex_api_helper.setup_plexapi() - - plex_library = plex.library - - sections = plex_library.sections() - - # never update this list, it needs to match what was available before v0.3.0 - contributes_to = ( - 'tv.plex.agents.movie', - 'com.plexapp.agents.imdb', - 'com.plexapp.agents.themoviedb', - 'dev.lizardbyte.retroarcher-plex', - ) - - for section in sections: - if section.agent not in contributes_to: - continue # skip items with unsupported metadata agents for < v0.3.0 - - if section.type != 'movie': - continue # skip non-movie sections - - field = 'theme' - - # not sure if this unlocks themes for collections - try: - section.unlockAllField(field=field, libtype='movie') - except ReadTimeout: - # this may timeout, but no big deal, we can just unlock the items individually - Log.Warn('ReadTimeout occurred while unlocking all themes for section: {}, will fallback to ' - 'individual item unlocking'.format(section.title)) - - # get all the items in the section - media_items = section.all() # this is redundant, assuming unlockAllField() works on movies - - # collections were added in v0.3.0, but collect them as well for anyone who may have used a nightly build - # get all collections in the section - collections = section.collections() - - # combine the items and collections into one list - # this is done so that we can process both items and collections in the same loop - all_items = media_items + collections - - for item in all_items: - if item.isLocked(field=field): - plex_api_helper.change_lock_status(item=item, field=field, lock=False) - - @staticmethod - def migrate_locked_collection_fields(): - """ - Unlock fields locked in collections. - - Prior to v0.3.0, fields for collections modified by Themerr-plex were locked which leads to an issue in v0.3.0 - and newer, since Themerr-plex will not update locked fields. - """ - plex = plex_api_helper.setup_plexapi() - - plex_library = plex.library - - sections = plex_library.sections() - - # never update this list, it needs to match what was available before v0.3.0 - contributes_to = ( - 'tv.plex.agents.movie', - 'com.plexapp.agents.imdb', - 'com.plexapp.agents.themoviedb', - 'dev.lizardbyte.retroarcher-plex', - ) - - for section in sections: - if section.agent not in contributes_to: - continue # skip items with unsupported metadata agents for < v0.3.0 - - if section.type != 'movie': - continue # skip non-movie sections - - fields = [ - 'art', - 'summary', - 'thumb', - ] - - # collections were added in v0.3.0, but collect them as well for anyone who may have used a nightly build - # get all collections in the section - collections = section.collections() - - for item in collections: - for field in fields: - if item.isLocked(field=field) and item.theme: # only unlock fields for collections with themes - plex_api_helper.change_lock_status(item=item, field=field, lock=False) - - def perform_migration(self, key): - # type: (str) -> None - """ - Perform the migration for the given key, if it has not already been performed. - - Parameters - ---------- - key : str - The key to perform the migration for. - - Examples - -------- - >>> MigrationHelper().perform_migration(key=MigrationHelper.LOCKED_THEMES) - """ - # validate - self._validate_migration_key(key=key, raise_exception=True) - - # check if the migration has already been performed - migration_status = self.get_migration_status(key=key) - if migration_status: - Log.Debug('Skipping "{}" migration, already completed.'.format(key)) - return - - # Use the dictionary to find and call the corresponding function - migration_function = self.migration_functions.get(key) - migration_function() - - # update the migration status file - self.set_migration_status(key) diff --git a/Contents/Code/webapp.py b/Contents/Code/webapp.py deleted file mode 100644 index 7e5b2b46..00000000 --- a/Contents/Code/webapp.py +++ /dev/null @@ -1,497 +0,0 @@ -# -*- 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 Lock, Thread - -# plex debugging -try: - import plexhints # noqa: F401 -except ImportError: - pass -else: # the code is running outside of Plex - from plexhints.constant_kit import CACHE_1DAY # constant kit - 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 - -# lib imports -import flask -from flask import Flask, Response, render_template, send_from_directory -from flask_babel import Babel -import polib -from six.moves.urllib.parse import quote_plus -from werkzeug.utils import secure_filename - -# local imports -from constants import contributes_to, issue_urls, plugin_directory, plugin_identifier, themerr_data_directory -import general_helper -from plex_api_helper import get_database_info, setup_plexapi -import themerr_db_helper -import tmdb_helper - -# setup flask app -app = Flask( - import_name=__name__, - root_path=os.path.join(plugin_directory, 'Contents', 'Resources', 'web'), - static_folder=os.path.join(plugin_directory, 'Contents', 'Resources', 'web'), - template_folder=os.path.join(plugin_directory, '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(plugin_directory, '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_webapp_log_werkzeug_messages'] -except KeyError: - # this fails when building docs - pass -else: - if Prefs['bool_webapp_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', -} - -# where the database cache is stored -database_cache_file = os.path.join(themerr_data_directory, 'database_cache.json') -database_cache_lock = Lock() - - -responses = { - 500: Response(response='Internal Server Error', status=500, mimetype='text/plain') -} - - -@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. - - Examples - -------- - >>> get_locale() - en - """ - locale = Prefs['enum_webapp_locale'] - Log.Debug('Getting locale: %s' % locale) - return locale - - -def start_server(): - # type: () -> bool - """ - Start the flask server. - - The flask server is started in a separate thread to allow the plugin to continue running. - - Returns - ------- - py:class:`bool` - True if the server is running, otherwise False. - - Examples - -------- - >>> start_server() - - See Also - -------- - Core.Start : Function that starts the plugin. - stop_server : Function that stops the webapp. - """ - # 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_webapp_http_host'], - port=Prefs['int_webapp_http_port'], - debug=False, - use_reloader=False # reloader doesn't work when running in a separate thread - ) - ) - - # start flask application - flask_thread.start() - return flask_thread.is_alive() - - -def stop_server(): - # type: () -> bool - """ - Stop the web server. - - This method currently does nothing. - - Returns - ------- - py:class:`bool` - True if the server was shutdown, otherwise False. - - Examples - -------- - >>> stop_server() - - See Also - -------- - start_server : Function that starts the webapp. - """ - return False - - -def cache_data(): - # type: () -> None - """ - Cache data for use in the Web UI dashboard. - - Because there are many http requests that must be made to gather the data for the dashboard, it can be - time-consuming to populate; therefore, this is performed within this caching function, which runs on a schedule. - This function will create a json file that can be loaded by other functions. - """ - # get all Plex items from supported metadata agents - plex_server = setup_plexapi() - plex_library = plex_server.library - - themerr_db_helper.update_cache() - - 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 the items in the section - media_items = section.all() - - # get all items in the section with theme songs - media_items_with_themes = section.all(theme__exists=True) - - # get all collections in the section - collections = section.collections() if Prefs['bool_auto_update_collection_themes'] else [] - collections_with_themes = section.collections(theme__exists=True) if Prefs[ - 'bool_auto_update_collection_themes'] else [] - - # combine the items and collections into one list - # this is done so that we can process both items and collections in the same loop - all_items = media_items + collections - - # add each section to the items dict - items[section.key] = dict( - key=section.key, - title=section.title, - agent=section.agent, - items=[], - media_count=len(media_items), - media_percent_complete=int( - len(media_items_with_themes) / len(media_items) * 100) if len(media_items_with_themes) else 0, - collection_count=len(collections), - collection_percent_complete=int( - len(collections_with_themes) / len(collections) * 100) if len(collections_with_themes) else 0, - collections_enabled=Prefs['bool_auto_update_collection_themes'], - total_count=len(all_items), - type=section.type, - ) - - for item in all_items: - # build the issue url - database_info = get_database_info(item=item) - database_type = database_info[0] - database = database_info[1] - item_agent = database_info[2] - database_id = database_info[3] - - og_db = database - og_db_id = database_id - - year = getattr(item, 'year', None) - - # convert imdb id to tmdb id, so we can build the issue url properly - if item.type == 'movie' and database_id and ( - item_agent == 'com.plexapp.agents.imdb' - or database_id.startswith('tt') - ): - # try to get tmdb id from imdb id - tmdb_id = tmdb_helper.get_tmdb_id_from_external_id( - external_id=database_id, database='imdb', item_type='movie') - database_id = tmdb_id if tmdb_id else None - - item_issue_url = None - - issue_url = issue_urls.get(database_type) - - if issue_url: - if item.type == 'movie': - # override the id since ThemerrDB issues require the slug as part of the url - if item_agent == 'dev.lizardbyte.retroarcher-plex': - issue_title = '{} ({})'.format(item.title, year) - - if database_id: - # get the slug and name from LizardByte db - try: - db_data = JSON.ObjectFromURL( - url='https://db.lizardbyte.dev/games/{}.json'.format(database_id), - cacheTime=CACHE_1DAY, - errors='strict' - ) - except Exception as e: - Log.Error('Error getting game data from LizardByte db: {}'.format(e)) - database_id = None - else: - issue_title = '{} ({})'.format(db_data['name'], year) - database_id = db_data['slug'] - else: - issue_title = '{} ({})'.format(getattr(item, "originalTitle", None) or item.title, year) - elif item.type == 'show': - issue_title = '{} ({})'.format(item.title, year) - else: # collections - issue_title = item.title - - # override the id since ThemerrDB issues require the slug as part of the url - if item_agent == 'dev.lizardbyte.retroarcher-plex': - if database_id: - # get the slug and name from LizardByte db - try: - db_data = JSON.ObjectFromURL( - url='https://db.lizardbyte.dev/{}/all.json'.format( - database_type.rsplit('_', 1)[-1]), - cacheTime=CACHE_1DAY, - errors='strict' - ) - issue_title = db_data[str(database_id)]['name'] - database_id = db_data[str(database_id)]['slug'] - except Exception as e: - Log.Error('Error getting collection data from LizardByte db: {}'.format(e)) - database_id = None - - if database_id: - # url encode the issue title - issue_title = quote_plus(issue_title) - - item_issue_url = issue_url.format(issue_title, database_id) - - if database_type and og_db and og_db_id and themerr_db_helper.item_exists( - database_type=database_type, - database=og_db, - id=og_db_id, - ): - issue_action = 'edit' - else: - issue_action = 'add' - - if item.theme: - theme_status = 'complete' - else: - if issue_action == 'edit': - theme_status = 'failed' - else: - theme_status = 'missing' - - theme_provider = general_helper.get_theme_provider(item=item) - - items[section.key]['items'].append(dict( - title=item.title, - agent=item_agent, - database=database, - database_type=database_type, - database_id=database_id, - issue_action=issue_action, - issue_url=item_issue_url, - theme=True if item.theme else False, - theme_provider=theme_provider, - theme_status=theme_status, - type=item.type, - year=year, - )) - - with database_cache_lock: - Core.storage.save(filename=database_cache_file, data=json.dumps(items), binary=False) - - -@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() - """ - if not os.path.isfile(database_cache_file): - return render_template('home_db_not_cached.html', title='Home') - - try: - items = json.loads(Core.storage.load(filename=database_cache_file, binary=False)) - except IOError: - return responses[500] - - 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/themerr-plex.po' % (app.config['BABEL_TRANSLATION_DIRECTORIES'], locale), # selected locale - '%s/themerr-plex.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/Strings/de.json b/Contents/Strings/de.json deleted file mode 100644 index 32417e6b..00000000 --- a/Contents/Strings/de.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "bool_plex_movie_support": "Plex Movie Agent Unterstützung (Themes zum aktualisierten Plex Movie Agent hinzufügen)", - "bool_plex_series_support": "Plex Series Agent-Support (Themes dem aktualisierten Plex Series-Agent hinzufügen)", - "bool_overwrite_plex_provided_themes": "Plex angegebene Themes überschreiben", - "bool_prefer_mp4a_codec": "MP4A AAC Codec bevorzugen (Erhöht die Kompatibilität mit Apple-Geräten)", - "bool_remove_unused_theme_songs": "Ungenutzte Theme-Lieder entfernen (Freischalten des Plex-Metadaten-Verzeichnisses)", - "bool_remove_unused_art": "Unbenutzte Kunst entfernen (gilt für Sammlungen, freigegeben Platz in Ihrem Plex Metadaten-Verzeichnis)", - "bool_remove_unused_posters": "Unbenutzte Poster entfernen (gilt für Sammlungen, freigegeben Platz im Plex Metadaten-Verzeichnis)", - "bool_auto_update_items": "Elemente automatisch aktualisieren (nur Elemente wurden geändert oder in ThemerrDB)", - "bool_auto_update_movie_themes": "Film-Themes beim automatischen Update aktualisieren", - "bool_auto_update_tv_themes": "TV-Show-Themes beim automatischen Update aktualisieren", - "bool_auto_update_collection_themes": "Sammlungsthemen beim automatischen Update aktualisieren", - "bool_update_collection_metadata_plex_movie": "Metadaten der Sammlung für Plex Movie Agent aktualisieren (Aktualisiere Poster, Kunst und Zusammenfassung)", - "bool_update_collection_metadata_legacy": "Metadaten der Sammlung für ältere Agenten aktualisieren (Updates, Poster, Kunst und Zusammenfassung)", - "int_update_themes_interval": "Intervall für automatische Update-Aufgabe in Minuten (min: 15)", - "int_update_database_cache_interval": "Intervall für Datenbank-Cache-Aktualisierungsaufgabe in Minuten (min: 15)", - "int_plexapi_plexapi_timeout": "PlexAPI Timeout in Sekunden (min: 1)", - "int_plexapi_upload_retries_max": "Max. Retries, ganze Zahl (min: 0)", - "int_plexapi_upload_threads": "Multiprozess-Threads, ganze Zahl (min: 1)", - "str_youtube_cookies": "YouTube Cookies (JSON-Format)", - "enum_webapp_locale": "Web UI Locale", - "str_webapp_http_host": "Web-UI-Host-Adresse (erfordert Plex Media Server Neustart)", - "int_webapp_http_port": "Web-UI-Port (erfordert Plex Media Server Neustart)", - "bool_webapp_log_werkzeug_messages": "Alle Webserver-Nachrichten protokollieren (Neustart des Plex Media Server erforderlich)", - "bool_migrate_locked_themes": "Motive von < v0.3.0 migrieren (Wenn Sie Themerr vor v0.3.0 verwendet haben, setzen Sie dies auf True)", - "bool_migrate_locked_collection_fields": "Metadaten der Sammlung von < v0.3.0 migrieren (Wenn Sie Themerr vor v0.3.0 verwendet haben, setzen Sie dies auf True)", - "bool_ignore_locked_fields": "Gesperrte Felder ignorieren (Medien immer hochladen, auch wenn Felder gesperrt sind)" -} diff --git a/Contents/Strings/en-gb.json b/Contents/Strings/en-gb.json deleted file mode 100644 index 748a0d62..00000000 --- a/Contents/Strings/en-gb.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "bool_plex_movie_support": "Plex Movie agent support (Add themes to the updated Plex Movie agent)", - "bool_plex_series_support": "Plex Series agent support (Add themes to the updated Plex Series agent)", - "bool_overwrite_plex_provided_themes": "Overwrite Plex provided themes", - "bool_prefer_mp4a_codec": "Prefer MP4A AAC Codec (Improves compatibility with Apple devices)", - "bool_remove_unused_theme_songs": "Remove unused theme songs (frees up space in your Plex metadata directory)", - "bool_remove_unused_art": "Remove unused art (applies to collections, frees up space in your Plex metadata directory)", - "bool_remove_unused_posters": "Remove unused posters (applies to collections, frees up space in your Plex metadata directory)", - "bool_auto_update_items": "Automatically update items (only items changed or previously missing in ThemerrDB)", - "bool_auto_update_movie_themes": "Update movie themes during automatic update", - "bool_auto_update_tv_themes": "Update tv show themes during automatic update", - "bool_auto_update_collection_themes": "Update collection themes during automatic update", - "bool_update_collection_metadata_plex_movie": "Update collection metadata for Plex Movie agent (Updates poster, art, and summary)", - "bool_update_collection_metadata_legacy": "Update collection metadata for legacy agents (Updates poster, art, and summary)", - "int_update_themes_interval": "Interval for automatic update task, in minutes (min: 15)", - "int_update_database_cache_interval": "Interval for database cache update task, in minutes (min: 15)", - "int_plexapi_plexapi_timeout": "PlexAPI Timeout, in seconds (min: 1)", - "int_plexapi_upload_retries_max": "Max Retries, integer (min: 0)", - "int_plexapi_upload_threads": "Multiprocessing Threads, integer (min: 1)", - "str_youtube_cookies": "YouTube Cookies (JSON format)", - "enum_webapp_locale": "Web UI Locale", - "str_webapp_http_host": "Web UI Host Address (requires Plex Media Server restart)", - "int_webapp_http_port": "Web UI Port (requires Plex Media Server restart)", - "bool_webapp_log_werkzeug_messages": "Log all web server messages (requires Plex Media Server restart)", - "bool_migrate_locked_themes": "Migrate themes from < v0.3.0 (If you used Themerr before v0.3.0, set this to True)", - "bool_migrate_locked_collection_fields": "Migrate collection metadata from < v0.3.0 (If you used Themerr before v0.3.0, set this to True)", - "bool_ignore_locked_fields": "Ignore locked fields (Always upload media, even if fields are locked)" -} diff --git a/Contents/Strings/en-us.json b/Contents/Strings/en-us.json deleted file mode 100644 index 748a0d62..00000000 --- a/Contents/Strings/en-us.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "bool_plex_movie_support": "Plex Movie agent support (Add themes to the updated Plex Movie agent)", - "bool_plex_series_support": "Plex Series agent support (Add themes to the updated Plex Series agent)", - "bool_overwrite_plex_provided_themes": "Overwrite Plex provided themes", - "bool_prefer_mp4a_codec": "Prefer MP4A AAC Codec (Improves compatibility with Apple devices)", - "bool_remove_unused_theme_songs": "Remove unused theme songs (frees up space in your Plex metadata directory)", - "bool_remove_unused_art": "Remove unused art (applies to collections, frees up space in your Plex metadata directory)", - "bool_remove_unused_posters": "Remove unused posters (applies to collections, frees up space in your Plex metadata directory)", - "bool_auto_update_items": "Automatically update items (only items changed or previously missing in ThemerrDB)", - "bool_auto_update_movie_themes": "Update movie themes during automatic update", - "bool_auto_update_tv_themes": "Update tv show themes during automatic update", - "bool_auto_update_collection_themes": "Update collection themes during automatic update", - "bool_update_collection_metadata_plex_movie": "Update collection metadata for Plex Movie agent (Updates poster, art, and summary)", - "bool_update_collection_metadata_legacy": "Update collection metadata for legacy agents (Updates poster, art, and summary)", - "int_update_themes_interval": "Interval for automatic update task, in minutes (min: 15)", - "int_update_database_cache_interval": "Interval for database cache update task, in minutes (min: 15)", - "int_plexapi_plexapi_timeout": "PlexAPI Timeout, in seconds (min: 1)", - "int_plexapi_upload_retries_max": "Max Retries, integer (min: 0)", - "int_plexapi_upload_threads": "Multiprocessing Threads, integer (min: 1)", - "str_youtube_cookies": "YouTube Cookies (JSON format)", - "enum_webapp_locale": "Web UI Locale", - "str_webapp_http_host": "Web UI Host Address (requires Plex Media Server restart)", - "int_webapp_http_port": "Web UI Port (requires Plex Media Server restart)", - "bool_webapp_log_werkzeug_messages": "Log all web server messages (requires Plex Media Server restart)", - "bool_migrate_locked_themes": "Migrate themes from < v0.3.0 (If you used Themerr before v0.3.0, set this to True)", - "bool_migrate_locked_collection_fields": "Migrate collection metadata from < v0.3.0 (If you used Themerr before v0.3.0, set this to True)", - "bool_ignore_locked_fields": "Ignore locked fields (Always upload media, even if fields are locked)" -} diff --git a/Contents/Strings/en.json b/Contents/Strings/en.json deleted file mode 100644 index 748a0d62..00000000 --- a/Contents/Strings/en.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "bool_plex_movie_support": "Plex Movie agent support (Add themes to the updated Plex Movie agent)", - "bool_plex_series_support": "Plex Series agent support (Add themes to the updated Plex Series agent)", - "bool_overwrite_plex_provided_themes": "Overwrite Plex provided themes", - "bool_prefer_mp4a_codec": "Prefer MP4A AAC Codec (Improves compatibility with Apple devices)", - "bool_remove_unused_theme_songs": "Remove unused theme songs (frees up space in your Plex metadata directory)", - "bool_remove_unused_art": "Remove unused art (applies to collections, frees up space in your Plex metadata directory)", - "bool_remove_unused_posters": "Remove unused posters (applies to collections, frees up space in your Plex metadata directory)", - "bool_auto_update_items": "Automatically update items (only items changed or previously missing in ThemerrDB)", - "bool_auto_update_movie_themes": "Update movie themes during automatic update", - "bool_auto_update_tv_themes": "Update tv show themes during automatic update", - "bool_auto_update_collection_themes": "Update collection themes during automatic update", - "bool_update_collection_metadata_plex_movie": "Update collection metadata for Plex Movie agent (Updates poster, art, and summary)", - "bool_update_collection_metadata_legacy": "Update collection metadata for legacy agents (Updates poster, art, and summary)", - "int_update_themes_interval": "Interval for automatic update task, in minutes (min: 15)", - "int_update_database_cache_interval": "Interval for database cache update task, in minutes (min: 15)", - "int_plexapi_plexapi_timeout": "PlexAPI Timeout, in seconds (min: 1)", - "int_plexapi_upload_retries_max": "Max Retries, integer (min: 0)", - "int_plexapi_upload_threads": "Multiprocessing Threads, integer (min: 1)", - "str_youtube_cookies": "YouTube Cookies (JSON format)", - "enum_webapp_locale": "Web UI Locale", - "str_webapp_http_host": "Web UI Host Address (requires Plex Media Server restart)", - "int_webapp_http_port": "Web UI Port (requires Plex Media Server restart)", - "bool_webapp_log_werkzeug_messages": "Log all web server messages (requires Plex Media Server restart)", - "bool_migrate_locked_themes": "Migrate themes from < v0.3.0 (If you used Themerr before v0.3.0, set this to True)", - "bool_migrate_locked_collection_fields": "Migrate collection metadata from < v0.3.0 (If you used Themerr before v0.3.0, set this to True)", - "bool_ignore_locked_fields": "Ignore locked fields (Always upload media, even if fields are locked)" -} diff --git a/Contents/Strings/es.json b/Contents/Strings/es.json deleted file mode 100644 index 7f6257fe..00000000 --- a/Contents/Strings/es.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "bool_plex_movie_support": "Soporte de agente de película de Plex (Añadir temas al agente de película de Plex actualizado)", - "bool_plex_series_support": "Soporte de agente de series de Plex (Añadir temas al agente actualizado de series de Plex)", - "bool_overwrite_plex_provided_themes": "Sobrescribir temas proporcionados por Plex", - "bool_prefer_mp4a_codec": "Preferir MP4A AAC Codec (mejora la compatibilidad con dispositivos Apple)", - "bool_remove_unused_theme_songs": "Eliminar canciones de tema no utilizadas (libera espacio en el directorio de metadatos de Plex)", - "bool_remove_unused_art": "Eliminar el arte no utilizado (se aplica a las colecciones, libera espacio en el directorio de metadatos de Plex)", - "bool_remove_unused_posters": "Eliminar pósters no utilizados (se aplica a las colecciones, libera espacio en el directorio de metadatos de Plex)", - "bool_auto_update_items": "Actualizar automáticamente los elementos (sólo elementos cambiados o faltantes en ThemerrDB)", - "bool_auto_update_movie_themes": "Actualizar temas de película durante la actualización automática", - "bool_auto_update_tv_themes": "Actualizar temas de series de tv durante la actualización automática", - "bool_auto_update_collection_themes": "Actualizar temas de la colección durante la actualización automática", - "bool_update_collection_metadata_plex_movie": "Actualizar metadatos de colección para agente de película de Plex (Actualizaciones de póster, arte y resumen)", - "bool_update_collection_metadata_legacy": "Actualizar metadatos de colección para agentes heredados (Actualizaciones de póster, arte y resumen)", - "int_update_themes_interval": "Intervalo para la tarea de actualización automática, en minutos (min: 15)", - "int_update_database_cache_interval": "Intervalo para la tarea de actualización de la caché de base de datos, en minutos (min: 15)", - "int_plexapi_plexapi_timeout": "Tiempo de espera de PlexAPI, en segundos (min: 1)", - "int_plexapi_upload_retries_max": "Máx. Reintentos, entero (min: 0)", - "int_plexapi_upload_threads": "Multiprocesamiento de hilos, entero (min: 1)", - "str_youtube_cookies": "Cookies de YouTube (formato JSON)", - "enum_webapp_locale": "Web UI Locale", - "str_webapp_http_host": "Dirección de host Web UI (requiere reiniciar Plex Media Server)", - "int_webapp_http_port": "Puerto Web UI (requiere reiniciar Plex Media Server)", - "bool_webapp_log_werkzeug_messages": "Registrar todos los mensajes del servidor web (requiere reiniciar Plex Media Server)", - "bool_migrate_locked_themes": "Migrar temas desde < v0.3.0 (Si utilizaba Themerr antes de v0.3.0, establezca este valor en True)", - "bool_migrate_locked_collection_fields": "Migrar temas desde < v0.3.0 (Si utilizaba Themerr antes de v0.3.0, establezca este valor en True)", - "bool_ignore_locked_fields": "Ignorar campos bloqueados (Siempre subir medios, incluso si los campos están bloqueados)" -} diff --git a/Contents/Strings/fr.json b/Contents/Strings/fr.json deleted file mode 100644 index e7bf1087..00000000 --- a/Contents/Strings/fr.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "bool_plex_movie_support": "Prise en charge de Plex Movie (Ajouter des thèmes à la mise à jour de Plex Movie agent)", - "bool_plex_series_support": "Prise en charge des agents de la Série Plex (Ajouter des thèmes à l'agent de la Série Plex mis à jour)", - "bool_overwrite_plex_provided_themes": "Écraser les thèmes fournis par Plex", - "bool_prefer_mp4a_codec": "Préférez le Codec AAC MP4A (Améliore la compatibilité avec les appareils Apple)", - "bool_remove_unused_theme_songs": "Supprimer les chansons de thème inutilisées (libère de l'espace dans le répertoire de métadonnées de Plex)", - "bool_remove_unused_art": "Supprimer les œuvres inutilisées (s'applique aux collections, libère de l'espace dans votre répertoire de métadonnées de Plex)", - "bool_remove_unused_posters": "Supprimer les affiches inutilisées (s'applique aux collections, libère de l'espace dans le répertoire de vos métadonnées de Plex)", - "bool_auto_update_items": "Mettre à jour automatiquement les éléments (seuls les éléments modifiés ou précédemment manquants dans ThemerrDB)", - "bool_auto_update_movie_themes": "Mettre à jour les thèmes de film lors de la mise à jour automatique", - "bool_auto_update_tv_themes": "Mettre à jour les thèmes de la télévision lors de la mise à jour automatique", - "bool_auto_update_collection_themes": "Mettre à jour les thèmes de collection lors de la mise à jour automatique", - "bool_update_collection_metadata_plex_movie": "Mettre à jour les métadonnées de la collection pour Plex Movie agent (Met à jour l'affiche, l'art et le résumé)", - "bool_update_collection_metadata_legacy": "Mettre à jour les métadonnées de la collection des anciens agents (mise à jour de l'affiche, de l'art et du résumé)", - "int_update_themes_interval": "Intervalle pour la tâche de mise à jour automatique, en minutes (min. 15)", - "int_update_database_cache_interval": "Intervalle pour la tâche de mise à jour du cache de la base de données, en minutes (min. 15)", - "int_plexapi_plexapi_timeout": "Délai d'attente de PlexAPI, en secondes (min : 1)", - "int_plexapi_upload_retries_max": "Nombre maximum de tentatives, nombre entier (min : 0)", - "int_plexapi_upload_threads": "Fil multitraitement, entier (min: 1)", - "str_youtube_cookies": "Cookies YouTube (format JSON)", - "enum_webapp_locale": "Web UI Locale", - "str_webapp_http_host": "Adresse de l'hôte de l'interface utilisateur Web (nécessite le redémarrage de Plex Media Server)", - "int_webapp_http_port": "Port de l'interface Web (nécessite le redémarrage de Plex Media Server)", - "bool_webapp_log_werkzeug_messages": "Journaliser tous les messages du serveur web (nécessite le redémarrage de Plex Media Server)", - "bool_migrate_locked_themes": "Migrer les thèmes de < v0.3.0 (Si vous avez utilisé Themerr avant la v0.3.0, définissez ceci à True)", - "bool_migrate_locked_collection_fields": "Migrer les métadonnées de la collection de < v0.3.0 (Si vous avez utilisé Themerr avant la v0.3.0, définissez ceci à True)", - "bool_ignore_locked_fields": "Ignorer les champs verrouillés (Toujours télécharger les médias, même si les champs sont verrouillés)" -} diff --git a/Contents/Strings/it.json b/Contents/Strings/it.json deleted file mode 100644 index 245f8851..00000000 --- a/Contents/Strings/it.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "bool_plex_movie_support": "Supporto agente Plex Movie (Aggiungi temi all'agente Plex Movie aggiornato)", - "bool_plex_series_support": "Supporto agente Plex Series (Aggiungi temi all'agente Plex Series aggiornato)", - "bool_overwrite_plex_provided_themes": "Sovrascrivi i temi forniti da Plex", - "bool_prefer_mp4a_codec": "Preferire MP4A AAC Codec (Migliora la compatibilità con i dispositivi Apple)", - "bool_remove_unused_theme_songs": "Rimuove i brani del tema inutilizzati (libera spazio nella directory dei metadati di Plex)", - "bool_remove_unused_art": "Rimuovi l'arte inutilizzata (si applica alle collezioni, libera spazio nella tua directory dei metadati di Plex)", - "bool_remove_unused_posters": "Rimuovi i poster inutilizzati (si applica alle collezioni, libera spazio nella directory dei metadati di Plex)", - "bool_auto_update_items": "Aggiorna automaticamente gli elementi (solo gli elementi cambiati o mancanti in ThemerrDB)", - "bool_auto_update_movie_themes": "Aggiorna i temi del film durante l'aggiornamento automatico", - "bool_auto_update_tv_themes": "Aggiorna temi TV durante l'aggiornamento automatico", - "bool_auto_update_collection_themes": "Aggiorna i temi della collezione durante l'aggiornamento automatico", - "bool_update_collection_metadata_plex_movie": "Aggiorna i metadati della collezione per Plex Movie agent (Aggiorna poster, arte e riepilogo)", - "bool_update_collection_metadata_legacy": "Aggiorna i metadati della raccolta per gli agenti legacy (Aggiorna poster, arte e riepilogo)", - "int_update_themes_interval": "Intervallo per attività di aggiornamento automatico, in minuti (min: 15)", - "int_update_database_cache_interval": "Intervallo per il compito di aggiornamento della cache del database, in minuti (min: 15)", - "int_plexapi_plexapi_timeout": "Timeout PlexAPI, in secondi (min: 1)", - "int_plexapi_upload_retries_max": "Max Retries, intero (min: 0)", - "int_plexapi_upload_threads": "Discussioni di elaborazione multiple, intere (min: 1)", - "str_youtube_cookies": "Cookie su YouTube (formato JSON)", - "enum_webapp_locale": "Web UI Locale", - "str_webapp_http_host": "Indirizzo Host UI Web (richiede il riavvio di Plex Media Server)", - "int_webapp_http_port": "Porta UI Web (richiede il riavvio di Plex Media Server)", - "bool_webapp_log_werkzeug_messages": "Registra tutti i messaggi del server web (richiede il riavvio di Plex Media Server)", - "bool_migrate_locked_themes": "Migra temi da < v0.3.0 (Se hai usato Themerr prima di v0.3.0, impostalo su True)", - "bool_migrate_locked_collection_fields": "Migra i metadati della collezione da < v0.3.0 (Se hai usato Themerr prima di v0.3.0, impostalo su True)", - "bool_ignore_locked_fields": "Ignora i campi bloccati (carica sempre il supporto, anche se i campi sono bloccati)" -} diff --git a/Contents/Strings/ja.json b/Contents/Strings/ja.json deleted file mode 100644 index 311314b6..00000000 --- a/Contents/Strings/ja.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "bool_plex_movie_support": "Flex Movie エージェントのサポート (更新された Flex Movie エージェントにテーマを追加)", - "bool_plex_series_support": "プレックス シリーズ エージェントのサポート (更新された プレックス シリーズ エージェントにテーマを追加)", - "bool_overwrite_plex_provided_themes": "プレックスが提供するテーマを上書き", - "bool_prefer_mp4a_codec": "MP4A AAC コーデックを優先 (Apple デバイスとの互換性を向上)", - "bool_remove_unused_theme_songs": "未使用のテーマ曲を削除します(プレックスのメタデータディレクトリの空き容量を確保します)", - "bool_remove_unused_art": "未使用のアートを削除します(コレクションに適用され、プレックスのメタデータディレクトリのスペースを解放します)", - "bool_remove_unused_posters": "未使用のポスターを削除します(コレクションに適用され、プレックスのメタデータディレクトリのスペースを解放します)", - "bool_auto_update_items": "アイテムを自動的に更新します (ThemerrDB で変更または以前に見つからなかったアイテムのみ)", - "bool_auto_update_movie_themes": "自動更新中にムービーテーマを更新", - "bool_auto_update_tv_themes": "自動更新中にテレビ番組テーマを更新する", - "bool_auto_update_collection_themes": "自動更新中にコレクションテーマを更新", - "bool_update_collection_metadata_plex_movie": "プレックスムービーエージェントのコレクションメタデータを更新(アップデートポスター、アート、概要)", - "bool_update_collection_metadata_legacy": "レガシーエージェントのコレクションメタデータを更新(投稿、アート、概要を更新)", - "int_update_themes_interval": "自動更新タスクの間隔 (分) (分: 15)", - "int_update_database_cache_interval": "データベースキャッシュの更新タスクの間隔(分:15分)", - "int_plexapi_plexapi_timeout": "PlexAPI タイムアウト(秒数:1)", - "int_plexapi_upload_retries_max": "最大再試行回数, 整数 (min: 0)", - "int_plexapi_upload_threads": "マルチプロセッシングスレッド, integer (min: 1)", - "str_youtube_cookies": "YouTubeクッキー(JSON形式)", - "enum_webapp_locale": "Web UI Locale", - "str_webapp_http_host": "Web UI ホスト アドレス (プレックス メディア サーバーの再起動が必要)", - "int_webapp_http_port": "Web UI ポート(プレックス メディア サーバーの再起動が必要)", - "bool_webapp_log_werkzeug_messages": "すべてのWebサーバーメッセージをログに記録します(プレックス メディアサーバーの再起動が必要です)", - "bool_migrate_locked_themes": "v0.3.0以前にThemerrを使用していた場合は、これをTrueに設定してください。", - "bool_migrate_locked_collection_fields": "収集メタデータをv0.3.0から移行する(v0.3.0より前にThemerrを使用した場合は、これをTrueに設定してください)", - "bool_ignore_locked_fields": "ロックされたフィールドを無視 (フィールドがロックされていても常にメディアをアップロード)" -} diff --git a/Contents/Strings/pt.json b/Contents/Strings/pt.json deleted file mode 100644 index 161c2c2c..00000000 --- a/Contents/Strings/pt.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "bool_plex_movie_support": "Suporte ao agente de cinema Plex (Adicionar temas ao agente Plex atualizado)", - "bool_plex_series_support": "Suporte de agentes de Séries Plex (Adicionar temas ao agente de séries Plex atualizado)", - "bool_overwrite_plex_provided_themes": "Sobrescrever temas Plex fornecidos", - "bool_prefer_mp4a_codec": "Preferir MP4A AAC Codec (Melhora a compatibilidade com dispositivos Apple)", - "bool_remove_unused_theme_songs": "Remover músicas temáticas não utilizadas (libera espaço em seu diretório de metadados Plex)", - "bool_remove_unused_art": "Remover arte não utilizada (aplica-se a coleções, libera espaço no seu diretório de metadados Plex)", - "bool_remove_unused_posters": "Remover pôsteres não utilizados (aplica-se a coleções, libera espaço no seu diretório de metadados Plex)", - "bool_auto_update_items": "Atualizar automaticamente itens (apenas itens alterados ou anteriormente ausentes no ThemerrDB)", - "bool_auto_update_movie_themes": "Atualizar temas de filmes durante a atualização automática", - "bool_auto_update_tv_themes": "Atualizar temas de tv durante a atualização automática", - "bool_auto_update_collection_themes": "Atualizar temas da coleção durante atualização automática", - "bool_update_collection_metadata_plex_movie": "Atualizar metadados de coleção para o agente Plex de filme (cartaz de atualizações, arte e resumo)", - "bool_update_collection_metadata_legacy": "Atualizar metadados de coleção para agentes legados (Cartaz, arte e resumo)", - "int_update_themes_interval": "Intervalo para atualização automática da tarefa, em minutos (min: 15)", - "int_update_database_cache_interval": "Intervalo para a tarefa de atualização do cache do banco de dados em minutos (min: 15)", - "int_plexapi_plexapi_timeout": "Tempo limite da PlexAPI, em segundos (min: 1)", - "int_plexapi_upload_retries_max": "Recuperação máxima, inteiro (min: 0)", - "int_plexapi_upload_threads": "Multiprocessamento de Threads, inteiro (min: 1)", - "str_youtube_cookies": "Cookies do YouTube (formato JSON)", - "enum_webapp_locale": "Web UI Locale", - "str_webapp_http_host": "Endereço de Host da Web (requer reinicialização Plex Media Server)", - "int_webapp_http_port": "Porta da Web UI (requer Plex Media Server reiniciar)", - "bool_webapp_log_werkzeug_messages": "Registrar todas as mensagens do servidor web (requer Plex Media Server reiniciar)", - "bool_migrate_locked_themes": "Migre temas de < v0.3.0 (Se você usou o Themerr antes da v0.3.0, defina isso como Verdade)", - "bool_migrate_locked_collection_fields": "Migrar metadados da coleção de < v0.3.0 (Se você usou Themerr antes da v0.3.0, defina isto como Verdade)", - "bool_ignore_locked_fields": "Ignorar campos bloqueados (Sempre enviar mídia, mesmo que os campos estejam bloqueados)" -} diff --git a/Contents/Strings/ru.json b/Contents/Strings/ru.json deleted file mode 100644 index 5f42bda1..00000000 --- a/Contents/Strings/ru.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "bool_plex_movie_support": "Поддержка Plex Movie agent (Добавить темы в обновленный Plex Movie агент)", - "bool_plex_series_support": "Поддержка Plex Series agent (добавить темы в обновленный Plex Series Agent)", - "bool_overwrite_plex_provided_themes": "Перезаписать предоставленные темы Plex", - "bool_prefer_mp4a_codec": "Предпочитайте MP4A AAC кодек (улучшает совместимость с устройствами Apple)", - "bool_remove_unused_theme_songs": "Удалить неиспользуемые песни темы (освобождает место в папке метаданных Plex)", - "bool_remove_unused_art": "Удалить неиспользованный арт (применяется к коллекциям, освобождает место в папке метаданных Plex)", - "bool_remove_unused_posters": "Удаление неиспользуемых постеров (применяется к коллекциям, освобождает место в папке метаданных Plex)", - "bool_auto_update_items": "Автоматически обновлять элементы (только измененные или ранее отсутствующие в ThemerrDB)", - "bool_auto_update_movie_themes": "Обновить темы фильмов при автоматическом обновлении", - "bool_auto_update_tv_themes": "Обновление tv показывать темы при автоматическом обновлении", - "bool_auto_update_collection_themes": "Обновление тем коллекции при автоматическом обновлении", - "bool_update_collection_metadata_plex_movie": "Обновить собираемые метаданные для Plex Movie агента (плакат обновлений, арт и сводка)", - "bool_update_collection_metadata_legacy": "Обновление сбора метаданных для старых агентов (плакат обновлений, искусство и резюме)", - "int_update_themes_interval": "Интервал для задачи автоматического обновления, в минутах (мин: 15)", - "int_update_database_cache_interval": "Интервал обновления базы данных в минутах (мин: 15)", - "int_plexapi_plexapi_timeout": "Таймаут PlexAPI в секундах (мин: 1)", - "int_plexapi_upload_retries_max": "Максимум повторов, целое число (мин: 0)", - "int_plexapi_upload_threads": "Многопроцессорные потоки, целое число (мин: 1)", - "str_youtube_cookies": "Cookies YouTube (формат JSON)", - "enum_webapp_locale": "Web UI Locale", - "str_webapp_http_host": "Адрес Web UI хоста (требуется перезапуск Plex Media Server)", - "int_webapp_http_port": "WebUI порт (требуется перезапуск Plex Media Server)", - "bool_webapp_log_werkzeug_messages": "Журнал всех сообщений веб-сервера (требуется перезапуск Plex Media Server)", - "bool_migrate_locked_themes": "Миграция тем из < v0.3.0 (Если вы использовали Themerr до v0.3.0, установите это значение Истинный)", - "bool_migrate_locked_collection_fields": "Мигрировать метаданные коллекции от < v0.3.0 (Если вы использовали Themerr до v0.3.0, установите это значение true)", - "bool_ignore_locked_fields": "Игнорировать заблокированные поля (всегда загружать медиа, даже если поля заблокированы)" -} diff --git a/Contents/Strings/sv.json b/Contents/Strings/sv.json deleted file mode 100644 index 4b50dcc4..00000000 --- a/Contents/Strings/sv.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "bool_plex_movie_support": "Stöd för Plex Movie agent (Lägg till teman till den uppdaterade Plex Movie agenten)", - "bool_plex_series_support": "Plex Series agentstöd (Lägg till teman till den uppdaterade Plex Series agenten)", - "bool_overwrite_plex_provided_themes": "Skriv över Plex angivna teman", - "bool_prefer_mp4a_codec": "Föredrar MP4A AAC Codec (Förbättrar kompatibiliteten med Apple-enheter)", - "bool_remove_unused_theme_songs": "Ta bort oanvända temalåtar (frigör utrymme i din Plex metadatakatalog)", - "bool_remove_unused_art": "Ta bort oanvänd konst (gäller samlingar, frigör utrymme i din Plex metadatakatalog)", - "bool_remove_unused_posters": "Ta bort oanvända affischer (gäller för samlingar, frigör utrymme i din Plex metadatakata)", - "bool_auto_update_items": "Uppdatera objekt automatiskt (endast objekt som ändrats eller tidigare saknats i ThemerrDB)", - "bool_auto_update_movie_themes": "Uppdatera filmteman under automatisk uppdatering", - "bool_auto_update_tv_themes": "Uppdatera TV-serie-teman under automatisk uppdatering", - "bool_auto_update_collection_themes": "Uppdatera samlingsteman vid automatisk uppdatering", - "bool_update_collection_metadata_plex_movie": "Uppdatera samlingsmetadata för Plex Movie agent (Uppdaterar affisch, konst och sammanfattning)", - "bool_update_collection_metadata_legacy": "Uppdatera samling metadata för äldre agenter (Uppdaterar affisch, konst och sammanfattning)", - "int_update_themes_interval": "Intervall för automatisk uppdatering, i minuter (min: 15)", - "int_update_database_cache_interval": "Intervall för uppdateringsuppgift för databascachen, i minuter (min: 15)", - "int_plexapi_plexapi_timeout": "PlexAPI Timeout, i sekunder (min: 1)", - "int_plexapi_upload_retries_max": "Max Försök igen, heltal (min: 0)", - "int_plexapi_upload_threads": "Multiprocessortrådar, heltal (min: 1)", - "str_youtube_cookies": "YouTube Cookies (JSON-format)", - "enum_webapp_locale": "Web UI Locale", - "str_webapp_http_host": "Webb UI värdadress (kräver omstart av Plex Media Server)", - "int_webapp_http_port": "Web UI Port (kräver omstart av Plex Media Server)", - "bool_webapp_log_werkzeug_messages": "Logga alla webbservermeddelanden (kräver omstart av Plex Media Server)", - "bool_migrate_locked_themes": "Migrera teman från < v0.3.0 (Om du använde Themerr innan v0.3.0, sätt detta till True)", - "bool_migrate_locked_collection_fields": "Migrera insamlingsmetadata från < v0.3.0 (Om du använde Themerr innan v0.3.0, sätt detta till True)", - "bool_ignore_locked_fields": "Ignorera låsta fält (ladda alltid upp media, även om fält är låsta)" -} diff --git a/Contents/Strings/tr.json b/Contents/Strings/tr.json deleted file mode 100644 index 40e1e23c..00000000 --- a/Contents/Strings/tr.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "bool_plex_movie_support": "Plex Movie aracı desteği (Güncellenen Plex Movie aracısına temalar ekleyin)", - "bool_plex_series_support": "Plex Serisi aracı desteği (Güncellenmiş Plex Serisi aracısına temalar ekleyin)", - "bool_overwrite_plex_provided_themes": "Plex tarafından sağlanan temaların üzerine yazma", - "bool_prefer_mp4a_codec": "MP4A AAC Codec'i tercih edin (Apple cihazlarıyla uyumluluğu artırır)", - "bool_remove_unused_theme_songs": "Kullanılmayan tema şarkılarını kaldırın (Plex metadata dizininizde yer açar)", - "bool_remove_unused_art": "Kullanılmayan resimleri kaldırma (koleksiyonlar için geçerlidir, Plex metadata dizininizde yer açar)", - "bool_remove_unused_posters": "Kullanılmayan posterleri kaldırın (koleksiyonlar için geçerlidir, Plex metadata dizininizde yer açar)", - "bool_auto_update_items": "Öğeleri otomatik olarak güncelle (yalnızca ThemerrDB'de değiştirilen veya daha önce eksik olan öğeler)", - "bool_auto_update_movie_themes": "Otomatik güncelleme sırasında film temalarını güncelleme", - "bool_auto_update_tv_themes": "Otomatik güncelleme sırasında dizi temalarını güncelleme", - "bool_auto_update_collection_themes": "Otomatik güncelleme sırasında koleksiyon temalarını güncelleme", - "bool_update_collection_metadata_plex_movie": "Plex Movie aracısı için koleksiyon meta verilerini güncelleme (Posteri, resmi ve özeti günceller)", - "bool_update_collection_metadata_legacy": "Eski ajanlar için koleksiyon meta verilerini güncelleyin (Poster, sanat ve özeti günceller)", - "int_update_themes_interval": "Otomatik güncelleme görevi için aralık, dakika cinsinden (min: 15)", - "int_update_database_cache_interval": "Veritabanı önbelleği güncelleme görevi için dakika cinsinden aralık (min: 15)", - "int_plexapi_plexapi_timeout": "PlexAPI Zaman Aşımı, saniye cinsinden (min: 1)", - "int_plexapi_upload_retries_max": "Maksimum Yeniden Deneme, tamsayı (min: 0)", - "int_plexapi_upload_threads": "Çoklu İşlem İş Parçacığı, tamsayı (min: 1)", - "str_youtube_cookies": "YouTube Çerezleri (JSON biçimi)", - "enum_webapp_locale": "Web Arayüzü Yerel Ayarı", - "str_webapp_http_host": "Web UI Ana Bilgisayar Adresi (Plex Media Server'ın yeniden başlatılmasını gerektirir)", - "int_webapp_http_port": "Web UI Bağlantı Noktası (Plex Media Server'ın yeniden başlatılmasını gerektirir)", - "bool_webapp_log_werkzeug_messages": "Tüm web sunucusu mesajlarını günlüğe kaydetme (Plex Media Server'ın yeniden başlatılmasını gerektirir)", - "bool_migrate_locked_themes": "Migrate themes from < v0.3.0 (Themerr'i v0.3.0'dan önce kullandıysanız, bunu True olarak ayarlayın)", - "bool_migrate_locked_collection_fields": "Koleksiyon meta verilerini < v0.3.0'dan taşı (Themerr'i v0.3.0'dan önce kullandıysanız, bunu True olarak ayarlayın)", - "bool_ignore_locked_fields": "Kilitli alanları yoksay (Alanlar kilitli olsa bile her zaman medya yükleyin)" -} diff --git a/Contents/Strings/zh.json b/Contents/Strings/zh.json deleted file mode 100644 index 8f772df7..00000000 --- a/Contents/Strings/zh.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "bool_plex_movie_support": "启用 Plex Movie 代理支持(为最新的 Plex Movie 代理添加主题音乐)", - "bool_plex_series_support": "启用 Plex Series 代理支持(为最新的 Plex Series 代理添加主题音乐)", - "bool_overwrite_plex_provided_themes": "覆盖 Plex 提供的主题音乐", - "bool_prefer_mp4a_codec": "优先使用 MP4A AAC 编解码器(提高与苹果设备的兼容性)", - "bool_remove_unused_theme_songs": "移除未使用的主题音乐(为 Plex 元数据目录释放存储空间)", - "bool_remove_unused_art": "移除未使用的背景(适用于合集,为 Plex 元数据目录释放存储空间)", - "bool_remove_unused_posters": "移除未使用的海报(适用于合集,为 Plex 元数据目录释放存储空间)", - "bool_auto_update_items": "自动更新项目(仅更新在 ThemerrDB 中已更改或之前不存在的项目)", - "bool_auto_update_movie_themes": "在自动更新期间更新电影主题音乐", - "bool_auto_update_tv_themes": "在自动更新期间更新电视节目主题音乐", - "bool_auto_update_collection_themes": "在自动更新期间更新合集主题音乐", - "bool_update_collection_metadata_plex_movie": "更新 Plex Movie 代理的合集元数据(更新海报、背景和简介)", - "bool_update_collection_metadata_legacy": "更新 Legacy 代理的合集元数据(更新海报、背景和简介)", - "int_update_themes_interval": "自动更新任务的间隔时间,以分钟为单位(最小值:15)", - "int_update_database_cache_interval": "数据库缓存更新任务的间隔时间,以分钟为单位(最小值:15)", - "int_plexapi_plexapi_timeout": "PlexAPI 超时时间,以秒为单位(最小值:1)", - "int_plexapi_upload_retries_max": "最大重试次数,整数(最小值:0)", - "int_plexapi_upload_threads": "并行处理线程数,整数(最小值:1)", - "str_youtube_cookies": "YouTube Cookies(JSON 格式)", - "enum_webapp_locale": "Web UI 语言", - "str_webapp_http_host": "Web UI 主机地址(需要重新启动 Plex 媒体服务器)", - "int_webapp_http_port": "Web UI 端口(需要重新启动 Plex 媒体服务器)", - "bool_webapp_log_werkzeug_messages": "记录所有 Web 服务器消息(需要重新启动 Plex 媒体服务器)", - "bool_migrate_locked_themes": "从 < v0.3.0 迁移主题音乐(如果你使用过 Themerr v0.3.0 之前的版本,请启用)", - "bool_migrate_locked_collection_fields": "从 < v0.3.0 迁移合集元数据(如果你使用过 Themerr v0.3.0 之前的版本,请启用)", - "bool_ignore_locked_fields": "忽略锁定的字段(即使字段已被锁定,也总是上传媒体)" -} diff --git a/DOCKER_README.md b/DOCKER_README.md index f47a3c1b..9224d9ea 100644 --- a/DOCKER_README.md +++ b/DOCKER_README.md @@ -1,28 +1,99 @@ -### lizardbyte/themerr-plex +# Docker -This is a [docker-mod](https://linuxserver.github.io/docker-mods/) for -[plex](https://hub.docker.com/r/linuxserver/plex) which adds [Themerr-plex](https://github.com/LizardByte/Themerr-plex) -to plex as a plugin, to be downloaded/updated during container start. +## lizardbyte/themerr-plex -This image extends the plex image, and is not intended to be created as a separate container. +### Using docker run +Create and run the container (substitute your ``): -### Installation +```bash +docker run -d \ + --name=themerr-plex \ + --restart=unless-stopped + -v :/config \ + -e PUID= \ + -e PGID= \ + -e TZ= \ + -p 9494:9494 \ + lizardbyte/themerr-plex +``` -In plex docker arguments, set an environment variable `DOCKER_MODS=lizardbyte/themerr-plex:latest` or -`DOCKER_MODS=ghcr.io/lizardbyte/themerr-plex:latest` +To update the container it must be removed and recreated: -If adding multiple mods, enter them in an array separated by `|`, such as -`DOCKER_MODS=lizardbyte/themerr-plex:latest|linuxserver/mods:other-plex-mod` +```bash +# Stop the container +docker stop themerr-plex +# Remove the container +docker rm themerr-plex +# Pull the latest update +docker pull lizardbyte/themerr-plex +# Run the container with the same parameters as before +docker run -d ... +``` -### Supported Architectures +### Using docker-compose -Specifying `lizardbyte/themerr-plex:latest` or `ghcr.io/lizardbyte/themerr-plex:latest` should retrieve the correct -image for your architecture. +Create a `docker-compose.yml` file with the following contents (substitute your ``): -The architectures supported by this image are: +```yaml +version: '3' +services: + themerr-plex: + image: lizardbyte/themerr-plex + container_name: themerr-plex + restart: unless-stopped + volumes: + - :/config + environment: + - PUID= + - PGID= + - TZ= + ports: + - 9494:9494 +``` -| Architecture | Available | -|:------------:|:---------:| -| x86-64 | ✅ | -| arm64 | ✅ | -| armhf | ✅ | +Create and start the container (run the command from the same folder as your `docker-compose.yml` file): + +```bash +docker-compose up -d +``` + +To update the container: +```bash +# Pull the latest update +docker-compose pull +# Update and restart the container +docker-compose up -d +``` + +### Parameters +You must substitute the `` with your own settings. + +Parameters are split into two halves separated by a colon. The left side represents the host and the right side the +container. + +**Example:** `-p external:internal` - This shows the port mapping from internal to external of the container. +Therefore `-p 9696:9696` would expose port `9696` from inside the container to be accessible from the host's IP on port +`9696` (e.g. `http://:9696`). The internal port must be `9696`, but the external port may be changed +(e.g. `-p 8080:9696`). + + +| Parameter | Function | Example Value | Required | +|-----------------------------|--------------------------------------------------------------------------------------|----------------------|:--------:| +| `-p :9494` | Web UI Port | `9494` | True | +| `-v :/config` | Volume mapping | `/home/themerr-plex` | True | +| `-e PUID=` | User ID | `1001` | False | +| `-e PGID=` | Group ID | `1001` | False | +| `-e TZ=` | Lookup TZ value [here](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) | `America/New_York` | True | + +### User / Group Identifiers: + +When using data volumes (-v flags) permissions issues can arise between the host OS and the container. To avoid this +issue you can specify the user PUID and group PGID. Ensure the data volume directory on the host is owned by the same +user you specify. + +In this instance `PUID=1001` and `PGID=1001`. To find yours use id user as below: + +```bash +$ id dockeruser +uid=1001(dockeruser) gid=1001(dockergroup) groups=1001(dockergroup) +``` diff --git a/Dockerfile b/Dockerfile index 8c00acb1..9f8ab140 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,93 +1,106 @@ -# syntax=docker/dockerfile:1.4 # artifacts: false -# platforms: linux/amd64,linux/arm64/v8,linux/arm/v7 -FROM ubuntu:22.04 AS buildstage +# platforms: linux/amd64,linux/arm64/v8 +FROM python:3.12-slim-bookworm AS base -# build args -ARG BUILD_VERSION -ARG COMMIT -ARG GITHUB_SHA=$COMMIT -# note: BUILD_VERSION may be blank, COMMIT is also available -# note: build_plist.py uses BUILD_VERSION and GITHUB_SHA +FROM base AS build SHELL ["/bin/bash", "-o", "pipefail", "-c"] -# install dependencies + +# install build dependencies RUN <<_DEPS #!/bin/bash set -e + +dependencies=( + "build-essential" + "libjpeg-dev" # pillow + "npm" # web dependencies + "pkg-config" + "libopenblas-dev" + "zlib1g-dev" # pillow +) apt-get update -y -apt-get install -y --no-install-recommends \ - npm=8.5.* \ - patch \ - python2=2.7.18* \ - python-pip=20.3.4* +apt-get install -y --no-install-recommends "${dependencies[@]}" apt-get clean rm -rf /var/lib/apt/lists/* _DEPS -# create build dir and copy GitHub repo there -COPY --link . /build +# python virtualenv +RUN python -m venv /opt/venv +# use the virtualenv: +ENV PATH="/opt/venv/bin:$PATH" -# set build dir +# setup app directory WORKDIR /build +COPY . . -# update pip -RUN <<_PIP -#!/bin/bash -set -e -python2 -m pip --no-python-version-warning --disable-pip-version-check install --no-cache-dir --upgrade \ - pip setuptools requests -# requests required to install python-plexapi -# dev requirements not necessary for docker image, significantly speeds up build since lxml doesn't need to build -_PIP - -# build plugin -RUN <<_BUILD +# setup python requirements +RUN <<_REQUIREMENTS #!/bin/bash set -e -python2 -m pip --no-python-version-warning --disable-pip-version-check install --no-cache-dir --upgrade \ - -r requirements-build.txt -python2 -m pip --no-python-version-warning --disable-pip-version-check install --no-cache-dir --upgrade \ - --target=./Contents/Libraries/Shared -r requirements.txt --no-warn-script-location -python2 ./scripts/_locale.py --compile -python2 ./scripts/build_plist.py -_BUILD - -## patch youtube-dl, cannot use git apply because we don't pass in any git files -#WORKDIR /build/Contents/Libraries/Shared -#RUN <<_PATCH -##!/bin/bash -#set -e -#patch_dir=/build/patches -#patch -p1 < "${patch_dir}/youtube_dl-compat.patch" -#_PATCH +python -m pip install --no-cache-dir --upgrade pip setuptools wheel +python -m pip install --no-cache-dir -r requirements.txt +_REQUIREMENTS -WORKDIR /build +# compile locales +RUN python scripts/_locale.py --compile # setup npm and dependencies RUN <<_NPM #!/bin/bash set -e npm install -mv ./node_modules ./Contents/Resources/web +mv -f ./node_modules/ ./web/ _NPM -# clean -RUN <<_CLEAN +# compile docs +WORKDIR /build/docs +RUN sphinx-build -M html source build + +FROM base AS app + +# copy app from builder +COPY --from=build /build/ /app/ + +# copy python venv +COPY --from=build /opt/venv/ /opt/venv/ +# use the venv +ENV PATH="/opt/venv/bin:$PATH" +# site-packages are in /opt/venv/lib/python/site-packages/ + +# setup remaining env variables +ENV THEMERR_DOCKER=True + +# network setup +EXPOSE 9494 + +# setup user +ARG PGID=1000 +ENV PGID=${PGID} +ARG PUID=1000 +ENV PUID=${PUID} +ENV TZ="UTC" +ARG UNAME=lizard +ENV UNAME=${UNAME} + +ENV HOME=/home/$UNAME + +# setup user +RUN <<_SETUP_USER #!/bin/bash set -e -rm -rf ./patches/ -rm -rf ./scripts/ -# list contents -ls -a -_CLEAN +groupadd -f -g "${PGID}" "${UNAME}" +useradd -lm -d ${HOME} -s /bin/bash -g "${PGID}" -u "${PUID}" "${UNAME}" +mkdir -p ${HOME}/.config/themerr-plex +ln -s ${HOME}/.config/themerr-plex /config +chown -R ${UNAME} ${HOME} +_SETUP_USER -FROM scratch AS deploy +# mounts +VOLUME /config -# variables -ARG PLUGIN_NAME="Themerr-plex.bundle" -ARG PLUGIN_DIR="/config/Library/Application Support/Plex Media Server/Plug-ins" +USER ${UNAME} +WORKDIR ${HOME} -# add files from buildstage -# trailing slash on build directory copies the contents of the directory, instead of the directory itself -COPY --link --from=buildstage /build/ $PLUGIN_DIR/$PLUGIN_NAME +ENTRYPOINT ["python", "./src/themerr_plex.py"] +HEALTHCHECK --start-period=90s CMD python ./src/themerr_plex.py --docker_healthcheck || exit 1 diff --git a/crowdin.yml b/crowdin.yml index d13c69e5..b8437427 100644 --- a/crowdin.yml +++ b/crowdin.yml @@ -2,6 +2,7 @@ "base_path": "." "base_url": "https://api.crowdin.com" # optional (for Crowdin Enterprise only) "preserve_hierarchy": true # false will flatten tree on crowdin, but doesn't work with dest option +"pull_request_title": "chore(l10n): update translations" "pull_request_labels": [ "crowdin", "l10n" @@ -9,9 +10,9 @@ "files": [ { - "source": "/Contents/Strings/*.po", + "source": "/locale/*.po", "dest": "/%original_file_name%", - "translation": "/Contents/Strings/%two_letters_code%/LC_MESSAGES/%original_file_name%", + "translation": "/locale/%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 @@ -20,18 +21,5 @@ } }, "update_option": "update_as_unapproved" - }, - { - "source": "/Contents/Strings/en.json", - "dest": "/themerr-plex.json", - "translation": "/Contents/Strings/%two_letters_code%.%file_extension%", - "languages_mapping": { - "two_letters_code": { - # map non-two letter codes here, left side is crowdin designation, right side is plex designation - "en-GB": "en-gb", - "en-US": "en-us" - } - }, - "update_option": "update_as_unapproved" } ] diff --git a/docs/source/about/docker.rst b/docs/source/about/docker.rst index e2bd3179..4f43056a 100644 --- a/docs/source/about/docker.rst +++ b/docs/source/about/docker.rst @@ -1,6 +1,2 @@ -:github_url: https://github.com/LizardByte/Themerr-plex/blob/master/DOCKER_README.md - -Docker ------- - -.. mdinclude:: ../../../DOCKER_README.md +.. include:: ../../../DOCKER_README.md + :parser: myst_parser.docutils_ diff --git a/docs/source/code_docs/general_helper.rst b/docs/source/code_docs/general_helper.rst deleted file mode 100644 index 7143af31..00000000 --- a/docs/source/code_docs/general_helper.rst +++ /dev/null @@ -1,9 +0,0 @@ -:github_url: https://github.com/LizardByte/Themerr-plex/blob/master/Contents/Code/general_helper.py - -.. include:: ../global.rst - -:modname:`general_helper` --------------------------- -.. automodule:: Code.general_helper - :members: - :show-inheritance: diff --git a/docs/source/code_docs/lizardbyte_db_helper.rst b/docs/source/code_docs/lizardbyte_db_helper.rst deleted file mode 100644 index 8fcf8e3f..00000000 --- a/docs/source/code_docs/lizardbyte_db_helper.rst +++ /dev/null @@ -1,9 +0,0 @@ -:github_url: https://github.com/LizardByte/Themerr-plex/blob/master/Contents/Code/lizardbyte_db_helper.py - -.. include:: ../global.rst - -:modname:`lizardbyte_db_helper` -------------------------------- -.. automodule:: Code.lizardbyte_db_helper - :members: - :show-inheritance: diff --git a/docs/source/code_docs/main.rst b/docs/source/code_docs/main.rst deleted file mode 100644 index 5c8a4212..00000000 --- a/docs/source/code_docs/main.rst +++ /dev/null @@ -1,9 +0,0 @@ -:github_url: https://github.com/LizardByte/Themerr-plex/blob/master/Contents/Code/__init__.py - -.. include:: ../global.rst - -:modname:`__init__` ------------------------- -.. automodule:: Code - :members: - :show-inheritance: diff --git a/docs/source/code_docs/migration_helper.rst b/docs/source/code_docs/migration_helper.rst deleted file mode 100644 index 9f8b7eb1..00000000 --- a/docs/source/code_docs/migration_helper.rst +++ /dev/null @@ -1,12 +0,0 @@ -:github_url: https://github.com/LizardByte/Themerr-plex/blob/master/Contents/Code/migration_helper.py - -.. include:: ../global.rst - -:modname:`migration_helper` ---------------------------- -.. automodule:: Code.migration_helper - :members: - :inherited-members: - :private-members: - :show-inheritance: - :undoc-members: diff --git a/docs/source/code_docs/plex_api_helper.rst b/docs/source/code_docs/plex_api_helper.rst deleted file mode 100644 index 76acff11..00000000 --- a/docs/source/code_docs/plex_api_helper.rst +++ /dev/null @@ -1,9 +0,0 @@ -:github_url: https://github.com/LizardByte/Themerr-plex/blob/master/Contents/Code/plex_api_helper.py - -.. include:: ../global.rst - -:modname:`plex_api_helper` --------------------------- -.. automodule:: Code.plex_api_helper - :members: - :show-inheritance: diff --git a/docs/source/code_docs/scheduled_tasks.rst b/docs/source/code_docs/scheduled_tasks.rst deleted file mode 100644 index edfa4ccb..00000000 --- a/docs/source/code_docs/scheduled_tasks.rst +++ /dev/null @@ -1,9 +0,0 @@ -:github_url: https://github.com/LizardByte/Themerr-plex/blob/master/Contents/Code/scheduled_tasks.py - -.. include:: ../global.rst - -:modname:`scheduled_tasks` ----------------------------- -.. automodule:: Code.scheduled_tasks - :members: - :show-inheritance: diff --git a/docs/source/code_docs/themerr_db_helper.rst b/docs/source/code_docs/themerr_db_helper.rst deleted file mode 100644 index 0b7567c6..00000000 --- a/docs/source/code_docs/themerr_db_helper.rst +++ /dev/null @@ -1,9 +0,0 @@ -:github_url: https://github.com/LizardByte/Themerr-plex/blob/master/Contents/Code/themerr_db_helper.py - -.. include:: ../global.rst - -:modname:`themerr_db_helper` ----------------------------- -.. automodule:: Code.themerr_db_helper - :members: - :show-inheritance: diff --git a/docs/source/code_docs/tmdb_helper.rst b/docs/source/code_docs/tmdb_helper.rst deleted file mode 100644 index 4d73092b..00000000 --- a/docs/source/code_docs/tmdb_helper.rst +++ /dev/null @@ -1,9 +0,0 @@ -:github_url: https://github.com/LizardByte/Themerr-plex/blob/master/Contents/Code/tmdb_helper.py - -.. include:: ../global.rst - -:modname:`tmdb_helper` ----------------------------- -.. automodule:: Code.tmdb_helper - :members: - :show-inheritance: diff --git a/docs/source/code_docs/webapp.rst b/docs/source/code_docs/webapp.rst deleted file mode 100644 index f255825c..00000000 --- a/docs/source/code_docs/webapp.rst +++ /dev/null @@ -1,9 +0,0 @@ -:github_url: https://github.com/LizardByte/Themerr-plex/blob/master/Contents/Code/webapp.py - -.. include:: ../global.rst - -:modname:`webapp` ----------------------------- -.. automodule:: Code.webapp - :members: - :show-inheritance: diff --git a/docs/source/code_docs/youtube_dl_helper.rst b/docs/source/code_docs/youtube_dl_helper.rst deleted file mode 100644 index 8dcd6203..00000000 --- a/docs/source/code_docs/youtube_dl_helper.rst +++ /dev/null @@ -1,9 +0,0 @@ -:github_url: https://github.com/LizardByte/Themerr-plex/blob/master/Contents/Code/youtube_dl_helper.py - -.. include:: ../global.rst - -:modname:`youtube_dl_helper` ----------------------------- -.. automodule:: Code.youtube_dl_helper - :members: - :show-inheritance: diff --git a/docs/source/conf.py b/docs/source/conf.py index 82ab3873..e12a824e 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -19,20 +19,18 @@ script_dir = os.path.dirname(os.path.abspath(__file__)) # the directory of this file source_dir = os.path.dirname(script_dir) # the source folder directory root_dir = os.path.dirname(source_dir) # the root folder directory +src_dir = os.path.join(root_dir, 'src') # the src folder directory - -paths = [ - os.path.join(root_dir, 'Contents', 'Libraries', 'Shared'), # location of plugin dependencies - os.path.join(root_dir, 'Contents'), # location of "Code" module, aka the Plugin -] - -for directory in paths: - sys.path.insert(0, directory) +try: + sys.path.insert(0, src_dir) + from common import definitions # put this in a try/except to prevent flake8 warning +except Exception as e: + print(f"Unable to import definitions from {root_dir}: {e}") + sys.exit(1) # -- Project information ----------------------------------------------------- -project = 'Themerr-plex' -project_copyright = '%s, %s' % (datetime.now().year, project) -epub_copyright = project_copyright +project = definitions.Names().name +project_copyright = f'{datetime.now().year}, {project}' author = 'ReenigneArcher' # The full version, including alpha/beta/rc tags @@ -46,7 +44,7 @@ # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'm2r2', # enable markdown files + 'myst_parser', # enable markdown files 'numpydoc', # this automatically loads `sphinx.ext.autosummary` as well 'sphinx.ext.autodoc', # autodocument modules 'sphinx.ext.autosectionlabel', @@ -64,7 +62,10 @@ exclude_patterns = ['toc.rst'] # Extensions to include. -source_suffix = ['.rst', '.md'] +source_suffix = { + '.rst': 'restructuredtext', + '.md': 'markdown', +} # Change default contents file master_doc = 'index' @@ -72,8 +73,8 @@ # -- Options for HTML output ------------------------------------------------- # images -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') +html_favicon = os.path.join(definitions.Paths().ROOT_DIR, 'web', 'images', 'favicon.ico') +html_logo = os.path.join(definitions.Paths().ROOT_DIR, 'web', 'images', '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, @@ -91,23 +92,11 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'sphinx_rtd_theme' +html_theme = 'furo' html_theme_options = { - 'analytics_id': 'G-SSW90X5YZX', # Provided by Google in your dashboard - 'analytics_anonymize_ip': False, - 'logo_only': False, - 'display_version': True, - 'prev_next_buttons_location': 'bottom', - 'style_external_links': True, - 'vcs_pageview_mode': 'blob', - 'style_nav_header_background': '#151515', - # Toc options - 'collapse_navigation': True, - 'sticky_navigation': True, - 'navigation_depth': 4, - 'includehidden': True, - 'titles_only': False, + "top_of_page_button": "edit", + "source_edit_link": "https://github.com/lizardbyte/themerr-plex/blob/master/docs/source/{filename}", } # extension config options @@ -122,11 +111,11 @@ # https://github.com/readthedocs/readthedocs.org/blob/eadf6ac6dc6abc760a91e1cb147cc3c5f37d1ea8/docs/conf.py#L235-L236 suppress_warnings = ["epub.unknown_project_files"] -python_version = '{}.{}'.format(sys.version_info.major, sys.version_info.minor) +python_version = f'{sys.version_info.major}.{sys.version_info.minor}' intersphinx_mapping = { 'python': ('https://docs.python.org/{}/'.format(python_version), None), - 'plexapi': ('https://docs.lizardbyte.dev/projects/python-plexapi-backport/en/latest/', None), + 'plexapi': ('https://python-plexapi.readthedocs.io/en/latest/', None), } numpydoc_show_class_members = True diff --git a/docs/source/src/common/common.rst b/docs/source/src/common/common.rst new file mode 100644 index 00000000..0f301a6a --- /dev/null +++ b/docs/source/src/common/common.rst @@ -0,0 +1,7 @@ +.. include:: ../global.rst + +:modname:`common.__init__` +-------------------------- +.. automodule:: common + :members: + :show-inheritance: diff --git a/docs/source/src/common/config.rst b/docs/source/src/common/config.rst new file mode 100644 index 00000000..30f6b6c9 --- /dev/null +++ b/docs/source/src/common/config.rst @@ -0,0 +1,7 @@ +.. include:: ../global.rst + +:modname:`common.config` +------------------------ +.. automodule:: common.config + :members: + :show-inheritance: diff --git a/docs/source/src/common/crypto.rst b/docs/source/src/common/crypto.rst new file mode 100644 index 00000000..095f0745 --- /dev/null +++ b/docs/source/src/common/crypto.rst @@ -0,0 +1,7 @@ +.. include:: ../global.rst + +:modname:`common.crypto` +-------------------------- +.. automodule:: common.crypto + :members: + :show-inheritance: diff --git a/docs/source/src/common/definitions.rst b/docs/source/src/common/definitions.rst new file mode 100644 index 00000000..86c96465 --- /dev/null +++ b/docs/source/src/common/definitions.rst @@ -0,0 +1,7 @@ +.. include:: ../global.rst + +:modname:`common.definitions` +----------------------------- +.. automodule:: common.definitions + :members: + :show-inheritance: diff --git a/docs/source/src/common/helpers.rst b/docs/source/src/common/helpers.rst new file mode 100644 index 00000000..9f08d062 --- /dev/null +++ b/docs/source/src/common/helpers.rst @@ -0,0 +1,7 @@ +.. include:: ../global.rst + +:modname:`common.helpers` +------------------------- +.. automodule:: common.helpers + :members: + :show-inheritance: diff --git a/docs/source/src/common/locales.rst b/docs/source/src/common/locales.rst new file mode 100644 index 00000000..f6a1776c --- /dev/null +++ b/docs/source/src/common/locales.rst @@ -0,0 +1,7 @@ +.. include:: ../global.rst + +:modname:`common.locales` +------------------------- +.. automodule:: common.locales + :members: + :show-inheritance: diff --git a/docs/source/src/common/logger.rst b/docs/source/src/common/logger.rst new file mode 100644 index 00000000..5451e66a --- /dev/null +++ b/docs/source/src/common/logger.rst @@ -0,0 +1,7 @@ +.. include:: ../global.rst + +:modname:`common.logger` +------------------------ +.. automodule:: common.logger + :members: + :show-inheritance: diff --git a/docs/source/src/common/threads.rst b/docs/source/src/common/threads.rst new file mode 100644 index 00000000..dda872f9 --- /dev/null +++ b/docs/source/src/common/threads.rst @@ -0,0 +1,7 @@ +.. include:: ../global.rst + +:modname:`common.threads` +------------------------- +.. automodule:: common.threads + :members: + :show-inheritance: diff --git a/docs/source/src/common/tray_icon.rst b/docs/source/src/common/tray_icon.rst new file mode 100644 index 00000000..ea83144d --- /dev/null +++ b/docs/source/src/common/tray_icon.rst @@ -0,0 +1,7 @@ +.. include:: ../global.rst + +:modname:`common.tray_icon` +--------------------------- +.. automodule:: common.tray_icon + :members: + :show-inheritance: diff --git a/docs/source/src/common/webapp.rst b/docs/source/src/common/webapp.rst new file mode 100644 index 00000000..68ce3388 --- /dev/null +++ b/docs/source/src/common/webapp.rst @@ -0,0 +1,7 @@ +.. include:: ../global.rst + +:modname:`common.webapp` +------------------------ +.. automodule:: common.webapp + :members: + :show-inheritance: diff --git a/docs/source/global.rst b/docs/source/src/global.rst similarity index 100% rename from docs/source/global.rst rename to docs/source/src/global.rst diff --git a/docs/source/src/themerr_plex.rst b/docs/source/src/themerr_plex.rst new file mode 100644 index 00000000..3449cd89 --- /dev/null +++ b/docs/source/src/themerr_plex.rst @@ -0,0 +1,7 @@ +.. include:: global.rst + +:modname:`themerr_plex` +----------------------- +.. automodule:: themerr_plex + :members: + :show-inheritance: diff --git a/docs/source/toc.rst b/docs/source/toc.rst index ea9ea9ce..34da1170 100644 --- a/docs/source/toc.rst +++ b/docs/source/toc.rst @@ -20,16 +20,17 @@ .. toctree:: :maxdepth: 0 - :caption: Plugin Code + :caption: Source Code :titlesonly: - code_docs/main - code_docs/general_helper - code_docs/lizardbyte_db_helper - code_docs/migration_helper - code_docs/plex_api_helper - code_docs/scheduled_tasks - code_docs/themerr_db_helper - code_docs/tmdb_helper - code_docs/webapp - code_docs/youtube_dl_helper + src/themerr_plex + src/common/common + src/common/config + src/common/crypto + src/common/definitions + src/common/helpers + src/common/locales + src/common/logger + src/common/threads + src/common/tray_icon + src/common/webapp diff --git a/Contents/Strings/aa/LC_MESSAGES/themerr-plex.po b/locale/aa/LC_MESSAGES/themerr-plex.po similarity index 100% rename from Contents/Strings/aa/LC_MESSAGES/themerr-plex.po rename to locale/aa/LC_MESSAGES/themerr-plex.po diff --git a/Contents/Strings/de/LC_MESSAGES/themerr-plex.po b/locale/de/LC_MESSAGES/themerr-plex.po similarity index 100% rename from Contents/Strings/de/LC_MESSAGES/themerr-plex.po rename to locale/de/LC_MESSAGES/themerr-plex.po diff --git a/Contents/Strings/en/LC_MESSAGES/themerr-plex.po b/locale/en/LC_MESSAGES/themerr-plex.po similarity index 100% rename from Contents/Strings/en/LC_MESSAGES/themerr-plex.po rename to locale/en/LC_MESSAGES/themerr-plex.po diff --git a/Contents/Strings/en_GB/LC_MESSAGES/themerr-plex.po b/locale/en_GB/LC_MESSAGES/themerr-plex.po similarity index 100% rename from Contents/Strings/en_GB/LC_MESSAGES/themerr-plex.po rename to locale/en_GB/LC_MESSAGES/themerr-plex.po diff --git a/Contents/Strings/en_US/LC_MESSAGES/themerr-plex.po b/locale/en_US/LC_MESSAGES/themerr-plex.po similarity index 100% rename from Contents/Strings/en_US/LC_MESSAGES/themerr-plex.po rename to locale/en_US/LC_MESSAGES/themerr-plex.po diff --git a/Contents/Strings/es/LC_MESSAGES/themerr-plex.po b/locale/es/LC_MESSAGES/themerr-plex.po similarity index 100% rename from Contents/Strings/es/LC_MESSAGES/themerr-plex.po rename to locale/es/LC_MESSAGES/themerr-plex.po diff --git a/Contents/Strings/fr/LC_MESSAGES/themerr-plex.po b/locale/fr/LC_MESSAGES/themerr-plex.po similarity index 100% rename from Contents/Strings/fr/LC_MESSAGES/themerr-plex.po rename to locale/fr/LC_MESSAGES/themerr-plex.po diff --git a/Contents/Strings/it/LC_MESSAGES/themerr-plex.po b/locale/it/LC_MESSAGES/themerr-plex.po similarity index 100% rename from Contents/Strings/it/LC_MESSAGES/themerr-plex.po rename to locale/it/LC_MESSAGES/themerr-plex.po diff --git a/Contents/Strings/ja/LC_MESSAGES/themerr-plex.po b/locale/ja/LC_MESSAGES/themerr-plex.po similarity index 100% rename from Contents/Strings/ja/LC_MESSAGES/themerr-plex.po rename to locale/ja/LC_MESSAGES/themerr-plex.po diff --git a/Contents/Strings/pt/LC_MESSAGES/themerr-plex.po b/locale/pt/LC_MESSAGES/themerr-plex.po similarity index 100% rename from Contents/Strings/pt/LC_MESSAGES/themerr-plex.po rename to locale/pt/LC_MESSAGES/themerr-plex.po diff --git a/Contents/Strings/ru/LC_MESSAGES/themerr-plex.po b/locale/ru/LC_MESSAGES/themerr-plex.po similarity index 100% rename from Contents/Strings/ru/LC_MESSAGES/themerr-plex.po rename to locale/ru/LC_MESSAGES/themerr-plex.po diff --git a/Contents/Strings/sv/LC_MESSAGES/themerr-plex.po b/locale/sv/LC_MESSAGES/themerr-plex.po similarity index 100% rename from Contents/Strings/sv/LC_MESSAGES/themerr-plex.po rename to locale/sv/LC_MESSAGES/themerr-plex.po diff --git a/Contents/Strings/themerr-plex.po b/locale/themerr-plex.po similarity index 100% rename from Contents/Strings/themerr-plex.po rename to locale/themerr-plex.po diff --git a/Contents/Strings/tr/LC_MESSAGES/themerr-plex.po b/locale/tr/LC_MESSAGES/themerr-plex.po similarity index 100% rename from Contents/Strings/tr/LC_MESSAGES/themerr-plex.po rename to locale/tr/LC_MESSAGES/themerr-plex.po diff --git a/Contents/Strings/zh/LC_MESSAGES/themerr-plex.po b/locale/zh/LC_MESSAGES/themerr-plex.po similarity index 100% rename from Contents/Strings/zh/LC_MESSAGES/themerr-plex.po rename to locale/zh/LC_MESSAGES/themerr-plex.po diff --git a/package.json b/package.json index 1d98de0d..a0c75092 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "@fontsource/open-sans": "5.1.0", "@fortawesome/fontawesome-free": "6.6.0", "bootstrap": "5.3.3", - "jquery": "3.7.1" + "jquery": "3.7.1", + "parsleyjs": "2.9.2" } } diff --git a/requirements-build.txt b/requirements-build.txt deleted file mode 100644 index 9d236e72..00000000 --- a/requirements-build.txt +++ /dev/null @@ -1 +0,0 @@ -Babel==2.9.1 diff --git a/requirements-dev.txt b/requirements-dev.txt index 591272c4..76c728f3 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,11 +1,6 @@ -# development environment requirements, these should not be distributed -flake8==3.9.2;python_version<"3" -m2r2==0.3.2;python_version<"3" -numpydoc==0.9.2;python_version<"3" -plexhints==2024.809.14117 # type hinting library for plex development -plexapi-backport[alert]==4.15.10 -pytest==4.6.11;python_version<"3" -pytest-cov==2.12.1;python_version<"3" -rstcheck==3.5.0;python_version<"3" -Sphinx==1.8.6;python_version<"3" -sphinx-rtd-theme==1.2.0;python_version<"3" +-r requirements.txt +flake8==7.1.1 +pyinstaller==6.9.0 +pytest==8.3.2 +pytest-cov==5.0.0 +rstcheck[sphinx]==6.2.4 diff --git a/requirements.txt b/requirements.txt index bd1a96c3..44aa7b00 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,20 +1,22 @@ -# 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==1.0.0;python_version<"3" -future==0.18.3 -plexapi-backport[alert]==4.15.10 # custom python-plexapi supporting python 2.7 -polib==1.2.0;python_version<"3" -requests==2.27.1;python_version<"3" # 2.27 is last version supporting Python 2.7 -schedule==0.6.0;python_version<"3" -six==1.16.0;python_version<"3" -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 -./third-party/youtube-dl - -# required for websocket to pass tests -pysocks==1.7.1;python_version<"3" -win-inet-pton==1.1.0;python_version<"3" and platform_system=="Windows" +babel==2.16.0 +configobj==5.0.8 +cryptography==43.0.0 +flask==3.0.3 +flask-babel==4.0.0 +flask-wtf==1.2.1 +furo==2024.8.6 +future==1.0.0 +myst-parser==4.0.0 +numpydoc==1.7.0 +pillow==9.5.0 +plexapi[alert]==4.15.15 +polib==1.2.0 +pyopenssl==24.2.1 +pystray==0.19.5 +requests==2.32.3 +requests-cache==1.2.1 +schedule==1.2.2 +six==1.16.0 # TODO: probably won't need this +sphinx==7.4.7 +werkzeug==3.0.3 +yt-dlp==2024.8.6 diff --git a/scripts/_locale.py b/scripts/_locale.py index 4e5c821c..d3e9d818 100644 --- a/scripts/_locale.py +++ b/scripts/_locale.py @@ -1,7 +1,5 @@ -# coding=utf-8 """ -.. - _locale.py +scripts/_locale.py Functions related to building, initializing, updating, and compiling localization translations. """ @@ -14,7 +12,7 @@ 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') +locale_dir = os.path.join(root_dir, 'locale') # target locales target_locales = [ @@ -30,7 +28,7 @@ 'ru', # Russian 'sv', # Swedish 'tr', # Turkish - 'zh', # Chinese Simplified + 'zh', # Chinese (Simplified) ] @@ -40,22 +38,22 @@ def babel_extract(): 'pybabel', 'extract', '-F', os.path.join(script_dir, 'babel.cfg'), - '-o', os.path.join(locale_dir, '%s.po' % project_name.lower()), + '-o', os.path.join(locale_dir, f'{project_name.lower()}.po'), '--sort-by-file', - '--msgid-bugs-address=github.com/%s' % project_name.lower(), - '--copyright-holder=%s' % project_name, - '--project=%s' % project_name, + f'--msgid-bugs-address=github.com/{project_name.lower()}', + f'--copyright-holder={project_name}', + f'--project={project_name}', '--version=v0', '--add-comments=NOTE', - './Contents/Resources/web' + './src', + './web', ] print(commands) subprocess.check_output(args=commands, cwd=root_dir) -def babel_init(locale_code): - # type: (str) -> None +def babel_init(locale_code: str): """Executes `pybabel init` in subprocess. :param locale_code: str - locale code @@ -63,7 +61,7 @@ def babel_init(locale_code): commands = [ 'pybabel', 'init', - '-i', os.path.join(locale_dir, '%s.po' % project_name.lower()), + '-i', os.path.join(locale_dir, f'{project_name.lower()}.po'), '-d', locale_dir, '-D', project_name.lower(), '-l', locale_code @@ -78,7 +76,7 @@ def babel_update(): commands = [ 'pybabel', 'update', - '-i', os.path.join(locale_dir, '%s.po' % project_name.lower()), + '-i', os.path.join(locale_dir, f'{project_name.lower()}.po'), '-d', locale_dir, '-D', project_name.lower(), '--update-header-comment' diff --git a/scripts/build.py b/scripts/build.py new file mode 100644 index 00000000..bdea1f2d --- /dev/null +++ b/scripts/build.py @@ -0,0 +1,44 @@ +""" +scripts/build.py + +Creates spec and builds binaries for Themerr-plex. +""" +# standard imports +import sys + +# lib imports +import PyInstaller.__main__ + + +def build(): + """Sets arguments for pyinstaller, creates spec, and builds binaries.""" + pyinstaller_args = [ + './src/themerr_plex.py', + '--onefile', + '--noconfirm', + '--paths=./', + '--add-data=docs:docs', + '--add-data=web:web', + '--add-data=locale:locale', + '--icon=./web/images/favicon.ico' + ] + + if sys.platform.lower() == 'win32': # windows + pyinstaller_args.append('--console') + pyinstaller_args.append('--splash=./web/images/icon-default.png') + + # fix args for windows + for index, arg in enumerate(pyinstaller_args): + pyinstaller_args[index] = arg.replace(':', ';') + elif sys.platform.lower() == 'darwin': # macOS + pyinstaller_args.append('--console') + pyinstaller_args.append('--osx-bundle-identifier=dev.lizardbyte.themerr-plex') + + elif sys.platform.lower() == 'linux': # linux + pyinstaller_args.append('--splash=./web/images/icon-default.png') + + PyInstaller.__main__.run(pyinstaller_args) + + +if __name__ == '__main__': + build() diff --git a/scripts/build_plist.py b/scripts/build_plist.py deleted file mode 100644 index 14ec45cd..00000000 --- a/scripts/build_plist.py +++ /dev/null @@ -1,109 +0,0 @@ -import os -import plistlib - -version = os.getenv('BUILD_VERSION', None) -print('version: %s' % version) - -commit = os.getenv('GITHUB_SHA', 'development build') -print('commit: %s' % commit) - -if not version: - checked = '' - if commit != 'development build': - version = commit[0:7] - print('using commit as version: %s' % version) - else: - version = commit - print('unknown version: %s' % version) -else: - checked = '' - -info_file = os.path.join('Contents', 'Info.plist') - -pl = dict( - CFBundleIdentifier='dev.lizardbyte.themerr-plex', - PlexAgentAttributionText=""" - - - - -
- Themerr-plex
-
-
- A plugin by LizardByte that adds theme songs to - movies. -
-
- - - - - - -
Version: %s%s| Releases
-
- - - - - -
Reference:| Docs
- ]]> - """ % (checked, version), - CFBundleDevelopmentRegion='English', - CFBundleExecutable='', - CFBundlePackageType='AAPL', - CFBundleSignature='hook', - PlexFrameworkVersion='2', - PlexClientPlatforms='', - PlexClientPlatformExclusions='', - PlexPluginClass='Resource', - PlexPluginCodePolicy='Elevated', - PlexPluginConsoleLogging='0', - PlexPluginDebug='1', - PlexPluginMode='Daemon', - PlexPluginRegions=[''], - PlexBundleVersion=version, - PlexShortBundleVersion=version, -) - -# PlexPluginMode: -# This one does nothing with a value of "Always On", a value of "daemon" keeps the plugin alive in the background. - -# PlexClientPlatforms and PlexClientPlatformExclusions: -# Any Clients support or not supported by the plugin. -# Possible values are * for all platforms, MacOSX, Windows, Linux, Roku, Android, iOS, Safari, Firefox, Chrome, LGTV, \ -# Samsung, PlexConnect and Plex Home Theater - -# PlexPluginRegions: -# Possible string values are the proper ISO two-letter code for the country. -# A full list of values are available at http://en.wikipedia.org/wiki/ISO_3166-1_alpha-2 - -# PlexPluginDebug: -# Possible values are 0 and 1. Setting it to "1" rather than "0" turns on debug logging - -# PlexPluginCodePolicy: -# This allows channels to access some python methods which are otherwise blocked, as well as import external code \ -# libraries, and interact with the PMS HTTP API - -# PlexPluginClass: -# This key is used to show that the plugin is an agent. possible values are 'Agent' and 'Resource' - -# PlexPluginConsoleLogging: -# This is used to send plugin log statements directly to stout when running PMS from the command line. \ -# Rarely used anymore - -plist_string = plistlib.writePlistToString(pl).replace('<', '<').replace('>', '>') - -with open(info_file, 'wb') as fp: - fp.write(plist_string) diff --git a/Contents/DefaultPrefs.json b/src/DefaultPrefs.json similarity index 100% rename from Contents/DefaultPrefs.json rename to src/DefaultPrefs.json diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/common/__init__.py b/src/common/__init__.py new file mode 100644 index 00000000..25c838b7 --- /dev/null +++ b/src/common/__init__.py @@ -0,0 +1,142 @@ +""" +src/common/__init__.py + +Responsible for initialization of Themerr-plex. +""" +# standard imports +import os +import subprocess +import sys +import threading +from typing import Union + +# local imports +from common import config +from common import definitions +from common import helpers +from common import logger + +# get logger +log = logger.get_logger(name=__name__) + +_INITIALIZED = False +CONFIG = None +CONFIG_FILE = None +DEBUG = False +DEV = False +SIGNAL = None # Signal to watch for +INIT_LOCK = threading.Lock() +QUIET = False + + +def initialize(config_file: str) -> bool: + """ + Initialize Themerr-plex. + + Sets up config, loggers, and http port. + + Parameters + ---------- + config_file : str + The path to the config file. + + Returns + ------- + bool + True if initialize succeeds, otherwise False. + + Raises + ------ + SystemExit + If unable to correct possible issues with config file. + + Examples + -------- + >>> initialize(config_file='config.ini') + True + """ + with INIT_LOCK: + + global CONFIG + global CONFIG_FILE + global DEBUG + global _INITIALIZED + + try: + CONFIG = config.create_config(config_file=config_file) + except Exception: + raise SystemExit("Unable to initialize due to a corrupted config file. Exiting...") + + CONFIG_FILE = config_file + + assert CONFIG is not None + + logger.blacklist_config(config=CONFIG) # setup log blacklist + + if _INITIALIZED: + return False + + # create logs folder + definitions.Paths.LOG_DIR, log_writable = helpers.check_folder_writable( + folder=definitions.Paths.LOG_DIR, + fallback=os.path.join(definitions.Paths.DATA_DIR, 'logs'), + name='logs' + ) + if not log_writable and not QUIET: + sys.stderr.write("Unable to create the log directory. Logging to screen only.\n") + + # setup loggers... cannot use logging until this is finished + logger.setup_loggers() + + if CONFIG['Network']['HTTP_PORT'] < 21 or CONFIG['Network']['HTTP_PORT'] > 65535: + log.warning(msg=f"HTTP_PORT out of bounds: 21 < {CONFIG['Network']['HTTP_PORT']} < 65535") + CONFIG['Network']['HTTP_PORT'] = 9494 + + DEBUG = DEBUG or bool(CONFIG['Logging']['DEBUG_LOGGING']) + + _INITIALIZED = True + return True + + +def stop(exit_code: Union[int, str] = 0, restart: bool = False): + """ + Stop Themerr-plex. + + This function ends the tray icon if it's running. Then restarts or shutdowns Themerr-plex depending on the value of + the `restart` parameter. + + Parameters + ---------- + exit_code : Union[int, str], default = 0 + The exit code to send. Does not apply if `restart = True`. + restart : bool, default = False + Set to True to restart Themerr-plex. + + Examples + -------- + >>> stop(exit_code=0, restart=False) + """ + # stop the tray icon + from common.tray_icon import tray_end + try: + tray_end() + except AttributeError: + pass + + if restart: + if definitions.Modes.FROZEN: + args = [definitions.Paths.BINARY_PATH] + else: + args = [sys.executable, definitions.Paths.BINARY_PATH] + args += sys.argv[1:] + + if '--nolaunch' not in args: # don't launch the browser again + args += ['--nolaunch'] # also os.execv requires at least one argument + + # os.execv(sys.executable, args) + # `os.execv` is more desirable, but is not working correctly + # flask app does not respond to requests after restarting + # alternative to os.execv() + subprocess.Popen(args=args, cwd=os.getcwd()) + + sys.exit(exit_code) diff --git a/src/common/config.py b/src/common/config.py new file mode 100644 index 00000000..ff44298e --- /dev/null +++ b/src/common/config.py @@ -0,0 +1,751 @@ +""" +src/common/config.py + +Responsible for config related functions. +""" +# standard imports +import base64 +import copy +import sys +from typing import Optional, List + +# lib imports +from configobj import ConfigObj +from validate import Validator, ValidateError + +# local imports +from common import definitions +from common import logger +from common import locales + +# get log +log = logger.get_logger(name=__name__) + +# get the config filename +FILENAME = definitions.Files.CONFIG + +# access the config dictionary here +CONFIG = None + +# localization +_ = locales.get_text() + +# increase CONFIG_VERSION default and max when changing default values +# then do `if CONFIG_VERSION == x:` something to change the old default value to the new default value +# then update the CONFIG_VERSION number + + +# https://regexpattern.com/windows-folder-path/ +# https://regexpattern.com/linux-folder-path/ +regex_directory = r'^[a-zA-Z]:\\(?:\w+\\?)*$' if definitions.Platform.os_platform == 'win32' else r'^\/(?:[^/]+\/)*$' + + +def on_change_tray_toggle() -> bool: + """ + Toggle the tray icon. + + This is needed, since ``tray_icon`` cannot be imported at the module level without a circular import. + + Returns + ------- + bool + ``True`` if successful, otherwise ``False``. + + See Also + -------- + common.tray_icon.tray_toggle : ``on_change_tray_toggle`` is an alias of this function. + + Examples + -------- + >>> on_change_tray_toggle() + True + """ + from common import tray_icon + return tray_icon.tray_toggle() + + +# types +# - section +# - boolean +# - option +# - string +# - integer +# - float +# data parsley types (Parsley validation) +# - alphanum (string) +# - email (string) +# - url (string) +# - number (float, integer) +# - integer (integer) +# - digits (string) +_CONFIG_SPEC_DICT = dict( + Info=dict( + type='section', + name=_('Info'), + description=_('For information purposes only.'), + icon='info', + CONFIG_VERSION=dict( + type='integer', + name=_('Config version'), + description=_('The configuration version.'), + default=0, # increment when updating config + min=0, + max=0, # increment when updating config + data_parsley_type='integer', + extra_class='col-md-3', + locked=True, + ), + FIRST_RUN_COMPLETE=dict( + type='boolean', + name=_('First run complete'), + description=_('Todo: Indicates if the user has completed the initial setup.'), + default=False, + locked=True, + ), + ), + General=dict( + type='section', + name=_('General'), + description=_('General settings.'), + icon='gear', + LOCALE=dict( + type='option', + name=_('Locale'), + description=_('The localization setting to use.'), + default='en', + options=[ + 'de', + 'en', + 'en_GB', + 'en_US', + 'es', + 'fr', + 'it', + 'ja', + 'pt', + 'ru', + 'sv', + 'tr', + 'zh', + ], + option_names=[ + f'German ({_("German")})', + f'English ({_("English")})', + f'English (Great Britain) ({_("English (Great Britain)")})', + f'English (United States) ({_("English (United States)")})', + f'Spanish ({_("Spanish")})', + f'French ({_("French")})', + f'Italian ({_("Italian")})', + f'Japanese ({_("Japanese")})', + f'Portuguese ({_("Portuguese")})', + f'Russian ({_("Russian")})', + f'Swedish ({_("Swedish")})', + f'Turkish ({_("Turkish")})', + f'Chinese (Simplified) ({_("Chinese (Simplified)")})', + ], + refresh=True, + extra_class='col-lg-6', + ), + LAUNCH_BROWSER=dict( + type='boolean', + name=_('Launch Browser on Startup '), + description=_(f'Open browser when {definitions.Names.name} starts.'), + default=True, + ), + SYSTEM_TRAY=dict( + type='boolean', + name=_('Enable System Tray Icon'), + description=_(f'Show {definitions.Names.name} shortcut in the system tray.'), + default=True, + # todo - fix circular import + on_change=on_change_tray_toggle, + ), + ), + Logging=dict( + type='section', + name=_('Logging'), + description=_('Logging settings.'), + icon='file-code', + LOG_DIR=dict( + type='string', + name=_('Log directory'), + advanced=True, + description=_('The directory where to store the log files.'), + data_parsley_pattern=regex_directory, + extra_class='col-lg-8', + button_directory=True, + ), + DEBUG_LOGGING=dict( + type='boolean', + name=_('Debug logging'), + advanced=True, + description=_('Enable debug logging.'), + default=True, + ), + ), + Network=dict( + type='section', + name=_('Network'), + description=_('Network settings.'), + icon='network-wired', + HTTP_HOST=dict( + type='string', + name=_('HTTP host address'), + advanced=True, + description=_('The HTTP address to bind to.'), + default='0.0.0.0', + data_parsley_pattern=r'\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)[.]){3}' + r'(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b', + # https://codverter.com/blog/articles/tech/20190105-extract-ipv4-ipv6-ip-addresses-using-regex.html + extra_class='col-md-4', + ), + HTTP_PORT=dict( + type='integer', + name=_('HTTP port'), + advanced=True, + description=_('Port to bind web server to. Note that ports below 1024 may require root.'), + default=9494, + min=21, + max=65535, + data_parsley_type='integer', + extra_class='col-md-3', + ), + HTTP_ROOT=dict( + type='string', + name=_('HTTP root'), + beta=True, + description=_('Todo: The base URL of the web server. Used for reverse proxies.'), + extra_class='col-lg-6', + ), + SSL=dict( + type='boolean', + name=_('SSL'), + default=True, + description=_('Run the web server with HTTPS. ' + 'Disabling this can be a security risk, do so at your own risk.'), + ), + ), + User_Interface=dict( + type='section', + name=_('User Interface'), + description=_('User interface settings.'), + icon='display', + BACKGROUND_VIDEO=dict( + type='boolean', + name=_('Background video'), + description=_('Enable background video.'), + default=True, + ), + ), + Updater=dict( + type='section', + name=_('Updater'), + description=_('Updater settings.'), + icon='arrows-spin', + AUTO_UPDATE=dict( + type='boolean', + name=_('Auto update'), + beta=True, + description=_(f'Todo: Automatically update {definitions.Names.name}.'), + default=False, + ), + ), + Plex=dict( + type='section', + name=_('Plex'), + description=_('Plex settings.'), + icon='chevron-right', + PLEX_URL=dict( + type='string', + name=_('Plex URL'), + description=_('The URL to the Plex server.'), + default='http://127.0.0.1:32400', + data_parsley_pattern=r'^https?:\/\/(?:[a-zA-Z0-9-]+\.?)+(:\d{1,5})?$', + extra_class='col-lg-6', + ), + PLEX_TOKEN=dict( + type='string', + name=_('Plex token'), + mask=True, + description=_('The Plex token.'), + data_parsley_type='alphanum', + extra_class='col-lg-6', + ), + PLEX_APP_SUPPORT_PATH=dict( + type='string', + name=_('Plex data directory'), + description=_('https://support.plex.tv/articles/' + '202915258-where-is-the-plex-media-server-data-directory-located/'), + data_parsley_pattern=regex_directory, + extra_class='col-lg-8', + button_directory=True, + ), + ), + Themerr=dict( + type='section', + name=_('Themerr'), + description=_('Themerr settings.'), + icon='music', + BOOL_THEMERR_ENABLED=dict( + type='boolean', + name=_('Themerr Enabled'), + description=_('When enabled, Themerr will attempt to update themes.'), + default=True, + ), + BOOL_PLEX_COLLECTION_SUPPORT=dict( + type='boolean', + name=_('Collections'), + description=_('Add themes to collections.'), + default=True, + ), + BOOL_PLEX_MOVIE_SUPPORT=dict( + type='boolean', + name=_('Movie support'), + description=_('Add themes to movies using the Plex Movie agent.'), + default=True, + ), + BOOL_PLEX_SERIES_SUPPORT=dict( + type='boolean', + name=_('Series support'), + description=_('Add themes to series using the Plex Series agent.'), + default=True, + ), + BOOL_OVERWRITE_PLEX_PROVIDED_THEMES=dict( + type='boolean', + name=_('Overwrite Plex themes'), + description=_('When enabled, Themerr will overwrite themes provided by Plex.'), + default=True, + ), + BOOL_PREFER_MP4A_CODEC=dict( + type='boolean', + name=_('Prefer MP4A AAC Codec'), + description=_('This can improve theme compatibility with Apple devices.'), + default=True, + ), + BOOL_REMOVE_UNUSED_THEMES=dict( + type='boolean', + name=_('Remove unused themes'), + description=_('Frees up space in your Plex metadata directory. ' + 'This option requires that the "Plex data directory" path is set'), + default=True, + ), + BOOL_REMOVE_UNUSED_ART=dict( + type='boolean', + name=_('Remove unused art'), + description=_('Applies to collection. ' + 'Frees up space in your Plex metadata directory. ' + 'This option requires that the "Plex data directory" path is set'), + default=True, + ), + BOOL_REMOVE_UNUSED_POSTERS=dict( + type='boolean', + name=_('Remove unused posters'), + description=_('Applies to collection. ' + 'Frees up space in your Plex metadata directory. ' + 'This option requires that the "Plex data directory" path is set'), + default=True, + ), + BOOL_UPDATE_COLLECTION_METADATA=dict( + type='boolean', + name=_('Collection metadata'), + description=_('Update collection metadata (poster, art, and summary) during scheduled update.'), + default=False, + ), + BOOL_IGNORE_LOCKED_FIELDS=dict( + type='boolean', + name=_('Ignore locked fields.'), + description=_('If you used Themerr-plex v2024.813.13709 or lower, ' + 'you may need to enable this to update items.'), + default=False, + ), + INT_UPDATE_THEMES_INTERVAL=dict( + type='integer', + name=_('Update themes interval'), + description=_('Interval for automatic update task, in minutes (min: 15).'), + default=60, + advanced=True, + extra_class='col-md-2', + ), + INT_UPDATE_DATABASE_CACHE_INTERVAL=dict( + type='integer', + name=_('Update database cache interval'), + description=_('Interval for database cache update task, in minutes (min: 15).'), + default=60, + advanced=True, + extra_class='col-md-2', + ), + INT_PLEXAPI_PLEXAPI_TIMEOUT=dict( + type='integer', + name=_('PlexAPI timeout'), + description=_('Increase this slightly if you experience timeouts when adding themes. (min: 1)'), + default=180, + advanced=True, + extra_class='col-md-2', + ), + INT_PLEXAPI_UPLOAD_RETRIES_MAX=dict( + type='integer', + name=_('Theme upload retries'), + description=_('If uploading themes fail, retry this many times. (min: 0)'), + default=3, + advanced=True, + extra_class='col-md-2', + ), + INT_PLEXAPI_UPLOAD_THREADS=dict( + type='integer', + name=_('Multiprocessing thread count'), + description=_('The number of threads to use when adding themes. (min: 1)'), + default=3, + advanced=True, + extra_class='col-md-2', + ), + STR_YOUTUBE_COOKIES=dict( + type='string', + name=_('YouTube Cookies'), + description=_('Using cookies may improve the success rate of downloading themes. (JSON format)'), + advanced=True, + ), + ), +) + + +def is_masked_field(section: str, key: str) -> bool: + """ + Check if a field is masked. + + This function will check if a field is masked in the config spec dictionary. + + Parameters + ---------- + section : str + The section of the config. + key : str + The key of the config field. + + Returns + ------- + bool + True if the field is masked, otherwise False. + + Examples + -------- + >>> is_masked_field(section='General', key='API_KEY') + True + """ + return _CONFIG_SPEC_DICT.get(section, {}).get(key, {}).get('mask', False) + + +def encode_value(value: str) -> str: + """ + Encode a value using base64. + + This function will encode a value using base64. + + Parameters + ---------- + value : str + The value to encode. + + Returns + ------- + str + The encoded value. + + Examples + -------- + >>> encode_value('some text') + 'c29tZSB0ZXh0' + """ + return base64.b64encode(value.encode('utf-8')).decode('utf-8') + + +def decode_value(value: str) -> str: + """ + Decode a base64 encoded value. + + This function will decode a base64 encoded value. + + Parameters + ---------- + value : str + The value to decode. + + Returns + ------- + str + The decoded value. If the value cannot be decoded, an empty string is returned. + + Examples + -------- + >>> decode_value('c29tZSB0ZXh0') + 'some text' + """ + try: + return base64.b64decode(value.encode('utf-8')).decode('utf-8') + except Exception as e: + log.error(msg=f"Unable to decode value: {e}") + return '' + + +def decode_config(config: ConfigObj) -> dict: + """ + Decode masked fields in the config. + + This function will create a decoded copy of the config object, and decode any masked fields. + + Parameters + ---------- + config : ConfigObj + The config object to decode. + + Returns + ------- + dict + A decoded copy of the config object. + + Examples + -------- + >>> config_object = create_config(config_file='config.ini') + >>> decode_config(config=config_object) + {...} + """ + _config = copy.deepcopy(config) # we need to do a deepcopy to avoid modifying the original config + + for section, options in _config.items(): + for key, value in options.items(): + if is_masked_field(section=section, key=key): + _config[section][key] = decode_value(value) + return _config + + +def convert_config(d: dict = _CONFIG_SPEC_DICT, _config_spec: Optional[List] = None) -> List: + """ + Convert a config spec dictionary to a config spec list. + + A config spec dictionary is a custom type of dictionary that will be converted into a standard config spec list + which can later be used by ``configobj``. + + Parameters + ---------- + d : dict + The dictionary to convert. + _config_spec : Optional[List] + This should not be set when using this function, but since this function calls itself it needs to pass in the + list that is being built in order to return the correct list. + + Returns + ------- + list + A list representing a configspec for ``configobj``. + + Examples + -------- + >>> convert_config(d=_CONFIG_SPEC_DICT) + [...] + """ + if _config_spec is None: + _config_spec = [] + + for k, v in d.items(): + try: + v['type'] + except TypeError: + pass + else: + # if a default value is not set, then set it to None + if 'default' not in v: + v['default'] = '' + + checks = ['min', 'max', 'options', 'default'] + check_value = '' + + for check in checks: + try: + v[check] + except KeyError: + pass + else: + check_value += f"{', ' if check_value != '' else ''}" + if check == 'options': + for option_value in v[check]: + if check_value: + check_value += f"{', ' if not check_value.endswith(', ') else ''}" + if isinstance(option_value, str): + check_value += f'"{option_value}"' + else: + check_value += f'{option_value}' + elif isinstance(v[check], str): + check_value += f"{check}=\"{v[check]}\"" + else: + check_value += f"{check}={v[check]}" + + check_value = f'({check_value})' if check_value else '' # add parenthesis if there's a value + + if v['type'] == 'section': # config section + _config_spec.append(f'[{k}]') + else: # int option + _config_spec.append(f"{k} = {v['type']}{check_value}") + + if isinstance(v, dict): + # continue parsing nested dictionary + convert_config(d=v, _config_spec=_config_spec) + + return _config_spec + + +def create_config(config_file: str, config_spec: dict = _CONFIG_SPEC_DICT) -> ConfigObj: + """ + Create a config file and `ConfigObj` using a config spec dictionary. + + A config spec dictionary is a strictly formatted dictionary that will be converted into a standard config spec list + to be later used by ``configobj``. + + The created config is validated against a Validator object. This function will remove keys from the user's + config.ini if they no longer exist in the config spec. + + Parameters + ---------- + config_file : str + Full filename of config file. + config_spec : dict, default = _CONFIG_SPEC_DICT + Config spec to use. + + Returns + ------- + ConfigObj + Dictionary of config keys and values. + + Raises + ------ + SystemExit + If config_spec is not valid. + + Examples + -------- + >>> create_config(config_file='config.ini') + ConfigObj({...}) + """ + # convert config spec dictionary to list + config_spec_list = convert_config(d=config_spec) + + config = ConfigObj( + configspec=config_spec_list, + encoding='UTF-8', + list_values=True, + stringify=True, + write_empty_values=False, + ) + config_valid = validate_config(config=config) + + if not config_valid: + # logger may not be initialized + log_msg = "Unable to initialize due to a corrupted config spec. Exiting..." + log.error(msg=log_msg) + raise SystemExit(log_msg) + + user_config = ConfigObj( + infile=config_file, + configspec=config_spec_list, + encoding='UTF-8', + list_values=True, + stringify=True, + write_empty_values=False, + ) + user_config_valid = validate_config(config=user_config) + if not user_config_valid: + # write to stderr and logger + log_msg = "Invalid 'config.ini' file, attempting to correct.\n" + log.error(msg=log_msg) + sys.stderr.write(log_msg) + + # dictionary comprehension + if config_valid and user_config_valid: + # remove values from user config that are no longer in the spec + user_config = { + key: { + k: v for k, v in value.items() if k in config.get(key, {}) + } for key, value in user_config.items() + } + + # remove sections from user config that are no longer in the spec + user_config = {key: value for key, value in user_config.items() if key in config} + + # merge user config into default config + config.merge(indict=user_config) + + # validate merged config + validate_config(config=config) + + config.filename = config_file + save_config(config=config) + + if config_spec == _CONFIG_SPEC_DICT: # set CONFIG dictionary + global CONFIG + CONFIG = config + + return config + + +def save_config(config: ConfigObj = CONFIG) -> bool: + """ + Save the config to file. + + Saves the `ConfigObj` to the specified file. + + Parameters + ---------- + config : ConfigObj, default = CONFIG + Config to save. + + Returns + ------- + bool + True if save successful, otherwise False. + + Examples + -------- + >>> config_object = create_config(config_file='config.ini') + >>> save_config(config=config_object) + True + """ + try: + config.write() + except Exception: + return False + else: + return True + + +def validate_config(config: ConfigObj) -> bool: + """ + Validate ConfigObj dictionary. + + Ensures that the given `ConfigObj` is valid. + + Parameters + ---------- + config : ConfigObj + Config to validate. + + Returns + ------- + bool + True if validation passes, otherwise False. + + Examples + -------- + >>> config_object = create_config(config_file='config.ini') + >>> validate_config(config=config_object) + True + """ + validator = Validator() + try: + config.validate( + validator=validator, + copy=False # don't write out default values + ) + return True + except ValidateError as e: + log_msg = f"Config validation error: {e}.\n" + log.error(msg=log_msg) + sys.stderr.write(log_msg) + return False diff --git a/src/common/crypto.py b/src/common/crypto.py new file mode 100644 index 00000000..21e7a8ca --- /dev/null +++ b/src/common/crypto.py @@ -0,0 +1,72 @@ +# standard imports +import os + +# lib imports +from cryptography import x509 +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.serialization import Encoding, PrivateFormat, NoEncryption +from datetime import datetime, timedelta, UTC + +# local imports +from common import definitions +from common import logger + +log = logger.get_logger(name=__name__) + +CERT_FILE = os.path.join(definitions.Paths.CONFIG_DIR, "cert.pem") +KEY_FILE = os.path.join(definitions.Paths.CONFIG_DIR, "key.pem") + + +def check_expiration(cert_path: str) -> int: + with open(cert_path, "rb") as cert_file: + cert_data = cert_file.read() + cert = x509.load_pem_x509_certificate(cert_data, default_backend()) + expiry_date = cert.not_valid_after_utc + return (expiry_date - datetime.now(UTC)).days + + +def generate_certificate(): + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=4096, + ) + subject = issuer = x509.Name([ + x509.NameAttribute(x509.NameOID.COMMON_NAME, u"localhost"), + ]) + cert = x509.CertificateBuilder().subject_name( + subject + ).issuer_name( + issuer + ).public_key( + private_key.public_key() + ).serial_number( + x509.random_serial_number() + ).not_valid_before( + datetime.now(UTC) + ).not_valid_after( + datetime.now(UTC) + timedelta(days=365) + ).sign(private_key, hashes.SHA256()) + + with open(KEY_FILE, "wb") as f: + f.write(private_key.private_bytes( + encoding=Encoding.PEM, + format=PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=NoEncryption(), + )) + + with open(CERT_FILE, "wb") as f: + f.write(cert.public_bytes(Encoding.PEM)) + + +def initialize_certificate() -> tuple[str, str]: + log.info("Initializing SSL certificate") + if os.path.exists(CERT_FILE) and os.path.exists(KEY_FILE): + cert_expires_in = check_expiration(CERT_FILE) + log.info(f"Certificate expires in {cert_expires_in} days.") + if cert_expires_in >= 90: + return CERT_FILE, KEY_FILE + log.info("Generating new certificate") + generate_certificate() + return CERT_FILE, KEY_FILE diff --git a/src/common/definitions.py b/src/common/definitions.py new file mode 100644 index 00000000..34909bd0 --- /dev/null +++ b/src/common/definitions.py @@ -0,0 +1,163 @@ +""" +src/common/definitions.py + +Contains classes with attributes to common definitions (paths and filenames). +""" +# standard imports +import os +import platform +import sys + + +class Names: + """ + Class representing common names. + + The purpose of this class is to ensure consistency when using these names. + + name : str + The application's name. i.e. `Themerr-plex`. + + Examples + -------- + >>> Names.name + 'Themerr-plex' + """ + name = 'Themerr-plex' + + +class Platform: + """ + Class representing the machine platform. + + The purpose of this class is to ensure consistency when there is a need for platform specific functions. + + bits : str + Operating system bitness. e.g. 64. + operating_system : str + Operating system name. e.g. 'Windows'. + os_platform : str + Operating system platform. e.g. 'win32', 'darwin', 'linux'. + machine : str + Machine architecture. e.g. 'AMD64'. + node : str + Machine name. + release : str + Operating system release. e.g. '10'. + version : str + Operating system version. e.g. '10.0.22000'. + edition : str + Windows edition. e.g. 'Core', None for non Windows platforms. + iot : bool + True if Windows IOT, otherwise False. + + Examples + -------- + >>> Platform.os_platform + ... + """ + bits = 64 if sys.maxsize > 2**32 else 32 + operating_system = platform.system() + os_platform = sys.platform.lower() + processor = platform.processor() + machine = platform.machine() + node = platform.node() + release = platform.release() + version = platform.version() + + # Windows only + edition = platform.win32_edition() if os_platform == 'win32' else None + iot = platform.win32_is_iot() if os_platform == 'win32' else False + + +class Modes: + """ + Class representing runtime variables. + + FROZEN : bool + ``True`` if running pyinstaller bundle version, otherwise ``False``. + DOCKER : bool + ``True`` if running Docker version, otherwise ``False``. + SPLASH : bool + ``True`` if capable of displaying a splash image on start, otherwise, ``False``. + + Examples + -------- + >>> Modes.FROZEN + False + """ + FROZEN = False + DOCKER = False + SPLASH = False + + if hasattr(sys, 'frozen') and hasattr(sys, '_MEIPASS'): # only when using the pyinstaller build + FROZEN = True + + if Platform.os_platform != 'darwin': # pyi_splash is not available on macos + SPLASH = True + + if os.getenv('THEMERR_DOCKER', False): # the environment variable is set in the Dockerfile + DOCKER = True + + +class Files: + """ + Class representing common Files. + + The purpose of this class is to ensure consistency when using these files. + + CONFIG : str + The default config file name. i.e. `config.ini`. + + Examples + -------- + >>> Files.CONFIG + 'config.ini' + """ + CONFIG = 'config.ini' + + +class Paths: + """ + Class representing common Paths. + + The purpose of this class is to ensure consistency when using these paths. + + COMMON_DIR : str + The directory containing the common python files. + SRC_DIR : str + The directory containing the application python files + ROOT_DIR : str + The root directory of the application. This is where the source files exist. + DATA_DIR : str + The data directory of the application. + DOCS_DIR : str + The directory containing html documentation. + LOCALE_DIR : str + The directory containing localization files. + LOG_DIR : str + The directory containing log files. + + Examples + -------- + >>> Paths.LOG_DIR + '.../logs' + """ + COMMON_DIR = os.path.dirname(os.path.abspath(__file__)) + SRC_DIR = os.path.dirname(COMMON_DIR) + ROOT_DIR = os.path.dirname(SRC_DIR) + DATA_DIR = ROOT_DIR + BINARY_PATH = os.path.abspath(os.path.join(SRC_DIR, 'themerr_plex.py')) + + if Modes.FROZEN: # pyinstaller build + DATA_DIR = os.path.dirname(sys.executable) + BINARY_PATH = os.path.abspath(sys.executable) + if Modes.DOCKER: # docker install + DATA_DIR = '/config' # overwrite the value that was already set + CONFIG_DIR = DATA_DIR + else: + CONFIG_DIR = os.path.join(DATA_DIR, 'config') + + DOCS_DIR = os.path.join(ROOT_DIR, 'docs', 'build', 'html') + LOCALE_DIR = os.path.join(ROOT_DIR, 'locale') + LOG_DIR = os.path.join(CONFIG_DIR, 'logs') diff --git a/src/common/helpers.py b/src/common/helpers.py new file mode 100644 index 00000000..c45a8686 --- /dev/null +++ b/src/common/helpers.py @@ -0,0 +1,469 @@ +""" +src/common/helpers.py + +Many reusable helper functions. +""" +# standard imports +import datetime +import json +import logging +import os +import requests +import requests_cache +import time +from typing import AnyStr, Optional, Union +from urllib.parse import quote, quote_plus, unquote, unquote_plus +import webbrowser + + +def check_folder_writable(fallback: str, name: str, folder: Optional[str] = None) -> tuple[str, Optional[bool]]: + """ + Check if folder or fallback folder is writeable. + + This function ensures that the folder can be created, if it doesn't exist. It also ensures there are sufficient + permissions to write to the folder. If the primary `folder` fails, it falls back to the `fallback` folder. + + Parameters + ---------- + fallback : str + Secondary folder to check, if the primary folder fails. + name : str + Short name of folder. + folder : str, optional + Primary folder to check. + + Returns + ------- + tuple[str, Optional[bool]] + A tuple containing: + folder : str + The original or fallback folder. + Optional[bool] + True if writeable, otherwise False. Nothing is returned if there is an error attempting to create the + directory. + + Examples + -------- + >>> check_folder_writable( + ... folder='logs', + ... fallback='backup_logs', + ... name='logs' + ... ) + ('logs', True) + """ + if not folder: + folder = fallback + + try: + os.makedirs(name=folder, exist_ok=True) + except OSError as e: + log.error(msg=f"Could not create {name} dir '{folder}': {e}") + if fallback and folder != fallback: + log.warning(msg=f"Falling back to {name} dir '{fallback}'") + return check_folder_writable(folder=None, fallback=fallback, name=name) + else: + return folder, None + + if not os.access(path=folder, mode=os.W_OK): + log.error(msg=f"Cannot write to {name} dir '{folder}'") + if fallback and folder != fallback: + log.warning(msg=f"Falling back to {name} dir '{fallback}'") + return check_folder_writable(folder=None, fallback=fallback, name=name) + else: + return folder, False + + return folder, True + + +def docker_healthcheck() -> bool: + """ + Check the health of the docker container. + + .. Warning:: This is only meant to be called by `themerr-plex.py`, and the interpreter should be immediate exited + following the result. + + The default port is used considering that the container will use the default port internally. + The external port should not make any difference. + + Returns + ------- + bool + True if status okay, otherwise False. + + Examples + -------- + >>> docker_healthcheck() + True + """ + protocols = ['http', 'https'] + + for p in protocols: + try: + response = requests.get(url=f'{p}://localhost:9696/status') + except requests.exceptions.ConnectionError: + pass + else: + if response.status_code == 200: + return True + + return False # did not get a valid response, so return False + + +def file_load(filename: str, binary: bool = False) -> Optional[AnyStr]: + """ + Open a file and return the contents. + + This function will open a file and return the contents. If the file does not exist, None is returned. + + Parameters + ---------- + filename : str + The file to open. + binary : bool, default = False + True to open the file in binary mode. + + Returns + ------- + Optional[AnyStr] + The contents of the file, or None if the file does not exist. + + Examples + -------- + >>> file_load(filename='file.txt') + 'This is the contents of the file.' + """ + mode = 'rb' if binary else 'r' + try: + with open(file=filename, mode=mode) as f: + return f.read() + except Exception as e: + log.error(f'Error loading data from {filename}: {e}') + return None + + +def file_save(filename: str, data: AnyStr, binary: bool = False) -> bool: + """ + Save data to a file. + + This function will save the given data to a file. + + Parameters + ---------- + filename : str + The file to save the data to. + data : AnyStr + The data to save. + binary : bool, default = False + True to save the data in binary mode. + + Returns + ------- + bool + True if successful, otherwise False. + + Examples + -------- + >>> file_save(filename='file.txt', data='This is the contents of the file.') + True + """ + mode = 'wb' if binary else 'w' + try: + with open(file=filename, mode=mode) as f: + f.write(data) + except Exception as e: + log.error(f'Error saving data to {filename}: {e}') + return False + else: + return True + + +def get_logger(name: str) -> logging.Logger: + """ + Get the logger for the given name. + + This function also exists in `logger.py` to prevent circular imports. + + Parameters + ---------- + name : str + Name of logger. + + Returns + ------- + logging.Logger + The logging.Logger object. + + Examples + -------- + >>> get_logger(name='my_log') + + """ + return logging.getLogger(name=name) + + +def json_get( + url: str, + headers: Optional[dict] = None, + params: Optional[dict] = None, + cache_time: int = 0, + sleep_time: Union[float, int] = 0.0, + request_type: str = 'get', +) -> dict: + """ + Get JSON data from a URL. + + This function will get JSON data from a URL, with optional headers and parameters. + + Parameters + ---------- + url : str + The URL to get JSON data from. + headers : Optional[dict] + Optional headers to send with the request. + params : Optional[dict] + Optional parameters to send with the request. + cache_time : int, default = 0 + Time in seconds to cache the data. + sleep_time : Union[float, int], default = 0.0 + Time in seconds to sleep before making the request. + request_type : str, default = 'get' + The type of request to make. Valid options are 'get' and 'post'. + + Returns + ------- + dict + The JSON data from the URL. If there is an error, an empty dictionary is returned. + + Examples + -------- + >>> json_get(url='https://www.example.com') + {...} + """ + if cache_time > 0: + session = requests_cache.CachedSession(cache_name='http_cache', expire_after=cache_time) + else: + session = requests.Session() + + if sleep_time > 0: + time.sleep(sleep_time) + + type_map = { + 'get': session.get, + 'post': session.post, + } + + request_type = request_type.lower() + if request_type not in type_map: + log.error(f'Invalid request type: {request_type}') + return {} + + try: + request = type_map[request_type](url=url, headers=headers, params=params) + except requests.exceptions.RequestException as e: + log.error(f'Error getting JSON data from {url}: {e}') + return {} + else: + try: + data = request.json() + except json.JSONDecodeError as e: + log.error(f'Error decoding JSON data from {url}: {e}') + return {} + + return data + + +def now(separate: bool = False) -> str: + """ + Function to get the current time, formatted. + + This function will return the current time formatted as YMDHMS + + Parameters + ---------- + separate : bool, default = False + True to separate time with a combination of dashes (`-`) and colons (`:`). + + Returns + ------- + str + The current time formatted as YMDHMS. + + Examples + -------- + >>> now() + '20220410184531' + + >>> now(separate=True) + '2022-04-10 18:46:12' + """ + return timestamp_to_YMDHMS(ts=timestamp(), separate=separate) + + +def open_url_in_browser(url: str) -> bool: + """ + Open a given url in the default browser. + + Attempt to open the given url in the default web browser, in a new tab. + + Parameters + ---------- + url : str + The url to open. + + Returns + ------- + bool + True if no error, otherwise False. + + Examples + -------- + >>> open_url_in_browser(url='https://www.google.com') + True + """ + try: + webbrowser.open(url=url, new=2) + except webbrowser.Error: + return False + else: + return True + + +def string_quote(string: str, use_plus: bool = False) -> str: + """ + Quote a string. + + This function will quote a string, replacing spaces with either `+` or `%20`. + + Parameters + ---------- + string : str + The string to quote. + use_plus : bool, default = False + True to replace spaces with `+`, otherwise `%20`. + + Returns + ------- + str + The quoted string. + + Examples + -------- + >>> string_quote(string='This is a string') + 'This%20is%20a%20string' + + >>> string_quote(string='This is a string', use_plus=True) + 'This+is+a+string' + """ + if use_plus: + return quote_plus(string) + return quote(string) + + +def string_unquote(string: str, use_plus: bool = False) -> str: + """ + Unquote a string. + + This function will unquote a string, replacing `+` or `%20` with spaces. + + Parameters + ---------- + string : str + The string to unquote. + use_plus : bool, default = False + True to replace `+` with spaces, otherwise `%20`. + + Returns + ------- + str + The unquoted string. + + Examples + -------- + >>> string_unquote(string='This%20is%20a%20string') + 'This is a string' + + >>> string_unquote(string='This+is+a+string', use_plus=True) + 'This is a string' + """ + if use_plus: + return unquote_plus(string) + return unquote(string) + + +def timestamp() -> int: + """ + Function to get the current time. + + This function uses time.time() to get the current time. + + Returns + ------- + int + The current time as a timestamp integer. + + Examples + -------- + >>> timestamp() + 1649631005 + """ + return int(time.time()) + + +def timestamp_to_YMDHMS(ts: int, separate: bool = False) -> str: + """ + Convert timestamp to YMDHMS format. + + Convert a given timestamp to YMDHMS format. + + Parameters + ---------- + ts : int + The timestamp to convert. + separate : bool, default = False + True to separate time with a combination of dashes (`-`) and colons (`:`). + + Returns + ------- + str + The timestamp formatted as YMDHMS. + + Examples + -------- + >>> timestamp_to_YMDHMS(ts=timestamp(), separate=False) + '20220410185142' + + >>> timestamp_to_YMDHMS(ts=timestamp(), separate=True) + '2022-04-10 18:52:09' + """ + dt = timestamp_to_datetime(ts=ts) + if separate: + return dt.strftime("%Y-%m-%d %H:%M:%S") + return dt.strftime("%Y%m%d%H%M%S") + + +def timestamp_to_datetime(ts: float) -> datetime.datetime: + """ + Convert timestamp to datetime object. + + This function returns the result of `datetime.datetime.fromtimestamp()`. + + Parameters + ---------- + ts : float + The timestamp to convert. + + Returns + ------- + datetime.datetime + Object `datetime.datetime`. + + Examples + -------- + >>> timestamp_to_datetime(ts=timestamp()) + datetime.datetime(20..., ..., ..., ..., ..., ...) + """ + return datetime.datetime.fromtimestamp(ts) + + +# get logger +log = get_logger(name=__name__) diff --git a/src/common/locales.py b/src/common/locales.py new file mode 100644 index 00000000..5a34abbe --- /dev/null +++ b/src/common/locales.py @@ -0,0 +1,152 @@ +""" +src/common/locales.py + +Functions related to localization. + +Localization (also referred to as l10n) is the process of adapting a product or service to a specific locale. +Translation is only one of several elements in the localization process. In addition to translation, the localization +process may also include: +- Adapting design and layout to properly display translated text in the language of the locale +- Adapting sorting functions to the alphabetical order of a specific locale +- Changing formats for date and time, addresses, numbers, currencies, etc. for specific target locales +- Adapting graphics to suit the expectations and tastes of a target locale +- Modifying content to suit the tastes and consumption habits of a target locale + +The aim of localization is to give a product or service the look and feel of having been created specifically for a +target market, no matter their language, cultural preferences, or location. +""" +# standard imports +import gettext +import os +import subprocess +import sys + +# lib imports +import babel +from babel import localedata + +# local imports +from common import config +from common.definitions import Paths +from common import logger + +default_domain = 'themerr-plex' +default_locale = 'en' +default_timezone = 'UTC' +supported_locales = ['en', 'es'] + +log = logger.get_logger(__name__) + + +def get_all_locales() -> dict: + """ + Get a dictionary of all possible locales for use with babel. + + Dictionary keys will be `locale_id` and value with be `locale_display_name`. + This is a shortened example of the returned value. + + .. code-block:: python + + { + 'de': 'Deutsch', + 'en': 'English', + 'en_GB': 'English (United Kingdom)', + 'en_US': 'English (United States)', + 'es': 'español', + 'fr': 'français', + 'it': 'italiano', + 'ru': 'русский' + } + + Returns + ------- + dict + Dictionary of all possible locales. + + Examples + -------- + >>> get_all_locales() + {... 'en': 'English', ... 'en_GB': 'English (United Kingdom)', ... 'es': 'español', ... 'fr': 'français', ...} + """ + log.debug(msg='Getting locale dictionary.') + locale_ids = localedata.locale_identifiers() + + locales = {} + + for locale_id in locale_ids: + locale = babel.Locale.parse(identifier=locale_id) + locales[locale_id] = locale.get_display_name() + + return locales + + +def get_locale() -> str: + """ + Verify the locale. + + Verify the locale from the config against supported locales and returns appropriate locale. + + Returns + ------- + str + The locale set in the config if it is valid, otherwise the default locale (en). + + Examples + -------- + >>> get_locale() + 'en' + """ + try: + config_locale = config.CONFIG['General']['LOCALE'] + except TypeError: + config_locale = None + + if config_locale in supported_locales: + return config_locale + else: + return default_locale + + +def get_text() -> gettext.gettext: + """ + Install the language defined in the conifg. + + This function installs the language defined in the config and allows translations in python code. + + Returns + ------- + gettext.gettext + The `gettext.gettext` method. + + Examples + -------- + >>> get_text() + > + """ + translation_fallback = False + if not os.path.isfile(os.path.join(Paths.LOCALE_DIR, get_locale(), 'LC_MESSAGES', f'{default_domain}.mo')): + log.warning(msg='No locale mo translation file found.') + + locale_script = os.path.join(Paths.ROOT_DIR, 'scripts', '_locale.py') + + if os.path.isfile(locale_script): + log.info(msg='Running locale compile script.') + # run python script in a subprocess + subprocess.run( + args=[sys.executable, locale_script, '--compile'], + cwd=Paths.ROOT_DIR, + ) + else: + log.warning(msg='Locale compile script not found. Defaulting to English.') + translation_fallback = True + + language = gettext.translation( + domain=default_domain, + localedir=Paths.LOCALE_DIR, + languages=[get_locale()], + fallback=translation_fallback, + ) + + language.install() + + return language.gettext diff --git a/src/common/logger.py b/src/common/logger.py new file mode 100644 index 00000000..cb0df501 --- /dev/null +++ b/src/common/logger.py @@ -0,0 +1,728 @@ +""" +src/common/logger.py + +Responsible for logging related functions. +""" +# standard imports +import contextlib +import errno +import logging +import multiprocessing +import os +import pkgutil +import re +import sys +import threading +import traceback +from logging import handlers +from logging.handlers import QueueHandler, QueueListener + +# lib imports +from configobj import ConfigObj + +# local imports +import common +from common import definitions +from common import helpers + +# These settings are for file logging only +app_name = 'common' +MAX_SIZE = 5000000 # 5 MB +MAX_FILES = 5 + +# used for log filters +_BLACKLIST_KEYS = ['_APITOKEN', '_TOKEN', '_KEY', '_SECRET', '_PASSWORD', '_APIKEY', '_ID', '_HOOK'] +_WHITELIST_KEYS = ['HTTPS_KEY'] + +LOG_BLACKLIST = [] + +_BLACKLIST_WORDS = set() + +# Global queue for multiprocessing logging +queue = None + + +def blacklist_config(config: ConfigObj): + """ + Update blacklist words. + + In order to filter words out of the logs, it is required to call this function. + + Values in the config for keys containing the following terms will be removed. + + - HOOK + - APIKEY + - KEY + - PASSWORD + - TOKEN + + Parameters + ---------- + config : ConfigObj + Config to parse. + + Examples + -------- + >>> config_object = common.config.create_config(config_file='config.ini') + >>> blacklist_config(config=config_object) + """ + blacklist = set() + blacklist_keys = ['HOOK', 'APIKEY', 'KEY', 'PASSWORD', 'TOKEN'] + + for k, v in config.items(): + for key, value in v.items(): + if isinstance(value, str) and len(value.strip()) > 5 and \ + key.upper() not in _WHITELIST_KEYS and (key.upper() in blacklist_keys or + any(bk in key.upper() for bk in _BLACKLIST_KEYS)): + blacklist.add(value.strip()) + + _BLACKLIST_WORDS.update(blacklist) + + +class NoThreadFilter(logging.Filter): + """ + Log filter for the current thread. + + .. todo:: This documentation needs to be improved. + + Parameters + ---------- + threadName : str + The name of the thread. + + Methods + ------- + filter: + Filter the given record. + + Examples + -------- + >>> NoThreadFilter('main') + + """ + + def __init__(self, threadName): + super(NoThreadFilter, self).__init__() + + self.threadName = threadName + + def filter(self, record) -> bool: + """ + Filter the given record. + + .. todo:: This documentation needs to be improved. + + Parameters + ---------- + record : NoThreadFilter + The record to filter. + + Returns + ------- + bool + True if record.threadName is not equal to self.threadName, otherwise False. + + Examples + -------- + >>> NoThreadFilter('main').filter(record=NoThreadFilter('test')) + True + + >>> NoThreadFilter('main').filter(record=NoThreadFilter('main')) + False + """ + return record.threadName != self.threadName + + +# Taken from Hellowlol/HTPC-Manager +class BlacklistFilter(logging.Filter): + """ + Filter logs for blacklisted words. + + Log filter for blacklisted tokens and passwords. + + Methods + ------- + filter: + Filter the given record. + + Examples + -------- + >>> BlacklistFilter() + + """ + + def __init__(self): + super(BlacklistFilter, self).__init__() + + def filter(self, record) -> bool: + """ + Filter the given record. + + .. todo:: This documentation needs to be improved. + + Parameters + ---------- + record : BlacklistFilter + The record to filter. + + Returns + ------- + bool + True in all cases. + + Examples + -------- + >>> BlacklistFilter().filter(record=BlacklistFilter()) + True + """ + if not LOG_BLACKLIST: + return True + + for item in _BLACKLIST_WORDS: + try: + if item in record.msg: + record.msg = record.msg.replace(item, 16 * '*') + + args = [] + for arg in record.args: + try: + arg_str = str(arg) + if item in arg_str: + arg_str = arg_str.replace(item, 16 * '*') + arg = arg_str + except Exception: + pass + args.append(arg) + record.args = tuple(args) + except Exception: + pass + + return True + + +class RegexFilter(logging.Filter): + """ + Base class for regex log filter. + + Log filter for regex. + + Attributes + ---------- + regex : re.compile + The compiled regex pattern. + + Methods + ------- + filter: + Filter the given record. + + Examples + -------- + >>> RegexFilter() + + """ + + def __init__(self): + super(RegexFilter, self).__init__() + + self.regex = re.compile(pattern=r'') + + def filter(self, record) -> bool: + """ + Filter the given record. + + .. todo:: This documentation needs to be improved. + + Parameters + ---------- + record : RegexFilter + The record to filter. + + Returns + ------- + bool + True in all cases. + + Examples + -------- + >>> RegexFilter().filter(record=RegexFilter()) + True + """ + if not LOG_BLACKLIST: + return True + + try: + matches = self.regex.findall(record.msg) + for match in matches: + record.msg = self.replace(record.msg, match) + + args = [] + for arg in record.args: + try: + arg_str = str(arg) + matches = self.regex.findall(arg_str) + if matches: + for match in matches: + arg_str = self.replace(arg_str, match) + arg = arg_str + except Exception: + pass + args.append(arg) + record.args = tuple(args) + except Exception: + pass + + return True + + def replace(self, text, match): + return text + + +class PublicIPFilter(RegexFilter): + """ + Log filter for public IP addresses. + + Class responsible for filtering public IP addresses. + + Attributes + ---------- + regex : re.compile + The compiled regex pattern. + + Methods + ------- + replace: + Filter that replaces a string within another string. + + Examples + -------- + >>> PublicIPFilter() + + """ + + def __init__(self): + super(PublicIPFilter, self).__init__() + + # Currently only checking for ipv4 addresses + self.regex = re.compile(pattern=r'[0-9]+(?:[.-][0-9]+){3}(?!\d*-[a-z0-9]{6})') + + def replace(self, text: str, ip: str) -> str: + """ + Filter a public address. + + Filter the given ip address out of the given text. The ip address will only be filter if it is public. + + Parameters + ---------- + text : str + The text to replace the ip address within. + ip : str + The ip address to replace with asterisks. + + Returns + ------- + str + The original text with the ip address replaced. + + Examples + -------- + >>> PublicIPFilter().replace(text='Testing 172.1.7.5', ip='172.1.7.5') + 'Testing ***.***.***.***' + """ + if helpers.is_public_ip(ip.replace('-', '.')): + partition = '-' if '-' in ip else '.' + return text.replace(ip, partition.join(['***'] * 4)) + return text + + +class EmailFilter(RegexFilter): + """ + Log filter for email addresses. + + Class responsible for filtering email addresses. + + Attributes + ---------- + regex : re.compile + The compiled regex pattern. + + Methods + ------- + replace: + Filter that replaces a string within another string. + + Examples + -------- + >>> EmailFilter() + + """ + + def __init__(self): + super(EmailFilter, self).__init__() + + self.regex = re.compile(pattern=r'([a-z0-9!#$%&\'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&\'*+/=?^_`{|}~-]+)*@' + r'(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)', + flags=re.IGNORECASE) + + def replace(self, text: str, email: str) -> str: + """ + Filter an email address. + + Filter the given email address out of the given text. + + Parameters + ---------- + text : str + The text to replace the email address within. + email : str + The email address to replace with asterisks. + + Returns + ------- + str + The original text with the email address replaced. + + Examples + -------- + >>> EmailFilter().replace(text='Testing example@example.com', email='example@example.com') + 'Testing ****************@********' + """ + email_parts = email.partition('@') + return text.replace(email, 16 * '*' + email_parts[1] + 8 * '*') + + +class PlexTokenFilter(RegexFilter): + """ + Log filter for X-Plex-Token. + + Class responsible for filtering Plex tokens. + + Attributes + ---------- + regex : re.compile + The compiled regex pattern. + + Methods + ------- + replace: + Filter that replaces a string within another string. + + Examples + -------- + >>> PlexTokenFilter() + + """ + + def __init__(self): + super(PlexTokenFilter, self).__init__() + + self.regex = re.compile(pattern=r'X-Plex-Token(?:=|%3D)([a-zA-Z0-9]+)') + + def replace(self, text: str, token: str) -> str: + """ + Filter a token. + + Filter the given token out of the given text. + + Parameters + ---------- + text : str + The text to replace the token within. + token : str + The token to replace with asterisks. + + Returns + ------- + str + The original text with the token replaced. + + Examples + -------- + >>> PlexTokenFilter().replace(text='x-plex-token=5FBCvHo9vFf9erz8ssLQ', token='5FBCvHo9vFf9erz8ssLQ') + 'x-plex-token=****************' + """ + return text.replace(token, 16 * '*') + + +@contextlib.contextmanager +def listener(logger: logging.Logger): + """ + Create a QueueListener. + + Wrapper that creates a QueueListener, starts it and automatically stops it. + To be used in a with statement in the main process, for multiprocessing. + + Parameters + ---------- + logger : logging.Logger + The logger object. + + Yields + ------ + None + + Examples + -------- + >>> logger = get_logger(name='themerr-plex') + >>> listener(logger=logger) + """ + + global queue + + # Initialize queue if not already done + if queue is None: + try: + queue = multiprocessing.Queue() + except OSError as e: + queue = False + + # Some machines don't have access to /dev/shm. See + # http://stackoverflow.com/questions/2009278 for more information. + if e.errno == errno.EACCES: + logger.warning('Multiprocess logging disabled, because current user cannot map shared memory. You ' + 'won\'t see any logging generated by the worker processed.') + + # Multiprocess logging may be disabled. + if not queue: + yield + else: + queue_listener = QueueListener(queue, *logger.handlers) + + try: + queue_listener.start() + yield + finally: + queue_listener.stop() + + +def init_multiprocessing(logger: logging.Logger): + """ + Remove all handlers and add QueueHandler on top. + + This should only be called inside a multiprocessing worker process, since it changes the logger completely. + + Parameters + ---------- + logger : logging.Logger + The logger to initialize for multiprocessing. + + Examples + -------- + >>> logger = get_logger(name='themerr-plex') + >>> init_multiprocessing(logger=logger) + """ + + # Multiprocess logging may be disabled. + if not queue: + return + + # Remove all handlers and add the Queue handler as the only one. + for handler in logger.handlers[:]: + logger.removeHandler(handler) + + queue_handler = QueueHandler(queue) + queue_handler.setLevel(logging.DEBUG) + + logger.addHandler(queue_handler) + + # Change current thread name for log record + threading.current_thread().name = multiprocessing.current_process().name + + +def get_logger(name: str) -> logging.Logger: # this also exists in helpers.py to prevent circular imports + """ + Get a logger. + + Return the logging.Logger object for a given name. Additionally, replaces logger.warn with logger.warning. + + Parameters + ---------- + name : str + The name of the logger to get. + + Returns + ------- + logging.Logger + The logging.Logger object. + + Examples + -------- + >>> get_logger(name='themerr-plex') + + """ + logger = logging.getLogger(name) + logger.warn = logger.warning # replace warn with warning + + return logger + + +def setup_loggers(): + """ + Setup all loggers. + + Setup all the available loggers. + + Examples + -------- + >>> setup_loggers() + """ + loggers_list = [app_name, 'werkzeug'] + + submodules = pkgutil.iter_modules(common.__path__) + + for submodule in submodules: + loggers_list.append(f'{app_name}.{submodule.name}') + + for logger_name in loggers_list: + init_logger(log_name=logger_name) + + +def init_logger(log_name: str) -> logging.Logger: + """ + Create a logger. + + Creates a logging.Logger object from the given log name. + + Parameters + ---------- + log_name : str + The name of the log to create. + + Returns + ------- + logging.Logger + The logging.Logger object. + + Examples + -------- + >>> init_logger(log_name='themerr-plex') + + """ + logger = logging.getLogger(name=log_name) + + # Close and remove old handlers. This is required to reinitialize the loggers at runtime + log_handlers = logger.handlers + for handler in log_handlers: + # Just make sure it is cleaned up. + if isinstance(handler, handlers.RotatingFileHandler): + handler.close() + elif isinstance(handler, logging.StreamHandler): + handler.flush() + + logger.removeHandler(handler) + + # Configure the logger to accept all messages + logger.propagate = False + logger.setLevel(logging.DEBUG if common.DEBUG else logging.INFO) + + # Setup file logger + file_formatter = logging.Formatter('%(asctime)s - %(levelname)-7s :: %(threadName)s : %(message)s', + '%Y-%m-%d %H:%M:%S') + + # Setup file logger + log_dir = definitions.Paths.LOG_DIR + if os.path.isdir(log_dir): + filename = os.path.join(log_dir, f'{log_name}.log') + file_handler = handlers.RotatingFileHandler(filename=filename, maxBytes=MAX_SIZE, backupCount=MAX_FILES, + encoding='utf-8') + file_handler.setLevel(logging.DEBUG) + file_handler.setFormatter(file_formatter) + + logger.addHandler(file_handler) + + # Setup console logger + if not common.QUIET: + console_formatter = logging.Formatter('%(asctime)s - %(levelname)s :: %(threadName)s : %(message)s', + '%Y-%m-%d %H:%M:%S') + console_handler = logging.StreamHandler() + console_handler.setFormatter(console_formatter) + console_handler.setLevel(logging.DEBUG) + + logger.addHandler(console_handler) + + # Add filters to log handlers + # Only add filters after the config file has been initialized + # Nothing prior to initialization should contain sensitive information + if not common.DEV and common.CONFIG: + log_handlers = logger.handlers + for handler in log_handlers: + handler.addFilter(BlacklistFilter()) + handler.addFilter(PublicIPFilter()) + handler.addFilter(EmailFilter()) + handler.addFilter(PlexTokenFilter()) + + # Install exception hooks + if log_name == app_name: # all tracebacks go to 'common.log' + _init_hooks(logger) + + # replace warn + # logger.warn = logger.warning + + return logger + + +def _init_hooks(logger: logging.Logger, global_exceptions: bool = True, thread_exceptions: bool = True, + pass_original: bool = True): + """This method installs exception catching mechanisms. + + Any exception caught will pass through the exception hook, and will be logged to the logger as an error. + Additionally, a traceback is provided. + + This is very useful for crashing threads and any other bugs, that may not be exposed when running as daemon. + + The default exception hook is still considered, if pass_original is True. + """ + + def excepthook(*exception_info): + # We should always catch this to prevent loops! + try: + message = "".join(traceback.format_exception(*exception_info)) + logger.error("Uncaught exception: %s", message) + except Exception: + pass + + # Original excepthook + if pass_original: + sys.__excepthook__(*exception_info) + + # Global exception hook + if global_exceptions: + sys.excepthook = excepthook + + # Thread exception hook + if thread_exceptions: + old_init = threading.Thread.__init__ + + def new_init(self, *args, **kwargs): + old_init(self, *args, **kwargs) + old_run = self.run + + def new_run(*args, **kwargs): + try: + old_run(*args, **kwargs) + except (KeyboardInterrupt, SystemExit): + raise + except Exception: + excepthook(*sys.exc_info()) + + self.run = new_run + + # Monkey patch the run() by monkey patching the __init__ method + threading.Thread.__init__ = new_init + + +def shutdown(): + """ + Stop logging. + + Shutdown logging. + + Examples + -------- + >>> shutdown() + """ + logging.shutdown() + + +# get logger +log = get_logger(name=__name__) diff --git a/src/common/threads.py b/src/common/threads.py new file mode 100644 index 00000000..71be38f7 --- /dev/null +++ b/src/common/threads.py @@ -0,0 +1,32 @@ +""" +src/common/threads.py + +Functions related to threading. + +Routine Listings +---------------- +run_in_thread : method + Alias of the built in method `threading.Thread`. + +Examples +-------- +>>> from common import config, threads, tray_icon +>>> config_object = config.create_config(config_file='config.ini') +>>> tray_icon.icon = tray_icon.tray_initialize() +>>> threads.run_in_thread(target=tray_icon.tray_run, name='pystray', daemon=True).start() + +>>> from common import config, threads, webapp +>>> config_object = config.create_config(config_file='config.ini') +>>> threads.run_in_thread(target=webapp.start_webapp, name='Flask', daemon=True).start() + * Serving Flask app 'common.webapp' (lazy loading) +... + * Running on http://.../ (Press CTRL+C to quit) +""" +# standard imports +import threading + +# just use standard threading.Thread for now +# todo +# this can probably be improved +# ideally would like to have basic functions and just pass in the target and args +run_in_thread = threading.Thread diff --git a/src/common/tray_icon.py b/src/common/tray_icon.py new file mode 100644 index 00000000..ec009931 --- /dev/null +++ b/src/common/tray_icon.py @@ -0,0 +1,412 @@ +""" +src/common/tray_icon.py + +Responsible for system tray icon and related functions. +""" +# standard imports +import os +from typing import Union + +# lib imports +from PIL import Image + +# local imports +import common +from common import config +from common import definitions +from common import helpers +from common import locales +from common import logger +from common import threads +from common import webapp + +# setup +_ = locales.get_text() +icon_running = False +icon_supported = False +log = logger.get_logger(name=__name__) + +# conditional imports +if definitions.Platform.os_platform == 'linux': + try: + import Xlib + except Exception: + pass +try: + from pystray import Icon, MenuItem, Menu +except Xlib.error.DisplayNameError: + Icon = None +else: + icon_class = Icon # avoids a messy import for pytest + icon_supported = True + +# additional setup +icon_object: Union[Icon, bool] = False + + +def tray_initialize() -> Union[Icon, bool]: + """ + Initialize the system tray icon. + + Some features of the tray icon may not be available, depending on the operating system. An attempt is made to setup + the tray icon with all the available features supported by the OS. + + Returns + ------- + Union[Icon, bool] + Icon + Instance of pystray.Icon if icon is supported. + bool + False if icon is not supported. + + Examples + -------- + >>> tray_initialize() + """ + if not icon_supported: + return False + tray_icon = Icon(name='themerr-plex') + tray_icon.title = definitions.Names.name + + image = Image.open(os.path.join(definitions.Paths.ROOT_DIR, 'web', 'images', 'favicon.ico')) + tray_icon.icon = image + + # NOTE: Open the application. "%(app_name)s" = "Themerr-plex". Do not translate "%(app_name)s". + first_menu_entry = MenuItem(text=_('Open %(app_name)s') % {'app_name': definitions.Names.name}, + action=open_webapp, default=True if tray_icon.HAS_DEFAULT_ACTION else False) + + if tray_icon.HAS_MENU: + menu = ( + first_menu_entry, + Menu.SEPARATOR, + # NOTE: Open GitHub Releases. "%(github)s" = "GitHub". Do not translate "%(github)s". + MenuItem(text=_('%(github)s Releases') % {'github': 'GitHub'}, action=github_releases), + MenuItem( + # NOTE: Donate to LizardByte. + text=_('Donate'), action=Menu( + MenuItem(text=_('GitHub Sponsors'), action=donate_github), + MenuItem(text='MEE6', action=donate_mee6), + MenuItem(text='Patreon', action=donate_patreon), + MenuItem(text='PayPal', action=donate_paypal), + ) + ), + Menu.SEPARATOR, + # NOTE: Open web browser when application starts. Do not translate "%(app_name)s". + MenuItem(text=_('Open browser when %(app_name)s starts') % {'app_name': definitions.Names.name}, + action=tray_browser, checked=lambda item: config.CONFIG['General']['LAUNCH_BROWSER']), + # NOTE: Disable or turn off icon. + MenuItem(text=_('Disable icon'), action=tray_disable), + Menu.SEPARATOR, + # NOTE: Restart the program. + MenuItem(text=_('Restart'), action=tray_restart), + # NOTE: Quit, Stop, End, or Shutdown the program. + MenuItem(text=_('Quit'), action=tray_quit), + ) + + else: + menu = ( + first_menu_entry, + ) + + tray_icon.menu = menu + + return tray_icon + + +def tray_browser(): + """ + Toggle the config option 'LAUNCH_BROWSER'. + + This functions switches the `LAUNCH_BROWSER` config option from True to False, or False to True. + + Examples + -------- + >>> tray_browser() + """ + # toggle the value of LAUNCH_BROWSER + config.CONFIG['General']['LAUNCH_BROWSER'] = not config.CONFIG['General']['LAUNCH_BROWSER'] + + config.save_config(config.CONFIG) + + +def tray_disable(): + """ + Turn off the config option 'SYSTEM_TRAY'. + + This function ends and disables the `SYSTEM_TRAY` config option. + + Examples + -------- + >>> tray_disable() + """ + tray_end() + config.CONFIG['General']['SYSTEM_TRAY'] = False + config.save_config(config.CONFIG) + + +def tray_end() -> bool: + """ + End the system tray icon. + + Hide and then stop the system tray icon. + + Returns + ------- + bool + ``True`` if successful, otherwise ``False``. + + Examples + -------- + >>> tray_end() + """ + try: + icon_class + except NameError: + return False + else: + if isinstance(icon_object, icon_class): + try: # this shouldn't be possible to call, other than through pytest + icon_object.visible = False + except AttributeError: + pass + + try: + icon_object.stop() + except AttributeError: + pass + except Exception as e: + log.error(f'Exception when stopping system tray icon: {e}') + else: + global icon_running + icon_running = False + return True + + +def tray_run_threaded() -> bool: + """ + Run the system tray in a thread. + + This function exectues various other functions to simplify starting the tray icon. + + Returns + ------- + bool + ``True`` if successful, otherwise ``False``. + + See Also + -------- + tray_initialize : This function first, initializes the tray icon using ``tray_initialize()``. + tray_run : Then, ``tray_run`` is executed in a thread. + pyra.threads.run_in_thread : Run a method within a thread. + + Examples + -------- + >>> tray_run_threaded() + True + """ + if icon_supported: + global icon_object + icon_object = tray_initialize() + threads.run_in_thread(target=tray_run, name='pystray', daemon=True).start() + return True + else: + return False + + +def tray_toggle() -> bool: + """ + Toggle the system tray icon. + + Hide/unhide the system tray icon. + + Returns + ------- + bool + ``True`` if successful, otherwise ``False``. + + Examples + -------- + >>> tray_toggle() + """ + if icon_supported: + if icon_running: + result = tray_end() + else: + result = tray_run_threaded() + else: + result = False + + return result + + +def tray_quit(): + """ + Shutdown Themerr-plex. + + Set the 'common.SIGNAL' variable to 'shutdown'. + + Examples + -------- + >>> tray_quit() + """ + common.SIGNAL = 'shutdown' + + +def tray_restart(): + """ + Restart Themerr-plex. + + Set the 'common.SIGNAL' variable to 'restart'. + + Examples + -------- + >>> tray_restart() + """ + common.SIGNAL = 'restart' + + +def tray_run(): + """ + Start the tray icon. + + Run the system tray icon in detached mode. + + Examples + -------- + >>> tray_run() + """ + try: + icon_class + except NameError: + pass + else: + global icon_running + + if isinstance(icon_object, icon_class): + try: + icon_object.run_detached() + except AttributeError: + pass + except NotImplementedError as e: + log.error(f'Error running system tray icon: {e}') + else: + icon_running = True + + +def open_webapp() -> bool: + """ + Open the webapp. + + Open Themerr-plex in the default web browser. + + Returns + ------- + bool + True if opening page was successful, otherwise False. + + Examples + -------- + >>> open_webapp() + True + """ + return helpers.open_url_in_browser(url=webapp.URL) + + +def github_releases(): + """ + Open GitHub Releases. + + Open GitHub Releases in the default web browser. + + Returns + ------- + bool + True if opening page was successful, otherwise False. + + Examples + -------- + >>> github_releases() + True + """ + url = 'https://github.com/LizardByte/Themerr-plex/releases/latest' + return helpers.open_url_in_browser(url=url) + + +def donate_github(): + """ + Open GitHub Sponsors. + + Open GitHub Sponsors in the default web browser. + + Returns + ------- + bool + True if opening page was successful, otherwise False. + + Examples + -------- + >>> donate_github() + True + """ + url = 'https://github.com/sponsors/LizardByte' + return helpers.open_url_in_browser(url=url) + + +def donate_mee6(): + """ + Open MEE6. + + Open MEE6 in the default web browser. + + Returns + ------- + bool + True if opening page was successful, otherwise False. + + Examples + -------- + >>> donate_mee6() + True + """ + url = 'https://mee6.xyz/m/804382334370578482' + return helpers.open_url_in_browser(url=url) + + +def donate_patreon(): + """ + Open Patreon. + + Open Patreon in the default web browser. + + Returns + ------- + bool + True if opening page was successful, otherwise False. + + Examples + -------- + >>> donate_patreon() + True + """ + url = 'https://www.patreon.com/LizardByte' + return helpers.open_url_in_browser(url=url) + + +def donate_paypal(): + """ + Open PayPal. + + Open PayPal in the default web browser. + + Returns + ------- + bool + True if opening page was successful, otherwise False. + + Examples + -------- + >>> donate_paypal() + True + """ + url = 'https://www.paypal.com/paypalme/ReenigneArcher' + return helpers.open_url_in_browser(url=url) diff --git a/src/common/webapp.py b/src/common/webapp.py new file mode 100644 index 00000000..480c9aa0 --- /dev/null +++ b/src/common/webapp.py @@ -0,0 +1,478 @@ +""" +src/common/webapp.py + +Responsible for serving the webapp. +""" +# standard imports +import json +import os +from typing import Optional + +# lib imports +from flask import Flask, Response +from flask import jsonify, render_template as flask_render_template, request, send_from_directory +from flask_babel import Babel +from flask_wtf import CSRFProtect +import polib +from werkzeug.utils import secure_filename + +# local imports +import common +from common import config +from common import crypto +from common.definitions import Paths +from common import helpers +from common import locales +from common import logger +from themerr.cache import database_cache_file + +# variables +URL_SCHEME = None +URL = None + +# localization +_ = locales.get_text() + +responses = { + 500: Response(response='Internal Server Error', status=500, mimetype='text/plain') +} + +# 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', +} + +# setup flask app +app = Flask( + import_name=__name__, + root_path=os.path.join(Paths.ROOT_DIR, 'web'), + static_folder=os.path.join(Paths.ROOT_DIR, 'web'), + template_folder=os.path.join(Paths.ROOT_DIR, 'web', 'templates'), +) + +# remove extra lines rendered jinja templates +app.jinja_env.trim_blocks = True +app.jinja_env.lstrip_blocks = True + +# add python builtins to jinja templates +jinja_functions = dict( + int=int, + str=str, +) +app.jinja_env.globals.update(jinja_functions) + +# localization +babel = Babel( + app=app, + default_locale=locales.default_locale, + default_timezone=locales.default_timezone, + default_translation_directories=Paths.LOCALE_DIR, + default_domain=locales.default_domain, + configure_jinja=True, + locale_selector=locales.get_locale +) + +# setup logging for flask +log_handlers = logger.get_logger(name=__name__).handlers + +for handler in log_handlers: + app.logger.addHandler(handler) + +csrf = CSRFProtect() +csrf.init_app(app) + + +def render_template(template_name_or_list, **context): + """ + Render a template, while providing our default context. + + This function is a wrapper around ``flask.render_template``. + Our UI config is added to the template context. + In the future, this function may be used to add other default contexts to templates. + + Parameters + ---------- + template_name_or_list : str + The name of the template to render. + **context + The context to pass to the template. + + Returns + ------- + render_template + The rendered template. + + Examples + -------- + >>> render_template(template_name_or_list='home.html', title=_('Home')) + """ + context['ui_config'] = common.CONFIG['User_Interface'].copy() + + return flask_render_template(template_name_or_list=template_name_or_list, **context) + + +@app.route('/') +@app.route('/home') +def home() -> render_template: + """ + Serve the webapp home page. + + .. todo:: This documentation needs to be improved. + + Returns + ------- + render_template + The rendered page. + + Notes + ----- + The following routes trigger this function. + + `/` + `/home` + + Examples + -------- + >>> home() + """ + if not os.path.isfile(database_cache_file): + return render_template('home_db_not_cached.html', title='Home') + + try: + items = json.loads(helpers.file_load(filename=database_cache_file, binary=False)) + except IOError: + return responses[500] + + return render_template('home.html', title=_('Home'), items=items) + + +@app.route('/settings/', defaults={'configuration_spec': None}) +@app.route('/settings/') +def settings(configuration_spec: Optional[str]) -> render_template: + """ + Serve the configuration page page. + + .. todo:: This documentation needs to be improved. + + Parameters + ---------- + configuration_spec : Optional[str] + The spec to return. In the future this will be used to return config specs of plugins; however that is not + currently implemented. + + Returns + ------- + render_template + The rendered page. + + Notes + ----- + The following routes trigger this function. + + `/settings` + + Examples + -------- + >>> settings() + """ + config_settings = config.decode_config(common.CONFIG) + + if not configuration_spec: + config_spec = config._CONFIG_SPEC_DICT + else: + # todo - handle plugin configs + config_spec = None + + return render_template('config.html', title=_('Settings'), config_settings=config_settings, config_spec=config_spec) + + +@app.route('/docs/', defaults={'filename': 'index.html'}) +@app.route('/docs/') +def docs(filename) -> send_from_directory: + """ + Serve the Sphinx html documentation. + + .. todo:: This documentation needs to be improved. + + Parameters + ---------- + filename : str + The html filename to return. + + Returns + ------- + flask.send_from_directory + The requested documentation page. + + Notes + ----- + The following routes trigger this function. + + `/docs/` + `/docs/` + + Examples + -------- + >>> docs(filename='index.html') + """ + + return send_from_directory(directory=os.path.join(Paths.DOCS_DIR), path=filename) + + +@app.route( + '/favicon.ico', + defaults={'img': 'favicon.ico'}, + methods=['GET'], +) +@app.route("/images/", methods=["GET"]) +def image(img: str) -> send_from_directory: + """ + Get image from static/images directory. + + Serve images from the static/images directory. + + Parameters + ---------- + img : str + The image to return. + + Returns + ------- + flask.send_from_directory + The image. + + Notes + ----- + The following routes trigger this function. + + - `/favicon.ico` + - `/images/` + + 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, path=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') +def status() -> dict: + """ + Check the status of Themerr-plex. + + This is useful for a healthcheck from Docker, and may have many other uses in the future for third party + applications. + + Returns + ------- + dict + A dictionary of the status. + + Examples + -------- + >>> status() + """ + web_status = {'result': 'success', 'message': 'Ok'} + return web_status + + +@app.route('/test_logger') +def test_logger() -> str: + """ + Test logging functions. + + Check `./logs/common.webapp.log` for output. + + Returns + ------- + str + A message telling the user to check the logs. + + Notes + ----- + The following routes trigger this function. + + `/test_logger` + + Examples + -------- + >>> test_logger() + """ + app.logger.info('testing from app.logger') + app.logger.warning('testing from app.logger') + app.logger.error('testing from app.logger') + app.logger.critical('testing from app.logger') + app.logger.debug('testing from app.logger') + return f'Testing complete, check "logs/{__name__}.log" for output.' + + +@app.route('/api/settings', methods=['GET', 'POST']) +@app.route('/api/settings/') +def api_settings() -> Response: + """ + Get current settings or save changes to settings from the web ui. + + This endpoint accepts a `GET` or `POST` request. A `GET` request will return the current settings. + A `POST` request will process the data passed in and return the results of processing. + + Returns + ------- + Response + A response formatted as ``flask.jsonify``. + + Examples + -------- + >>> api_settings() + + """ + config_spec = config._CONFIG_SPEC_DICT + + if request.method == 'GET': + return config.CONFIG + if request.method == 'POST': + # setup return data + message = '' # this will be populated as we progress + result_status = 'OK' + + boolean_dict = { + 'true': True, + 'false': False, + } + + data = request.form + _config = config.decode_config(common.CONFIG) + for option, value in data.items(): + split_option = option.split('|', 1) + key = split_option[0] + setting = split_option[1] + + setting_type = config_spec[key][setting]['type'] + + # get the original value + try: + og_value = _config[key][setting] + except KeyError: + og_value = '' + finally: + if setting_type == 'boolean': + value = boolean_dict[value.lower()] # using eval could allow code injection, so use dictionary + if setting_type == 'float': + value = float(value) + if setting_type == 'integer': + value = int(value) + if config.is_masked_field(section=key, key=setting): + value = config.encode_value(value) + + if og_value != value: + # setting changed, get the on change command + try: + setting_change_method = config_spec[key][setting]['on_change'] + except KeyError: + pass + else: + setting_change_method() + + config.CONFIG[key][setting] = value + + valid = config.validate_config(config=config.CONFIG) + + if valid: + message += 'Selected settings are valid.' + config.save_config(config=config.CONFIG) + + else: + message += 'Selected settings are not valid.' + + return jsonify({'status': f'{result_status}', 'message': f'{message}'}) + + +def start_webapp(): + """ + Start the webapp. + + Start the flask webapp. This is placed in it's own function to allow the ability to start the webapp within a + thread in a simple way. + + Examples + -------- + >>> start_webapp() + * Serving Flask app 'common.webapp' (lazy loading) + ... + * Running on https://.../ (Press CTRL+C to quit) + + >>> from common import webapp, threads + >>> threads.run_in_thread(target=webapp.start_webapp, name='Flask', daemon=True).start() + * Serving Flask app 'common.webapp' (lazy loading) + ... + * Running on https://.../ (Press CTRL+C to quit) + """ + global URL, URL_SCHEME + URL_SCHEME = 'https' if config.CONFIG['Network']['SSL'] else 'http' + URL = f"{URL_SCHEME}://127.0.0.1:{config.CONFIG['Network']['HTTP_PORT']}" + + if config.CONFIG['Network']['SSL']: + cert_file, key_file = crypto.initialize_certificate() + else: + cert_file = key_file = None + + app.run( + host=config.CONFIG['Network']['HTTP_HOST'], + port=config.CONFIG['Network']['HTTP_PORT'], + debug=common.DEV, + ssl_context=(cert_file, key_file) if config.CONFIG['Network']['SSL'] else None, + use_reloader=False # reloader doesn't work when running in a separate thread + ) + + +@app.route("/translations", methods=["GET"]) +def translations() -> Response: + """ + Serve the translations. + + Gets the user's locale and serves the translations for the webapp. + + Returns + ------- + Response + The translations. + + Examples + -------- + >>> translations() + """ + locale = locales.get_locale() + + po_files = [ + f'{Paths.LOCALE_DIR}/{locale}/LC_MESSAGES/themerr-plex.po', # selected locale + f'{Paths.LOCALE_DIR}/themerr-plex.po', # 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 + app.logger.debug(f'Translation: {entry.msgid} -> {entry.msgstr}') + + return Response(response=json.dumps(data), + status=200, + mimetype='application/json') diff --git a/src/plex/__init__.py b/src/plex/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/Contents/Code/plex_api_helper.py b/src/plex/plexapi.py similarity index 61% rename from Contents/Code/plex_api_helper.py rename to src/plex/plexapi.py index 640a6399..69d893bb 100644 --- a/Contents/Code/plex_api_helper.py +++ b/src/plex/plexapi.py @@ -1,24 +1,12 @@ -# -*- coding: utf-8 -*- - # standard imports +from future.moves import queue import os import time import threading +from typing import Callable, Optional, Tuple -# plex debugging -try: - import plexhints # noqa: F401 -except ImportError: - pass -else: # the code is running outside of Plex - from plexhints.log_kit import Log # log kit - from plexhints.parse_kit import JSON # parse kit - from plexhints.prefs_kit import Prefs # prefs kit - -# imports from Libraries\Shared -from future.moves import queue +# lib imports import requests -from typing import Callable, Optional, Tuple import urllib3 from urllib3.exceptions import InsecureRequestWarning from plexapi.alert import AlertListener @@ -28,18 +16,21 @@ from plexapi.utils import reverseSearchType # local imports -from constants import contributes_to, guid_map, media_type_dict, plex_token, plex_url -import general_helper -import lizardbyte_db_helper -import themerr_db_helper -import tmdb_helper -from youtube_dl_helper import process_youtube +from common import config +from common import helpers +from common import logger +from themerr.constants import contributes_to, guid_map, media_type_dict +from themerr import general +from themerr import themerr_db +from themerr import tmdb +from youtube.youtube_dl import process_youtube # fix random _strptime import bug in plexapi import _strptime # noqa: F401 -plex_server = None +log = logger.get_logger(__name__) +plex_server = None q = queue.Queue() # disable auto-reload, because Themerr doesn't rely on it, so it will only slow down the app @@ -47,7 +38,7 @@ os.environ["PLEXAPI_PLEXAPI_AUTORELOAD"] = "false" -def setup_plexapi(): +def setup_plexapi() -> Optional[plexapi.server.PlexServer]: """ Create the Plex server object. @@ -65,10 +56,12 @@ def setup_plexapi(): ... """ global plex_server + plex_token = config.CONFIG['Plex']['PLEX_TOKEN'] + plex_url = config.CONFIG['Plex']['PLEX_URL'] if not plex_server: if not plex_token: - Log.Error('Plex token not found in environment, cannot proceed.') - return False + log.error('Plex token not found, cannot proceed.') + return None sess = requests.Session() sess.verify = False # Ignore verifying the SSL certificate @@ -80,8 +73,7 @@ def setup_plexapi(): return plex_server -def update_plex_item(rating_key): - # type: (int) -> bool +def update_plex_item(rating_key: int) -> bool: """ Automated update of Plex item using only the rating key. @@ -106,14 +98,14 @@ def update_plex_item(rating_key): item = get_plex_item(rating_key=rating_key) if not item: - Log.Error('Could not find item with rating key: %s' % rating_key) + log.error(f'Could not find item with rating key: {rating_key}') return False database_info = get_database_info(item=item) - Log.Debug('-' * 50) - Log.Debug('item title: {}'.format(item.title)) - Log.Debug('item type: {}'.format(item.type)) - Log.Debug('database_info: {}'.format(database_info)) + log.debug('-' * 50) + log.debug(f'item title: {item.title}') + log.debug(f'item type: {item.type}') + log.debug(f'database_info: {database_info}') database_type = database_info[0] database = database_info[1] @@ -121,53 +113,47 @@ def update_plex_item(rating_key): database_id = database_info[3] if database and database_type and database_id: - if not themerr_db_helper.item_exists(database_type=database_type, database=database, id=database_id): - Log.Debug('{} item does not exist in ThemerrDB, skipping: {} ({})' - .format(item.type, item.title, database_id)) + if not themerr_db.item_exists(database_type=database_type, database=database, id=database_id): + log.debug(f'{item.type} item does not exist in ThemerrDB, skipping: {item.title} ({database_id})') return False try: - data = JSON.ObjectFromURL( - cacheTime=3600, - url='https://app.lizardbyte.dev/ThemerrDB/{}/{}/{}.json'.format(database_type, database, database_id), - errors='ignore' # don't crash the plugin + data = helpers.json_get( + cache_time=3600, + url=f'https://app.lizardbyte.dev/ThemerrDB/{database_type}/{database}/{database_id}.json', ) except Exception as e: - Log.Error('{}: Error retrieving data from ThemerrDB: {}'.format(item.ratingKey, e)) + log.error(f'{item.ratingKey}: Error retrieving data from ThemerrDB: {e}') else: if data: # update collection metadata - Log.Debug('data found for {} {}'.format(item.type, item.title)) + log.debug(f'data found for {item.type} {item.title}') if item.type == 'collection': # determine if we want to update the metadata based on the agent and user preferences update_collection_metadata = False if agent == 'tv.plex.agents.movie': # new Plex Movie agent - if Prefs['bool_update_collection_metadata_plex_movie']: - update_collection_metadata = True - elif database != 'igdb': # any other legacy agents except RetroArcher - # game collections/franchises don't have extended metadata - if Prefs['bool_update_collection_metadata_legacy']: + if config.CONFIG['Themerr']['BOOL_AUTO_UPDATE_COLLECTION_THEMES']: update_collection_metadata = True if update_collection_metadata: # update poster try: - url = 'https://image.tmdb.org/t/p/original{}'.format(data['poster_path']) + url = f'https://image.tmdb.org/t/p/original{data['poster_path']}' except KeyError: pass else: add_media(item=item, media_type='posters', media_url_id=data['poster_path'], media_url=url) # update art try: - url = 'https://image.tmdb.org/t/p/original{}'.format(data['backdrop_path']) + url = f'https://image.tmdb.org/t/p/original{data['backdrop_path']}' except KeyError: pass else: add_media(item=item, media_type='art', media_url_id=data['backdrop_path'], media_url=url) # update summary - if item.isLocked(field='summary') and not Prefs['bool_ignore_locked_fields']: - Log.Debug('Not overwriting locked summary for collection: {}'.format(item.title)) + if item.isLocked(field='summary') and not config.CONFIG['Themerr']['BOOL_IGNORE_LOCKED_FIELDS']: + log.debug(f'Not overwriting locked summary for collection: {item.title}') else: try: summary = data['overview'] @@ -175,28 +161,28 @@ def update_plex_item(rating_key): pass else: if item.summary != summary: - Log.Info('Updating summary for collection: {}'.format(item.title)) + log.info(f'Updating summary for collection: {item.title}') try: item.editSummary(summary=summary, locked=False) except Exception as e: - Log.Error('{}: Error updating summary: {}'.format(item.ratingKey, e)) + log.error(f'{item.ratingKey}: Error updating summary: {e}') - if item.isLocked(field='theme') and not Prefs['bool_ignore_locked_fields']: - Log.Debug('Not overwriting locked theme for {}: {}'.format(item.type, item.title)) + if item.isLocked(field='theme') and not config.CONFIG['Themerr']['BOOL_IGNORE_LOCKED_FIELDS']: + log.debug(f'Not overwriting locked theme for {item.type}: {item.title}') elif ( - not Prefs['bool_overwrite_plex_provided_themes'] and - general_helper.get_theme_provider(item=item) == 'plex' + not config.CONFIG['Themerr']['BOOL_OVERWRITE_PLEX_PROVIDED_THEMES'] and + general.get_theme_provider(item=item) == 'plex' ): - Log.Debug('Not overwriting Plex provided theme for {}: {}'.format(item.type, item.title)) + log.debug(f'Not overwriting Plex provided theme for {item.type}: {item.title}') else: # get youtube_url try: yt_video_url = data['youtube_theme_url'] except KeyError: - Log.Info('{}: No theme song found for {} ({})'.format(item.ratingKey, item.title, item.year)) + log.info(f'{item.ratingKey}: No theme song found for {item.title} ({item.year})') else: - settings_hash = general_helper.get_themerr_settings_hash() - themerr_data = general_helper.get_themerr_json_data(item=item) + settings_hash = general.get_themerr_settings_hash() + themerr_data = general.get_themerr_json_data(item=item) try: skip = themerr_data['settings_hash'] == settings_hash \ @@ -205,22 +191,26 @@ def update_plex_item(rating_key): skip = False if skip: - Log.Info('Skipping {} for type: {}, title: {}, rating_key: {}'.format( - media_type_dict['themes']['name'], item.type, item.title, item.ratingKey - )) + log.info(f'Skipping {media_type_dict['themes']['name']} for ' + f'type: {item.type}, title: {item.title}, rating_key: {item.ratingKey}') else: try: theme_url = process_youtube(url=yt_video_url) except Exception as e: - Log.Exception('{}: Error processing youtube url: {}'.format(item.ratingKey, e)) + log.exception(f'{item.ratingKey}: Error processing youtube url:', exc_info=e) else: if theme_url: add_media(item=item, media_type='themes', media_url_id=yt_video_url, media_url=theme_url) -def add_media(item, media_type, media_url_id, media_file=None, media_url=None): - # type: (PlexPartialObject, str, str, Optional[str], Optional[str]) -> bool +def add_media( + item: PlexPartialObject, + media_type: str, + media_url_id: str, + media_file: Optional[str] = None, + media_url: Optional[str] = None, +) -> bool: """ Apply media to the specified item. @@ -252,26 +242,23 @@ def add_media(item, media_type, media_url_id, media_file=None, media_url=None): """ uploaded = False - settings_hash = general_helper.get_themerr_settings_hash() - themerr_data = general_helper.get_themerr_json_data(item=item) + settings_hash = general.get_themerr_settings_hash() + themerr_data = general.get_themerr_json_data(item=item) - if item.isLocked(field=media_type_dict[media_type]['plex_field']) and not Prefs['bool_ignore_locked_fields']: - Log.Info('Not overwriting locked "{}" for {}: {}'.format( - media_type_dict[media_type]['name'], item.type, item.title - )) + if (item.isLocked(field=media_type_dict[media_type]['plex_field']) and + not config.CONFIG['Themerr']['BOOL_IGNORE_LOCKED_FIELDS']): + log.info(f'Not overwriting locked "{media_type_dict[media_type]['name']}" for {item.type}: {item.title}') return False if media_file or media_url: - Log.Info('Plexapi attempting to upload {} for type: {}, title: {}, rating_key: {}'.format( - media_type_dict[media_type]['name'], item.type, item.title, item.ratingKey - )) + log.info(f'Plexapi attempting to upload {media_type_dict[media_type]['name']} for ' + f'type: {item.type}, title: {item.title}, rating_key: {item.ratingKey}') try: if themerr_data['settings_hash'] == settings_hash \ and themerr_data[media_type_dict[media_type]['themerr_data_key']] == media_url_id: - Log.Info('Skipping {} for type: {}, title: {}, rating_key: {}'.format( - media_type_dict[media_type]['name'], item.type, item.title, item.ratingKey - )) + log.info(f'Skipping {media_type_dict[media_type]['name']} for ' + f'type: {item.type}, title: {item.title}, rating_key: {item.ratingKey}') # false because we aren't doing anything, and the listener will not see this item again return False @@ -279,20 +266,17 @@ def add_media(item, media_type, media_url_id, media_file=None, media_url=None): pass # remove existing theme uploads - if Prefs[media_type_dict[media_type]['remove_pref']]: - general_helper.remove_uploaded_media(item=item, media_type=media_type) + if config.CONFIG['Themerr'][media_type_dict[media_type]['remove_pref']]: + general.remove_uploaded_media(item=item, media_type=media_type) - Log.Info('Attempting to upload {} for type: {}, title: {}, rating_key: {}'.format( - media_type_dict[media_type]['name'], item.type, item.title, item.ratingKey - )) + log.info(f'Attempting to upload {media_type_dict[media_type]['name']} for ' + f'type: {item.type}, title: {item.title}, rating_key: {item.ratingKey}') if media_file: uploaded = upload_media(item=item, method=media_type_dict[media_type]['method'](item), filepath=media_file) if media_url: uploaded = upload_media(item=item, method=media_type_dict[media_type]['method'](item), url=media_url) else: - Log.Warning('No theme songs provided for type: {}, title: {}, rating_key: {}'.format( - item.type, item.title, item.ratingKey - )) + log.warning(f'No theme songs provided for type: {item.type}, title: {item.title}, rating_key: {item.ratingKey}') if uploaded: # new data for themerr.json @@ -301,20 +285,18 @@ def add_media(item, media_type, media_url_id, media_file=None, media_url=None): ) new_themerr_data[media_type_dict[media_type]['themerr_data_key']] = media_url_id - general_helper.update_themerr_data_file(item=item, new_themerr_data=new_themerr_data) + general.update_themerr_data_file(item=item, new_themerr_data=new_themerr_data) # unlock the field since it contains an automatically added value change_lock_status(item=item, field=media_type_dict[media_type]['plex_field'], lock=False) else: - Log.Debug('Could not upload {} for type: {}, title: {}, rating_key: {}'.format( - media_type_dict[media_type]['name'], item.type, item.title, item.ratingKey - )) + log.debug(f'Could not upload {media_type_dict[media_type]['name']} for ' + f'type: {item.type}, title: {item.title}, rating_key: {item.ratingKey}') return uploaded -def change_lock_status(item, field, lock=False): - # type: (PlexPartialObject, str, bool) -> bool +def change_lock_status(item: PlexPartialObject, field: str, lock: bool = False) -> bool: """ Change the lock status of the specified field. @@ -340,11 +322,11 @@ def change_lock_status(item, field, lock=False): current_status = item.isLocked(field=field) if current_status == lock: - Log.Debug('Lock field "{}" is already {} for item: {}'.format(field, lock, item.title)) + log.debug(f'Lock field "{field}" is already {lock} for item: {item.title}') return current_status == lock edits = { - '{}.locked'.format(field): int(lock), + f'{field}.locked': int(lock), } count = 0 @@ -362,7 +344,7 @@ def change_lock_status(item, field, lock=False): break if not successful: - Log.Error('{}: Error {}ing field: {}'.format(item.ratingKey, lock_string, exception)) + log.error(f'{item.ratingKey}: Error {lock_string}ing field: {exception}') # we need to reload the item to get the new lock status reload_kwargs = {field: True} @@ -370,13 +352,17 @@ def change_lock_status(item, field, lock=False): locked = item.isLocked(field=field) if locked != lock: - Log.Error('{}: Error {}ing field: {} != {}'.format(item.ratingKey, lock_string, locked, lock)) + log.error(f'{item.ratingKey}: Error {lock_string}ing field: {locked} != {lock}') return locked == lock -def upload_media(item, method, filepath=None, url=None): - # type: (PlexPartialObject, Callable, Optional[str], Optional[str]) -> bool +def upload_media( + item: PlexPartialObject, + method: Callable, + filepath: Optional[str] = None, + url: Optional[str] = None, +) -> bool: """ Upload media to the specified item. @@ -406,22 +392,22 @@ def upload_media(item, method, filepath=None, url=None): ... """ count = 0 - while count <= int(Prefs['int_plexapi_upload_retries_max']): + while count <= int(config.CONFIG['Themerr']['INT_PLEXAPI_UPLOAD_RETRIES_MAX']): try: if filepath: if method == item.uploadTheme: - method(filepath=filepath, timeout=int(Prefs['int_plexapi_plexapi_timeout'])) + method(filepath=filepath, timeout=int(config.CONFIG['Themerr']['INT_PLEXAPI_PLEXAPI_TIMEOUT'])) else: method(filepath=filepath) elif url: if method == item.uploadTheme: - method(url=url, timeout=int(Prefs['int_plexapi_plexapi_timeout'])) + method(url=url, timeout=int(config.CONFIG['Themerr']['INT_PLEXAPI_PLEXAPI_TIMEOUT'])) else: method(url=url) except BadRequest as e: sleep_time = 2 ** count - Log.Error('%s: Error uploading media: %s' % (item.ratingKey, e)) - Log.Error('%s: Trying again in : %s' % (item.ratingKey, sleep_time)) + log.error(f'{item.ratingKey}: Error uploading media: {e}') + log.error(f'{item.ratingKey}: Trying again in : {sleep_time} seconds') time.sleep(sleep_time) count += 1 else: @@ -429,8 +415,7 @@ def upload_media(item, method, filepath=None, url=None): return False -def get_database_info(item): - # type: (PlexPartialObject) -> Tuple[Optional[str], Optional[str], Optional[str], Optional[str]] +def get_database_info(item: PlexPartialObject) -> Tuple[Optional[str], Optional[str], Optional[str], Optional[str]]: """ Get the database info for the specified item. @@ -451,9 +436,12 @@ def get_database_info(item): -------- >>> get_database_info(item=...) """ - Log.Debug('Getting database info for item: %s' % item.title) + log.debug(f'Getting database info for item: {item.title}') plex = setup_plexapi() + if not plex: + log.error('Unable to setup plex server, cannot proceed. Ensure Plex is properly configured in the settings.') + return None, None, None, None agent = None database = None @@ -473,28 +461,10 @@ def get_database_info(item): database_id = temp_database_id database = temp_database - if temp_database == 'themoviedb': # tmdb is our prefered db, so we break if found + if temp_database == 'themoviedb': # tmdb is our preferred db, so we break if found database_id = temp_database_id database = temp_database 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_type = 'games' - database = 'igdb' - database_id = item.guid.split('igdb-')[1].split('}')[0] - elif agent == 'com.plexapp.agents.themoviedb': - # com.plexapp.agents.themoviedb://363088?lang=en - database_type = 'movies' - database = 'themoviedb' - database_id = item.guid.split('://')[1].split('?')[0] - elif agent == 'com.plexapp.agents.imdb': - # com.plexapp.agents.imdb://tt0113189?lang=en - database_type = 'movies' - database = 'imdb' - database_id = item.guid.split('://')[1].split('?')[0] elif item.type == 'show': database_type = 'tv_shows' @@ -507,7 +477,7 @@ def get_database_info(item): temp_database_id = split_guid[1] if temp_database == 'imdb' or temp_database == 'thetvdb': - database_id = tmdb_helper.get_tmdb_id_from_external_id( + database_id = tmdb.get_tmdb_id_from_external_id( external_id=temp_database_id, database=split_guid[0], item_type='tv', @@ -516,27 +486,10 @@ def get_database_info(item): database = 'themoviedb' break - if temp_database == 'themoviedb': # tmdb is our prefered db, so we break if found + if temp_database == 'themoviedb': # tmdb is our preferred db, so we break if found database_id = temp_database_id database = temp_database break - elif item.guid: - split_guid = item.guid.split('://') - agent = split_guid[0] - if agent == 'com.plexapp.agents.themoviedb': - database = 'themoviedb' - database_id = item.guid.split('://')[1].split('?')[0] - elif agent == 'com.plexapp.agents.thetvdb': - temp_database = 'thetvdb' - temp_database_id = item.guid.split('://')[1].split('?')[0] - - # ThemerrDB does not have TVDB IDs, so we need to convert it to TMDB ID - database_id = tmdb_helper.get_tmdb_id_from_external_id( - external_id=temp_database_id, - database='tvdb', - item_type='tv', - ) - database = 'themoviedb' if database_id else None elif item.type == 'collection': # this is tricky since collections don't match up with any of the databases @@ -546,31 +499,21 @@ def get_database_info(item): section = plex.library.sectionByID(item.librarySectionID) agent = section.agent - if agent == 'dev.lizardbyte.retroarcher-plex': - # this collection is for a game library - database = 'igdb' - collection_data = lizardbyte_db_helper.get_igdb_id_from_collection(search_query=item.title) - if collection_data: - database_id = collection_data[0] - database_type = collection_data[1] - else: - database = 'themoviedb' - database_type = 'movie_collections' + database = 'themoviedb' + database_type = 'movie_collections' - # we need to get the library language for the library that this item belongs to - library_language = plex.library.sectionByID(item.librarySectionID).language + # we need to get the library language for the library that this item belongs to + library_language = plex.library.sectionByID(item.librarySectionID).language - database_id = tmdb_helper.get_tmdb_id_from_collection( - search_query='{}&language={}'.format(item.title, library_language) - ) + database_id = tmdb.get_tmdb_id_from_collection( + search_query=f'{item.title}&language={library_language}' + ) - Log.Debug('Database info for item: {}, database_info: {}'.format( - item.title, (database_type, database, agent, database_id))) + log.debug(f'Database info for item: {item.title}, database_info: {(database_type, database, agent, database_id)}') return database_type, database, agent, database_id -def get_plex_item(rating_key): - # type: (int) -> PlexPartialObject +def get_plex_item(rating_key: int) -> Optional[PlexPartialObject]: """ Get any item from the Plex Server. @@ -592,13 +535,15 @@ def get_plex_item(rating_key): ... """ plex = setup_plexapi() + if not plex: + log.error('Unable to setup plex server, cannot proceed. Ensure Plex is properly configured in the settings.') + return None item = plex.fetchItem(ekey=rating_key) return item -def process_queue(): - # type: () -> None +def process_queue() -> None: """ Add items to the queue. @@ -614,12 +559,11 @@ def process_queue(): try: update_plex_item(rating_key=rating_key) # process that rating_key except Exception as e: - Log.Exception('Unexpected error processing rating key: %s, error: %s' % (rating_key, e)) + log.exception(f'Unexpected error processing rating key: {rating_key}, error: {e}') q.task_done() # tells the queue that we are done with this item -def start_queue_threads(): - # type: () -> None +def start_queue_threads() -> None: """ Start queue threads. @@ -632,7 +576,7 @@ def start_queue_threads(): """ # create multiple threads for processing themes faster # minimum value of 1 - for t in range(max(1, int(Prefs['int_plexapi_upload_threads']))): + for t in range(max(1, int(config.CONFIG['Themerr']['INT_PLEXAPI_UPLOAD_THREADS']))): try: # for each thread, start it t = threading.Thread(target=process_queue) @@ -641,12 +585,11 @@ def start_queue_threads(): # start the daemon thread t.start() except RuntimeError as e: - Log.Error('RuntimeError encountered: %s' % e) + log.error(f'RuntimeError encountered: {e}') break -def plex_listener(): - # type: () -> None +def plex_listener() -> None: """ Listen for events from Plex server. @@ -658,12 +601,14 @@ def plex_listener(): ... """ plex = setup_plexapi() - listener = AlertListener(server=plex, callback=plex_listener_handler, callbackError=Log.Error) + if not plex: + log.error('Unable to setup plex server, cannot proceed. Ensure Plex is properly configured in the settings.') + return + listener = AlertListener(server=plex, callback=plex_listener_handler, callbackError=log.error) listener.start() -def plex_listener_handler(data): - # type: (dict) -> None +def plex_listener_handler(data: dict) -> None: """ Process events from ``plex_listener()``. @@ -690,8 +635,10 @@ def plex_listener_handler(data): # https://github.com/pkkid/python-plexapi/blob/8b3235445f6b3051c39ff6d6fc5d49f4e674d576/plexapi/utils.py#L35-L55 if ( ( - (reverseSearchType(libtype=entry['type']) == 'movie' and Prefs['bool_plex_movie_support']) or - (reverseSearchType(libtype=entry['type']) == 'show' and Prefs['bool_plex_series_support']) + (reverseSearchType(libtype=entry['type']) == 'movie' and + config.CONFIG['Themerr']['BOOL_PLEX_MOVIE_SUPPORT']) or + (reverseSearchType(libtype=entry['type']) == 'show' and + config.CONFIG['Themerr']['BOOL_PLEX_SERIES_SUPPORT']) ) and entry['state'] == 5 and entry['identifier'] == 'com.plexapp.plugins.library' @@ -702,14 +649,13 @@ def plex_listener_handler(data): rating_key = int(entry['itemID']) - # since we added the themerr json file, we no longer need to keep track of whether the update + # since we added the themerr JSON file, we no longer need to keep track of whether the update # here is from Themerr updating the theme, as we will just skip it if no changes are required if rating_key not in q.queue: # if the item was not in the list, then add it to the queue q.put(item=rating_key) -def scheduled_update(): - # type: () -> None +def scheduled_update() -> None: """ Update all items in the Plex Server. @@ -725,8 +671,11 @@ def scheduled_update(): scheduled_tasks.schedule_loop : The method that runs the pending scheduled tasks. """ plex = setup_plexapi() + if not plex: + log.error('Unable to setup plex server, cannot proceed. Ensure Plex is properly configured in the settings.') + return - themerr_db_helper.update_cache() + themerr_db.update_cache() plex_library = plex.library @@ -734,33 +683,31 @@ def scheduled_update(): 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 - # individual items that was matched with a supported agent... + # with legacy agents, not all items in the library had to match to the library agent + # not the case with new agents (probably) continue # skip unsupported metadata agents - if not plex_token: - Log.Error('Plex token not found in environment, cannot proceed.') - continue - # check if the agent is enabled - if not general_helper.continue_update(item_agent=section.agent, item_type=section.type): - Log.Debug('Themerr-plex is disabled for agent "{}"'.format(section.agent)) + if not general.continue_update(item_agent=section.agent): + log.debug(f'Themerr-plex is disabled for agent "{section.agent}"') continue + # TODO: add a check and option to ignore specific libraries + all_items = [] # get all the items in the section if section.type == 'movie': - media_items = section.all() if Prefs['bool_auto_update_movie_themes'] else [] + media_items = section.all() if config.CONFIG['Themerr']['BOOL_PLEX_MOVIE_SUPPORT'] else [] # get all collections in the section - collections = section.collections() if Prefs['bool_auto_update_collection_themes'] else [] + collections = section.collections() if config.CONFIG['Themerr']['BOOL_PLEX_COLLECTION_SUPPORT'] else [] # combine the items and collections into one list # this is done so that we can process both items and collections in the same loop all_items = media_items + collections elif section.type == 'show': - all_items = section.all() if Prefs['bool_auto_update_tv_themes'] else [] + all_items = section.all() if config.CONFIG['Themerr']['BOOL_PLEX_SERIES_SUPPORT'] else [] for item in all_items: if item.ratingKey not in q.queue: diff --git a/src/themerr/__init__.py b/src/themerr/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/themerr/cache.py b/src/themerr/cache.py new file mode 100644 index 00000000..2ef24d66 --- /dev/null +++ b/src/themerr/cache.py @@ -0,0 +1,164 @@ +# standard imports +import json +import os +from threading import Lock + +# lib imports +from six.moves.urllib.parse import quote_plus + +# local imports +from common import config +from common import definitions +from common import helpers +from common import logger +from themerr.constants import contributes_to, issue_urls +from themerr import general +from plex.plexapi import get_database_info, setup_plexapi +from themerr import themerr_db +from themerr import tmdb + +log = logger.get_logger(name=__name__) + +# where the database cache is stored +database_cache_file = os.path.join(definitions.Paths.CONFIG_DIR, 'database_cache.json') +database_cache_lock = Lock() + + +def cache_data() -> None: + """ + Cache data for use in the Web UI dashboard. + + Because there are many http requests that must be made to gather the data for the dashboard, it can be + time-consuming to populate; therefore, this is performed within this caching function, which runs on a schedule. + This function will create a json file that can be loaded by other functions. + """ + # get all Plex items from supported metadata agents + plex_server = setup_plexapi() + if not plex_server: + log.error('Unable to setup plex server, cannot proceed. Ensure Plex is properly configured in the settings.') + return + + plex_library = plex_server.library + + themerr_db.update_cache() + + 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 the items in the section + media_items = section.all() + + # get all items in the section with theme songs + media_items_with_themes = section.all(theme__exists=True) + + # get all collections in the section + collections = section.collections() if config.CONFIG['Themerr']['BOOL_PLEX_COLLECTION_SUPPORT'] else [] + collections_with_themes = section.collections(theme__exists=True) if config.CONFIG['Themerr'][ + 'BOOL_PLEX_COLLECTION_SUPPORT'] else [] + + # combine the items and collections into one list + # this is done so that we can process both items and collections in the same loop + all_items = media_items + collections + + # add each section to the items dict + items[section.key] = dict( + key=section.key, + title=section.title, + agent=section.agent, + items=[], + media_count=len(media_items), + media_percent_complete=int( + len(media_items_with_themes) / len(media_items) * 100) if len(media_items_with_themes) else 0, + collection_count=len(collections), + collection_percent_complete=int( + len(collections_with_themes) / len(collections) * 100) if len(collections_with_themes) else 0, + collections_enabled=config.CONFIG['Themerr']['BOOL_PLEX_COLLECTION_SUPPORT'], + total_count=len(all_items), + type=section.type, + ) + + for item in all_items: + # build the issue url + database_info = get_database_info(item=item) + database_type = database_info[0] + database = database_info[1] + item_agent = database_info[2] + database_id = database_info[3] + + og_db = database + og_db_id = database_id + + year = getattr(item, 'year', None) + + # convert imdb id to tmdb id, so we can build the issue url properly + if item.type == 'movie' and database_id and ( + item_agent == 'com.plexapp.agents.imdb' + or database_id.startswith('tt') + ): + # try to get tmdb id from imdb id + tmdb_id = tmdb.get_tmdb_id_from_external_id( + external_id=database_id, database='imdb', item_type='movie') + database_id = tmdb_id if tmdb_id else None + + item_issue_url = None + + issue_url = issue_urls.get(database_type) + + if issue_url: + # override the id since ThemerrDB issues require the slug as part of the url + if item.type == 'movie': + issue_title = f'{getattr(item, "originalTitle", None) or item.title} ({year})' + elif item.type == 'show': + issue_title = f'{item.title} ({year})' + else: # collections + issue_title = item.title + + if database_id: + # url encode the issue title + issue_title = quote_plus(issue_title) + + item_issue_url = issue_url.format(issue_title, database_id) + + if database_type and og_db and og_db_id and themerr_db.item_exists( + database_type=database_type, + database=og_db, + id=og_db_id, + ): + issue_action = 'edit' + else: + issue_action = 'add' + + if item.theme: + theme_status = 'complete' + else: + if issue_action == 'edit': + theme_status = 'failed' + else: + theme_status = 'missing' + + theme_provider = general.get_theme_provider(item=item) + + items[section.key]['items'].append(dict( + title=item.title, + agent=item_agent, + database=database, + database_type=database_type, + database_id=database_id, + issue_action=issue_action, + issue_url=item_issue_url, + theme=True if item.theme else False, + theme_provider=theme_provider, + theme_status=theme_status, + type=item.type, + year=year, + )) + + with database_cache_lock: + helpers.file_save(filename=database_cache_file, data=json.dumps(items), binary=False) diff --git a/src/themerr/constants.py b/src/themerr/constants.py new file mode 100644 index 00000000..82f2986f --- /dev/null +++ b/src/themerr/constants.py @@ -0,0 +1,95 @@ +# standard imports +import os + +contributes_to = [ + 'tv.plex.agents.movie', # new movie agent + 'tv.plex.agents.series', # new tv show agent +] + +guid_map = dict( + imdb='imdb', + tmdb='themoviedb', + tvdb='thetvdb' +) + +metadata_type_map = dict( + album='Albums', + artist='Artists', + collection='Collections', + movie='Movies', + show='TV Shows' +) + +# the explicit IPv4 address is used because `localhost` can resolve to ::1, which `websocket` rejects +plex_url = 'http://127.0.0.1:32400' # TODO: this needs to be a configuration option +plex_token = os.environ.get('PLEXTOKEN') # TODO: this needs to be a configuration option + +plex_section_type_settings_map = dict( + album=9, + artist=8, + movie=1, + photo=13, + show=2, +) + +# issue url constants +base_url = 'https://github.com/LizardByte/ThemerrDB/issues/new?assignees=' +issue_label = 'request-theme' +issue_template = 'theme.yml' +url_name = 'database_url' +title_prefix = dict( + movies='[MOVIE]: ', + movie_collections='[MOVIE COLLECTION]: ', + tv_shows='[TV SHOW]: ', +) +url_prefix = dict( + movies='https://www.themoviedb.org/movie/', + movie_collections='https://www.themoviedb.org/collection/', + tv_shows='https://www.themoviedb.org/tv/', +) + +# two additional strings to fill in later, item title and item url +issue_urls = dict( + movies=f'{base_url}' + f'&labels={issue_label}' + f'&template={issue_template}' + f'&title={title_prefix['movies']}{'{}'}' + f'&{url_name}={url_prefix['movies']}{'{}'}', + movie_collections=f'{base_url}' + f'&labels={issue_label}' + f'&template={issue_template}' + f'&title={title_prefix['movie_collections']}{'{}'}' + f'&{url_name}={url_prefix['movie_collections']}{'{}'}', + tv_shows=f'{base_url}' + f'&labels={issue_label}' + f'&template={issue_template}' + f'&title={title_prefix['tv_shows']}{'{}'}' + f'&{url_name}={url_prefix['tv_shows']}{'{}'}', +) + +media_type_dict = dict( + art=dict( + method=lambda item: item.uploadArt, + type='art', + name='art', + themerr_data_key='art_url', + remove_pref='BOOL_REMOVE_UNUSED_ART', + plex_field='art', + ), + posters=dict( + method=lambda item: item.uploadPoster, + type='posters', + name='poster', + themerr_data_key='poster_url', + remove_pref='BOOL_REMOVE_UNUSED_POSTERS', + plex_field='thumb', + ), + themes=dict( + method=lambda item: item.uploadTheme, + type='themes', + name='theme', + themerr_data_key='youtube_theme_url', + remove_pref='BOOL_REMOVE_UNUSED_THEMES', + plex_field='theme', + ), +) diff --git a/Contents/Code/general_helper.py b/src/themerr/general.py similarity index 64% rename from Contents/Code/general_helper.py rename to src/themerr/general.py index 808b8993..5d7d4ebd 100644 --- a/Contents/Code/general_helper.py +++ b/src/themerr/general.py @@ -1,35 +1,21 @@ -# -*- coding: utf-8 -*- - # standard imports import hashlib import json import os import shutil +from typing import Optional -# 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.parse_kit import XML # parse kit - from plexhints.prefs_kit import Prefs # prefs kit - -# imports from Libraries\Shared +# lib imports from plexapi.base import PlexPartialObject -from typing import Optional # local imports -from constants import ( - contributes_to, - metadata_base_directory, - metadata_type_map, - plex_section_type_settings_map, - plex_url, - themerr_data_directory -) +from common import config +from common import definitions +from common import helpers +from common import logger +from themerr.constants import metadata_type_map + +log = logger.get_logger(__name__) # constants legacy_keys = [ @@ -37,8 +23,7 @@ ] -def _get_metadata_path(item): - # type: (PlexPartialObject) -> str +def _get_metadata_path(item: PlexPartialObject) -> str: """ Get the metadata path of the item. @@ -62,60 +47,12 @@ def _get_metadata_path(item): guid = item.guid full_hash = hashlib.sha1(guid).hexdigest() metadata_path = os.path.join( - metadata_base_directory, metadata_type_map[item.type], + config.CONFIG['Plex']['PLEX_APP_SUPPORT_PATH'], 'Metadata', metadata_type_map[item.type], full_hash[0], full_hash[1:] + '.bundle') return metadata_path -def agent_enabled(item_agent, item_type): - # type: (str, str) -> bool - """ - Check if the specified agent is enabled. - - Parameters - ---------- - item_agent : str - The agent to check. - item_type : str - The type of the item to check. - - Returns - ------- - py:class:`bool` - True if the agent is enabled, False otherwise. - - Examples - -------- - >>> agent_enabled(item_agent='com.plexapp.agents.imdb', item_type='movie') - True - >>> agent_enabled(item_agent='com.plexapp.agents.themoviedb', item_type='movie') - True - >>> agent_enabled(item_agent='com.plexapp.agents.themoviedb', item_type='show') - True - >>> agent_enabled(item_agent='com.plexapp.agents.thetvdb', item_type='show') - True - >>> agent_enabled(item_agent='dev.lizardbyte.retroarcher-plex', item_type='movie') - True - """ - # get the settings for this agent - settings_url = '{}/system/agents/{}/config/{}'.format( - plex_url, item_agent, plex_section_type_settings_map[item_type]) - settings_data = XML.ElementFromURL( - url=settings_url, - cacheTime=0 - ) - Log.Debug('settings data: {}'.format(settings_data)) - - themerr_plex_element = settings_data.find(".//Agent[@name='Themerr-plex']") - - if themerr_plex_element.get('enabled') == '1': # Plex is using a string - return True - else: - return False - - -def continue_update(item_agent, item_type): - # type: (str, str) -> bool +def continue_update(item_agent: str) -> bool: """ Check if the specified agent should continue updating. @@ -123,8 +60,6 @@ def continue_update(item_agent, item_type): ---------- item_agent : str The agent to check. - item_type : str - The type of the item to check. Returns ------- @@ -133,33 +68,20 @@ def continue_update(item_agent, item_type): Examples -------- - >>> continue_update(item_agent='tv.plex.agents.movie', item_type='movie') - True - >>> continue_update(item_agent='tv.plex.agents.series', item_type='show') - True - >>> continue_update(item_agent='com.plexapp.agents.imdb', item_type='movie') - True - >>> continue_update(item_agent='com.plexapp.agents.themoviedb', item_type='movie') - True - >>> continue_update(item_agent='com.plexapp.agents.themoviedb', item_type='show') - True - >>> continue_update(item_agent='com.plexapp.agents.thetvdb', item_type='show') + >>> continue_update(item_agent='tv.plex.agents.movie') True - >>> continue_update(item_agent='dev.lizardbyte.retroarcher-plex', item_type='movie') + >>> continue_update(item_agent='tv.plex.agents.series') True """ if item_agent == 'tv.plex.agents.movie': - return Prefs['bool_plex_movie_support'] + return config.CONFIG['Themerr']['BOOL_PLEX_MOVIE_SUPPORT'] elif item_agent == 'tv.plex.agents.series': - return Prefs['bool_plex_series_support'] - elif item_agent in contributes_to: - return agent_enabled(item_agent=item_agent, item_type=item_type) + return config.CONFIG['Themerr']['BOOL_PLEX_SERIES_SUPPORT'] else: return False -def get_media_upload_path(item, media_type): - # type: (PlexPartialObject, str) -> str +def get_media_upload_path(item: PlexPartialObject, media_type: str) -> str: """ Get the path to the theme upload directory. @@ -195,15 +117,14 @@ def get_media_upload_path(item, media_type): if media_type not in allowed_media_types: raise ValueError( 'This error should be reported to https://github.com/LizardByte/Themerr-plex/issues;' - 'media_type must be one of: {}'.format(allowed_media_types) + f'media_type must be one of: {allowed_media_types}' ) theme_upload_path = os.path.join(_get_metadata_path(item=item), 'Uploads', media_type) return theme_upload_path -def get_theme_provider(item): - # type: (PlexPartialObject) -> Optional[str] +def get_theme_provider(item: PlexPartialObject) -> Optional[str]: """ Get the theme provider. @@ -237,7 +158,7 @@ def get_theme_provider(item): } if not item.themes(): - Log.Debug('No themes found for item: {}'.format(item.title)) + log.debug(f'No themes found for item: {item.title}') return provider = None @@ -248,7 +169,7 @@ def get_theme_provider(item): selected = theme break if not selected: - Log.Debug('No selected theme found for item: {}'.format(item.title)) + log.debug(f'No selected theme found for item: {item.title}') return if selected.provider in provider_map.keys(): @@ -271,8 +192,7 @@ def get_theme_provider(item): return provider -def get_themerr_json_path(item): - # type: (PlexPartialObject) -> str +def get_themerr_json_path(item: PlexPartialObject) -> str: """ Get the path to the Themerr data file. @@ -293,13 +213,12 @@ def get_themerr_json_path(item): >>> get_themerr_json_path(item=...) '.../Plex Media Server/Plug-in Support/Data/dev.lizardbyte.themerr-plex/DataItems/...' """ - themerr_json_path = os.path.join(themerr_data_directory, metadata_type_map[item.type], - '{}.json'.format(item.ratingKey)) + themerr_json_path = os.path.join(definitions.Paths.CONFIG_DIR, 'data', metadata_type_map[item.type], + f'{item.ratingKey}.json') return themerr_json_path -def get_themerr_json_data(item): - # type: (PlexPartialObject) -> dict +def get_themerr_json_data(item: PlexPartialObject) -> dict: """ Get the Themerr data for the specified item. @@ -319,15 +238,14 @@ def get_themerr_json_data(item): themerr_json_path = get_themerr_json_path(item=item) if os.path.isfile(themerr_json_path): - themerr_data = json.loads(s=str(Core.storage.load(filename=themerr_json_path, binary=False))) + themerr_data = json.loads(s=str(helpers.file_load(filename=themerr_json_path, binary=False))) else: themerr_data = dict() return themerr_data -def get_themerr_settings_hash(): - # type: () -> str +def get_themerr_settings_hash() -> str: """ Get a hash of the current Themerr settings. @@ -343,15 +261,14 @@ def get_themerr_settings_hash(): """ # use to compare previous settings to new settings themerr_settings = dict( - bool_prefer_mp4a_codec=Prefs['bool_prefer_mp4a_codec'], - int_plexapi_plexapi_timeout=Prefs['int_plexapi_plexapi_timeout'], + bool_prefer_mp4a_codec=config.CONFIG['Themerr']['BOOL_PREFER_MP4A_CODEC'], + int_plexapi_plexapi_timeout=config.CONFIG['Themerr']['INT_PLEXAPI_PLEXAPI_TIMEOUT'], ) settings_hash = hashlib.sha256(json.dumps(themerr_settings)).hexdigest() return settings_hash -def remove_uploaded_media(item, media_type): - # type: (PlexPartialObject, str) -> None +def remove_uploaded_media(item: PlexPartialObject, media_type: str) -> None: """ Remove themes for the specified item. @@ -379,8 +296,7 @@ def remove_uploaded_media(item, media_type): shutil.rmtree(path=theme_upload_path, ignore_errors=True, onerror=remove_uploaded_media_error_handler) -def remove_uploaded_media_error_handler(func, path, exc_info): - # type: (any, any, any) -> None +def remove_uploaded_media_error_handler(func: any, path: any, exc_info: any) -> None: """ Error handler for removing themes. @@ -395,11 +311,10 @@ def remove_uploaded_media_error_handler(func, path, exc_info): exc_info : any The exception information. """ - Log.Error('Error removing themes with function: %s, path: %s, exception info: %s' % (func, path, exc_info)) + log.error(f'Error removing themes with function: {func}, path: {path}, exception info: {exc_info}') -def update_themerr_data_file(item, new_themerr_data): - # type: (PlexPartialObject, dict) -> None +def update_themerr_data_file(item: PlexPartialObject, new_themerr_data: dict) -> None: """ Update the Themerr data file for the specified item. @@ -433,4 +348,4 @@ def update_themerr_data_file(item, new_themerr_data): os.makedirs(os.path.dirname(themerr_json_path)) # write themerr json - Core.storage.save(filename=themerr_json_path, data=json.dumps(themerr_data), binary=False) + helpers.file_save(filename=themerr_json_path, data=json.dumps(themerr_data), binary=False) diff --git a/Contents/Code/scheduled_tasks.py b/src/themerr/scheduled_tasks.py similarity index 60% rename from Contents/Code/scheduled_tasks.py rename to src/themerr/scheduled_tasks.py index cb839352..3201523c 100644 --- a/Contents/Code/scheduled_tasks.py +++ b/src/themerr/scheduled_tasks.py @@ -1,43 +1,35 @@ -# -*- coding: utf-8 -*- - # standard imports -import logging import threading import time -# plex debugging -try: - import plexhints # noqa: F401 -except ImportError: - pass -else: # the code is running outside of Plex - from plexhints.log_kit import Log # log kit - from plexhints.prefs_kit import Prefs # prefs kit - -# imports from Libraries\Shared +# lib imports import schedule from typing import Any, Callable, Iterable, Mapping, Optional # local imports -from constants import plugin_identifier -from plex_api_helper import scheduled_update -from webapp import cache_data +from common import config +from common import logger +from plex.plexapi import scheduled_update +from themerr.cache import cache_data -# setup logging for schedule -Log.Info('Adding schedule log handlers to plex plugin logger') +log = logger.get_logger(name=__name__) -# get the plugin logger -plugin_logger = logging.getLogger(plugin_identifier) +# setup logging for schedule +log.info('Adding schedule log handlers to plex plugin logger') -schedule.logger.handlers = plugin_logger.handlers -schedule.logger.setLevel(plugin_logger.level) +schedule.logger.handlers = log.handlers +schedule.logger.setLevel(log.level) # test message schedule.logger.info('schedule logger test message') -def run_threaded(target, daemon=None, args=(), **kwargs): - # type: (Callable, Optional[bool], Iterable, Mapping[str, Any]) -> threading.Thread +def run_threaded( + target: Callable, + daemon: Optional[bool] = None, + args: Iterable = (), + **kwargs: Mapping[str, Any], +) -> threading.Thread: """ Run a function in a thread. @@ -62,7 +54,7 @@ def run_threaded(target, daemon=None, args=(), **kwargs): Examples -------- - >>> run_threaded(target=Log.Info, daemon=True, args=['Hello, world!']) + >>> run_threaded(target=log.info, daemon=True, args=['Hello, world!']) "Hello, world!" """ job_thread = threading.Thread(target=target, args=args, kwargs=kwargs) @@ -72,8 +64,7 @@ def run_threaded(target, daemon=None, args=(), **kwargs): return job_thread -def schedule_loop(): - # type: () -> None +def schedule_loop() -> None: """ Start the schedule loop. @@ -92,12 +83,11 @@ def schedule_loop(): time.sleep(1) -def setup_scheduling(): - # type: () -> None +def setup_scheduling() -> None: """ Sets up the scheduled tasks. - The Tasks setup depend on the preferences set by the user. + The Tasks setup depends on the preferences set by the user. Examples -------- @@ -108,13 +98,13 @@ def setup_scheduling(): -------- plex_api_helper.scheduled_update : Scheduled function to update the themes. """ - if Prefs['bool_auto_update_items']: - schedule.every(max(15, int(Prefs['int_update_themes_interval']))).minutes.do( + if config.CONFIG['Themerr']['BOOL_THEMERR_ENABLED']: + schedule.every(max(15, int(config.CONFIG['Themerr']['INT_UPDATE_THEMES_INTERVAL']))).minutes.do( job_func=run_threaded, target=scheduled_update ) - schedule.every(max(15, int(Prefs['int_update_database_cache_interval']))).minutes.do( + schedule.every(max(15, int(config.CONFIG['Themerr']['INT_UPDATE_DATABASE_CACHE_INTERVAL']))).minutes.do( job_func=run_threaded, target=cache_data ) diff --git a/Contents/Code/themerr_db_helper.py b/src/themerr/themerr_db.py similarity index 58% rename from Contents/Code/themerr_db_helper.py rename to src/themerr/themerr_db.py index 535857f2..574f6dac 100644 --- a/Contents/Code/themerr_db_helper.py +++ b/src/themerr/themerr_db.py @@ -1,28 +1,18 @@ -# -*- coding: utf-8 -*- - # standard imports from threading import Lock import time +from typing import Union -# plex debugging -try: - import plexhints # noqa: F401 -except ImportError: - pass -else: # the code is running outside of Plex - from plexhints.log_kit import Log # log kit - from plexhints.parse_kit import JSON # parse kit +# local imports +from common import helpers +from common import logger -# imports from Libraries\Shared -from typing import Union +log = logger.get_logger(name=__name__) database_cache = {} last_cache_update = 0 db_field_name = dict( - games={'igdb': 'id'}, - game_collections={'igdb': 'id'}, - game_franchises={'igdb': 'id'}, movies={'themoviedb': 'id', 'imdb': 'imdb_id'}, movie_collections={'themoviedb': 'id'}, tv_shows={'themoviedb': 'id'}, @@ -31,8 +21,7 @@ lock = Lock() -def update_cache(): - # type: () -> None +def update_cache() -> None: """ Update the ThemerrDB cache. @@ -44,31 +33,29 @@ def update_cache(): Updating the cache less than an hour after the last update is a no-op. """ - Log.Info('Updating ThemerrDB cache') + log.info('Updating ThemerrDB cache') global last_cache_update if time.time() - last_cache_update < 3600: - Log.Info('Cache updated less than an hour ago, skipping') + log.info('Cache updated less than an hour ago, skipping') return with lock: for database_type, databases in db_field_name.items(): try: - pages = JSON.ObjectFromURL( - cacheTime=3600, - url='https://app.lizardbyte.dev/ThemerrDB/{}/pages.json'.format(database_type), - errors='ignore' # don't crash the plugin + pages = helpers.json_get( + cache_time=3600, + url=f'https://app.lizardbyte.dev/ThemerrDB/{database_type}/pages.json', ) page_count = pages['pages'] id_index = {db: set() for db in databases} for page in range(page_count): - page_data = JSON.ObjectFromURL( - cacheTime=3600, - url='https://app.lizardbyte.dev/ThemerrDB/{}/all_page_{}.json'.format(database_type, page + 1), - errors='ignore' # don't crash the plugin + page_data = helpers.json_get( + cache_time=3600, + url=f'https://app.lizardbyte.dev/ThemerrDB/{database_type}/all_page_{page + 1}.json', ) for db in databases: @@ -76,17 +63,16 @@ def update_cache(): database_cache[database_type] = id_index - Log.Info('{}: database updated'.format(database_type)) + log.info(f'{database_type}: database updated') except Exception as e: - Log.Error('{}: Error retrieving page index from ThemerrDB: {}'.format(database_type, e)) + log.error(f'{database_type}: Error retrieving page index from ThemerrDB: {e}') database_cache[database_type] = {} last_cache_update = time.time() -def item_exists(database_type, database, id): - # type: (str, str, Union[int, str]) -> bool +def item_exists(database_type: str, database: str, id: Union[int, str]) -> bool: """ Check if an item exists in the ThemerrDB. @@ -108,15 +94,11 @@ def item_exists(database_type, database, id): Examples -------- - >>> item_exists(database_type='games', database='igdb', id=1234) - True - >>> item_exists(database_type='movies', database='themoviedb', id=1234) False """ if database_type not in db_field_name: - Log.Critical('"{}" is not a valid database_type. Allowed values are: {}' - .format(database_type, db_field_name.keys())) + log.critical(f'"{database_type}" is not a valid database_type. Allowed values are: {db_field_name.keys()}') return False if database_type not in database_cache: diff --git a/Contents/Code/tmdb_helper.py b/src/themerr/tmdb.py similarity index 51% rename from Contents/Code/tmdb_helper.py rename to src/themerr/tmdb.py index 6dab42c0..a6c7fa47 100644 --- a/Contents/Code/tmdb_helper.py +++ b/src/themerr/tmdb.py @@ -1,25 +1,38 @@ -# -*- coding: utf-8 -*- - -# plex debugging -try: - import plexhints # noqa: F401 -except ImportError: - pass -else: # the code is running outside of Plex - from plexhints.constant_kit import CACHE_1DAY # constant kit - from plexhints.log_kit import Log # log kit - from plexhints.parse_kit import JSON # parse kit - from plexhints.util_kit import String # util kit - -# imports from Libraries\Shared +# standard imports from typing import Optional, Union -# url borrowed from TheMovieDB.bundle -tmdb_base_url = 'http://127.0.0.1:32400/services/tmdb?uri=' +# local imports +from common import config +from common import helpers +from common import logger +log = logger.get_logger(name=__name__) -def get_tmdb_id_from_external_id(external_id, database, item_type): - # type: (Union[int, str], str, str) -> Optional[int] + +def tmdb_base_url() -> Optional[str]: + """ + Get the base URL for the Plex TMDB service. + + .. todo:: is Plex going to keep these services after removing plugins altogether? + + Returns + ------- + str + Base URL for the Plex TMDB service. + + Examples + -------- + >>> tmdb_base_url() + '.../services/tmdb?uri=' + """ + try: + return f'{config.CONFIG['Plex']['PLEX_URL']}/services/tmdb?uri=' + except (KeyError, TypeError): + log.exception('Error getting base URL for Plex TMDB service. Config may not be initialized.') + return + + +def get_tmdb_id_from_external_id(external_id: Union[int, str], database: str, item_type: str) -> Optional[int]: """ Convert IMDB ID to TMDB ID. @@ -47,38 +60,39 @@ def get_tmdb_id_from_external_id(external_id, database, item_type): 48866 """ if database.lower() not in ['imdb', 'tvdb']: - Log.Exception('Invalid database: {}'.format(database)) + log.exception(f'Invalid database: {database}') return if item_type.lower() not in ['movie', 'tv']: - Log.Exception('Invalid item type: {}'.format(item_type)) + log.exception(f'Invalid item type: {item_type}') return # according to https://www.themoviedb.org/talk/5f6a0500688cd000351c1712 we can search by external id # https://api.themoviedb.org/3/find/tt0458290?api_key=###&external_source=imdb_id find_url_suffix = 'find/{}?external_source={}_id' - url = '{}/{}'.format( - tmdb_base_url, - find_url_suffix.format(String.Quote(s=str(external_id), usePlus=True), database.lower()) - ) + url = f'{tmdb_base_url()}/{find_url_suffix.format( + helpers.string_quote(string=str(external_id), use_plus=True), database.lower())}' try: - tmdb_data = JSON.ObjectFromURL( - url=url, sleep=2.0, headers=dict(Accept='application/json'), cacheTime=CACHE_1DAY, errors='strict') + tmdb_data = helpers.json_get( + url=url, + sleep_time=2.0, + headers=dict(Accept='application/json'), + cache_time=86400, # 1 day + ) except Exception as e: - Log.Debug('Error converting external ID to TMDB ID: {}'.format(e)) + log.debug(f'Error converting external ID to TMDB ID: {e}') else: - Log.Debug('TMDB data: {}'.format(tmdb_data)) + log.debug(f'TMDB data: {tmdb_data}') try: # this is already an integer, but let's force it - tmdb_id = int(tmdb_data['{}_results'.format(item_type.lower())][0]['id']) + tmdb_id = int(tmdb_data[f'{item_type.lower()}_results'][0]['id']) except (IndexError, KeyError, ValueError): - Log.Debug('Error converting external ID to TMDB ID: {}'.format(tmdb_data)) + log.debug(f'Error converting external ID to TMDB ID: {tmdb_data}') else: return tmdb_id -def get_tmdb_id_from_collection(search_query): - # type: (str) -> Optional[int] +def get_tmdb_id_from_collection(search_query: str) -> Optional[int]: """ Search for a collection by name. @@ -105,25 +119,29 @@ def get_tmdb_id_from_collection(search_query): query_url = 'search/collection?query={}' query_item = search_query.split('&', 1)[0] - # Plex returns 500 error if spaces are in collection query, same with `_`, `+`, and `%20`... so use `-` - url = '{}/{}'.format(tmdb_base_url, query_url.format(String.Quote( - s=search_query.replace(' ', '-'), usePlus=False))) + # Plex returns 500 error if spaces are in the collection query, same with `_`, `+`, and `%20`... so use `-` + url = f'{tmdb_base_url()}/{query_url.format(helpers.string_quote( + string=search_query.replace(' ', '-'), use_plus=False))}' try: - tmdb_data = JSON.ObjectFromURL( - url=url, sleep=2.0, headers=dict(Accept='application/json'), cacheTime=CACHE_1DAY, errors='strict') + tmdb_data = helpers.json_get( + url=url, + sleep_time=2.0, + headers=dict(Accept='application/json'), + cache_time=86400, # 1 day + ) except Exception as e: - Log.Debug('Error searching for collection {}: {}'.format(search_query, e)) + log.debug(f'Error searching for collection {search_query}: {e}') else: collection_id = None - Log.Debug('TMDB data: {}'.format(tmdb_data)) + log.debug(f'TMDB data: {tmdb_data}') end_string = 'Collection' # collection names on themoviedb end with 'Collection' try: for result in tmdb_data['results']: if result['name'].lower() == query_item.lower() or \ - '{} {}'.format(query_item.lower(), end_string).lower() == result['name'].lower(): + f'{query_item.lower()} {end_string}'.lower() == result['name'].lower(): collection_id = int(result['id']) except (IndexError, KeyError, ValueError): - Log.Debug('Error searching for collection {}: {}'.format(search_query, tmdb_data)) + log.debug(f'Error searching for collection {search_query}: {tmdb_data}') else: return collection_id diff --git a/src/themerr_plex.py b/src/themerr_plex.py new file mode 100644 index 00000000..3be8c3f5 --- /dev/null +++ b/src/themerr_plex.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +""" +Themerr-plex.py + +Responsible for starting Themerr Plex. +""" +# standard imports +import argparse +import os +import sys +import time +from typing import Union + +# local imports +import common +from common import config +from common import definitions +from common import helpers +from common import locales +from common import logger +from common import threads + +app_name = 'themerr-plex' + +# locales +_ = locales.get_text() + +# get logger +log = logger.get_logger(name=app_name) + + +class IntRange(object): + """ + Custom IntRange class for argparse. + + Prevents printing out large list of possible choices for integer ranges. + + Parameters + ---------- + stop : int + Range maximum value. + start : int, default = 0 + Range minimum value. + + Methods + ------- + __call__: + Validate that value is within accepted range. + + Examples + -------- + >>> IntRange(0, 10) + + """ + def __init__(self, stop: int, start: int = 0,): + """ + Initialize the IntRange class object. + + If stop is less than start, the values will be corrected automatically. + """ + if stop < start: + stop, start = start, stop + self.start, self.stop = start, stop + + def __call__(self, value: Union[int, str]) -> int: + """ + Validate that value is within accepted range. + + Validate the provided value is within the range of the `IntRange()` object. + + Parameters + ---------- + value : Union[int, str] + The value to validate. + + Returns + ------- + int + The original value. + + Raises + ------ + argparse.ArgumentTypeError + If provided value is outside the accepted range. + + Examples + -------- + >>> IntRange(0, 10).__call__(5) + 5 + + >>> IntRange(0, 10).__call__(15) + Traceback (most recent call last): + ... + argparse.ArgumentTypeError: Value outside of range: (0, 10) + """ + value = int(value) + if value < self.start or value >= self.stop: + raise argparse.ArgumentTypeError(f'Value outside of range: ({self.start}, {self.stop})') + return value + + +def main(): + """ + Application entry point. + + Parses arguments and initializes the application. + + Examples + -------- + >>> if __name__ == "__main__": + ... main() + """ + # Fixed paths + if definitions.Modes.FROZEN: # only when using the pyinstaller build + + if definitions.Modes.SPLASH: + import pyi_splash # module cannot be installed outside of pyinstaller builds + pyi_splash.update_text("Attempting to start Themerr-plex") + + # Set up and gather command line arguments + # todo... fix translations for '--help' command + parser = argparse.ArgumentParser(description=_('Themerr-plex is an application that manages theme songs for Plex.\n' + 'Arguments supplied here are meant to be temporary.')) + + parser.add_argument('--config', help=_('Specify a config file to use')) + parser.add_argument('--debug', action='store_true', help=_('Use debug logging level')) + parser.add_argument('--dev', action='store_true', help=_('Start Themerr-plex in the development environment')) + parser.add_argument('--docker_healthcheck', action='store_true', help=_('Health check the container and exit')) + parser.add_argument('--nolaunch', action='store_true', help=_('Do not open Themerr-plex in browser')) + parser.add_argument('-p', '--port', default=9494, type=IntRange(21, 65535), + help=_('Force Themerr-plex to run on a specified port, default=9494') + ) + parser.add_argument('-q', '--quiet', action='store_true', help=_('Turn off console logging')) + parser.add_argument('-v', '--version', action='store_true', help=_('Print the version details and exit')) + + args = parser.parse_args() + + if args.docker_healthcheck: + status = helpers.docker_healthcheck() + exit_code = int(not status) + sys.exit(exit_code) + + if args.version: + print('version arg is not yet implemented') + sys.exit() + + if args.config: + config_file = args.config + else: + config_file = os.path.join(definitions.Paths.CONFIG_DIR, definitions.Files.CONFIG) + if args.debug: + common.DEBUG = True + if args.dev: + common.DEV = True + if args.quiet: + common.QUIET = True + + # initialize Themerr-plex + # logging should not occur until after initialize + # any submodules that require translations need to be imported after config is initialize + common.initialize(config_file=config_file) + + if args.config: + log.info(msg=f"Themerr-plex is using custom config file: {config_file}.") + if args.debug: + log.info(msg="Themerr-plex will log debug messages.") + if args.dev: + log.info(msg="Themerr-plex is running in the dev environment.") + if args.quiet: + log.info(msg="Themerr-plex is running in quiet mode. Nothing will be printed to console.") + + if args.port: + config.CONFIG['Network']['HTTP_PORT'] = args.port + config.CONFIG.write() + + if config.CONFIG['General']['SYSTEM_TRAY']: + from common import tray_icon # submodule requires translations so importing after initialization + # also do not import if not required by config options + + tray_icon.tray_run_threaded() + + # start the webapp + if definitions.Modes.SPLASH: # pyinstaller build only, not darwin platforms + pyi_splash.update_text("Starting the webapp") + time.sleep(3) # show splash screen for a min of 3 seconds + pyi_splash.close() # close the splash screen + from common import webapp # import at use due to translations + from plex import plexapi # import at use due to config + from themerr import scheduled_tasks + + threads.run_in_thread(target=webapp.start_webapp, name='Flask', daemon=True).start() + + # this should be after starting flask app + if config.CONFIG['General']['LAUNCH_BROWSER'] and not args.nolaunch: + helpers.open_url_in_browser(url=webapp.URL) + + # start plex listener + plexapi.start_queue_threads() + plexapi.plex_listener() + + # scheduled tasks + scheduled_tasks.setup_scheduling() + + wait() # wait for signal + + +def wait(): + """ + Wait for signal. + + Endlessly loop while `common.SIGNAL = None`. + If `common.SIGNAL` is changed to `shutdown` or `restart` `common.stop()` will be executed. + If KeyboardInterrupt signal is detected `common.stop()` will be executed. + + Examples + -------- + >>> wait() + """ + log.info("Themerr-plex is ready!") + + while True: # wait endlessly for a signal + if not common.SIGNAL: + try: + time.sleep(1) + except KeyboardInterrupt: + common.SIGNAL = 'shutdown' + else: + log.info(f'Received signal: {common.SIGNAL}') + + if common.SIGNAL == 'shutdown': + common.stop() + elif common.SIGNAL == 'restart': + common.stop(restart=True) + else: + log.error('Unknown signal. Shutting down...') + common.stop() + + break + + +if __name__ == "__main__": + main() diff --git a/src/youtube/__init__.py b/src/youtube/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/Contents/Code/youtube_dl_helper.py b/src/youtube/youtube_dl.py similarity index 67% rename from Contents/Code/youtube_dl_helper.py rename to src/youtube/youtube_dl.py index 34cc9cf0..fd795478 100644 --- a/Contents/Code/youtube_dl_helper.py +++ b/src/youtube/youtube_dl.py @@ -1,33 +1,21 @@ -# -*- coding: utf-8 -*- - # standard imports -import logging import json import os import tempfile - -# plex debugging -try: - import plexhints # noqa: F401 -except ImportError: - pass -else: # the code is running outside of Plex - from plexhints.log_kit import Log # log kit - from plexhints.prefs_kit import Prefs # prefs kit - -# imports from Libraries\Shared from typing import Optional -import youtube_dl + +# lib imports +import yt_dlp # local imports -from constants import plugin_identifier, plugin_support_data_directory +from common import config +from common import definitions +from common import logger -# get the plugin logger -plugin_logger = logging.getLogger(plugin_identifier) +log = logger.get_logger(name=__name__) -def nsbool(value): - # type: (bool) -> str +def ns_bool(value: bool) -> str: """ Format a boolean value for a Netscape cookie jar file. @@ -44,10 +32,9 @@ def nsbool(value): return 'TRUE' if value else 'FALSE' -def process_youtube(url): - # type: (str) -> Optional[str] +def process_youtube(url: str) -> Optional[str]: """ - Process URL using `youtube_dl` + Process URL using `yt_dlp` Parameters ---------- @@ -65,40 +52,43 @@ def process_youtube(url): ... """ - cookie_jar_file = tempfile.NamedTemporaryFile(dir=plugin_support_data_directory, delete=False) + cookie_jar_file = tempfile.NamedTemporaryFile( + dir=os.path.join(definitions.Paths.CONFIG_DIR, 'cookies'), + delete=False, + ) cookie_jar_file.write('# Netscape HTTP Cookie File\n') youtube_dl_params = dict( cookiefile=cookie_jar_file.name, - logger=plugin_logger, + logger=log, socket_timeout=10, youtube_include_dash_manifest=False, ) - if Prefs['str_youtube_cookies']: + if config.CONFIG['Themerr']['STR_YOUTUBE_COOKIES']: try: - cookies = json.loads(Prefs['str_youtube_cookies']) + cookies = json.loads(config.CONFIG['Themerr']['STR_YOUTUBE_COOKIES']) for cookie in cookies: include_subdomain = cookie['domain'].startswith('.') expiry = int(cookie.get('expiry', 0)) values = [ cookie['domain'], - nsbool(include_subdomain), + ns_bool(include_subdomain), cookie['path'], - nsbool(cookie['secure']), + ns_bool(cookie['secure']), str(expiry), cookie['name'], cookie['value'] ] - cookie_jar_file.write('{}\n'.format('\t'.join(values))) + cookie_jar_file.write(f'{'\t'.join(values)}\n') except Exception as e: - Log.Exception('Failed to write YouTube cookies to file, will try anyway. Error: {}'.format(e)) + log.exception(f'Failed to write YouTube cookies to file, will try anyway. Error: {e}') cookie_jar_file.flush() cookie_jar_file.close() try: - ydl = youtube_dl.YoutubeDL(params=youtube_dl_params) + ydl = yt_dlp.YoutubeDL(params=youtube_dl_params) with ydl: try: @@ -107,10 +97,10 @@ def process_youtube(url): download=False # We just want to extract the info ) except Exception as exc: - if isinstance(exc, youtube_dl.utils.ExtractorError) and exc.expected: - Log.Info('YDL returned YT error while downloading {}: {}'.format(url, exc)) + if isinstance(exc, yt_dlp.utils.ExtractorError) and exc.expected: + log.info(f'yt-dlp returned YT error while downloading {url}: {exc}') else: - Log.Exception('YDL returned an unexpected error while downloading {}: {}'.format(url, exc)) + log.exception(f'yt-dlp returned an unexpected error while downloading {url}: {exc}') return None if 'entries' in result: @@ -138,7 +128,7 @@ def process_youtube(url): elif 'mp4a' == fmt['acodec'].split('.')[0]: temp_codec = 'mp4a' else: - Log.Debug('Unknown codec: %s' % fmt['acodec']) + log.debug(f'Unknown codec: {fmt['acodec']}') continue # unknown codec filesize = int(fmt['filesize']) if filesize > selected[temp_codec]['size']: @@ -152,7 +142,7 @@ def process_youtube(url): elif 0 < selected['mp4a']['size'] > selected['opus']['size']: audio_url = selected['mp4a']['audio_url'] - if audio_url and Prefs['bool_prefer_mp4a_codec']: # mp4a codec is preferred + if audio_url and config.CONFIG['Themerr']['BOOL_PREFER_MP4A_CODEC']: # mp4a codec is preferred if selected['mp4a']['audio_url']: # mp4a codec is available audio_url = selected['mp4a']['audio_url'] elif selected['opus']['audio_url']: # fallback to opus :( @@ -163,4 +153,4 @@ def process_youtube(url): try: os.remove(cookie_jar_file.name) except Exception as e: - Log.Exception('Failed to delete cookie jar file: {}'.format(e)) + log.exception(f'Failed to delete cookie jar file: {e}') diff --git a/tests/conftest.py b/tests/conftest.py index 6bd28a15..35b1a79e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,8 @@ -# -*- coding: utf-8 -*- +""" +tests/conftest.py +Fixtures for pytest. +""" # standard imports from functools import partial import os @@ -10,44 +13,32 @@ import plexapi from plexapi.exceptions import NotFound from plexapi.server import PlexServer -from plexhints.agent_kit import Agent import pytest import requests # add Contents directory to the system path pytest.root_dir = root_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -pytest.contents_dir = contents_dir = os.path.join(root_dir, 'Contents') -if os.path.isdir(contents_dir): - sys.path.append(contents_dir) +pytest.src_dir = src_dir = os.path.join(root_dir, 'src') + +if os.path.isdir(src_dir): # avoid flake8 E402 warning + sys.path.insert(0, src_dir) # local imports - from Code import constants - from Code import Themerr, ThemerrMovies, ThemerrTvShows - from Code import themerr_db_helper - from Code import webapp + import common + from common import config + from common import definitions + from common import webapp + from themerr import themerr_db else: - raise Exception('Contents directory not found') + raise Exception('src directory not found') # plex server setup SERVER_BASEURL = plexapi.CONFIG.get("auth.server_baseurl") SERVER_TOKEN = plexapi.CONFIG.get("auth.server_token") # constants -MOVIE_SECTIONS = ["Movies", "Movies-imdb", "Movies-tmdb"] -TV_SHOW_SECTIONS = ["TV Shows", "TV Shows-tmdb", "TV Shows-tvdb"] - - -def wait_for_file(file_path, timeout=300): - # type: (str, int) -> None - found = False - count = 0 - while not found and count < timeout: # plugin takes a little while to start on macOS - count += 1 - if os.path.isfile(file_path): - found = True - else: - time.sleep(1) - assert found, "After {} seconds, {} file not found".format(timeout, file_path) +MOVIE_SECTIONS = ["Movies"] +TV_SHOW_SECTIONS = ["TV Shows"] def wait_for_themes(section): @@ -75,23 +66,39 @@ def wait_for_themes(section): "Not all themes were uploaded in time, themes uploaded: {}/{}".format(with_themes, total)) -# basic fixtures -@pytest.fixture(params=['movies', 'tv_shows'], scope="function") -def agent(request): - # type: (any) -> Agent - if request.param == 'movies': - return ThemerrMovies() - elif request.param == 'tv_shows': - return ThemerrTvShows() - else: - return Themerr() +@pytest.fixture(scope='function') +def test_config_file(): + """Set a test config file path""" + test_config_file = os.path.join(definitions.Paths.CONFIG_DIR, 'test_config.ini') # use a dummy ini file + + yield test_config_file + + +@pytest.fixture(scope='function') +def test_config_object(test_config_file): + """Create a test config object""" + test_config_object = config.create_config(config_file=test_config_file) + + config.CONFIG = test_config_object + + yield test_config_object + + +@pytest.fixture(scope='function') +def test_common_init(test_config_file): + test_common_init = common.initialize(config_file=test_config_file) + + yield test_common_init + + common._INITIALIZED = False + common.SIGNAL = 'shutdown' @pytest.fixture(scope='function') -def test_client(): +def test_client(test_common_init): """Create a test client for testing webapp endpoints""" app = webapp.app - app.config['TESTING'] = True + app.testing = True client = app.test_client() @@ -103,25 +110,6 @@ def test_client(): # plex server fixtures -@pytest.fixture(scope="session") -def plugin_logs(): - # list contents of the plugin logs directory - plugin_logs = os.listdir(os.environ['PLEX_PLUGIN_LOG_PATH']) - - yield plugin_logs - - -# plex server fixtures -@pytest.fixture(scope="session") -def plugin_log_file(): - # the primary plugin log file - plugin_log_file = os.path.join(os.environ['PLEX_PLUGIN_LOG_PATH'], "{}.log".format(constants.plugin_identifier)) - - wait_for_file(file_path=plugin_log_file, timeout=300) - - yield plugin_log_file - - @pytest.fixture(scope="session") def sess(): session = requests.Session() @@ -196,6 +184,6 @@ def section(section_names, plex): @pytest.fixture(scope='function') def empty_themerr_db_cache(): - themerr_db_helper.database_cache = {} # reset the cache - themerr_db_helper.last_cache_update = 0 + themerr_db.database_cache = {} # reset the cache + themerr_db.last_cache_update = 0 return diff --git a/tests/functional/test_plex_has_themes.py b/tests/functional/test_plex_has_themes.py new file mode 100644 index 00000000..273327fd --- /dev/null +++ b/tests/functional/test_plex_has_themes.py @@ -0,0 +1,10 @@ +def _check_themes(items): + # ensure all items have themes + for item in items: + print(item.title) + assert item.theme, "No theme found for {}".format(item.title) + + +def test_items(section): + items = section.all() + _check_themes(items=items) diff --git a/tests/functional/test_plex_plugin.py b/tests/functional/test_plex_plugin.py deleted file mode 100644 index 50fe4dd3..00000000 --- a/tests/functional/test_plex_plugin.py +++ /dev/null @@ -1,43 +0,0 @@ -# -*- coding: utf-8 -*- - -# standard imports -import os - - -def _check_themes(items): - # ensure all items have themes - for item in items: - print(item.title) - assert item.theme, "No theme found for {}".format(item.title) - - -def test_plugin_logs(plugin_logs): - print('plugin_logs: {}'.format(plugin_logs)) - assert plugin_logs, "No plugin logs found" - - -def test_plugin_log_file(plugin_log_file): - assert os.path.isfile(plugin_log_file), "Plugin log file not found" - - -def test_plugin_log_file_exceptions(plugin_log_file): - # get all the lines in the plugin log file - with open(plugin_log_file, 'r') as f: - lines = f.readlines() - - critical_exceptions = [] - for line in lines: - if ') : CRITICAL (' in line: - critical_exceptions.append(line) - - assert len(critical_exceptions) <= 1, "Too many exceptions logged to plugin log file" - - for exception in critical_exceptions: - # every plugin will have this exception - assert exception.endswith('Exception getting hosted resource hashes (most recent call last):\n'), ( - "Unexpected exception: {}".format(exception)) - - -def test_items(section): - items = section.all() - _check_themes(items=items) diff --git a/tests/functional/test_webapp.py b/tests/functional/test_webapp.py index 03087201..001530e2 100644 --- a/tests/functional/test_webapp.py +++ b/tests/functional/test_webapp.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - # standard imports import os @@ -7,19 +5,19 @@ import pytest # local imports -from Code import webapp +from themerr import cache @pytest.fixture(scope='function') def remove_themerr_db_cache_file(): - _backup_file_name = "{}.bak".format(webapp.database_cache_file) + _backup_file_name = "{}.bak".format(cache.database_cache_file) # rename the file, so it is not found - os.rename(webapp.database_cache_file, _backup_file_name) + os.rename(cache.database_cache_file, _backup_file_name) yield # rename the file back - os.rename(_backup_file_name, webapp.database_cache_file) + os.rename(_backup_file_name, cache.database_cache_file) def test_home(test_client): diff --git a/tests/unit/common/__init__.py b/tests/unit/common/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/plex/__init__.py b/tests/unit/plex/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/test_plex_api_helper.py b/tests/unit/plex/test_plexapi.py similarity index 81% rename from tests/unit/test_plex_api_helper.py rename to tests/unit/plex/test_plexapi.py index f841e06a..5ada2329 100644 --- a/tests/unit/test_plex_api_helper.py +++ b/tests/unit/plex/test_plexapi.py @@ -1,10 +1,8 @@ -# -*- coding: utf-8 -*- - # lib imports import pytest # local imports -from Code import plex_api_helper +from plex import plexapi def test_all_themes_unlocked(section): @@ -21,6 +19,6 @@ def test_all_themes_unlocked(section): def test_change_lock_status(section, lock): field = 'theme' for item in section.all(): - change_status = plex_api_helper.change_lock_status(item, field=field, lock=lock) + change_status = plexapi.change_lock_status(item, field=field, lock=lock) assert change_status, 'change_lock_status did not return True' assert item.isLocked(field=field) == lock, 'Failed to change lock status to {}'.format(lock) diff --git a/tests/unit/test_code.py b/tests/unit/test_code.py deleted file mode 100644 index 428997d9..00000000 --- a/tests/unit/test_code.py +++ /dev/null @@ -1,165 +0,0 @@ -# -*- coding: utf-8 -*- - -# local imports -import Code -from Code import ValidatePrefs -from Code import default_prefs -from plexhints.agent_kit import Media -from plexhints.model_kit import Movie, MetadataModel -from plexhints.object_kit import MessageContainer, SearchResult -from plexhints.prefs_kit import Prefs - -# setup items to test -test_items = dict( - a=dict( - primary_agent='dev.lizardbyte.retroarcher-plex', - rating_key=1, - title='007 - GoldenEye (USA)', - year=1997, - id='{igdb-1638}', - category='games', - ), - b=dict( - primary_agent='com.plexapp.agents.themoviedb', - rating_key=2, - title='GoldenEye', - year=1995, - id='710', - category='movies', - ), - c=dict( - primary_agent='com.plexapp.agents.imdb', - rating_key=3, - title='GoldenEye', - year=1995, - id='tt0113189', - category='movies', - ), - d=dict( - primary_agent='com.plexapp.agents.themoviedb', - rating_key=4, - title='The 100', - year=2014, - id='48866', - category='tv_shows', - ), - e=dict( - primary_agent='com.plexapp.agents.thetvdb', - rating_key=5, - title='The 100', - year=2014, - id='268592', - category='tv_shows', - ), -) - - -def test_copy_prefs(): - Code.copy_prefs() - assert Code.last_prefs, "Prefs did not copy" - - for key in default_prefs: - assert Code.last_prefs[key] == Prefs[key] - - -def test_validate_prefs(): - result_container = ValidatePrefs() - assert isinstance(result_container, MessageContainer) - assert result_container.header == "Success" - assert "Provided preference values are ok" in result_container.message - - # invalidate prefs, cannot do this due to: - # TypeError: '_PreferenceSet' object does not support item assignment - # Code.Prefs['int_plexapi_plexapi_timeout'] = 'not an integer' - # result_container = ValidatePrefs() - # assert isinstance(result_container, MessageContainer) - # assert result_container.header == "Error" - # assert "must be an integer" in result_container.message - - -def test_validate_prefs_default_prefs(): - # add a default pref and make sure it is not in DefaultPrefs.json - default_prefs['new_pref'] = 'new_value' - result_container = ValidatePrefs() - assert isinstance(result_container, MessageContainer) - assert result_container.header == "Error" - assert "missing from 'DefaultPrefs.json'" in result_container.message - - -def test_start(): - # todo - pass - - -def test_main(): - # todo - pass - - -def test_themerr_agent_search(agent): - # if agent is for movies - supported_categories = [] - if isinstance(agent, Code.ThemerrMovies): - supported_categories.append('movies') - supported_categories.append('games') - elif isinstance(agent, Code.ThemerrTvShows): - supported_categories.append('tv_shows') - - for key, item in test_items.items(): - if item['category'] not in supported_categories: - continue - - if isinstance(agent, Code.ThemerrMovies): - media = Media.Movie() - media.primary_metadata = Movie() - elif isinstance(agent, Code.ThemerrTvShows): - media = Media.TV_Show() - media.primary_metadata = MetadataModel() - else: - assert False, "Agent is not ThemerrMovies or ThemerrTvShows" - - media.primary_agent = item['primary_agent'] - media.primary_metadata.id = item['id'] - media.primary_metadata.title = item['title'] - media.primary_metadata.year = item['year'] - - database = None - item_id = item['id'] - if item['category'] == 'games': - database = item['id'][1:-1].split('-')[0] - item_id = item['id'][1:-1].split('-')[-1] - elif item['category'] == 'movies' or item['category'] == 'tv_shows': - database = item['primary_agent'].split('.')[-1] - - results = agent.search(results=SearchResult(), media=media, lang='en', manual=False) - - for result in results.__items__: - # print(result.__dict__) - assert result.name == item['title'] - assert result.year == item['year'] - assert result.id == "%s-%s-%s" % (item['category'], database, item_id) - - -def test_themerr_agent_update(agent): - metadata = Movie() - - for key, item in test_items.items(): - media = Movie() - - database = None - item_id = item['id'] - if item['category'] == 'games': - database = item['id'][1:-1].split('-')[0] - item_id = item['id'][1:-1].split('-')[-1] - elif item['category'] == 'movies' or item['category'] == 'tv_shows': - database = item['primary_agent'].split('.')[-1] - - media.id = item['rating_key'] - metadata.title = item['title'] - metadata.year = item['year'] - metadata.id = "%s-%s-%s" % (item['category'], database, item_id) - - # this won't actually upload a theme since we're not working with a real Plex Server - metadata = agent.update(metadata=metadata, media=media, lang='en', force=True) - - assert isinstance(metadata, Movie) diff --git a/tests/unit/test_lizardbyte_db_helper.py b/tests/unit/test_lizardbyte_db_helper.py deleted file mode 100644 index 27d7c9dc..00000000 --- a/tests/unit/test_lizardbyte_db_helper.py +++ /dev/null @@ -1,31 +0,0 @@ -# -*- coding: utf-8 -*- - -# lib imports -import pytest - -# local imports -from Code import lizardbyte_db_helper - - -@pytest.mark.parametrize('search_query, collection_type, expected_type, expected_id', [ - ('James Bond', 'game_collections', 'game_collections', 326), - ('James Bond', 'game_franchises', 'game_franchises', 37), - ('James Bond', None, 'game_collections', 326), -]) -def test_get_igdb_id_from_collection(search_query, collection_type, expected_type, expected_id): - igdb_id = lizardbyte_db_helper.get_igdb_id_from_collection( - search_query=search_query, - collection_type=collection_type - ) - assert igdb_id == (expected_id, expected_type) - - -def test_get_igdb_id_from_collection_invalid(): - test = lizardbyte_db_helper.get_igdb_id_from_collection(search_query='Not a real collection') - assert test is None - - invalid_collection_type = lizardbyte_db_helper.get_igdb_id_from_collection( - search_query='James Bond', - collection_type='invalid', - ) - assert invalid_collection_type is None diff --git a/tests/unit/test_migration_helper.py b/tests/unit/test_migration_helper.py deleted file mode 100644 index 70cbaef1..00000000 --- a/tests/unit/test_migration_helper.py +++ /dev/null @@ -1,122 +0,0 @@ -# -*- coding: utf-8 -*- - -# standard imports -import os - -# lib imports -import pytest - -# local imports -from Code import migration_helper -from Code import plex_api_helper - -migration_helper_object = migration_helper.MigrationHelper() - - -@pytest.fixture(scope='function') -def migration_helper_fixture(): - return migration_helper_object - - -@pytest.fixture(scope='function') -def migration_status_file(migration_helper_fixture): - migration_status_file = migration_helper_fixture.migration_status_file - - # delete the migration status file if it exists - if os.path.isfile(migration_status_file): - os.remove(migration_status_file) - - # yield the migration status file - yield - - # delete the migration status file if it exists - if os.path.isfile(migration_status_file): - os.remove(migration_status_file) - - -@pytest.mark.parametrize('key, raise_exception, expected_return, expected_raise', [ - (migration_helper_object.LOCKED_THEMES, False, True, None), - (migration_helper_object.LOCKED_THEMES, True, True, None), - (migration_helper_object.LOCKED_COLLECTION_FIELDS, False, True, None), - (migration_helper_object.LOCKED_COLLECTION_FIELDS, True, True, None), - ('invalid', False, False, None), - ('invalid', True, False, AttributeError), -]) -def test_validate_migration_key(migration_helper_fixture, key, raise_exception, expected_return, expected_raise): - if expected_raise is not None: - with pytest.raises(expected_raise): - migration_helper_fixture._validate_migration_key(key=key, raise_exception=raise_exception) - else: - validated = migration_helper_fixture._validate_migration_key(key=key, raise_exception=raise_exception) - assert validated == expected_return, 'Expected {} but got {}'.format(expected_return, validated) - - -@pytest.mark.parametrize('key, expected', [ - (migration_helper_object.LOCKED_THEMES, None), - (migration_helper_object.LOCKED_COLLECTION_FIELDS, None), - pytest.param('invalid', None, marks=pytest.mark.xfail(raises=AttributeError)), -]) -def test_get_migration_status(migration_helper_fixture, migration_status_file, key, expected): - migration_status = migration_helper_fixture.get_migration_status(key=key) - assert migration_status == expected, 'Expected {} but got {}'.format(expected, migration_status) - - -@pytest.mark.parametrize('key', [ - migration_helper_object.LOCKED_THEMES, - migration_helper_object.LOCKED_COLLECTION_FIELDS, - pytest.param('invalid', marks=pytest.mark.xfail(raises=AttributeError)), -]) -def test_set_migration_status(migration_helper_fixture, migration_status_file, key): - # perform the test twice, to load an existing migration file - for _ in range(2): - migration_helper_fixture.set_migration_status(key=key) - migration_status = migration_helper_fixture.get_migration_status(key=key) - assert migration_status is True, 'Migration status was not set to True' - - -@pytest.mark.parametrize('key', [ - migration_helper_object.LOCKED_THEMES, - migration_helper_object.LOCKED_COLLECTION_FIELDS, -]) -def test_perform_migration(migration_helper_fixture, migration_status_file, key): - # perform the migration twice, should return early on the second run - for _ in range(2): - migration_helper_fixture.perform_migration(key=key) - migration_status = migration_helper_fixture.get_migration_status(key=key) - assert migration_status is True, 'Migration status was not set to True' - - -def test_migrate_locked_themes(section): - field = 'theme' - - # lock all is not working - # section.lockAllField(field=field, libtype='movie') - # section.reload() - - for item in section.all(): - plex_api_helper.change_lock_status(item=item, field=field, lock=True) - assert item.isLocked(field=field) is True, '{} for movie is not locked'.format(field) - - migration_helper_object.migrate_locked_themes() - section.reload() - - for item in section.all(): - assert item.isLocked(field=field) is False, '{} for movie is still locked'.format(field) - - -@pytest.mark.parametrize('field', [ - 'art', - 'summary', - 'thumb', -]) -def test_migrate_locked_collection_fields(field, section): - # lock all is not working, so lock manually - for item in section.collections(): - plex_api_helper.change_lock_status(item=item, field=field, lock=True) - assert item.isLocked(field=field) is True, '{} for collection is not locked'.format(field) - - migration_helper_object.migrate_locked_collection_fields() - section.reload() - - for item in section.collections(): - assert item.isLocked(field=field) is False, '{} for collection is still locked'.format(field) diff --git a/tests/unit/test_themerr_db_helper.py b/tests/unit/test_themerr_db_helper.py deleted file mode 100644 index 19803266..00000000 --- a/tests/unit/test_themerr_db_helper.py +++ /dev/null @@ -1,35 +0,0 @@ -# -*- coding: utf-8 -*- - -# local imports -from Code import plex_api_helper -from Code import themerr_db_helper - - -def test_update_cache(empty_themerr_db_cache): - themerr_db_helper.update_cache() - assert themerr_db_helper.last_cache_update > 0, 'Cache update did not complete' - - assert "movies" in themerr_db_helper.database_cache, 'Cache does not contain movies' - assert "movie_collections" in themerr_db_helper.database_cache, 'Cache does not contain movie_collections' - assert "games" in themerr_db_helper.database_cache, 'Cache does not contain games' - assert "game_collections" in themerr_db_helper.database_cache, 'Cache does not contain game_collections' - assert "game_franchises" in themerr_db_helper.database_cache, 'Cache does not contain game_franchises' - assert "tv_shows" in themerr_db_helper.database_cache, 'Cache does not contain tv_shows' - - -def test_item_exists(empty_themerr_db_cache, section): - for item in section.all(): - database_info = plex_api_helper.get_database_info(item=item) - - database_type = database_info[0] - database = database_info[1] - database_id = database_info[3] - - assert themerr_db_helper.item_exists(database_type=database_type, database=database, id=database_id), \ - '{} {} {} does not exist in ThemerrDB'.format(database, database_type, database_id) - - -def test_item_exists_with_invalid_database(): - # movie is not valid... the correct type is movies - assert not themerr_db_helper.item_exists(database_type='movie', database='invalid', id='invalid'), \ - 'Invalid database should not exist in ThemerrDB' diff --git a/tests/unit/test_webapp.py b/tests/unit/test_webapp.py deleted file mode 100644 index 2a68fd1c..00000000 --- a/tests/unit/test_webapp.py +++ /dev/null @@ -1,18 +0,0 @@ -# -*- coding: utf-8 -*- - -# standard imports -import json -import os - -# local imports -from Code import webapp - - -def test_cache_data(): - webapp.cache_data() - assert os.path.isfile(webapp.database_cache_file), "Database cache file not found" - - with open(webapp.database_cache_file, 'r') as f: - data = json.load(f) - - assert data, "Database cache file is empty" diff --git a/tests/unit/themerr/__init__.py b/tests/unit/themerr/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/themerr/test_cache.py b/tests/unit/themerr/test_cache.py new file mode 100644 index 00000000..778be847 --- /dev/null +++ b/tests/unit/themerr/test_cache.py @@ -0,0 +1,16 @@ +# standard imports +import json +import os + +# local imports +from themerr import cache + + +def test_cache_data(): + cache.cache_data() + assert os.path.isfile(cache.database_cache_file), "Database cache file not found" + + with open(cache.database_cache_file, 'r') as f: + data = json.load(f) + + assert data, "Database cache file is empty" diff --git a/tests/unit/test_general_helper.py b/tests/unit/themerr/test_general.py similarity index 64% rename from tests/unit/test_general_helper.py rename to tests/unit/themerr/test_general.py index 48932f6f..b691be7e 100644 --- a/tests/unit/test_general_helper.py +++ b/tests/unit/themerr/test_general.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - # standard imports import os import shutil @@ -8,8 +6,8 @@ import pytest # local imports -from Code import constants -from Code import general_helper +from themerr import constants +from themerr import general def test_get_metadata_path(section): @@ -18,32 +16,18 @@ def test_get_metadata_path(section): ] for item in test_items: - metadata_path = general_helper._get_metadata_path(item=item) + metadata_path = general._get_metadata_path(item=item) assert metadata_path.endswith('.bundle') assert os.path.isdir(metadata_path) -@pytest.mark.parametrize('item_agent, item_type, expected', [ - ('com.plexapp.agents.imdb', 'movie', True), - ('com.plexapp.agents.themoviedb', 'movie', True), - ('com.plexapp.agents.themoviedb', 'show', True), - ('com.plexapp.agents.thetvdb', 'show', True), -]) -def test_agent_enabled(item_agent, item_type, expected): - assert general_helper.agent_enabled(item_agent=item_agent, item_type=item_type) is expected - - @pytest.mark.parametrize('item_agent, item_type, expected', [ ('tv.plex.agents.movie', 'movie', True), - ('com.plexapp.agents.imdb', 'movie', True), - ('com.plexapp.agents.themoviedb', 'movie', True), ('tv.plex.agents.series', 'show', True), - ('com.plexapp.agents.themoviedb', 'show', True), - ('com.plexapp.agents.thetvdb', 'show', True), ('invalid', 'invalid', False), ]) def test_continue_update(item_agent, item_type, expected): - assert general_helper.continue_update(item_agent=item_agent, item_type=item_type) is expected + assert general.continue_update(item_agent=item_agent) is expected @pytest.mark.parametrize('media_type', ['art', 'posters', 'themes']) @@ -53,7 +37,7 @@ def test_get_media_upload_path(section, media_type): ] for item in test_items: - media_upload_path = general_helper.get_media_upload_path(item=item, media_type=media_type) + media_upload_path = general.get_media_upload_path(item=item, media_type=media_type) assert media_upload_path.endswith(os.path.join('.bundle', 'Uploads', media_type)) # todo - test collections, with art and posters if media_type == 'themes': @@ -66,7 +50,7 @@ def test_get_theme_provider(section): ] for item in test_items: - theme_provider = general_helper.get_theme_provider(item=item) + theme_provider = general.get_theme_provider(item=item) assert theme_provider assert isinstance(theme_provider, str) assert theme_provider == 'themerr' @@ -78,7 +62,7 @@ def test_get_media_upload_path_invalid(section): ] with pytest.raises(ValueError): - general_helper.get_media_upload_path(item=test_items[0], media_type='invalid') + general.get_media_upload_path(item=test_items[0], media_type='invalid') def test_get_themerr_json_path(section): @@ -87,7 +71,7 @@ def test_get_themerr_json_path(section): ] for item in test_items: - themerr_json_path = general_helper.get_themerr_json_path(item=item) + themerr_json_path = general.get_themerr_json_path(item=item) assert themerr_json_path.endswith('{}.json'.format(item.ratingKey)) assert os.path.join('Plex Media Server', 'Plug-in Support', 'Data', constants.plugin_identifier, 'DataItems') in themerr_json_path @@ -99,13 +83,13 @@ def test_get_themerr_json_data(section): ] for item in test_items: - themerr_json_data = general_helper.get_themerr_json_data(item=item) + themerr_json_data = general.get_themerr_json_data(item=item) assert isinstance(themerr_json_data, dict) assert 'youtube_theme_url' in themerr_json_data.keys() def test_get_themerr_settings_hash(): - themerr_settings_hash = general_helper.get_themerr_settings_hash() + themerr_settings_hash = general.get_themerr_settings_hash() assert themerr_settings_hash assert isinstance(themerr_settings_hash, str) @@ -121,12 +105,12 @@ def test_remove_uploaded_media(section): for item in test_items: for media_type in ['themes']: # todo - test art and posters # backup current directory - current_directory = general_helper.get_media_upload_path(item=item, media_type=media_type) + current_directory = general.get_media_upload_path(item=item, media_type=media_type) assert os.path.isdir(current_directory) shutil.copytree(current_directory, '{}.bak'.format(current_directory)) assert os.path.isdir('{}.bak'.format(current_directory)) - general_helper.remove_uploaded_media(item=item, media_type=media_type) + general.remove_uploaded_media(item=item, media_type=media_type) assert not os.path.isdir(current_directory) # restore backup @@ -136,7 +120,7 @@ def test_remove_uploaded_media(section): def test_remove_uploaded_media_error_handler(): # just try to execute the error handler function - general_helper.remove_uploaded_media_error_handler( + general.remove_uploaded_media_error_handler( func=test_remove_uploaded_media_error_handler, path=os.getcwd(), exc_info=OSError @@ -153,9 +137,9 @@ def test_update_themerr_data_file(section): } for item in test_items: - general_helper.update_themerr_data_file(item=item, new_themerr_data=new_themerr_data) - themerr_json_data = general_helper.get_themerr_json_data(item=item) + general.update_themerr_data_file(item=item, new_themerr_data=new_themerr_data) + themerr_json_data = general.get_themerr_json_data(item=item) assert themerr_json_data['pytest'] == 'test' - for key in general_helper.legacy_keys: + for key in general.legacy_keys: assert key not in themerr_json_data diff --git a/tests/unit/test_scheduled_tasks.py b/tests/unit/themerr/test_scheduled_tasks.py similarity index 91% rename from tests/unit/test_scheduled_tasks.py rename to tests/unit/themerr/test_scheduled_tasks.py index fcbd162f..9db2568e 100644 --- a/tests/unit/test_scheduled_tasks.py +++ b/tests/unit/themerr/test_scheduled_tasks.py @@ -1,10 +1,8 @@ -# -*- coding: utf-8 -*- - # standard imports import time # local imports -from Code import scheduled_tasks +from themerr import scheduled_tasks def test_run_threaded(): diff --git a/tests/unit/themerr/test_themerr_db.py b/tests/unit/themerr/test_themerr_db.py new file mode 100644 index 00000000..8ccca904 --- /dev/null +++ b/tests/unit/themerr/test_themerr_db.py @@ -0,0 +1,30 @@ +# local imports +from plex import plexapi +from themerr import themerr_db + + +def test_update_cache(empty_themerr_db_cache): + themerr_db.update_cache() + assert themerr_db.last_cache_update > 0, 'Cache update did not complete' + + assert "movies" in themerr_db.database_cache, 'Cache does not contain movies' + assert "movie_collections" in themerr_db.database_cache, 'Cache does not contain movie_collections' + assert "tv_shows" in themerr_db.database_cache, 'Cache does not contain tv_shows' + + +def test_item_exists(empty_themerr_db_cache, section): + for item in section.all(): + database_info = plexapi.get_database_info(item=item) + + database_type = database_info[0] + database = database_info[1] + database_id = database_info[3] + + assert themerr_db.item_exists(database_type=database_type, database=database, id=database_id), \ + '{} {} {} does not exist in ThemerrDB'.format(database, database_type, database_id) + + +def test_item_exists_with_invalid_database(): + # movie is not valid... the correct type is movies + assert not themerr_db.item_exists(database_type='movie', database='invalid', id='invalid'), \ + 'Invalid database should not exist in ThemerrDB' diff --git a/tests/unit/test_tmdb_helper.py b/tests/unit/themerr/test_tmdb.py similarity index 68% rename from tests/unit/test_tmdb_helper.py rename to tests/unit/themerr/test_tmdb.py index 2d50daad..7e2903e3 100644 --- a/tests/unit/test_tmdb_helper.py +++ b/tests/unit/themerr/test_tmdb.py @@ -1,11 +1,8 @@ -# -*- coding: utf-8 -*- - # lib imports -import plexhints import pytest # local imports -from Code import tmdb_helper +from themerr import tmdb @pytest.mark.parametrize('tmdb_test_id, database, item_type', [ @@ -13,10 +10,7 @@ ('268592', 'tvdb', 'tv'), ]) def test_get_tmdb_id_from_external_id(tmdb_test_id, database, item_type): - print(plexhints.CONTENTS) - print(plexhints.ELEVATED_POLICY) - - tmdb_id = tmdb_helper.get_tmdb_id_from_external_id(external_id=tmdb_test_id, database=database, item_type=item_type) + tmdb_id = tmdb.get_tmdb_id_from_external_id(external_id=tmdb_test_id, database=database, item_type=item_type) assert tmdb_id, "No tmdb_id found for {}".format(tmdb_test_id) assert isinstance(tmdb_id, int), "tmdb_id is not an int: {}".format(tmdb_id) @@ -27,7 +21,7 @@ def test_get_tmdb_id_from_external_id(tmdb_test_id, database, item_type): ('invalid', 'imdb', 'game'), ]) def test_get_tmdb_id_from_external_id_invalid(tmdb_test_id, database, item_type): - test = tmdb_helper.get_tmdb_id_from_external_id(external_id=tmdb_test_id, database=database, item_type=item_type) + test = tmdb.get_tmdb_id_from_external_id(external_id=tmdb_test_id, database=database, item_type=item_type) assert test is None, "tmdb_id found for invalid imdb_id: {}".format(test) @@ -36,11 +30,11 @@ def test_get_tmdb_id_from_external_id_invalid(tmdb_test_id, database, item_type) 'James Bond Collection', ]) def test_get_tmdb_id_from_collection(tmdb_test_collection): - tmdb_id = tmdb_helper.get_tmdb_id_from_collection(search_query=tmdb_test_collection) + tmdb_id = tmdb.get_tmdb_id_from_collection(search_query=tmdb_test_collection) assert tmdb_id, "No tmdb_id found for {}".format(tmdb_test_collection) assert isinstance(tmdb_id, int), "tmdb_id is not an int: {}".format(tmdb_id) def test_get_tmdb_id_from_collection_invalid(): - test = tmdb_helper.get_tmdb_id_from_collection(search_query='Not a real collection') + test = tmdb.get_tmdb_id_from_collection(search_query='Not a real collection') assert test is None, "tmdb_id found for invalid collection: {}".format(test) diff --git a/tests/unit/youtube/__init__.py b/tests/unit/youtube/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/test_youtube_dl_helper.py b/tests/unit/youtube/test_youtube_dl.py similarity index 76% rename from tests/unit/test_youtube_dl_helper.py rename to tests/unit/youtube/test_youtube_dl.py index 4a522d92..a2054082 100644 --- a/tests/unit/test_youtube_dl_helper.py +++ b/tests/unit/youtube/test_youtube_dl.py @@ -1,10 +1,8 @@ -# -*- coding: utf-8 -*- - # lib imports import pytest # local imports -from Code import youtube_dl_helper +from youtube import youtube_dl @pytest.mark.parametrize('url', [ @@ -13,7 +11,7 @@ ]) def test_process_youtube(url): # test valid urls - audio_url = youtube_dl_helper.process_youtube(url=url) + audio_url = youtube_dl.process_youtube(url=url) assert audio_url is not None assert audio_url.startswith('https://') @@ -24,5 +22,5 @@ def test_process_youtube(url): ]) def test_process_youtube_invalid(url): # test invalid urls - audio_url = youtube_dl_helper.process_youtube(url=url) + audio_url = youtube_dl.process_youtube(url=url) assert audio_url is None diff --git a/third-party/youtube-dl b/third-party/youtube-dl deleted file mode 160000 index c5098961..00000000 --- a/third-party/youtube-dl +++ /dev/null @@ -1 +0,0 @@ -Subproject commit c5098961b04ce83f4615f2a846c84f803b072639 diff --git a/Contents/Resources/web/css/custom.css b/web/css/custom.css similarity index 59% rename from Contents/Resources/web/css/custom.css rename to web/css/custom.css index f77ab5ce..48b536c5 100644 --- a/Contents/Resources/web/css/custom.css +++ b/web/css/custom.css @@ -15,7 +15,7 @@ body { /*we need to put content higher so links aren't blocked by the transparent border.*/ .container { position: relative; - /*z-index: 1;*/ + z-index: 1; } .navbar { @@ -113,15 +113,6 @@ body { } } -.feature { - display: inline-flex; - align-items: center; - justify-content: center; - height: 3rem; - width: 3rem; - font-size: 1.5rem; -} - .widgetbot { display: flex; align-items: center; @@ -164,6 +155,127 @@ iframe.feedback { transform: scale(1.1); } +/*settings page*/ + +.advanced-setting { + /*display: none;*/ +} + +div.advanced-setting { + border-left: 1px solid #cc7b19; +} + +li.advanced-setting { + border-left: 1px solid #cc7b19; +} + +.beta-setting { + /*display: none;*/ +} + +div.beta-setting { + border-left: 1px solid #b30016; +} + +li.beta-setting { + border-left: 1px solid #b30016; +} + +.form-group, +.checkbox { + padding-left: 10px; + margin-left: -10px; + margin-bottom: 15px; +} + +.settings-alert { + /*float: left;*/ + padding: 0; + margin: 5px 0; + border: 0; + position: relative; +} + +.settings-alert ul { + padding: 0; +} + +.settings-alert ul li { + list-style: none; + padding: 5px 12px; + margin: 0; + border: 1px solid #ebccd1; + border-radius: 4px; +} + +.settings-alert ul li:before { + content: "\f071"; + font-family: "Font Awesome 6 Free"; + font-weight: 900; + margin-right: 5px; +} + +.form-control, +.form-select, +.form-select option { + border-radius: 4px; + background-color: #151515; + border-width: 0; + color: white; +} + +.alert_placeholder { + /* borrowed from widgetbot */ + display: flex; + flex-direction: column-reverse; + position: fixed; + z-index: 3147482999; /* display above widgetbot */ + padding: 7px 0px 20px; + width: 350px; + max-height: calc(70% - 100px); + right: 20px; + bottom: 76px; +} + +.form-control:disabled { + background-color: dimgray; +} + +.form-control:focus { + background-color: #151515; + color: white; +} + +.form-check-input:not(:checked):disabled { + background-color: dimgray; +} + +.form-check-input:not(:checked):enabled { + background-color: #151515; +} + +/* Style the background video */ +#backgroundVideo { + position: fixed; + right: 0; + bottom: 0; + min-width: 100%; + min-height: 100%; + z-index: -1000; +} + +/* transparent background video overlay */ +.backgroundVideoOverlay { + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + background-color: black; + opacity: 0.8; + z-index: -999; +} + .progress-bar:first-child { overflow: visible; z-index: 999; diff --git a/web/css/sidebar.css b/web/css/sidebar.css new file mode 100644 index 00000000..cf456418 --- /dev/null +++ b/web/css/sidebar.css @@ -0,0 +1,106 @@ +/*https://codepen.io/lfrichter/pen/mQJJyB*/ +#wrapper { + -webkit-transition: all 0.5s ease; + -moz-transition: all 0.5s ease; + -o-transition: all 0.5s ease; + transition: all 0.5s ease; +} + +#wrapper.sidebar-expanded { + padding-left: 250px; +} + +#wrapper.sidebar-collapsed { + padding-left: 80px; +} + +#sidebar-wrapper { + top: 80px; + position: fixed; + height: 100vh; + background-color: #151515; + /*padding: 0;*/ + overflow-y: scroll; + overflow-x: hidden; + -webkit-transition: all 0.5s ease; + -moz-transition: all 0.5s ease; + -o-transition: all 0.5s ease; + transition: all 0.5s ease; +} + +#sidebar-wrapper.sidebar-expanded { + width: 250px; + left: 250px; + margin-left: -250px; +} + +#sidebar-wrapper.sidebar-collapsed { + width: 80px; + left: 80px; + margin-left: -80px; +} + +#page-content-wrapper { + position: absolute; + padding: 10px; +} + +#page-content-wrapper.sidebar-expanded { + /*calc method - https://stackoverflow.com/a/14101451/11214013*/ + width: -moz-calc(100% - 250px); /* Firefox */ + width: -webkit-calc(100% - 250px); /* WebKit */ + width: -o-calc(100% - 250px); /* Opera */ + width: calc(100% - 250px); /* Standard */ +} + +#page-content-wrapper.sidebar-collapsed { + /*calc method - https://stackoverflow.com/a/14101451/11214013*/ + width: -moz-calc(100% - 80px); /* Firefox */ + width: -webkit-calc(100% - 80px); /* WebKit */ + width: -o-calc(100% - 80px); /* Opera */ + width: calc(100% - 80px); /* Standard */ +} + +/* ----------| Menu item*/ +#sidebar-wrapper .list-group a { + height: 50px; + color: white; +} + +/* ----------| Submenu item*/ +#sidebar-wrapper .list-group li.list-group-item { + background-color: #151515; +} + +#sidebar-wrapper .list-group .sidebar-submenu a { + height: 45px; + padding-left: 30px; +} + +.sidebar-submenu { + font-size: 0.9rem; +} + +/* ----------| Separators */ +.sidebar-separator-title { + background-color: #151515; + height: 35px; +} + +.sidebar-separator { + background-color: #151515; + height: 25px; +} + +.logo-separator { + background-color: #151515; + height: 60px; +} + +a.bg-dark { + background-color: #151515 !important; +} + +a.bg-dark:not(.no-hover):hover { + background-color: #303436 !important; +} diff --git a/Contents/Resources/attribution.png b/web/images/attribution.png similarity index 100% rename from Contents/Resources/attribution.png rename to web/images/attribution.png diff --git a/Contents/Resources/web/images/favicon.ico b/web/images/favicon.ico similarity index 100% rename from Contents/Resources/web/images/favicon.ico rename to web/images/favicon.ico diff --git a/Contents/Resources/icon-default.png b/web/images/icon-default.png similarity index 100% rename from Contents/Resources/icon-default.png rename to web/images/icon-default.png diff --git a/Contents/Resources/web/images/icon.png b/web/images/icon.png similarity index 100% rename from Contents/Resources/web/images/icon.png rename to web/images/icon.png diff --git a/web/js/show_alert.js b/web/js/show_alert.js new file mode 100644 index 00000000..8d01aec9 --- /dev/null +++ b/web/js/show_alert.js @@ -0,0 +1,42 @@ +// create alert placeholder container +let alert_placeholder = document.createElement('div'); +alert_placeholder.id = 'alert_placeholder'; +alert_placeholder.className = 'container alert_placeholder'; +document.body.appendChild(alert_placeholder); + +function showAlert(message, alert_type = 'alert-info', icon_class = null, timeout = null) { + // get current timestamp + let now = Date.now() + + // create the alert div + let alert_div = document.createElement('div'); + alert_div.id = `alert_div_${now}`; + alert_div.className = `alert ${alert_type} alert-dismissible fade show`; + alert_div.setAttribute('role', 'alert'); + alert_div.textContent = message; + + // create the icon and prepend it to the message + if (icon_class !== null) { + let icon = document.createElement('i'); + icon.className = icon_class; + alert_div.prepend(icon); + } + + // create the alert close button + let alert_close = document.createElement('button'); + alert_close.type = 'button'; + alert_close.className = 'btn-close'; + alert_close.setAttribute('data-bs-dismiss', 'alert'); + alert_close.setAttribute('aria-label', 'Close'); + + // append the elements to the placeholder + alert_div.appendChild(alert_close); + alert_placeholder.appendChild(alert_div); + + // close alert after timeout + if (timeout !== null) { + setTimeout(function () { + $(`#alert_div_${now}`).remove(); + }, timeout); + } +} diff --git a/web/js/sidebar.js b/web/js/sidebar.js new file mode 100644 index 00000000..f1c31ee9 --- /dev/null +++ b/web/js/sidebar.js @@ -0,0 +1,22 @@ +//https://codepen.io/lfrichter/pen/mQJJyB + +// Collapse/Expand icon +button = $('#collapse-icon') +button.addClass('fa-xmark'); + +// Collapse click +$('[data-toggle=sidebar-collapse]').click(function() { + SidebarCollapse(); +}); + +function SidebarCollapse () { + $('.sidebar-separator-title').toggleClass('invisible'); + $('.menu-collapsed').toggleClass('d-none'); + $('#wrapper').toggleClass('sidebar-expanded sidebar-collapsed'); + $('#sidebar-wrapper').toggleClass('sidebar-expanded sidebar-collapsed'); + $('#page-content-wrapper').toggleClass('sidebar-expanded sidebar-collapsed'); + $('.sidebar-item').toggleClass('justify-content-start justify-content-center') + + // Collapse/Expand icon + button.toggleClass('fa-xmark fa-bars'); +} diff --git a/Contents/Resources/web/js/translations.js b/web/js/translations.js similarity index 100% rename from Contents/Resources/web/js/translations.js rename to web/js/translations.js diff --git a/Contents/Resources/web/templates/base.html b/web/templates/base.html similarity index 67% rename from Contents/Resources/web/templates/base.html rename to web/templates/base.html index 7a0a6ad2..35b2ce2c 100644 --- a/Contents/Resources/web/templates/base.html +++ b/web/templates/base.html @@ -1,5 +1,5 @@ - + {% if title %} Themerr-plex - {{ title }} @@ -20,6 +20,7 @@ + @@ -27,9 +28,7 @@ - - - + {% block head %}{% endblock head %} @@ -38,9 +37,25 @@ {% include 'navbar.html' %} + {% if ui_config['BACKGROUND_VIDEO'] %} +
+ + +
+
+ {% endif %} + {% block content %}{% endblock %} + + + + + + {% block scripts %}{% endblock %} diff --git a/web/templates/config.html b/web/templates/config.html new file mode 100644 index 00000000..f191fd0d --- /dev/null +++ b/web/templates/config.html @@ -0,0 +1,237 @@ +{% extends 'base.html' %} +{% block head %} + + +{% endblock head %} + +{% block modals %} +{% endblock modals %} + +{% block content %} + +{% endblock content %} + +{% block scripts %} + +{% endblock scripts %} diff --git a/Contents/Resources/web/templates/home.html b/web/templates/home.html similarity index 99% rename from Contents/Resources/web/templates/home.html rename to web/templates/home.html index 9ee3b49a..4ab5ef76 100644 --- a/Contents/Resources/web/templates/home.html +++ b/web/templates/home.html @@ -1,4 +1,6 @@ {% extends 'base.html' %} +{% block head %} +{% endblock head %} {% block modals %} {% endblock modals %} diff --git a/Contents/Resources/web/templates/home_db_not_cached.html b/web/templates/home_db_not_cached.html similarity index 91% rename from Contents/Resources/web/templates/home_db_not_cached.html rename to web/templates/home_db_not_cached.html index e6ae2431..1a5e07b1 100644 --- a/Contents/Resources/web/templates/home_db_not_cached.html +++ b/web/templates/home_db_not_cached.html @@ -1,4 +1,6 @@ {% extends 'base.html' %} +{% block head %} +{% endblock head %} {% block modals %} {% endblock modals %} diff --git a/Contents/Resources/web/templates/navbar.html b/web/templates/navbar.html similarity index 54% rename from Contents/Resources/web/templates/navbar.html rename to web/templates/navbar.html index d29ef6a9..4e08d522 100644 --- a/Contents/Resources/web/templates/navbar.html +++ b/web/templates/navbar.html @@ -9,14 +9,17 @@ diff --git a/Contents/Resources/web/templates/translations.html b/web/templates/translations.html similarity index 100% rename from Contents/Resources/web/templates/translations.html rename to web/templates/translations.html diff --git a/web/videos/Retro Delorean.license b/web/videos/Retro Delorean.license new file mode 100644 index 00000000..e0b8fa86 --- /dev/null +++ b/web/videos/Retro Delorean.license @@ -0,0 +1,3 @@ +Retro Delorean video is under CC license, https://support.google.com/youtube/answer/2797468 + +https://www.youtube.com/watch?v=97DgTj0NKK0 diff --git a/web/videos/Retro Delorean.mp4 b/web/videos/Retro Delorean.mp4 new file mode 100644 index 00000000..4936bf88 Binary files /dev/null and b/web/videos/Retro Delorean.mp4 differ