diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 01cebce1..6401901e 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -72,29 +72,8 @@ jobs: 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 +82,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 +93,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 +109,130 @@ 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: Debug after extract + shell: bash + run: | + ls -a ./Contents + + - 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: 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 + + 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 + + # plex home directory + echo "Plex home directory: ${PLEX_HOME}" + + # stop service + sudo systemctl stop plexmediaserver + + # start server in the background + "/usr/lib/plexmediaserver/Plex Media Server" & + 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 + uses: nick-fields/retry@v2.8.3 + with: + max_attempts: 3 + timeout_minutes: 3 + shell: bash + command: | + #if [[ "${{ matrix.os }}" == "ubuntu-latest" ]]; then + # python \ + # -u scripts/plex-bootstraptest.py \ + # --destination plex \ + # --advertise-ip 127.0.0.1 \ + # --bootstrap-timeout 540 \ + # --docker-tag latest \ + # --without-shows \ + # --without-music \ + # --without-photos \ + # --unclaimed + #else + 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 + #fi + # on_retry_command: | + # docker rm -f $(docker ps --latest) + + - name: Test with pytest + shell: bash # our Python 2.7 setup action doesn't support PowerShell + run: | + python -m pytest -v 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/requirements-dev.txt b/requirements-dev.txt index 3820f502..a4ceb482 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -3,7 +3,9 @@ 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.1 pytest==4.6.11;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/scripts/plex-bootstraptest.py b/scripts/plex-bootstraptest.py new file mode 100644 index 00000000..23de2074 --- /dev/null +++ b/scripts/plex-bootstraptest.py @@ -0,0 +1,698 @@ +#!/usr/bin/env python3 +# -*- 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 + +""" +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 +from subprocess import call +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", + 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) + + # 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 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 = 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..30c12afd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,13 @@ # standard imports +from functools import partial import os +import platform import sys # lib imports from plexhints.agent_kit import Agent import pytest +import requests # add Contents directory to the system path if os.path.isdir('Contents'): @@ -16,7 +19,63 @@ else: raise Exception('Contents directory not found') +# plexapi must be imported after anything from Code is imported due to issue with aliases +import plexapi +from plexapi.exceptions import NotFound +from plexapi.myplex import MyPlexAccount +from plexapi.server import PlexServer +# 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__))) + +APP_DATA_PATH = dict( + Darwin="~/Library/Application Support/Plex Media Server", + Linux="/var/lib/plexmediaserver/Library/Application Support/Plex Media Server", + Windows="{}\\Plex Media Server".format(os.getenv('LOCALAPPDATA')) +) +PLATFORM = platform.system() +BOOTSTRAP_DATA_PATH = APP_DATA_PATH[PLATFORM] + + +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" + ) + + +# basic fixtures @pytest.fixture def agent(): # type: () -> Agent @@ -36,3 +95,73 @@ 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 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): + return plex.library.section("Movies-new-agent") + + +@pytest.fixture() +def movies_imdb_agent(plex): + return plex.library.section("Movies-imdb-agent") + + +@pytest.fixture() +def movies_themoviedb_agent(plex): + return plex.library.section("Movies-themoviedb-agent") + + +@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_plex_plugin.py b/tests/functional/test_plex_plugin.py new file mode 100644 index 00000000..b178c46f --- /dev/null +++ b/tests/functional/test_plex_plugin.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +# standard imports +import os +import time + +# local imports +from .. import conftest as utils +from Code import constants + + +def test_plugin_logs(): + plugin_log_file = os.path.join( + utils.BOOTSTRAP_DATA_PATH, "Logs", "PMS Plugin Logs", "{}.log".format(constants.plugin_identifier)) + assert os.path.isfile(plugin_log_file), "Plugin log file not found: {}".format(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):'), ( + "Unexpected exception: {}".format(exception)) + + +def test_movies_new_agent(movies_new_agent): + while movies_new_agent.refreshing: + time.sleep(1) + for item in movies_new_agent.all(): + print(item.title) + assert item.theme, "No theme found for {}".format(item.title) + + +def test_movies_imdb_agent(movies_imdb_agent): + while movies_imdb_agent.refreshing: + time.sleep(1) + for item in movies_imdb_agent.all(): + print(item.title) + assert item.theme, "No theme found for {}".format(item.title) + + +def test_movies_themoviedb_agent(movies_themoviedb_agent): + while movies_themoviedb_agent.refreshing: + time.sleep(1) + for item in movies_themoviedb_agent.all(): + print(item.title) + assert item.theme, "No theme found for {}".format(item.title) diff --git a/tests/unit/test_code.py b/tests/unit/test_code.py index c5f51abd..104ad5d8 100644 --- a/tests/unit/test_code.py +++ b/tests/unit/test_code.py @@ -1,9 +1,11 @@ # 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 +36,13 @@ ) +def test_copy_prefs(): + Code.copy_prefs() + + for key in default_prefs: + assert Code.last_prefs[key] == Prefs[key] + + def test_validate_prefs(): result_container = ValidatePrefs() assert isinstance(result_container, MessageContainer) @@ -66,7 +75,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 +102,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():