From f58e109024b5a188371185ac30e59fa31995e6ea Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 9 Aug 2024 15:40:33 -0400 Subject: [PATCH] refactor!: move project to standalone version --- .github/workflows/CI.yml | 210 ++--- .github/workflows/localize.yml | 28 +- .gitignore | 28 +- .gitmodules | 4 - Contents/Strings/de.json | 28 - Contents/Strings/en-us.json | 28 - Contents/Strings/en.json | 28 - Contents/Strings/es.json | 28 - Contents/Strings/fr.json | 28 - Contents/Strings/it.json | 28 - Contents/Strings/ja.json | 28 - Contents/Strings/pt.json | 28 - Contents/Strings/ru.json | 28 - Contents/Strings/sv.json | 28 - Contents/Strings/tr.json | 28 - Contents/Strings/zh.json | 28 - DOCKER_README.md | 10 +- docs/source/about/docker.rst | 8 +- docs/source/code_docs/general_helper.rst | 9 - .../source/code_docs/lizardbyte_db_helper.rst | 9 - docs/source/code_docs/main.rst | 9 - docs/source/code_docs/migration_helper.rst | 12 - docs/source/code_docs/plex_api_helper.rst | 9 - docs/source/code_docs/scheduled_tasks.rst | 9 - docs/source/code_docs/themerr_db_helper.rst | 9 - docs/source/code_docs/tmdb_helper.rst | 9 - docs/source/code_docs/webapp.rst | 9 - docs/source/code_docs/youtube_dl_helper.rst | 9 - docs/source/conf.py | 53 +- docs/source/src/common/common.rst | 7 + docs/source/src/common/config.rst | 7 + docs/source/src/common/definitions.rst | 7 + docs/source/src/common/helpers.rst | 7 + docs/source/src/common/locales.rst | 7 + docs/source/src/common/logger.rst | 7 + docs/source/src/common/threads.rst | 7 + docs/source/src/common/tray_icon.rst | 7 + docs/source/src/common/webapp.rst | 7 + docs/source/{ => src}/global.rst | 0 docs/source/src/themerr_plex.rst | 7 + docs/source/toc.rst | 22 +- .../aa/LC_MESSAGES/themerr-plex.po | 0 .../de/LC_MESSAGES/themerr-plex.po | 0 .../en/LC_MESSAGES/themerr-plex.po | 0 .../en_GB/LC_MESSAGES/themerr-plex.po | 0 .../en_US/LC_MESSAGES/themerr-plex.po | 0 .../es/LC_MESSAGES/themerr-plex.po | 0 .../fr/LC_MESSAGES/themerr-plex.po | 0 .../it/LC_MESSAGES/themerr-plex.po | 0 .../ja/LC_MESSAGES/themerr-plex.po | 0 .../pt/LC_MESSAGES/themerr-plex.po | 0 .../ru/LC_MESSAGES/themerr-plex.po | 0 .../sv/LC_MESSAGES/themerr-plex.po | 0 {Contents/Strings => locale}/themerr-plex.po | 0 .../tr/LC_MESSAGES/themerr-plex.po | 0 .../zh/LC_MESSAGES/themerr-plex.po | 0 requirements-build.txt | 1 - requirements-dev.txt | 17 +- requirements.txt | 36 +- scripts/_locale.py | 23 +- scripts/build_plist.py | 109 --- {Contents => src}/Code/__init__.py | 0 {Contents => src}/Code/constants.py | 0 {Contents => src}/Code/default_prefs.py | 0 {Contents => src}/Code/general_helper.py | 0 .../Code/lizardbyte_db_helper.py | 0 {Contents => src}/Code/migration_helper.py | 0 {Contents => src}/Code/plex_api_helper.py | 0 {Contents => src}/Code/scheduled_tasks.py | 0 {Contents => src}/Code/themerr_db_helper.py | 0 {Contents => src}/Code/tmdb_helper.py | 0 {Contents => src}/Code/webapp.py | 0 {Contents => src}/Code/youtube_dl_helper.py | 0 {Contents => src}/DefaultPrefs.json | 0 src/__init__.py | 0 src/common/__init__.py | 145 ++++ src/common/config.py | 451 +++++++++++ src/common/definitions.py | 161 ++++ src/common/helpers.py | 269 +++++++ src/common/locales.py | 153 ++++ src/common/logger.py | 732 ++++++++++++++++++ src/common/threads.py | 33 + src/common/tray_icon.py | 413 ++++++++++ src/common/webapp.py | 371 +++++++++ .../original_prefs_strings.json | 0 src/themerr_plex.py | 236 ++++++ third-party/youtube-dl | 1 - .../Resources/web => web}/css/custom.css | 0 .../Resources => web/images}/attribution.png | Bin .../Resources => web/images}/icon-default.png | Bin .../Resources/web => web}/images/icon.png | Bin .../images/themerr-plex.ico | Bin .../Resources/web => web}/js/translations.js | 0 .../Resources/web => web}/templates/base.html | 0 .../Resources/web => web}/templates/home.html | 0 .../templates/home_db_not_cached.html | 0 .../web => web}/templates/navbar.html | 0 .../web => web}/templates/translations.html | 0 98 files changed, 3177 insertions(+), 836 deletions(-) delete mode 100644 .gitmodules delete mode 100644 Contents/Strings/de.json delete mode 100644 Contents/Strings/en-us.json delete mode 100644 Contents/Strings/en.json delete mode 100644 Contents/Strings/es.json delete mode 100644 Contents/Strings/fr.json delete mode 100644 Contents/Strings/it.json delete mode 100644 Contents/Strings/ja.json delete mode 100644 Contents/Strings/pt.json delete mode 100644 Contents/Strings/ru.json delete mode 100644 Contents/Strings/sv.json delete mode 100644 Contents/Strings/tr.json delete mode 100644 Contents/Strings/zh.json delete mode 100644 docs/source/code_docs/general_helper.rst delete mode 100644 docs/source/code_docs/lizardbyte_db_helper.rst delete mode 100644 docs/source/code_docs/main.rst delete mode 100644 docs/source/code_docs/migration_helper.rst delete mode 100644 docs/source/code_docs/plex_api_helper.rst delete mode 100644 docs/source/code_docs/scheduled_tasks.rst delete mode 100644 docs/source/code_docs/themerr_db_helper.rst delete mode 100644 docs/source/code_docs/tmdb_helper.rst delete mode 100644 docs/source/code_docs/webapp.rst delete mode 100644 docs/source/code_docs/youtube_dl_helper.rst create mode 100644 docs/source/src/common/common.rst create mode 100644 docs/source/src/common/config.rst create mode 100644 docs/source/src/common/definitions.rst create mode 100644 docs/source/src/common/helpers.rst create mode 100644 docs/source/src/common/locales.rst create mode 100644 docs/source/src/common/logger.rst create mode 100644 docs/source/src/common/threads.rst create mode 100644 docs/source/src/common/tray_icon.rst create mode 100644 docs/source/src/common/webapp.rst rename docs/source/{ => src}/global.rst (100%) create mode 100644 docs/source/src/themerr_plex.rst rename {Contents/Strings => locale}/aa/LC_MESSAGES/themerr-plex.po (100%) rename {Contents/Strings => locale}/de/LC_MESSAGES/themerr-plex.po (100%) rename {Contents/Strings => locale}/en/LC_MESSAGES/themerr-plex.po (100%) rename {Contents/Strings => locale}/en_GB/LC_MESSAGES/themerr-plex.po (100%) rename {Contents/Strings => locale}/en_US/LC_MESSAGES/themerr-plex.po (100%) rename {Contents/Strings => locale}/es/LC_MESSAGES/themerr-plex.po (100%) rename {Contents/Strings => locale}/fr/LC_MESSAGES/themerr-plex.po (100%) rename {Contents/Strings => locale}/it/LC_MESSAGES/themerr-plex.po (100%) rename {Contents/Strings => locale}/ja/LC_MESSAGES/themerr-plex.po (100%) rename {Contents/Strings => locale}/pt/LC_MESSAGES/themerr-plex.po (100%) rename {Contents/Strings => locale}/ru/LC_MESSAGES/themerr-plex.po (100%) rename {Contents/Strings => locale}/sv/LC_MESSAGES/themerr-plex.po (100%) rename {Contents/Strings => locale}/themerr-plex.po (100%) rename {Contents/Strings => locale}/tr/LC_MESSAGES/themerr-plex.po (100%) rename {Contents/Strings => locale}/zh/LC_MESSAGES/themerr-plex.po (100%) delete mode 100644 requirements-build.txt delete mode 100644 scripts/build_plist.py rename {Contents => src}/Code/__init__.py (100%) rename {Contents => src}/Code/constants.py (100%) rename {Contents => src}/Code/default_prefs.py (100%) rename {Contents => src}/Code/general_helper.py (100%) rename {Contents => src}/Code/lizardbyte_db_helper.py (100%) rename {Contents => src}/Code/migration_helper.py (100%) rename {Contents => src}/Code/plex_api_helper.py (100%) rename {Contents => src}/Code/scheduled_tasks.py (100%) rename {Contents => src}/Code/themerr_db_helper.py (100%) rename {Contents => src}/Code/tmdb_helper.py (100%) rename {Contents => src}/Code/webapp.py (100%) rename {Contents => src}/Code/youtube_dl_helper.py (100%) rename {Contents => src}/DefaultPrefs.json (100%) create mode 100644 src/__init__.py create mode 100644 src/common/__init__.py create mode 100644 src/common/config.py create mode 100644 src/common/definitions.py create mode 100644 src/common/helpers.py create mode 100644 src/common/locales.py create mode 100644 src/common/logger.py create mode 100644 src/common/threads.py create mode 100644 src/common/tray_icon.py create mode 100644 src/common/webapp.py rename Contents/Strings/en-gb.json => src/original_prefs_strings.json (100%) create mode 100644 src/themerr_plex.py delete mode 160000 third-party/youtube-dl rename {Contents/Resources/web => web}/css/custom.css (100%) rename {Contents/Resources => web/images}/attribution.png (100%) rename {Contents/Resources => web/images}/icon-default.png (100%) rename {Contents/Resources/web => web}/images/icon.png (100%) rename Contents/Resources/web/images/favicon.ico => web/images/themerr-plex.ico (100%) rename {Contents/Resources/web => web}/js/translations.js (100%) rename {Contents/Resources/web => web}/templates/base.html (100%) rename {Contents/Resources/web => web}/templates/home.html (100%) rename {Contents/Resources/web => web}/templates/home_db_not_cached.html (100%) rename {Contents/Resources/web => web}/templates/navbar.html (100%) rename {Contents/Resources/web => web}/templates/translations.html (100%) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 643bf2c9..a7bb77de 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,177 +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' - - - name: Patch third-party deps - if: false # disabled - shell: bash - working-directory: Themerr-plex.bundle/third-party - run: | - patch_dir=${{ github.workspace }}/Themerr-plex.bundle/patches + python-version: '3.12' + architecture: ${{ matrix.architecture }} - # 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 + - name: Setup Python Dependencies 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.614.221009 - with: - allowUpdates: true - body: ${{ needs.setup_release.outputs.release_body }} - discussionCategory: announcements - 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: | @@ -216,30 +108,26 @@ 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.614.221009 + with: + allowUpdates: true + body: ${{ needs.setup_release.outputs.release_body }} + discussionCategory: announcements + 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 1eb8ac05..52367149 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@v6 with: add-paths: | - Contents/Strings/*.po + locale/*.po token: ${{ secrets.GH_BOT_TOKEN }} # must trigger PR tests commit-message: New localization template branch: localize/update diff --git a/.gitignore b/.gitignore index 0eaec38e..07e92ee3 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 @@ -153,22 +145,16 @@ dmypy.json cython_debug/ # PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# JetBrains specific template is maintainted in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # 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 +logs/ +*config.ini 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/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-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..075266d5 100644 --- a/DOCKER_README.md +++ b/DOCKER_README.md @@ -1,4 +1,8 @@ -### lizardbyte/themerr-plex +# Docker + +## lizardbyte/themerr-plex + +### TODO: Update this file 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) @@ -6,7 +10,7 @@ to plex as a plugin, to be downloaded/updated during container start. This image extends the plex image, and is not intended to be created as a separate container. -### Installation +## Installation In plex docker arguments, set an environment variable `DOCKER_MODS=lizardbyte/themerr-plex:latest` or `DOCKER_MODS=ghcr.io/lizardbyte/themerr-plex:latest` @@ -14,7 +18,7 @@ In plex docker arguments, set an environment variable `DOCKER_MODS=lizardbyte/th If adding multiple mods, enter them in an array separated by `|`, such as `DOCKER_MODS=lizardbyte/themerr-plex:latest|linuxserver/mods:other-plex-mod` -### Supported Architectures +## Supported Architectures Specifying `lizardbyte/themerr-plex:latest` or `ghcr.io/lizardbyte/themerr-plex:latest` should retrieve the correct image for your architecture. 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..3e76e5d6 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', 'themerr-plex.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/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..4c463583 100644 --- a/docs/source/toc.rst +++ b/docs/source/toc.rst @@ -20,16 +20,16 @@ .. 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/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/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..2ca08834 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,20 +1,16 @@ -# 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 +flask==3.0.3 +flask-babel==4.0.0 +furo==2024.8.6 +future==1.0.0 +myst-parser==4.0.0 +pillow==9.5.0 +plexapi[alert]==4.15.15 +polib==1.2.0 +pystray==0.19.5 +requests==2.32.3 +schedule==1.2.2 +six==1.16.0 # TODO: probably won't need this +werkzeug==3.0.3 # TODO: isn't this included with flask? +yt-dlp==2024.8.6 diff --git a/scripts/_locale.py b/scripts/_locale.py index 4e5c821c..308e3817 100644 --- a/scripts/_locale.py +++ b/scripts/_locale.py @@ -1,4 +1,3 @@ -# coding=utf-8 """ .. _locale.py @@ -14,7 +13,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 +29,7 @@ 'ru', # Russian 'sv', # Swedish 'tr', # Turkish - 'zh', # Chinese Simplified + 'zh', # Chinese (Simplified) ] @@ -40,22 +39,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 +62,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 +77,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_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/Code/__init__.py b/src/Code/__init__.py similarity index 100% rename from Contents/Code/__init__.py rename to src/Code/__init__.py diff --git a/Contents/Code/constants.py b/src/Code/constants.py similarity index 100% rename from Contents/Code/constants.py rename to src/Code/constants.py diff --git a/Contents/Code/default_prefs.py b/src/Code/default_prefs.py similarity index 100% rename from Contents/Code/default_prefs.py rename to src/Code/default_prefs.py diff --git a/Contents/Code/general_helper.py b/src/Code/general_helper.py similarity index 100% rename from Contents/Code/general_helper.py rename to src/Code/general_helper.py diff --git a/Contents/Code/lizardbyte_db_helper.py b/src/Code/lizardbyte_db_helper.py similarity index 100% rename from Contents/Code/lizardbyte_db_helper.py rename to src/Code/lizardbyte_db_helper.py diff --git a/Contents/Code/migration_helper.py b/src/Code/migration_helper.py similarity index 100% rename from Contents/Code/migration_helper.py rename to src/Code/migration_helper.py diff --git a/Contents/Code/plex_api_helper.py b/src/Code/plex_api_helper.py similarity index 100% rename from Contents/Code/plex_api_helper.py rename to src/Code/plex_api_helper.py diff --git a/Contents/Code/scheduled_tasks.py b/src/Code/scheduled_tasks.py similarity index 100% rename from Contents/Code/scheduled_tasks.py rename to src/Code/scheduled_tasks.py diff --git a/Contents/Code/themerr_db_helper.py b/src/Code/themerr_db_helper.py similarity index 100% rename from Contents/Code/themerr_db_helper.py rename to src/Code/themerr_db_helper.py diff --git a/Contents/Code/tmdb_helper.py b/src/Code/tmdb_helper.py similarity index 100% rename from Contents/Code/tmdb_helper.py rename to src/Code/tmdb_helper.py diff --git a/Contents/Code/webapp.py b/src/Code/webapp.py similarity index 100% rename from Contents/Code/webapp.py rename to src/Code/webapp.py diff --git a/Contents/Code/youtube_dl_helper.py b/src/Code/youtube_dl_helper.py similarity index 100% rename from Contents/Code/youtube_dl_helper.py rename to src/Code/youtube_dl_helper.py 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..4995fb8b --- /dev/null +++ b/src/common/__init__.py @@ -0,0 +1,145 @@ +""" +src/common/__init__.py + +Responsible for initialization of Themerr-plex. +""" +# future imports +from __future__ import annotations + +# 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..b4b8b49d --- /dev/null +++ b/src/common/config.py @@ -0,0 +1,451 @@ +""" +.. + config.py + +Responsible for config related functions. +""" +# standard imports +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 + + +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 + -------- + pyra.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=[ + 'en', + 'es', + ], + option_names=[ + f'English ({_("English")})', + f'Español ({_("Spanish")})', + ], + 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=r'^[a-zA-Z]:\\(?:\w+\\?)*$' if definitions.Platform.os_platform == 'win32' + else r'^\/(?:[^/]+\/)*$ ', + # https://regexpattern.com/windows-folder-path/ + # https://regexpattern.com/linux-folder-path/ + 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', + ), + ), + 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, + ), + ), +) + + +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: + 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 + config.write() # write the config file + + 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/definitions.py b/src/common/definitions.py new file mode 100644 index 00000000..90534b24 --- /dev/null +++ b/src/common/definitions.py @@ -0,0 +1,161 @@ +""" +.. + 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(COMMON_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 + + DOCS_DIR = os.path.join(ROOT_DIR, 'docs', 'build', 'html') + LOCALE_DIR = os.path.join(ROOT_DIR, 'locale') + LOG_DIR = os.path.join(DATA_DIR, 'logs') diff --git a/src/common/helpers.py b/src/common/helpers.py new file mode 100644 index 00000000..080c0962 --- /dev/null +++ b/src/common/helpers.py @@ -0,0 +1,269 @@ +""" +.. + helpers.py + +Many reusable helper functions. +""" +# future imports +from __future__ import annotations + +# standard imports +import datetime +import logging +import os +import requests +import time +from typing import Optional +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 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 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 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..5b288461 --- /dev/null +++ b/src/common/locales.py @@ -0,0 +1,153 @@ +""" +.. + 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..24a3849d --- /dev/null +++ b/src/common/logger.py @@ -0,0 +1,732 @@ +""" +.. + logger.py + +Responsible for logging related functions. +""" +# future imports +from __future__ import annotations + +# 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 +py_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 not 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 create 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 = [py_name, 'werkzeug'] + + submodules = pkgutil.iter_modules(common.__path__) + + for submodule in submodules: + loggers_list.append(f'{py_name}.{submodule[1]}') + + 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 == py_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..2af452c9 --- /dev/null +++ b/src/common/threads.py @@ -0,0 +1,33 @@ +""" +.. + 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..66998930 --- /dev/null +++ b/src/common/tray_icon.py @@ -0,0 +1,413 @@ +""" +.. + 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 + +# 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', 'themerr-plex.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 + """ + url = f"http://127.0.0.1:{config.CONFIG['Network']['HTTP_PORT']}" + return helpers.open_url_in_browser(url=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..db8a6f4d --- /dev/null +++ b/src/common/webapp.py @@ -0,0 +1,371 @@ +""" +.. + webapp.py + +Responsible for serving the webapp. +""" +# standard imports +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 + +# local imports +import common +from common import config +from common.definitions import Paths +from common import locales +from common import logger + +# localization +_ = locales.get_text() + +# 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) + + +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() + """ + return render_template('home.html', title=_('Home')) + + +@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 = 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') +def favicon() -> send_from_directory: + """ + Serve the favicon.ico file. + + .. todo:: This documentation needs to be improved. + + Returns + ------- + flask.send_from_directory + The ico file. + + Notes + ----- + The following routes trigger this function. + + `/favicon.ico` + + Examples + -------- + >>> favicon() + """ + return send_from_directory(directory=os.path.join(app.static_folder, 'images'), + path='themerr-plex.ico', mimetype='image/vnd.microsoft.icon') + + +@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 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 + 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.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 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 http://.../ (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 http://.../ (Press CTRL+C to quit) + """ + app.run( + host=config.CONFIG['Network']['HTTP_HOST'], + port=config.CONFIG['Network']['HTTP_PORT'], + debug=common.DEV, + use_reloader=False # reloader doesn't work when running in a separate thread + ) diff --git a/Contents/Strings/en-gb.json b/src/original_prefs_strings.json similarity index 100% rename from Contents/Strings/en-gb.json rename to src/original_prefs_strings.json diff --git a/src/themerr_plex.py b/src/themerr_plex.py new file mode 100644 index 00000000..dc1c5599 --- /dev/null +++ b/src/themerr_plex.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +""" +Themerr-plex.py + +Responsible for starting Themerr Plex. +""" +# future imports +from __future__ import annotations + +# 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 + +py_name = 'common-plex' + +# locales +_ = locales.get_text() + +# get logger +log = logger.get_logger(name=py_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.DATA_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 + 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: + url = f"http://127.0.0.1:{config.CONFIG['Network']['HTTP_PORT']}" + helpers.open_url_in_browser(url=url) + + 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/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 100% rename from Contents/Resources/web/css/custom.css rename to web/css/custom.css 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/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/Contents/Resources/web/images/favicon.ico b/web/images/themerr-plex.ico similarity index 100% rename from Contents/Resources/web/images/favicon.ico rename to web/images/themerr-plex.ico 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 100% rename from Contents/Resources/web/templates/base.html rename to web/templates/base.html diff --git a/Contents/Resources/web/templates/home.html b/web/templates/home.html similarity index 100% rename from Contents/Resources/web/templates/home.html rename to web/templates/home.html diff --git a/Contents/Resources/web/templates/home_db_not_cached.html b/web/templates/home_db_not_cached.html similarity index 100% rename from Contents/Resources/web/templates/home_db_not_cached.html rename to web/templates/home_db_not_cached.html diff --git a/Contents/Resources/web/templates/navbar.html b/web/templates/navbar.html similarity index 100% rename from Contents/Resources/web/templates/navbar.html rename to web/templates/navbar.html 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