diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 88c8339c..6eb0cda2 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -10,7 +10,6 @@ updates: schedule: interval: "daily" time: "08:00" - target-branch: "nightly" open-pull-requests-limit: 10 - package-ecosystem: "github-actions" @@ -18,7 +17,6 @@ updates: schedule: interval: "daily" time: "08:30" - target-branch: "nightly" open-pull-requests-limit: 10 - package-ecosystem: "npm" @@ -26,7 +24,6 @@ updates: schedule: interval: "daily" time: "09:00" - target-branch: "nightly" open-pull-requests-limit: 10 - package-ecosystem: "nuget" @@ -34,7 +31,6 @@ updates: schedule: interval: "daily" time: "09:30" - target-branch: "nightly" open-pull-requests-limit: 10 - package-ecosystem: "pip" @@ -42,7 +38,6 @@ updates: schedule: interval: "daily" time: "10:00" - target-branch: "nightly" open-pull-requests-limit: 10 - package-ecosystem: "gitsubmodule" @@ -50,5 +45,4 @@ updates: schedule: interval: "daily" time: "10:30" - target-branch: "nightly" open-pull-requests-limit: 10 diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 69be6381..469806e0 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Verify Changelog id: verify_changelog @@ -39,14 +39,17 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: path: Themerr-plex.bundle - - name: Install Python 2.7 - uses: LizardByte/.github/actions/setup_python2@nightly + - name: Set up Python + uses: LizardByte/setup-python-action@v2023.1128.10441 + with: + python-version: '2.7' - - name: Set up Python 2.7 Dependencies + - name: Set up Python Dependencies + shell: bash working-directory: Themerr-plex.bundle run: | echo "Installing Requirements" @@ -59,40 +62,30 @@ jobs: python -m pip install --upgrade --target=./Contents/Libraries/Shared -r \ requirements.txt --no-warn-script-location + - name: Patch python deps + shell: bash + working-directory: Themerr-plex.bundle/Contents/Libraries/Shared + run: | + patch_dir=${{ github.workspace }}/Themerr-plex.bundle/patches + patch -p1 < "${patch_dir}/youtube_dl-compat.patch" + patch -p1 < "${patch_dir}/youtube_dl-extractor.patch" + + - name: Install npm packages + working-directory: Themerr-plex.bundle + run: | + npm install + 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: Test Plex Plugin - working-directory: Themerr-plex.bundle - run: | - python ./Contents/Code/__init__.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 - - name: Package Release shell: bash - if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }} run: | 7z \ "-xr!*.git*" \ @@ -101,9 +94,12 @@ jobs: "-xr!plexhints*" \ "-xr!Themerr-plex.bundle/.*" \ "-xr!Themerr-plex.bundle/cache.sqlite" \ + "-xr!Themerr-plex.bundle/codecov.yml" \ + "-xr!Themerr-plex.bundle/crowdin.yml" \ "-xr!Themerr-plex.bundle/DOCKER_README.md" \ "-xr!Themerr-plex.bundle/Dockerfile" \ "-xr!Themerr-plex.bundle/docs" \ + "-xr!Themerr-plex.bundle/patches" \ "-xr!Themerr-plex.bundle/scripts" \ "-xr!Themerr-plex.bundle/tests" \ a "./Themerr-plex.bundle.zip" "Themerr-plex.bundle" @@ -111,6 +107,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 @@ -119,3 +123,97 @@ 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 }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - 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/setup-python-action@v2023.1128.10441 + with: + python-version: '2.7' + + - name: Bootstrap Plex server + env: + PLEXAPI_PLEXAPI_TIMEOUT: "60" + id: bootstrap + uses: LizardByte/plexhints@v0.1.3 + with: + additional_server_queries_put: >- + /system/agents/com.plexapp.agents.imdb/config/1?order=com.plexapp.agents.imdb%2Cdev.lizardbyte.themerr-plex + /system/agents/com.plexapp.agents.themoviedb/config/1?order=com.plexapp.agents.themoviedb%2Cdev.lizardbyte.themerr-plex + plugin_bundles_to_install: >- + Themerr-plex.bundle + without_shows: true + without_music: true + without_photos: true + + - name: Install python dependencies + shell: bash + run: | + python -m pip --no-python-version-warning --disable-pip-version-check install --upgrade \ + pip setuptools wheel + python -m pip --no-python-version-warning --disable-pip-version-check install -r requirements-dev.txt + + - name: Test with pytest + env: + PLEX_PLUGIN_LOG_PATH: ${{ steps.bootstrap.outputs.PLEX_PLUGIN_LOG_PATH }} + PLEXAPI_AUTH_SERVER_BASEURL: ${{ steps.bootstrap.outputs.PLEX_SERVER_BASEURL }} + PLEXAPI_AUTH_SERVER_TOKEN: ${{ steps.bootstrap.outputs.PLEXTOKEN }} + PLEXAPI_PLEXAPI_TIMEOUT: "60" + PLEXTOKEN: ${{ steps.bootstrap.outputs.PLEXTOKEN }} + id: test + shell: bash + run: | + python -m pytest \ + -rxXs \ + --maxfail=1 \ + --tb=native \ + --verbose \ + --cov=Contents/Code \ + tests + + - name: Debug log file + if: always() + shell: bash + run: | + echo "Debugging log file" + if [[ "${{ runner.os }}" == "Windows" ]]; then + log_file=$(cygpath.exe -u \ + "${{ steps.bootstrap.outputs.PLEX_PLUGIN_LOG_PATH }}/dev.lizardbyte.themerr-plex.log") + else + log_file="${{ steps.bootstrap.outputs.PLEX_PLUGIN_LOG_PATH }}/dev.lizardbyte.themerr-plex.log" + fi + cat "${log_file}" + + - name: Upload coverage + # any except canceled or skipped + if: always() && (steps.test.outcome == 'success' || steps.test.outcome == 'failure') + uses: codecov/codecov-action@v3 + with: + flags: ${{ runner.os }} diff --git a/.github/workflows/auto-create-pr.yml b/.github/workflows/auto-create-pr.yml index 811747c6..13705dd5 100644 --- a/.github/workflows/auto-create-pr.yml +++ b/.github/workflows/auto-create-pr.yml @@ -20,7 +20,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Create Pull Request uses: repo-sync/pull-request@v2 diff --git a/.github/workflows/automerge.yml b/.github/workflows/automerge.yml index 49ddebf4..733b4de8 100644 --- a/.github/workflows/automerge.yml +++ b/.github/workflows/automerge.yml @@ -31,7 +31,7 @@ jobs: github-token: "${{ secrets.GITHUB_TOKEN }}" - name: Label autoapproved - uses: actions/github-script@v6 + uses: actions/github-script@v7 with: github-token: ${{ secrets.GH_BOT_TOKEN }} script: | diff --git a/.github/workflows/autoupdate-labeler.yml b/.github/workflows/autoupdate-labeler.yml deleted file mode 100644 index 974c9fa7..00000000 --- a/.github/workflows/autoupdate-labeler.yml +++ /dev/null @@ -1,72 +0,0 @@ ---- -# This action is centrally managed in https://github.com//.github/ -# Don't make changes to this file in this repo as they will be overwritten with changes made to the same file in -# the above-mentioned repo. - -# Label PRs with `autoupdate` if various conditions are met, otherwise, remove the label. - -name: Label PR autoupdate - -on: - pull_request_target: - types: - - edited - - opened - - reopened - - synchronize - -jobs: - label_pr: - if: >- - startsWith(github.repository, 'LizardByte/') && - contains(github.event.pull_request.body, fromJSON('"] I want maintainers to keep my branch updated"')) - runs-on: ubuntu-latest - env: - GH_TOKEN: ${{ github.token }} - steps: - - name: Check if member - id: org_member - run: | - status="true" - gh api \ - -H "Accept: application/vnd.github+json" \ - /orgs/${{ github.repository_owner }}/members/${{ github.actor }} || status="false" - - echo "result=${status}" >> $GITHUB_OUTPUT - - - name: Label autoupdate - if: >- - steps.org_member.outputs.result == 'true' && - contains(github.event.pull_request.labels.*.name, 'autoupdate') == false && - contains(github.event.pull_request.body, - fromJSON('"\n- [x] I want maintainers to keep my branch updated"')) == true - uses: actions/github-script@v6 - with: - github-token: ${{ secrets.GH_BOT_TOKEN }} - script: | - github.rest.issues.addLabels({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - labels: ['autoupdate'] - }) - - - name: Unlabel autoupdate - if: >- - contains(github.event.pull_request.labels.*.name, 'autoupdate') && - ( - (github.event.action == 'synchronize' && steps.org_member.outputs.result == 'false') || - (contains(github.event.pull_request.body, - fromJSON('"\n- [x] I want maintainers to keep my branch updated"')) == false - ) - ) - uses: actions/github-script@v6 - with: - github-token: ${{ secrets.GH_BOT_TOKEN }} - script: | - github.rest.issues.removeLabel({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - name: ['autoupdate'] - }) diff --git a/.github/workflows/autoupdate.yml b/.github/workflows/autoupdate.yml deleted file mode 100644 index 83f4e161..00000000 --- a/.github/workflows/autoupdate.yml +++ /dev/null @@ -1,51 +0,0 @@ ---- -# This action is centrally managed in https://github.com//.github/ -# Don't make changes to this file in this repo as they will be overwritten with changes made to the same file in -# the above-mentioned repo. - -# This workflow is designed to work with the following workflows: -# - automerge -# - autoupdate-labeler - -# It uses an action that auto-updates pull requests branches, when changes are pushed to their destination branch. -# Auto-updating to the latest destination branch works only in the context of upstream repo and not forks. -# Dependabot PRs are updated by an action that comments `@depdenabot rebase` on dependabot PRs. (disabled) - -name: autoupdate - -on: - push: - branches: - - 'nightly' - -jobs: - autoupdate: - name: Autoupdate autoapproved PR created in the upstream - if: startsWith(github.repository, 'LizardByte/') - runs-on: ubuntu-latest - steps: - - name: Update - uses: docker://chinthakagodawita/autoupdate-action:v1 - env: - EXCLUDED_LABELS: "central_dependency,dependencies" - GITHUB_TOKEN: '${{ secrets.GH_BOT_TOKEN }}' - PR_FILTER: "labelled" - PR_LABELS: "autoupdate" - PR_READY_STATE: "all" - MERGE_CONFLICT_ACTION: "fail" - -# Disabled due to: -# - no major version tag, resulting in constant nagging to update this action -# - additionally, the code is sketchy, 16k+ lines of code? -# https://github.com/bbeesley/gha-auto-dependabot-rebase/blob/main/dist/main.cjs -# -# dependabot-rebase: -# name: Dependabot Rebase -# if: >- -# startsWith(github.repository, 'LizardByte/') -# runs-on: ubuntu-latest -# steps: -# - name: rebase -# uses: "bbeesley/gha-auto-dependabot-rebase@v1.3.18" -# env: -# GITHUB_TOKEN: ${{ secrets.GH_BOT_TOKEN }} diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index acb883af..edeeb2bd 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -38,7 +38,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Find dockerfiles id: find @@ -86,7 +86,7 @@ jobs: steps: - name: Checkout if: ${{ github.ref == 'refs/heads/master' || github.base_ref == 'master' }} - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Verify Changelog id: verify_changelog @@ -162,7 +162,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Hadolint id: hadolint @@ -193,7 +193,7 @@ jobs: steps: - name: Maximize build space - uses: easimon/maximize-build-space@v7 + uses: easimon/maximize-build-space@v8 with: root-reserve-mb: 30720 # https://github.com/easimon/maximize-build-space#caveats remove-dotnet: 'true' @@ -203,7 +203,7 @@ jobs: remove-docker-images: 'true' - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: submodules: recursive @@ -309,10 +309,10 @@ jobs: echo "tags=${TAGS}" >> $GITHUB_OUTPUT - name: Set Up QEMU - uses: docker/setup-qemu-action@v2 + uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 id: buildx - name: Cache Docker Layers @@ -325,14 +325,14 @@ jobs: - name: Log in to Docker Hub if: ${{ steps.prepare.outputs.push == 'true' }} # PRs do not have access to secrets - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Log in to the Container registry if: ${{ steps.prepare.outputs.push == 'true' }} # PRs do not have access to secrets - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ secrets.GH_BOT_NAME }} @@ -341,7 +341,7 @@ jobs: - name: Build artifacts if: ${{ steps.prepare.outputs.artifacts == 'true' }} id: build_artifacts - uses: docker/build-push-action@v4 + uses: docker/build-push-action@v5 with: context: ./ file: ${{ matrix.dockerfile }} @@ -363,7 +363,7 @@ jobs: - name: Build and push id: build - uses: docker/build-push-action@v4 + uses: docker/build-push-action@v5 with: context: ./ file: ${{ matrix.dockerfile }} diff --git a/.github/workflows/ci-qodana.yml b/.github/workflows/ci-qodana.yml index 5763df11..efc56349 100644 --- a/.github/workflows/ci-qodana.yml +++ b/.github/workflows/ci-qodana.yml @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Prepare id: prepare @@ -165,7 +165,7 @@ jobs: continue-on-error: true steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: submodules: recursive @@ -214,7 +214,7 @@ jobs: - name: Qodana id: qodana continue-on-error: true # ensure dispatch-qodana job is run - uses: JetBrains/qodana-action@v2023.2.1 + uses: JetBrains/qodana-action@v2023.2.6 with: additional-cache-hash: ${{ github.ref }}-${{ matrix.language }} artifact-name: qodana-${{ matrix.language }} # yamllint disable-line rule:line-length diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..358ff9c9 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,147 @@ +--- +# This action is centrally managed in https://github.com//.github/ +# Don't make changes to this file in this repo as they will be overwritten with changes made to the same file in +# the above-mentioned repo. + +# This workflow will analyze all supported languages in the repository using CodeQL Analysis. + +name: "CodeQL" + +on: + push: + branches: ["master", "nightly"] + pull_request: + branches: ["master", "nightly"] + schedule: + - cron: '00 12 * * 0' # every Sunday at 12:00 UTC + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + languages: + name: Get language matrix + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.lang.outputs.result }} + continue: ${{ steps.continue.outputs.result }} + steps: + - name: Get repo languages + uses: actions/github-script@v7 + id: lang + with: + script: | + // CodeQL supports ['cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby', 'swift'] + // Use only 'java' to analyze code written in Java, Kotlin or both + // Use only 'javascript' to analyze code written in JavaScript, TypeScript or both + // Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support + const supported_languages = ['cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby', 'swift'] + + const remap_languages = { + 'c++': 'cpp', + 'c#': 'csharp', + 'kotlin': 'java', + 'typescript': 'javascript', + } + + const repo = context.repo + const response = await github.rest.repos.listLanguages(repo) + let matrix = { + "include": [] + } + + for (let [key, value] of Object.entries(response.data)) { + // remap language + if (remap_languages[key.toLowerCase()]) { + console.log(`Remapping language: ${key} to ${remap_languages[key.toLowerCase()]}`) + key = remap_languages[key.toLowerCase()] + } + if (supported_languages.includes(key.toLowerCase()) && + !matrix['include'].includes({"language": key.toLowerCase()})) { + console.log(`Found supported language: ${key}`) + matrix['include'].push({"language": key.toLowerCase()}) + } + } + + // print languages + console.log(`matrix: ${JSON.stringify(matrix)}`) + + return matrix + + - name: Continue + uses: actions/github-script@v7 + id: continue + with: + script: | + // if matrix['include'] is an empty list return false, otherwise true + const matrix = ${{ steps.lang.outputs.result }} // this is already json encoded + + if (matrix['include'].length == 0) { + return false + } else { + return true + } + + analyze: + name: Analyze + if: ${{ needs.languages.outputs.continue == 'true' }} + needs: [languages] + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.languages.outputs.matrix) }} + + steps: + - name: Maximize build space + uses: easimon/maximize-build-space@v8 + with: + root-reserve-mb: 20480 + remove-dotnet: ${{ (matrix.language == 'csharp' && 'false') || 'true' }} + remove-android: 'true' + remove-haskell: 'true' + remove-codeql: 'false' + remove-docker-images: 'true' + + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: recursive + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # yamllint disable-line rule:line-length + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + # Pre autobuild + # create a file named .codeql-prebuild-${{ matrix.language }}.sh in the root of your repository + - name: Prebuild + run: | + # check if .qodeql-prebuild-${{ matrix.language }}.sh exists + if [ -f "./.codeql-prebuild-${{ matrix.language }}.sh" ]; then + echo "Running .codeql-prebuild-${{ matrix.language }}.sh" + ./.codeql-prebuild-${{ matrix.language }}.sh + fi + + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). + - name: Autobuild + uses: github/codeql-action/autobuild@v2 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/issues-stale.yml b/.github/workflows/issues-stale.yml index c168034e..aecc8243 100644 --- a/.github/workflows/issues-stale.yml +++ b/.github/workflows/issues-stale.yml @@ -31,12 +31,15 @@ jobs: exempt-pr-labels: 'dependencies,l10n' stale-issue-label: 'stale' stale-issue-message: > - This issue is stale because it has been open for 90 days with no activity. - Comment or remove the stale label, otherwise this will be closed in 10 days. + It seems this issue hasn't had any activity in the past 90 days. + If it's still something you'd like addressed, please let us know by leaving a comment. + Otherwise, to help keep our backlog tidy, we'll be closing this issue in 10 days. Thanks! stale-pr-label: 'stale' stale-pr-message: > - This PR is stale because it has been open for 90 days with no activity. - Comment or remove the stale label, otherwise this will be closed in 10 days. + It looks like this PR has been idle for 90 days. + If it's still something you're working on or would like to pursue, + please leave a comment or update your branch. + Otherwise, we'll be closing this PR in 10 days to reduce our backlog. Thanks! repo-token: ${{ secrets.GH_BOT_TOKEN }} - name: Invalid Template @@ -48,7 +51,6 @@ jobs: This PR was closed because the the template was not completed after 5 days. days-before-stale: 0 days-before-close: 5 - exempt-pr-labels: 'dependencies,l10n' only-labels: 'invalid:template-incomplete' stale-issue-label: 'invalid:template-incomplete' stale-issue-message: > diff --git a/.github/workflows/issues.yml b/.github/workflows/issues.yml index d7a1025c..aec6006c 100644 --- a/.github/workflows/issues.yml +++ b/.github/workflows/issues.yml @@ -20,6 +20,6 @@ jobs: runs-on: ubuntu-latest steps: - name: Label Actions - uses: dessant/label-actions@v3 + uses: dessant/label-actions@v4 with: github-token: ${{ secrets.GH_BOT_TOKEN }} diff --git a/.github/workflows/localize.yml b/.github/workflows/localize.yml new file mode 100644 index 00000000..c977f3b1 --- /dev/null +++ b/.github/workflows/localize.yml @@ -0,0 +1,77 @@ +--- +name: localize + +on: + push: + branches: [nightly] + paths: # prevents workflow from running unless these files change + - '.github/workflows/localize.yml' + - 'Contents/Strings/Themerr-plex.po' + - 'Contents/Code/**.py' + - 'Contents/Resources/web/templates/**' + workflow_dispatch: + +jobs: + localize: + name: Update Localization + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: LizardByte/action-setup-python@master + with: + python-version: '2.7' + + - name: Set up Python Dependencies + run: | + python -m pip install --upgrade pip setuptools requests + python -m pip install -r requirements.txt # requests is required to install python-plexapi + + - name: Update Strings + run: | + python ./scripts/_locale.py --extract + + - name: git diff + run: | + # disable the pager + git config --global pager.diff false + + # print the git diff + git diff Contents/Strings/themerr-plex.po + + # set the variable with minimal output, replacing `\t` with ` ` + OUTPUT=$(git diff --numstat Contents/Strings/themerr-plex.po | sed -e "s#\t# #g") + echo "git_diff=${OUTPUT}" >> $GITHUB_ENV + + - name: git reset + if: ${{ env.git_diff == '1 1 Contents/Strings/themerr-plex.po' }} # only run if more than 1 line changed + run: | + git reset --hard + + - name: Get current date + id: date + run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT + + - name: Create/Update Pull Request + uses: peter-evans/create-pull-request@v5 + with: + add-paths: | + Contents/Strings/*.po + token: ${{ secrets.GH_BOT_TOKEN }} # must trigger PR tests + commit-message: New localization template + branch: localize/update + delete-branch: true + base: nightly + title: New Babel Updates + body: | + Update report + - Updated ${{ steps.date.outputs.date }} + - Auto-generated by [create-pull-request][1] + + [1]: https://github.com/peter-evans/create-pull-request + labels: | + babel + l10n diff --git a/.github/workflows/pull-requests.yml b/.github/workflows/pull-requests.yml deleted file mode 100644 index 58243872..00000000 --- a/.github/workflows/pull-requests.yml +++ /dev/null @@ -1,32 +0,0 @@ ---- -# This action is centrally managed in https://github.com//.github/ -# Don't make changes to this file in this repo as they will be overwritten with changes made to the same file in -# the above-mentioned repo. - -# Ensure PRs are made against `nightly` branch. - -name: Pull Requests - -on: - pull_request_target: - types: [opened, synchronize, edited, reopened] - -# no concurrency for pull_request_target events - -jobs: - check-pull-request: - name: Check Pull Request - if: startsWith(github.repository, 'LizardByte/') - runs-on: ubuntu-latest - steps: - - uses: Vankka/pr-target-branch-action@v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - target: master - exclude: nightly # Don't prevent going from nightly -> master - change-to: nightly - comment: | - Your PR was set to `master`, PRs should be sent to `nightly`. - The base branch of this PR has been automatically changed to `nightly`. - Please check that there are no merge conflicts diff --git a/.github/workflows/python-flake8.yml b/.github/workflows/python-flake8.yml index 19bcdb9d..4b0d3081 100644 --- a/.github/workflows/python-flake8.yml +++ b/.github/workflows/python-flake8.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v4 # https://github.com/actions/setup-python diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml deleted file mode 100644 index fcd1f0d3..00000000 --- a/.github/workflows/python-tests.yml +++ /dev/null @@ -1,36 +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: | - # requests is required to install python-plexapi - python -m pip --no-python-version-warning --disable-pip-version-check install --upgrade \ - pip setuptools requests - 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: 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/release-notifier.yml b/.github/workflows/release-notifier.yml index ed7b3ef2..2827224b 100644 --- a/.github/workflows/release-notifier.yml +++ b/.github/workflows/release-notifier.yml @@ -14,11 +14,14 @@ on: jobs: discord: - if: startsWith(github.repository, 'LizardByte/') + if: >- + startsWith(github.repository, 'LizardByte/') and + not(github.event.release.prerelease) and + not(github.event.release.draft) runs-on: ubuntu-latest steps: - name: discord - uses: sarisia/actions-status-discord@v1 # https://github.com/sarisia/actions-status-discord + uses: sarisia/actions-status-discord@v1 with: webhook: ${{ secrets.DISCORD_RELEASE_WEBHOOK }} nodetail: true @@ -30,11 +33,14 @@ jobs: color: 0xFF4500 facebook_group: - if: startsWith(github.repository, 'LizardByte/') + if: >- + startsWith(github.repository, 'LizardByte/') and + not(github.event.release.prerelease) and + not(github.event.release.draft) runs-on: ubuntu-latest steps: - name: facebook-post-action - uses: ReenigneArcher/facebook-post-action@v1 # https://github.com/ReenigneArcher/facebook-post-action + uses: ReenigneArcher/facebook-post-action@v1 with: page_id: ${{ secrets.FACEBOOK_GROUP_ID }} access_token: ${{ secrets.FACEBOOK_ACCESS_TOKEN }} @@ -44,11 +50,14 @@ jobs: url: ${{ github.event.release.html_url }} facebook_page: - if: startsWith(github.repository, 'LizardByte/') + if: >- + startsWith(github.repository, 'LizardByte/') and + not(github.event.release.prerelease) and + not(github.event.release.draft) runs-on: ubuntu-latest steps: - name: facebook-post-action - uses: ReenigneArcher/facebook-post-action@v1 # https://github.com/ReenigneArcher/facebook-post-action + uses: ReenigneArcher/facebook-post-action@v1 with: page_id: ${{ secrets.FACEBOOK_PAGE_ID }} access_token: ${{ secrets.FACEBOOK_ACCESS_TOKEN }} @@ -58,11 +67,14 @@ jobs: url: ${{ github.event.release.html_url }} reddit: - if: startsWith(github.repository, 'LizardByte/') + if: >- + startsWith(github.repository, 'LizardByte/') and + not(github.event.release.prerelease) and + not(github.event.release.draft) runs-on: ubuntu-latest steps: - name: reddit - uses: bluwy/release-for-reddit-action@v2 # https://github.com/bluwy/release-for-reddit-action + uses: bluwy/release-for-reddit-action@v2 with: username: ${{ secrets.REDDIT_USERNAME }} password: ${{ secrets.REDDIT_PASSWORD }} @@ -75,14 +87,17 @@ jobs: comment: ${{ github.event.release.body }} twitter: - if: startsWith(github.repository, 'LizardByte/') + if: >- + startsWith(github.repository, 'LizardByte/') and + not(github.event.release.prerelease) and + not(github.event.release.draft) runs-on: ubuntu-latest steps: - name: twitter - uses: ethomson/send-tweet-action@v1 # https://github.com/ethomson/send-tweet-action + uses: nearform-actions/github-action-notify-twitter@v1 with: - consumer-key: ${{ secrets.TWITTER_API_KEY }} - consumer-secret: ${{ secrets.TWITTER_API_SECRET }} - access-token: ${{ secrets.TWITTER_ACCESS_TOKEN }} - access-token-secret: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }} - status: ${{ github.event.release.html_url }} + message: ${{ github.event.release.html_url }} + twitter-app-key: ${{ secrets.TWITTER_API_KEY }} + twitter-app-secret: ${{ secrets.TWITTER_API_SECRET }} + twitter-access-token: ${{ secrets.TWITTER_ACCESS_TOKEN }} + twitter-access-token-secret: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }} diff --git a/.github/workflows/yaml-lint.yml b/.github/workflows/yaml-lint.yml index 6327d5d6..7e1fd469 100644 --- a/.github/workflows/yaml-lint.yml +++ b/.github/workflows/yaml-lint.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Find additional files id: find-files diff --git a/.gitignore b/.gitignore index da380237..0eaec38e 100644 --- a/.gitignore +++ b/.gitignore @@ -168,3 +168,7 @@ plexhints-temp # Remove python modules Contents/Libraries/Shared/ + +# npm +node_modules/ +package-lock.json diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 086d61fb..a05ea658 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -11,6 +11,12 @@ build: os: ubuntu-20.04 tools: python: "2.7" + jobs: + pre_build: + - python ./scripts/build_plist.py + post_build: + - rstcheck -r . # lint rst files + # - rstfmt --check --diff -w 120 . # check rst formatting # Build documentation in the docs/ directory with Sphinx sphinx: @@ -25,4 +31,3 @@ python: install: - requirements: requirements.txt # plugin requirements - requirements: requirements-dev.txt # docs requirements - system_packages: true diff --git a/.rstcheck.cfg b/.rstcheck.cfg new file mode 100644 index 00000000..1f97fedd --- /dev/null +++ b/.rstcheck.cfg @@ -0,0 +1,11 @@ +# configuration file for rstcheck, an rst linting tool +# https://rstcheck.readthedocs.io/en/latest/usage/config + +[rstcheck] +ignore_directives = + automodule, + include, + mdinclude, + todo, +ignore_roles = + modname, diff --git a/CHANGELOG.md b/CHANGELOG.md index 77ef5405..34a48fd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,47 @@ # Changelog +## [0.3.0] - 2023-11-29 +**Added** +- Option to enable/disable support for Plex Movie agent - (enabled by default) +- Option to update themes on a schedule - (enabled by default) +- Option to download themes for collections - (enabled by default) +- Option to update collection metadata (art, poster, and summary) - + (enabled by default for legacy agents, disabled for Plex Movie agent) +- Options to remove unused media (themes, art, posters) on update - + (enabled by default for themes, disabled for art and posters) +- Themerr icon +- Version is now printed to the log on startup +- Version is now displayed in the Plex plugin menu +- Web UI which shows the completion percentage of theme songs in the Plex libraries +- Option to add YouTube cookies to workaround EU consent issue + +**Fixed** +- Themerr-plex will now skip upload of media if the existing media is the same +- Themerr-plex is now categorized as a Utility plugin instead of Music +- Refactored code to use common methods where possible +- Use TMDB api to convert IMDB ids to TMDB ids +- Fix AlertListener on IPv6-aware hosts +- Fix error handling around update_plex_item to prevent plugin hanging +- youtube-dl messages are now logged to Themerr-plex plugin log +- Disable plexapi auto-reload +- Use correct types for plex item typehints +- Ensure themes added by Themerr-plex are unlocked +- Don't update metadata/fields which are locked +- Disable restricted python in Plex plugin framework +- Remove unused YouTube parameters + +**Dependencies** +- Bump peter-evans/create-pull-request from 4 to 5 +- Bump actions/checkout from 3 to 4 +- Use plexapi-backport and bump to 4.15.6 +- Use plexhints from pypi and bump to 0.1.3 +- Bump youtube-dl to 00ef748 + +**Misc** +- Update LizardByte workflows +- Improve CI/CD testing +- Add CodeQL analysis + ## [0.2.0] - 2023-07-31 **Added** - Add option to prefer MPEG AAC audio codec over Opus diff --git a/Contents/Code/__init__.py b/Contents/Code/__init__.py index 0ec9426d..6ad2ce87 100644 --- a/Contents/Code/__init__.py +++ b/Contents/Code/__init__.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- # standard imports +import inspect import re -import sys # plex debugging try: @@ -20,23 +20,57 @@ from plexhints.log_kit import Log # log kit from plexhints.model_kit import Movie # model kit from plexhints.object_kit import MessageContainer, MetadataSearchResult, SearchResult # object kit - from plexhints.parse_kit import JSON # parse kit from plexhints.prefs_kit import Prefs # prefs kit # imports from Libraries\Shared from typing import Optional +try: + # get the original Python builtins module + python_builtins = inspect.getmodule(object) + + # get the Sandbox instance + sandbox = inspect.stack()[1][0].f_locals["self"] + + # bypass RestrictedPython + getattr(sandbox, "_core").loader.compile = lambda src, name, _=False: python_builtins.compile(src, name, "exec") + + # restore Python builtins + sandbox.environment.update(python_builtins.vars(python_builtins)) +except Exception as e: + Log.Exception("Failed to bypass RestrictedPython: {}".format(e)) + # local imports -if sys.version_info.major < 3: - from default_prefs import default_prefs - from helpers import issue_url_games, issue_url_movies - from plex_api_helper import add_themes, get_plex_item, plex_listener - from youtube_dl_helper import process_youtube -else: - from .default_prefs import default_prefs - from .helpers import issue_url_games, issue_url_movies - from .plex_api_helper import add_themes, get_plex_item, plex_listener - from .youtube_dl_helper import process_youtube +from default_prefs import default_prefs +from constants import contributes_to, version +from plex_api_helper import plex_listener, start_queue_threads, update_plex_item +from scheduled_tasks import setup_scheduling +from webapp import start_server + +# variables +last_prefs = dict() + + +def copy_prefs(): + # type: () -> None + """ + Copy the current preferences to the last preferences. + + This function is used to copy the current preferences to the last preferences. This is useful to determine if the + preferences have changed. + + Examples + -------- + >>> copy_prefs() + """ + global last_prefs + last_prefs = dict() + + for key in default_prefs: + try: + last_prefs[key] = Prefs[key] + except KeyError: + pass # this was already logged def ValidatePrefs(): @@ -61,6 +95,8 @@ def ValidatePrefs(): >>> ValidatePrefs() ... """ + global last_prefs + # todo - validate username and password error_message = '' # start with a blank error message @@ -93,6 +129,22 @@ def ValidatePrefs(): Log.Error("Setting '%s' must be greater than 0; Value '%s'" % (key, Prefs[key])) error_message += "Setting '%s' must be greater than 0; Value '%s'
" % (key, Prefs[key]) + # restart webserver if required + requires_restart = [ + 'str_webapp_http_host', + 'int_webapp_http_port', + 'bool_webapp_log_werkzeug_messages' + ] + + if key in requires_restart: + try: + if last_prefs[key] != Prefs[key]: + Log.Info('Changing this setting ({}) requires a Plex Media Server restart.'.format(key)) + except KeyError: + pass + + copy_prefs() # since validate prefs runs on startup, this will have already run at least once + if error_message != '': return MessageContainer(header='Error', message=error_message) else: @@ -111,27 +163,38 @@ def Start(): `_ for more information. - First preferences are validated using the ``ValidatePrefs()`` method. Then the ``plex_api_helper.plex_listener()`` - method is started to handle updating theme songs for the new Plex Movie agent. + Preferences are validated, then additional threads are started for the web server, queue, plex listener, and + scheduled tasks. Examples -------- >>> Start() ... """ + Log.Info('Themerr-plex, version: {}'.format(version)) + # validate prefs prefs_valid = ValidatePrefs() if prefs_valid.header == 'Error': Log.Warn('Themerr-plex plug-in preferences are not valid.') - Log.Debug('Themerr-plex plug-in started.') + start_server() # start the web server + Log.Debug('web server started.') + + start_queue_threads() # start queue threads + Log.Debug('queue threads started.') + + if Prefs['bool_plex_movie_support']: + plex_listener() # start watching plex + Log.Debug('plex_listener started, watching for activity from new Plex Movie agent.') + + setup_scheduling() # start scheduled tasks + Log.Debug('scheduled tasks started.') - # start watching plex - plex_listener() - Log.Debug('plex_listener started, watching for activity from new Plex Movie agent.') + Log.Debug('plug-in started.') -@handler(prefix='/music/themerr-plex', name='Themerr-plex', thumb='attribution.png') +@handler(prefix='/applications/themerr-plex', name='Themerr-plex ({})'.format(version), thumb='icon-default.png') def main(): """ Create the main plug-in ``handler``. @@ -194,12 +257,7 @@ class Themerr(Agent.Movies): primary_provider = False fallback_agent = False accepts_from = [] - contributes_to = [ - 'com.plexapp.agents.imdb', - 'com.plexapp.agents.themoviedb', - # 'com.plexapp.agents.thetvdb', # not available as movie agent - 'dev.lizardbyte.retroarcher-plex' - ] + contributes_to = contributes_to @staticmethod def search(results, media, lang, manual): @@ -299,57 +357,10 @@ def update(metadata, media, lang, force): >>> Themerr().update(metadata=..., media=..., lang='en', force=True) ... """ - rating_key = int(media.id) # rating key of plex item - - Log.Debug('%s: Updating with arguments: {metadata=%s, media=%s, lang=%s, force=%s' % - (rating_key, metadata, media, lang, force)) + Log.Debug('Updating with arguments: {metadata=%s, media=%s, lang=%s, force=%s' % + (metadata, media, lang, force)) - Log.Info('%s: metadata.id: %s' % (rating_key, metadata.id)) - split_id = metadata.id.split('-') - item_type = split_id[0] - database = split_id[1] - database_id = split_id[2] - url = 'https://app.lizardbyte.dev/ThemerrDB/%s/%s/%s.json' % (item_type, database, database_id) - - try: - data = JSON.ObjectFromURL(url=url, errors='ignore') - except Exception as e: - Log.Error('%s: Error retrieving data from ThemerrDB: %s' % (rating_key, e)) - if database == 'themoviedb': # movies - # need to use python-plexapi to get the movie year - plex_item = get_plex_item(rating_key=rating_key) - - # create the url to add the theme song to ThemerrDB - try: - issue_title = '%s (%s)' % (plex_item.title, plex_item.year) - issue_url = issue_url_movies % (issue_title, database_id) - Log.Info('%s: Theme song missing in ThemerrDB. Add it here -> "%s"' % (rating_key, issue_url)) - except Exception as e: - Log.Error('%s: Error creating the url to add the theme song to ThemerrDB: %s' % (rating_key, e)) - elif database == 'igdb': # games - try: - game_data = JSON.ObjectFromURL(url='https://db.lizardbyte.dev/games/%s.json' % database_id) - except Exception as e: - Log.Error('%s: Error retrieving data from LizardByteDB: %s' % (rating_key, e)) - else: - try: - issue_year = game_data['release_date'][0]['y'] - except (KeyError, IndexError): - issue_year = None - issue_url_suffix = game_data['slug'] - issue_title = '%s (%s)' % (game_data['name'], issue_year) - issue_url = issue_url_games % (issue_title, issue_url_suffix) - Log.Info('%s: Theme song missing in ThemerrDB. Add it here -> %s' % (rating_key, issue_url)) - else: - try: - yt_video_url = data['youtube_theme_url'] - except KeyError: - Log.Info('%s: No theme song found for %s (%s)' % (rating_key, metadata.title, metadata.year)) - return - else: - theme_url = process_youtube(url=yt_video_url) - - if theme_url: - add_themes(rating_key=rating_key, theme_urls=[theme_url]) + rating_key = int(media.id) # rating key of plex item + update_plex_item(rating_key=rating_key) return metadata diff --git a/Contents/Code/constants.py b/Contents/Code/constants.py new file mode 100644 index 00000000..4ecd13a2 --- /dev/null +++ b/Contents/Code/constants.py @@ -0,0 +1,127 @@ +# -*- coding: utf-8 -*- + +# standard imports +import plistlib +import os + +# plex debugging +try: + import plexhints # noqa: F401 +except ImportError: + pass +else: # the code is running outside of Plex + from plexhints.core_kit import Core # core kit + +# get plugin directory from core kit +plugin_directory = Core.bundle_path +if plugin_directory.endswith('test.bundle'): + # use current directory instead, to allow for testing outside of Plex + if os.path.basename(os.getcwd()) == 'docs': + # use parent directory if current directory is 'docs' + plugin_directory = os.path.dirname(os.getcwd()) + else: + plugin_directory = os.getcwd() + +# get identifier and version from Info.plist file +info_file_path = os.path.join(plugin_directory, 'Contents', 'Info.plist') +try: + info_plist = plistlib.readPlist(pathOrFile=info_file_path) +except IOError: + info_plist = dict( + CFBundleIdentifier='dev.lizardbyte.themerr-plex', + PlexBundleVersion='0.0.0' + ) +plugin_identifier = info_plist['CFBundleIdentifier'] +version = info_plist['PlexBundleVersion'] + +app_support_directory = Core.app_support_path +metadata_base_directory = os.path.join(app_support_directory, 'Metadata') +plugin_support_directory = os.path.join(app_support_directory, 'Plug-in Support') +plugin_support_data_directory = os.path.join(plugin_support_directory, 'Data') +themerr_data_directory = os.path.join(plugin_support_data_directory, plugin_identifier, 'DataItems') + +contributes_to = [ + 'tv.plex.agents.movie', + 'com.plexapp.agents.imdb', + 'com.plexapp.agents.themoviedb', + # 'com.plexapp.agents.thetvdb', # not available as movie agent + 'dev.lizardbyte.retroarcher-plex' +] + +guid_map = dict( + imdb='imdb', + tmdb='themoviedb', + tvdb='thetvdb' +) + +metadata_type_map = dict( + album='Albums', + artist='Artists', + collection='Collections', + movie='Movies', + show='TV Shows' +) + +# issue url constants +base_url = 'https://github.com/LizardByte/ThemerrDB/issues/new?assignees=' +issue_label = 'request-theme' +issue_template = 'theme.yml' +url_name = 'database_url' +title_prefix = dict( + games='[GAME]: ', + game_collections='[GAME COLLECTION]: ', + game_franchises='[GAME FRANCHISE]: ', + movies='[MOVIE]: ', + movie_collections='[MOVIE COLLECTION]: ', +) +url_prefix = dict( + games='https://www.igdb.com/games/', + game_collections='https://www.igdb.com/collections/', + game_franchises='https://www.igdb.com/franchises/', + movies='https://www.themoviedb.org/movie/', + movie_collections='https://www.themoviedb.org/collection/', +) + +# two additional strings to fill in later, item title and item url +issue_urls = dict( + games='{}&labels={}&template={}&title={}{}&{}={}{}'.format( + base_url, issue_label, issue_template, title_prefix['games'], '{}', url_name, url_prefix['games'], '{}'), + game_collections='{}&labels={}&template={}&title={}{}&{}={}{}'.format( + base_url, issue_label, issue_template, title_prefix['game_collections'], '{}', url_name, + url_prefix['game_collections'], '{}'), + game_franchises='{}&labels={}&template={}&title={}{}&{}={}{}'.format( + base_url, issue_label, issue_template, title_prefix['game_franchises'], '{}', url_name, + url_prefix['game_franchises'], '{}'), + movies='{}&labels={}&template={}&title={}{}&{}={}{}'.format( + base_url, issue_label, issue_template, title_prefix['movies'], '{}', url_name, url_prefix['movies'], '{}'), + movie_collections='{}&labels={}&template={}&title={}{}&{}={}{}'.format( + base_url, issue_label, issue_template, title_prefix['movie_collections'], '{}', url_name, + url_prefix['movie_collections'], '{}'), +) + +media_type_dict = dict( + art=dict( + method=lambda item: item.uploadArt, + type='art', + name='art', + themerr_data_key='art_url', + remove_pref='bool_remove_unused_art', + plex_field='art', + ), + posters=dict( + method=lambda item: item.uploadPoster, + type='posters', + name='poster', + themerr_data_key='poster_url', + remove_pref='bool_remove_unused_posters', + plex_field='thumb', + ), + themes=dict( + method=lambda item: item.uploadTheme, + type='themes', + name='theme', + themerr_data_key='youtube_theme_url', + remove_pref='bool_remove_unused_theme_songs', + plex_field='theme', + ), +) diff --git a/Contents/Code/default_prefs.py b/Contents/Code/default_prefs.py index c3f01557..1a0bc426 100644 --- a/Contents/Code/default_prefs.py +++ b/Contents/Code/default_prefs.py @@ -1,8 +1,24 @@ +# -*- coding: utf-8 -*- + default_prefs = dict( + bool_plex_movie_support='True', bool_prefer_mp4a_codec='True', + bool_remove_unused_theme_songs='True', + bool_remove_unused_art='False', + bool_remove_unused_posters='False', + bool_auto_update_items='True', + bool_auto_update_movie_themes='True', + bool_auto_update_collection_themes='True', + bool_update_collection_metadata_plex_movie='False', + bool_update_collection_metadata_legacy='True', + int_update_themes_interval='60', + int_update_database_cache_interval='60', int_plexapi_plexapi_timeout='180', int_plexapi_upload_retries_max='3', int_plexapi_upload_threads='3', - str_youtube_user='', - str_youtube_passwd='' + str_youtube_cookies='', + enum_webapp_locale='en', + str_webapp_http_host='0.0.0.0', + int_webapp_http_port='9494', + bool_webapp_log_werkzeug_messages='False' ) diff --git a/Contents/Code/general_helper.py b/Contents/Code/general_helper.py new file mode 100644 index 00000000..615d5fd1 --- /dev/null +++ b/Contents/Code/general_helper.py @@ -0,0 +1,239 @@ +# -*- coding: utf-8 -*- + +# standard imports +import hashlib +import json +import os +import shutil + +# plex debugging +try: + import plexhints # noqa: F401 +except ImportError: + pass +else: # the code is running outside of Plex + from plexhints.core_kit import Core # core kit + from plexhints.log_kit import Log # log kit + from plexhints.prefs_kit import Prefs # prefs kit + + +# 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 + """ + Get the path to the theme upload directory. + + Get the hashed path of the theme upload directory for the item specified by the ``item``. + + Parameters + ---------- + item : any + The item to get the theme upload path for. + media_type : str + The media type to get the theme upload path for. Must be one of 'art', 'posters', or 'themes'. + + Returns + ------- + 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') + "...bundle/Uploads/art..." + >>> get_media_upload_path(item=..., media_type='posters') + "...bundle/Uploads/posters..." + >>> 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( + metadata_base_directory, metadata_type_map[item.type], + full_hash[0], full_hash[1:] + '.bundle', 'Uploads', media_type) + return theme_upload_path + + +def get_themerr_json_path(item): + # type: (any) -> str + """ + Get the path to the Themerr data file. + + Get the path to the Themerr data file for the item specified by the ``item``. + + Parameters + ---------- + item : any + The item to get the Themerr data file path for. + + Returns + ------- + str + The path to the Themerr data file. + + Examples + -------- + >>> get_themerr_json_path(item=...) + '.../Plex Media Server/Plug-in Support/Data/dev.lizardbyte.themerr-plex/DataItems/...' + """ + themerr_json_path = os.path.join(themerr_data_directory, metadata_type_map[item.type], + '{}.json'.format(item.ratingKey)) + return themerr_json_path + + +def get_themerr_json_data(item): + # type: (any) -> dict + """ + Get the Themerr data for the specified item. + + Themerr data is stored as a JSON file in the Themerr data directory, and is used to ensure that we don't + unnecessarily re-upload media to the Plex server. + + Parameters + ---------- + item : any + The item to get the Themerr data for. + + Returns + ------- + dict + The Themerr data for the specified item, or empty dict if no Themerr data exists. + """ + themerr_json_path = get_themerr_json_path(item=item) + + if os.path.isfile(themerr_json_path): + themerr_data = json.loads(s=str(Core.storage.load(filename=themerr_json_path, binary=False))) + else: + themerr_data = dict() + + return themerr_data + + +def get_themerr_settings_hash(): + # type: () -> str + """ + Get a hash of the current Themerr settings. + + Returns + ------- + str + Hash of the current Themerr settings. + + Examples + -------- + >>> get_themerr_settings_hash() + '...' + """ + # use to compare previous settings to new settings + themerr_settings = dict( + bool_prefer_mp4a_codec=Prefs['bool_prefer_mp4a_codec'], + int_plexapi_plexapi_timeout=Prefs['int_plexapi_plexapi_timeout'], + ) + settings_hash = hashlib.sha256(json.dumps(themerr_settings)).hexdigest() + return settings_hash + + +def remove_uploaded_media(item, media_type): + # type: (any, str) -> None + """ + Remove themes for the specified item. + + Deletes the themes upload directory for the item specified by the ``item``. + + Parameters + ---------- + item : any + The item to remove the themes from. + media_type : str + The media type to remove the themes from. Must be one of 'art', 'posters', or 'themes'. + + Returns + ------- + bool + True if the themes were removed successfully, False otherwise. + + Examples + -------- + >>> remove_uploaded_media(item=..., media_type='themes') + ... + """ + theme_upload_path = get_media_upload_path(item=item, media_type=media_type) + if os.path.isdir(theme_upload_path): + shutil.rmtree(path=theme_upload_path, ignore_errors=True, onerror=remove_uploaded_media_error_handler) + + +def remove_uploaded_media_error_handler(func, path, exc_info): + # type: (any, any, any) -> None + """ + Error handler for removing themes. + + Handles errors that occur when removing themes using ``shutil``. + + Parameters + ---------- + func : any + The function that caused the error. + path : str + The path that caused the error. + exc_info : any + The exception information. + """ + Log.Error('Error removing themes with function: %s, path: %s, exception info: %s' % (func, path, exc_info)) + + +def update_themerr_data_file(item, new_themerr_data): + # type: (any, dict) -> None + """ + Update the Themerr data file for the specified item. + + This updates the themerr data file after uploading media to the Plex server. + + Parameters + ---------- + item : any + The item to update the Themerr data file for. + new_themerr_data : dict + The Themerr data to update the Themerr data file with. + """ + # get the old themerr data + themerr_data = get_themerr_json_data(item=item) + + # remove legacy keys + for key in legacy_keys: + try: + del themerr_data[key] + except KeyError: + pass + + # update the old themerr data with the new themerr data + themerr_data.update(new_themerr_data) + + # get path + themerr_json_path = get_themerr_json_path(item=item) + + # create directory if it doesn't exist + if not os.path.isdir(os.path.dirname(themerr_json_path)): + os.makedirs(os.path.dirname(themerr_json_path)) + + # write themerr json + Core.storage.save(filename=themerr_json_path, data=json.dumps(themerr_data), binary=False) diff --git a/Contents/Code/helpers.py b/Contents/Code/helpers.py deleted file mode 100644 index 55b28439..00000000 --- a/Contents/Code/helpers.py +++ /dev/null @@ -1,36 +0,0 @@ -guid_map = dict( - imdb='imdb', - tmdb='themoviedb', - tvdb='thetvdb' -) - - -base_url = 'https://github.com/LizardByte/ThemerrDB/issues/new?assignees=' -issue_labels = dict( - game='request-game', - movie='request-movie', -) -issue_template = dict( - game='game-theme.yml', - movie='movie-theme.yml', -) -title_prefix = dict( - game='[GAME]: ', - movie='[MOVIE]: ', -) -url_name = dict( - game='igdb_url', - movie='themoviedb_url', -) -url_prefix = dict( - game='https://www.igdb.com/games/', - movie='https://www.themoviedb.org/movie/', -) - -# two additional strings to fill in later, item title and item url -issue_url_movies = '%s&labels=%s&template=%s&title=%s%s&%s=%s%s' % (base_url, issue_labels['movie'], - issue_template['movie'], title_prefix['movie'], - '%s', url_name['movie'], url_prefix['movie'], '%s') -issue_url_games = '%s&labels=%s&template=%s&title=%s%s&%s=%s%s' % (base_url, issue_labels['game'], - issue_template['game'], title_prefix['game'], - '%s', url_name['game'], url_prefix['game'], '%s') diff --git a/Contents/Code/lizardbyte_db_helper.py b/Contents/Code/lizardbyte_db_helper.py new file mode 100644 index 00000000..9fc99912 --- /dev/null +++ b/Contents/Code/lizardbyte_db_helper.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- + +# plex debugging +try: + import plexhints # noqa: F401 +except ImportError: + pass +else: # the code is running outside of Plex + from plexhints.log_kit import Log # log kit + from plexhints.parse_kit import JSON # parse kit + +# imports from Libraries\Shared +from typing import Optional, Tuple + +collection_types = dict( + game_collections=dict( + db_base_url='https://db.lizardbyte.dev/collections' + ), + game_franchises=dict( + db_base_url='https://db.lizardbyte.dev/franchises' + ), +) + + +def get_igdb_id_from_collection(search_query, collection_type=None): + # type: (str, Optional[str]) -> Optional[Tuple[int, str]] + """ + Search for a collection by name. + + Match a collection by name against the LizardByte db (clone of IGDB), to get the collection ID. + + Parameters + ---------- + search_query : str + Collection name to search for. + collection_type : Optional[str] + Collection type to search for. Valid values are 'game_collections' and 'game_franchises'. If not provided, will + first search for 'game_collections', then 'game_franchises', returning the first match. + + Returns + ------- + Optional[Tuple[int, str]] + Tuple of ``id`` and ``collection_type`` if found, otherwise None. + + Examples + -------- + >>> get_igdb_id_from_collection(search_query='James Bond', collection_type='game_collections') + 326 + >>> get_igdb_id_from_collection(search_query='James Bond', collection_type='game_franchises') + 37 + """ + Log.Debug('Searching LizardByte db for collection: {}'.format(search_query)) + + if collection_type is None: + collection_types_list = ['game_collections', 'game_franchises'] + else: + collection_types_list = [collection_type] + + for collection_type in collection_types_list: + try: + db_base_url = collection_types[collection_type]['db_base_url'] + except KeyError: + Log.Error('Invalid collection type: {}'.format(collection_type)) + else: + url = '{}/all.json'.format(db_base_url) + try: + collection_data = JSON.ObjectFromURL(url=url, headers=dict(Accept='application/json'), + cacheTime=0, errors='strict') + except ValueError as e: + Log.Error('Error getting collection data: {}'.format(e)) + else: + for _ in collection_data: + if search_query.lower() == collection_data[_]['name'].lower(): + return collection_data[_]['id'], collection_type diff --git a/Contents/Code/plex_api_helper.py b/Contents/Code/plex_api_helper.py index 1cac76da..dc6dc53f 100644 --- a/Contents/Code/plex_api_helper.py +++ b/Contents/Code/plex_api_helper.py @@ -2,7 +2,6 @@ # standard imports import os -import sys import time import threading @@ -13,33 +12,48 @@ pass else: # the code is running outside of Plex from plexhints.log_kit import Log # log kit - from plexhints.parse_kit import JSON # parse kit + from plexhints.parse_kit import JSON, XML # parse kit from plexhints.prefs_kit import Prefs # prefs kit # imports from Libraries\Shared from future.moves import queue import requests -from typing import Optional +from typing import Callable, Optional, Tuple import urllib3 from urllib3.exceptions import InsecureRequestWarning from plexapi.alert import AlertListener +from plexapi.base import PlexPartialObject from plexapi.exceptions import BadRequest import plexapi.server from plexapi.utils import reverseSearchType # local imports -if sys.version_info.major < 3: - from helpers import guid_map, issue_url_movies - from youtube_dl_helper import process_youtube -else: - from .helpers import guid_map, issue_url_movies - from .youtube_dl_helper import process_youtube +from constants import contributes_to, guid_map, media_type_dict +import general_helper +import lizardbyte_db_helper +import themerr_db_helper +import tmdb_helper +from youtube_dl_helper import process_youtube plex = None -# list currently processing items to avoid processing again q = queue.Queue() -processing_completed = [] + +# disable auto-reload, because Themerr doesn't rely on it, so it will only slow down the app +# when accessing a missing field +os.environ["PLEXAPI_PLEXAPI_AUTORELOAD"] = "false" + +# the explicit IPv4 address is used because `localhost` can resolve to ::1, which `websocket` rejects +plex_url = 'http://127.0.0.1:32400' +plex_token = os.environ.get('PLEXTOKEN') + +plex_section_type_settings_map = dict( + album=9, + artist=8, + movie=1, + photo=13, + show=2, +) def setup_plexapi(): @@ -61,9 +75,6 @@ def setup_plexapi(): """ global plex if not plex: - plex_url = 'http://localhost:32400' - plex_token = os.environ.get('PLEXTOKEN') - if not plex_token: Log.Error('Plex token not found in environment, cannot proceed.') return False @@ -73,74 +84,269 @@ def setup_plexapi(): urllib3.disable_warnings(InsecureRequestWarning) # Disable the insecure request warning # create the plex server object - plexapi.server.TIMEOUT = int(Prefs['int_plexapi_plexapi_timeout']) plex = plexapi.server.PlexServer(baseurl=plex_url, token=plex_token, session=sess) return plex -def add_themes(rating_key, theme_files=None, theme_urls=None): - # type: (int, Optional[list], Optional[list]) -> bool +def update_plex_item(rating_key): + # type: (int) -> bool """ - Apply themes to the specified item. + Automated update of Plex item using only the rating key. - Adds theme songs to the item specified by the ``rating_key``. + Given the rating key, this function will automatically handle collecting the required information to update the + theme song, and any other metadata. Parameters ---------- - rating_key : str - The key corresponding to the item that the themes will be added to. - theme_files : Optional[list] - A list of paths to theme songs. - theme_urls : Optional[list] - A list of urls to theme songs. + rating_key : int + The rating key of the item to be updated. Returns ------- bool - True if the themes were added successfully, False otherwise. + True if the item was updated successfully, False otherwise. Examples -------- - >>> add_themes(theme_list=[...], rating_key=...) + >>> update_plex_item(rating_key=12345) + """ + # first get the plex item + item = get_plex_item(rating_key=rating_key) + + if not item: + Log.Error('Could not find item with rating key: %s' % rating_key) + return False + + database_info = get_database_info(item=item) + Log.Debug('-' * 50) + Log.Debug('item title: {}'.format(item.title)) + Log.Debug('item type: {}'.format(item.type)) + Log.Debug('database_info: {}'.format(database_info)) + + database_type = database_info[0] + database = database_info[1] + agent = database_info[2] + database_id = database_info[3] + + if database and database_type and database_id: + if not themerr_db_helper.item_exists(database_type=database_type, database=database, id=database_id): + Log.Debug('{} item does not exist in ThemerrDB, skipping: {} ({})' + .format(item.type, item.title, database_id)) + return False + + try: + data = JSON.ObjectFromURL( + cacheTime=3600, + url='https://app.lizardbyte.dev/ThemerrDB/{}/{}/{}.json'.format(database_type, database, database_id), + errors='ignore' # don't crash the plugin + ) + except Exception as e: + Log.Error('{}: Error retrieving data from ThemerrDB: {}'.format(item.ratingKey, e)) + else: + if data: + # update collection metadata + Log.Debug('data found for {} {}'.format(item.type, item.title)) + if item.type == 'collection': + # determine if we want to update the metadata based on the agent and user preferences + update_collection_metadata = False + + if agent == 'tv.plex.agents.movie': # new Plex Movie agent + if Prefs['bool_update_collection_metadata_plex_movie']: + update_collection_metadata = True + elif database != 'igdb': # any other legacy agents except RetroArcher + # game collections/franchises don't have extended metadata + if Prefs['bool_update_collection_metadata_legacy']: + update_collection_metadata = True + + if update_collection_metadata: + # update poster + try: + url = 'https://image.tmdb.org/t/p/original{}'.format(data['poster_path']) + except KeyError: + pass + else: + add_media(item=item, media_type='posters', media_url_id=data['poster_path'], media_url=url) + # update art + try: + url = 'https://image.tmdb.org/t/p/original{}'.format(data['backdrop_path']) + except KeyError: + pass + else: + add_media(item=item, media_type='art', media_url_id=data['backdrop_path'], media_url=url) + # update summary + if item.isLocked(field='summary'): + Log.Debug('Not overwriting locked summary for collection: {}'.format(item.title)) + else: + try: + summary = data['overview'] + except KeyError: + pass + else: + if item.summary != summary: + Log.Info('Updating summary for collection: {}'.format(item.title)) + try: + item.editSummary(summary=summary, locked=False) + except Exception as e: + Log.Error('{}: Error updating summary: {}'.format(item.ratingKey, e)) + + if item.isLocked(field='theme'): + Log.Debug('Not overwriting locked theme for {}: {}'.format(item.type, item.title)) + else: + # get youtube_url + try: + yt_video_url = data['youtube_theme_url'] + except KeyError: + Log.Info('{}: No theme song found for {} ({})'.format(item.ratingKey, item.title, item.year)) + else: + settings_hash = general_helper.get_themerr_settings_hash() + themerr_data = general_helper.get_themerr_json_data(item=item) + + try: + skip = themerr_data['settings_hash'] == settings_hash \ + and themerr_data[media_type_dict['themes']['themerr_data_key']] == yt_video_url + except KeyError: + skip = False + + if skip: + Log.Info('Skipping {} for type: {}, title: {}, rating_key: {}'.format( + media_type_dict['themes']['name'], item.type, item.title, item.ratingKey + )) + else: + try: + theme_url = process_youtube(url=yt_video_url) + except Exception as e: + Log.Exception('{}: Error processing youtube url: {}'.format(item.ratingKey, e)) + else: + if theme_url: + add_media(item=item, media_type='themes', + media_url_id=yt_video_url, media_url=theme_url) + + +def add_media(item, media_type, media_url_id, media_file=None, media_url=None): + # type: (PlexPartialObject, str, str, Optional[str], Optional[str]) -> bool + """ + Apply media to the specified item. + + Adds theme song to the item specified by the ``rating_key``. If the same theme song is already present, it will be + skipped. + + Parameters + ---------- + item : PlexPartialObject + The Plex item to add the theme to. + media_type : str + The type of media to add. Must be one of 'art', 'posters', or 'themes'. + media_url_id : str + The url or id of the media. + media_file : Optional[str] + Full path to media file. + media_url : Optional[str] + URL of media. + + Returns + ------- + bool + True if the media was added successfully or already present, False otherwise. + + Examples + -------- + >>> add_media(item=..., media_type='themes', media_url_id=..., media_url=...) + >>> add_media(item=..., media_type='themes', media_url_url=..., media_file=...) """ uploaded = False - if theme_files or theme_urls: + settings_hash = general_helper.get_themerr_settings_hash() + themerr_data = general_helper.get_themerr_json_data(item=item) + + if item.isLocked(field=media_type_dict[media_type]['plex_field']): + Log.Info('Not overwriting locked "{}" for {}: {}'.format( + media_type_dict[media_type]['name'], item.type, item.title + )) + return False + + if media_file or media_url: global plex if not plex: plex = setup_plexapi() - Log.Info('Plexapi working with item with rating key: %s' % rating_key) - - if plex: - plex_item = plex.fetchItem(ekey=int(rating_key)) # must be an int or weird things happen + Log.Info('Plexapi attempting to upload {} for type: {}, title: {}, rating_key: {}'.format( + media_type_dict[media_type]['name'], item.type, item.title, item.ratingKey + )) - if theme_files: - for theme_file in theme_files: - Log.Info('Attempting to upload theme file: %s' % theme_file) - uploaded = upload_theme(plex_item=plex_item, filepath=theme_file) - if theme_urls: - for theme_url in theme_urls: - Log.Info('Attempting to upload theme file: %s' % theme_url) - uploaded = upload_theme(plex_item=plex_item, url=theme_url) + try: + if themerr_data['settings_hash'] == settings_hash \ + and themerr_data[media_type_dict[media_type]['themerr_data_key']] == media_url_id: + Log.Info('Skipping {} for type: {}, title: {}, rating_key: {}'.format( + media_type_dict[media_type]['name'], item.type, item.title, item.ratingKey + )) + + # false because we aren't doing anything, and the listener will not see this item again + return False + except KeyError: + pass + + # remove existing theme uploads + if Prefs[media_type_dict[media_type]['remove_pref']]: + general_helper.remove_uploaded_media(item=item, media_type=media_type) + + Log.Info('Attempting to upload {} for type: {}, title: {}, rating_key: {}'.format( + media_type_dict[media_type]['name'], item.type, item.title, item.ratingKey + )) + if media_file: + uploaded = upload_media(item=item, method=media_type_dict[media_type]['method'](item), filepath=media_file) + if media_url: + uploaded = upload_media(item=item, method=media_type_dict[media_type]['method'](item), url=media_url) + else: + Log.Warning('No theme songs provided for type: {}, title: {}, rating_key: {}'.format( + item.type, item.title, item.ratingKey + )) + + if uploaded: + # new data for themerr.json + new_themerr_data = dict( + settings_hash=settings_hash + ) + new_themerr_data[media_type_dict[media_type]['themerr_data_key']] = media_url_id + + general_helper.update_themerr_data_file(item=item, new_themerr_data=new_themerr_data) + + # unlock the field since it contains an automatically added value + edit_field = "{}.locked".format(media_type_dict[media_type]['plex_field']) + edits = { + edit_field: 0, + } + count = 0 + while count < 3: # there are random read timeouts + try: + item.edit(**edits) + except requests.ReadTimeout as e: + Log.Error('{}: Error unlocking field: {}'.format(item.ratingKey, e)) + time.sleep(5) + count += 1 + else: + break else: - Log.Info('No theme songs provided for rating key: %s' % rating_key) + Log.Debug('Could not upload {} for type: {}, title: {}, rating_key: {}'.format( + media_type_dict[media_type]['name'], item.type, item.title, item.ratingKey + )) return uploaded -def upload_theme(plex_item, filepath=None, url=None): - # type: (any, Optional[str], Optional[str]) -> bool +def upload_media(item, method, filepath=None, url=None): + # type: (PlexPartialObject, Callable, Optional[str], Optional[str]) -> bool """ - Upload a theme to the specified item. + Upload media to the specified item. - Uploads a theme to the item specified by the ``plex_item``. + Uploads art, poster, or theme to the item specified by the ``item``. Parameters ---------- - plex_item : any - The item to upload the theme to. + item : PlexPartialObject + The Plex item to upload the theme to. + method : Callable + The method to use to upload the theme. filepath : Optional[str] The path to the theme song. url : Optional[str] @@ -153,20 +359,28 @@ def upload_theme(plex_item, filepath=None, url=None): Examples -------- - >>> upload_theme(plex_item=..., url=...) + >>> upload_media(item=..., method=item.uploadArt, url=...) + >>> upload_media(item=..., method=item.uploadPoster, url=...) + >>> upload_media(item=..., method=item.uploadTheme, url=...) ... """ count = 0 while count <= int(Prefs['int_plexapi_upload_retries_max']): try: if filepath: - plex_item.uploadTheme(filepath=filepath) + if method == item.uploadTheme: + method(filepath=filepath, timeout=int(Prefs['int_plexapi_plexapi_timeout'])) + else: + method(filepath=filepath) elif url: - plex_item.uploadTheme(url=url) + if method == item.uploadTheme: + method(url=url, timeout=int(Prefs['int_plexapi_plexapi_timeout'])) + else: + method(url=url) except BadRequest as e: sleep_time = 2**count - Log.Error('%s: Error uploading theme: %s' % (plex_item.ratingKey, e)) - Log.Error('%s: Trying again in : %s' % (plex_item.ratingKey, sleep_time)) + Log.Error('%s: Error uploading media: %s' % (item.ratingKey, e)) + Log.Error('%s: Trying again in : %s' % (item.ratingKey, sleep_time)) time.sleep(sleep_time) count += 1 else: @@ -174,8 +388,103 @@ def upload_theme(plex_item, filepath=None, url=None): return False +def get_database_info(item): + # type: (PlexPartialObject) -> Tuple[Optional[str], Optional[str], Optional[str], Optional[str]] + """ + Get the database info for the specified item. + + Get the ``database_type``, ``database``, ``agent``, ``database_id`` which can be used to locate the theme song + in ThemerrDB. + + Parameters + ---------- + item : PlexPartialObject + The Plex item to get the database info for. + + Returns + ------- + Tuple[Optional[str], Optional[str], Optional[str], Optional[str]] + The ``database_type``, ``database``, ``agent``, ``database_id``. + + Examples + -------- + >>> get_database_info(item=...) + """ + Log.Debug('Getting database info for item: %s' % item.title) + + # setup plex just in case + global plex + if not plex: + plex = setup_plexapi() + + agent = None + database = None + database_id = None + database_type = None + + if item.type == 'movie': + if item.guids: # guids is a blank list for items from legacy agents, only available for new agent items + agent = 'tv.plex.agents.movie' + database_type = 'movies' + for guid in item.guids: + split_guid = guid.id.split('://') + temp_database = guid_map[split_guid[0]] + temp_database_id = split_guid[1] + + if temp_database == 'imdb': + database_id = temp_database_id + database = temp_database + + if temp_database == 'themoviedb': # tmdb is our prefered db, so we break if found + database_id = temp_database_id + database = temp_database + break + elif item.guid: + split_guid = item.guid.split('://') + agent = split_guid[0] + if agent == 'dev.lizardbyte.retroarcher-plex': + # dev.lizardbyte.retroarcher-plex://{igdb-1638}{platform-4}{(USA)}?lang=en + database_type = 'games' + database = 'igdb' + database_id = item.guid.split('igdb-')[1].split('}')[0] + elif agent == 'com.plexapp.agents.themoviedb': + # com.plexapp.agents.themoviedb://363088?lang=en + database_type = 'movies' + database = 'themoviedb' + database_id = item.guid.split('://')[1].split('?')[0] + elif agent == 'com.plexapp.agents.imdb': + # com.plexapp.agents.imdb://tt0113189?lang=en + database_type = 'movies' + database = 'imdb' + database_id = item.guid.split('://')[1].split('?')[0] + + elif item.type == 'collection': + # this is tricky since collections don't match up with any of the databases + # we'll use the collection title and try to find a match + + # using the section id, we can probably figure out the agent + section = plex.library.sectionByID(item.librarySectionID) + agent = section.agent + + if agent == 'dev.lizardbyte.retroarcher-plex': + # this collection is for a game library + database = 'igdb' + collection_data = lizardbyte_db_helper.get_igdb_id_from_collection(search_query=item.title) + if collection_data: + database_id = collection_data[0] + database_type = collection_data[1] + else: + database = 'themoviedb' + database_type = 'movie_collections' + database_id = tmdb_helper.get_tmdb_id_from_collection(search_query=item.title) + + Log.Debug('Database info for item: {}, database_info: {}'.format( + item.title, (database_type, database, agent, database_id))) + return database_type, database, agent, database_id + + def get_plex_item(rating_key): - # type: (int) -> any + # type: (int) -> PlexPartialObject """ Get any item from the Plex Server. @@ -184,24 +493,27 @@ def get_plex_item(rating_key): Parameters ---------- rating_key : int - The ``rating_key`` of the item to upload a theme for. + The ``rating_key`` of the item to get. Returns ------- - any - The item from the Plex Server. + PlexPartialObject + The Plex item from the Plex Server. Examples -------- >>> get_plex_item(rating_key=1) ... """ - global plex, processing_completed + global plex if not plex: plex = setup_plexapi() - plex_item = plex.fetchItem(ekey=rating_key) + if plex: + item = plex.fetchItem(ekey=rating_key) + else: + item = None - return plex_item + return item def process_queue(): @@ -213,25 +525,28 @@ def process_queue(): Examples -------- - >>> t = threading.Thread(target=process_queue, daemon=True) + >>> process_queue() ... """ while True: rating_key = q.get() # get the rating_key from the queue - update_plex_movie_item(rating_key=rating_key) # process that rating_key + try: + update_plex_item(rating_key=rating_key) # process that rating_key + except Exception as e: + Log.Exception('Unexpected error processing rating key: %s, error: %s' % (rating_key, e)) q.task_done() # tells the queue that we are done with this item -def plex_listener(): +def start_queue_threads(): # type: () -> None """ - Listen for events from Plex server. + Start queue threads. - Send events to ``plex_listener_handler`` and errors to ``Log.Error``. + Start the queue threads based on the number of threads set in the preferences. Examples -------- - >>> plex_listener() + >>> start_queue_threads() ... """ # create multiple threads for processing themes faster @@ -248,6 +563,19 @@ def plex_listener(): Log.Error('RuntimeError encountered: %s' % e) break + +def plex_listener(): + # type: () -> None + """ + Listen for events from Plex server. + + Send events to ``plex_listener_handler`` and errors to ``Log.Error``. + + Examples + -------- + >>> plex_listener() + ... + """ global plex if not plex: plex = setup_plexapi() @@ -260,7 +588,8 @@ def plex_listener_handler(data): """ Process events from ``plex_listener()``. - Check if we need to add an item to the queue or remove it from the ``processing_completed`` list. + Check if we need to add an item to the queue. This is used to automatically add themes to items from the + new Plex Movie agent, since metadata agents cannot extend it. Parameters ---------- @@ -272,7 +601,6 @@ def plex_listener_handler(data): >>> plex_listener_handler(data={'type': 'timeline'}) ... """ - global processing_completed # Log.Debug(data) if data['type'] == 'timeline': for entry in data['TimelineEntry']: @@ -284,89 +612,83 @@ def plex_listener_handler(data): if (reverseSearchType(libtype=entry['type']) == 'movie' and entry['state'] == 5 and entry['identifier'] == 'com.plexapp.plugins.library'): - # todo - add themes for collections # identifier always appears to be `com.plexapp.plugins.library` for updating library metadata # entry['title'] = movie title # entry['itemID'] = rating key rating_key = int(entry['itemID']) - # check if entry has already processed to avoid endless looping - if rating_key in processing_completed: - processing_completed.remove(int(entry['itemID'])) - Log.Debug('Finished uploading theme: {title=%s, rating_key=%s}' % - (entry['title'], entry['itemID'])) - return - elif rating_key not in q.queue: + # since we added the themerr json file, we no longer need to keep track of whether the update + # here is from Themerr updating the theme, as we will just skip it if no changes are required + if rating_key not in q.queue: # if the item was not in the list, then add it to the queue q.put(item=rating_key) -def update_plex_movie_item(rating_key): - # type: (int) -> None +def scheduled_update(): + # type: () -> None """ - Upload theme songs to the Plex Media Server. - - Add themes to the server. Once finished, add the ``rating_key`` to the ``processing_completed`` list. + Update all items in the Plex Server. - Parameters - ---------- - rating_key : int - The ``rating_key`` of the item to upload a theme for. + This is used to update all items in the Plex Server. It is called from a scheduled task. Examples -------- - >>> update_plex_movie_item(rating_key=1) - ... + >>> scheduled_update() + + See Also + -------- + scheduled_tasks.setup_scheduling : The method where the scheduled task is configurerd. + scheduled_tasks.schedule_loop : The method that runs the pending scheduled tasks. """ - global plex, processing_completed + global plex if not plex: plex = setup_plexapi() - plex_item = plex.fetchItem(ekey=rating_key) - - themerr_db_logs = [] - - # guids does not appear to exist for legacy agents or plugins - # therefore, we don't need to filter those out - for guid in plex_item.guids: - split_guid = guid.id.split('://') - database = guid_map[split_guid[0]] - database_id = split_guid[1] - - Log.Debug('%s: Attempting update for: {title=%s, rating_key=%s, database=%s, database_id=%s}' % - (rating_key, plex_item.title, plex_item.ratingKey, database, database_id)) - - url = 'https://app.lizardbyte.dev/ThemerrDB/movies/%s/%s.json' % (database, database_id) - - try: - data = JSON.ObjectFromURL(url=url, errors='ignore') - except Exception: - themerr_db_logs.append('%s: Could not retrieve data from ThemerrDB using %s' % (rating_key, database)) - if database == 'themoviedb': - issue_title = '%s (%s)' % (plex_item.title, plex_item.year) - issue_url = issue_url_movies % (issue_title, database_id) - themerr_db_logs.insert( - 0, - '%s: Theme song missing in ThemerrDB. Add it here -> "%s"' % (rating_key, issue_url) - ) - else: - try: - yt_video_url = data['youtube_theme_url'] - except KeyError: - Log.Info('%s: No theme song found for %s (%s)' % (rating_key, plex_item.title, plex_item.year)) - return - else: - theme_url = process_youtube(url=yt_video_url) - - if theme_url: - theme_added = add_themes(rating_key=plex_item.ratingKey, theme_urls=[theme_url]) - - # add the item to processing_completed list - if theme_added: - processing_completed.append(rating_key) - - # theme found and uploaded using this database, so return - return - # could not upload theme using any database, so log the errors - for log in themerr_db_logs: - Log.Error(log) + themerr_db_helper.update_cache() + + plex_library = plex.library + + sections = plex_library.sections() + + for section in sections: + if section.agent not in contributes_to: + # todo - there is a small chance that a library with an unsupported agent could still have + # individual items that was matched with a supported agent... + continue # skip unsupported metadata agents + + if section.agent == 'tv.plex.agents.movie': + if not Prefs['bool_plex_movie_support']: + continue + elif section.agent in contributes_to: + # check if the agent is enabled + if not plex_token: + Log.Error('Plex token not found in environment, cannot proceed.') + continue + + # get the settings for this agent + settings_url = '{}/system/agents/{}/config/{}'.format( + plex_url, section.agent, plex_section_type_settings_map[section.type]) + settings_data = XML.ElementFromURL( + url=settings_url, + cacheTime=0 + ) + Log.Debug('settings data: {}'.format(settings_data)) + + themerr_plex_element = settings_data.find(".//Agent[@name='Themerr-plex']") + if themerr_plex_element.get('enabled') != '1': # Plex is using a string + Log.Debug('Themerr-plex is disabled for agent "{}"'.format(section.agent)) + continue + + # get all the items in the section + media_items = section.all() if Prefs['bool_auto_update_movie_themes'] else [] + + # get all collections in the section + collections = section.collections() if Prefs['bool_auto_update_collection_themes'] else [] + + # combine the items and collections into one list + # this is done so that we can process both items and collections in the same loop + all_items = media_items + collections + + for item in all_items: + if item.ratingKey not in q.queue: + q.put(item=item.ratingKey) diff --git a/Contents/Code/scheduled_tasks.py b/Contents/Code/scheduled_tasks.py new file mode 100644 index 00000000..9a1f66f4 --- /dev/null +++ b/Contents/Code/scheduled_tasks.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- + +# standard imports +import logging +import threading +import time + +# plex debugging +try: + import plexhints # noqa: F401 +except ImportError: + pass +else: # the code is running outside of Plex + from plexhints.log_kit import Log # log kit + from plexhints.prefs_kit import Prefs # prefs kit + +# imports from Libraries\Shared +import schedule +from typing import Any, Callable, Iterable, Mapping, Optional + +# local imports +from constants import plugin_identifier +from plex_api_helper import scheduled_update +from webapp import cache_data + +# setup logging for schedule +Log.Info('Adding schedule log handlers to plex plugin logger') + +# get the plugin logger +plugin_logger = logging.getLogger(plugin_identifier) + +schedule.logger.handlers = plugin_logger.handlers +schedule.logger.setLevel(plugin_logger.level) + +# test message +schedule.logger.info('schedule logger test message') + + +def run_threaded(target, daemon=None, args=(), **kwargs): + # type: (Callable, Optional[bool], Iterable, Mapping[str, Any]) -> threading.Thread + """ + Run a function in a thread. + + Allows to run a function in a thread, which is useful for long-running tasks, and it + allows the main thread to continue. + + Parameters + ---------- + target : Callable + The function to run in a thread. + daemon : Optional[bool] + Whether the thread should be a daemon thread. + args : Iterable + The positional arguments to pass to the function. + kwargs : Mapping[str, Any] + The keyword arguments to pass to the function. + + Returns + ------- + threading.Thread + The thread that the function is running in. + + Examples + -------- + >>> run_threaded(target=Log.Info, daemon=True, args=['Hello, world!']) + "Hello, world!" + """ + job_thread = threading.Thread(target=target, args=args, kwargs=kwargs) + if daemon: + job_thread.daemon = True + job_thread.start() + return job_thread + + +def schedule_loop(): + # type: () -> None + """ + Start the schedule loop. + + Before the schedule loop is started, all jobs are run once. + + Examples + -------- + >>> schedule_loop() + ... + """ + time.sleep(60) # give a little time for the server to start + schedule.run_all() # run all jobs once + + while True: + schedule.run_pending() + time.sleep(1) + + +def setup_scheduling(): + # type: () -> None + """ + Sets up the scheduled tasks. + + The Tasks setup depend on the preferences set by the user. + + Examples + -------- + >>> setup_scheduling() + ... + + See Also + -------- + plex_api_helper.scheduled_update : Scheduled function to update the themes. + """ + if Prefs['bool_auto_update_items']: + schedule.every(max(15, int(Prefs['int_update_themes_interval']))).minutes.do( + job_func=run_threaded, + target=scheduled_update + ) + schedule.every(max(15, int(Prefs['int_update_database_cache_interval']))).minutes.do( + job_func=run_threaded, + target=cache_data + ) + + run_threaded(target=schedule_loop, daemon=True) # start the schedule loop in a thread diff --git a/Contents/Code/themerr_db_helper.py b/Contents/Code/themerr_db_helper.py new file mode 100644 index 00000000..2a3b2ad5 --- /dev/null +++ b/Contents/Code/themerr_db_helper.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8 -*- + +# standard imports +from threading import Lock +import time + +# plex debugging +try: + import plexhints # noqa: F401 +except ImportError: + pass +else: # the code is running outside of Plex + from plexhints.log_kit import Log # log kit + from plexhints.parse_kit import JSON # parse kit + +# imports from Libraries\Shared +from typing import Union + +database_cache = {} +last_cache_update = 0 + +db_field_name = dict( + games={'igdb': 'id'}, + game_collections={'igdb': 'id'}, + game_franchises={'igdb': 'id'}, + movies={'themoviedb': 'id', 'imdb': 'imdb_id'}, + movie_collections={'themoviedb': 'id'}, +) + +lock = Lock() + + +def update_cache(): + # type: () -> None + """ + Update the ThemerrDB cache. + + The pages.json file is fetched for all database types, then each all_page_N.json file is fetched to form the + complete set of available IDs. + + Attempting to update the cache while an update is already in progress will wait until the current update is + complete. + + Updating the cache less than an hour after the last update is a no-op. + """ + Log.Info('Updating ThemerrDB cache') + + global last_cache_update + + if time.time() - last_cache_update < 3600: + Log.Info('Cache updated less than an hour ago, skipping') + return + + with lock: + for database_type, databases in db_field_name.items(): + try: + pages = JSON.ObjectFromURL( + cacheTime=3600, + url='https://app.lizardbyte.dev/ThemerrDB/{}/pages.json'.format(database_type), + errors='ignore' # don't crash the plugin + ) + page_count = pages['pages'] + + id_index = {db: set() for db in databases} + + for page in range(page_count): + page_data = JSON.ObjectFromURL( + cacheTime=3600, + url='https://app.lizardbyte.dev/ThemerrDB/{}/all_page_{}.json'.format(database_type, page + 1), + errors='ignore' # don't crash the plugin + ) + + for db in databases: + id_index[db].update(str(item[db_field_name[database_type][db]]) for item in page_data) + + database_cache[database_type] = id_index + + Log.Info('{}: database updated'.format(database_type)) + except Exception as e: + Log.Error('{}: Error retrieving page index from ThemerrDB: {}'.format(database_type, e)) + + database_cache[database_type] = {} + + last_cache_update = time.time() + + +def item_exists(database_type, database, id): + # type: (str, str, Union[int, str]) -> bool + """ + Check if an item exists in the ThemerrDB. + + Parameters + ---------- + database_type : str + The type of database to check for the item. + + database : str + The database to check for the item. + + id : Union[int, str] + The ID of the item to check for. + + Returns + ------- + bool + True if the item exists in the ThemerrDB, otherwise False. + + Examples + -------- + >>> item_exists(database_type='games', database='igdb', id=1234) + True + + >>> item_exists(database_type='movies', database='themoviedb', id=1234) + False + """ + if database_type not in db_field_name: + Log.Critical('"{}" is not a valid database_type. Allowed values are: {}' + .format(database_type, db_field_name.keys())) + return False + + if database_type not in database_cache: + update_cache() + + type_cache = database_cache[database_type] + return database in type_cache and str(id) in type_cache[database] diff --git a/Contents/Code/tmdb_helper.py b/Contents/Code/tmdb_helper.py new file mode 100644 index 00000000..0b2515ca --- /dev/null +++ b/Contents/Code/tmdb_helper.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- + +# plex debugging +try: + import plexhints # noqa: F401 +except ImportError: + pass +else: # the code is running outside of Plex + from plexhints.constant_kit import CACHE_1DAY # constant kit + from plexhints.log_kit import Log # log kit + from plexhints.parse_kit import JSON # parse kit + from plexhints.util_kit import String # util kit + +# imports from Libraries\Shared +from typing import Optional + +# url borrowed from TheMovieDB.bundle +tmdb_base_url = 'http://127.0.0.1:32400/services/tmdb?uri=' + + +def get_tmdb_id_from_imdb_id(imdb_id): + # type: (str) -> Optional[int] + """ + Convert IMDB ID to TMDB ID. + + Use the builtin Plex tmdb api service to search for a movie by IMDB ID. + + Parameters + ---------- + imdb_id : str + IMDB ID to convert. + + Returns + ------- + Optional[int] + Return TMDB ID if found, otherwise None. + + Examples + -------- + >>> get_tmdb_id_from_imdb_id(imdb_id='tt1254207') + 10378 + """ + # according to https://www.themoviedb.org/talk/5f6a0500688cd000351c1712 we can search by imdb id + # https://api.themoviedb.org/3/find/tt0458290?api_key=###&external_source=imdb_id + find_imdb_item = 'find/{}?external_source=imdb_id' + + url = '{}/{}'.format(tmdb_base_url, find_imdb_item.format(String.Quote(s=imdb_id, usePlus=True))) + try: + tmdb_data = JSON.ObjectFromURL( + url=url, sleep=2.0, headers=dict(Accept='application/json'), cacheTime=CACHE_1DAY, errors='strict') + except Exception as e: + Log.Debug('Error converting IMDB ID to TMDB ID: {}'.format(e)) + else: + Log.Debug('TMDB data: {}'.format(tmdb_data)) + try: + tmdb_id = int(tmdb_data['movie_results'][0]['id']) # this is already an integer, but let's force it + except (IndexError, KeyError, ValueError): + Log.Debug('Error converting IMDB ID to TMDB ID: {}'.format(tmdb_data)) + else: + return tmdb_id + + +def get_tmdb_id_from_collection(search_query): + # type: (str) -> Optional[int] + """ + Search for a collection by name. + + Use the builtin Plex tmdb api service to search for a tmdb collection by name. + + Parameters + ---------- + search_query : str + Name of collection to search for. + + Returns + ------- + Optional[int] + Return collection ID if found, otherwise None. + + Examples + -------- + >>> get_tmdb_id_from_collection(search_query='James Bond Collection') + 645 + >>> get_tmdb_id_from_collection(search_query='James Bond') + 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 `-` + url = '{}/{}'.format(tmdb_base_url, query_url.format(String.Quote( + s=search_query.replace(' ', '-'), usePlus=True))) + try: + tmdb_data = JSON.ObjectFromURL( + url=url, sleep=2.0, headers=dict(Accept='application/json'), cacheTime=CACHE_1DAY, errors='strict') + except Exception as e: + Log.Debug('Error searching for collection {}: {}'.format(search_query, e)) + else: + collection_id = None + Log.Debug('TMDB data: {}'.format(tmdb_data)) + + end_string = 'Collection' # collection names on themoviedb end with 'Collection' + try: + for result in tmdb_data['results']: + if result['name'].lower() == search_query.lower() or \ + '{} {}'.format(search_query.lower(), end_string).lower() == result['name'].lower(): + collection_id = int(result['id']) + except (IndexError, KeyError, ValueError): + Log.Debug('Error searching for collection {}: {}'.format(search_query, tmdb_data)) + else: + return collection_id diff --git a/Contents/Code/webapp.py b/Contents/Code/webapp.py new file mode 100644 index 00000000..59443dab --- /dev/null +++ b/Contents/Code/webapp.py @@ -0,0 +1,492 @@ +# -*- coding: utf-8 -*- + +# future imports +from __future__ import division # fix float division for python2 + +# standard imports +import json +import logging +import os +from threading import Lock, Thread + +# plex debugging +try: + import plexhints # noqa: F401 +except ImportError: + pass +else: # the code is running outside of Plex + from plexhints.constant_kit import CACHE_1DAY # constant kit + from plexhints.core_kit import Core # core kit + from plexhints.log_kit import Log # log kit + from plexhints.parse_kit import JSON # parse kit + from plexhints.prefs_kit import Prefs # prefs kit + +# lib imports +import flask +from flask import Flask, Response, render_template, send_from_directory +from flask_babel import Babel +import polib +from werkzeug.utils import secure_filename + +# local imports +from constants import contributes_to, issue_urls, plugin_directory, plugin_identifier, themerr_data_directory +import general_helper +from plex_api_helper import get_database_info, setup_plexapi +import themerr_db_helper +import tmdb_helper + +# setup flask app +app = Flask( + import_name=__name__, + root_path=os.path.join(plugin_directory, 'Contents', 'Resources', 'web'), + static_folder=os.path.join(plugin_directory, 'Contents', 'Resources', 'web'), + template_folder=os.path.join(plugin_directory, 'Contents', 'Resources', 'web', 'templates') + ) + +# remove extra lines rendered jinja templates +app.jinja_env.trim_blocks = True +app.jinja_env.lstrip_blocks = True + +# localization +babel = Babel( + app=app, + default_locale='en', + default_timezone='UTC', + default_domain='themerr-plex', + configure_jinja=True +) + +app.config['BABEL_TRANSLATION_DIRECTORIES'] = os.path.join(plugin_directory, 'Contents', 'Strings') + +# setup logging for flask +Log.Info('Adding flask log handlers to plex plugin logger') + +# get the plugin logger +plugin_logger = logging.getLogger(plugin_identifier) + +# replace the app.logger handlers with the plugin logger handlers +app.logger.handlers = plugin_logger.handlers +app.logger.setLevel(plugin_logger.level) + +# test message +app.logger.info('flask app logger test message') + +try: + Prefs['bool_webapp_log_werkzeug_messages'] +except KeyError: + # this fails when building docs + pass +else: + if Prefs['bool_webapp_log_werkzeug_messages']: + # get the werkzeug logger + werkzeug_logger = logging.getLogger('werkzeug') + + # replace the werkzeug logger handlers with the plugin logger handlers + werkzeug_logger.handlers = plugin_logger.handlers + + # use the same log level as the plugin logger + werkzeug_logger.setLevel(plugin_logger.level) + + # test message + werkzeug_logger.info('werkzeug logger test message') + + +# mime type map +mime_type_map = { + 'gif': 'image/gif', + 'ico': 'image/vnd.microsoft.icon', + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'png': 'image/png', + 'svg': 'image/svg+xml', +} + +# where the database cache is stored +database_cache_file = os.path.join(themerr_data_directory, 'database_cache.json') +database_cache_lock = Lock() + + +@babel.localeselector +def get_locale(): + # type: () -> str + """ + Get the locale from the config. + + Get the locale specified in the config. This does not need to be called as it is done so automatically by `babel`. + + Returns + ------- + str + The locale. + + Examples + -------- + >>> get_locale() + en + """ + return Prefs['enum_webapp_locale'] + + +def start_server(): + # type: () -> bool + """ + Start the flask server. + + The flask server is started in a separate thread to allow the plugin to continue running. + + Returns + ------- + bool + True if the server is running, otherwise False. + + Examples + -------- + >>> start_server() + + See Also + -------- + Core.Start : Function that starts the plugin. + stop_server : Function that stops the webapp. + """ + # use threading to start the flask app... or else web server seems to be killed after a couple of minutes + flask_thread = Thread( + target=app.run, + kwargs=dict( + host=Prefs['str_webapp_http_host'], + port=Prefs['int_webapp_http_port'], + debug=False, + use_reloader=False # reloader doesn't work when running in a separate thread + ) + ) + + # start flask application + flask_thread.start() + return flask_thread.is_alive() + + +def stop_server(): + # type: () -> bool + """ + Stop the web server. + + This method currently does nothing. + + Returns + ------- + bool + True if the server was shutdown, otherwise False. + + Examples + -------- + >>> stop_server() + + See Also + -------- + start_server : Function that starts the webapp. + """ + return False + + +def cache_data(): + # type: () -> None + """ + Cache data for use in the Web UI dashboard. + + Because there are many http requests that must be made to gather the data for the dashboard, it can be + time-consuming to populate; therefore, this is performed within this caching function, which runs on a schedule. + This function will create a json file that can be loaded by other functions. + """ + # get all Plex items from supported metadata agents + plex_server = setup_plexapi() + plex_library = plex_server.library + + themerr_db_helper.update_cache() + + sections = plex_library.sections() + + items = dict() + + for section in sections: + if section.agent not in contributes_to: + # todo - there is a small chance that a library with an unsupported agent could still have + # a individual items that was matched with a supported agent... + continue # skip unsupported metadata agents + + # get all the items in the section + media_items = section.all() + + # get all items in the section with theme songs + media_items_with_themes = section.all(theme__exists=True) + + # get all collections in the section + collections = section.collections() if Prefs['bool_auto_update_collection_themes'] else [] + collections_with_themes = section.collections(theme__exists=True) if Prefs[ + 'bool_auto_update_collection_themes'] else [] + + # combine the items and collections into one list + # this is done so that we can process both items and collections in the same loop + all_items = media_items + collections + + # add each section to the items dict + items[section.key] = dict( + title=section.title, + agent=section.agent, + items=[], + media_count=len(media_items), + media_percent_complete=int( + len(media_items_with_themes) / len(media_items) * 100) if len(media_items_with_themes) else 0, + collection_count=len(collections), + collection_percent_complete=int( + len(collections_with_themes) / len(collections) * 100) if len(collections_with_themes) else 0, + collections_enabled=Prefs['bool_auto_update_collection_themes'], + total_count=len(all_items), + ) + + for item in all_items: + # build the issue url + database_info = get_database_info(item=item) + database_type = database_info[0] + database = database_info[1] + item_agent = database_info[2] + database_id = database_info[3] + + og_db = database + og_db_id = database_id + + try: + year = item.year + except AttributeError: + year = None + + # convert imdb id to tmdb id, so we can build the issue url properly + if item.type == 'movie' and item_agent == 'com.plexapp.agents.imdb': + # try to get tmdb id from imdb id + tmdb_id = tmdb_helper.get_tmdb_id_from_imdb_id(imdb_id=database_id) + if tmdb_id: + database_id = tmdb_id + + item_issue_url = None + + try: + issue_url = issue_urls[database_type] + except KeyError: + issue_url = None + + if issue_url: + if item.type == 'movie': + # override the id since ThemerrDB issues require the slug as part of the url + if item_agent == 'dev.lizardbyte.retroarcher-plex': + # get the slug and name from LizardByte db + try: + db_data = JSON.ObjectFromURL( + url='https://db.lizardbyte.dev/games/{}.json'.format(database_id), + cacheTime=CACHE_1DAY, + errors='strict' + ) + issue_title = '{} ({})'.format(db_data['name'], year) + database_id = db_data['slug'] + except Exception as e: + Log.Error('Error getting game data from LizardByte db: {}'.format(e)) + issue_title = '{} ({})'.format(item.title, year) + database_id = None + else: + issue_title = '{} ({})'.format(getattr(item, "originalTitle", None) or item.title, year) + else: # collections + issue_title = item.title + + # override the id since ThemerrDB issues require the slug as part of the url + if item_agent == 'dev.lizardbyte.retroarcher-plex': + # get the slug and name from LizardByte db + try: + db_data = JSON.ObjectFromURL( + url='https://db.lizardbyte.dev/{}/all.json'.format( + database_type.rsplit('_', 1)[-1]), + cacheTime=CACHE_1DAY, + errors='strict' + ) + issue_title = db_data[str(database_id)]['name'] + database_id = db_data[str(database_id)]['slug'] + except Exception as e: + Log.Error('Error getting collection data from LizardByte db: {}'.format(e)) + database_id = None + + item_issue_url = issue_url.format(issue_title, database_id if database_id else '') + + if database_type and themerr_db_helper.item_exists( + database_type=database_type, + database=og_db, + id=og_db_id, + ): + issue_action = 'edit' + else: + issue_action = 'add' + + if item.theme: + theme_status = 'complete' + + selected = (theme for theme in item.themes() if theme.selected).next() + user_provided = (getattr(selected, 'provider', None) == 'local') + + if user_provided: + themerr_provided = False + else: + themerr_data = general_helper.get_themerr_json_data(item=item) + themerr_provided = True if themerr_data else False + else: + if issue_action == 'edit': + theme_status = 'failed' + else: + theme_status = 'missing' + + user_provided = False + themerr_provided = False + + items[section.key]['items'].append(dict( + title=item.title, + agent=item_agent, + database=database, + database_type=database_type, + database_id=database_id, + issue_action=issue_action, + issue_url=item_issue_url, + theme=True if item.theme else False, + theme_status=theme_status, + themerr_provided=themerr_provided, + type=item.type, + user_provided=user_provided, + year=year, + )) + + with database_cache_lock: + Core.storage.save(filename=database_cache_file, data=json.dumps(items), binary=False) + + +@app.route('/', methods=["GET"]) +@app.route('/home', methods=["GET"]) +def home(): + # type: () -> render_template + """ + Serve the webapp home page. + + This page serves the Themerr completion report for supported Plex libraries. + + Returns + ------- + render_template + The rendered page. + + Notes + ----- + The following routes trigger this function. + + - `/` + - `/home` + + Examples + -------- + >>> home() + """ + items = [] + try: + items = json.loads(Core.storage.load(filename=database_cache_file, binary=False)) + except IOError: + pass + + if items: + return render_template('home.html', title='Home', items=items) + else: + return render_template('home_db_not_cached.html', title='Home') + + +@app.route("/", methods=["GET"]) +def image(img): + # type: (str) -> flask.send_from_directory + """ + Get image from static/images directory. + + Returns + ------- + flask.send_from_directory + The image. + + Notes + ----- + The following routes trigger this function. + + - `/favicon.ico` + + Examples + -------- + >>> image('favicon.ico') + """ + directory = os.path.join(app.static_folder, 'images') + filename = os.path.basename(secure_filename(filename=img)) # sanitize the input + + if os.path.isfile(os.path.join(directory, filename)): + file_extension = filename.rsplit('.', 1)[-1] + if file_extension in mime_type_map: + return send_from_directory(directory=directory, filename=filename, mimetype=mime_type_map[file_extension]) + else: + return Response(response='Invalid file type', status=400, mimetype='text/plain') + else: + return Response(response='Image not found', status=404, mimetype='text/plain') + + +@app.route('/status', methods=["GET"]) +def status(): + # type: () -> dict + """ + Check the status of Themerr-plex. + + This can be used to test if the plugin is still running. It could be used as part of a healthcheck for Docker, + and may have many other uses in the future. + + Returns + ------- + dict + A dictionary of the status. + + Examples + -------- + >>> status() + """ + web_status = {'result': 'success', 'message': 'Ok'} + return web_status + + +@app.route("/translations", methods=["GET"]) +def translations(): + # type: () -> Response + """ + Serve the translations. + + Returns + ------- + Response + The translations. + + Examples + -------- + >>> translations() + """ + locale = get_locale() + + po_files = [ + '%s/%s/LC_MESSAGES/themerr-plex.po' % (app.config['BABEL_TRANSLATION_DIRECTORIES'], locale), # selected locale + '%s/themerr-plex.po' % app.config['BABEL_TRANSLATION_DIRECTORIES'], # fallback to default domain + ] + + for po_file in po_files: + if os.path.isfile(po_file): + po = polib.pofile(po_file) + + # convert the po to json + data = dict() + for entry in po: + if entry.msgid: + data[entry.msgid] = entry.msgstr + Log.Debug('Translation: %s -> %s' % (entry.msgid, entry.msgstr)) + + return Response(response=json.dumps(data), + status=200, + mimetype='application/json') diff --git a/Contents/Code/youtube_dl_helper.py b/Contents/Code/youtube_dl_helper.py index 7a51d2e7..1aaedd62 100644 --- a/Contents/Code/youtube_dl_helper.py +++ b/Contents/Code/youtube_dl_helper.py @@ -1,6 +1,10 @@ # -*- coding: utf-8 -*- # standard imports +import logging +import json +import os +import tempfile # plex debugging try: @@ -12,9 +16,31 @@ from plexhints.prefs_kit import Prefs # prefs kit # imports from Libraries\Shared +from constants import plugin_identifier, plugin_support_data_directory from typing import Optional import youtube_dl +# get the plugin logger +plugin_logger = logging.getLogger(plugin_identifier) + + +def nsbool(value): + # type: (bool) -> str + """ + Format a boolean value for a Netscape cookie jar file. + + Parameters + ---------- + value : bool + The boolean value to format. + + Returns + ------- + str + 'TRUE' or 'FALSE'. + """ + return 'TRUE' if value else 'FALSE' + def process_youtube(url): # type: (str) -> Optional[str] @@ -36,63 +62,103 @@ def process_youtube(url): >>> process_youtube(url='https://www.youtube.com/watch?v=dQw4w9WgXcQ') ... """ + + cookie_jar_file = tempfile.NamedTemporaryFile(dir=plugin_support_data_directory, delete=False) + cookie_jar_file.write('# Netscape HTTP Cookie File\n') + youtube_dl_params = dict( - outmpl='%(id)s.%(ext)s', + cookiefile=cookie_jar_file.name, + logger=plugin_logger, + socket_timeout=10, youtube_include_dash_manifest=False, - username=Prefs['str_youtube_user'] if Prefs['str_youtube_user'] else None, - password=Prefs['str_youtube_passwd'] if Prefs['str_youtube_passwd'] else None, ) - ydl = youtube_dl.YoutubeDL(params=youtube_dl_params) - - with ydl: - result = ydl.extract_info( - url=url, - download=False # We just want to extract the info - ) - if 'entries' in result: - # Can be a playlist or a list of videos - video_data = result['entries'][0] - else: - # Just a video - video_data = result - - selected = { - 'opus': { - 'size': 0, - 'audio_url': None - }, - 'mp4a': { - 'size': 0, - 'audio_url': None - }, - } - if video_data: - for fmt in video_data['formats']: # loop through formats, select largest audio size for better quality - if 'audio only' in fmt['format']: - if 'opus' == fmt['acodec']: - temp_codec = 'opus' - elif 'mp4a' == fmt['acodec'].split('.')[0]: - temp_codec = 'mp4a' + if Prefs['str_youtube_cookies']: + try: + cookies = json.loads(Prefs['str_youtube_cookies']) + for cookie in cookies: + include_subdom = cookie['domain'].startswith('.') + expiry = int(cookie.get('expiry', 0)) + values = [ + cookie['domain'], + nsbool(include_subdom), + cookie['path'], + nsbool(cookie['secure']), + str(expiry), + cookie['name'], + cookie['value'] + ] + cookie_jar_file.write('{}\n'.format('\t'.join(values))) + except Exception as e: + Log.Exception('Failed to write YouTube cookies to file, will try anyway. Error: {}'.format(e)) + + cookie_jar_file.flush() + cookie_jar_file.close() + + try: + ydl = youtube_dl.YoutubeDL(params=youtube_dl_params) + + with ydl: + try: + result = ydl.extract_info( + url=url, + download=False # We just want to extract the info + ) + except Exception as exc: + if isinstance(exc, youtube_dl.utils.ExtractorError) and exc.expected: + Log.Info('YDL returned YT error while downloading {}: {}'.format(url, exc)) else: - Log.Debug('Unknown codec: %s' % fmt['acodec']) - continue # unknown codec - filesize = int(fmt['filesize']) - if filesize > selected[temp_codec]['size']: - selected[temp_codec]['size'] = filesize - selected[temp_codec]['audio_url'] = fmt['url'] - - audio_url = None - - if 0 < selected['opus']['size'] > selected['mp4a']['size']: - audio_url = selected['opus']['audio_url'] - elif 0 < selected['mp4a']['size'] > selected['opus']['size']: - audio_url = selected['mp4a']['audio_url'] - - if audio_url and Prefs['bool_prefer_mp4a_codec']: # mp4a codec is preferred - if selected['mp4a']['audio_url']: # mp4a codec is available - audio_url = selected['mp4a']['audio_url'] - elif selected['opus']['audio_url']: # fallback to opus :( + Log.Exception('YDL returned an unexpected error while downloading {}: {}'.format(url, exc)) + return None + + if 'entries' in result: + # Can be a playlist or a list of videos + video_data = result['entries'][0] + else: + # Just a video + video_data = result + + selected = { + 'opus': { + 'size': 0, + 'audio_url': None + }, + 'mp4a': { + 'size': 0, + 'audio_url': None + }, + } + if video_data: + for fmt in video_data['formats']: # loop through formats, select largest audio size for better quality + if 'audio only' in fmt['format']: + if 'opus' == fmt['acodec']: + temp_codec = 'opus' + elif 'mp4a' == fmt['acodec'].split('.')[0]: + temp_codec = 'mp4a' + else: + Log.Debug('Unknown codec: %s' % fmt['acodec']) + continue # unknown codec + filesize = int(fmt['filesize']) + if filesize > selected[temp_codec]['size']: + selected[temp_codec]['size'] = filesize + selected[temp_codec]['audio_url'] = fmt['url'] + + audio_url = None + + if 0 < selected['opus']['size'] > selected['mp4a']['size']: audio_url = selected['opus']['audio_url'] + elif 0 < selected['mp4a']['size'] > selected['opus']['size']: + audio_url = selected['mp4a']['audio_url'] + + if audio_url and Prefs['bool_prefer_mp4a_codec']: # mp4a codec is preferred + if selected['mp4a']['audio_url']: # mp4a codec is available + audio_url = selected['mp4a']['audio_url'] + elif selected['opus']['audio_url']: # fallback to opus :( + audio_url = selected['opus']['audio_url'] - return audio_url # return None or url found + return audio_url # return None or url found + finally: + try: + os.remove(cookie_jar_file.name) + except Exception as e: + Log.Exception('Failed to delete cookie jar file: {}'.format(e)) diff --git a/Contents/DefaultPrefs.json b/Contents/DefaultPrefs.json index c23672b4..8dbe8aaa 100644 --- a/Contents/DefaultPrefs.json +++ b/Contents/DefaultPrefs.json @@ -1,4 +1,11 @@ [ + { + "id": "bool_plex_movie_support", + "type": "bool", + "label": "Plex Movie agent support (Add themes to the updated Plex Movie agent)", + "default": "True", + "secure": "false" + }, { "id": "bool_prefer_mp4a_codec", "type": "bool", @@ -6,6 +13,76 @@ "default": "True", "secure": "false" }, + { + "id": "bool_remove_unused_theme_songs", + "type": "bool", + "label": "Remove unused theme songs (frees up space in your Plex metadata directory)", + "default": "True", + "secure": "false" + }, + { + "id": "bool_remove_unused_art", + "type": "bool", + "label": "Remove unused art (applies to collections, frees up space in your Plex metadata directory)", + "default": "False", + "secure": "false" + }, + { + "id": "bool_remove_unused_posters", + "type": "bool", + "label": "Remove unused posters (applies to collections, frees up space in your Plex metadata directory)", + "default": "False", + "secure": "false" + }, + { + "id": "bool_auto_update_items", + "type": "bool", + "label": "Automatically update items (only items changed or previously missing in ThemerrDB)", + "default": "True", + "secure": "false" + }, + { + "id": "bool_auto_update_movie_themes", + "type": "bool", + "label": "Update movie themes during automatic update", + "default": "True", + "secure": "false" + }, + { + "id": "bool_auto_update_collection_themes", + "type": "bool", + "label": "Update collection themes during automatic update", + "default": "True", + "secure": "false" + }, + { + "id": "bool_update_collection_metadata_plex_movie", + "type": "bool", + "label": "Update collection metadata for Plex Movie agent (Updates poster, art, and summary)", + "default": "False", + "secure": "false" + }, + { + "id": "bool_update_collection_metadata_legacy", + "type": "bool", + "label": "Update collection metadata for legacy agents (Updates poster, art, and summary)", + "default": "True", + "secure": "false" + }, + { + "id": "int_update_themes_interval", + "type": "text", + "label": "Interval for automatic update task, in minutes (min: 15)", + "default": "60", + "secure": "false" + }, + { + "id": "int_update_database_cache_interval", + "type": "text", + "label": "Interval for database cache update task, in minutes (min: 15)", + "default": "60", + "secure": "false" + }, { "id": "int_plexapi_plexapi_timeout", "type": "text", @@ -28,18 +105,47 @@ "secure": "false" }, { - "id": "str_youtube_user", + "id": "str_youtube_cookies", "type": "text", - "label": "YouTube Username", + "label": "YouTube Cookies (JSON format)", "default": "", "secure": "true" }, { - "id": "str_youtube_passwd", + "id": "enum_webapp_locale", + "type": "enum", + "label": "Web UI Locale", + "default": "en", + "values": [ + "de", + "en", + "en_GB", + "en_US", + "es", + "fr", + "it", + "ru" + ] + }, + { + "id": "str_webapp_http_host", "type": "text", - "label": "YouTube Password", - "default": "", - "option": "hidden", - "secure": "true" + "label": "Web UI Host Address (requires Plex Media Server restart)", + "default": "0.0.0.0", + "secure": "false" + }, + { + "id": "int_webapp_http_port", + "type": "text", + "label": "Web UI Port (requires Plex Media Server restart)", + "default": "9494", + "secure": "false" + }, + { + "id": "bool_webapp_log_werkzeug_messages", + "type": "bool", + "label": "Log all web server messages (requires Plex Media Server restart)", + "default": "False", + "secure": "false" } ] diff --git a/Contents/Resources/favicon.ico b/Contents/Resources/favicon.ico deleted file mode 100644 index 79620bf5..00000000 Binary files a/Contents/Resources/favicon.ico and /dev/null differ diff --git a/Contents/Resources/icon-default.png b/Contents/Resources/icon-default.png index 162dee10..3d006184 100644 Binary files a/Contents/Resources/icon-default.png and b/Contents/Resources/icon-default.png differ diff --git a/Contents/Resources/web/css/custom.css b/Contents/Resources/web/css/custom.css new file mode 100644 index 00000000..f77ab5ce --- /dev/null +++ b/Contents/Resources/web/css/custom.css @@ -0,0 +1,170 @@ +body { + padding-top: 80px; +} + +/*Apply to elements that serve as anchors*/ +.offset-anchor { + border-top: 80px solid transparent; + margin: -80px 0 0; + -webkit-background-clip: padding-box; + -moz-background-clip: padding; + background-clip: padding-box; +} + +/*Because offset-anchor causes sections to overlap the bottom of previous ones,*/ +/*we need to put content higher so links aren't blocked by the transparent border.*/ +.container { + position: relative; + /*z-index: 1;*/ +} + +.navbar { + min-height: 80px; + border-bottom: 1px solid white; +} + +.navbar-brand { + font-size: 1.75rem; +} + +.nav-link { + font-size: 1.25rem; +} + +.nav-link-sm { + font-size: 1rem; +} + +.bg-dark { + --bs-bg-opacity: 1; + background-color: #151515 !important; +} + +.bg-dark-gray { + --bs-bg-opacity: 1; + background-color: #303436 !important; +} + +.carousel-overlay-title { + position: absolute; + top: 20vh; + right: 10vw; + padding: 1rem; + text-shadow: 2px 2px 5px black; + user-select: none; + z-index: 1; +} + +.carousel-overlay-subtitle { + position: absolute; + top: 30vh; + right: 10vw; + padding: 1rem; + text-shadow: 2px 2px 5px black; + user-select: none; + z-index: 1; +} + +.carousel-item.active, .carousel-item .view { + height: 50vh !important; +} + +.carousel-item img { + object-fit: cover; + width:100% !important; + height: 100% !important; +} + +.content h1 { + font-size: 55px; +} + +@media screen and (max-width: 320px) { + .content h1 { + font-size: 30px; + } +} + +@media screen and (min-width: 321px) and (max-width: 768px) { + .content h1 { + font-size: 30px; + } +} + +.content h5 { + font-size: 23px; + margin-left: 80px; + margin-right: 80px; +} + +@media screen and (max-width: 320px) { + .content h5 { + font-size: 18px; + margin-left: 20px; + margin-right: 20px + } +} + +@media screen and (min-width: 321px) and (max-width: 768px) { + .content h5 { + font-size: 18px; + margin-left: 20px; + margin-right: 20px; + } +} + +.feature { + display: inline-flex; + align-items: center; + justify-content: center; + height: 3rem; + width: 3rem; + font-size: 1.5rem; +} + +.widgetbot { + display: flex; + align-items: center; + justify-content: space-around; +} + +.feedback { + display: flex; + align-items: center; + justify-content: space-around; +} + +iframe.feedback { + width: 100vw; + min-height: calc(100vh - 80px - 72px); /* subtract navbar and footer height */ +} + +@media screen and (min-width: 321px) and (max-width: 768px) { + iframe.feedback { + min-height: calc(100vh - 80px - 93px); /* subtract navbar and footer height */ + } +} + +@media screen and (max-width: 320px) { + iframe.feedback { + min-height: calc(100vh - 80px - 114px); /* subtract navbar and footer height */ + } +} + +/*card image hover*/ +.hover-zoom { + overflow: hidden; +} + +.hover-zoom img { + transition: all 1.5s ease; +} + +.hover-zoom:hover img { + transform: scale(1.1); +} + +.progress-bar:first-child { + overflow: visible; + z-index: 999; +} diff --git a/Contents/Resources/web/images/favicon.ico b/Contents/Resources/web/images/favicon.ico new file mode 100644 index 00000000..4fc332ec Binary files /dev/null and b/Contents/Resources/web/images/favicon.ico differ diff --git a/Contents/Resources/web/images/icon.png b/Contents/Resources/web/images/icon.png new file mode 100644 index 00000000..3d006184 Binary files /dev/null and b/Contents/Resources/web/images/icon.png differ diff --git a/Contents/Resources/web/js/translations.js b/Contents/Resources/web/js/translations.js new file mode 100644 index 00000000..fe1a239b --- /dev/null +++ b/Contents/Resources/web/js/translations.js @@ -0,0 +1,32 @@ +let translations = null + +let getTranslation = function(string) { + // download translations + if (translations === null) { + $.ajax({ + async: false, + url: "/translations/", + type: "GET", + dataType: "json", + success: function (result) { + translations = result + } + }) + } + + if (translations) { + try { + if (translations[string]) { + return translations[string] + } else { + return string + } + } catch (err) { + return string + } + } + else { + // could not download translations + return string + } +} diff --git a/Contents/Resources/web/templates/base.html b/Contents/Resources/web/templates/base.html new file mode 100644 index 00000000..7a0a6ad2 --- /dev/null +++ b/Contents/Resources/web/templates/base.html @@ -0,0 +1,47 @@ + + + + {% if title %} + Themerr-plex - {{ title }} + {% else %} + Themerr-plex + {% endif %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% block modals %}{% endblock %} +
+ + {% include 'navbar.html' %} + + + {% block content %}{% endblock %} +
+ {% block scripts %}{% endblock %} + + + diff --git a/Contents/Resources/web/templates/home.html b/Contents/Resources/web/templates/home.html new file mode 100644 index 00000000..a8c7c53a --- /dev/null +++ b/Contents/Resources/web/templates/home.html @@ -0,0 +1,183 @@ +{% extends 'base.html' %} +{% block modals %} +{% endblock modals %} + +{% block content %} +
+
+ + {# constants #} + {% set title_column_width = 'col-6' %} + {% set type_column_width = 'col-2' %} + {% set year_column_width = 'col-1' %} + {% set contribute_column_width = 'col-1' %} + {% set status_column_width = 'col-2' %} + + {% for section in items %} + +
+
+
+ +

{{ items[section]['title'] }}

+ +

+
+
+ + + {% set library_type = _('Games') if items[section]['agent'] == 'dev.lizardbyte.retroarcher-plex' + else _('Movies') %} + {% if items[section]['media_percent_complete'] == 100 %} + {% set progress_bar_color = 'bg-success' %} + {% elif items[section]['media_percent_complete'] > 50 %} + {% set progress_bar_color = 'bg-warning' %} + {% else %} + {% set progress_bar_color = 'bg-danger' %} + {% endif %} +
+
+
+
+ {{ library_type }} - {{ items[section]['media_percent_complete'] }}% +
+ +
+
+
+
+
+ + {% if items[section]['collection_count'] > 0 %} + {% if items[section]['collection_percent_complete'] == 100 %} + {% set progress_bar_color = 'bg-success' %} + {% elif items[section]['collection_percent_complete'] > 50 %} + {% set progress_bar_color = 'bg-warning' %} + {% else %} + {% set progress_bar_color = 'bg-danger' %} + {% endif %} +
+
+
+
+ {{ _('Collections') }} - {{ items[section]['collection_percent_complete'] }}% +
+ +
+
+
+
+
+ {% endif %} + + +
+
+
+ + + + + + + + + + + {% for item in items[section]['items'] %} + {% if item['theme_status'] == 'complete' %} + {% set theme_status_color = 'table-success' %} + {% elif item['theme_status'] == 'missing' %} + {% set theme_status_color = 'table-danger' %} + {% elif item['theme_status'] == 'failed' %} + {% set theme_status_color = 'table-warning' %} + {% else %} + {% set theme_status_color = 'table-danger' %} + {% endif %} + + {% if item['issue_action'] == 'add' %} + {% set contribute_button_color = 'btn-warning' %} + {% set contribute_button_text = _('Add') %} + {% elif item['issue_action'] == 'edit' %} + {% set contribute_button_color = 'btn-info' %} + {% set contribute_button_text = _('Edit') %} + {% else %} + {% set contribute_button_color = 'btn-danger' %} + {% endif %} + + + + + + + + + {% endfor %} + +
{{ items[section]['title'] }}
{{ _('Title') }}{{ _('Type') }}{{ _('Year') }}{{ _('Contribute') }}{{ _('Status') }}
{{ item['title'] }}{{ item['type'] }}{{ item['year'] }} + {% if item['issue_url'] != None %} + {{ contribute_button_text }} + {% else %} + {{ _('No known ID') }} + {% endif %} + + {% if item['user_provided'] %} + {{ _('User provided') }} + {% elif item['themerr_provided'] %} + {{ _('Themerr provided') }} + {% elif item['theme_status'] == 'complete' %} + {{ _('Unknown provider') }} + {% elif item['theme_status'] == 'missing' %} + {{ _('Missing from ThemerrDB') }} + {% elif item['theme_status'] == 'failed' %} + {{ _('Failed to download') }} + {% else %} + {{ _('Unknown status') }} + {% endif %} +
+
+
+
+ + +
+ {% endfor %} + +
+
+{% endblock content %} + +{% block scripts %} + + +{% endblock scripts %} diff --git a/Contents/Resources/web/templates/home_db_not_cached.html b/Contents/Resources/web/templates/home_db_not_cached.html new file mode 100644 index 00000000..e6ae2431 --- /dev/null +++ b/Contents/Resources/web/templates/home_db_not_cached.html @@ -0,0 +1,16 @@ +{% extends 'base.html' %} +{% block modals %} +{% endblock modals %} + +{% block content %} +
+
+ +

{{ _('Database is being cached, please try again soon.') }}

+ +
+
+{% endblock content %} + +{% block scripts %} +{% endblock scripts %} diff --git a/Contents/Resources/web/templates/navbar.html b/Contents/Resources/web/templates/navbar.html new file mode 100644 index 00000000..d29ef6a9 --- /dev/null +++ b/Contents/Resources/web/templates/navbar.html @@ -0,0 +1,64 @@ + diff --git a/Contents/Resources/web/templates/translations.html b/Contents/Resources/web/templates/translations.html new file mode 100644 index 00000000..516f9176 --- /dev/null +++ b/Contents/Resources/web/templates/translations.html @@ -0,0 +1 @@ +{# This template is used to extract translations used in javascript files #} diff --git a/Contents/Strings/aa/LC_MESSAGES/themerr-plex.po b/Contents/Strings/aa/LC_MESSAGES/themerr-plex.po new file mode 100644 index 00000000..adc46b8d --- /dev/null +++ b/Contents/Strings/aa/LC_MESSAGES/themerr-plex.po @@ -0,0 +1,64 @@ +msgid "" +msgstr "" +"Project-Id-Version: lizardbyte\n" +"Report-Msgid-Bugs-To: github.com/themerr-plex\n" +"POT-Creation-Date: 2023-08-05 13:12-0400\n" +"PO-Revision-Date: 2023-08-06 14:47\n" +"Last-Translator: \n" +"Language-Team: Afar\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.9.1\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: lizardbyte\n" +"X-Crowdin-Project-ID: 606145\n" +"X-Crowdin-Language: aa\n" +"X-Crowdin-File: /[LizardByte.Themerr-plex] nightly/themerr-plex.po\n" +"X-Crowdin-File-ID: 25\n" +"Language: aa_ER\n" + +#: Contents/Resources/web/templates/home.html:65 +msgid "Title" +msgstr "crwdns5109:0crwdne5109:0" + +#: Contents/Resources/web/templates/home.html:66 +msgid "Year" +msgstr "crwdns5111:0crwdne5111:0" + +#: Contents/Resources/web/templates/home.html:67 +msgid "Contribute" +msgstr "crwdns5113:0crwdne5113:0" + +#: Contents/Resources/web/templates/home.html:81 +msgid "Add" +msgstr "crwdns5115:0crwdne5115:0" + +#: Contents/Resources/web/templates/home.html:84 +msgid "Edit" +msgstr "crwdns5117:0crwdne5117:0" + +#: Contents/Resources/web/templates/navbar.html:23 +msgid "Donate" +msgstr "crwdns5119:0crwdne5119:0" + +#: Contents/Resources/web/templates/navbar.html:28 +msgid "GitHub Sponsors" +msgstr "crwdns5121:0crwdne5121:0" + +#: Contents/Resources/web/templates/navbar.html:44 +msgid "Support" +msgstr "crwdns5123:0crwdne5123:0" + +#: Contents/Resources/web/templates/navbar.html:49 +msgid "Docs" +msgstr "crwdns5125:0crwdne5125:0" + +#: Contents/Resources/web/templates/navbar.html:53 +msgid "Support Center" +msgstr "crwdns5127:0crwdne5127:0" + +#: Contents/Resources/web/templates/navbar.html:59 +msgid "Home" +msgstr "crwdns5129:0crwdne5129:0" + diff --git a/Contents/Strings/de/LC_MESSAGES/themerr-plex.po b/Contents/Strings/de/LC_MESSAGES/themerr-plex.po new file mode 100644 index 00000000..4e2276eb --- /dev/null +++ b/Contents/Strings/de/LC_MESSAGES/themerr-plex.po @@ -0,0 +1,116 @@ +msgid "" +msgstr "" +"Project-Id-Version: lizardbyte\n" +"Report-Msgid-Bugs-To: github.com/themerr-plex\n" +"POT-Creation-Date: 2023-11-17 01:01+0000\n" +"PO-Revision-Date: 2023-11-17 01:35\n" +"Last-Translator: \n" +"Language-Team: German\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.9.1\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: lizardbyte\n" +"X-Crowdin-Project-ID: 606145\n" +"X-Crowdin-Language: de\n" +"X-Crowdin-File: /[LizardByte.Themerr-plex] nightly/themerr-plex.po\n" +"X-Crowdin-File-ID: 25\n" +"Language: de_DE\n" + +#: Contents/Resources/web/templates/home.html:34 +msgid "Games" +msgstr "Spiele" + +#: Contents/Resources/web/templates/home.html:35 +msgid "Movies" +msgstr "Filme" + +#: Contents/Resources/web/templates/home.html:77 +msgid "Collections" +msgstr "Sammlungen" + +#: Contents/Resources/web/templates/home.html:97 +msgid "Title" +msgstr "Titel" + +#: Contents/Resources/web/templates/home.html:98 +msgid "Type" +msgstr "Tippe" + +#: Contents/Resources/web/templates/home.html:99 +msgid "Year" +msgstr "Jahr" + +#: Contents/Resources/web/templates/home.html:100 +msgid "Contribute" +msgstr "Beitragen" + +#: Contents/Resources/web/templates/home.html:101 +msgid "Status" +msgstr "Status" + +#: Contents/Resources/web/templates/home.html:117 +msgid "Add" +msgstr "hinzufügen" + +#: Contents/Resources/web/templates/home.html:120 +msgid "Edit" +msgstr "bearbeiten" + +#: Contents/Resources/web/templates/home.html:135 +msgid "No known ID" +msgstr "Keine ID bekannt" + +#: Contents/Resources/web/templates/home.html:140 +msgid "User provided" +msgstr "" + +#: Contents/Resources/web/templates/home.html:142 +msgid "Themerr provided" +msgstr "" + +#: Contents/Resources/web/templates/home.html:144 +msgid "Unknown provider" +msgstr "" + +#: Contents/Resources/web/templates/home.html:146 +msgid "Missing from ThemerrDB" +msgstr "Fehlt in ThemerrDB" + +#: Contents/Resources/web/templates/home.html:148 +msgid "Failed to download" +msgstr "Download fehlgeschlagen" + +#: Contents/Resources/web/templates/home.html:150 +msgid "Unknown status" +msgstr "" + +#: Contents/Resources/web/templates/home_db_not_cached.html:9 +msgid "Database is being cached, please try again soon." +msgstr "" + +#: Contents/Resources/web/templates/navbar.html:23 +msgid "Donate" +msgstr "Spenden" + +#: Contents/Resources/web/templates/navbar.html:28 +msgid "GitHub Sponsors" +msgstr "GitHub Sponsoren" + +#: Contents/Resources/web/templates/navbar.html:44 +msgid "Support" +msgstr "Hilfe" + +#: Contents/Resources/web/templates/navbar.html:49 +msgid "Docs" +msgstr "Dokumente" + +#: Contents/Resources/web/templates/navbar.html:53 +msgid "Support Center" +msgstr "Hilfecenter" + +#: Contents/Resources/web/templates/navbar.html:59 +msgid "Home" +msgstr "Startseite" + diff --git a/Contents/Strings/en/LC_MESSAGES/themerr-plex.po b/Contents/Strings/en/LC_MESSAGES/themerr-plex.po new file mode 100644 index 00000000..008b2bea --- /dev/null +++ b/Contents/Strings/en/LC_MESSAGES/themerr-plex.po @@ -0,0 +1,116 @@ +msgid "" +msgstr "" +"Project-Id-Version: lizardbyte\n" +"Report-Msgid-Bugs-To: github.com/themerr-plex\n" +"POT-Creation-Date: 2023-11-17 01:01+0000\n" +"PO-Revision-Date: 2023-11-17 01:35\n" +"Last-Translator: \n" +"Language-Team: English\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.9.1\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: lizardbyte\n" +"X-Crowdin-Project-ID: 606145\n" +"X-Crowdin-Language: en\n" +"X-Crowdin-File: /[LizardByte.Themerr-plex] nightly/themerr-plex.po\n" +"X-Crowdin-File-ID: 25\n" +"Language: en_US\n" + +#: Contents/Resources/web/templates/home.html:34 +msgid "Games" +msgstr "Games" + +#: Contents/Resources/web/templates/home.html:35 +msgid "Movies" +msgstr "Movies" + +#: Contents/Resources/web/templates/home.html:77 +msgid "Collections" +msgstr "Collections" + +#: Contents/Resources/web/templates/home.html:97 +msgid "Title" +msgstr "Title" + +#: Contents/Resources/web/templates/home.html:98 +msgid "Type" +msgstr "Type" + +#: Contents/Resources/web/templates/home.html:99 +msgid "Year" +msgstr "Year" + +#: Contents/Resources/web/templates/home.html:100 +msgid "Contribute" +msgstr "Contribute" + +#: Contents/Resources/web/templates/home.html:101 +msgid "Status" +msgstr "Status" + +#: Contents/Resources/web/templates/home.html:117 +msgid "Add" +msgstr "Add" + +#: Contents/Resources/web/templates/home.html:120 +msgid "Edit" +msgstr "Edit" + +#: Contents/Resources/web/templates/home.html:135 +msgid "No known ID" +msgstr "No known ID" + +#: Contents/Resources/web/templates/home.html:140 +msgid "User provided" +msgstr "" + +#: Contents/Resources/web/templates/home.html:142 +msgid "Themerr provided" +msgstr "" + +#: Contents/Resources/web/templates/home.html:144 +msgid "Unknown provider" +msgstr "" + +#: Contents/Resources/web/templates/home.html:146 +msgid "Missing from ThemerrDB" +msgstr "Missing from ThemerrDB" + +#: Contents/Resources/web/templates/home.html:148 +msgid "Failed to download" +msgstr "Failed to download" + +#: Contents/Resources/web/templates/home.html:150 +msgid "Unknown status" +msgstr "" + +#: Contents/Resources/web/templates/home_db_not_cached.html:9 +msgid "Database is being cached, please try again soon." +msgstr "" + +#: Contents/Resources/web/templates/navbar.html:23 +msgid "Donate" +msgstr "Donate" + +#: Contents/Resources/web/templates/navbar.html:28 +msgid "GitHub Sponsors" +msgstr "GitHub Sponsors" + +#: Contents/Resources/web/templates/navbar.html:44 +msgid "Support" +msgstr "Support" + +#: Contents/Resources/web/templates/navbar.html:49 +msgid "Docs" +msgstr "Docs" + +#: Contents/Resources/web/templates/navbar.html:53 +msgid "Support Center" +msgstr "Support Center" + +#: Contents/Resources/web/templates/navbar.html:59 +msgid "Home" +msgstr "Home" + diff --git a/Contents/Strings/en_GB/LC_MESSAGES/themerr-plex.po b/Contents/Strings/en_GB/LC_MESSAGES/themerr-plex.po new file mode 100644 index 00000000..5f539450 --- /dev/null +++ b/Contents/Strings/en_GB/LC_MESSAGES/themerr-plex.po @@ -0,0 +1,116 @@ +msgid "" +msgstr "" +"Project-Id-Version: lizardbyte\n" +"Report-Msgid-Bugs-To: github.com/themerr-plex\n" +"POT-Creation-Date: 2023-11-17 01:01+0000\n" +"PO-Revision-Date: 2023-11-17 01:35\n" +"Last-Translator: \n" +"Language-Team: English, United Kingdom\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.9.1\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: lizardbyte\n" +"X-Crowdin-Project-ID: 606145\n" +"X-Crowdin-Language: en-GB\n" +"X-Crowdin-File: /[LizardByte.Themerr-plex] nightly/themerr-plex.po\n" +"X-Crowdin-File-ID: 25\n" +"Language: en_GB\n" + +#: Contents/Resources/web/templates/home.html:34 +msgid "Games" +msgstr "Games" + +#: Contents/Resources/web/templates/home.html:35 +msgid "Movies" +msgstr "Movies" + +#: Contents/Resources/web/templates/home.html:77 +msgid "Collections" +msgstr "Collections" + +#: Contents/Resources/web/templates/home.html:97 +msgid "Title" +msgstr "Title" + +#: Contents/Resources/web/templates/home.html:98 +msgid "Type" +msgstr "Type" + +#: Contents/Resources/web/templates/home.html:99 +msgid "Year" +msgstr "Year" + +#: Contents/Resources/web/templates/home.html:100 +msgid "Contribute" +msgstr "Contribute" + +#: Contents/Resources/web/templates/home.html:101 +msgid "Status" +msgstr "Status" + +#: Contents/Resources/web/templates/home.html:117 +msgid "Add" +msgstr "Add" + +#: Contents/Resources/web/templates/home.html:120 +msgid "Edit" +msgstr "Edit" + +#: Contents/Resources/web/templates/home.html:135 +msgid "No known ID" +msgstr "No known ID" + +#: Contents/Resources/web/templates/home.html:140 +msgid "User provided" +msgstr "" + +#: Contents/Resources/web/templates/home.html:142 +msgid "Themerr provided" +msgstr "" + +#: Contents/Resources/web/templates/home.html:144 +msgid "Unknown provider" +msgstr "" + +#: Contents/Resources/web/templates/home.html:146 +msgid "Missing from ThemerrDB" +msgstr "Missing from ThemerrDB" + +#: Contents/Resources/web/templates/home.html:148 +msgid "Failed to download" +msgstr "Failed to download" + +#: Contents/Resources/web/templates/home.html:150 +msgid "Unknown status" +msgstr "" + +#: Contents/Resources/web/templates/home_db_not_cached.html:9 +msgid "Database is being cached, please try again soon." +msgstr "" + +#: Contents/Resources/web/templates/navbar.html:23 +msgid "Donate" +msgstr "Donate" + +#: Contents/Resources/web/templates/navbar.html:28 +msgid "GitHub Sponsors" +msgstr "GitHub Sponsors" + +#: Contents/Resources/web/templates/navbar.html:44 +msgid "Support" +msgstr "Support" + +#: Contents/Resources/web/templates/navbar.html:49 +msgid "Docs" +msgstr "Docs" + +#: Contents/Resources/web/templates/navbar.html:53 +msgid "Support Center" +msgstr "Support Center" + +#: Contents/Resources/web/templates/navbar.html:59 +msgid "Home" +msgstr "Home" + diff --git a/Contents/Strings/en_US/LC_MESSAGES/themerr-plex.po b/Contents/Strings/en_US/LC_MESSAGES/themerr-plex.po new file mode 100644 index 00000000..de90a465 --- /dev/null +++ b/Contents/Strings/en_US/LC_MESSAGES/themerr-plex.po @@ -0,0 +1,116 @@ +msgid "" +msgstr "" +"Project-Id-Version: lizardbyte\n" +"Report-Msgid-Bugs-To: github.com/themerr-plex\n" +"POT-Creation-Date: 2023-11-17 01:01+0000\n" +"PO-Revision-Date: 2023-11-17 01:35\n" +"Last-Translator: \n" +"Language-Team: English, United States\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.9.1\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: lizardbyte\n" +"X-Crowdin-Project-ID: 606145\n" +"X-Crowdin-Language: en-US\n" +"X-Crowdin-File: /[LizardByte.Themerr-plex] nightly/themerr-plex.po\n" +"X-Crowdin-File-ID: 25\n" +"Language: en_US\n" + +#: Contents/Resources/web/templates/home.html:34 +msgid "Games" +msgstr "Games" + +#: Contents/Resources/web/templates/home.html:35 +msgid "Movies" +msgstr "Movies" + +#: Contents/Resources/web/templates/home.html:77 +msgid "Collections" +msgstr "Collections" + +#: Contents/Resources/web/templates/home.html:97 +msgid "Title" +msgstr "Title" + +#: Contents/Resources/web/templates/home.html:98 +msgid "Type" +msgstr "Type" + +#: Contents/Resources/web/templates/home.html:99 +msgid "Year" +msgstr "Year" + +#: Contents/Resources/web/templates/home.html:100 +msgid "Contribute" +msgstr "Contribute" + +#: Contents/Resources/web/templates/home.html:101 +msgid "Status" +msgstr "Status" + +#: Contents/Resources/web/templates/home.html:117 +msgid "Add" +msgstr "Add" + +#: Contents/Resources/web/templates/home.html:120 +msgid "Edit" +msgstr "Edit" + +#: Contents/Resources/web/templates/home.html:135 +msgid "No known ID" +msgstr "No known ID" + +#: Contents/Resources/web/templates/home.html:140 +msgid "User provided" +msgstr "" + +#: Contents/Resources/web/templates/home.html:142 +msgid "Themerr provided" +msgstr "" + +#: Contents/Resources/web/templates/home.html:144 +msgid "Unknown provider" +msgstr "" + +#: Contents/Resources/web/templates/home.html:146 +msgid "Missing from ThemerrDB" +msgstr "Missing from ThemerrDB" + +#: Contents/Resources/web/templates/home.html:148 +msgid "Failed to download" +msgstr "Failed to download" + +#: Contents/Resources/web/templates/home.html:150 +msgid "Unknown status" +msgstr "" + +#: Contents/Resources/web/templates/home_db_not_cached.html:9 +msgid "Database is being cached, please try again soon." +msgstr "" + +#: Contents/Resources/web/templates/navbar.html:23 +msgid "Donate" +msgstr "Donate" + +#: Contents/Resources/web/templates/navbar.html:28 +msgid "GitHub Sponsors" +msgstr "GitHub Sponsors" + +#: Contents/Resources/web/templates/navbar.html:44 +msgid "Support" +msgstr "Support" + +#: Contents/Resources/web/templates/navbar.html:49 +msgid "Docs" +msgstr "Docs" + +#: Contents/Resources/web/templates/navbar.html:53 +msgid "Support Center" +msgstr "Support Center" + +#: Contents/Resources/web/templates/navbar.html:59 +msgid "Home" +msgstr "Home" + diff --git a/Contents/Strings/es/LC_MESSAGES/themerr-plex.po b/Contents/Strings/es/LC_MESSAGES/themerr-plex.po new file mode 100644 index 00000000..d1d426e6 --- /dev/null +++ b/Contents/Strings/es/LC_MESSAGES/themerr-plex.po @@ -0,0 +1,116 @@ +msgid "" +msgstr "" +"Project-Id-Version: lizardbyte\n" +"Report-Msgid-Bugs-To: github.com/themerr-plex\n" +"POT-Creation-Date: 2023-11-17 01:01+0000\n" +"PO-Revision-Date: 2023-11-17 01:35\n" +"Last-Translator: \n" +"Language-Team: Spanish\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.9.1\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: lizardbyte\n" +"X-Crowdin-Project-ID: 606145\n" +"X-Crowdin-Language: es-ES\n" +"X-Crowdin-File: /[LizardByte.Themerr-plex] nightly/themerr-plex.po\n" +"X-Crowdin-File-ID: 25\n" +"Language: es_ES\n" + +#: Contents/Resources/web/templates/home.html:34 +msgid "Games" +msgstr "Juegos" + +#: Contents/Resources/web/templates/home.html:35 +msgid "Movies" +msgstr "Películas" + +#: Contents/Resources/web/templates/home.html:77 +msgid "Collections" +msgstr "Colecciones" + +#: Contents/Resources/web/templates/home.html:97 +msgid "Title" +msgstr "Título" + +#: Contents/Resources/web/templates/home.html:98 +msgid "Type" +msgstr "Tipo" + +#: Contents/Resources/web/templates/home.html:99 +msgid "Year" +msgstr "Año" + +#: Contents/Resources/web/templates/home.html:100 +msgid "Contribute" +msgstr "Contribuir" + +#: Contents/Resources/web/templates/home.html:101 +msgid "Status" +msgstr "Estado" + +#: Contents/Resources/web/templates/home.html:117 +msgid "Add" +msgstr "Añadir" + +#: Contents/Resources/web/templates/home.html:120 +msgid "Edit" +msgstr "Editar" + +#: Contents/Resources/web/templates/home.html:135 +msgid "No known ID" +msgstr "Sin identificación conocida" + +#: Contents/Resources/web/templates/home.html:140 +msgid "User provided" +msgstr "" + +#: Contents/Resources/web/templates/home.html:142 +msgid "Themerr provided" +msgstr "" + +#: Contents/Resources/web/templates/home.html:144 +msgid "Unknown provider" +msgstr "" + +#: Contents/Resources/web/templates/home.html:146 +msgid "Missing from ThemerrDB" +msgstr "Falta en ThemerrDB" + +#: Contents/Resources/web/templates/home.html:148 +msgid "Failed to download" +msgstr "Error de descarga" + +#: Contents/Resources/web/templates/home.html:150 +msgid "Unknown status" +msgstr "" + +#: Contents/Resources/web/templates/home_db_not_cached.html:9 +msgid "Database is being cached, please try again soon." +msgstr "" + +#: Contents/Resources/web/templates/navbar.html:23 +msgid "Donate" +msgstr "Donar" + +#: Contents/Resources/web/templates/navbar.html:28 +msgid "GitHub Sponsors" +msgstr "Patrocinadores de GitHub" + +#: Contents/Resources/web/templates/navbar.html:44 +msgid "Support" +msgstr "Soporte" + +#: Contents/Resources/web/templates/navbar.html:49 +msgid "Docs" +msgstr "Docs" + +#: Contents/Resources/web/templates/navbar.html:53 +msgid "Support Center" +msgstr "Centro de soporte" + +#: Contents/Resources/web/templates/navbar.html:59 +msgid "Home" +msgstr "Inicio" + diff --git a/Contents/Strings/fr/LC_MESSAGES/themerr-plex.po b/Contents/Strings/fr/LC_MESSAGES/themerr-plex.po new file mode 100644 index 00000000..e0893a7f --- /dev/null +++ b/Contents/Strings/fr/LC_MESSAGES/themerr-plex.po @@ -0,0 +1,116 @@ +msgid "" +msgstr "" +"Project-Id-Version: lizardbyte\n" +"Report-Msgid-Bugs-To: github.com/themerr-plex\n" +"POT-Creation-Date: 2023-11-17 01:01+0000\n" +"PO-Revision-Date: 2023-11-17 08:11\n" +"Last-Translator: \n" +"Language-Team: French\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.9.1\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"X-Crowdin-Project: lizardbyte\n" +"X-Crowdin-Project-ID: 606145\n" +"X-Crowdin-Language: fr\n" +"X-Crowdin-File: /[LizardByte.Themerr-plex] nightly/themerr-plex.po\n" +"X-Crowdin-File-ID: 25\n" +"Language: fr_FR\n" + +#: Contents/Resources/web/templates/home.html:34 +msgid "Games" +msgstr "Jeux" + +#: Contents/Resources/web/templates/home.html:35 +msgid "Movies" +msgstr "Films" + +#: Contents/Resources/web/templates/home.html:77 +msgid "Collections" +msgstr "Collections" + +#: Contents/Resources/web/templates/home.html:97 +msgid "Title" +msgstr "Titre" + +#: Contents/Resources/web/templates/home.html:98 +msgid "Type" +msgstr "Type" + +#: Contents/Resources/web/templates/home.html:99 +msgid "Year" +msgstr "Année" + +#: Contents/Resources/web/templates/home.html:100 +msgid "Contribute" +msgstr "Contribuer" + +#: Contents/Resources/web/templates/home.html:101 +msgid "Status" +msgstr "Statut" + +#: Contents/Resources/web/templates/home.html:117 +msgid "Add" +msgstr "Ajouter" + +#: Contents/Resources/web/templates/home.html:120 +msgid "Edit" +msgstr "Modifier" + +#: Contents/Resources/web/templates/home.html:135 +msgid "No known ID" +msgstr "Aucun ID connu" + +#: Contents/Resources/web/templates/home.html:140 +msgid "User provided" +msgstr "Fourni par l'utilisateur" + +#: Contents/Resources/web/templates/home.html:142 +msgid "Themerr provided" +msgstr "Fourni par Themerr" + +#: Contents/Resources/web/templates/home.html:144 +msgid "Unknown provider" +msgstr "Fournisseur inconnu" + +#: Contents/Resources/web/templates/home.html:146 +msgid "Missing from ThemerrDB" +msgstr "Absent de ThemerrDB" + +#: Contents/Resources/web/templates/home.html:148 +msgid "Failed to download" +msgstr "Téléchargement échoué" + +#: Contents/Resources/web/templates/home.html:150 +msgid "Unknown status" +msgstr "État Inconnu" + +#: Contents/Resources/web/templates/home_db_not_cached.html:9 +msgid "Database is being cached, please try again soon." +msgstr "La base de données est mise en cache, veuillez réessayer prochainement." + +#: Contents/Resources/web/templates/navbar.html:23 +msgid "Donate" +msgstr "Faire un don" + +#: Contents/Resources/web/templates/navbar.html:28 +msgid "GitHub Sponsors" +msgstr "GitHub Sponsors" + +#: Contents/Resources/web/templates/navbar.html:44 +msgid "Support" +msgstr "Assistance" + +#: Contents/Resources/web/templates/navbar.html:49 +msgid "Docs" +msgstr "Documentation" + +#: Contents/Resources/web/templates/navbar.html:53 +msgid "Support Center" +msgstr "Centre d'assistance" + +#: Contents/Resources/web/templates/navbar.html:59 +msgid "Home" +msgstr "Accueil" + diff --git a/Contents/Strings/it/LC_MESSAGES/themerr-plex.po b/Contents/Strings/it/LC_MESSAGES/themerr-plex.po new file mode 100644 index 00000000..baf17a2a --- /dev/null +++ b/Contents/Strings/it/LC_MESSAGES/themerr-plex.po @@ -0,0 +1,116 @@ +msgid "" +msgstr "" +"Project-Id-Version: lizardbyte\n" +"Report-Msgid-Bugs-To: github.com/themerr-plex\n" +"POT-Creation-Date: 2023-11-17 01:01+0000\n" +"PO-Revision-Date: 2023-11-17 01:35\n" +"Last-Translator: \n" +"Language-Team: Italian\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.9.1\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: lizardbyte\n" +"X-Crowdin-Project-ID: 606145\n" +"X-Crowdin-Language: it\n" +"X-Crowdin-File: /[LizardByte.Themerr-plex] nightly/themerr-plex.po\n" +"X-Crowdin-File-ID: 25\n" +"Language: it_IT\n" + +#: Contents/Resources/web/templates/home.html:34 +msgid "Games" +msgstr "Giochi" + +#: Contents/Resources/web/templates/home.html:35 +msgid "Movies" +msgstr "Film" + +#: Contents/Resources/web/templates/home.html:77 +msgid "Collections" +msgstr "Raccolte" + +#: Contents/Resources/web/templates/home.html:97 +msgid "Title" +msgstr "Titolo" + +#: Contents/Resources/web/templates/home.html:98 +msgid "Type" +msgstr "Tipo" + +#: Contents/Resources/web/templates/home.html:99 +msgid "Year" +msgstr "Anno" + +#: Contents/Resources/web/templates/home.html:100 +msgid "Contribute" +msgstr "Contribuisci" + +#: Contents/Resources/web/templates/home.html:101 +msgid "Status" +msgstr "Stato" + +#: Contents/Resources/web/templates/home.html:117 +msgid "Add" +msgstr "Aggiungi" + +#: Contents/Resources/web/templates/home.html:120 +msgid "Edit" +msgstr "Modifica" + +#: Contents/Resources/web/templates/home.html:135 +msgid "No known ID" +msgstr "ID non noto" + +#: Contents/Resources/web/templates/home.html:140 +msgid "User provided" +msgstr "" + +#: Contents/Resources/web/templates/home.html:142 +msgid "Themerr provided" +msgstr "" + +#: Contents/Resources/web/templates/home.html:144 +msgid "Unknown provider" +msgstr "" + +#: Contents/Resources/web/templates/home.html:146 +msgid "Missing from ThemerrDB" +msgstr "Manca da ThemerrDB" + +#: Contents/Resources/web/templates/home.html:148 +msgid "Failed to download" +msgstr "Impossibile scaricare" + +#: Contents/Resources/web/templates/home.html:150 +msgid "Unknown status" +msgstr "" + +#: Contents/Resources/web/templates/home_db_not_cached.html:9 +msgid "Database is being cached, please try again soon." +msgstr "" + +#: Contents/Resources/web/templates/navbar.html:23 +msgid "Donate" +msgstr "Dona" + +#: Contents/Resources/web/templates/navbar.html:28 +msgid "GitHub Sponsors" +msgstr "GitHub Sponsors" + +#: Contents/Resources/web/templates/navbar.html:44 +msgid "Support" +msgstr "Supporto" + +#: Contents/Resources/web/templates/navbar.html:49 +msgid "Docs" +msgstr "Documentazione" + +#: Contents/Resources/web/templates/navbar.html:53 +msgid "Support Center" +msgstr "Centro assistenza" + +#: Contents/Resources/web/templates/navbar.html:59 +msgid "Home" +msgstr "Pagina iniziale" + diff --git a/Contents/Strings/ru/LC_MESSAGES/themerr-plex.po b/Contents/Strings/ru/LC_MESSAGES/themerr-plex.po new file mode 100644 index 00000000..8c191b00 --- /dev/null +++ b/Contents/Strings/ru/LC_MESSAGES/themerr-plex.po @@ -0,0 +1,116 @@ +msgid "" +msgstr "" +"Project-Id-Version: lizardbyte\n" +"Report-Msgid-Bugs-To: github.com/themerr-plex\n" +"POT-Creation-Date: 2023-11-17 01:01+0000\n" +"PO-Revision-Date: 2023-11-17 01:35\n" +"Last-Translator: \n" +"Language-Team: Russian\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.9.1\n" +"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n" +"X-Crowdin-Project: lizardbyte\n" +"X-Crowdin-Project-ID: 606145\n" +"X-Crowdin-Language: ru\n" +"X-Crowdin-File: /[LizardByte.Themerr-plex] nightly/themerr-plex.po\n" +"X-Crowdin-File-ID: 25\n" +"Language: ru_RU\n" + +#: Contents/Resources/web/templates/home.html:34 +msgid "Games" +msgstr "Игры" + +#: Contents/Resources/web/templates/home.html:35 +msgid "Movies" +msgstr "Фильмы" + +#: Contents/Resources/web/templates/home.html:77 +msgid "Collections" +msgstr "Коллекции" + +#: Contents/Resources/web/templates/home.html:97 +msgid "Title" +msgstr "Заголовок" + +#: Contents/Resources/web/templates/home.html:98 +msgid "Type" +msgstr "Тип" + +#: Contents/Resources/web/templates/home.html:99 +msgid "Year" +msgstr "Год" + +#: Contents/Resources/web/templates/home.html:100 +msgid "Contribute" +msgstr "Внести вклад" + +#: Contents/Resources/web/templates/home.html:101 +msgid "Status" +msgstr "Статус" + +#: Contents/Resources/web/templates/home.html:117 +msgid "Add" +msgstr "Добавить" + +#: Contents/Resources/web/templates/home.html:120 +msgid "Edit" +msgstr "Редактировать" + +#: Contents/Resources/web/templates/home.html:135 +msgid "No known ID" +msgstr "Идентификация неизвестна" + +#: Contents/Resources/web/templates/home.html:140 +msgid "User provided" +msgstr "" + +#: Contents/Resources/web/templates/home.html:142 +msgid "Themerr provided" +msgstr "" + +#: Contents/Resources/web/templates/home.html:144 +msgid "Unknown provider" +msgstr "" + +#: Contents/Resources/web/templates/home.html:146 +msgid "Missing from ThemerrDB" +msgstr "Отсутствует в ThemerrDB" + +#: Contents/Resources/web/templates/home.html:148 +msgid "Failed to download" +msgstr "Не удалось загрузить" + +#: Contents/Resources/web/templates/home.html:150 +msgid "Unknown status" +msgstr "" + +#: Contents/Resources/web/templates/home_db_not_cached.html:9 +msgid "Database is being cached, please try again soon." +msgstr "" + +#: Contents/Resources/web/templates/navbar.html:23 +msgid "Donate" +msgstr "Пожертвовать" + +#: Contents/Resources/web/templates/navbar.html:28 +msgid "GitHub Sponsors" +msgstr "Спонсоры на GitHub" + +#: Contents/Resources/web/templates/navbar.html:44 +msgid "Support" +msgstr "Поддержка" + +#: Contents/Resources/web/templates/navbar.html:49 +msgid "Docs" +msgstr "Документация" + +#: Contents/Resources/web/templates/navbar.html:53 +msgid "Support Center" +msgstr "Центр поддержки" + +#: Contents/Resources/web/templates/navbar.html:59 +msgid "Home" +msgstr "Главная" + diff --git a/Contents/Strings/sv/LC_MESSAGES/themerr-plex.po b/Contents/Strings/sv/LC_MESSAGES/themerr-plex.po new file mode 100644 index 00000000..855b6b3a --- /dev/null +++ b/Contents/Strings/sv/LC_MESSAGES/themerr-plex.po @@ -0,0 +1,116 @@ +msgid "" +msgstr "" +"Project-Id-Version: lizardbyte\n" +"Report-Msgid-Bugs-To: github.com/themerr-plex\n" +"POT-Creation-Date: 2023-11-17 01:01+0000\n" +"PO-Revision-Date: 2023-11-17 01:35\n" +"Last-Translator: \n" +"Language-Team: Swedish\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.9.1\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Crowdin-Project: lizardbyte\n" +"X-Crowdin-Project-ID: 606145\n" +"X-Crowdin-Language: sv-SE\n" +"X-Crowdin-File: /[LizardByte.Themerr-plex] nightly/themerr-plex.po\n" +"X-Crowdin-File-ID: 25\n" +"Language: sv_SE\n" + +#: Contents/Resources/web/templates/home.html:34 +msgid "Games" +msgstr "Spel" + +#: Contents/Resources/web/templates/home.html:35 +msgid "Movies" +msgstr "Filmer" + +#: Contents/Resources/web/templates/home.html:77 +msgid "Collections" +msgstr "Samlingar" + +#: Contents/Resources/web/templates/home.html:97 +msgid "Title" +msgstr "Titel" + +#: Contents/Resources/web/templates/home.html:98 +msgid "Type" +msgstr "Typ" + +#: Contents/Resources/web/templates/home.html:99 +msgid "Year" +msgstr "Årtal" + +#: Contents/Resources/web/templates/home.html:100 +msgid "Contribute" +msgstr "Bidra" + +#: Contents/Resources/web/templates/home.html:101 +msgid "Status" +msgstr "Status" + +#: Contents/Resources/web/templates/home.html:117 +msgid "Add" +msgstr "Lägg till" + +#: Contents/Resources/web/templates/home.html:120 +msgid "Edit" +msgstr "Redigera" + +#: Contents/Resources/web/templates/home.html:135 +msgid "No known ID" +msgstr "Inget känt ID" + +#: Contents/Resources/web/templates/home.html:140 +msgid "User provided" +msgstr "" + +#: Contents/Resources/web/templates/home.html:142 +msgid "Themerr provided" +msgstr "" + +#: Contents/Resources/web/templates/home.html:144 +msgid "Unknown provider" +msgstr "" + +#: Contents/Resources/web/templates/home.html:146 +msgid "Missing from ThemerrDB" +msgstr "Saknas från ThemerrDB" + +#: Contents/Resources/web/templates/home.html:148 +msgid "Failed to download" +msgstr "Misslyckades med att ladda ner" + +#: Contents/Resources/web/templates/home.html:150 +msgid "Unknown status" +msgstr "" + +#: Contents/Resources/web/templates/home_db_not_cached.html:9 +msgid "Database is being cached, please try again soon." +msgstr "" + +#: Contents/Resources/web/templates/navbar.html:23 +msgid "Donate" +msgstr "Donera" + +#: Contents/Resources/web/templates/navbar.html:28 +msgid "GitHub Sponsors" +msgstr "GitHub-sponsorer" + +#: Contents/Resources/web/templates/navbar.html:44 +msgid "Support" +msgstr "Support" + +#: Contents/Resources/web/templates/navbar.html:49 +msgid "Docs" +msgstr "Dokumentation" + +#: Contents/Resources/web/templates/navbar.html:53 +msgid "Support Center" +msgstr "Online support" + +#: Contents/Resources/web/templates/navbar.html:59 +msgid "Home" +msgstr "Hem" + diff --git a/Contents/Strings/themerr-plex.po b/Contents/Strings/themerr-plex.po new file mode 100644 index 00000000..829f2050 --- /dev/null +++ b/Contents/Strings/themerr-plex.po @@ -0,0 +1,116 @@ +# Translations template for Themerr-plex. +# Copyright (C) 2023 Themerr-plex +# This file is distributed under the same license as the Themerr-plex +# project. +# FIRST AUTHOR , 2023. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: Themerr-plex v0\n" +"Report-Msgid-Bugs-To: github.com/themerr-plex\n" +"POT-Creation-Date: 2023-11-17 01:01+0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.9.1\n" + +#: Contents/Resources/web/templates/home.html:34 +msgid "Games" +msgstr "" + +#: Contents/Resources/web/templates/home.html:35 +msgid "Movies" +msgstr "" + +#: Contents/Resources/web/templates/home.html:77 +msgid "Collections" +msgstr "" + +#: Contents/Resources/web/templates/home.html:97 +msgid "Title" +msgstr "" + +#: Contents/Resources/web/templates/home.html:98 +msgid "Type" +msgstr "" + +#: Contents/Resources/web/templates/home.html:99 +msgid "Year" +msgstr "" + +#: Contents/Resources/web/templates/home.html:100 +msgid "Contribute" +msgstr "" + +#: Contents/Resources/web/templates/home.html:101 +msgid "Status" +msgstr "" + +#: Contents/Resources/web/templates/home.html:117 +msgid "Add" +msgstr "" + +#: Contents/Resources/web/templates/home.html:120 +msgid "Edit" +msgstr "" + +#: Contents/Resources/web/templates/home.html:135 +msgid "No known ID" +msgstr "" + +#: Contents/Resources/web/templates/home.html:140 +msgid "User provided" +msgstr "" + +#: Contents/Resources/web/templates/home.html:142 +msgid "Themerr provided" +msgstr "" + +#: Contents/Resources/web/templates/home.html:144 +msgid "Unknown provider" +msgstr "" + +#: Contents/Resources/web/templates/home.html:146 +msgid "Missing from ThemerrDB" +msgstr "" + +#: Contents/Resources/web/templates/home.html:148 +msgid "Failed to download" +msgstr "" + +#: Contents/Resources/web/templates/home.html:150 +msgid "Unknown status" +msgstr "" + +#: Contents/Resources/web/templates/home_db_not_cached.html:9 +msgid "Database is being cached, please try again soon." +msgstr "" + +#: Contents/Resources/web/templates/navbar.html:23 +msgid "Donate" +msgstr "" + +#: Contents/Resources/web/templates/navbar.html:28 +msgid "GitHub Sponsors" +msgstr "" + +#: Contents/Resources/web/templates/navbar.html:44 +msgid "Support" +msgstr "" + +#: Contents/Resources/web/templates/navbar.html:49 +msgid "Docs" +msgstr "" + +#: Contents/Resources/web/templates/navbar.html:53 +msgid "Support Center" +msgstr "" + +#: Contents/Resources/web/templates/navbar.html:59 +msgid "Home" +msgstr "" + diff --git a/Dockerfile b/Dockerfile index 5400ceed..5ae1aa9f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,6 +17,8 @@ RUN <<_DEPS set -e apt-get update -y apt-get install -y --no-install-recommends \ + npm=8.5.* \ + patch \ python2=2.7.18* \ python-pip=20.3.4* apt-get clean @@ -48,10 +50,31 @@ python2 -m pip --no-python-version-warning --disable-pip-version-check install - python2 ./scripts/build_plist.py _BUILD +# patch youtube-dl +WORKDIR /build/Contents/Libraries/Shared +RUN <<_YOUTUBE_DL_PATCH +#!/bin/bash +set -e +patch_dir=/build/patches +patch -p1 < "${patch_dir}/youtube_dl-compat.patch" +patch -p1 < "${patch_dir}/youtube_dl-extractor.patch" +_YOUTUBE_DL_PATCH + +WORKDIR /build + +# setup npm and dependencies +RUN <<_NPM +#!/bin/bash +set -e +npm install +mv ./node_modules ./Contents/Resources/web +_NPM + # clean RUN <<_CLEAN #!/bin/bash set -e +rm -rf ./patches/ rm -rf ./scripts/ # list contents ls -a diff --git a/README.rst b/README.rst index 6bd387fd..339a8b28 100644 --- a/README.rst +++ b/README.rst @@ -2,7 +2,7 @@ Overview ======== -LizardByte has the full documentation hosted on `Read the Docs `_. +LizardByte has the full documentation hosted on `Read the Docs `__. About ----- @@ -10,10 +10,10 @@ Themerr-plex is a metadata agent plug-in for Plex Media Player. The plug-in adds This plugin contributes to the following metadata agents. - - Plex Movie + - Plex Movie - `tv.plex.agents.movie` - Plex Movie (Legacy) - `com.plexapp.agents.imdb` - The Movie Database - `com.plexapp.agents.themoviedb` - - `RetroArcher `_ - `dev.lizardbyte.retroarcher-plex` + - `RetroArcher `__ - `dev.lizardbyte.retroarcher-plex` Integrations ------------ @@ -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/crowdin.yml b/crowdin.yml new file mode 100644 index 00000000..e9165198 --- /dev/null +++ b/crowdin.yml @@ -0,0 +1,22 @@ +--- +"base_path": "." +"base_url": "https://api.crowdin.com" # optional (for Crowdin Enterprise only) +"preserve_hierarchy": false # flatten tree on crowdin +"pull_request_labels": [ + "crowdin", + "l10n" +] + +"files": [ + { + "source": "/Contents/Strings/*.po", + "translation": "/Contents/Strings/%two_letters_code%/LC_MESSAGES/%original_file_name%", + "languages_mapping": { + "two_letters_code": { + # map non-two letter codes here, left side is crowdin designation, right side is babel designation + "en-GB": "en_GB", + "en-US": "en_US" + } + } + } +] 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/about/installation.rst b/docs/source/about/installation.rst index 29aff326..24ceb628 100644 --- a/docs/source/about/installation.rst +++ b/docs/source/about/installation.rst @@ -12,7 +12,7 @@ The bundle is cross platform, meaning Linux, macOS, and Windows are supported. #. Extract the contents to your Plex Media Server Plugins directory. .. Tip:: See - `How do I find the Plug-Ins folder `_ + `How do I find the Plug-Ins folder `__ for information specific to your Plex server install. Docker diff --git a/docs/source/about/troubleshooting.rst b/docs/source/about/troubleshooting.rst index 770ce2eb..baceb0a4 100644 --- a/docs/source/about/troubleshooting.rst +++ b/docs/source/about/troubleshooting.rst @@ -3,17 +3,34 @@ Troubleshooting =============== +Rate Limiting / Videos Not Downloading +-------------------------------------- + +By default, YouTube-DL will perform queries to YouTube anonymously. As a result, YouTube may rate limit the +requests, or sometimes simply block the content (e.g. for age-restricted content, but not only). + +Adding your YouTube credentials (e-mail and password) in Themerr's preference may fix the problem. Hoewever, +YouTube also sometimes changes the way its login page works, preventing YouTube-DL from using those credentials. + +A workaround is to login in a web browser, and then export your YouTube cookies with a tool such as `Get cookies.txt +locally `__. Note +that Themerr currently only supports Chromium's JSON export format. In the exporter you use, if prompted, you need to +use the "JSON" or "Chrome" format. + +You can then paste that value in the "YouTube Cookies" field in the plugin preferences page. On the next media update +or scheduled run, the cookies will be used and hopefully videos will start downloading again. + Plugin Logs ----------- -See `Plugin Log Files `_ for the plugin +See `Plugin Log Files `__ for the plugin log directory. Plex uses rolling logs. There will be six log files available. The newest log file will be named ``dev.lizardbyte.themerr-plex.log``. There will be additional log files with the same name, appended with a `1-5`. It is best to replicate the issue you are experiencing, then review the latest log file. The information in the log -file may seem cryptic. If so it would be best to reach out for `support `_. +file may seem cryptic. If so it would be best to reach out for `support `__. .. Attention:: Before uploading logs, it would be wise to review the data in the log file. Plex does not filter the masked settings (e.g. credentials) out of the log file. @@ -22,5 +39,5 @@ Plex Media Server Logs ---------------------- If you have a more severe problem, you may need to troubleshoot an issue beyond the plugin itself. See -`Plex Media Server Logs `_ +`Plex Media Server Logs `__ for more information. diff --git a/docs/source/about/usage.rst b/docs/source/about/usage.rst index 730437a1..38cae230 100644 --- a/docs/source/about/usage.rst +++ b/docs/source/about/usage.rst @@ -22,9 +22,50 @@ Minimal setup is required to use Themerr-plex. In addition to the installation, .. Attention:: It may take several minutes after completing a metadata refresh for a theme song to be available. +Web UI +------ + +A web interface is provided by the plugin. Currently the web ui only provides a couple of end points. + +/ (root) +^^^^^^^^ + +This endpoint will display a report showing the theme song status for each item in a library supported by Themerr-plex. +A supported library is any that has the default agent as one supported by Themerr-plex. + +The report provides an easy means to contribute to `ThemerrDB `__ by providing +`Add/Edit` buttons for items that can be added to ThemerrDB. + +/status +^^^^^^^ + +An endpoint that provides a JSON response. If a valid response is returned, Themerr-plex is running. + +**Example Response** + +.. code-block:: json + + { + "message":"Ok", + "result":"success" + } + Preferences ----------- +Plex Movie agent support +^^^^^^^^^^^^^^^^^^^^^^^^ + +Description + When enabled, Themerr-plex will add themes to movies using the Plex Movie agent. This is the new agent that is + not using the Plex plugin framework, so Themerr-plex cannot contribute to this agent with standard techniques. + Instead Themerr-plex will start a websocket server and listen for events from the Plex server. Whenever a movie + is added or has it's metadata refreshed, Themerr-plex will attempt to add a theme song to the movie (if the theme + song is available in ThemerrDB). + +Default + ``True`` + Prefer MP4A AAC Codec ^^^^^^^^^^^^^^^^^^^^^ @@ -35,13 +76,120 @@ Description the Opus codec. Default - True + ``True`` + +Remove unused theme songs +^^^^^^^^^^^^^^^^^^^^^^^^^ + +Description + When Themerr-plex uploads a theme song to the Plex server, it will remove any existing theme songs for the same + item. With this setting enabled, Themerr-plex can free up space in Plex's metadata directory. This will only remove + items that were uploaded by Themerr-plex or via the hidden Plex rest API method, it will not affect local media + assets. + +Default + ``True`` + +Remove unused art +^^^^^^^^^^^^^^^^^ + +Description + When Themerr-plex uploads art to the Plex server, it will remove any existing art for the same + item. With this setting enabled, Themerr-plex can free up space in Plex's metadata directory. This will only remove + items that are user uploaded, it will not affect items added by metadata agents or local media assets. + +Default + ``False`` + +Remove unused posters +^^^^^^^^^^^^^^^^^^^^^ + +Description + When Themerr-plex uploads posters to the Plex server, it will remove any existing posters for the same + item. With this setting enabled, Themerr-plex can free up space in Plex's metadata directory. This will only remove + items that are user uploaded, it will not affect items added by metadata agents or local media assets. + +Default + ``False`` + +Automatically update items +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Description + When enabled, Themerr-plex will periodically check for changes in ThemerrDB and apply the changes to the items in + your Plex Media Server automatically. + +Default + ``True`` + +Update movie themes during automatic update +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Description + When enabled, Themerr-plex will update movie themes during automatic updates. + +Default + ``True`` + +Update collection themes during automatic update +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Description + When enabled, Themerr-plex will update collection themes during automatic updates. + +Default + ``True`` + +Update collection metadata for Plex Movie agent +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Description + When enabled, Themerr-plex will update collection metadata for the Plex Movie agent during automatic updates. + Requires ``Update collection themes during automatic update`` to be enabled. + +Default + ``False`` + +Update collection metadata for legacy agents +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Description + When enabled, Themerr-plex will update collection metadata for legacy agents during automatic updates. Themerr-plex + must also be enabled in the agent settings. + Requires ``Update collection themes during automatic update`` to be enabled. + +Default + ``True`` + +Interval for automatic update task +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Description + The interval (in minutes) to run the automatic update task. + +Default + ``60`` + +Minimum + ``15`` + +Interval for database cache update task +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Description + The interval (in minutes) to run the database cache update task. This data is used to display the Web UI dashboard. + +Default + ``60`` + +Minimum + ``15`` PlexAPI Timeout ^^^^^^^^^^^^^^^ Description - The timeout (in seconds) when uploading theme audio to the Plex server. + The timeout (in seconds) when uploading media to the Plex server. Default ``180`` @@ -76,20 +224,58 @@ Default Minimum ``1`` -YouTube Username +YouTube Cookies ^^^^^^^^^^^^^^^^ Description - The YouTube Username to use. Supplying YouTube credentials will allow access to age restricted content. + The cookies to use for the requests to YouTube. Should be in Chromium JSON export format. + `Example exporter `__. Default None -YouTube Password -^^^^^^^^^^^^^^^^ +Web UI Locale +^^^^^^^^^^^^^ Description - The YouTube Password to use. Supplying YouTube credentials will allow access to age restricted content. + The localization value to use for translations. Default - None + ``en`` + +Web UI Host Address +^^^^^^^^^^^^^^^^^^^ + +Description + The host address to bind the Web UI to. + +.. Attention:: + Changing this value requires a Plex Media Server restart. + +Default + ``0.0.0.0`` + +Web UI Port +^^^^^^^^^^^ + +Description + The port to bind the Web UI to. + +.. Attention:: + Changing this value requires a Plex Media Server restart. + +Default + ``9494`` + +Log all web server messages +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Description + If set to ``True``, all web server messages will be logged. This will include logging requests and status codes when + requesting any resource. It is recommended to keep this disabled unless debugging. + +.. Attention:: + Changing this value requires a Plex Media Server restart. + +Default + ``False`` diff --git a/docs/source/code_docs/general_helper.rst b/docs/source/code_docs/general_helper.rst new file mode 100644 index 00000000..182e872a --- /dev/null +++ b/docs/source/code_docs/general_helper.rst @@ -0,0 +1,9 @@ +:github_url: https://github.com/LizardByte/Themerr-plex/tree/nightly/Contents/Code/general_helper.py + +.. include:: ../global.rst + +:modname:`general_helper` +-------------------------- +.. automodule:: Code.general_helper + :members: + :show-inheritance: diff --git a/docs/source/code_docs/lizardbyte_db_helper.rst b/docs/source/code_docs/lizardbyte_db_helper.rst new file mode 100644 index 00000000..c2a9bc2e --- /dev/null +++ b/docs/source/code_docs/lizardbyte_db_helper.rst @@ -0,0 +1,9 @@ +:github_url: https://github.com/LizardByte/Themerr-plex/tree/nightly/Contents/Code/lizardbyte_db_helper.py + +.. include:: ../global.rst + +:modname:`lizardbyte_db_helper` +------------------------------- +.. automodule:: Code.lizardbyte_db_helper + :members: + :show-inheritance: diff --git a/docs/source/code_docs/scheduled_tasks.rst b/docs/source/code_docs/scheduled_tasks.rst new file mode 100644 index 00000000..1dca784b --- /dev/null +++ b/docs/source/code_docs/scheduled_tasks.rst @@ -0,0 +1,9 @@ +:github_url: https://github.com/LizardByte/Themerr-plex/tree/nightly/Contents/Code/scheduled_tasks.py + +.. include:: ../global.rst + +:modname:`scheduled_tasks` +---------------------------- +.. automodule:: Code.scheduled_tasks + :members: + :show-inheritance: diff --git a/docs/source/code_docs/tmdb_helper.rst b/docs/source/code_docs/tmdb_helper.rst new file mode 100644 index 00000000..195ecec1 --- /dev/null +++ b/docs/source/code_docs/tmdb_helper.rst @@ -0,0 +1,9 @@ +:github_url: https://github.com/LizardByte/Themerr-plex/tree/nightly/Contents/Code/tmdb_helper.py + +.. include:: ../global.rst + +:modname:`tmdb_helper` +---------------------------- +.. automodule:: Code.tmdb_helper + :members: + :show-inheritance: diff --git a/docs/source/code_docs/webapp.rst b/docs/source/code_docs/webapp.rst new file mode 100644 index 00000000..1b5893d2 --- /dev/null +++ b/docs/source/code_docs/webapp.rst @@ -0,0 +1,9 @@ +:github_url: https://github.com/LizardByte/Themerr-plex/tree/nightly/Contents/Code/webapp.py + +.. include:: ../global.rst + +:modname:`webapp` +---------------------------- +.. automodule:: Code.webapp + :members: + :show-inheritance: diff --git a/docs/source/conf.py b/docs/source/conf.py index c2c6a65c..74148f9e 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -72,8 +72,8 @@ # -- Options for HTML output ------------------------------------------------- # images -html_favicon = os.path.join(root_dir, 'Contents', 'Resources', 'favicon.ico') -html_logo = os.path.join(root_dir, 'Contents', 'Resources', 'attribution.png') +html_favicon = os.path.join(root_dir, 'Contents', 'Resources', 'web', 'images', 'favicon.ico') +html_logo = os.path.join(root_dir, 'Contents', 'Resources', 'icon-default.png') # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, diff --git a/docs/source/contributing/build.rst b/docs/source/contributing/build.rst index 1334c980..e14ec16f 100644 --- a/docs/source/contributing/build.rst +++ b/docs/source/contributing/build.rst @@ -7,7 +7,7 @@ Python 2.7. Clone ----- -Ensure `git `_ is installed and run the following: +Ensure `git `__ is installed and run the following: .. code-block:: bash @@ -36,6 +36,26 @@ Build Plist python ./scripts/build_plist.py +npm dependencies +---------------- +Install nodejs and npm. Downloads available `here `__. + +Install npm dependencies. + .. code-block:: bash + + npm install + +Move modules directory. + Linux/macOS + .. code-block:: bash + + mv ./node_modules ./Contents/Resources/web + + Windows + .. code-block:: batch + + move .\node_modules .\Contents\Resources\web + Remote Build ------------ It may be beneficial to build remotely in some cases. This will enable easier building on different operating systems. diff --git a/docs/source/contributing/contributing.rst b/docs/source/contributing/contributing.rst index adbe1c0c..d64aca4e 100644 --- a/docs/source/contributing/contributing.rst +++ b/docs/source/contributing/contributing.rst @@ -4,4 +4,4 @@ Contributing ============ Read our contribution guide in our organization level -`docs `_. +`docs `__. diff --git a/docs/source/contributing/database.rst b/docs/source/contributing/database.rst index 21a299f6..94229fc2 100644 --- a/docs/source/contributing/database.rst +++ b/docs/source/contributing/database.rst @@ -3,5 +3,5 @@ Database ======== -The database of themes is held in our `ThemerrDB `_ repository. To contribute +The database of themes is held in our `ThemerrDB `__ repository. To contribute to the database, follow the documentation there. diff --git a/docs/source/contributing/testing.rst b/docs/source/contributing/testing.rst index 45b74946..e6770893 100644 --- a/docs/source/contributing/testing.rst +++ b/docs/source/contributing/testing.rst @@ -5,7 +5,7 @@ Testing Flake8 ------ -Themerr-plex uses `Flake8 `_ for enforcing consistent code styling. Flake is included +Themerr-plex uses `Flake8 `__ for enforcing consistent code styling. Flake8 is included in the ``requirements-dev.txt``. The config file for flake8 is ``.flake8``. This is already included in the root of the repo and should not be modified. @@ -17,10 +17,10 @@ Test with Flake8 Sphinx ------ -Themerr-plex uses `Sphinx `_ for documentation building. Sphinx is included +Themerr-plex uses `Sphinx `__ for documentation building. Sphinx is included in the ``requirements-dev.txt``. -Themerr-plex follows `numpydoc `_ styling and formatting in +Themerr-plex follows `numpydoc `__ styling and formatting in docstrings. This will be tested when building the docs. `numpydoc` is included in the ``requirements-dev.txt``. The config file for Sphinx is ``docs/source/conf.py``. This is already included in the root of the repo and should not @@ -39,14 +39,35 @@ 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 +Themerr-plex uses `pytest `__ for unit testing. pytest is included in the ``requirements-dev.txt``. 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/docs/source/toc.rst b/docs/source/toc.rst index 38f5c908..eac31f21 100644 --- a/docs/source/toc.rst +++ b/docs/source/toc.rst @@ -24,5 +24,10 @@ :titlesonly: code_docs/main + code_docs/general_helper + code_docs/lizardbyte_db_helper code_docs/plex_api_helper + code_docs/scheduled_tasks + code_docs/tmdb_helper + code_docs/webapp code_docs/youtube_dl_helper diff --git a/package.json b/package.json new file mode 100644 index 00000000..729e7337 --- /dev/null +++ b/package.json @@ -0,0 +1,8 @@ +{ + "dependencies": { + "@fontsource/open-sans": "5.0.17", + "@fortawesome/fontawesome-free": "6.5.0", + "bootstrap": "5.3.2", + "jquery": "3.7.1" + } +} diff --git a/patches/youtube_dl-compat.patch b/patches/youtube_dl-compat.patch new file mode 100644 index 00000000..9c878397 --- /dev/null +++ b/patches/youtube_dl-compat.patch @@ -0,0 +1,38 @@ +diff --git a/youtube_dl/compat.py b/youtube_dl/compat.py +index 3c526a78d..6e2a92d92 100644 +--- a/youtube_dl/compat.py ++++ b/youtube_dl/compat.py +@@ -58,18 +58,22 @@ except ImportError: # Python 2 + + # Also fix up lack of method arg in old Pythons + try: +- _req = compat_urllib_request.Request +- _req('http://127.0.0.1', method='GET') ++ type(compat_urllib_request.Request('http://127.0.0.1', method='GET')) + except TypeError: +- class _request(object): +- def __new__(cls, url, *args, **kwargs): +- method = kwargs.pop('method', None) +- r = _req(url, *args, **kwargs) +- if method: +- r.get_method = types.MethodType(lambda _: method, r) +- return r +- +- compat_urllib_request.Request = _request ++ def _add_init_method_arg(cls): ++ init = cls.__init__ ++ ++ def wrapped_init(self, *args, **kwargs): ++ method = kwargs.pop('method', 'GET') ++ init(self, *args, **kwargs) ++ if self.has_data() and method == 'GET': ++ method = 'POST' ++ self.get_method = types.MethodType(lambda _: method, self) ++ ++ cls.__init__ = wrapped_init ++ ++ _add_init_method_arg(compat_urllib_request.Request) ++ del _add_init_method_arg + + + try: diff --git a/patches/youtube_dl-extractor.patch b/patches/youtube_dl-extractor.patch new file mode 100644 index 00000000..384e6fd9 --- /dev/null +++ b/patches/youtube_dl-extractor.patch @@ -0,0 +1,25 @@ +diff --git a/youtube_dl/extractor/youtube.py b/youtube_dl/extractor/youtube.py +index 9c419c002..3bf483c1c 100644 +--- a/youtube_dl/extractor/youtube.py ++++ b/youtube_dl/extractor/youtube.py +@@ -260,16 +260,10 @@ class YoutubeBaseInfoExtractor(InfoExtractor): + cookies = self._get_cookies('https://www.youtube.com/') + if cookies.get('__Secure-3PSID'): + return +- consent_id = None +- consent = cookies.get('CONSENT') +- if consent: +- if 'YES' in consent.value: +- return +- consent_id = self._search_regex( +- r'PENDING\+(\d+)', consent.value, 'consent', default=None) +- if not consent_id: +- consent_id = random.randint(100, 999) +- self._set_cookie('.youtube.com', 'CONSENT', 'YES+cb.20210328-17-p0.en+FX+%s' % consent_id) ++ socs = cookies.get('SOCS') ++ if socs and not socs.value.startswith('CAA'): # not consented ++ return ++ self._set_cookie('.youtube.com', 'SOCS', 'CAI', secure=True) # accept all (required for mixes) + + def _real_initialize(self): + self._initialize_consent() diff --git a/requirements-dev.txt b/requirements-dev.txt index 405d8888..241779bf 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,7 +2,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 +plexhints==0.1.3 # type hinting library for plex development +plexapi-backport[alert]==4.15.6 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" diff --git a/requirements.txt b/requirements.txt index ea494ae1..e33a65fd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,19 +1,21 @@ # these requirements must support python 2.7 # it is doubtful that Plex will ever update to Python 3+ +flask==1.1.4;python_version<"3" +flask-babel==1.0.0;python_version<"3" future==0.18.3 +plexapi-backport[alert]==4.15.6 # 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" typing==3.10.0.0 +werkzeug==1.0.1;python_version<"3" # youtube_dl is not capable or willing to create a new release so have to install from git # youtube_dl==2021.12.17 -# unknown if dependabot can update this -# git+https://github.com/ytdl-org/youtube-dl.git@26035bde46c0acc30dc053618451d9aeca4b7709#egg=youtube_dl -https://github.com/ytdl-org/youtube-dl/archive/26035bde46c0acc30dc053618451d9aeca4b7709.zip#egg=youtube_dl +# dependabot cannot update this +# git+https://github.com/ytdl-org/youtube-dl.git@00ef748cc0e35ee60efd0f7a00e373ab8d1af86b#egg=youtube_dl +https://github.com/ytdl-org/youtube-dl/archive/00ef748cc0e35ee60efd0f7a00e373ab8d1af86b.zip#egg=youtube_dl -# custom python-plexapi supporting python 2.7 -# this is used to upload theme songs since Movie agents cannot correctly do so -# git+https://github.com/reenignearcher/python-plexapi.git@master-py2.7#egg=plexapi -https://github.com/reenignearcher/python-plexapi/archive/master-py2.7.zip#egg=plexapi - -# websocket-client is required for plexapi alert listener -websocket-client==0.59.0;python_version<"3" +# required for websocket to pass tests +pysocks==1.7.1;python_version<"3" +win-inet-pton==1.1.0;python_version<"3" and platform_system=="Windows" diff --git a/scripts/_locale.py b/scripts/_locale.py new file mode 100644 index 00000000..af1c4460 --- /dev/null +++ b/scripts/_locale.py @@ -0,0 +1,123 @@ +# coding=utf-8 +""" +.. + _locale.py + +Functions related to building, initializing, updating, and compiling localization translations. +""" +# standard imports +import argparse +import os +import subprocess + +project_name = 'Themerr-plex' + +script_dir = os.path.dirname(os.path.abspath(__file__)) +root_dir = os.path.dirname(script_dir) +locale_dir = os.path.join(root_dir, 'Contents', 'Strings') + +# target locales +target_locales = [ + 'de', # Deutsch + 'en', # English + 'en_GB', # English (United Kingdom) + 'en_US', # English (United States) + 'es', # español + 'fr', # français + 'it', # italiano + 'ru', # русский +] + + +def babel_extract(): + """Executes `pybabel extract` in subprocess.""" + commands = [ + 'pybabel', + 'extract', + '-F', os.path.join(script_dir, 'babel.cfg'), + '-o', os.path.join(locale_dir, '%s.po' % project_name.lower()), + '--sort-by-file', + '--msgid-bugs-address=github.com/%s' % project_name.lower(), + '--copyright-holder=%s' % project_name, + '--project=%s' % project_name, + '--version=v0', + '--add-comments=NOTE', + './Contents/Resources/web' + ] + + print(commands) + subprocess.check_output(args=commands, cwd=root_dir) + + +def babel_init(locale_code): + # type: (str) -> None + """Executes `pybabel init` in subprocess. + + :param locale_code: str - locale code + """ + commands = [ + 'pybabel', + 'init', + '-i', os.path.join(locale_dir, '%s.po' % project_name.lower()), + '-d', locale_dir, + '-D', project_name.lower(), + '-l', locale_code + ] + + print(commands) + subprocess.check_output(args=commands, cwd=root_dir) + + +def babel_update(): + """Executes `pybabel update` in subprocess.""" + commands = [ + 'pybabel', + 'update', + '-i', os.path.join(locale_dir, '%s.po' % project_name.lower()), + '-d', locale_dir, + '-D', project_name.lower(), + '--update-header-comment' + ] + + print(commands) + subprocess.check_output(args=commands, cwd=root_dir) + + +def babel_compile(): + """Executes `pybabel compile` in subprocess.""" + commands = [ + 'pybabel', + 'compile', + '-d', locale_dir, + '-D', project_name.lower() + ] + + print(commands) + subprocess.check_output(args=commands, cwd=root_dir) + + +if __name__ == '__main__': + # Set up and gather command line arguments + parser = argparse.ArgumentParser( + description='Script helps update locale translations. Translations must be done manually.') + + parser.add_argument('--extract', action='store_true', help='Extract messages from python files and templates.') + parser.add_argument('--init', action='store_true', help='Initialize any new locales specified in target locales.') + parser.add_argument('--update', action='store_true', help='Update existing locales.') + parser.add_argument('--compile', action='store_true', help='Compile translated locales.') + + args = parser.parse_args() + + if args.extract: + babel_extract() + + if args.init: + for locale_id in target_locales: + if not os.path.isdir(os.path.join(locale_dir, locale_id)): + babel_init(locale_code=locale_id) + + if args.update: + babel_update() + + if args.compile: + babel_compile() diff --git a/scripts/babel.cfg b/scripts/babel.cfg new file mode 100644 index 00000000..759e805a --- /dev/null +++ b/scripts/babel.cfg @@ -0,0 +1,2 @@ +[python: **.py] +[jinja2: **/templates/**.html] diff --git a/scripts/build_plist.py b/scripts/build_plist.py index 45b5a31e..14ec45cd 100644 --- a/scripts/build_plist.py +++ b/scripts/build_plist.py @@ -86,7 +86,7 @@ # Samsung, PlexConnect and Plex Home Theater # PlexPluginRegions: -# Possible string values are the proper ISO two letter code for the country. +# Possible string values are the proper ISO two-letter code for the country. # A full list of values are available at http://en.wikipedia.org/wiki/ISO_3166-1_alpha-2 # PlexPluginDebug: 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 dbdff526..789825aa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,22 +1,188 @@ +# -*- 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.server import PlexServer from plexhints.agent_kit import Agent 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 themerr_db_helper + from Code import webapp else: raise Exception('Contents directory not found') +# plex server setup +SERVER_BASEURL = plexapi.CONFIG.get("auth.server_baseurl") +SERVER_TOKEN = plexapi.CONFIG.get("auth.server_token") + +def wait_for_file(file_path, timeout=300): + # type: (str, int) -> None + found = False + count = 0 + while not found and count < timeout: # plugin takes a little while to start on macOS + count += 1 + if os.path.isfile(file_path): + found = True + else: + time.sleep(1) + assert found, "After {} seconds, {} file not found".format(timeout, file_path) + + +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 + + assert with_themes == total, ( + "Not all themes were uploaded in time, themes uploaded: {}/{}".format(with_themes, total)) + + +# basic fixtures @pytest.fixture def agent(): # type: () -> Agent return Themerr() + + +@pytest.fixture +def test_client(scope='function'): + """Create a test client for testing webapp endpoints""" + app = webapp.app + app.config['TESTING'] = True + + client = app.test_client() + + # Create a test client using the Flask application configured for testing + with client as test_client: + # 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(os.environ['PLEX_PLUGIN_LOG_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(os.environ['PLEX_PLUGIN_LOG_PATH'], "{}.log".format(constants.plugin_identifier)) + + wait_for_file(file_path=plugin_log_file, timeout=300) + + 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." + + return PlexServer(SERVER_BASEURL, SERVER_TOKEN, session=sess) + + +@pytest.fixture(scope="session") +def movies_new_agent(plex): + movies = plex.library.section("Movies") + wait_for_themes(movies=movies) + return movies + + +@pytest.fixture(scope="session") +def movies_imdb_agent(plex): + movies = plex.library.section("Movies-imdb") + wait_for_themes(movies=movies) + return movies + + +@pytest.fixture(scope="session") +def movies_themoviedb_agent(plex): + movies = plex.library.section("Movies-tmdb") + wait_for_themes(movies=movies) + return movies + + +@pytest.fixture(scope="session") +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(scope="session") +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(scope="session") +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 + ) + + +@pytest.fixture(scope='function') +def empty_themerr_db_cache(): + themerr_db_helper.database_cache = {} # reset the cache + themerr_db_helper.last_cache_update = 0 + return 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/__init__.py b/tests/functional/__init__.py new file mode 100644 index 00000000..e69de29b 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..ca45b9ac --- /dev/null +++ b/tests/functional/test_plex_plugin.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +# standard imports +import os + + +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_logs(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" + + +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)) + + +def test_movies_new_agent(movies_new_agent): + _check_themes(movies_new_agent) + + +def test_movies_imdb_agent(movies_imdb_agent): + _check_themes(movies_imdb_agent) + + +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 new file mode 100644 index 00000000..ba60cd19 --- /dev/null +++ b/tests/functional/test_webapp.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# lib imports +import pytest + + +def test_home(test_client): + """ + WHEN the '/' page is requested (GET) + THEN check that the response is valid + + Repeat for '/home' + """ + try: + response = test_client.get('/') + except AttributeError: + pytest.skip("cannot access Plex token/server") + else: + assert response.status_code == 200 + + response = test_client.get('/home') + assert response.status_code == 200 + + +def test_image(test_client): + """ + WHEN the '/favicon.ico' file is requested (GET) + THEN check that the response is valid + THEN check the content type is 'image/vnd.microsoft.icon' + """ + response = test_client.get('favicon.ico') + assert response.status_code == 200 + assert response.content_type == 'image/vnd.microsoft.icon' + + +def test_status(test_client): + """ + WHEN the '/status' page is requested (GET) + THEN check that the response is valid + """ + response = test_client.get('/status') + assert response.status_code == 200 + assert response.content_type == 'application/json' 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..7f044d78 --- /dev/null +++ b/tests/unit/test_general_helper.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +# standard imports +import os +import shutil + +# lib imports +import pytest + +# local imports +from Code import constants +from Code import general_helper + + +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) + + +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') + + +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 + + +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 + + +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 + ) + + +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_scheduled_tasks.py b/tests/unit/test_scheduled_tasks.py new file mode 100644 index 00000000..e735bb2b --- /dev/null +++ b/tests/unit/test_scheduled_tasks.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# standard imports +import time + +# local imports +from Code import scheduled_tasks + + +def test_run_threaded(): + def hello_world(): + time.sleep(10) + return 'Hello, world!' + + test_thread = scheduled_tasks.run_threaded(target=hello_world, daemon=True) + assert test_thread.is_alive() + + test_thread.join() + assert not test_thread.is_alive() + + +def test_schedule_loop(): + test_thread = scheduled_tasks.run_threaded(target=scheduled_tasks.schedule_loop, daemon=True) + assert test_thread.is_alive() + + +def test_setup_scheduling(): + scheduled_tasks.setup_scheduling() + assert scheduled_tasks.schedule.jobs diff --git a/tests/unit/test_themerr_db_helper.py b/tests/unit/test_themerr_db_helper.py new file mode 100644 index 00000000..c1bf53bc --- /dev/null +++ b/tests/unit/test_themerr_db_helper.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- + +# local imports +from Code import plex_api_helper +from Code import themerr_db_helper + + +def test_update_cache(empty_themerr_db_cache): + themerr_db_helper.update_cache() + assert themerr_db_helper.last_cache_update > 0, 'Cache update did not complete' + + assert "movies" in themerr_db_helper.database_cache, 'Cache does not contain movies' + assert "movie_collections" in themerr_db_helper.database_cache, 'Cache does not contain movie_collections' + assert "games" in themerr_db_helper.database_cache, 'Cache does not contain games' + assert "game_collections" in themerr_db_helper.database_cache, 'Cache does not contain game_collections' + assert "game_franchises" in themerr_db_helper.database_cache, 'Cache does not contain game_franchises' + + +def test_item_exists(empty_themerr_db_cache, movies_new_agent, movies_imdb_agent, movies_themoviedb_agent): + movies = movies_new_agent.all() + movies.extend(movies_imdb_agent.all()) + movies.extend(movies_themoviedb_agent.all()) + + for item in movies: + database_info = plex_api_helper.get_database_info(item=item) + + database_type = database_info[0] + database = database_info[1] + database_id = database_info[3] + + assert themerr_db_helper.item_exists(database_type=database_type, database=database, id=database_id), \ + '{} {} {} does not exist in ThemerrDB'.format(database, database_type, database_id) + + +def test_item_exists_with_invalid_database(): + # movie is not valid... the correct type is movies + assert not themerr_db_helper.item_exists(database_type='movie', database='invalid', id='invalid'), \ + 'Invalid database should not exist in ThemerrDB' 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_webapp.py b/tests/unit/test_webapp.py new file mode 100644 index 00000000..2a68fd1c --- /dev/null +++ b/tests/unit/test_webapp.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- + +# standard imports +import json +import os + +# local imports +from Code import webapp + + +def test_cache_data(): + webapp.cache_data() + assert os.path.isfile(webapp.database_cache_file), "Database cache file not found" + + with open(webapp.database_cache_file, 'r') as f: + data = json.load(f) + + assert data, "Database cache file is empty" diff --git a/tests/unit/test_youtube_dl_helper.py b/tests/unit/test_youtube_dl_helper.py index 15a9a68b..1486aacd 100644 --- a/tests/unit/test_youtube_dl_helper.py +++ b/tests/unit/test_youtube_dl_helper.py @@ -1,7 +1,4 @@ -# lib imports -import pytest -from youtube_dl import DownloadError - +# -*- coding: utf-8 -*- # local imports from Code import youtube_dl_helper @@ -10,17 +7,20 @@ 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', 'https://blahblahblah', ] for url in invalid_urls: - with pytest.raises(DownloadError): - youtube_dl_helper.process_youtube(url=url) + audio_url = youtube_dl_helper.process_youtube(url=url) + assert audio_url is None