├── .editorconfig ├── .github ├── release-drafter.yaml ├── release-next.env ├── renovate.json └── workflows │ ├── build.yml │ ├── codeql.yml │ ├── publish.yaml │ ├── release-drafter.yaml │ ├── release-next-checkbox-update.yaml │ ├── release-next-create-pr.yaml │ ├── release-next-publish.yaml │ ├── test-action.yaml │ └── test.yaml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE.txt ├── Metadata.md ├── README.md ├── action.yaml ├── build_plugin.sh ├── jprm ├── __init__.py └── __main__.py ├── requirements-dev.txt ├── requirements.txt ├── setup.py ├── tests ├── __init__.py ├── data │ ├── image.png │ ├── jprm.json │ ├── jprm.yaml │ ├── manifest_pluginA.json │ ├── manifest_pluginA2.json │ ├── manifest_pluginAB.json │ ├── manifest_pluginAB2.json │ ├── manifest_pluginB.json │ ├── pluginA_1.0.0.zip │ ├── pluginA_1.1.0.zip │ └── pluginB_1.0.0.zip ├── test_plugin.py ├── test_repo.py ├── test_repo_add_external.py ├── test_repo_remove.py ├── test_slugify.py └── test_utils.py └── tox.ini /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | charset = utf-8 7 | end_of_line = lf 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.{yml,yaml}] 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.github/release-drafter.yaml: -------------------------------------------------------------------------------- 1 | name-template: 'Version $RESOLVED_VERSION' 2 | tag-template: 'v$RESOLVED_VERSION' 3 | commitish: refs/heads/master 4 | categories: 5 | - title: ":boom: Breaking changes" 6 | labels: 7 | - breaking 8 | - title: ":fire: Removed" 9 | labels: 10 | - removed 11 | - title: ":rewind: Reverted Changes" 12 | labels: 13 | - revert 14 | - reverted 15 | - title: ':rocket: Features' 16 | labels: 17 | - 'feature' 18 | - 'enhancement' 19 | - title: ':bug: Bug Fixes' 20 | labels: 21 | - 'fix' 22 | - 'bugfix' 23 | - 'bug' 24 | - title: ':toolbox: Maintenance' 25 | labels: 26 | - 'chore' 27 | - title: ":arrow_up: Dependency updates" 28 | labels: 29 | - dependencies # Default label used by Dependabot 30 | - title: ":construction_worker: CI & build changes" 31 | collapse-after: 5 32 | labels: 33 | - ci 34 | - build 35 | - title: ":memo: Documentation updates" 36 | labels: 37 | - documentation 38 | - title: ":white_check_mark: Tests" 39 | labels: 40 | - test 41 | - tests 42 | exclude-labels: 43 | - no-changelog 44 | - skip-changelog 45 | - invalid 46 | change-template: '- $TITLE ([#$NUMBER]($URL)) @$AUTHOR' 47 | change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks. 48 | version-resolver: 49 | major: 50 | labels: 51 | - 'major' 52 | - 'breaking' 53 | minor: 54 | labels: 55 | - 'minor' 56 | - 'feature' 57 | patch: 58 | labels: 59 | - 'patch' 60 | default: patch 61 | 62 | autolabeler: 63 | - label: 'documentation' 64 | files: 65 | - '*.md' 66 | - 'build_plugin.sh' 67 | - label: 'build' 68 | files: 69 | - 'setup.py' 70 | - label: 'ci' 71 | files: 72 | - '.github/**' 73 | - label: 'chore' 74 | files: 75 | - 'tox.ini' 76 | - '.gitignore' 77 | - label: 'dependencies' 78 | files: 79 | - 'requirements.txt' 80 | template: | 81 | ## Changes 82 | 83 | $CHANGES 84 | -------------------------------------------------------------------------------- /.github/release-next.env: -------------------------------------------------------------------------------- 1 | REFRESH_CHECKBOX='Refresh release draft and update this PR' 2 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base", 4 | ":dependencyDashboard", 5 | ":timezone(Etc/UTC)", 6 | ":preserveSemverRanges" 7 | ], 8 | "labels": ["dependencies"], 9 | "internalChecksFilter": "strict", 10 | "rebaseWhen": "conflicted", 11 | "packageRules": [ 12 | { 13 | "description": "Add the ci and github-actions GitHub label to GitHub Action bump PRs", 14 | "matchManagers": ["github-actions"], 15 | "labels": ["ci-dependencies"] 16 | } 17 | ], 18 | "pip_requirements": { 19 | "fileMatch": ["requirements(-[a-z]+)?\\.txt$"] 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build Python Package 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout repository 13 | uses: actions/checkout@v4 14 | 15 | - name: Set up Python 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: '3.10' 19 | 20 | - uses: actions/cache@v4.1.1 21 | with: 22 | path: ~/.cache/pip 23 | key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} 24 | restore-keys: | 25 | ${{ runner.os }}-pip- 26 | 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | pip install setuptools wheel 31 | 32 | - name: Build package 33 | run: | 34 | python setup.py sdist bdist_wheel 35 | 36 | - uses: actions/upload-artifact@v4 37 | with: 38 | name: build-artifact 39 | retention-days: 30 40 | if-no-files-found: error 41 | path: dist 42 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: CodeQL Analysis 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | schedule: 11 | - cron: '38 8 * * 6' 12 | 13 | jobs: 14 | analyze: 15 | runs-on: ubuntu-latest 16 | if: ${{ github.repository == 'oddstr13/jellyfin-plugin-repository-manager' }} 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | language: [ 'python' ] 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@v4 24 | 25 | - name: Initialize CodeQL 26 | uses: github/codeql-action/init@v3 27 | with: 28 | languages: ${{ matrix.language }} 29 | queries: +security-and-quality 30 | 31 | - name: Set up Python 32 | uses: actions/setup-python@v5 33 | with: 34 | python-version: '3.10' 35 | 36 | - name: Autobuild 37 | uses: github/codeql-action/autobuild@v3 38 | 39 | - name: Perform CodeQL Analysis 40 | uses: github/codeql-action/analyze@v3 41 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publish Python Package 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | workflow_dispatch: 8 | inputs: 9 | upload_url: 10 | required: true 11 | description: upload url of the release the assets need to get uploaded to 12 | workflow_call: 13 | inputs: 14 | upload_url: 15 | required: true 16 | type: string 17 | 18 | jobs: 19 | build-publish: 20 | runs-on: ubuntu-latest 21 | environment: release 22 | permissions: 23 | id-token: write 24 | contents: write 25 | steps: 26 | - name: Checkout repository 27 | uses: actions/checkout@v4 28 | 29 | - name: Set up Python 30 | uses: actions/setup-python@v5 31 | with: 32 | python-version: '3.10' 33 | 34 | - name: Install dependencies 35 | run: | 36 | python -m pip install --upgrade pip 37 | pip install setuptools wheel 38 | pip install twine 39 | 40 | - name: Build package 41 | run: | 42 | python setup.py sdist bdist_wheel 43 | twine check dist/* 44 | 45 | - uses: actions/upload-artifact@v4 46 | with: 47 | name: build-artifact 48 | retention-days: 30 49 | if-no-files-found: error 50 | path: dist 51 | 52 | - name: Publish to PyPI 53 | uses: pypa/gh-action-pypi-publish@v1.10.3 54 | 55 | - name: Debug upload_url 56 | if: ${{ always() }} 57 | run: | 58 | echo "event_name: ${{ github.event_name }}" 59 | echo "release.upload_url: ${{ github.event.release.upload_url }}" 60 | echo "inputs.upload_url: ${{ inputs.upload_url }}" 61 | echo "ternary output: ${{ github.event_name == 'release' && github.event.release.upload_url || inputs.upload_url }}" 62 | 63 | - name: Upload GitHub Release Artifacts 64 | uses: shogo82148/actions-upload-release-asset@v1 65 | with: 66 | upload_url: "${{ github.event_name == 'release' && github.event.release.upload_url || inputs.upload_url }}" 67 | asset_path: 'dist/*' 68 | overwrite: true 69 | 70 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yaml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request_target: # autolabeler 8 | types: [opened, reopened, synchronize] 9 | 10 | jobs: 11 | update_release_draft: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: release-drafter/release-drafter@v6.0.0 15 | with: 16 | config-name: release-drafter.yaml 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | -------------------------------------------------------------------------------- /.github/workflows/release-next-checkbox-update.yaml: -------------------------------------------------------------------------------- 1 | name: Update release prepp PR (manual) 2 | on: 3 | pull_request: 4 | types: 5 | - edited 6 | 7 | jobs: 8 | detect: 9 | if: github.head_ref == 'release/next' 10 | runs-on: ubuntu-latest 11 | outputs: 12 | checked: ${{ steps.detect.outputs.checked }} 13 | unchecked: ${{ steps.detect.outputs.unchecked }} 14 | refresh_checkbox: ${{ steps.dotenv.outputs.refresh_checkbox }} 15 | steps: 16 | - name: Checkbox Trigger 17 | id: detect 18 | uses: karlderkaefer/github-action-checkbox-trigger@v1.1.7 19 | with: 20 | github-token: ${{ github.token }} 21 | action: detect 22 | 23 | - name: List changes 24 | run: | 25 | echo "checked=${{ steps.detect.outputs.checked }}" 26 | echo "unchecked=${{ steps.detect.outputs.unchecked }}" 27 | 28 | - name: Checkout repository 29 | uses: actions/checkout@v4 30 | 31 | - name: Dotenv Action 32 | id: dotenv 33 | uses: falti/dotenv-action@v1.1.4 34 | with: 35 | path: ./.github/release-next.env 36 | 37 | update: 38 | name: Call prepp PR workflow 39 | needs: detect 40 | if: ${{ contains(needs.detect.outputs.checked, needs.detect.outputs.refresh_checkbox) }} 41 | uses: ./.github/workflows/release-next-create-pr.yaml 42 | -------------------------------------------------------------------------------- /.github/workflows/release-next-create-pr.yaml: -------------------------------------------------------------------------------- 1 | name: Release prepp PR 2 | on: 3 | push: 4 | branches: 5 | - master 6 | workflow_call: 7 | workflow_dispatch: 8 | 9 | concurrency: 10 | group: ${{ github.workflow }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | detect: 15 | name: "Load env variables" 16 | runs-on: ubuntu-latest 17 | outputs: 18 | refresh_checkbox: ${{ steps.dotenv.outputs.refresh_checkbox }} 19 | steps: 20 | - name: Checkout repository 21 | uses: actions/checkout@v4 22 | 23 | - name: Dotenv Action 24 | id: dotenv 25 | uses: falti/dotenv-action@v1.1.4 26 | with: 27 | path: ./.github/release-next.env 28 | 29 | draft: 30 | name: "Update draft (and grab variables)" 31 | needs: detect 32 | runs-on: ubuntu-latest 33 | permissions: 34 | contents: write 35 | outputs: 36 | tag_name: ${{ steps.draft.outputs.tag_name }} 37 | body: ${{ steps.draft.outputs.body }} 38 | steps: 39 | - name: Update Draft 40 | uses: release-drafter/release-drafter@v6.0.0 41 | id: draft 42 | env: 43 | GITHUB_TOKEN: ${{ github.token }} 44 | with: 45 | config-name: release-drafter.yaml 46 | crud: 47 | name: "Create Release PR" 48 | needs: 49 | - detect 50 | - draft 51 | runs-on: ubuntu-latest 52 | permissions: 53 | pull-requests: write 54 | contents: write 55 | steps: 56 | - name: Checkout repository 57 | uses: actions/checkout@v4 58 | with: 59 | ref: refs/heads/master 60 | 61 | - name: Update version 62 | run: | 63 | TAG="${{ needs.draft.outputs.tag_name }}" 64 | NEXT_VERSION="${TAG#v}" 65 | 66 | sed -i -e 's/^__version__ *=.*$/__version__ = "'"${NEXT_VERSION}"'"/' jprm/__init__.py 67 | git add jprm/__init__.py 68 | 69 | echo "NEXT_VERSION=${NEXT_VERSION}" >> $GITHUB_ENV 70 | 71 | - name: Commit changes 72 | run: | 73 | git config user.name "jellyfin-bot" 74 | git config user.email "team@jellyfin.org" 75 | 76 | git checkout -b release/next 77 | git commit -m "Bump version to ${{ env.NEXT_VERSION }}" 78 | 79 | git push -f origin release/next 80 | 81 | - name: Create or update PR 82 | uses: k3rnels-actions/pr-update@v2.1.0 83 | with: 84 | token: ${{ github.token }} 85 | pr_title: Prepare for release ${{ needs.draft.outputs.tag_name }} 86 | pr_source: release/next 87 | pr_labels: 'release-prep,skip-changelog' 88 | pr_body: | 89 | :robot: This is a generated PR to bump the `release.yaml` version and update the changelog. 90 | 91 | - [ ] ${{ needs.detect.outputs.refresh_checkbox }} 92 | Check this box if you have updated tags and titles. 93 | 94 | --- 95 | 96 | ${{ needs.draft.outputs.body }} 97 | -------------------------------------------------------------------------------- /.github/workflows/release-next-publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publish release now 2 | on: 3 | pull_request: 4 | types: 5 | - closed 6 | branches: 7 | - 'master' 8 | 9 | jobs: 10 | if_merged: 11 | if: github.head_ref == 'release/next' && github.event.pull_request.merged == true 12 | runs-on: ubuntu-latest 13 | permissions: 14 | contents: write 15 | outputs: 16 | upload_url: ${{ steps.draft.outputs.upload_url }} 17 | steps: 18 | - name: Publish release 19 | uses: release-drafter/release-drafter@v6.0.0 20 | id: draft 21 | env: 22 | GITHUB_TOKEN: ${{ github.token }} 23 | with: 24 | config-name: release-drafter.yaml 25 | publish: true 26 | 27 | - name: Checkout repository 28 | uses: actions/checkout@v4 29 | 30 | - name: Delete branch 31 | continue-on-error: true 32 | run: git push origin --delete 'release/next' 33 | 34 | do_publish: 35 | name: Call release workflow 36 | needs: if_merged 37 | uses: ./.github/workflows/publish.yaml 38 | with: 39 | upload_url: ${{ needs.if_merged.outputs.upload_url }} 40 | -------------------------------------------------------------------------------- /.github/workflows/test-action.yaml: -------------------------------------------------------------------------------- 1 | name: Test Composite Action 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | workflow_dispatch: 11 | 12 | jobs: 13 | test-action: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout Repo 17 | uses: actions/checkout@v4 18 | 19 | - name: Setup .Net 20 | uses: actions/setup-dotnet@v4 21 | with: 22 | dotnet-version: '6.0.x' 23 | 24 | - name: Checkout Jellyfin Template Plugin 25 | uses: actions/checkout@v4 26 | with: 27 | repository: 'jellyfin/jellyfin-plugin-template' 28 | path: 'plugin-template' 29 | 30 | - name: Build Template Plugin with Action 31 | uses: ./ 32 | id: jprm 33 | with: 34 | verbosity: 'debug' 35 | path: 'plugin-template' 36 | output: 'plugin-template/artifacts' 37 | 38 | - name: List Output Artifact 39 | run: 'ls -la ${{ steps.jprm.outputs.artifact }}' 40 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test JPRM 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v4 21 | 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v5 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | python -m pip install -r requirements-dev.txt 31 | python -m pip install -r requirements.txt 32 | 33 | - name: Lint with flake8 34 | run: | 35 | # stop the build if there are Python syntax errors or undefined names 36 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 37 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 38 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 39 | 40 | - name: Run tests 41 | run: | 42 | coverage run 43 | coverage report 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.py[ocd] 3 | *.egg-info 4 | /build/ 5 | /dist/ 6 | .idea 7 | /venv/ 8 | 9 | .mypy_cache/ 10 | .pytest_cache/ 11 | .coverage 12 | htmlcov/ 13 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.linting.flake8Enabled": true, 3 | "python.testing.unittestEnabled": false, 4 | "python.testing.pytestEnabled": true, 5 | "python.linting.mypyEnabled": true 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /Metadata.md: -------------------------------------------------------------------------------- 1 | # Plugin metadata file 2 | 3 | The build metadata file should be placed in one of the [following locations][CONFIG_LOCATIONS]: 4 | 5 | - `jprm.yaml` 6 | - `.jprm.yaml` 7 | - `.ci/jprm.yaml` 8 | - `.github/jprm.yaml` 9 | - `.gitlab/jprm.yaml` 10 | - `meta.yaml` 11 | - `build.yaml` 12 | 13 | If there are multiple, the first file found is used. 14 | 15 | | key | type | required | comment | 16 | |-----|------|----------|---------| 17 | | `name` | string | ✔️ | Full plugin name | 18 | | `guid` | string | ✔️ | Plugin GUID – Must be unique! | 19 | | `image` | string | ❌ | Defaults to [`image.png`][DEFAULT_IMAGE_FILE] if present | 20 | | `imageUrl` | string | ❌ | Fully qualified link to the image to display in the plugin catalog. Gets overwritten by `image` on `repo add` if it is present. | 21 | | `version` | int \| string | ✔️ | Plugin version – must be a valid C# assembly version number (`major.minor.build.revision`) – only major is required, the rest defaults to `0` | 22 | | `targetAbi` | string | ✔️ | Minimum Jellyfin version | 23 | | `framework` | string | ❌ | Framework version to build with – must be same or higher than what your targeted Jellyfin version is built with. Defaults to [`netstandard2.1`][DEFAULT_FRAMEWORK] for legacy reasons, please specify. | 24 | | `overview` | string | ✔️ | A short single-line description (tagline) of your plugin | 25 | | `description` | string | ✔️ | A longer multi-line description | 26 | | `category` | string | ✔️ | `Authentication` / `Channels` / `General` / `Live TV` / `Metadata` / `Notifications` | 27 | | `owner` | string | ✔️ | Name of maintainer | 28 | | `artifacts` | list[string] | ✔️ | List of artifacts to include in the plugin zip | 29 | | `changelog` | string | ✔️ | Changes since last release | 30 | 31 | [DEFAULT_IMAGE_FILE]: https://github.com/oddstr13/jellyfin-plugin-repository-manager/blob/a2267abe5cbffe602dd8dd0d5c532ea32da7bafe/jprm/__init__.py#L35 32 | [DEFAULT_FRAMEWORK]: https://github.com/oddstr13/jellyfin-plugin-repository-manager/blob/a2267abe5cbffe602dd8dd0d5c532ea32da7bafe/jprm/__init__.py#L36 33 | [CONFIG_LOCATIONS]: https://github.com/oddstr13/jellyfin-plugin-repository-manager/blob/a2267abe5cbffe602dd8dd0d5c532ea32da7bafe/jprm/__init__.py#L37-L45 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Installing 2 | ========== 3 | From PyPI 4 | --------- 5 | ```bash 6 | pip install --user jprm 7 | ``` 8 | 9 | From GitHub 10 | ----------- 11 | ``` 12 | pip install --user git+https://github.com/oddstr13/jellyfin-plugin-repository-manager 13 | ``` 14 | 15 | From local developement directory 16 | --------------------------------- 17 | ``` 18 | git clone https://github.com/oddstr13/jellyfin-plugin-repository-manager.git 19 | pip install --user --editable jellyfin-plugin-metadata-manager 20 | ``` 21 | 22 | Stand-alone Python script 23 | ------------------------- 24 | ``` 25 | wget -O jprm.py https://raw.githubusercontent.com/oddstr13/jellyfin-plugin-repository-manager/master/jprm/__init__.py 26 | ``` 27 | 28 | Examples 29 | ======== 30 | 31 | Running jprm 32 | ------------ 33 | 34 | ``` 35 | jprm --version 36 | ``` 37 | 38 | ``` 39 | python3 -m jprm --version 40 | ``` 41 | 42 | ``` 43 | python3 jprm.py --version 44 | ``` 45 | 46 | Initializing plugin repository 47 | ------------------------------ 48 | 49 | ``` 50 | mkdir -p /path/to/repo 51 | jprm repo init /path/to/repo 52 | ``` 53 | 54 | See [build_plugin.sh](https://github.com/oddstr13/jellyfin-plugin-repository-manager/blob/master/build_plugin.sh) for an example script. 55 | -------------------------------------------------------------------------------- /action.yaml: -------------------------------------------------------------------------------- 1 | name: 'jprm-build' 2 | description: 'Builds a Jellyfin plugin via Jellyfin Plugin Repository Manager' 3 | author: 'Odd Stråbø' 4 | 5 | branding: 6 | icon: package 7 | color: purple 8 | 9 | inputs: 10 | path: 11 | required: false 12 | default: '.' 13 | description: 'The path to the sources of the plugin solution (default: ".")' 14 | output: 15 | required: false 16 | default: './artifacts' 17 | description: 'Path to dotnet build directory (default: ./artifacts)' 18 | version: 19 | required: false 20 | default: '' 21 | description: 'Overwrite the detected version of the plugin (default: "")' 22 | dotnet-config: 23 | required: false 24 | default: 'Release' 25 | description: 'The dotnet build configuration (default: Release)' 26 | dotnet-target: 27 | required: false 28 | default: '' 29 | description: 'Overwrite the detected dotnet target framework used for the plugin build (default: "")' 30 | max-cpu-count: 31 | required: false 32 | default: '1' 33 | description: 'Maximum number of processors to use during build (default: "1")' 34 | verbosity: 35 | required: false 36 | default: 'debug' 37 | description: 'The log verbosity of JPRM' 38 | 39 | outputs: 40 | artifact: 41 | description: 'Returns the built plugin zip' 42 | value: ${{ steps.build.outputs.artifact }} 43 | 44 | runs: 45 | using: composite 46 | steps: 47 | - name: Ensure ouput dir exists 48 | shell: bash 49 | run: |- 50 | echo "::group::Preparing Environment" 51 | mkdir -p ${{ inputs.output }} 52 | 53 | - name: Setup JRPM Deps 54 | shell: bash 55 | run: |- 56 | # PEP 668 can burn. 57 | rm -f /usr/lib/python*/EXTERNALLY-MANAGED 58 | python3 -m pip config set global.break-system-packages true 59 | 60 | python3 -m pip install -r ${{ github.action_path }}/requirements.txt 61 | echo "::endgroup::" 62 | 63 | - id: build 64 | name: Run JPRM build 65 | shell: bash 66 | run: |- 67 | echo "::group::Building and Packaging" 68 | 69 | if [[ -n "${{ inputs.dotnet-target }}" ]]; then 70 | DOTNET_FRAMEWORK="--dotnet-framework ${{ inputs.dotnet-target }}" 71 | else 72 | DOTNET_FRAMEWORK="" 73 | fi 74 | 75 | if [[ -n "${{ inputs.version }}" ]]; then 76 | PLUGIN_VERSION="-v ${{ inputs.version }}" 77 | else 78 | PLUGIN_VERSION="" 79 | fi 80 | 81 | artifact="$(python3 ${{ github.action_path }}/jprm/__init__.py --verbosity=${{ inputs.verbosity }} plugin build ${{ inputs.path }} -o ${{ inputs.output }} ${PLUGIN_VERSION} --dotnet-configuration ${{ inputs.dotnet-config }} --max-cpu-count ${{ inputs.max-cpu-count }} ${DOTNET_FRAMEWORK})" 82 | 83 | echo "Artifact: ${artifact}" 84 | echo "artifact=${artifact}" >> $GITHUB_OUTPUT 85 | echo "::endgroup::" 86 | -------------------------------------------------------------------------------- /build_plugin.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Copyright (c) 2020 - Odd Strabo 4 | # 5 | # 6 | # The Unlicense 7 | # ============= 8 | # 9 | # This is free and unencumbered software released into the public domain. 10 | # 11 | # Anyone is free to copy, modify, publish, use, compile, sell, or 12 | # distribute this software, either in source code form or as a compiled 13 | # binary, for any purpose, commercial or non-commercial, and by any 14 | # means. 15 | # 16 | # In jurisdictions that recognize copyright laws, the author or authors 17 | # of this software dedicate any and all copyright interest in the 18 | # software to the public domain. We make this dedication for the benefit 19 | # of the public at large and to the detriment of our heirs and 20 | # successors. We intend this dedication to be an overt act of 21 | # relinquishment in perpetuity of all present and future rights to this 22 | # software under copyright law. 23 | # 24 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 25 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 26 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 27 | # IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 28 | # OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 29 | # ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 30 | # OTHER DEALINGS IN THE SOFTWARE. 31 | # 32 | # For more information, please refer to 33 | # 34 | 35 | MY=$(dirname $(realpath -s "${0}")) 36 | JPRM="jprm" 37 | 38 | DEFAULT_REPO_DIR="${MY}/test_repo" 39 | DEFAULT_REPO_URL="http://localhost:8080" 40 | 41 | PLUGIN=${1:-${PLUGIN:-.}} 42 | 43 | ARTIFACT_DIR=${ARTIFACT_DIR:-"${MY}/artifacts"} 44 | mkdir -p "${ARTIFACT_DIR}" 45 | 46 | JELLYFIN_REPO=${JELLYFIN_REPO:-${DEFAULT_REPO_DIR}} 47 | JELLYFIN_REPO_URL=${JELLYFIN_REPO_URL:-${DEFAULT_REPO_URL}} 48 | 49 | # Each segment of the version is a 16bit number. 50 | # Max number is 65535. 51 | VERSION_SUFFIX=${VERSION_SUFFIX:-$(date -u +%y%m.%d%H.%M%S)} 52 | 53 | meta_version=$(grep -Po '^ *version: * "*\K[^"$]+' "${PLUGIN}/build.yaml") 54 | VERSION=${VERSION:-$(echo $meta_version | sed 's/\.[0-9]*\.[0-9]*\.[0-9]*$/.'"$VERSION_SUFFIX"'/')} 55 | 56 | # !!! VERSION IS OVERWRITTEN HERE 57 | #VERSION="${meta_version}" 58 | 59 | find "${PLUGIN}" -name project.assets.json -exec rm -v '{}' ';' 60 | 61 | zipfile=$($JPRM --verbosity=debug plugin build "${PLUGIN}" --output="${ARTIFACT_DIR}" --version="${VERSION}") && { 62 | $JPRM --verbosity=debug repo add --url=${JELLYFIN_REPO_URL} "${JELLYFIN_REPO}" "${zipfile}" 63 | } 64 | exit $? 65 | -------------------------------------------------------------------------------- /jprm/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Copyright (c) 2020 - Odd Strabo 4 | # 5 | # This Source Code Form is subject to the terms of the Mozilla Public 6 | # License, v. 2.0. If a copy of the MPL was not distributed with this 7 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 8 | # 9 | 10 | import os 11 | import json 12 | import hashlib 13 | import datetime 14 | from typing import Optional, Union 15 | import zipfile 16 | import subprocess 17 | import tempfile 18 | import shutil 19 | import logging 20 | from functools import total_ordering 21 | import re 22 | import uuid 23 | 24 | import yaml 25 | import click 26 | import click_log 27 | from slugify import slugify 28 | import tabulate 29 | 30 | logger = logging.getLogger("jprm") 31 | click_log.basic_config(logger) 32 | 33 | __version__ = "1.1.0" 34 | JSON_METADATA_FILE = "meta.json" 35 | DEFAULT_IMAGE_FILE = "image.png" 36 | DEFAULT_FRAMEWORK = "netstandard2.1" 37 | CONFIG_LOCATIONS = [ 38 | "jprm.yaml", 39 | ".jprm.yaml", 40 | ".ci/jprm.yaml", 41 | ".github/jprm.yaml", 42 | ".gitlab/jprm.yaml", 43 | "meta.yaml", 44 | "build.yaml", 45 | ] 46 | 47 | #################### 48 | 49 | 50 | def checksum_file(path, checksum_type='md5'): 51 | cs = hashlib.new(checksum_type) 52 | 53 | with open(path, "rb") as fh: 54 | data = True 55 | while data: 56 | data = fh.read(1_048_576) 57 | if data: 58 | cs.update(data) 59 | 60 | return cs.hexdigest() 61 | 62 | 63 | def zip_path(fn, path, prefix=''): 64 | 65 | with zipfile.ZipFile(fn, "w", zipfile.ZIP_DEFLATED) as z: 66 | for root, dirs, files in os.walk(path, topdown=True): 67 | for d in dirs: 68 | fp = os.path.join(root, d) 69 | ap = os.path.join(prefix, os.path.relpath(fp, path)) 70 | 71 | if not ap: 72 | continue 73 | 74 | z.write(fp, ap) 75 | 76 | for f in files: 77 | fp = os.path.join(root, f) 78 | ap = os.path.join(prefix, os.path.relpath(fp, path)) 79 | 80 | z.write(fp, ap) 81 | 82 | 83 | def load_manifest(manifest_file_name): 84 | """ 85 | Read in an arbitrary YAML manifest and return it 86 | """ 87 | with open(manifest_file_name, 'r') as manifest_file: 88 | try: 89 | cfg = yaml.load(manifest_file, Loader=yaml.SafeLoader) 90 | except yaml.YAMLError as e: 91 | logger.error("Failed to load YAML manifest {}: {}".format(manifest_file_name, e)) 92 | return None 93 | return cfg 94 | 95 | 96 | def get_config(path): 97 | for config_file in CONFIG_LOCATIONS: 98 | config_path = os.path.join(path, config_file) 99 | if os.path.exists(config_path): 100 | build_cfg = load_manifest(config_path) 101 | if build_cfg is not None: 102 | return build_cfg 103 | logger.warning("Failed to locate config file.") 104 | return None 105 | 106 | 107 | def run_os_command(command, environment=None, shell=False, cwd=None): 108 | if shell: 109 | cmd = command 110 | else: 111 | cmd = command.split() 112 | 113 | logger.debug(['run_os_command', cmd, environment, shell, cwd]) 114 | try: 115 | command_output = None 116 | command_output = subprocess.run( 117 | cmd, 118 | env=environment, 119 | stdout=subprocess.PIPE, 120 | stderr=subprocess.PIPE, 121 | shell=shell, 122 | cwd=cwd, 123 | ) 124 | except Exception as e: 125 | logger.exception(command_output, exc_info=e) 126 | raise 127 | 128 | return command_output.stdout.decode('utf8'), command_output.stderr.decode('utf8'), command_output.returncode 129 | 130 | 131 | #################### 132 | 133 | 134 | @total_ordering 135 | class Version(object): 136 | version_re = re.compile(r'^(?P[0-9]+)(\.(?P[0-9]+)(\.(?P[0-9]+)(\.(?P[0-9]+))?)?)?$') 137 | 138 | major = None 139 | minor = None 140 | build = None 141 | revision = None 142 | 143 | def __init__(self, version): 144 | if isinstance(version, Version): 145 | self.major = version.major 146 | self.minor = version.minor 147 | self.build = version.build 148 | self.revision = version.revision 149 | 150 | elif isinstance(version, str): 151 | match = self.version_re.match(version) 152 | if not match: 153 | raise ValueError(version) 154 | 155 | gd = match.groupdict() 156 | self.major = int(gd.get('major')) if gd.get('major') else None 157 | self.minor = int(gd.get('minor')) if gd.get('minor') else None 158 | self.build = int(gd.get('build')) if gd.get('build') else None 159 | self.revision = int(gd.get('revision')) if gd.get('revision') else None 160 | 161 | elif isinstance(version, int): 162 | self.major = version 163 | 164 | else: 165 | raise TypeError(version) 166 | 167 | def full(self): 168 | return '{major}.{minor}.{build}.{revision}'.format( 169 | major = self.major or 0, 170 | minor = self.minor or 0, 171 | build = self.build or 0, 172 | revision = self.revision or 0, 173 | ) 174 | 175 | def __str__(self): 176 | if self.revision is not None: 177 | return '{major}.{minor}.{build}.{revision}'.format(**self) 178 | if self.build is not None: 179 | return '{major}.{minor}.{build}'.format(**self) 180 | if self.minor is not None: 181 | return '{major}.{minor}'.format(**self) 182 | else: 183 | return '{major}'.format(**self) 184 | 185 | def __repr__(self): 186 | return "<{}({})>".format(self.__class__.__name__, repr(str(self))) 187 | 188 | def __iter__(self): 189 | return iter(self.values()) 190 | 191 | def __getitem__(self, key): 192 | if key in ('major', 0): 193 | return self.major 194 | 195 | if key in ('minor', 1): 196 | return self.minor 197 | 198 | if key in ('build', 2): 199 | return self.build 200 | 201 | if key in ('revision', 3): 202 | return self.revision 203 | 204 | raise KeyError 205 | 206 | def __setitem__(self, key, value): 207 | if key not in self: 208 | raise KeyError(key) 209 | 210 | if value is not None: 211 | value = int(value) 212 | 213 | if key in ('major', 0): 214 | self.major = value 215 | if value is None: 216 | self.minor = None 217 | self.build = None 218 | self.revision = None 219 | 220 | if key in ('minor', 1): 221 | self.minor = value 222 | if value is None: 223 | self.build = None 224 | self.revision = None 225 | 226 | if key in ('build', 2): 227 | self.build = value 228 | if value is None: 229 | self.revision = None 230 | 231 | if key in ('revision', 3): 232 | self.revision = value 233 | 234 | def __delitem__(self, key): 235 | self[key] = None 236 | 237 | def __len__(self): 238 | return 4 239 | 240 | def __contains__(self, key): 241 | return key in ('major', 'minor', 'build', 'revision', 0, 1, 2, 3) 242 | 243 | def keys(self): 244 | return ('major', 'minor', 'build', 'revision') 245 | 246 | def values(self): 247 | return (self.major, self.minor, self.build, self.revision) 248 | 249 | def items(self): 250 | return ( 251 | ('major', self.major), 252 | ('minor', self.minor), 253 | ('build', self.build), 254 | ('revision', self.revision), 255 | ) 256 | 257 | def get(self, key, default=None): 258 | if key not in self: 259 | logger.warning('Accessing non-existant key `{}` of `{!r}`'.format(key, self)) 260 | return default 261 | 262 | return self[key] 263 | 264 | @staticmethod 265 | def _hasattrs(obj, *names): 266 | for name in names: 267 | if not hasattr(obj, name): 268 | return False 269 | return True 270 | 271 | def __eq__(self, other): 272 | if self._hasattrs(other, 'major', 'minor', 'build', 'revision'): 273 | return ( 274 | self.major or 0, 275 | self.minor or 0, 276 | self.build or 0, 277 | self.revision or 0, 278 | ) == ( 279 | other.major or 0, 280 | other.minor or 0, 281 | other.build or 0, 282 | other.revision or 0, 283 | ) 284 | 285 | return NotImplemented 286 | 287 | def __lt__(self, other): 288 | if self._hasattrs(other, 'major', 'minor', 'build', 'revision'): 289 | return ( 290 | self.major or 0, 291 | self.minor or 0, 292 | self.build or 0, 293 | self.revision or 0, 294 | ) < ( 295 | other.major or 0, 296 | other.minor or 0, 297 | other.build or 0, 298 | other.revision or 0, 299 | ) 300 | 301 | return NotImplemented 302 | 303 | def pop(self, k, d=KeyError): 304 | raise NotImplementedError 305 | 306 | 307 | #################### 308 | 309 | 310 | def build_plugin(path, output=None, build_cfg=None, version=None, dotnet_config='Release', dotnet_framework=None, max_cpu_count=None): 311 | if build_cfg is None: 312 | build_cfg = get_config(path) 313 | 314 | if build_cfg is None: 315 | return None 316 | 317 | if version is None: 318 | version = build_cfg['version'] 319 | 320 | if version is not None: 321 | version = Version(version).full() 322 | 323 | if output is None: 324 | output = './bin/' 325 | 326 | if max_cpu_count is None: 327 | max_cpu_count = 1 328 | 329 | if dotnet_framework is None: 330 | if 'framework' not in build_cfg: 331 | logger.warning("`framework` is not specified in build manifest, defaulting to `{}`.".format(DEFAULT_FRAMEWORK)) 332 | logger.warning("The default target framework may change in the future.") 333 | dotnet_framework = build_cfg.get('framework', DEFAULT_FRAMEWORK) 334 | 335 | params = { 336 | 'dotnet_config': dotnet_config, 337 | 'dotnet_framework': dotnet_framework, 338 | 'output': output, 339 | 'max_cpu_count': max_cpu_count, 340 | 'version': version, 341 | } 342 | 343 | logger.debug(params) 344 | 345 | projects = [] 346 | 347 | sln_file = None 348 | for fn in os.listdir(path): 349 | if fn.endswith('.sln'): 350 | sln_file = os.path.join(path, fn) 351 | break 352 | 353 | if sln_file is not None: 354 | projects.extend(solution_get_projects(sln_file)) 355 | else: 356 | for fn in os.listdir(path): 357 | if fn.endswith('.csproj'): 358 | projects.append(os.path.join(path, fn)) 359 | break 360 | 361 | dbp_file = os.path.join(path, "Directory.Build.props") 362 | if os.path.exists(dbp_file): 363 | projects.append(dbp_file) 364 | 365 | for project in projects: 366 | set_project_version(project, version=version) 367 | set_project_framework(project, framework=dotnet_framework) 368 | 369 | clean_command = "dotnet clean --configuration={dotnet_config} --framework={dotnet_framework}" 370 | stdout, stderr, retcode = run_os_command(clean_command.format(**params), cwd=path) 371 | if retcode: 372 | logger.info(stdout) 373 | logger.error(stderr) 374 | exit(1) 375 | 376 | restore_command = "dotnet restore --no-cache" 377 | stdout, stderr, retcode = run_os_command(restore_command.format(**params), cwd=path) 378 | if retcode: 379 | logger.info(stdout) 380 | logger.error(stderr) 381 | exit(1) 382 | 383 | build_command = "dotnet publish --nologo --no-restore" \ 384 | " --configuration={dotnet_config} --framework={dotnet_framework}" \ 385 | " -p:PublishDir={output} -p:Version={version} -maxcpucount:{max_cpu_count}" 386 | 387 | stdout, stderr, retcode = run_os_command(build_command.format(**params), cwd=path) 388 | if retcode: 389 | logger.info(stdout) 390 | logger.error(stderr) 391 | exit(1) 392 | 393 | logger.info(stdout) 394 | 395 | 396 | def package_plugin(path, build_cfg=None, version=None, binary_path=None, output=None, bundle=False): 397 | if build_cfg is None: 398 | build_cfg = get_config(path) 399 | 400 | if build_cfg is None: 401 | return None 402 | 403 | if version is None: 404 | version = build_cfg['version'] 405 | 406 | if version is not None: 407 | version = Version(version).full() 408 | 409 | if binary_path is None: 410 | binary_path = './bin/' 411 | 412 | if output is None: 413 | output = './artifacts/' 414 | 415 | image_path = None 416 | if "image" in build_cfg: 417 | image_path = os.path.join(path, build_cfg['image']) 418 | if not os.path.exists(image_path): 419 | logger.error("Image `{}` not found at expected path `{}`.".format(build_cfg['image'], image_path)) 420 | exit(1) 421 | 422 | if image_path is None: 423 | image_path = os.path.join(path, DEFAULT_IMAGE_FILE) 424 | if os.path.exists(image_path): 425 | logger.info("Image autodetected at path `{}`.".format(image_path)) 426 | else: 427 | image_path = None 428 | 429 | slug = slugify(build_cfg['name']) 430 | 431 | output_file = "{slug}_{version}.zip".format(slug=slug, version=version) 432 | output_path = os.path.join(output, output_file) 433 | 434 | with tempfile.TemporaryDirectory() as tempdir: 435 | for artifact in build_cfg['artifacts']: 436 | artifact_path = os.path.join(binary_path, artifact) 437 | artifact_temp_path = os.path.join(tempdir, artifact) 438 | 439 | artifact_temp_dir = os.path.dirname(artifact_temp_path) 440 | if not os.path.exists(artifact_temp_dir): 441 | os.makedirs(artifact_temp_dir) 442 | 443 | shutil.copyfile(artifact_path, artifact_temp_path) 444 | 445 | if image_path is not None: 446 | image_name = os.path.basename(image_path) 447 | image_temp_path = os.path.join(tempdir, image_name) 448 | shutil.copyfile(image_path, image_temp_path) 449 | 450 | build_cfg['image'] = image_name 451 | 452 | meta = generate_metadata(build_cfg, version=version) 453 | meta_tempfile = os.path.join(tempdir, JSON_METADATA_FILE) 454 | with open(meta_tempfile, 'w') as fh: 455 | json.dump(meta, fh, sort_keys=True, indent=4) 456 | 457 | try: 458 | zip_path(output_path, tempdir) 459 | except FileNotFoundError as e: 460 | logger.error(e) 461 | exit(1) 462 | 463 | md5 = checksum_file(output_path, checksum_type='md5') 464 | 465 | with open(output_path + '.md5sum', 'wb') as fh: 466 | fh.write(md5.encode()) 467 | fh.write(b' *') 468 | fh.write(output_file.encode()) 469 | fh.write(b'\n') 470 | 471 | shutil.move(meta_tempfile, '{filename}.{meta}'.format(filename=output_path, meta=JSON_METADATA_FILE)) 472 | 473 | return output_path 474 | 475 | 476 | def generate_metadata(build_cfg, version=None, build_date=None): 477 | 478 | if version is None: 479 | version = build_cfg['version'] 480 | 481 | if version is not None: 482 | version = Version(version).full() 483 | 484 | if build_date is None: 485 | build_date = datetime.datetime.utcnow().isoformat(timespec='seconds') + 'Z' 486 | 487 | meta = { 488 | "guid": str(uuid.UUID(build_cfg['guid'])), 489 | "name": build_cfg['name'], 490 | "description": build_cfg['description'], 491 | "overview": build_cfg['overview'], 492 | "owner": build_cfg['owner'], 493 | "category": build_cfg['category'], 494 | ######## 495 | "version": version, 496 | "changelog": build_cfg['changelog'], 497 | "targetAbi": build_cfg['targetAbi'], 498 | # "sourceUrl": "{url}/{slug}/{slug}_{version}.zip".format( 499 | # url=repo_url.rstrip('/'), 500 | # slug=slug, 501 | # version=version, 502 | # ), 503 | # "checksum": bin_md5sum, 504 | "timestamp": build_date, 505 | } 506 | 507 | if "imageUrl" in build_cfg: 508 | meta['imageUrl'] = build_cfg['imageUrl'] 509 | 510 | if "image" in build_cfg: 511 | meta['image'] = build_cfg['image'] 512 | 513 | elif "imageUrl" not in build_cfg: 514 | logger.warning("Neither image nor imageUrl is specified.") 515 | 516 | if "image" in meta and "imageUrl" in meta: 517 | logger.warning("Both image and imageUrl is specified.") 518 | 519 | return meta 520 | 521 | 522 | def generate_plugin_manifest(filename, repo_url='', plugin_url=None, meta=None, md5=None): 523 | if meta is None: 524 | meta_filename = '{filename}.{meta}'.format(filename=filename, meta=JSON_METADATA_FILE) 525 | if os.path.exists(meta_filename): 526 | with open(meta_filename) as fh: 527 | meta = json.load(fh) 528 | logger.info("Read meta from `{}`".format(meta_filename)) 529 | logger.debug(meta) 530 | 531 | if meta is None: 532 | with zipfile.ZipFile(filename, 'r') as zf: 533 | if JSON_METADATA_FILE in zf.namelist(): 534 | with zf.open(JSON_METADATA_FILE, 'r') as fh: 535 | meta = json.load(fh) 536 | logger.info("Read meta from `{}:{}`".format(filename, JSON_METADATA_FILE)) 537 | logger.debug(meta) 538 | 539 | if meta is None: 540 | raise ValueError('Metadata not provided') 541 | 542 | # TODO: Read .md5sum file 543 | if md5 is None: 544 | md5 = checksum_file(filename) 545 | 546 | if not repo_url and not plugin_url: 547 | logger.warning("repo and plugin url not provided, provide at least one.") 548 | 549 | slug = slugify(meta['name']) 550 | 551 | source_url = "{url}/{slug}/{slug}_{version}.zip".format( 552 | url=repo_url.rstrip('/'), 553 | slug=slug, 554 | version=meta['version'], 555 | ) 556 | if plugin_url: 557 | logger.info("Plugin url `{}` overrides the autogenerated `{}`.".format(plugin_url, source_url)) 558 | source_url = plugin_url 559 | 560 | manifest = { 561 | "guid": str(uuid.UUID(meta['guid'])), 562 | "name": meta['name'], 563 | "description": meta['description'], 564 | "overview": meta['overview'], 565 | "owner": meta['owner'], 566 | "category": meta['category'], 567 | 568 | "versions": [{ 569 | "version": meta['version'], 570 | "changelog": meta['changelog'], 571 | "targetAbi": meta['targetAbi'], 572 | "sourceUrl": source_url, 573 | "checksum": md5, 574 | "timestamp": meta['timestamp'], 575 | }] 576 | } 577 | 578 | if "imageUrl" in meta: 579 | manifest['imageUrl'] = meta['imageUrl'] 580 | 581 | if "image" in meta: 582 | manifest['image'] = meta['image'] 583 | 584 | manifest['imageUrl'] = "{url}/{slug}/{image}".format( 585 | url=repo_url.rstrip('/'), 586 | slug=slug, 587 | image=meta['image'], 588 | ) 589 | 590 | if "imageUrl" in meta: 591 | logger.warning("Image URL `{}` is getting overwritten by `{}` due to presence of `image`.".format(meta['imageUrl'], manifest['imageUrl'])) 592 | 593 | elif "imageUrl" not in meta: 594 | logger.warning("Neither image nor imageUrl is specified.") 595 | 596 | return manifest 597 | 598 | 599 | def update_plugin_manifest(old, new): 600 | new_versions = new.pop('versions') 601 | old_versions = old.pop('versions') 602 | 603 | new_version_numbers = [x['version'] for x in new_versions] 604 | 605 | old.update(new) 606 | 607 | old['versions'] = [] 608 | 609 | while old_versions: 610 | ver = old_versions.pop(0) 611 | 612 | # Upgrade old incomplete version numbers - Jellyfin is not a fan of those. 613 | ver['version'] = Version(ver['version']).full() 614 | 615 | if ver['version'] not in new_version_numbers: 616 | old['versions'].append(ver) 617 | 618 | while new_versions: 619 | ver = new_versions.pop(0) 620 | old['versions'].append(ver) 621 | 622 | old['versions'].sort(key=lambda l: Version(l['version']), reverse=True) 623 | return old 624 | 625 | 626 | def get_plugin_from_manifest(repo_manifest: dict, plugin: Union[str, uuid.UUID]) -> Optional[dict]: 627 | if plugin is None: 628 | return None 629 | 630 | if isinstance(plugin, uuid.UUID): 631 | plugin = str(plugin) 632 | else: 633 | try: 634 | plugin = str(uuid.UUID(plugin)) 635 | except ValueError: 636 | pass 637 | 638 | items = [item for item in repo_manifest if plugin in [item.get('name'), item.get('guid'), slugify(item.get('name'))]] 639 | if items: 640 | return items[0] 641 | 642 | return None 643 | 644 | 645 | _project_version_re = re.compile(r'\(?P.*?)\') 646 | _project_file_version_re = re.compile(r'\(?P.*?)\') 647 | _project_assembly_version_re = re.compile(r'\(?P.*?)\') 648 | _project_version_pattern = '{version}' 649 | _project_file_version_pattern = '{version}' 650 | _project_assembly_version_pattern = '{version}' 651 | 652 | 653 | def set_project_version(project_file, version): 654 | version = Version(version) 655 | logger.info("Setting project version to {}".format(version.full())) 656 | 657 | with open(project_file, 'r') as fh: 658 | pdata = fh.read() 659 | 660 | ver_matches = list(_project_version_re.finditer(pdata)) 661 | file_ver_matches = list(_project_file_version_re.finditer(pdata)) 662 | ass_ver_matches = list(_project_assembly_version_re.finditer(pdata)) 663 | if len(ver_matches) > 1 or len(file_ver_matches) > 1 or len(ass_ver_matches) > 1: 664 | logger.error('Found multiple instances of the version tag(s), bailing.') 665 | return None 666 | 667 | if ver_matches: 668 | old_version = ver_matches[0]['version'] 669 | logger.debug('Old version: {}'.format(old_version)) 670 | else: 671 | old_version = None 672 | 673 | if file_ver_matches: 674 | old_file_version = file_ver_matches[0]['version'] 675 | logger.debug('Old file version: {}'.format(old_file_version)) 676 | else: 677 | old_file_version = None 678 | 679 | if ass_ver_matches: 680 | old_assembly_version = ass_ver_matches[0]['version'] 681 | logger.debug('Old assembly version: {}'.format(old_assembly_version)) 682 | else: 683 | old_assembly_version = None 684 | 685 | pdata = _project_version_re.sub(_project_version_pattern.format(version=version.full()), pdata) 686 | pdata = _project_file_version_re.sub(_project_file_version_pattern.format(version=version.full()), pdata) 687 | pdata = _project_assembly_version_re.sub(_project_assembly_version_pattern.format(version=version.full()), pdata) 688 | 689 | with open(project_file, 'w') as fh: 690 | fh.write(pdata) 691 | 692 | return (old_version, old_file_version, old_assembly_version) 693 | 694 | 695 | _project_framework_re = re.compile(r'\(?P.*?)\') 696 | _project_framework_pattern = '{framework}' 697 | 698 | 699 | def set_project_framework(project_file, framework): 700 | logger.info("Setting project framework to {}".format(framework)) 701 | 702 | with open(project_file, 'r') as fh: 703 | pdata = fh.read() 704 | 705 | framework_matches = list(_project_framework_re.finditer(pdata)) 706 | if len(framework_matches) > 1: 707 | logger.error('Found multiple instances of the TargetFramework tag, bailing.') 708 | return None 709 | 710 | if framework_matches: 711 | old_framework = framework_matches[0]['framework'] 712 | logger.debug('Old framework: {}'.format(old_framework)) 713 | else: 714 | old_framework = None 715 | 716 | pdata = _project_framework_re.sub(_project_framework_pattern.format(framework=framework), pdata) 717 | 718 | with open(project_file, 'w') as fh: 719 | fh.write(pdata) 720 | 721 | return old_framework 722 | 723 | 724 | _solution_file_project_re = re.compile(r'\s*Project\("[^"]*"\)\s*=\s*"(?P[^"]*)",\s*"(?P[^"]+proj)",\s*"[^"]*"\s*') 725 | 726 | 727 | def solution_get_projects(sln_file): 728 | with open(sln_file, 'r') as fh: 729 | data = fh.read() 730 | 731 | sln_dir = os.path.dirname(sln_file) 732 | matches = _solution_file_project_re.finditer(data) 733 | for match in matches: 734 | gd = match.groupdict() 735 | project_file = os.path.join(sln_dir, gd.get('project_file').replace('\\', os.path.sep)) 736 | yield project_file 737 | 738 | 739 | #################### 740 | 741 | 742 | class RepoPathParam(click.ParamType): 743 | name = 'repo_path' 744 | 745 | def __init__(self, should_exist=None): 746 | self.should_exist = should_exist 747 | 748 | def convert(self, value, param, ctx): 749 | if not value.endswith('.json'): 750 | value = os.path.join(value, 'manifest.json') 751 | 752 | if self.should_exist is not None: 753 | does_exist = os.path.exists(value) 754 | if self.should_exist and not does_exist: 755 | self.fail('Can not find repository at `{}`. Try initializing the repo first.'.format(value)) 756 | elif does_exist and not self.should_exist: 757 | self.fail('There is already an existing repository at `{}`.'.format(value), param, ctx) 758 | 759 | dirname = os.path.dirname(value) 760 | if not os.path.exists(dirname): 761 | self.fail('The directory `{}` does not exist.'.format(dirname), param, ctx) 762 | 763 | return value 764 | 765 | 766 | class ZipFileParam(click.ParamType): 767 | def validate(self, value, param, ctx): 768 | if not os.path.exists(value): 769 | self.fail('No such file: `{}`'.format(value)) 770 | return False 771 | 772 | if not zipfile.is_zipfile(value): 773 | self.fail('`{}` is not a zip file.'.format(value)) 774 | return False 775 | 776 | return True 777 | 778 | 779 | #################### 780 | 781 | 782 | @click.group() 783 | @click.version_option(version=__version__, prog_name='Jellyfin Plugin Repository Manager') 784 | @click_log.simple_verbosity_option(logger) 785 | def cli(): 786 | pass # Command grouping 787 | 788 | 789 | @cli.group('plugin') 790 | def cli_plugin(): 791 | pass # Command grouping 792 | 793 | 794 | @cli_plugin.command('build') 795 | @click.argument('path', 796 | nargs=1, 797 | required=False, 798 | default='.', 799 | type=click.Path(exists=True, file_okay=False, dir_okay=True, writable=True), 800 | ) 801 | @click.option('--output', '-o', 802 | default=None, 803 | type=click.Path(exists=False, file_okay=False, dir_okay=True, writable=True), 804 | help='Path to dotnet build directory', 805 | ) 806 | @click.option('--version', '-v', 807 | default=None, 808 | help='Plugin version', 809 | ) 810 | @click.option('--dotnet-configuration', 811 | default='Release', 812 | help='Dotnet configuration', 813 | ) 814 | @click.option('--dotnet-framework', 815 | default=None, 816 | help='Dotnet framework ({})'.format(DEFAULT_FRAMEWORK), 817 | ) 818 | @click.option('--max-cpu-count', 819 | default=1, 820 | type=int, 821 | help='Max number of cores to use during build (1)', 822 | ) 823 | def cli_plugin_build(path, output, dotnet_configuration, dotnet_framework, max_cpu_count, version): 824 | build_cfg = get_config(path) 825 | if build_cfg is None: 826 | raise click.UsageError('No build config found in `{}`'.format(path)) 827 | 828 | with tempfile.TemporaryDirectory() as bintemp: 829 | build_plugin(path, output=bintemp, build_cfg=build_cfg, dotnet_config=dotnet_configuration, dotnet_framework=dotnet_framework, 830 | version=version, max_cpu_count=max_cpu_count) 831 | filename = package_plugin(path, build_cfg=build_cfg, version=version, binary_path=bintemp, output=output) 832 | click.echo(filename) 833 | 834 | 835 | @cli.group('repo') 836 | def cli_repo(): 837 | pass # Command grouping 838 | 839 | 840 | @cli_repo.command('init') 841 | @click.argument('repo_path', 842 | nargs=1, 843 | required=True, 844 | type=RepoPathParam(should_exist=False), 845 | ) 846 | def cli_repo_init(repo_path): 847 | if os.path.exists(repo_path): 848 | raise click.BadParameter("File already exists: `{}`".format(repo_path)) 849 | 850 | with open(repo_path, 'w') as fh: 851 | json.dump([], fh) 852 | logger.info("Initialized `{}`.".format(repo_path)) 853 | 854 | 855 | @cli_repo.command('add') 856 | @click.argument('repo_path', 857 | nargs=1, 858 | required=True, 859 | type=RepoPathParam(should_exist=True), 860 | ) 861 | @click.argument('plugins', 862 | nargs=-1, 863 | required=True, 864 | type=ZipFileParam(), 865 | ) 866 | @click.option('--url', '-u', 867 | default='', 868 | help='Repository public base URL', 869 | ) 870 | @click.option('plugin_urls', '--plugin-url', '-U', 871 | default=[], 872 | help='Full URL of the plugin zip file', 873 | multiple=True, 874 | ) 875 | def cli_repo_add(repo_path, plugins, url='', plugin_urls=[]): 876 | with open(repo_path, 'r') as fh: 877 | logger.debug('Reading repo manifest from {}'.format(repo_path)) 878 | repo_manifest = json.load(fh) 879 | 880 | if plugin_urls and len(plugin_urls) != len(plugins): 881 | logger.error("When plugin url is specified, the number of times it's specified must match the number of plugins.") 882 | exit(1) 883 | 884 | for i, plugin_file in enumerate(plugins): 885 | logger.info("Processing {}".format(plugin_file)) 886 | 887 | plugin_url = None 888 | if plugin_urls: 889 | plugin_url = plugin_urls[i] 890 | 891 | plugin_manifest = generate_plugin_manifest(plugin_file, repo_url=url, plugin_url=plugin_url) 892 | logger.debug(plugin_manifest) 893 | 894 | # TODO: Add support for separate repo file path 895 | repo_dir = os.path.dirname(repo_path) 896 | name = plugin_manifest['name'] 897 | slug = slugify(name) 898 | version = plugin_manifest['versions'][0]['version'] 899 | guid = uuid.UUID(plugin_manifest['guid']) 900 | 901 | logger.info("Adding {plugin} version {version} to {repo}".format( 902 | plugin=name, 903 | version=version, 904 | repo=repo_path, 905 | )) 906 | 907 | plugin_dir = os.path.join(repo_dir, slug) 908 | 909 | if plugin_url: 910 | logger.warning("Plugin url is specified, we are NOT copying the plugin file to the repo.") 911 | else: 912 | plugin_target = os.path.join(plugin_dir, '{slug}_{version}.zip'.format( 913 | slug=slug, 914 | version=version 915 | )) 916 | 917 | if not os.path.exists(plugin_dir): 918 | os.makedirs(plugin_dir) 919 | 920 | logger.info("Copying {plugin_file} to {plugin_target}".format( 921 | plugin_file=plugin_file, 922 | plugin_target=plugin_target, 923 | )) 924 | shutil.copyfile(plugin_file, plugin_target) 925 | 926 | if "image" in plugin_manifest: 927 | image_data = None 928 | with zipfile.ZipFile(plugin_file, 'r') as zf: 929 | if plugin_manifest["image"] in zf.namelist(): 930 | with zf.open(plugin_manifest["image"], 'r') as fh: 931 | image_data = fh.read() 932 | logger.info("Read image from `{}:{}`".format(plugin_file, plugin_manifest["image"])) 933 | 934 | if image_data is not None: 935 | image_target_path = os.path.join(plugin_dir, plugin_manifest["image"]) 936 | 937 | write_image = True 938 | if os.path.exists(image_target_path): 939 | existing_image_size = os.stat(image_target_path).st_size 940 | if existing_image_size == len(image_data): 941 | with open(image_target_path, "rb") as fh: 942 | existing_image = fh.read() 943 | 944 | if existing_image == image_data: 945 | write_image = False 946 | logger.info("Existing image same as new, skipping copy.") 947 | del existing_image 948 | else: 949 | logger.info("Existing image differs in size ({}).".format(existing_image_size)) 950 | 951 | if write_image: 952 | logger.info("Writing image to `{}`.".format(image_target_path)) 953 | if not os.path.exists(plugin_dir): 954 | os.makedirs(plugin_dir) 955 | with open(image_target_path, "wb") as fh: 956 | fh.write(image_data) 957 | del image_data 958 | 959 | updated = False 960 | for p_manifest in repo_manifest: 961 | if uuid.UUID(p_manifest.get('guid')) == guid: 962 | update_plugin_manifest(p_manifest, plugin_manifest) 963 | updated = True 964 | 965 | if not updated: 966 | repo_manifest.append(plugin_manifest) 967 | 968 | tmpfile = repo_path + '.tmp' 969 | with open(tmpfile, 'w') as fh: 970 | logging.debug('Writing repo manifest to {}'.format(tmpfile)) 971 | json.dump(repo_manifest, fh, indent=4) 972 | fh.flush() 973 | os.fsync(fh.fileno()) 974 | logging.debug('Renaming {} to {}'.format(tmpfile, repo_path)) 975 | os.replace(tmpfile, repo_path) 976 | 977 | 978 | @cli_repo.command('list') 979 | @click.argument('repo_path', 980 | nargs=1, 981 | required=True, 982 | type=RepoPathParam(should_exist=True), 983 | ) 984 | @click.argument('plugin', 985 | nargs=1, 986 | required=False, 987 | default=None, 988 | ) 989 | def cli_repo_list(repo_path, plugin): 990 | with open(repo_path, 'r') as fh: 991 | logger.debug('Reading repo manifest from {}'.format(repo_path)) 992 | repo_manifest = json.load(fh) 993 | 994 | if plugin is not None: 995 | try: 996 | plugin = str(uuid.UUID(plugin)) 997 | except ValueError: 998 | pass 999 | 1000 | items = [item for item in repo_manifest if plugin in [item.get('name'), item.get('guid'), slugify(item.get('name'))]] 1001 | if items: 1002 | item = items[0] 1003 | for version in item.get('versions', []): 1004 | click.echo(version.get('version')) 1005 | else: 1006 | raise click.UsageError('PLUGIN `{}` not found in `{}`'.format(plugin, repo_path)) 1007 | 1008 | else: 1009 | table = [] 1010 | for item in repo_manifest: 1011 | name = item.get('name') 1012 | guid = item.get('guid') 1013 | versions = sorted( 1014 | [release.get('version', '0.0') for release in item.get('versions', [])], 1015 | key = lambda rel: Version(rel), 1016 | reverse = True, 1017 | ) 1018 | 1019 | if versions: 1020 | version = versions[0] 1021 | else: 1022 | version = '' 1023 | 1024 | table.append([name, version, slugify(name), guid]) 1025 | 1026 | if table: 1027 | click.echo(tabulate.tabulate(table, headers=('NAME', 'VERSION', 'SLUG', 'GUID'), tablefmt='plain', colalign=('left', 'right', 'left'))) 1028 | 1029 | 1030 | @cli_repo.command('remove') 1031 | @click.argument('repo_path', 1032 | nargs=1, 1033 | required=True, 1034 | type=RepoPathParam(should_exist=True), 1035 | ) 1036 | @click.argument('plugin', 1037 | nargs=1, 1038 | required=True, 1039 | default=None, 1040 | ) 1041 | @click.argument('version', 1042 | nargs=1, 1043 | required=False, 1044 | default=None, 1045 | type=Version, 1046 | ) 1047 | def cli_repo_remove(repo_path, plugin, version: Optional[Version]): 1048 | with open(repo_path, 'r') as fh: 1049 | logger.debug('Reading repo manifest from {}'.format(repo_path)) 1050 | repo_manifest = json.load(fh) 1051 | 1052 | plugin_manifest = get_plugin_from_manifest(repo_manifest, plugin) 1053 | if plugin_manifest is None: 1054 | raise click.UsageError('PLUGIN `{}` not found in `{}`'.format(plugin, repo_path)) 1055 | 1056 | if version is None: 1057 | logger.warning(f"Removing plugin {plugin_manifest.get('name')})") 1058 | repo_manifest.remove(plugin_manifest) 1059 | click.echo(f"removed {plugin_manifest.get('guid')}") 1060 | else: 1061 | version_str = version.full() 1062 | for release in list(plugin_manifest.get('versions', [])): 1063 | if release.get('version') == version_str: 1064 | logger.warning(f"Removing version {version} of plugin {plugin_manifest.get('name')})") 1065 | plugin_manifest['versions'].remove(release) 1066 | click.echo(f"removed {plugin_manifest.get('guid')} {version_str}") 1067 | 1068 | tmpfile = repo_path + '.tmp' 1069 | with open(tmpfile, 'w') as fh: 1070 | logging.debug('Writing repo manifest to {}'.format(tmpfile)) 1071 | json.dump(repo_manifest, fh, indent=4) 1072 | fh.flush() 1073 | os.fsync(fh.fileno()) 1074 | logging.debug('Renaming {} to {}'.format(tmpfile, repo_path)) 1075 | os.replace(tmpfile, repo_path) 1076 | 1077 | 1078 | #################### 1079 | 1080 | 1081 | if __name__ == "__main__": 1082 | cli() 1083 | -------------------------------------------------------------------------------- /jprm/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Copyright (c) 2020 - Odd Strabo 4 | # 5 | # This Source Code Form is subject to the terms of the Mozilla Public 6 | # License, v. 2.0. If a copy of the MPL was not distributed with this 7 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 8 | # 9 | 10 | from jprm import cli 11 | 12 | if __name__ == "__main__": 13 | cli() 14 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | pytest >= 5.4 2 | pytest-click==1.1.0 3 | pytest-datafiles==3.0.0 4 | coverage >= 5.2 5 | flake8==7.1.1 6 | flake8-import-order==0.18.2 7 | testfixtures==8.3.0 8 | 9 | types-tabulate==0.9.0.20240106 10 | types-python-slugify==8.0.2.20240310 11 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | 2 | Click ~= 8.0, != 8.0.0 3 | click-log ~=0.4.0 4 | PyYAML <7, >=5.4 5 | python-slugify ~=8.0 6 | tabulate ~=0.9.0 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import re 3 | 4 | from setuptools import setup, find_packages 5 | 6 | with open('jprm/__init__.py', 'r') as fh: 7 | version = re.search(r'__version__ *= *["\'](.*?)["\']', fh.read()).group(1) 8 | 9 | with open("README.md", "r") as fh: 10 | long_description = fh.read() 11 | 12 | with open("requirements.txt", "r") as fh: 13 | requirements = [] 14 | for line in fh.readlines(): 15 | line = line.strip() 16 | if line: 17 | requirements.append(line) 18 | 19 | setup( 20 | name='jprm', 21 | version=version, 22 | author='Odd Stråbø', 23 | author_email='oddstr13@openshell.no', 24 | description='Jellyfin Plugin Repository Manager', 25 | keywords='Jellyfin plugin repository compile publish development', 26 | url='https://github.com/oddstr13/jellyfin-plugin-repository-manager', 27 | long_description=long_description, 28 | long_description_content_type="text/markdown", 29 | packages=find_packages(exclude=["tests"]), 30 | install_requires=requirements, 31 | entry_points={ 32 | 'console_scripts': [ 33 | 'jprm=jprm.__main__:cli', 34 | ] 35 | }, 36 | zip_safe=True, 37 | classifiers=[ 38 | 'Programming Language :: Python :: 3 :: Only', 39 | 'Programming Language :: Python :: 3.8', 40 | 'Programming Language :: Python :: 3.9', 41 | 'Programming Language :: Python :: 3.10', 42 | 'Programming Language :: Python :: 3.11', 43 | 'Programming Language :: Python :: 3.12', 44 | 'License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)', 45 | 'Operating System :: OS Independent', 46 | 'Intended Audience :: Developers', 47 | 'Intended Audience :: System Administrators', 48 | 'Topic :: Software Development :: Build Tools', 49 | 'Topic :: System :: Software Distribution', 50 | ], 51 | python_requires='>=3.8.1', 52 | ) 53 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddstr13/jellyfin-plugin-repository-manager/651fc28fa4b38a920737549e1bb7e8e3539af1dd/tests/__init__.py -------------------------------------------------------------------------------- /tests/data/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddstr13/jellyfin-plugin-repository-manager/651fc28fa4b38a920737549e1bb7e8e3539af1dd/tests/data/image.png -------------------------------------------------------------------------------- /tests/data/jprm.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Plugin A", 3 | "guid": "f5ddc434-4b42-45d0-a049-8dda7f1ed30b", 4 | "version": "1.0.0.0", 5 | "targetAbi": "10.8.0.0", 6 | "framework": "net6.0", 7 | "owner": "Oddstr13", 8 | "overview": "Test plugin A", 9 | "description": "Test plugin A", 10 | "category": "Other", 11 | "artifacts": [ 12 | "dummy.dll" 13 | ], 14 | "changelog": "Initial Release\n" 15 | } 16 | -------------------------------------------------------------------------------- /tests/data/jprm.yaml: -------------------------------------------------------------------------------- 1 | name: 'Plugin A' 2 | guid: 'f5ddc434-4b42-45d0-a049-8dda7f1ed30b' 3 | version: '1.0.0.0' 4 | targetAbi: '10.8.0.0' 5 | framework: 'net6.0' 6 | owner: 'Oddstr13' 7 | overview: 'Test plugin A' 8 | description: 'Test plugin A' 9 | category: 'Other' 10 | artifacts: 11 | - 'dummy.dll' 12 | changelog: > 13 | Initial Release 14 | -------------------------------------------------------------------------------- /tests/data/manifest_pluginA.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "guid": "f5ddc434-4b42-45d0-a049-8dda7f1ed30b", 4 | "name": "Plugin A", 5 | "description": "Test plugin A", 6 | "overview": "Test plugin A", 7 | "owner": "Oddstr13", 8 | "category": "Other", 9 | "versions": [ 10 | { 11 | "version": "1.0.0.0", 12 | "changelog": "Initial Release\n", 13 | "targetAbi": "10.8.0.0", 14 | "sourceUrl": "/plugin-a/plugin-a_1.0.0.0.zip", 15 | "checksum": "33d17c8b998bc4f9f0d73d89dffb20f7", 16 | "timestamp": "2022-07-12T00:00:00Z" 17 | } 18 | ] 19 | } 20 | ] -------------------------------------------------------------------------------- /tests/data/manifest_pluginA2.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "guid": "f5ddc434-4b42-45d0-a049-8dda7f1ed30b", 4 | "name": "Plugin A", 5 | "description": "Test plugin A", 6 | "overview": "Test plugin A", 7 | "owner": "Oddstr13", 8 | "category": "Other", 9 | "imageUrl": "https://raw.githubusercontent.com/jellyfin/jellyfin-ux/c6d7b1fc16c8d62df9125d6463f3d404e0e81bae/plugins/repository/jellyfin-plugin-reports.png", 10 | "versions": [ 11 | { 12 | "version": "1.1.0.0", 13 | "changelog": "Initial Release\n", 14 | "targetAbi": "10.8.0.0", 15 | "sourceUrl": "/plugin-a/plugin-a_1.1.0.0.zip", 16 | "checksum": "882a432ec1d53396006bbf5cc9f378b7", 17 | "timestamp": "2022-07-12T01:00:00Z" 18 | }, 19 | { 20 | "version": "1.0.0.0", 21 | "changelog": "Initial Release\n", 22 | "targetAbi": "10.8.0.0", 23 | "sourceUrl": "/plugin-a/plugin-a_1.0.0.0.zip", 24 | "checksum": "33d17c8b998bc4f9f0d73d89dffb20f7", 25 | "timestamp": "2022-07-12T00:00:00Z" 26 | } 27 | ] 28 | } 29 | ] -------------------------------------------------------------------------------- /tests/data/manifest_pluginAB.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "guid": "f5ddc434-4b42-45d0-a049-8dda7f1ed30b", 4 | "name": "Plugin A", 5 | "description": "Test plugin A", 6 | "overview": "Test plugin A", 7 | "owner": "Oddstr13", 8 | "category": "Other", 9 | "imageUrl": "https://raw.githubusercontent.com/jellyfin/jellyfin-ux/c6d7b1fc16c8d62df9125d6463f3d404e0e81bae/plugins/repository/jellyfin-plugin-reports.png", 10 | "versions": [ 11 | { 12 | "version": "1.1.0.0", 13 | "changelog": "Initial Release\n", 14 | "targetAbi": "10.8.0.0", 15 | "sourceUrl": "/plugin-a/plugin-a_1.1.0.0.zip", 16 | "checksum": "882a432ec1d53396006bbf5cc9f378b7", 17 | "timestamp": "2022-07-12T01:00:00Z" 18 | }, 19 | { 20 | "version": "1.0.0.0", 21 | "changelog": "Initial Release\n", 22 | "targetAbi": "10.8.0.0", 23 | "sourceUrl": "/plugin-a/plugin-a_1.0.0.0.zip", 24 | "checksum": "33d17c8b998bc4f9f0d73d89dffb20f7", 25 | "timestamp": "2022-07-12T00:00:00Z" 26 | } 27 | ] 28 | }, 29 | { 30 | "guid": "64bddcee-f8a0-444b-a467-e51ad47fea63", 31 | "name": "Plugin B", 32 | "description": "Test plugin B", 33 | "overview": "Test plugin B", 34 | "owner": "Oddstr13", 35 | "category": "Other", 36 | "versions": [ 37 | { 38 | "version": "1.0.0.0", 39 | "changelog": "Initial Release\n", 40 | "targetAbi": "10.8.0.0", 41 | "sourceUrl": "/plugin-b/plugin-b_1.0.0.0.zip", 42 | "checksum": "3421515aca933561ec62cfb62ab8fadd", 43 | "timestamp": "2022-07-12T01:00:00Z" 44 | } 45 | ], 46 | "image": "image.png", 47 | "imageUrl": "/plugin-b/image.png" 48 | } 49 | ] -------------------------------------------------------------------------------- /tests/data/manifest_pluginAB2.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "guid": "f5ddc434-4b42-45d0-a049-8dda7f1ed30b", 4 | "name": "Plugin A", 5 | "description": "Test plugin A", 6 | "overview": "Test plugin A", 7 | "owner": "Oddstr13", 8 | "category": "Other", 9 | "imageUrl": "https://raw.githubusercontent.com/jellyfin/jellyfin-ux/c6d7b1fc16c8d62df9125d6463f3d404e0e81bae/plugins/repository/jellyfin-plugin-reports.png", 10 | "versions": [ 11 | { 12 | "version": "1.1.0.0", 13 | "changelog": "Initial Release\n", 14 | "targetAbi": "10.8.0.0", 15 | "sourceUrl": "/plugin-a/plugin-a_1.1.0.0.zip", 16 | "checksum": "882a432ec1d53396006bbf5cc9f378b7", 17 | "timestamp": "2022-07-12T01:00:00Z" 18 | } 19 | ] 20 | }, 21 | { 22 | "guid": "64bddcee-f8a0-444b-a467-e51ad47fea63", 23 | "name": "Plugin B", 24 | "description": "Test plugin B", 25 | "overview": "Test plugin B", 26 | "owner": "Oddstr13", 27 | "category": "Other", 28 | "versions": [ 29 | { 30 | "version": "1.0.0.0", 31 | "changelog": "Initial Release\n", 32 | "targetAbi": "10.8.0.0", 33 | "sourceUrl": "/plugin-b/plugin-b_1.0.0.0.zip", 34 | "checksum": "3421515aca933561ec62cfb62ab8fadd", 35 | "timestamp": "2022-07-12T01:00:00Z" 36 | } 37 | ], 38 | "image": "image.png", 39 | "imageUrl": "/plugin-b/image.png" 40 | } 41 | ] 42 | -------------------------------------------------------------------------------- /tests/data/manifest_pluginB.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "guid": "64bddcee-f8a0-444b-a467-e51ad47fea63", 4 | "name": "Plugin B", 5 | "description": "Test plugin B", 6 | "overview": "Test plugin B", 7 | "owner": "Oddstr13", 8 | "category": "Other", 9 | "versions": [ 10 | { 11 | "version": "1.0.0.0", 12 | "changelog": "Initial Release\n", 13 | "targetAbi": "10.8.0.0", 14 | "sourceUrl": "/plugin-b/plugin-b_1.0.0.0.zip", 15 | "checksum": "3421515aca933561ec62cfb62ab8fadd", 16 | "timestamp": "2022-07-12T01:00:00Z" 17 | } 18 | ], 19 | "image": "image.png", 20 | "imageUrl": "/plugin-b/image.png" 21 | } 22 | ] 23 | -------------------------------------------------------------------------------- /tests/data/pluginA_1.0.0.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddstr13/jellyfin-plugin-repository-manager/651fc28fa4b38a920737549e1bb7e8e3539af1dd/tests/data/pluginA_1.0.0.zip -------------------------------------------------------------------------------- /tests/data/pluginA_1.1.0.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddstr13/jellyfin-plugin-repository-manager/651fc28fa4b38a920737549e1bb7e8e3539af1dd/tests/data/pluginA_1.1.0.zip -------------------------------------------------------------------------------- /tests/data/pluginB_1.0.0.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddstr13/jellyfin-plugin-repository-manager/651fc28fa4b38a920737549e1bb7e8e3539af1dd/tests/data/pluginB_1.0.0.zip -------------------------------------------------------------------------------- /tests/test_plugin.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import shutil 3 | 4 | import pytest 5 | import jprm 6 | 7 | from .test_utils import TEST_DATA_DIR 8 | 9 | 10 | @pytest.mark.datafiles( 11 | TEST_DATA_DIR / "jprm.yaml", 12 | ) 13 | def test_package_plugin(tmp_path_factory, datafiles: Path): 14 | bindir: Path = tmp_path_factory.mktemp("bin") 15 | plugin: Path = tmp_path_factory.mktemp("plugin") 16 | artifacts: Path = tmp_path_factory.mktemp("artifacts") 17 | 18 | (bindir / "dummy.dll").write_text("", "utf-8") 19 | 20 | shutil.copy(datafiles / "jprm.yaml", plugin) 21 | 22 | output_path = Path( 23 | jprm.package_plugin( 24 | str(plugin), version="5.0", binary_path=str(bindir), output=str(artifacts) 25 | ) 26 | ) 27 | 28 | assert output_path.exists() 29 | assert (artifacts / "plugin-a_5.0.0.0.zip").is_file() 30 | assert (artifacts / "plugin-a_5.0.0.0.zip.meta.json").is_file() 31 | assert (artifacts / "plugin-a_5.0.0.0.zip.md5sum").is_file() 32 | 33 | res = jprm.run_os_command( 34 | "md5sum -c plugin-a_5.0.0.0.zip.md5sum", cwd=str(artifacts) 35 | ) 36 | assert res[2] == 0 37 | 38 | # res = jprm.run_os_command('unzip -t plugin-a_5.0.0.0.zip', cwd=str(artifacts)) 39 | # assert res[2] == 0 40 | -------------------------------------------------------------------------------- /tests/test_repo.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | import pytest 5 | from click.testing import CliRunner 6 | import jprm 7 | 8 | from .test_utils import TEST_DATA_DIR, json_load 9 | 10 | 11 | def test_init_repo_path(cli_runner: CliRunner, tmp_path: Path): 12 | result = cli_runner.invoke( 13 | jprm.cli, ["--verbosity=debug", "repo", "init", str(tmp_path)] 14 | ) 15 | 16 | manifest = tmp_path / "manifest.json" 17 | 18 | assert result.exit_code == 0 19 | assert os.path.exists(manifest) 20 | 21 | data = json_load(manifest) 22 | 23 | assert data == [] 24 | 25 | 26 | def test_init_repo_file(cli_runner: CliRunner, tmp_path: Path): 27 | manifest = tmp_path / "foo.json" 28 | 29 | result = cli_runner.invoke( 30 | jprm.cli, ["--verbosity=debug", "repo", "init", str(manifest)] 31 | ) 32 | 33 | assert result.exit_code == 0 34 | assert os.path.exists(manifest) 35 | 36 | data = json_load(manifest) 37 | 38 | assert data == [] 39 | 40 | 41 | def test_double_init_repo_path(cli_runner: CliRunner, tmp_path: Path): 42 | # Initializing an existing repo is not allowed 43 | cli_runner.invoke(jprm.cli, ["--verbosity=debug", "repo", "init", str(tmp_path)]) 44 | result = cli_runner.invoke( 45 | jprm.cli, ["--verbosity=debug", "repo", "init", str(tmp_path)] 46 | ) 47 | 48 | assert result.exit_code == 2 49 | 50 | 51 | @pytest.mark.datafiles( 52 | TEST_DATA_DIR / "pluginA_1.0.0.zip", 53 | TEST_DATA_DIR / "pluginA_1.1.0.zip", 54 | TEST_DATA_DIR / "pluginB_1.0.0.zip", 55 | TEST_DATA_DIR / "manifest_pluginA.json", 56 | TEST_DATA_DIR / "manifest_pluginA2.json", 57 | TEST_DATA_DIR / "manifest_pluginAB.json", 58 | ) 59 | def test_repo_add(cli_runner: CliRunner, tmp_path: Path, datafiles: Path): 60 | manifest_file = tmp_path / "repo.json" 61 | result = cli_runner.invoke( 62 | jprm.cli, ["--verbosity=debug", "repo", "init", str(manifest_file)] 63 | ) 64 | assert result.exit_code == 0 65 | 66 | manifest_a = json_load(datafiles / "manifest_pluginA.json") 67 | manifest_a2 = json_load(datafiles / "manifest_pluginA2.json") 68 | manifest_ab = json_load(datafiles / "manifest_pluginAB.json") 69 | 70 | cli_runner.invoke( 71 | jprm.cli, 72 | [ 73 | "--verbosity=debug", 74 | "repo", 75 | "add", 76 | str(manifest_file), 77 | str(datafiles / "pluginA_1.0.0.zip"), 78 | ], 79 | ) 80 | manifest = json_load(manifest_file) 81 | assert manifest == manifest_a 82 | assert (tmp_path / "plugin-a" / "plugin-a_1.0.0.0.zip").exists() 83 | 84 | cli_runner.invoke( 85 | jprm.cli, 86 | [ 87 | "--verbosity=debug", 88 | "repo", 89 | "add", 90 | str(manifest_file), 91 | str(datafiles / "pluginA_1.1.0.zip"), 92 | ], 93 | ) 94 | manifest = json_load(manifest_file) 95 | assert manifest == manifest_a2 96 | assert (tmp_path / "plugin-a" / "plugin-a_1.1.0.0.zip").exists() 97 | 98 | cli_runner.invoke( 99 | jprm.cli, 100 | [ 101 | "--verbosity=debug", 102 | "repo", 103 | "add", 104 | str(manifest_file), 105 | str(datafiles / "pluginB_1.0.0.zip"), 106 | ], 107 | ) 108 | manifest = json_load(manifest_file) 109 | assert manifest == manifest_ab 110 | assert (tmp_path / "plugin-b" / "plugin-b_1.0.0.0.zip").exists() 111 | assert (tmp_path / "plugin-b" / "image.png").exists() 112 | -------------------------------------------------------------------------------- /tests/test_repo_add_external.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | from click.testing import CliRunner 5 | from testfixtures import compare, LogCapture 6 | import jprm 7 | 8 | from .test_utils import TEST_DATA_DIR, json_load 9 | 10 | 11 | @pytest.mark.datafiles( 12 | TEST_DATA_DIR / "pluginA_1.0.0.zip", 13 | TEST_DATA_DIR / "manifest_pluginA.json", 14 | ) 15 | @pytest.mark.parametrize( 16 | "url", 17 | [ 18 | "http://example.org/plugins/pluginA_1.0.0.zip", 19 | "https://example.org/pluginA.zip", 20 | "https://ipfs.io/ipfs/Qme7ss3ARVgxv6rXqVPiikMJ8u2NLgmgszg13pYrDKEoiu", 21 | "ipfs://Qme7ss3ARVgxv6rXqVPiikMJ8u2NLgmgszg13pYrDKEoiu", 22 | "ipfs://bafybeiemxf5abjwjbikoz4mc3a3dla6ual3jsgpdr4cjr3oz3evfyavhwq/wiki/Vincent_van_Gogh.html", 23 | ], 24 | ) 25 | def test_repo_add( 26 | url: str, 27 | cli_runner: CliRunner, 28 | tmp_path: Path, 29 | datafiles: Path, 30 | ): 31 | manifest_file = tmp_path / "repo.json" 32 | result = cli_runner.invoke( 33 | jprm.cli, ["--verbosity=debug", "repo", "init", str(manifest_file)] 34 | ) 35 | assert result.exit_code == 0 36 | 37 | manifest_a = json_load(datafiles / "manifest_pluginA.json") 38 | manifest_a[0]["versions"][0]["sourceUrl"] = url 39 | 40 | with LogCapture("jprm") as capture: 41 | cli_runner.invoke( 42 | jprm.cli, 43 | [ 44 | "--verbosity=debug", 45 | "repo", 46 | "add", 47 | str(manifest_file), 48 | str(datafiles / "pluginA_1.0.0.zip"), 49 | "--plugin-url", 50 | url, 51 | ], 52 | ) 53 | manifest = json_load(manifest_file) 54 | compare(manifest, manifest_a) 55 | 56 | capture.check_present( 57 | ( 58 | "jprm", 59 | "INFO", 60 | f"Plugin url `{url}` overrides the autogenerated `/plugin-a/plugin-a_1.0.0.0.zip`.", 61 | ), 62 | ) 63 | 64 | assert not (tmp_path / "plugin-a" / "plugin-a_1.0.0.0.zip").exists() 65 | -------------------------------------------------------------------------------- /tests/test_repo_remove.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | from pathlib import Path 3 | 4 | import pytest 5 | from click.testing import CliRunner 6 | from testfixtures import compare 7 | import jprm 8 | 9 | from .test_utils import TEST_DATA_DIR, json_load 10 | 11 | 12 | @pytest.mark.parametrize( 13 | "input_file,args,output_file,guid", 14 | [ 15 | ( 16 | "manifest_pluginAB.json", 17 | ["f5ddc434-4b42-45d0-a049-8dda7f1ed30b", "1"], 18 | "manifest_pluginAB2.json", 19 | "f5ddc434-4b42-45d0-a049-8dda7f1ed30b", 20 | ), 21 | ( 22 | "manifest_pluginAB.json", 23 | ["f5ddc434-4b42-45d0-a049-8dda7f1ed30b", "1.0"], 24 | "manifest_pluginAB2.json", 25 | "f5ddc434-4b42-45d0-a049-8dda7f1ed30b", 26 | ), 27 | ( 28 | "manifest_pluginAB.json", 29 | ["f5ddc434-4b42-45d0-a049-8dda7f1ed30b", "1.0.0.0"], 30 | "manifest_pluginAB2.json", 31 | "f5ddc434-4b42-45d0-a049-8dda7f1ed30b", 32 | ), 33 | ( 34 | "manifest_pluginAB.json", 35 | ["plugin-a", "1.0.0.0"], 36 | "manifest_pluginAB2.json", 37 | "f5ddc434-4b42-45d0-a049-8dda7f1ed30b", 38 | ), 39 | ( 40 | "manifest_pluginAB.json", 41 | ["Plugin A"], 42 | "manifest_pluginB.json", 43 | "f5ddc434-4b42-45d0-a049-8dda7f1ed30b", 44 | ), 45 | ( 46 | "manifest_pluginAB.json", 47 | ["f5ddc434-4b42-45d0-a049-8dda7f1ed30b"], 48 | "manifest_pluginB.json", 49 | "f5ddc434-4b42-45d0-a049-8dda7f1ed30b", 50 | ), 51 | ], 52 | ) 53 | @pytest.mark.datafiles( 54 | TEST_DATA_DIR / "manifest_pluginAB.json", 55 | TEST_DATA_DIR / "manifest_pluginAB2.json", 56 | TEST_DATA_DIR / "manifest_pluginB.json", 57 | ) 58 | def test_repo_remove( 59 | input_file, 60 | args, 61 | output_file, 62 | guid, 63 | cli_runner: CliRunner, 64 | datafiles: Path, 65 | ): 66 | manifest_file = datafiles / "repo.json" 67 | shutil.copyfile(datafiles / input_file, manifest_file) 68 | 69 | result = cli_runner.invoke( 70 | jprm.cli, ["--verbosity=debug", "repo", "remove", str(manifest_file), *args] 71 | ) 72 | assert result.exit_code == 0 73 | 74 | if len(args) == 1: 75 | assert f"removed {guid}" in result.stdout.splitlines(False) 76 | else: 77 | version = jprm.Version(args[1]).full() 78 | assert f"removed {guid} {version}" in result.stdout.splitlines(False) 79 | 80 | compare( 81 | expected=json_load(datafiles / output_file), 82 | actual=json_load(manifest_file), 83 | ) 84 | -------------------------------------------------------------------------------- /tests/test_slugify.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from slugify import slugify 3 | 4 | 5 | @pytest.mark.parametrize( 6 | "name,slug", 7 | [ 8 | ("Bookshelf", "bookshelf"), 9 | ("Fanart", "fanart"), 10 | ("IMVDb", "imvdb"), 11 | ("Kodi Sync Queue", "kodi-sync-queue"), 12 | ("LDAP Authentication", "ldap-authentication"), 13 | ("NextPVR", "nextpvr"), 14 | ("Open Subtitles", "open-subtitles"), 15 | ("Playback Reporting", "playback-reporting"), 16 | ("Reports", "reports"), 17 | ("TMDb Box Sets", "tmdb-box-sets"), 18 | ("Trakt", "trakt"), 19 | ("TVHeadend", "tvheadend"), 20 | ("Cover Art Archive", "cover-art-archive"), 21 | ("TheTVDB", "thetvdb"), 22 | ("AniDB", "anidb"), 23 | ("AniList", "anilist"), 24 | ("AniSearch", "anisearch"), 25 | ("Kitsu", "kitsu"), 26 | ("TVMaze", "tvmaze"), 27 | ("Webhook", "webhook"), 28 | ("OPDS", "opds"), 29 | ("Session Cleaner", "session-cleaner"), 30 | ("VGMdb", "vgmdb"), 31 | ("Simkl", "simkl"), 32 | ("Subtitle Extract", "subtitle-extract"), 33 | ], 34 | ) 35 | def test_slugify(name: str, slug: str): 36 | assert slugify(name) == slug 37 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import json 4 | from pathlib import Path 5 | 6 | import pytest 7 | import jprm 8 | 9 | 10 | TEST_DATA_DIR = Path(os.path.dirname(os.path.realpath(__file__))) / "data" 11 | 12 | 13 | def json_load(path: Path, **kwargs): 14 | with open(path, "rt", encoding="utf8") as handle: 15 | return json.load(handle, **kwargs) 16 | 17 | 18 | @pytest.mark.datafiles( 19 | TEST_DATA_DIR / "jprm.yaml", 20 | TEST_DATA_DIR / "jprm.json", 21 | ) 22 | def test_load_manifest(datafiles: Path): 23 | assert jprm.load_manifest(datafiles / "jprm.yaml") == json_load( 24 | datafiles / "jprm.json" 25 | ) 26 | 27 | 28 | @pytest.mark.datafiles( 29 | TEST_DATA_DIR / "jprm.yaml", 30 | TEST_DATA_DIR / "jprm.json", 31 | ) 32 | def test_get_config(datafiles: Path): 33 | assert jprm.get_config(datafiles) == json_load(datafiles / "jprm.json") 34 | 35 | 36 | @pytest.mark.datafiles( 37 | TEST_DATA_DIR / "jprm.yaml", 38 | TEST_DATA_DIR / "jprm.json", 39 | ) 40 | def test_get_config_old(datafiles: Path): 41 | (datafiles / "jprm.yaml").replace(datafiles / "build.yaml") 42 | assert jprm.get_config(datafiles) == json_load(datafiles / "jprm.json") 43 | 44 | 45 | def test_invalid_manifest(tmp_path: Path): 46 | with open(tmp_path / "jprm.yaml", "wt", encoding="utf8") as fh: 47 | fh.write("]]]") 48 | assert jprm.get_config(tmp_path) is None 49 | 50 | 51 | def test_no_manifest(tmp_path: Path): 52 | assert jprm.get_config(tmp_path) is None 53 | 54 | 55 | @pytest.mark.parametrize( 56 | "cmd,kw,res", 57 | [ 58 | ("true", {}, ("", "", 0)), 59 | ("false", {}, ("", "", 1)), 60 | ("echo '123'", {"shell": True}, ("123\n", "", 0)), 61 | ("echo '123'", {"shell": False}, ("'123'\n", "", 0)), 62 | ("echo '123' > /dev/stderr", {"shell": True}, ("", "123\n", 0)), 63 | ], 64 | ) 65 | @pytest.mark.skipif(sys.platform.startswith("win"), reason="Unix only commands") 66 | def test_run_os_command(cmd, kw, res): 67 | assert jprm.run_os_command(cmd, **kw) == res 68 | 69 | 70 | def test_run_os_command_error(tmp_path: Path): 71 | with pytest.raises(FileNotFoundError): 72 | jprm.run_os_command("echo", cwd=tmp_path / "potatoe") 73 | 74 | 75 | def test_version(): 76 | ver = jprm.Version("1.2.3.0") 77 | assert ver.major == 1 78 | assert ver.minor == 2 79 | assert ver.build == 3 80 | assert ver.revision == 0 81 | assert str(ver) == "1.2.3.0" 82 | assert ver.values() == (1, 2, 3, 0) 83 | assert ver.keys() == ("major", "minor", "build", "revision") 84 | assert dict(ver.items()) == {"major": 1, "minor": 2, "build": 3, "revision": 0} 85 | assert "major" in ver 86 | assert len(ver) == 4 87 | 88 | del ver["revision"] 89 | assert str(ver) == "1.2.3" 90 | ver.major = 2 91 | assert str(ver) == "2.2.3" 92 | ver.minor = 0 93 | assert str(ver) == "2.0.3" 94 | ver.build = None 95 | assert str(ver) == "2.0" 96 | 97 | ver["major"] = 3 98 | assert str(ver) == "3.0" 99 | 100 | ver["minor"] = None 101 | assert str(ver) == "3" 102 | 103 | assert ver == jprm.Version(ver) 104 | 105 | assert repr(ver) == "" 106 | assert [x for x in ver] == [3, None, None, None] 107 | 108 | assert ver.get("foo", False) is False 109 | assert ver.get("minor", False) is None 110 | assert ver.get("major", False) == 3 111 | 112 | assert ver == jprm.Version(3) 113 | 114 | with pytest.raises(ValueError): 115 | jprm.Version("1.2.3-beta.2") 116 | 117 | with pytest.raises(TypeError): 118 | jprm.Version(3.5) 119 | 120 | with pytest.raises(KeyError): 121 | print(ver["__len__"]) 122 | 123 | assert ver.full() == "3.0.0.0" 124 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 9999 3 | import-order-style = pep8 4 | exclude = ./.git,./.vscode 5 | ignore = E501, E251, E122, E124, E128 6 | 7 | [pytest] 8 | minversion = 5.4 9 | testpaths = 10 | tests 11 | 12 | [coverage:run] 13 | include = ./* 14 | omit = tests/* 15 | command_line = -m pytest 16 | --------------------------------------------------------------------------------