diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml
index 01cebce1..221c4d17 100644
--- a/.github/workflows/CI.yml
+++ b/.github/workflows/CI.yml
@@ -47,6 +47,7 @@ jobs:
uses: LizardByte/.github/actions/setup_python2@nightly
- name: Set up Python 2.7 Dependencies
+ shell: bash
working-directory: Themerr-plex.bundle
run: |
echo "Installing Requirements"
@@ -66,35 +67,15 @@ jobs:
mv ./node_modules ./Contents/Resources/web
- name: Build plist
+ shell: bash
working-directory: Themerr-plex.bundle
env:
BUILD_VERSION: ${{ needs.check_changelog.outputs.next_version }}
run: |
python ./scripts/build_plist.py
- - name: Upload Artifacts
- if: ${{ github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' }}
- uses: actions/upload-artifact@v3
- with:
- name: Themerr-plex.bundle
- if-no-files-found: error # 'warn' or 'ignore' are also available, defaults to `warn`
- path: |
- ${{ github.workspace }}
- !**/*.git*
- !**/*.pyc
- !**/__pycache__
- !**/plexhints*
- !**/Themerr-plex.bundle/.*
- !**/Themerr-plex.bundle/cache.sqlite
- !**/Themerr-plex.bundle/DOCKER_README.md
- !**/Themerr-plex.bundle/Dockerfile
- !**/Themerr-plex.bundle/docs
- !**/Themerr-plex.bundle/scripts
- !**/Themerr-plex.bundle/tests
-
- name: Package Release
shell: bash
- if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
run: |
7z \
"-xr!*.git*" \
@@ -103,6 +84,7 @@ jobs:
"-xr!plexhints*" \
"-xr!Themerr-plex.bundle/.*" \
"-xr!Themerr-plex.bundle/cache.sqlite" \
+ "-xr!Themerr-plex.bundle/crowdin.yml" \
"-xr!Themerr-plex.bundle/DOCKER_README.md" \
"-xr!Themerr-plex.bundle/Dockerfile" \
"-xr!Themerr-plex.bundle/docs" \
@@ -113,6 +95,14 @@ jobs:
mkdir artifacts
mv ./Themerr-plex.bundle.zip ./artifacts/
+ - name: Upload Artifacts
+ uses: actions/upload-artifact@v3
+ with:
+ name: Themerr-plex.bundle
+ if-no-files-found: error # 'warn' or 'ignore' are also available, defaults to `warn`
+ path: |
+ ${{ github.workspace }}/artifacts
+
- name: Create Release
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
uses: LizardByte/.github/actions/create_release@master
@@ -121,3 +111,135 @@ jobs:
next_version: ${{ needs.check_changelog.outputs.next_version }}
last_version: ${{ needs.check_changelog.outputs.last_version }}
release_body: ${{ needs.check_changelog.outputs.release_body }}
+
+ pytest:
+ needs: [build]
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [windows-latest, ubuntu-latest, macos-latest]
+
+ runs-on: ${{ matrix.os }}
+ env:
+ PLEXAPI_AUTH_SERVER_BASEURL: http://127.0.0.1:32400
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v3
+
+ - name: Download artifacts
+ uses: actions/download-artifact@v3
+ 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/.github/actions/setup_python2@nightly
+
+ - 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 -r requirements-dev.txt
+
+ - name: Install Plex Media Server
+ shell: bash
+ run: |
+ if [[ "${{ matrix.os }}" == "windows-latest" ]]; then
+ choco install plexmediaserver
+ elif [[ "${{ matrix.os }}" == "macos-latest" ]]; then
+ brew install --cask plex-media-server
+
+ # starting with pms 1.29.2 servers must be claimed... disable that
+ # https://forums.plex.tv/t/new-server-claiming-requirement-for-macos/816337
+ defaults write com.plexapp.plexmediaserver enableLocalSecurity -bool FALSE
+
+ # copy plugin before starting plex server
+ mkdir -p "${HOME}/Library/Application Support/Plex Media Server/Plug-ins"
+ cp -r ./Themerr-plex.bundle "${HOME}/Library/Application Support/Plex Media Server/Plug-ins/"
+
+ open "/Applications/Plex Media Server.app"
+ elif [[ "${{ matrix.os }}" == "ubuntu-latest" ]]; then
+ curl https://downloads.plex.tv/plex-keys/PlexSign.key | sudo apt-key add -
+ echo deb https://downloads.plex.tv/repo/deb public main | \
+ sudo tee /etc/apt/sources.list.d/plexmediaserver.list
+ sudo apt-get update
+ sudo apt-get install plexmediaserver
+
+ # stop service
+ sudo systemctl stop plexmediaserver
+
+ # debug
+ cat /lib/systemd/system/plexmediaserver.service
+
+ # do not edit service directly, use override
+ override=/etc/systemd/system/plexmediaserver.service.d/override.conf
+ sudo mkdir -p $(dirname ${override})
+ sudo touch ${override}
+ echo "[Service]" | sudo tee ${override}
+ echo "User=$USER" | sudo tee -a ${override}
+
+ # take ownership
+ sudo chown -R $USER:$USER "/var/lib/plexmediaserver/Library/Application Support/Plex Media Server"
+
+ # reload service
+ sudo systemctl daemon-reload
+
+ # start
+ sudo systemctl start plexmediaserver
+ else
+ echo "Unknown OS: ${{ matrix.os }}"
+ exit 1
+ fi
+
+ - name: Update Plex registry settings
+ if: ${{ matrix.os == 'windows-latest' }}
+ run: |
+ # starting with pmps 1.32.2 servers must be claimed... disable that
+ # https://forums.plex.tv/t/new-claiming-requirement-for-windows/839096
+ REG ADD "HKCU\Software\Plex, Inc.\Plex Media Server" /v enableLocalSecurity /t REG_DWORD /d 0 /f
+
+ - name: Bootstrap Plex server
+ id: boostrap
+ shell: bash
+ run: |
+ python \
+ -u scripts/plex-bootstraptest.py \
+ --destination plex \
+ --advertise-ip 127.0.0.1 \
+ --bootstrap-timeout 540 \
+ --no-docker \
+ --server-name plex-test-${{ matrix.os }}-${{ github.run_id }} \
+ --without-shows \
+ --without-music \
+ --without-photos \
+ --unclaimed
+
+ - name: Test with pytest
+ id: test
+ shell: bash
+ run: |
+ python -m pytest \
+ -rXs \
+ --tb=native \
+ --verbose \
+ --cov=Contents/Code \
+ tests
+
+ - name: Upload coverage
+ # any except cancelled or skipped
+ if: always() && (steps.test.outcome == 'success' || steps.test.outcome == 'failure')
+ uses: codecov/codecov-action@v3
+ with:
+ flags: ${{ runner.os }}
diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml
deleted file mode 100644
index 36d56a25..00000000
--- a/.github/workflows/python-tests.yml
+++ /dev/null
@@ -1,40 +0,0 @@
----
-name: Python Tests
-
-on:
- pull_request:
- branches: [master, nightly]
- types: [opened, synchronize, reopened]
-
-jobs:
- pytest:
- strategy:
- fail-fast: false
- matrix:
- os: [windows-latest, ubuntu-latest, macos-latest]
-
- runs-on: ${{ matrix.os }}
- steps:
- - name: Checkout
- uses: actions/checkout@v3
-
- - name: Set up Python
- uses: LizardByte/.github/actions/setup_python2@nightly
-
- - 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 -r requirements-dev.txt
- python -m pip --no-python-version-warning --disable-pip-version-check install -r requirements.txt
-
- - name: Build plist
- shell: bash
- run: |
- python ./scripts/build_plist.py
-
- - name: Test with pytest
- shell: bash # our Python 2.7 setup action doesn't support PowerShell
- run: |
- python -m pytest -v
diff --git a/Contents/Code/__init__.py b/Contents/Code/__init__.py
index adc88603..eafcb87a 100644
--- a/Contents/Code/__init__.py
+++ b/Contents/Code/__init__.py
@@ -23,9 +23,6 @@
# imports from Libraries\Shared
from typing import Optional
-# we need to import youtube_dl.compat to prevent plexapi-backport from screwing with the standard library
-# this needs to occur before any plexapi import
-from youtube_dl import compat # noqa: F401
# local imports
from default_prefs import default_prefs
diff --git a/Contents/Code/general_helper.py b/Contents/Code/general_helper.py
index 048ee8cb..615d5fd1 100644
--- a/Contents/Code/general_helper.py
+++ b/Contents/Code/general_helper.py
@@ -20,6 +20,11 @@
# local imports
from constants import metadata_base_directory, metadata_type_map, themerr_data_directory
+# constants
+legacy_keys = [
+ 'downloaded_timestamp'
+]
+
def get_media_upload_path(item, media_type):
# type: (any, str) -> str
@@ -40,6 +45,11 @@ def get_media_upload_path(item, media_type):
str
The path to the theme upload directory.
+ Raises
+ ------
+ ValueError
+ If the ``media_type`` is not one of 'art', 'posters', or 'themes'.
+
Examples
--------
>>> get_media_upload_path(item=..., media_type='art')
@@ -49,6 +59,13 @@ def get_media_upload_path(item, media_type):
>>> get_media_upload_path(item=..., media_type='themes')
"...bundle/Uploads/themes..."
"""
+ allowed_media_types = ['art', 'posters', 'themes']
+ if media_type not in allowed_media_types:
+ raise ValueError(
+ 'This error should be reported to https://github.com/LizardByte/Themerr-plex/issues;'
+ 'media_type must be one of: {}'.format(allowed_media_types)
+ )
+
guid = item.guid
full_hash = hashlib.sha1(guid).hexdigest()
theme_upload_path = os.path.join(
@@ -202,9 +219,6 @@ def update_themerr_data_file(item, new_themerr_data):
themerr_data = get_themerr_json_data(item=item)
# remove legacy keys
- legacy_keys = [
- 'downloaded_timestamp'
- ]
for key in legacy_keys:
try:
del themerr_data[key]
diff --git a/Contents/Code/tmdb_helper.py b/Contents/Code/tmdb_helper.py
index 86abaa31..0b2515ca 100644
--- a/Contents/Code/tmdb_helper.py
+++ b/Contents/Code/tmdb_helper.py
@@ -85,7 +85,6 @@ def get_tmdb_id_from_collection(search_query):
645
"""
# /search/collection?query=James%20Bond%20Collection&include_adult=false&language=en-US&page=1"
-
query_url = 'search/collection?query={}'
# Plex returns 500 error if spaces are in collection query, same with `_`, `+`, and `%20`... so use `-`
diff --git a/README.rst b/README.rst
index db36e819..339a8b28 100644
--- a/README.rst
+++ b/README.rst
@@ -26,6 +26,10 @@ Integrations
:alt: Read the Docs
:target: http://themerr-plex.readthedocs.io/
+.. image:: https://img.shields.io/codecov/c/gh/LizardByte/Themerr-plex?token=1LYYVYWY9D&style=for-the-badge&logo=codecov&label=codecov
+ :alt: Codecov
+ :target: https://codecov.io/gh/LizardByte/Themerr-plex
+
Downloads
---------
diff --git a/codecov.yml b/codecov.yml
new file mode 100644
index 00000000..c9d3a1ab
--- /dev/null
+++ b/codecov.yml
@@ -0,0 +1,15 @@
+---
+codecov:
+ branch: master
+
+coverage:
+ status:
+ project:
+ default:
+ target: auto
+ threshold: 10%
+
+comment:
+ layout: "diff, flags, files"
+ behavior: default
+ require_changes: false # if true: only post the comment if coverage changes
diff --git a/docs/Makefile b/docs/Makefile
index d0c3cbf1..8b6275ab 100644
--- a/docs/Makefile
+++ b/docs/Makefile
@@ -3,7 +3,7 @@
# You can set these variables from the command line, and also
# from the environment for the first two.
-SPHINXOPTS ?=
+SPHINXOPTS ?= -W --keep-going
SPHINXBUILD ?= sphinx-build
SOURCEDIR = source
BUILDDIR = build
diff --git a/docs/make.bat b/docs/make.bat
index dc1312ab..08ca2232 100644
--- a/docs/make.bat
+++ b/docs/make.bat
@@ -9,6 +9,7 @@ if "%SPHINXBUILD%" == "" (
)
set SOURCEDIR=source
set BUILDDIR=build
+set "SPHINXOPTS=-W --keep-going"
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
@@ -25,11 +26,11 @@ if errorlevel 9009 (
if "%1" == "" goto help
-%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% || exit /b %ERRORLEVEL%
goto end
:help
-%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% || exit /b %ERRORLEVEL%
:end
popd
diff --git a/docs/source/contributing/testing.rst b/docs/source/contributing/testing.rst
index 66fdd335..e6770893 100644
--- a/docs/source/contributing/testing.rst
+++ b/docs/source/contributing/testing.rst
@@ -39,6 +39,11 @@ Test with Sphinx
cd docs
sphinx-build -b html source build
+Lint with rstcheck
+ .. code-block:: bash
+
+ rstcheck -r .
+
pytest
------
Themerr-plex uses `pytest `__ for unit testing. pytest is included in the
@@ -46,7 +51,23 @@ Themerr-plex uses `pytest `__ for unit testing
No config is required for pytest.
+.. attention::
+ A locally installed Plex server is required to run some of the tests. The server must be running locally so that the
+ plugin logs can be parsed for exceptions. It is not recommended to run the tests against a production server.
+
+A script is provided that allows you to prepare the Plex server for testing. Use the help argument to see the options.
+
+Bootstrap the Plex server for testing
+.. code-block:: bash
+
+ python scripts/plex-bootstraptest.py --help
+
Test with pytest
.. code-block:: bash
python -m pytest
+
+.. tip::
+ Due to the complexity of setting up the environment for testing, it is recommended to run the tests in GitHub
+ Actions. This will ensure that the tests are run in a clean environment and will not be affected by any local
+ changes.
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 3820f502..0faabf42 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -3,7 +3,10 @@ flake8==3.9.2;python_version<"3"
m2r2==0.3.2;python_version<"3"
numpydoc==0.9.2;python_version<"3"
git+https://github.com/LizardByte/plexhints.git#egg=plexhints # type hinting library for plex development
+plexapi-backport[alert]==4.15.2
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"
+tqdm==4.64.1;python_version<"3"
diff --git a/requirements.txt b/requirements.txt
index 8117043a..4151f54f 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -3,7 +3,7 @@
flask==1.1.4;python_version<"3"
flask-babel==1.0.0;python_version<"3"
future==0.18.3
-plexapi-backport[alert]==4.15.1 # custom python-plexapi supporting python 2.7
+plexapi-backport[alert]==4.15.2 # 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"
diff --git a/scripts/plex-bootstraptest.py b/scripts/plex-bootstraptest.py
new file mode 100644
index 00000000..0d09da31
--- /dev/null
+++ b/scripts/plex-bootstraptest.py
@@ -0,0 +1,712 @@
+# -*- coding: utf-8 -*-
+"""
+The script is used to bootstrap a the test environment for plexapi
+with all the libraries required for testing.
+
+By default this uses a docker.
+
+It can be used manually using:
+python plex-bootraptest.py --no-docker --server-name name_of_server --account Hellowlol --password yourpassword
+
+Borrowed from python-plexapi and python-plexapi-backport... then modified.
+"""
+from __future__ import absolute_import
+from __future__ import division
+from builtins import dict
+from builtins import input
+from builtins import range
+import argparse
+import os
+import platform
+import shutil
+import socket
+import time
+from plexapi.backports import glob
+from plexapi.backports import makedirs
+from shutil import copyfile
+try:
+ from shutil import which
+except ImportError:
+ from backports.shutil_which import which
+import subprocess
+from uuid import uuid4
+
+import plexapi
+from plexapi.exceptions import BadRequest, NotFound
+from plexapi.myplex import MyPlexAccount
+from plexapi.server import PlexServer
+from plexapi.utils import SEARCHTYPES
+from tqdm import tqdm
+
+DOCKER_CMD = [
+ "docker",
+ "run",
+ "-d",
+ "--name",
+ "plex-test-%(container_name_extra)s%(image_tag)s",
+ "--restart",
+ "on-failure",
+ "-p",
+ "32400:32400/tcp",
+ "-p",
+ "3005:3005/tcp",
+ "-p",
+ "8324:8324/tcp",
+ "-p",
+ "32469:32469/tcp",
+ "-p",
+ "1900:1900/udp",
+ "-p",
+ "32410:32410/udp",
+ "-p",
+ "32412:32412/udp",
+ "-p",
+ "32413:32413/udp",
+ "-p",
+ "32414:32414/udp",
+ "-e",
+ "PLEX_CLAIM=%(claim_token)s",
+ "-e",
+ "ADVERTISE_IP=http://%(advertise_ip)s:32400/",
+ "-e",
+ "TZ=%(timezone)s",
+ "-e",
+ "LANG=%(language)s",
+ "-h",
+ "%(hostname)s",
+ "-v",
+ "%(destination)s/db:/config",
+ "-v",
+ "%(destination)s/transcode:/transcode",
+ "-v",
+ "%(destination)s/media:/data",
+ "plexinc/pms-docker:%(image_tag)s",
+]
+
+
+BASE_DIR_PATH = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+STUB_MOVIE_PATH = os.path.join(BASE_DIR_PATH, "tests", "data", "video_stub.mp4")
+STUB_MP3_PATH = os.path.join(BASE_DIR_PATH, "tests", "data", "audio_stub.mp3")
+STUB_IMAGE_PATH = os.path.join(BASE_DIR_PATH, "tests", "data", "cute_cat.jpg")
+
+
+def check_ext(path, ext):
+ """I hate glob so much."""
+ result = []
+ for root, dirs, fil in os.walk(path):
+ for f in fil:
+ fp = os.path.join(root, f)
+ if fp.endswith(ext):
+ result.append(fp)
+
+ return result
+
+
+class ExistingSection(Exception):
+ """This server has sections, exiting"""
+
+ def __init__(self, *args):
+ raise SystemExit("This server has sections exiting")
+
+
+def clean_pms(server, path):
+ for section in server.library.sections():
+ print("Deleting %s" % section.title)
+ section.delete()
+
+ server.library.cleanBundles()
+ server.library.optimize()
+ print("optimized db and removed any bundles")
+
+ shutil.rmtree(path, ignore_errors=False, onerror=None)
+ print("Deleted %s" % path)
+
+
+def setup_music(music_path, docker=False):
+ print("Setup files for the Music section..")
+ makedirs(music_path, exist_ok=True)
+
+ all_music = {
+
+ "Broke for free": {
+ "Layers": [
+ "1 - As Colorful As Ever.mp3",
+ # "02 - Knock Knock.mp3",
+ # "03 - Only Knows.mp3",
+ # "04 - If.mp3",
+ # "05 - Note Drop.mp3",
+ # "06 - Murmur.mp3",
+ # "07 - Spellbound.mp3",
+ # "08 - The Collector.mp3",
+ # "09 - Quit Bitching.mp3",
+ # "10 - A Year.mp3",
+ ]
+ },
+
+ }
+
+ m3u_file = open(os.path.join(music_path, "playlist.m3u"), "w")
+
+ for artist, album in all_music.items():
+ for k, v in album.items():
+ artist_album = os.path.join(music_path, artist, k)
+ makedirs(artist_album, exist_ok=True)
+ for song in v:
+ trackpath = os.path.join(artist_album, song)
+ copyfile(STUB_MP3_PATH, trackpath)
+
+ if docker:
+ reltrackpath = os.path.relpath(trackpath, os.path.dirname(music_path))
+ m3u_file.write(os.path.join("/data", reltrackpath) + "\n")
+ else:
+ m3u_file.write(trackpath + "\n")
+
+ m3u_file.close()
+
+ return len(check_ext(music_path, (".mp3")))
+
+
+def setup_movies(movies_path):
+ print("Setup files for the Movies section..")
+ makedirs(movies_path, exist_ok=True)
+
+ if len(glob(movies_path + "/*.mkv", recursive=True)) == 4:
+ return 4
+
+ required_movies = {
+ "Elephants Dream": 2006,
+ "Sita Sings the Blues": 2008,
+ "Big Buck Bunny": 2008,
+ "Sintel": 2010,
+ }
+ expected_media_count = 0
+ for name, year in required_movies.items():
+ expected_media_count += 1
+ if not os.path.isfile(get_movie_path(movies_path, name, year)):
+ copyfile(STUB_MOVIE_PATH, get_movie_path(movies_path, name, year))
+
+ return expected_media_count
+
+
+def setup_images(photos_path):
+ print("Setup files for the Photos section..")
+
+ makedirs(photos_path, exist_ok=True)
+ # expected_photo_count = 0
+ folders = {
+ ("Cats",): 3,
+ ("Cats", "Cats in bed"): 7,
+ ("Cats", "Cats not in bed"): 1,
+ ("Cats", "Not cats in bed"): 1,
+ }
+ has_photos = 0
+ for folder_path, required_cnt in folders.items():
+ folder_path = os.path.join(photos_path, *folder_path)
+ makedirs(folder_path, exist_ok=True)
+ photos_in_folder = len(glob(os.path.join(folder_path, "/*.jpg")))
+ while photos_in_folder < required_cnt:
+ # Dunno why this is need got permission error on photo0.jpg
+ photos_in_folder += 1
+ full_path = os.path.join(folder_path, "photo%d.jpg" % photos_in_folder)
+ copyfile(STUB_IMAGE_PATH, full_path)
+ has_photos += photos_in_folder
+
+ return len(check_ext(photos_path, (".jpg")))
+
+
+def setup_show(tvshows_path):
+ print("Setup files for the TV-Shows section..")
+ makedirs(tvshows_path, exist_ok=True)
+ makedirs(os.path.join(tvshows_path, "Game of Thrones"), exist_ok=True)
+ makedirs(os.path.join(tvshows_path, "The 100"), exist_ok=True)
+ required_tv_shows = {
+ "Game of Thrones": [list(range(1, 11)), list(range(1, 11))],
+ "The 100": [list(range(1, 14)), list(range(1, 17))],
+ }
+ expected_media_count = 0
+ for show_name, seasons in required_tv_shows.items():
+ for season_id, episodes in enumerate(seasons, start=1):
+ for episode_id in episodes:
+ expected_media_count += 1
+ episode_path = get_tvshow_path(
+ tvshows_path, show_name, season_id, episode_id
+ )
+ if not os.path.isfile(episode_path):
+ copyfile(STUB_MOVIE_PATH, episode_path)
+
+ return expected_media_count
+
+
+def get_default_ip():
+ """ Return the first IP address of the current machine if available. """
+ available_ips = list(
+ set(
+ [
+ i[4][0]
+ for i in socket.getaddrinfo(socket.gethostname(), None)
+ if i[4][0] not in ("127.0.0.1", "::1")
+ and not i[4][0].startswith("fe80:")
+ ]
+ )
+ )
+ return available_ips[0] if len(available_ips) else None
+
+
+def get_plex_account(opts):
+ """ Authenticate with Plex using the command line options. """
+ if not opts.unclaimed:
+ if opts.token:
+ return MyPlexAccount(token=opts.token)
+ return plexapi.utils.getMyPlexAccount(opts)
+ return None
+
+
+def get_movie_path(movies_path, name, year):
+ """ Return a movie path given its title and year. """
+ return os.path.join(movies_path, "%s (%d).mp4" % (name, year))
+
+
+def get_tvshow_path(tvshows_path, name, season, episode):
+ """ Return a TV show path given its title, season, and episode. """
+ return os.path.join(tvshows_path, name, "S%02dE%02d.mp4" % (season, episode))
+
+
+def add_library_section(server, section):
+ """ Add the specified section to our Plex instance. This tends to be a bit
+ flaky, so we retry a few times here.
+ """
+ start = time.time()
+ runtime = 0
+ while runtime < 60:
+ try:
+ server.library.add(**section)
+ return True
+ except BadRequest as err:
+ if "server is still starting up. Please retry later" in str(err):
+ time.sleep(1)
+ continue
+ raise
+ runtime = time.time() - start
+ raise SystemExit("Timeout adding section to Plex instance.")
+
+
+def create_section(server, section, opts): # noqa: C901
+ processed_media = 0
+ expected_media_count = section.pop("expected_media_count", 0)
+ expected_media_type = (section["type"],)
+ if section["type"] == "show":
+ expected_media_type = ("show", "season", "episode")
+ if section["type"] == "artist":
+ expected_media_type = ("artist", "album", "track")
+ expected_media_type = tuple(SEARCHTYPES[t] for t in expected_media_type)
+
+ def alert_callback(data):
+ """ Listen to the Plex notifier to determine when metadata scanning is complete. """
+ global processed_media
+ if data["type"] == "timeline":
+ for entry in data["TimelineEntry"]:
+ if (
+ entry.get("identifier", "com.plexapp.plugins.library")
+ == "com.plexapp.plugins.library"
+ ):
+ # Missed mediaState means that media was processed (analyzed & thumbnailed)
+ if (
+ "mediaState" not in entry
+ and entry["type"] in expected_media_type
+ ):
+ # state=5 means record processed, applicable only when metadata source was set
+ if entry["state"] == 5:
+ cnt = 1
+ if entry["type"] == SEARCHTYPES["show"]:
+ show = server.library.sectionByID(
+ entry["sectionID"]
+ ).get(entry["title"])
+ cnt = show.leafCount
+ bar.update(cnt)
+ processed_media += cnt
+ # state=1 means record processed, when no metadata source was set
+ elif (
+ entry["state"] == 1
+ and entry["type"] == SEARCHTYPES["photo"]
+ ):
+ bar.update()
+ processed_media += 1
+
+ runtime = 0
+ start = time.time()
+ bar = tqdm(desc="Scanning section " + section["name"], total=expected_media_count)
+ notifier = server.startAlertListener(alert_callback)
+ time.sleep(3)
+ add_library_section(server, section)
+ while bar.n < bar.total:
+ if runtime >= 120:
+ print("Metadata scan taking too long, but will continue anyway..")
+ break
+ time.sleep(3)
+ runtime = int(time.time() - start)
+ bar.close()
+ notifier.stop()
+
+
+if __name__ == "__main__": # noqa: C901
+ default_ip = get_default_ip()
+ parser = argparse.ArgumentParser(description=__doc__)
+ # Authentication arguments
+ mg = parser.add_mutually_exclusive_group()
+ g = mg.add_argument_group()
+ g.add_argument("--username", help="Your Plex username")
+ g.add_argument("--password", help="Your Plex password")
+ mg.add_argument(
+ "--token",
+ help="Plex.tv authentication token",
+ default=plexapi.CONFIG.get("auth.server_token"),
+ )
+ mg.add_argument(
+ "--unclaimed",
+ help="Do not claim the server",
+ default=False,
+ action="store_true",
+ )
+ # Test environment arguments
+ parser.add_argument(
+ "--no-docker", help="Use docker", default=False, action="store_true"
+ )
+ parser.add_argument(
+ "--timezone", help="Timezone to set inside plex", default="UTC"
+ ) # noqa
+ parser.add_argument(
+ "--language", help="Language to set inside plex", default="en_US.UTF-8"
+ ) # noqa
+ parser.add_argument(
+ "--destination",
+ help="Local path where to store all the media",
+ default=os.path.join(os.getcwd(), "plex"),
+ ) # noqa
+ parser.add_argument(
+ "--plugin-bundle-destination",
+ help="Local path where to copy the plugin bundle to",
+ default="auto"
+ ) # noqa
+ parser.add_argument(
+ "--plugin-bundle-source",
+ help="Local path where bundle is located",
+ default=os.path.join(os.getcwd(), "Themerr-plex.bundle"),
+ ) # noqa
+ parser.add_argument(
+ "--advertise-ip",
+ help="IP address which should be advertised by new Plex instance",
+ required=default_ip is None,
+ default=default_ip,
+ ) # noqa
+ parser.add_argument(
+ "--docker-tag", help="Docker image tag to install", default="latest"
+ ) # noqa
+ parser.add_argument(
+ "--bootstrap-timeout",
+ help="Timeout for each step of bootstrap, in seconds (default: %(default)s)",
+ default=180,
+ type=int,
+ ) # noqa
+ parser.add_argument(
+ "--server-name",
+ help="Name for the new server",
+ default="plex-test-docker-%s" % str(uuid4()),
+ ) # noqa
+ parser.add_argument(
+ "--accept-eula", help="Accept Plex's EULA", default=False, action="store_true"
+ ) # noqa
+ parser.add_argument(
+ "--without-movies",
+ help="Do not create Movies section",
+ default=True,
+ dest="with_movies",
+ action="store_false",
+ ) # noqa
+ parser.add_argument(
+ "--without-shows",
+ help="Do not create TV Shows section",
+ default=True,
+ dest="with_shows",
+ action="store_false",
+ ) # noqa
+ parser.add_argument(
+ "--without-music",
+ help="Do not create Music section",
+ default=True,
+ dest="with_music",
+ action="store_false",
+ ) # noqa
+ parser.add_argument(
+ "--without-photos",
+ help="Do not create Photos section",
+ default=True,
+ dest="with_photos",
+ action="store_false",
+ ) # noqa
+ parser.add_argument(
+ "--show-token",
+ help="Display access token after bootstrap",
+ default=False,
+ action="store_true",
+ ) # noqa
+ opts, _ = parser.parse_known_args()
+
+ account = get_plex_account(opts)
+ path = os.path.realpath(os.path.expanduser(opts.destination))
+ media_path = os.path.join(path, "media")
+ makedirs(media_path, exist_ok=True)
+
+ APP_DATA_PATH = dict(
+ Darwin="{}/Library/Application Support/Plex Media Server".format(os.getenv('HOME')),
+ Linux="/var/lib/plexmediaserver/Library/Application Support/Plex Media Server",
+ Windows="{}\\Plex Media Server".format(os.getenv('LOCALAPPDATA'))
+ )
+
+ # copy the plugin
+ if opts.plugin_bundle_destination == "auto":
+ opts.plugin_bundle_destination = os.path.join(
+ path, "db", "Library", "Application Support", "Plex Media Server", "Plug-ins", "Themerr-plex.bundle") if \
+ opts.no_docker is False else os.path.join(
+ APP_DATA_PATH[platform.system()], "Plug-ins", "Themerr-plex.bundle")
+
+ try:
+ shutil.copytree(opts.plugin_bundle_source, opts.plugin_bundle_destination)
+ except OSError as e:
+ if 'file exists' in str(e).lower():
+ print("Warning: Skipping copy, plugin already exists at %s" % opts.plugin_bundle_destination)
+ else:
+ print("Warning: %s" % e)
+ else:
+ print("Copied plugin bundle to %s" % opts.plugin_bundle_destination)
+
+ # copy the plugin plist file back to Contents
+ # this is necessary to get elevated policy with plexhints
+ try:
+ shutil.copyfile(os.path.join(opts.plugin_bundle_source, "Contents", "Info.plist"),
+ os.path.join(os.getcwd(), "Contents", "Info.plist"))
+ except OSError as e:
+ if 'file exists' in str(e).lower():
+ print("Warning: Skipping copy, plugin plist already exists at %s" % os.path.join(
+ os.getcwd(), "Contents", "Info.plist"))
+ else:
+ print("Warning: %s" % e)
+ else:
+ print("Copied plugin plist to %s" % os.path.join(os.getcwd(), "Contents", "Info.plist"))
+
+ # Download the Plex Docker image
+ if opts.no_docker is False:
+ print(
+ "Creating Plex instance named %s with advertised ip %s"
+ % (opts.server_name, opts.advertise_ip)
+ )
+ if which("docker") is None:
+ print("Docker is required to be available")
+ exit(1)
+ if subprocess.call(["docker", "pull", "plexinc/pms-docker:%s" % opts.docker_tag]) != 0:
+ print("Got an error when executing docker pull!")
+ exit(1)
+
+ # Start the Plex Docker container
+
+ arg_bindings = {
+ "destination": path,
+ "hostname": opts.server_name,
+ "claim_token": account.claimToken() if account else "",
+ "timezone": opts.timezone,
+ "language": opts.language,
+ "advertise_ip": opts.advertise_ip,
+ "image_tag": opts.docker_tag,
+ "container_name_extra": "" if account else "unclaimed-",
+ }
+ docker_cmd = [c % arg_bindings for c in DOCKER_CMD]
+ exit_code = subprocess.call(docker_cmd)
+ if exit_code != 0:
+ raise SystemExit(
+ "Error %s while starting the Plex docker container" % exit_code
+ )
+
+ # Wait for the Plex container to start
+ print("Waiting for the Plex to start..")
+ start = time.time()
+ runtime = 0
+ server = None
+ while not server and (runtime < opts.bootstrap_timeout):
+ try:
+ if account:
+ server = account.device(opts.server_name).connect()
+ else:
+ server = PlexServer("http://%s:32400" % opts.advertise_ip)
+
+ except KeyboardInterrupt:
+ break
+
+ except Exception as err:
+ print(err)
+ time.sleep(1)
+
+ runtime = time.time() - start
+
+ if not server:
+ raise SystemExit(
+ "Server didn't appear in your account after %ss" % opts.bootstrap_timeout
+ )
+
+ print("Plex container started after %ss" % int(runtime))
+ print("Plex server version %s" % server.version)
+
+ if opts.accept_eula:
+ server.settings.get("acceptedEULA").set(True)
+ # Disable settings for background tasks when using the test server.
+ # These tasks won't work on the test server since we are using fake media files
+ if not opts.unclaimed and account and account.subscriptionActive:
+ server.settings.get("GenerateIntroMarkerBehavior").set("never")
+ server.settings.get("GenerateCreditsMarkerBehavior").set("never")
+ server.settings.get("GenerateBIFBehavior").set("never")
+ server.settings.get("GenerateChapterThumbBehavior").set("never")
+ server.settings.get("LoudnessAnalysisBehavior").set("never")
+ server.settings.save()
+
+ sections = []
+
+ # Lets add a check here do somebody don't mess up
+ # there normal server if they run manual tests.
+ # Like i did....
+ if len(server.library.sections()) and opts.no_docker is True:
+ ans = input(
+ "The server has %s sections, do you wish to remove it?\n> "
+ % len(server.library.sections())
+ )
+ if ans in ("y", "Y", "Yes"):
+ ans = input(
+ "Are you really sure you want to delete %s libraries? There is no way back\n> "
+ % len(server.library.sections())
+ )
+ if ans in ("y", "Y", "Yes"):
+ clean_pms(server, path)
+ else:
+ raise ExistingSection()
+ else:
+ raise ExistingSection()
+
+ # Prepare Movies section
+ if opts.with_movies:
+ movies_path = os.path.join(media_path, "Movies")
+ num_movies = setup_movies(movies_path)
+
+ sections.append(
+ dict(
+ name="Movies-new-agent",
+ type="movie",
+ location="/data/Movies" if opts.no_docker is False else movies_path,
+ agent="tv.plex.agents.movie",
+ scanner="Plex Movie",
+ language="en-US",
+ expected_media_count=num_movies,
+ )
+ )
+ sections.append(
+ dict(
+ name="Movies-imdb-agent",
+ type="movie",
+ location="/data/Movies" if opts.no_docker is False else movies_path,
+ agent="com.plexapp.agents.imdb",
+ scanner="Plex Movie Scanner",
+ # language="en-US",
+ expected_media_count=num_movies,
+ )
+ )
+ sections.append(
+ dict(
+ name="Movies-themoviedb-agent",
+ type="movie",
+ location="/data/Movies" if opts.no_docker is False else movies_path,
+ agent="com.plexapp.agents.themoviedb",
+ scanner="Plex Movie Scanner",
+ # language="en-US",
+ expected_media_count=num_movies,
+ )
+ )
+
+ # Prepare TV Show section
+ if opts.with_shows:
+ tvshows_path = os.path.join(media_path, "TV-Shows")
+ num_ep = setup_show(tvshows_path)
+
+ sections.append(
+ dict(
+ name="TV Shows",
+ type="show",
+ location="/data/TV-Shows" if opts.no_docker is False else tvshows_path,
+ agent="tv.plex.agents.series",
+ scanner="Plex TV Series",
+ language="en-US",
+ expected_media_count=num_ep,
+ )
+ )
+
+ # Prepare Music section
+ if opts.with_music:
+ music_path = os.path.join(media_path, "Music")
+ song_c = setup_music(music_path, docker=not opts.no_docker)
+
+ sections.append(
+ dict(
+ name="Music",
+ type="artist",
+ location="/data/Music" if opts.no_docker is False else music_path,
+ agent="tv.plex.agents.music",
+ scanner="Plex Music",
+ language="en-US",
+ expected_media_count=song_c,
+ )
+ )
+
+ # Prepare Photos section
+ if opts.with_photos:
+ photos_path = os.path.join(media_path, "Photos")
+ has_photos = setup_images(photos_path)
+
+ sections.append(
+ dict(
+ name="Photos",
+ type="photo",
+ location="/data/Photos" if opts.no_docker is False else photos_path,
+ agent="com.plexapp.agents.none",
+ scanner="Plex Photo Scanner",
+ expected_media_count=has_photos,
+ )
+ )
+
+ # enable plugin for supported agents
+ legacy_agents = [
+ 'com.plexapp.agents.imdb',
+ 'com.plexapp.agents.themoviedb',
+ ]
+ for agent in legacy_agents:
+ server.query(
+ key='/system/agents/{agent}/config/1?order={agent}%2C{plugin_agent}'.format(
+ agent=agent,
+ plugin_agent='dev.lizardbyte.themerr-plex',
+ ), method=server._session.put)
+
+ # Create the Plex library in our instance
+ if sections:
+ print("Creating the Plex libraries on %s" % server.friendlyName)
+ for section in sections:
+ create_section(server, section, opts)
+
+ # Share this instance with the specified username
+ if account:
+ shared_username = os.environ.get("SHARED_USERNAME", "PKKid")
+ try:
+ user = account.user(shared_username)
+ account.updateFriend(user, server)
+ print("The server was shared with user %s" % shared_username)
+ except NotFound:
+ pass
+
+ # Finished: Display our Plex details
+ print("Base URL is %s" % server.url("", False))
+ if account and opts.show_token:
+ print("Auth token is %s" % account.authenticationToken)
+ print("Server %s is ready to use!" % opts.server_name)
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/conftest.py b/tests/conftest.py
index 100b5ccb..3fccc57c 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,22 +1,96 @@
+# -*- coding: utf-8 -*-
# standard imports
+from functools import partial
import os
import sys
+import time
# lib imports
+import plexapi
+from plexapi.exceptions import NotFound
+from plexapi.myplex import MyPlexAccount
+from plexapi.server import PlexServer
from plexhints.agent_kit import Agent
+from plexhints.core_kit import PLUGIN_LOGS_PATH
import pytest
+import requests
# add Contents directory to the system path
if os.path.isdir('Contents'):
sys.path.append('Contents')
# local imports
+ from Code import constants
from Code import Themerr
from Code import webapp
else:
raise Exception('Contents directory not found')
+# plex server setup
+SERVER_BASEURL = plexapi.CONFIG.get("auth.server_baseurl")
+MYPLEX_USERNAME = plexapi.CONFIG.get("auth.myplex_username")
+MYPLEX_PASSWORD = plexapi.CONFIG.get("auth.myplex_password")
+SERVER_TOKEN = plexapi.CONFIG.get("auth.server_token")
+TEST_AUTHENTICATED = "authenticated"
+TEST_ANONYMOUSLY = "anonymously"
+ANON_PARAM = pytest.param(TEST_ANONYMOUSLY, marks=pytest.mark.anonymous)
+AUTH_PARAM = pytest.param(TEST_AUTHENTICATED, marks=pytest.mark.authenticated)
+
+
+BASE_DIR_PATH = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+
+
+def pytest_generate_tests(metafunc):
+ if "plex" in metafunc.fixturenames:
+ if (
+ "account" in metafunc.fixturenames
+ or TEST_AUTHENTICATED in metafunc.definition.keywords
+ ):
+ metafunc.parametrize("plex", [AUTH_PARAM], indirect=True)
+ else:
+ metafunc.parametrize("plex", [ANON_PARAM, AUTH_PARAM], indirect=True)
+ elif "account" in metafunc.fixturenames:
+ metafunc.parametrize("account", [AUTH_PARAM], indirect=True)
+
+
+def pytest_runtest_setup(item):
+ if "client" in item.keywords and not item.config.getvalue("client"):
+ return pytest.skip("Need --client option to run.")
+ if TEST_AUTHENTICATED in item.keywords and not (MYPLEX_USERNAME and MYPLEX_PASSWORD or SERVER_TOKEN):
+ return pytest.skip(
+ "You have to specify MYPLEX_USERNAME and MYPLEX_PASSWORD or SERVER_TOKEN to run authenticated tests"
+ )
+ if TEST_ANONYMOUSLY in item.keywords and (MYPLEX_USERNAME and MYPLEX_PASSWORD or SERVER_TOKEN):
+ return pytest.skip(
+ "Anonymous tests should be ran on unclaimed server, without providing MYPLEX_USERNAME and "
+ "MYPLEX_PASSWORD or SERVER_TOKEN"
+ )
+
+
+def wait_for_themes(movies):
+ # ensure library is not refreshing
+ while movies.refreshing:
+ time.sleep(1)
+
+ # wait for themes to be uploaded
+ timer = 0
+ with_themes = 0
+ total = len(movies.all())
+ while timer < 180 and with_themes < total:
+ with_themes = 0
+ try:
+ for item in movies.all():
+ if item.theme:
+ with_themes += 1
+ except requests.ReadTimeout:
+ time.sleep(10) # try to recover from ReadTimeout (hit api limit?)
+ else:
+ time.sleep(3)
+ timer += 3
+
+
+# basic fixtures
@pytest.fixture
def agent():
# type: () -> Agent
@@ -36,3 +110,96 @@ def test_client(scope='function'):
# Establish an application context
with app.app_context():
yield test_client # this is where the testing happens!
+
+
+# plex server fixtures
+@pytest.fixture(scope="session")
+def plugin_logs():
+ # list contents of the plugin logs directory
+ plugin_logs = os.listdir(PLUGIN_LOGS_PATH)
+
+ yield plugin_logs
+
+
+# plex server fixtures
+@pytest.fixture(scope="session")
+def plugin_log_file():
+ # the primary plugin log file
+ plugin_log_file = os.path.join(PLUGIN_LOGS_PATH, "{}.log".format(constants.plugin_identifier))
+
+ yield plugin_log_file
+
+
+@pytest.fixture(scope="session")
+def sess():
+ session = requests.Session()
+ session.request = partial(session.request, timeout=120)
+ return session
+
+
+@pytest.fixture(scope="session")
+def plex(request, sess):
+ assert SERVER_BASEURL, "Required SERVER_BASEURL not specified."
+
+ if request.param == TEST_AUTHENTICATED:
+ token = MyPlexAccount(session=sess).authenticationToken
+ else:
+ token = None
+ return PlexServer(SERVER_BASEURL, token, session=sess)
+
+
+@pytest.fixture()
+def movies_new_agent(plex):
+ movies = plex.library.section("Movies-new-agent")
+ wait_for_themes(movies=movies)
+ return movies
+
+
+@pytest.fixture()
+def movies_imdb_agent(plex):
+ movies = plex.library.section("Movies-imdb-agent")
+ wait_for_themes(movies=movies)
+ return movies
+
+
+@pytest.fixture()
+def movies_themoviedb_agent(plex):
+ movies = plex.library.section("Movies-themoviedb-agent")
+ wait_for_themes(movies=movies)
+ return movies
+
+
+@pytest.fixture()
+def collection_new_agent(plex, movies_new_agent, movie_new_agent):
+ try:
+ return movies_new_agent.collection("Test Collection")
+ except NotFound:
+ return plex.createCollection(
+ title="Test Collection",
+ section=movies_new_agent,
+ items=movie_new_agent
+ )
+
+
+@pytest.fixture()
+def collection_imdb_agent(plex, movies_imdb_agent, movie_imdb_agent):
+ try:
+ return movies_imdb_agent.collection("Test Collection")
+ except NotFound:
+ return plex.createCollection(
+ title="Test Collection",
+ section=movies_imdb_agent,
+ items=movie_imdb_agent
+ )
+
+
+@pytest.fixture()
+def collection_themoviedb_agent(plex, movies_themoviedb_agent, movie_themoviedb_agent):
+ try:
+ return movies_themoviedb_agent.collection("Test Collection")
+ except NotFound:
+ return plex.createCollection(
+ title="Test Collection",
+ section=movies_themoviedb_agent,
+ items=movie_themoviedb_agent
+ )
diff --git a/tests/data/video_stub.mp4 b/tests/data/video_stub.mp4
new file mode 100644
index 00000000..d9a10e31
Binary files /dev/null and b/tests/data/video_stub.mp4 differ
diff --git a/tests/functional/test_docs.py b/tests/functional/test_docs.py
new file mode 100644
index 00000000..fb78dc84
--- /dev/null
+++ b/tests/functional/test_docs.py
@@ -0,0 +1,72 @@
+import os
+import platform
+import pytest
+import shutil
+import subprocess
+
+
+def build_docs():
+ """Test building sphinx docs"""
+ doc_types = [
+ 'html',
+ 'epub',
+ ]
+
+ # remove existing build directory
+ build_dir = os.path.join(os.getcwd(), 'docs', 'build')
+ if os.path.isdir(build_dir):
+ shutil.rmtree(path=build_dir)
+
+ for doc_type in doc_types:
+ print('Building {} docs'.format(doc_type))
+ result = subprocess.check_call(
+ args=['make', doc_type],
+ cwd=os.path.join(os.getcwd(), 'docs'),
+ shell=True if platform.system() == 'Windows' else False,
+ )
+ assert result == 0, 'Failed to build {} docs'.format(doc_type)
+
+ # ensure docs built
+ assert os.path.isfile(os.path.join(build_dir, 'html', 'index.html')), 'HTML docs not built'
+ assert os.path.isfile(os.path.join(build_dir, 'epub', 'Themerr-plex.epub')), 'EPUB docs not built'
+
+
+def test_make_docs():
+ """Test building working sphinx docs"""
+ build_docs()
+
+
+def test_make_docs_broken():
+ """Test building sphinx docs with known warnings"""
+ # create a dummy rst file
+ dummy_file = os.path.join(os.getcwd(), 'docs', 'source', 'dummy.rst')
+
+ # write test to dummy file, creating the file if it doesn't exist
+ with open(dummy_file, 'w+') as f:
+ f.write('Dummy file\n')
+ f.write('==========\n')
+
+ # ensure CalledProcessError is raised
+ with pytest.raises(subprocess.CalledProcessError):
+ build_docs()
+
+ # remove the dummy rst file
+ os.remove(dummy_file)
+
+
+def test_rstcheck():
+ """Test rstcheck"""
+ # get list of all the rst files in the project (skip venv and Contents/Libraries)
+ rst_files = []
+ for root, dirs, files in os.walk(os.getcwd()):
+ for f in files:
+ if f.lower().endswith('.rst') and 'venv' not in root and 'Contents/Libraries' not in root:
+ rst_files.append(os.path.join(root, f))
+
+ assert rst_files, 'No rst files found'
+
+ # run rstcheck on all the rst files
+ for rst_file in rst_files:
+ print('Checking {}'.format(rst_file))
+ result = subprocess.check_call(['rstcheck', rst_file])
+ assert result == 0, 'rstcheck failed on {}'.format(rst_file)
diff --git a/tests/functional/test_plex_plugin.py b/tests/functional/test_plex_plugin.py
new file mode 100644
index 00000000..aac4f0d3
--- /dev/null
+++ b/tests/functional/test_plex_plugin.py
@@ -0,0 +1,55 @@
+# -*- coding: utf-8 -*-
+# standard imports
+import os
+
+# lib imports
+import pytest
+
+
+def _check_themes(movies):
+ # ensure all movies have themes
+ for item in movies.all():
+ print(item.title)
+ assert item.theme, "No theme found for {}".format(item.title)
+
+
+def test_plugin_los(plugin_logs):
+ print('plugin_logs: {}'.format(plugin_logs))
+ assert plugin_logs, "No plugin logs found"
+
+
+def test_plugin_log_file(plugin_log_file):
+ assert os.path.isfile(plugin_log_file), "Plugin log file not found: {}".format(plugin_log_file)
+
+
+def test_plugin_log_file_exceptions(plugin_log_file):
+ # get all the lines in the plugin log file
+ with open(plugin_log_file, 'r') as f:
+ lines = f.readlines()
+
+ critical_exceptions = []
+ for line in lines:
+ if ') : CRITICAL (' in line:
+ critical_exceptions.append(line)
+
+ assert len(critical_exceptions) <= 1, "Too many exceptions logged to plugin log file"
+
+ for exception in critical_exceptions:
+ # every plugin will have this exception
+ assert exception.endswith('Exception getting hosted resource hashes (most recent call last):\n'), (
+ "Unexpected exception: {}".format(exception))
+
+
+@pytest.mark.anonymous
+def test_movies_new_agent(movies_new_agent):
+ _check_themes(movies_new_agent)
+
+
+@pytest.mark.anonymous
+def test_movies_imdb_agent(movies_imdb_agent):
+ _check_themes(movies_imdb_agent)
+
+
+@pytest.mark.anonymous
+def test_movies_themoviedb_agent(movies_themoviedb_agent):
+ _check_themes(movies_themoviedb_agent)
diff --git a/tests/functional/test_webapp.py b/tests/functional/test_webapp.py
index ece8c70f..ba60cd19 100644
--- a/tests/functional/test_webapp.py
+++ b/tests/functional/test_webapp.py
@@ -1,3 +1,4 @@
+# -*- coding: utf-8 -*-
# lib imports
import pytest
diff --git a/tests/unit/test_code.py b/tests/unit/test_code.py
index c5f51abd..f0a8b03b 100644
--- a/tests/unit/test_code.py
+++ b/tests/unit/test_code.py
@@ -1,9 +1,12 @@
+# -*- coding: utf-8 -*-
# local imports
+import Code
from Code import ValidatePrefs
from Code import default_prefs
from plexhints.agent_kit import Media
from plexhints.model_kit import Movie
from plexhints.object_kit import MessageContainer, SearchResult
+from plexhints.prefs_kit import Prefs
# setup items to test
test_items = dict(
@@ -34,6 +37,14 @@
)
+def test_copy_prefs():
+ Code.copy_prefs()
+ assert Code.last_prefs, "Prefs did not copy"
+
+ for key in default_prefs:
+ assert Code.last_prefs[key] == Prefs[key]
+
+
def test_validate_prefs():
result_container = ValidatePrefs()
assert isinstance(result_container, MessageContainer)
@@ -48,6 +59,8 @@ def test_validate_prefs():
# assert result_container.header == "Error"
# assert "must be an integer" in result_container.message
+
+def test_validate_prefs_default_prefs():
# add a default pref and make sure it is not in DefaultPrefs.json
default_prefs['new_pref'] = 'new_value'
result_container = ValidatePrefs()
@@ -66,7 +79,7 @@ def test_main():
pass
-def test_themerr_search(agent):
+def test_themerr_agent_search(agent):
for key, item in test_items.items():
media = Media.Movie()
media.primary_metadata = Movie()
@@ -93,7 +106,7 @@ def test_themerr_search(agent):
assert result.id == "%s-%s-%s" % (item['category'], database, item_id)
-def test_themerr_update(agent):
+def test_themerr_agent_update(agent):
metadata = Movie()
for key, item in test_items.items():
diff --git a/tests/unit/test_general_helper.py b/tests/unit/test_general_helper.py
new file mode 100644
index 00000000..9b2a2235
--- /dev/null
+++ b/tests/unit/test_general_helper.py
@@ -0,0 +1,122 @@
+# -*- coding: utf-8 -*-
+# standard imports
+import os
+import shutil
+
+# lib imports
+import pytest
+
+# local imports
+from Code import constants
+from Code import general_helper
+
+
+@pytest.mark.anonymous
+def test_get_media_upload_path(movies_themoviedb_agent):
+ test_items = [
+ movies_themoviedb_agent.all()[0]
+ ]
+
+ media_types = ['art', 'posters', 'themes']
+
+ for item in test_items:
+ for media_type in media_types:
+ media_upload_path = general_helper.get_media_upload_path(item=item, media_type=media_type)
+ assert media_upload_path.endswith(os.path.join('.bundle', 'Uploads', media_type))
+ # todo - test collections, with art and posters
+ if media_type == 'themes':
+ assert os.path.isdir(media_upload_path)
+
+
+@pytest.mark.anonymous
+def test_get_media_upload_path_invalid(movies_themoviedb_agent):
+ test_items = [
+ movies_themoviedb_agent.all()[0]
+ ]
+
+ with pytest.raises(ValueError):
+ general_helper.get_media_upload_path(item=test_items[0], media_type='invalid')
+
+
+@pytest.mark.anonymous
+def test_get_themerr_json_path(movies_themoviedb_agent):
+ test_items = [
+ movies_themoviedb_agent.all()[0]
+ ]
+
+ for item in test_items:
+ themerr_json_path = general_helper.get_themerr_json_path(item=item)
+ assert themerr_json_path.endswith('{}.json'.format(item.ratingKey))
+ assert os.path.join('Plex Media Server', 'Plug-in Support', 'Data', constants.plugin_identifier,
+ 'DataItems') in themerr_json_path
+
+
+@pytest.mark.anonymous
+def test_get_themerr_json_data(movies_themoviedb_agent):
+ test_items = [
+ movies_themoviedb_agent.all()[0]
+ ]
+
+ for item in test_items:
+ themerr_json_data = general_helper.get_themerr_json_data(item=item)
+ assert isinstance(themerr_json_data, dict)
+ assert 'youtube_theme_url' in themerr_json_data.keys()
+
+
+def test_get_themerr_settings_hash():
+ themerr_settings_hash = general_helper.get_themerr_settings_hash()
+ assert themerr_settings_hash
+ assert isinstance(themerr_settings_hash, str)
+
+ # ensure hash is 256 bits long
+ assert len(themerr_settings_hash) == 64
+
+
+@pytest.mark.anonymous
+def test_remove_uploaded_media(movies_themoviedb_agent):
+ test_items = [
+ movies_themoviedb_agent.all()[0]
+ ]
+
+ for item in test_items:
+ for media_type in ['themes']: # todo - test art and posters
+ # backup current directory
+ current_directory = general_helper.get_media_upload_path(item=item, media_type=media_type)
+ assert os.path.isdir(current_directory)
+ shutil.copytree(current_directory, '{}.bak'.format(current_directory))
+ assert os.path.isdir('{}.bak'.format(current_directory))
+
+ general_helper.remove_uploaded_media(item=item, media_type=media_type)
+ assert not os.path.isdir(current_directory)
+
+ # restore backup
+ shutil.move('{}.bak'.format(current_directory), current_directory)
+ assert os.path.isdir(current_directory)
+
+
+def test_remove_uploaded_media_error_handler():
+ # just try to execute the error handler function
+ general_helper.remove_uploaded_media_error_handler(
+ func=test_remove_uploaded_media_error_handler,
+ path=os.getcwd(),
+ exc_info=OSError
+ )
+
+
+@pytest.mark.anonymous
+def test_update_themerr_data_file(movies_themoviedb_agent):
+ test_items = [
+ movies_themoviedb_agent.all()[0]
+ ]
+
+ new_themerr_data = {
+ 'pytest': 'test'
+ }
+
+ for item in test_items:
+ general_helper.update_themerr_data_file(item=item, new_themerr_data=new_themerr_data)
+ themerr_json_data = general_helper.get_themerr_json_data(item=item)
+ assert themerr_json_data['pytest'] == 'test'
+
+ for key in general_helper.legacy_keys:
+ assert key not in themerr_json_data
diff --git a/tests/unit/test_lizardbyte_db_helper.py b/tests/unit/test_lizardbyte_db_helper.py
new file mode 100644
index 00000000..dd59e11a
--- /dev/null
+++ b/tests/unit/test_lizardbyte_db_helper.py
@@ -0,0 +1,44 @@
+# -*- coding: utf-8 -*-
+# local imports
+from Code import lizardbyte_db_helper
+
+
+def test_get_igdb_id_from_collection():
+ tests = [
+ {
+ 'search_query': 'James Bond',
+ 'collection_type': 'game_collections',
+ 'expected_type': 'game_collections',
+ 'expected_id': 326,
+ },
+ {
+ 'search_query': 'James Bond',
+ 'collection_type': 'game_franchises',
+ 'expected_type': 'game_franchises',
+ 'expected_id': 37,
+ },
+ {
+ 'search_query': 'James Bond',
+ 'collection_type': None,
+ 'expected_type': 'game_collections',
+ 'expected_id': 326,
+ },
+ ]
+
+ for test in tests:
+ igdb_id = lizardbyte_db_helper.get_igdb_id_from_collection(
+ search_query=test['search_query'],
+ collection_type=test['collection_type']
+ )
+ assert igdb_id == (test['expected_id'], test['expected_type'])
+
+
+def test_get_igdb_id_from_collection_invalid():
+ test = lizardbyte_db_helper.get_igdb_id_from_collection(search_query='Not a real collection')
+ assert test is None
+
+ invalid_collection_type = lizardbyte_db_helper.get_igdb_id_from_collection(
+ search_query='James Bond',
+ collection_type='invalid',
+ )
+ assert invalid_collection_type is None
diff --git a/tests/unit/test_tmdb_helper.py b/tests/unit/test_tmdb_helper.py
new file mode 100644
index 00000000..0fc0367c
--- /dev/null
+++ b/tests/unit/test_tmdb_helper.py
@@ -0,0 +1,41 @@
+# -*- coding: utf-8 -*-
+# lib imports
+import plexhints
+
+# local imports
+from Code import tmdb_helper
+
+
+def test_get_tmdb_id_from_imdb_id():
+ print(plexhints.CONTENTS)
+ print(plexhints.ELEVATED_POLICY)
+ tests = [
+ 'tt1254207'
+ ]
+
+ for test in tests:
+ tmdb_id = tmdb_helper.get_tmdb_id_from_imdb_id(imdb_id=test)
+ assert tmdb_id, "No tmdb_id found for {}".format(test)
+ assert isinstance(tmdb_id, int), "tmdb_id is not an int: {}".format(tmdb_id)
+
+
+def test_get_tmdb_id_from_imdb_id_invalid():
+ test = tmdb_helper.get_tmdb_id_from_imdb_id(imdb_id='invalid')
+ assert test is None, "tmdb_id found for invalid imdb_id: {}".format(test)
+
+
+def test_get_tmdb_id_from_collection():
+ tests = [
+ 'James Bond',
+ 'James Bond Collection',
+ ]
+
+ for test in tests:
+ tmdb_id = tmdb_helper.get_tmdb_id_from_collection(search_query=test)
+ assert tmdb_id, "No tmdb_id found for {}".format(test)
+ assert isinstance(tmdb_id, int), "tmdb_id is not an int: {}".format(tmdb_id)
+
+
+def test_get_tmdb_id_from_collection_invalid():
+ test = tmdb_helper.get_tmdb_id_from_collection(search_query='Not a real collection')
+ assert test is None, "tmdb_id found for invalid collection: {}".format(test)
diff --git a/tests/unit/test_youtube_dl_helper.py b/tests/unit/test_youtube_dl_helper.py
index 15a9a68b..35bfbb42 100644
--- a/tests/unit/test_youtube_dl_helper.py
+++ b/tests/unit/test_youtube_dl_helper.py
@@ -1,3 +1,4 @@
+# -*- coding: utf-8 -*-
# lib imports
import pytest
from youtube_dl import DownloadError
@@ -10,12 +11,15 @@ def test_process_youtube():
# test valid urls
valid_urls = [
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
+ 'https://www.youtube.com/watch?v=Wb8j8Ojd4YQ&list=PLMYr5_xSeuXAbhxYHz86hA1eCDugoxXY0&pp=iAQB', # playlist test
]
for url in valid_urls:
audio_url = youtube_dl_helper.process_youtube(url=url)
assert audio_url is not None
assert audio_url.startswith('https://')
+
+def test_process_youtube_invalid():
# test invalid urls
invalid_urls = [
'https://www.youtube.com/watch?v=notavideoid',