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.
-
-
-
-
-
-
- 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