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',