diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..2d028b2 --- /dev/null +++ b/.flake8 @@ -0,0 +1,7 @@ +[flake8] +filename = + *.py, + *.pys +max-line-length = 120 +extend-exclude = + venv/ diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..6eb0cda --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,48 @@ +--- +# This action is centrally managed in https://github.com//.github/ +# Don't make changes to this file in this repo as they will be overwritten with changes made to the same file in +# the above-mentioned repo. + +version: 2 +updates: + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "daily" + time: "08:00" + open-pull-requests-limit: 10 + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + time: "08:30" + open-pull-requests-limit: 10 + + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "daily" + time: "09:00" + open-pull-requests-limit: 10 + + - package-ecosystem: "nuget" + directory: "/" + schedule: + interval: "daily" + time: "09:30" + open-pull-requests-limit: 10 + + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "daily" + time: "10:00" + open-pull-requests-limit: 10 + + - package-ecosystem: "gitsubmodule" + directory: "/" + schedule: + interval: "daily" + time: "10:30" + open-pull-requests-limit: 10 diff --git a/.github/label-actions.yml b/.github/label-actions.yml new file mode 100644 index 0000000..2949601 --- /dev/null +++ b/.github/label-actions.yml @@ -0,0 +1,49 @@ +--- +# This action is centrally managed in https://github.com//.github/ +# Don't make changes to this file in this repo as they will be overwritten with changes made to the same file in +# the above-mentioned repo. + +# Configuration for Label Actions - https://github.com/dessant/label-actions + +added: + comment: > + This feature has been added and will be available in the next release. +fixed: + comment: > + This issue has been fixed and will be available in the next release. +invalid:duplicate: + comment: > + :wave: @{issue-author}, this appears to be a duplicate of a pre-existing issue. + close: true + lock: true + unlabel: 'status:awaiting-triage' + +-invalid:duplicate: + reopen: true + unlock: true + +invalid:support: + comment: > + :wave: @{issue-author}, we use the issue tracker exclusively for bug reports. + However, this issue appears to be a support request. Please use our + [Support Center](https://app.lizardbyte.dev/support) for support issues. Thanks. + close: true + lock: true + lock-reason: 'off-topic' + unlabel: 'status:awaiting-triage' + +-invalid:support: + reopen: true + unlock: true + +invalid:template-incomplete: + issues: + comment: > + :wave: @{issue-author}, please edit your issue to complete the template with + all the required info. Your issue will be automatically closed in 5 days if + the template is not completed. Thanks. + prs: + comment: > + :wave: @{issue-author}, please edit your PR to complete the template with + all the required info. Your PR will be automatically closed in 5 days if + the template is not completed. Thanks. diff --git a/.github/pr_release_template.md b/.github/pr_release_template.md new file mode 100644 index 0000000..b6f6acf --- /dev/null +++ b/.github/pr_release_template.md @@ -0,0 +1,28 @@ +## Description + +This PR was created automatically. + + +### Screenshot + + + +### Issues Fixed or Closed + + + + + +## Type of Change +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] Dependency update (updates to dependencies) +- [ ] Documentation update (changes to documentation) +- [ ] Repository update (changes to repository files, e.g. `.github/...`) + +## Branch Updates +- [x] I want maintainers to keep my branch updated + +## Changelog Summary + diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml new file mode 100644 index 0000000..95b8c30 --- /dev/null +++ b/.github/workflows/CI.yml @@ -0,0 +1,125 @@ +--- +name: CI + +on: + pull_request: + branches: [master] + types: [opened, synchronize, reopened] + push: + branches: [master] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + setup_release: + name: Setup Release + outputs: + changelog_changes: ${{ steps.setup_release.outputs.changelog_changes }} + changelog_date: ${{ steps.setup_release.outputs.changelog_date }} + changelog_exists: ${{ steps.setup_release.outputs.changelog_exists }} + changelog_release_exists: ${{ steps.setup_release.outputs.changelog_release_exists }} + changelog_url: ${{ steps.setup_release.outputs.changelog_url }} + changelog_version: ${{ steps.setup_release.outputs.changelog_version }} + publish_pre_release: ${{ steps.setup_release.outputs.publish_pre_release }} + publish_release: ${{ steps.setup_release.outputs.publish_release }} + publish_stable_release: ${{ steps.setup_release.outputs.publish_stable_release }} + release_body: ${{ steps.setup_release.outputs.release_body }} + release_build: ${{ steps.setup_release.outputs.release_build }} + release_commit: ${{ steps.setup_release.outputs.release_commit }} + release_generate_release_notes: ${{ steps.setup_release.outputs.release_generate_release_notes }} + release_tag: ${{ steps.setup_release.outputs.release_tag }} + release_version: ${{ steps.setup_release.outputs.release_version }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Release + id: setup_release + uses: LizardByte/setup-release-action@v2023.1210.1904 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + + build: + env: + KODI_BRANCH: Nexus + KODI_PYTHON_VERSION: '3.8' # kodi uses 3.8? https://kodi.wiki/view/Python_libraries + needs: + - setup_release + runs-on: ubuntu-22.04 + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.KODI_PYTHON_VERSION }} + + - name: Install python dependencies + shell: bash + run: | + python -m pip install --upgrade pip setuptools wheel + python -m pip install -r requirements-dev.txt + python -m pip install -r requirements.txt + + - name: Compile Locale Translations + shell: bash + run: | + python -m scripts.locale --compile + + - name: Build + shell: bash + env: + BUILD_VERSION: ${{ needs.setup_release.outputs.release_tag }} + run: | + python -m scripts.build + + - name: Package Release + shell: bash + run: | + mkdir -p ./artifacts + mv ./build/service.themerr.zip ./artifacts/ + + - name: Upload Artifacts + uses: actions/upload-artifact@v3 + with: + name: service.themerr + if-no-files-found: error # 'warn' or 'ignore' are also available, defaults to `warn` + path: | + ${{ github.workspace }}/artifacts + + - name: Test with pytest + id: test + shell: bash + run: | + python -m pytest \ + -rxXs \ + --tb=native \ + --verbose \ + --cov=src \ + tests + + - name: Upload coverage + # any except canceled or skipped + if: always() && (steps.test.outcome == 'success' || steps.test.outcome == 'failure') + uses: codecov/codecov-action@v3 + + - name: Create/Update GitHub Release + if: ${{ needs.setup_release.outputs.publish_release == 'true' }} + uses: LizardByte/create-release-action@v2023.1210.832 + with: + allowUpdates: true + body: '' + discussionCategory: announcements + generateReleaseNotes: true + name: ${{ needs.setup_release.outputs.release_tag }} + prerelease: ${{ needs.setup_release.outputs.publish_pre_release }} + tag: ${{ needs.setup_release.outputs.release_tag }} + token: ${{ secrets.GH_BOT_TOKEN }} diff --git a/.github/workflows/auto-create-pr.yml b/.github/workflows/auto-create-pr.yml new file mode 100644 index 0000000..13705dd --- /dev/null +++ b/.github/workflows/auto-create-pr.yml @@ -0,0 +1,35 @@ +--- +# This action is centrally managed in https://github.com//.github/ +# Don't make changes to this file in this repo as they will be overwritten with changes made to the same file in +# the above-mentioned repo. + +# This workflow creates a PR automatically when anything is merged/pushed into the `nightly` branch. The PR is created +# against the `master` (default) branch. + +name: Auto create PR + +on: + push: + branches: + - 'nightly' + +jobs: + create_pr: + if: startsWith(github.repository, 'LizardByte/') + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Create Pull Request + uses: repo-sync/pull-request@v2 + with: + source_branch: "" # should be "nightly" as it's the triggering branch + destination_branch: "master" + pr_title: "Pulling ${{ github.ref_name }} into master" + pr_template: ".github/pr_release_template.md" + pr_assignee: "${{ secrets.GH_BOT_NAME }}" + pr_draft: true + pr_allow_empty: false + github_token: ${{ secrets.GH_BOT_TOKEN }} diff --git a/.github/workflows/automerge.yml b/.github/workflows/automerge.yml new file mode 100644 index 0000000..733b4de --- /dev/null +++ b/.github/workflows/automerge.yml @@ -0,0 +1,64 @@ +--- +# This action is centrally managed in https://github.com//.github/ +# Don't make changes to this file in this repo as they will be overwritten with changes made to the same file in +# the above-mentioned repo. + +# This workflow will, first, automatically approve PRs created by @LizardByte-bot. Then it will automerge relevant PRs. + +name: Automerge PR + +on: + pull_request: + types: + - opened + - synchronize + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + autoapprove: + if: >- + contains(fromJson('["LizardByte-bot"]'), github.event.pull_request.user.login) && + contains(fromJson('["LizardByte-bot"]'), github.actor) && + startsWith(github.repository, 'LizardByte/') + runs-on: ubuntu-latest + steps: + - name: Autoapproving + uses: hmarr/auto-approve-action@v3 + with: + github-token: "${{ secrets.GITHUB_TOKEN }}" + + - name: Label autoapproved + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GH_BOT_TOKEN }} + script: | + github.rest.issues.addLabels({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + labels: ['autoapproved', 'autoupdate'] + }) + + automerge: + if: startsWith(github.repository, 'LizardByte/') + needs: [autoapprove] + runs-on: ubuntu-latest + + steps: + - name: Automerging + uses: pascalgn/automerge-action@v0.15.6 + env: + BASE_BRANCHES: nightly + GITHUB_TOKEN: ${{ secrets.GH_BOT_TOKEN }} + GITHUB_LOGIN: ${{ secrets.GH_BOT_NAME }} + MERGE_LABELS: "!dependencies" + MERGE_METHOD: "squash" + MERGE_COMMIT_MESSAGE: "{pullRequest.title} (#{pullRequest.number})" + MERGE_DELETE_BRANCH: true + MERGE_ERROR_FAIL: true + MERGE_FILTER_AUTHOR: ${{ secrets.GH_BOT_NAME }} + MERGE_RETRIES: "240" # 1 hour + MERGE_RETRY_SLEEP: "15000" # 15 seconds diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..ae52487 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,147 @@ +--- +# This action is centrally managed in https://github.com//.github/ +# Don't make changes to this file in this repo as they will be overwritten with changes made to the same file in +# the above-mentioned repo. + +# This workflow will analyze all supported languages in the repository using CodeQL Analysis. + +name: "CodeQL" + +on: + push: + branches: ["master", "nightly"] + pull_request: + branches: ["master", "nightly"] + schedule: + - cron: '00 12 * * 0' # every Sunday at 12:00 UTC + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + languages: + name: Get language matrix + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.lang.outputs.result }} + continue: ${{ steps.continue.outputs.result }} + steps: + - name: Get repo languages + uses: actions/github-script@v7 + id: lang + with: + script: | + // CodeQL supports ['cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby', 'swift'] + // Use only 'java' to analyze code written in Java, Kotlin or both + // Use only 'javascript' to analyze code written in JavaScript, TypeScript or both + // Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support + const supported_languages = ['cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby', 'swift'] + + const remap_languages = { + 'c++': 'cpp', + 'c#': 'csharp', + 'kotlin': 'java', + 'typescript': 'javascript', + } + + const repo = context.repo + const response = await github.rest.repos.listLanguages(repo) + let matrix = { + "include": [] + } + + for (let [key, value] of Object.entries(response.data)) { + // remap language + if (remap_languages[key.toLowerCase()]) { + console.log(`Remapping language: ${key} to ${remap_languages[key.toLowerCase()]}`) + key = remap_languages[key.toLowerCase()] + } + if (supported_languages.includes(key.toLowerCase()) && + !matrix['include'].includes({"language": key.toLowerCase()})) { + console.log(`Found supported language: ${key}`) + matrix['include'].push({"language": key.toLowerCase()}) + } + } + + // print languages + console.log(`matrix: ${JSON.stringify(matrix)}`) + + return matrix + + - name: Continue + uses: actions/github-script@v7 + id: continue + with: + script: | + // if matrix['include'] is an empty list return false, otherwise true + const matrix = ${{ steps.lang.outputs.result }} // this is already json encoded + + if (matrix['include'].length == 0) { + return false + } else { + return true + } + + analyze: + name: Analyze + if: ${{ needs.languages.outputs.continue == 'true' }} + needs: [languages] + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.languages.outputs.matrix) }} + + steps: + - name: Maximize build space + uses: easimon/maximize-build-space@v8 + with: + root-reserve-mb: 20480 + remove-dotnet: ${{ (matrix.language == 'csharp' && 'false') || 'true' }} + remove-android: 'true' + remove-haskell: 'true' + remove-codeql: 'false' + remove-docker-images: 'true' + + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: recursive + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # yamllint disable-line rule:line-length + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + # Pre autobuild + # create a file named .codeql-prebuild-${{ matrix.language }}.sh in the root of your repository + - name: Prebuild + run: | + # check if .qodeql-prebuild-${{ matrix.language }}.sh exists + if [ -f "./.codeql-prebuild-${{ matrix.language }}.sh" ]; then + echo "Running .codeql-prebuild-${{ matrix.language }}.sh" + ./.codeql-prebuild-${{ matrix.language }}.sh + fi + + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/issues-stale.yml b/.github/workflows/issues-stale.yml new file mode 100644 index 0000000..deb3d74 --- /dev/null +++ b/.github/workflows/issues-stale.yml @@ -0,0 +1,61 @@ +--- +# This action is centrally managed in https://github.com//.github/ +# Don't make changes to this file in this repo as they will be overwritten with changes made to the same file in +# the above-mentioned repo. + +# Manage stale issues and PRs. + +name: Stale Issues / PRs + +on: + schedule: + - cron: '00 10 * * *' + +jobs: + stale: + name: Check Stale Issues / PRs + if: startsWith(github.repository, 'LizardByte/') + runs-on: ubuntu-latest + steps: + - name: Stale + uses: actions/stale@v9 + with: + close-issue-message: > + This issue was closed because it has been stalled for 10 days with no activity. + close-pr-message: > + This PR was closed because it has been stalled for 10 days with no activity. + days-before-stale: 90 + days-before-close: 10 + exempt-all-assignees: true + exempt-issue-labels: 'added,fixed' + exempt-pr-labels: 'dependencies,l10n' + stale-issue-label: 'stale' + stale-issue-message: > + It seems this issue hasn't had any activity in the past 90 days. + If it's still something you'd like addressed, please let us know by leaving a comment. + Otherwise, to help keep our backlog tidy, we'll be closing this issue in 10 days. Thanks! + stale-pr-label: 'stale' + stale-pr-message: > + It looks like this PR has been idle for 90 days. + If it's still something you're working on or would like to pursue, + please leave a comment or update your branch. + Otherwise, we'll be closing this PR in 10 days to reduce our backlog. Thanks! + repo-token: ${{ secrets.GH_BOT_TOKEN }} + + - name: Invalid Template + uses: actions/stale@v9 + with: + close-issue-message: > + This issue was closed because the the template was not completed after 5 days. + close-pr-message: > + This PR was closed because the the template was not completed after 5 days. + days-before-stale: 0 + days-before-close: 5 + only-labels: 'invalid:template-incomplete' + stale-issue-label: 'invalid:template-incomplete' + stale-issue-message: > + Invalid issues template. + stale-pr-label: 'invalid:template-incomplete' + stale-pr-message: > + Invalid PR template. + repo-token: ${{ secrets.GH_BOT_TOKEN }} diff --git a/.github/workflows/issues.yml b/.github/workflows/issues.yml new file mode 100644 index 0000000..aec6006 --- /dev/null +++ b/.github/workflows/issues.yml @@ -0,0 +1,25 @@ +--- +# This action is centrally managed in https://github.com//.github/ +# Don't make changes to this file in this repo as they will be overwritten with changes made to the same file in +# the above-mentioned repo. + +# Label and un-label actions using `../label-actions.yml`. + +name: Issues + +on: + issues: + types: [labeled, unlabeled] + discussion: + types: [labeled, unlabeled] + +jobs: + label: + name: Label Actions + if: startsWith(github.repository, 'LizardByte/') + runs-on: ubuntu-latest + steps: + - name: Label Actions + uses: dessant/label-actions@v4 + with: + github-token: ${{ secrets.GH_BOT_TOKEN }} diff --git a/.github/workflows/localize.yml b/.github/workflows/localize.yml new file mode 100644 index 0000000..a1bfcd9 --- /dev/null +++ b/.github/workflows/localize.yml @@ -0,0 +1,78 @@ +--- +name: localize + +on: + push: + branches: [master] + paths: # prevents workflow from running unless these files change + - '.github/workflows/localize.yml' + - 'locale/themerr-jellyfin.po' + - 'src/**.py' + workflow_dispatch: + +jobs: + localize: + name: Update Localization + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.8' + + - name: Set up Python Dependencies + run: | + python -m pip install --upgrade pip setuptools wheel + python -m pip install -r requirements-dev.txt + + - name: Update Strings + run: | + python ./scripts/_locale.py --extract + + - name: git diff + run: | + # disable the pager + git config --global pager.diff false + + # print the git diff + git diff Contents/Strings/themerr-plex.po + + # set the variable with minimal output, replacing `\t` with ` ` + OUTPUT=$(git diff --numstat Contents/Strings/themerr-plex.po | sed -e "s#\t# #g") + echo "git_diff=${OUTPUT}" >> $GITHUB_ENV + + - name: git reset + if: ${{ env.git_diff == '1 1 Contents/Strings/themerr-plex.po' }} # only run if more than 1 line changed + run: | + git reset --hard + + - name: Get current date + id: date + run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT + + - name: Create/Update Pull Request + uses: peter-evans/create-pull-request@v5 + with: + add-paths: | + Contents/Strings/*.po + token: ${{ secrets.GH_BOT_TOKEN }} # must trigger PR tests + commit-message: New localization template + branch: localize/update + delete-branch: true + base: master + title: New Babel Updates + body: | + Update report + - Updated ${{ steps.date.outputs.date }} + - Auto-generated by [create-pull-request][1] + + [1]: https://github.com/peter-evans/create-pull-request + labels: | + babel + l10n diff --git a/.github/workflows/python-flake8.yml b/.github/workflows/python-flake8.yml new file mode 100644 index 0000000..e08ab10 --- /dev/null +++ b/.github/workflows/python-flake8.yml @@ -0,0 +1,38 @@ +--- +# This action is centrally managed in https://github.com//.github/ +# Don't make changes to this file in this repo as they will be overwritten with changes made to the same file in +# the above-mentioned repo. + +# Lint python files with flake8. + +name: flake8 + +on: + pull_request: + branches: [master, nightly] + types: [opened, synchronize, reopened] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + flake8: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 # https://github.com/actions/setup-python + with: + python-version: '3.10' + + - name: Install dependencies + run: | + # pin flake8 before v6.0.0 due to removal of support for type comments (required for Python 2.7 type hints) + python -m pip install --upgrade pip setuptools "flake8<6" + + - name: Test with flake8 + run: | + python -m flake8 --verbose diff --git a/.github/workflows/release-notifier.yml b/.github/workflows/release-notifier.yml new file mode 100644 index 0000000..5735465 --- /dev/null +++ b/.github/workflows/release-notifier.yml @@ -0,0 +1,103 @@ +--- +# This action is centrally managed in https://github.com//.github/ +# Don't make changes to this file in this repo as they will be overwritten with changes made to the same file in +# the above-mentioned repo. + +# Send release notification to various platforms. + +name: Release Notifications + +on: + release: + types: [published] + # https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#onevent_nametypes + +jobs: + discord: + if: >- + startsWith(github.repository, 'LizardByte/') && + not(github.event.release.prerelease) && + not(github.event.release.draft) + runs-on: ubuntu-latest + steps: + - name: discord + uses: sarisia/actions-status-discord@v1 + with: + webhook: ${{ secrets.DISCORD_RELEASE_WEBHOOK }} + nodetail: true + nofail: false + username: ${{ secrets.DISCORD_USERNAME }} + avatar_url: ${{ secrets.ORG_LOGO_URL }} + title: ${{ github.event.repository.name }} ${{ github.ref_name }} Released + description: ${{ github.event.release.body }} + color: 0xFF4500 + + facebook_group: + if: >- + startsWith(github.repository, 'LizardByte/') && + not(github.event.release.prerelease) && + not(github.event.release.draft) + runs-on: ubuntu-latest + steps: + - name: facebook-post-action + uses: ReenigneArcher/facebook-post-action@v1 + with: + page_id: ${{ secrets.FACEBOOK_GROUP_ID }} + access_token: ${{ secrets.FACEBOOK_ACCESS_TOKEN }} + message: | + ${{ github.event.repository.name }} ${{ github.ref_name }} Released + ${{ github.event.release.body }} + url: ${{ github.event.release.html_url }} + + facebook_page: + if: >- + startsWith(github.repository, 'LizardByte/') && + not(github.event.release.prerelease) && + not(github.event.release.draft) + runs-on: ubuntu-latest + steps: + - name: facebook-post-action + uses: ReenigneArcher/facebook-post-action@v1 + with: + page_id: ${{ secrets.FACEBOOK_PAGE_ID }} + access_token: ${{ secrets.FACEBOOK_ACCESS_TOKEN }} + message: | + ${{ github.event.repository.name }} ${{ github.ref_name }} Released + ${{ github.event.release.body }} + url: ${{ github.event.release.html_url }} + + reddit: + if: >- + startsWith(github.repository, 'LizardByte/') && + not(github.event.release.prerelease) && + not(github.event.release.draft) + runs-on: ubuntu-latest + steps: + - name: reddit + uses: bluwy/release-for-reddit-action@v2 + with: + username: ${{ secrets.REDDIT_USERNAME }} + password: ${{ secrets.REDDIT_PASSWORD }} + app-id: ${{ secrets.REDDIT_CLIENT_ID }} + app-secret: ${{ secrets.REDDIT_CLIENT_SECRET }} + subreddit: ${{ secrets.REDDIT_SUBREDDIT }} + title: ${{ github.event.repository.name }} ${{ github.ref_name }} Released + url: ${{ github.event.release.html_url }} + flair-id: ${{ secrets.REDDIT_FLAIR_ID }} # https://www.reddit.com/r/>/api/link_flair.json + comment: ${{ github.event.release.body }} + + twitter: + if: >- + startsWith(github.repository, 'LizardByte/') && + not(github.event.release.prerelease) && + not(github.event.release.draft) + runs-on: ubuntu-latest + steps: + - name: twitter + uses: nearform-actions/github-action-notify-twitter@v1 + with: + message: ${{ github.event.release.html_url }} + twitter-app-key: ${{ secrets.TWITTER_API_KEY }} + twitter-app-secret: ${{ secrets.TWITTER_API_SECRET }} + twitter-access-token: ${{ secrets.TWITTER_ACCESS_TOKEN }} + twitter-access-token-secret: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }} diff --git a/.github/workflows/yaml-lint.yml b/.github/workflows/yaml-lint.yml new file mode 100644 index 0000000..7e1fd46 --- /dev/null +++ b/.github/workflows/yaml-lint.yml @@ -0,0 +1,66 @@ +--- +# This action is centrally managed in https://github.com//.github/ +# Don't make changes to this file in this repo as they will be overwritten with changes made to the same file in +# the above-mentioned repo. + +# Lint yaml files. + +name: yaml lint + +on: + pull_request: + branches: [master, nightly] + types: [opened, synchronize, reopened] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + yaml-lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Find additional files + id: find-files + run: | + # space separated list of files + FILES=.clang-format + + # empty placeholder + FOUND="" + + for FILE in ${FILES}; do + if [ -f "$FILE" ] + then + FOUND="$FOUND $FILE" + fi + done + + echo "found=${FOUND}" >> $GITHUB_OUTPUT + + - name: yaml lint + id: yaml-lint + uses: ibiqlik/action-yamllint@v3 + with: + # https://yamllint.readthedocs.io/en/stable/configuration.html#default-configuration + config_data: | + extends: default + rules: + comments: + level: error + line-length: + max: 120 + truthy: + # GitHub uses "on" for workflow event triggers + # .clang-format file has options of "Yes" "No" that will be caught by this, so changed to "warning" + allowed-values: ['true', 'false', 'on'] + check-keys: true + level: warning + file_or_dir: . ${{ steps.find-files.outputs.found }} + + - name: Log + run: | + cat "${{ steps.yaml-lint.outputs.logfile }}" >> $GITHUB_STEP_SUMMARY diff --git a/.gitignore b/.gitignore index 68bc17f..2dc53ca 100644 --- a/.gitignore +++ b/.gitignore @@ -157,4 +157,4 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +.idea/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..adfda87 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "third-party/repo-scripts"] + path = third-party/repo-scripts + url = https://github.com/xbmc/repo-scripts.git + branch = matrix diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..751e1eb --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,37 @@ +--- +# .readthedocs.yaml +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Set the version of Python +build: + os: ubuntu-22.04 + tools: + python: "3.8" + jobs: + post_build: + - find ./third-party -iname "*.rst" -type f -delete # find and delete rst files in third-party + - rstcheck -r . # lint rst files + # - rstfmt --check --diff -w 120 . # check rst formatting + +# submodules required to include youtube-dl +submodules: + include: all + recursive: true + +# Build documentation in the docs/ directory with Sphinx +sphinx: + builder: html + configuration: docs/source/conf.py + fail_on_warning: true + +# Using Sphinx, build docs in additional formats +formats: all + +python: + install: + - requirements: requirements.txt # plugin requirements + - requirements: requirements-dev.txt # docs requirements diff --git a/.rstcheck.cfg b/.rstcheck.cfg new file mode 100644 index 0000000..ecb8a0c --- /dev/null +++ b/.rstcheck.cfg @@ -0,0 +1,12 @@ +# configuration file for rstcheck, an rst linting tool +# https://rstcheck.readthedocs.io/en/latest/usage/config + +[rstcheck] +ignore_directives = + automodule, + include, + mdinclude, + tab, + todo, +ignore_roles = + modname, diff --git a/LICENSE b/LICENSE.txt similarity index 100% rename from LICENSE rename to LICENSE.txt diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..211bf81 --- /dev/null +++ b/README.rst @@ -0,0 +1,29 @@ +Overview +======== +LizardByte has the full documentation hosted on `Read the Docs `__. + +About +----- +Themerr-kodi is an add-on for Kodi. The add-on plays theme music while browsing movies in your library. + +Integrations +------------ + +.. image:: https://img.shields.io/github/actions/workflow/status/lizardbyte/themerr-kodi/CI.yml.svg?branch=master&label=CI%20build&logo=github&style=for-the-badge + :alt: GitHub Workflow Status (CI) + :target: https://github.com/LizardByte/Themerr-kodi/actions/workflows/CI.yml?query=branch%3Amaster + +.. image:: https://img.shields.io/readthedocs/themerr-kodi?label=Docs&style=for-the-badge&logo=readthedocs + :alt: Read the Docs + :target: http://themerr-kodi.readthedocs.io/ + +.. image:: https://img.shields.io/codecov/c/gh/LizardByte/Themerr-kodi?token=YBoHCJziqM&style=for-the-badge&logo=codecov&label=codecov + :alt: Codecov + :target: https://codecov.io/gh/LizardByte/Themerr-kodi + +Downloads +--------- + +.. image:: https://img.shields.io/github/downloads/lizardbyte/themerr-kodi/total?style=for-the-badge&logo=github + :alt: GitHub Releases + :target: https://github.com/LizardByte/Themerr-kodi/releases/latest diff --git a/crowdin.yml b/crowdin.yml new file mode 100644 index 0000000..0be504b --- /dev/null +++ b/crowdin.yml @@ -0,0 +1,22 @@ +--- +"base_path": "." +"base_url": "https://api.crowdin.com" # optional (for Crowdin Enterprise only) +"preserve_hierarchy": false # flatten tree on crowdin +"pull_request_labels": [ + "crowdin", + "l10n" +] + +"files": [ + { + "source": "/locale/*.po", + "translation": "/locale/%two_letters_code%/LC_MESSAGES/%original_file_name%", + "languages_mapping": { + "two_letters_code": { + # map non-two letter codes here, left side is crowdin designation, right side is babel designation + "en-GB": "en_GB", + "en-US": "en_US" + } + } + } +] diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..8b6275a --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= -W --keep-going +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..08ca223 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,36 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build +set "SPHINXOPTS=-W --keep-going" + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% || exit /b %ERRORLEVEL% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% || exit /b %ERRORLEVEL% + +:end +popd diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst new file mode 100644 index 0000000..55cc5fe --- /dev/null +++ b/docs/source/about/changelog.rst @@ -0,0 +1,17 @@ +Changelog +========= + +.. only:: epub + + You can view the changelog in the + `online version `__. + +.. only:: html + + .. raw:: html + + + + diff --git a/docs/source/about/installation.rst b/docs/source/about/installation.rst new file mode 100644 index 0000000..ca7109a --- /dev/null +++ b/docs/source/about/installation.rst @@ -0,0 +1,23 @@ +Installation +============ +The recommended method for running Themerr-kodi is to use the `zip`_ in the `latest release`_. + +Zip +--- +The zip is cross platform, meaning all Kodi clients are supported. + +#. Download the ``service.themerr.zip`` from the `latest release`_ +#. Move the ``service.themerr.zip`` to a location your Kodi client can access. +#. Follow the steps in + `how to install from ZIP file `__. + +Source +------ +.. Caution:: Installing from source is not recommended most users. + +#. Follow the steps in :ref:`Build `. +#. Move the compiled ``service.themerr.zip`` to a location your Kodi client can access. +#. Follow the steps in + `how to install from ZIP file `__. + +.. _latest release: https://github.com/LizardByte/Themerr-kodi/releases/latest diff --git a/docs/source/about/overview.rst b/docs/source/about/overview.rst new file mode 100644 index 0000000..ec7f524 --- /dev/null +++ b/docs/source/about/overview.rst @@ -0,0 +1 @@ +.. include:: ../../../README.rst \ No newline at end of file diff --git a/docs/source/about/troubleshooting.rst b/docs/source/about/troubleshooting.rst new file mode 100644 index 0000000..5b8db2f --- /dev/null +++ b/docs/source/about/troubleshooting.rst @@ -0,0 +1,22 @@ +Troubleshooting +=============== + +Plugin Fails to Install +----------------------- + +Try clearing the contents of the following locations, or restart Kodi: + +- .kodi/addons/temp +- .kodi/temp/temp +- .kodi/temp/archive_cache + +See `common errors `__ for more information. + + +Logging +------- + +Per Kodi `Add-on guidelines `__, +the add-on will only log when the user enables debug logging. + +Log messages from the add-on will be prefixed with ``Themerr:``. diff --git a/docs/source/about/usage.rst b/docs/source/about/usage.rst new file mode 100644 index 0000000..9346c6a --- /dev/null +++ b/docs/source/about/usage.rst @@ -0,0 +1,25 @@ +Usage +===== + +Minimal setup is required to use Themerr-kodi. In addition to the installation, a couple of settings can be configured. + +Preferences +----------- + +Dev Mode +^^^^^^^^ + +Description + When enabled, Themerr-kodi will use Kodi's notification system to output log messages. + +Default + ``False`` + +Theme Timeout +^^^^^^^^^^^^^ + +Description + The amount of time, in seconds, that Themerr-kodi will wait before playing or stopping a theme. + +Default + ``3`` diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..bc864db --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,129 @@ +# Configuration file for the Sphinx documentation builder. +# +# This file only contains a selection of the most common options. For a full +# list see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# standard imports +from datetime import datetime +import os +import sys + + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. + +script_dir = os.path.dirname(os.path.abspath(__file__)) # the directory of this file +source_dir = os.path.dirname(script_dir) # the source folder directory +root_dir = os.path.dirname(source_dir) # the root folder directory + + +paths = [ + root_dir, + os.path.join(root_dir, 'src', 'resources', 'lib'), # location of addon dependencies + os.path.join(root_dir, 'src'), # location of the addon +] + +for directory in paths: + sys.path.insert(0, directory) + +try: + from scripts.bootstrap_kodi_requirements import bootstrap_kodi_modules + bootstrap_kodi_modules() +except ModuleNotFoundError as e: + raise e + +# -- Project information ----------------------------------------------------- +project = 'Themerr-kodi' +project_copyright = f'{datetime.now ().year}, {project}' +epub_copyright = project_copyright +author = 'ReenigneArcher' + +# The full version, including alpha/beta/rc tags +# https://docs.readthedocs.io/en/stable/reference/environment-variables.html#envvar-READTHEDOCS_VERSION +version = os.getenv('READTHEDOCS_VERSION', 'dirty') + + +# -- General configuration --------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'm2r2', # enable markdown files + 'numpydoc', # this automatically loads `sphinx.ext.autosummary` as well + 'sphinx.ext.autodoc', # autodocument modules + 'sphinx.ext.autosectionlabel', + 'sphinx.ext.intersphinx', # link to other projects' documentation + 'sphinx.ext.todo', # enable to-do sections + 'sphinx.ext.viewcode', # add links to view source code + 'sphinx_copybutton', # add a copy button to code blocks + 'sphinx_inline_tabs', # add tabs +] + +# Add any paths that contain templates here, relative to this directory. +# templates_path = ['_templates'] + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = ['toc.rst'] + +# Extensions to include. +source_suffix = ['.rst', '.md'] + +# Change default contents file +master_doc = 'index' + +# -- Options for HTML output ------------------------------------------------- + +# images +html_favicon = os.path.join(root_dir, 'src', 'resources', 'assets', 'images', 'favicon.ico') +html_logo = os.path.join(root_dir, 'themerr.png') + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +# html_static_path = ['_static'] + +# These paths are either relative to html_static_path +# or fully qualified paths (eg. https://...) +# html_css_files = [ +# 'css/custom.css', +# ] +# html_js_files = [ +# 'js/custom.js', +# ] + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +html_theme = 'furo' + +html_theme_options = { + "top_of_page_button": "edit", + "source_edit_link": "https://github.com/lizardbyte/themerr-kodi/blob/master/docs/source/{filename}", +} + +# extension config options +autosectionlabel_prefix_document = True # Make sure the target is unique +todo_include_todos = True + +# numpydoc config +numpydoc_validation_checks = {'all', 'SA01'} # Report warnings for all checks *except* for SA01 + +# disable epub mimetype warnings +# https://github.com/readthedocs/readthedocs.org/blob/eadf6ac6dc6abc760a91e1cb147cc3c5f37d1ea8/docs/conf.py#L235-L236 +suppress_warnings = ["epub.unknown_project_files"] + +python_version = f'{sys.version_info.major}.{sys.version_info.minor}' + +intersphinx_mapping = { + 'python': (f'https://docs.python.org/{sys.version_info.major}.{sys.version_info.minor}', None), + 'xbmc': ('https://romanvm.github.io/Kodistubs', None), +} + +numpydoc_show_class_members = True +numpydoc_show_inherited_class_members = False diff --git a/docs/source/contributing/additional_information.rst b/docs/source/contributing/additional_information.rst new file mode 100644 index 0000000..824e0bf --- /dev/null +++ b/docs/source/contributing/additional_information.rst @@ -0,0 +1,107 @@ +Additional Information +====================== + +References +---------- + +Kodi Built-in modules +^^^^^^^^^^^^^^^^^^^^^ + +- `Kodistubs `__ +- `Built in modules `__ + + +Kodi References +^^^^^^^^^^^^^^^ + +- `Add-on development `__ +- `Add-on rules `__ +- `JSON-RPC API `__ +- `Third party python modules `__ + +Similar Add-ons +^^^^^^^^^^^^^^^ + +- `service.tvtunes `__ + +Notes +----- + +Kodistubs +^^^^^^^^^ + +`Kodistubs` is a project that provides stubs for the Kodi built-in modules. It makes it very easy to develop Kodi add-ons +in an IDE like PyCharm. This is included in the ``requirements-dev.txt``. + +Python Dependencies +^^^^^^^^^^^^^^^^^^^ +Python dependencies can be added in three different ways. + +1. Kodi add-on modules +2. PyPI modules +3. Submodules + +.. tab:: Kodi add-on modules + + The preferred method is to use Kodi add-on modules. Using this method allows the dependency to be included without + including extra bloat. + + 1. Add the dependency to the ``src/addon.xml`` file in the ```` section. + 2. Add the dependency to the ``scripts/bootstrap_kodi_requirements.py``, ``required_modules`` list variable. + + .. todo:: Automatically gather dependencies from the ``src/addon.xml`` to populate the ``required_modules`` list + variable. + +.. tab:: PyPI modules + + If the dependency is not available as a Kodi add-on module, the next preferred method is to use PyPI modules. + Using this method allows the dependency to be installed from PyPI when the add-on is built. + + 1. Add the dependency to the ``requirements.txt`` file, and hard pin the version. e.g. ``my_requirement==1.2.3`` + +.. tab:: Submodules + + If the dependency is not available as a Kodi add-on module or a PyPI module, the last resort is to use submodules. + + 1. Add the dependency as a submodule in the ``third-party`` directory. + + .. code-block:: bash + + git submodule add + + 2. Checkout a stable version of the dependency. + + .. code-block:: bash + + git checkout + + 3. Add the branch, that dependabot should track, to the ``.gitmodules`` file. + + .. code-block:: ini + + [submodule "third-party/"] + path = third-party/ + url = + branch = + +IDE Configuration +^^^^^^^^^^^^^^^^^ + +To allow your IDE to find dependencies which are provided by Kodi, you may be able to add the +``third-party/repo-scripts/script.module./lib`` directory to your IDE's sources list. In PyCharm, you can +right click the ``lib`` directory and select ``Mark Directory as`` -> ``Sources Root``. In VSCode, you can add the +following to your ``.vscode/settings.json`` file: + +.. code-block:: json + + { + "python.analysis.extraPaths": [ + "./third-party/repo-scripts/script.module./lib" + ] + } + +Todo +^^^^ + +.. todo:: Automatically update the dependency versions in the ``src/addon.xml`` file from the version in the + ``./third-party/repo-scripts`` submodule. It might be possible to use renovate bot to do this. diff --git a/docs/source/contributing/build.rst b/docs/source/contributing/build.rst new file mode 100644 index 0000000..5396db2 --- /dev/null +++ b/docs/source/contributing/build.rst @@ -0,0 +1,51 @@ +Build +===== +Follow the steps below to build the add-on. + +Clone +----- +Ensure `git `__ is installed and run the following: + + .. code-block:: bash + + git clone --recurse-submodules https://github.com/lizardbyte/themerr-kodi.git + cd ./themerr-kodi + +Setup venv +---------- +It is recommended to setup and activate a `venv`_. + +Install Requirements +-------------------- +Install Requirements (Optional) + .. code-block:: bash + + python -m pip install -r requirements.txt + +Development Requirements (Required) + .. code-block:: bash + + python -m pip install -r requirements-dev.txt + +Compile Translations +-------------------- +.. code-block:: bash + + python -m scripts.locale --compile + +Build Add-on +------------ +.. code-block:: bash + + python -m scripts.build + +Remote Build +------------ +It may be beneficial to build remotely in some cases. This will enable easier building on different operating systems. + +#. Fork the project +#. Activate workflows +#. Trigger the `CI` workflow manually +#. Download the artifacts from the workflow run summary + +.. _venv: https://docs.python.org/3/library/venv.html diff --git a/docs/source/contributing/contributing.rst b/docs/source/contributing/contributing.rst new file mode 100644 index 0000000..a50300d --- /dev/null +++ b/docs/source/contributing/contributing.rst @@ -0,0 +1,5 @@ +Contributing +============ + +Read our contribution guide in our organization level +`docs `__. diff --git a/docs/source/contributing/database.rst b/docs/source/contributing/database.rst new file mode 100644 index 0000000..eed2404 --- /dev/null +++ b/docs/source/contributing/database.rst @@ -0,0 +1,5 @@ +Database +======== + +The database of themes is held in our `ThemerrDB `__ repository. To contribute +to the database, follow the documentation there. diff --git a/docs/source/contributing/testing.rst b/docs/source/contributing/testing.rst new file mode 100644 index 0000000..20d43c0 --- /dev/null +++ b/docs/source/contributing/testing.rst @@ -0,0 +1,55 @@ +Testing +======= + +Flake8 +------ +Themerr-kodi uses `Flake8 `__ for enforcing consistent code styling. Flake8 is +included in the ``requirements-dev.txt``. + +The config file for flake8 is ``.flake8``. This is already included in the root of the repo and should not be modified. + +Test with Flake8 + .. code-block:: bash + + python -m flake8 + +Sphinx +------ +Themerr-kodi uses `Sphinx `__ for documentation building. Sphinx is included +in the ``requirements-dev.txt``. + +Themerr-kodi follows `numpydoc `__ styling and formatting in +docstrings. This will be tested when building the docs. `numpydoc` is included in the ``requirements-dev.txt``. + +The config file for Sphinx is ``docs/source/conf.py``. This is already included in the root of the repo and should not +be modified. + +Test with Sphinx + .. code-block:: bash + + cd docs + make html + + Alternatively + + .. code-block:: bash + + cd docs + sphinx-build -b html source build + +Lint with rstcheck + .. code-block:: bash + + rstcheck -r . + +pytest +------ +Themerr-kodi uses `pytest `__ for unit testing. pytest is included in the +``requirements-dev.txt``. + +No config is required for pytest. + +Test with pytest + .. code-block:: bash + + python -m pytest diff --git a/docs/source/global.rst b/docs/source/global.rst new file mode 100644 index 0000000..b117004 --- /dev/null +++ b/docs/source/global.rst @@ -0,0 +1,5 @@ +.. role:: modname + :class: modname + +.. role:: title + :class: title diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..176e544 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,3 @@ +Table of Contents +================= +.. include:: toc.rst diff --git a/docs/source/source_code/source_code.rst b/docs/source/source_code/source_code.rst new file mode 100644 index 0000000..dc02fb9 --- /dev/null +++ b/docs/source/source_code/source_code.rst @@ -0,0 +1,13 @@ +Source Code +=========== +Our source code is documented using the `numpydoc `__ standard. + +Source +------ + +.. toctree:: + :caption: src + :maxdepth: 1 + :glob: + + src/** diff --git a/docs/source/source_code/src/service.rst b/docs/source/source_code/src/service.rst new file mode 100644 index 0000000..999425f --- /dev/null +++ b/docs/source/source_code/src/service.rst @@ -0,0 +1,7 @@ +.. include:: ../../global.rst + +:modname:`src.service` +------------------------------- +.. automodule:: src.service + :members: + :show-inheritance: diff --git a/docs/source/source_code/src/themerr/gui.rst b/docs/source/source_code/src/themerr/gui.rst new file mode 100644 index 0000000..b87667d --- /dev/null +++ b/docs/source/source_code/src/themerr/gui.rst @@ -0,0 +1,7 @@ +.. include:: ../../../global.rst + +:modname:`src.themerr.gui` +-------------------------- +.. automodule:: src.themerr.gui + :members: + :show-inheritance: diff --git a/docs/source/source_code/src/themerr/logger.rst b/docs/source/source_code/src/themerr/logger.rst new file mode 100644 index 0000000..8a07291 --- /dev/null +++ b/docs/source/source_code/src/themerr/logger.rst @@ -0,0 +1,7 @@ +.. include:: ../../../global.rst + +:modname:`src.themerr.logger` +----------------------------- +.. automodule:: src.themerr.logger + :members: + :show-inheritance: diff --git a/docs/source/source_code/src/themerr/monitor.rst b/docs/source/source_code/src/themerr/monitor.rst new file mode 100644 index 0000000..5251089 --- /dev/null +++ b/docs/source/source_code/src/themerr/monitor.rst @@ -0,0 +1,7 @@ +.. include:: ../../../global.rst + +:modname:`src.themerr.monitor` +------------------------------ +.. automodule:: src.themerr.monitor + :members: + :show-inheritance: diff --git a/docs/source/source_code/src/themerr/notifier.rst b/docs/source/source_code/src/themerr/notifier.rst new file mode 100644 index 0000000..c851c76 --- /dev/null +++ b/docs/source/source_code/src/themerr/notifier.rst @@ -0,0 +1,7 @@ +.. include:: ../../../global.rst + +:modname:`src.themerr.notifier` +------------------------------- +.. automodule:: src.themerr.notifier + :members: + :show-inheritance: diff --git a/docs/source/source_code/src/themerr/player.rst b/docs/source/source_code/src/themerr/player.rst new file mode 100644 index 0000000..7786b2f --- /dev/null +++ b/docs/source/source_code/src/themerr/player.rst @@ -0,0 +1,7 @@ +.. include:: ../../../global.rst + +:modname:`src.themerr.player` +----------------------------- +.. automodule:: src.themerr.player + :members: + :show-inheritance: diff --git a/docs/source/source_code/src/themerr/plugin.rst b/docs/source/source_code/src/themerr/plugin.rst new file mode 100644 index 0000000..b2e9fda --- /dev/null +++ b/docs/source/source_code/src/themerr/plugin.rst @@ -0,0 +1,7 @@ +.. include:: ../../../global.rst + +:modname:`src.themerr.plugin` +----------------------------- +.. automodule:: src.themerr.plugin + :members: + :show-inheritance: diff --git a/docs/source/source_code/src/themerr/settings.rst b/docs/source/source_code/src/themerr/settings.rst new file mode 100644 index 0000000..86639e5 --- /dev/null +++ b/docs/source/source_code/src/themerr/settings.rst @@ -0,0 +1,7 @@ +.. include:: ../../../global.rst + +:modname:`src.themerr.settings` +------------------------------- +.. automodule:: src.themerr.settings + :members: + :show-inheritance: diff --git a/docs/source/source_code/src/themerr/youtube.rst b/docs/source/source_code/src/themerr/youtube.rst new file mode 100644 index 0000000..957b5f7 --- /dev/null +++ b/docs/source/source_code/src/themerr/youtube.rst @@ -0,0 +1,7 @@ +.. include:: ../../../global.rst + +:modname:`src.themerr.youtube` +------------------------------ +.. automodule:: src.themerr.youtube + :members: + :show-inheritance: diff --git a/docs/source/toc.rst b/docs/source/toc.rst new file mode 100644 index 0000000..a1ae32f --- /dev/null +++ b/docs/source/toc.rst @@ -0,0 +1,25 @@ +.. toctree:: + :maxdepth: 2 + :caption: About + + about/overview + about/installation + about/usage + about/troubleshooting + about/changelog + +.. toctree:: + :maxdepth: 2 + :caption: Contributing + + contributing/contributing + contributing/database + contributing/build + contributing/testing + contributing/additional_information + +.. toctree:: + :maxdepth: 2 + :caption: Source Code + + source_code/source_code diff --git a/locale/de/LC_MESSAGES/themerr-jellyfin.po b/locale/de/LC_MESSAGES/themerr-jellyfin.po new file mode 100644 index 0000000..e4d2b50 --- /dev/null +++ b/locale/de/LC_MESSAGES/themerr-jellyfin.po @@ -0,0 +1,21 @@ +# German translations for Themerr-jellyfin. +# Copyright (C) 2023 Themerr-jellyfin +# This file is distributed under the same license as the Themerr-jellyfin +# project. +# FIRST AUTHOR , 2023. +# +msgid "" +msgstr "" +"Project-Id-Version: Themerr-jellyfin v0\n" +"Report-Msgid-Bugs-To: github.com/themerr-jellyfin\n" +"POT-Creation-Date: 2023-12-22 17:40-0500\n" +"PO-Revision-Date: 2023-12-22 17:38-0500\n" +"Last-Translator: FULL NAME \n" +"Language: de\n" +"Language-Team: de \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.14.0\n" + diff --git a/locale/en/LC_MESSAGES/themerr-jellyfin.po b/locale/en/LC_MESSAGES/themerr-jellyfin.po new file mode 100644 index 0000000..c362754 --- /dev/null +++ b/locale/en/LC_MESSAGES/themerr-jellyfin.po @@ -0,0 +1,21 @@ +# English translations for Themerr-jellyfin. +# Copyright (C) 2023 Themerr-jellyfin +# This file is distributed under the same license as the Themerr-jellyfin +# project. +# FIRST AUTHOR , 2023. +# +msgid "" +msgstr "" +"Project-Id-Version: Themerr-jellyfin v0\n" +"Report-Msgid-Bugs-To: github.com/themerr-jellyfin\n" +"POT-Creation-Date: 2023-12-22 17:40-0500\n" +"PO-Revision-Date: 2023-12-22 17:38-0500\n" +"Last-Translator: FULL NAME \n" +"Language: en\n" +"Language-Team: en \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.14.0\n" + diff --git a/locale/en_GB/LC_MESSAGES/themerr-jellyfin.po b/locale/en_GB/LC_MESSAGES/themerr-jellyfin.po new file mode 100644 index 0000000..3e961b7 --- /dev/null +++ b/locale/en_GB/LC_MESSAGES/themerr-jellyfin.po @@ -0,0 +1,21 @@ +# English (United Kingdom) translations for Themerr-jellyfin. +# Copyright (C) 2023 Themerr-jellyfin +# This file is distributed under the same license as the Themerr-jellyfin +# project. +# FIRST AUTHOR , 2023. +# +msgid "" +msgstr "" +"Project-Id-Version: Themerr-jellyfin v0\n" +"Report-Msgid-Bugs-To: github.com/themerr-jellyfin\n" +"POT-Creation-Date: 2023-12-22 17:40-0500\n" +"PO-Revision-Date: 2023-12-22 17:38-0500\n" +"Last-Translator: FULL NAME \n" +"Language: en_GB\n" +"Language-Team: en_GB \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.14.0\n" + diff --git a/locale/en_US/LC_MESSAGES/themerr-jellyfin.po b/locale/en_US/LC_MESSAGES/themerr-jellyfin.po new file mode 100644 index 0000000..26581d6 --- /dev/null +++ b/locale/en_US/LC_MESSAGES/themerr-jellyfin.po @@ -0,0 +1,21 @@ +# English (United States) translations for Themerr-jellyfin. +# Copyright (C) 2023 Themerr-jellyfin +# This file is distributed under the same license as the Themerr-jellyfin +# project. +# FIRST AUTHOR , 2023. +# +msgid "" +msgstr "" +"Project-Id-Version: Themerr-jellyfin v0\n" +"Report-Msgid-Bugs-To: github.com/themerr-jellyfin\n" +"POT-Creation-Date: 2023-12-22 17:40-0500\n" +"PO-Revision-Date: 2023-12-22 17:38-0500\n" +"Last-Translator: FULL NAME \n" +"Language: en_US\n" +"Language-Team: en_US \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.14.0\n" + diff --git a/locale/es/LC_MESSAGES/themerr-jellyfin.po b/locale/es/LC_MESSAGES/themerr-jellyfin.po new file mode 100644 index 0000000..7fc6695 --- /dev/null +++ b/locale/es/LC_MESSAGES/themerr-jellyfin.po @@ -0,0 +1,21 @@ +# Spanish translations for Themerr-jellyfin. +# Copyright (C) 2023 Themerr-jellyfin +# This file is distributed under the same license as the Themerr-jellyfin +# project. +# FIRST AUTHOR , 2023. +# +msgid "" +msgstr "" +"Project-Id-Version: Themerr-jellyfin v0\n" +"Report-Msgid-Bugs-To: github.com/themerr-jellyfin\n" +"POT-Creation-Date: 2023-12-22 17:40-0500\n" +"PO-Revision-Date: 2023-12-22 17:38-0500\n" +"Last-Translator: FULL NAME \n" +"Language: es\n" +"Language-Team: es \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.14.0\n" + diff --git a/locale/fr/LC_MESSAGES/themerr-jellyfin.po b/locale/fr/LC_MESSAGES/themerr-jellyfin.po new file mode 100644 index 0000000..ddd52de --- /dev/null +++ b/locale/fr/LC_MESSAGES/themerr-jellyfin.po @@ -0,0 +1,21 @@ +# French translations for Themerr-jellyfin. +# Copyright (C) 2023 Themerr-jellyfin +# This file is distributed under the same license as the Themerr-jellyfin +# project. +# FIRST AUTHOR , 2023. +# +msgid "" +msgstr "" +"Project-Id-Version: Themerr-jellyfin v0\n" +"Report-Msgid-Bugs-To: github.com/themerr-jellyfin\n" +"POT-Creation-Date: 2023-12-22 17:40-0500\n" +"PO-Revision-Date: 2023-12-22 17:38-0500\n" +"Last-Translator: FULL NAME \n" +"Language: fr\n" +"Language-Team: fr \n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.14.0\n" + diff --git a/locale/it/LC_MESSAGES/themerr-jellyfin.po b/locale/it/LC_MESSAGES/themerr-jellyfin.po new file mode 100644 index 0000000..3900d4e --- /dev/null +++ b/locale/it/LC_MESSAGES/themerr-jellyfin.po @@ -0,0 +1,21 @@ +# Italian translations for Themerr-jellyfin. +# Copyright (C) 2023 Themerr-jellyfin +# This file is distributed under the same license as the Themerr-jellyfin +# project. +# FIRST AUTHOR , 2023. +# +msgid "" +msgstr "" +"Project-Id-Version: Themerr-jellyfin v0\n" +"Report-Msgid-Bugs-To: github.com/themerr-jellyfin\n" +"POT-Creation-Date: 2023-12-22 17:40-0500\n" +"PO-Revision-Date: 2023-12-22 17:38-0500\n" +"Last-Translator: FULL NAME \n" +"Language: it\n" +"Language-Team: it \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.14.0\n" + diff --git a/locale/ru/LC_MESSAGES/themerr-jellyfin.po b/locale/ru/LC_MESSAGES/themerr-jellyfin.po new file mode 100644 index 0000000..4926104 --- /dev/null +++ b/locale/ru/LC_MESSAGES/themerr-jellyfin.po @@ -0,0 +1,22 @@ +# Russian translations for Themerr-jellyfin. +# Copyright (C) 2023 Themerr-jellyfin +# This file is distributed under the same license as the Themerr-jellyfin +# project. +# FIRST AUTHOR , 2023. +# +msgid "" +msgstr "" +"Project-Id-Version: Themerr-jellyfin v0\n" +"Report-Msgid-Bugs-To: github.com/themerr-jellyfin\n" +"POT-Creation-Date: 2023-12-22 17:40-0500\n" +"PO-Revision-Date: 2023-12-22 17:38-0500\n" +"Last-Translator: FULL NAME \n" +"Language: ru\n" +"Language-Team: ru \n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " +"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.14.0\n" + diff --git a/locale/themerr-jellyfin.po b/locale/themerr-jellyfin.po new file mode 100644 index 0000000..a9c6514 --- /dev/null +++ b/locale/themerr-jellyfin.po @@ -0,0 +1,20 @@ +# Translations template for Themerr-jellyfin. +# Copyright (C) 2023 Themerr-jellyfin +# This file is distributed under the same license as the Themerr-jellyfin +# project. +# FIRST AUTHOR , 2023. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: Themerr-jellyfin v0\n" +"Report-Msgid-Bugs-To: github.com/themerr-jellyfin\n" +"POT-Creation-Date: 2023-12-22 17:40-0500\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.14.0\n" + diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..b92fe83 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,13 @@ +Babel==2.14.0 +flake8==6.1.0 +furo==2023.9.10 +m2r2==0.3.3.post2 +numpydoc==1.6.0 +kodi-addon-checker==0.0.31 +Kodistubs==20.0.1 # docs: https://romanvm.github.io/Kodistubs +pytest==7.4.3 +pytest-cov==4.1.0 +rstcheck==6.2.0 +Sphinx==7.1.2 +sphinx-copybutton==0.5.2 +sphinx_inline_tabs==2023.4.21 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e69de29 diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/babel.cfg b/scripts/babel.cfg new file mode 100644 index 0000000..efceab8 --- /dev/null +++ b/scripts/babel.cfg @@ -0,0 +1 @@ +[python: **.py] diff --git a/scripts/bootstrap_kodi_requirements.py b/scripts/bootstrap_kodi_requirements.py new file mode 100644 index 0000000..d7ac749 --- /dev/null +++ b/scripts/bootstrap_kodi_requirements.py @@ -0,0 +1,29 @@ +# standard imports +import os +import sys + +# todo: programmatically retrieve this from addon.xml +required_modules = [ + 'script.module.requests', + 'script.module.youtube.dl', +] + +root_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def bootstrap_kodi_modules(): + # check if the required modules are installed, only for unit testing + for m in required_modules: + module_found = False + for p in sys.path: + if p.endswith(os.path.join(m, 'lib')): + module_found = True + break + if not module_found: + dev_path = os.path.join(root_dir, 'third-party', 'repo-scripts', m, 'lib') + if os.path.isdir(dev_path): + print(f"Adding dev path: {dev_path}") + sys.path.insert(0, dev_path) + else: + print(f"Module not found: {m}") + raise ModuleNotFoundError(f"Module not found: {m}") diff --git a/scripts/build.py b/scripts/build.py new file mode 100644 index 0000000..29423b6 --- /dev/null +++ b/scripts/build.py @@ -0,0 +1,137 @@ +# standard imports +import os +import shutil +import subprocess +import sys + +# local imports +from src.themerr import constants + +# list of directory to copy contents of +source_dirs = [ + 'src', +] + +# list of files to copy +source_files = [ + 'LICENSE.txt', +] + +# clean directories +clean_dirs = [ + os.path.join('resources', 'lib', 'bin'), +] + +script_directory: str = os.path.dirname(os.path.abspath(__file__)) +root_directory: str = os.path.dirname(script_directory) +build_root_directory: str = os.path.join(root_directory, 'build') +build_directory: str = os.path.join(build_root_directory, constants.addon_id) +pip_install_directory: str = os.path.join(build_directory, 'resources', 'lib') + + +def build(): + # create the build directory + try: + os.makedirs(build_directory, exist_ok=False) + except FileExistsError: + # remove the build directory + shutil.rmtree(build_directory) + + # create the build directory + os.makedirs(build_directory, exist_ok=False) + + # copy the source directories, recursively + for directory in source_dirs: + source_directory: str = os.path.join(root_directory, directory) + shutil.copytree( + src=source_directory, + dst=build_directory, + dirs_exist_ok=True, + ignore=shutil.ignore_patterns('*.pyc', '__pycache__'), + ) + + # copy the source files + for file in source_files: + source_file: str = os.path.join(root_directory, file) + destination_file: str = os.path.join(build_directory, file) + + shutil.copy2(source_file, destination_file) + + +def check_addon(): + """ + Run kodi-addon-checker --branch nexus service.themerr in subprocess. + """ + kodi_branch = os.getenv('KODI_BRANCH', 'Nexus').lower() + subprocess.run( + args=['kodi-addon-checker', '--branch', kodi_branch, build_directory], + check=True, # raise called process error if return code is non-zero + ) + + +def install_dependencies(): + """ + Install dependencies in subprocess, using this script's python executable. + """ + # get python executable path + python = sys.executable + + # install dependencies to specified directory + subprocess.run( + args=[python, '-m', 'pip', 'install', '-r', 'requirements.txt', '-t', pip_install_directory], + check=True, # raise called process error if return code is non-zero + ) + + +def clean(): + """ + Clean the build directory. + """ + for directory in clean_dirs: + directory_path: str = os.path.join(build_directory, directory) + if os.path.isdir(directory_path): + print(f"Removing {directory_path}") + shutil.rmtree(directory_path) + + # recursively remove any __pycache__ directories + for root, dirs, files in os.walk(build_directory): + for directory in dirs: + if directory == '__pycache__': + directory_path: str = os.path.join(root, directory) + print(f"Removing {directory_path}") + shutil.rmtree(directory_path) + + +def package(): + """ + Package the addon into a zip file. + """ + # remove the archive if it exists + archive_name: str = constants.addon_id + archive_path: str = os.path.join(root_directory, f"{archive_name}.zip") + final_archive_path: str = os.path.join(build_root_directory, f"{archive_name}.zip") + if os.path.exists(final_archive_path): + os.remove(final_archive_path) + + shutil.make_archive( + base_name=archive_name, + format='zip', + root_dir=build_root_directory, + ) + + # move to build root directory + shutil.move( + src=archive_path, + dst=build_root_directory, + ) + + +if __name__ == '__main__': + build() + check_addon() + + # ideally this would be before the check, but kodi-addon-checker tries refactoring everything + install_dependencies() + clean() + + package() diff --git a/scripts/locale.py b/scripts/locale.py new file mode 100644 index 0000000..6e9a314 --- /dev/null +++ b/scripts/locale.py @@ -0,0 +1,123 @@ +# coding=utf-8 +""" +.. + _locale.py + +Functions related to building, initializing, updating, and compiling localization translations. +""" +# standard imports +import argparse +import os +import subprocess + +project_name = 'Themerr-jellyfin' + +script_dir = os.path.dirname(os.path.abspath(__file__)) +root_dir = os.path.dirname(script_dir) +locale_dir = os.path.join(root_dir, 'locale') + +# target locales +target_locales = [ + 'de', # Deutsch + 'en', # English + 'en_GB', # English (United Kingdom) + 'en_US', # English (United States) + 'es', # español + 'fr', # français + 'it', # italiano + 'ru', # русский +] + + +def babel_extract(): + """Executes `pybabel extract` in subprocess.""" + commands = [ + 'pybabel', + 'extract', + '-F', os.path.join(script_dir, 'babel.cfg'), + '-o', os.path.join(locale_dir, f'{project_name.lower()}.po'), + '--sort-by-file', + f'--msgid-bugs-address=github.com/{project_name.lower()}', + f'--copyright-holder={project_name}', + f'--project={project_name}', + '--version=v0', + '--add-comments=NOTE', + './src', + ] + + print(commands) + subprocess.check_output(args=commands, cwd=root_dir) + + +def babel_init(locale_code): + # type: (str) -> None + """Executes `pybabel init` in subprocess. + + :param locale_code: str - locale code + """ + commands = [ + 'pybabel', + 'init', + '-i', os.path.join(locale_dir, f'{project_name.lower()}.po'), + '-d', locale_dir, + '-D', project_name.lower(), + '-l', locale_code + ] + + print(commands) + subprocess.check_output(args=commands, cwd=root_dir) + + +def babel_update(): + """Executes `pybabel update` in subprocess.""" + commands = [ + 'pybabel', + 'update', + '-i', os.path.join(locale_dir, f'{project_name.lower()}.po'), + '-d', locale_dir, + '-D', project_name.lower(), + '--update-header-comment' + ] + + print(commands) + subprocess.check_output(args=commands, cwd=root_dir) + + +def babel_compile(): + """Executes `pybabel compile` in subprocess.""" + commands = [ + 'pybabel', + 'compile', + '-d', locale_dir, + '-D', project_name.lower() + ] + + print(commands) + subprocess.check_output(args=commands, cwd=root_dir) + + +if __name__ == '__main__': + # Set up and gather command line arguments + parser = argparse.ArgumentParser( + description='Script helps update locale translations. Translations must be done manually.') + + parser.add_argument('--extract', action='store_true', help='Extract messages from python files and templates.') + parser.add_argument('--init', action='store_true', help='Initialize any new locales specified in target locales.') + parser.add_argument('--update', action='store_true', help='Update existing locales.') + parser.add_argument('--compile', action='store_true', help='Compile translated locales.') + + args = parser.parse_args() + + if args.extract: + babel_extract() + + if args.init: + for locale_id in target_locales: + if not os.path.isdir(os.path.join(locale_dir, locale_id)): + babel_init(locale_code=locale_id) + + if args.update: + babel_update() + + if args.compile: + babel_compile() diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/addon.xml b/src/addon.xml new file mode 100644 index 0000000..a73c982 --- /dev/null +++ b/src/addon.xml @@ -0,0 +1,28 @@ + + + + + + + resources/assets/images/clearlogo.png + resources/assets/images/icon.png + resources/assets/images/fanart.jpg + resources/assets/images/screenshot-01.jpg + resources/assets/images/banner.jpg + + Plugin for Kodi that adds theme songs to movies using ThemerrDB. + AGPL-3.0-only + all + https://github.com/LizardByte/Themerr-jellyfin + Play theme songs while browsing movies + https://app.lizardbyte.dev/ + + + + + + + diff --git a/src/resources/assets/images/banner.jpg b/src/resources/assets/images/banner.jpg new file mode 100644 index 0000000..77525a1 Binary files /dev/null and b/src/resources/assets/images/banner.jpg differ diff --git a/src/resources/assets/images/clearlogo.png b/src/resources/assets/images/clearlogo.png new file mode 100644 index 0000000..b25c0ad Binary files /dev/null and b/src/resources/assets/images/clearlogo.png differ diff --git a/src/resources/assets/images/fanart.jpg b/src/resources/assets/images/fanart.jpg new file mode 100644 index 0000000..c14d018 Binary files /dev/null and b/src/resources/assets/images/fanart.jpg differ diff --git a/src/resources/assets/images/favicon.ico b/src/resources/assets/images/favicon.ico new file mode 100644 index 0000000..4fc332e Binary files /dev/null and b/src/resources/assets/images/favicon.ico differ diff --git a/src/resources/assets/images/icon.png b/src/resources/assets/images/icon.png new file mode 100644 index 0000000..8bb9f00 Binary files /dev/null and b/src/resources/assets/images/icon.png differ diff --git a/src/resources/assets/images/screenshot-01.jpg b/src/resources/assets/images/screenshot-01.jpg new file mode 100644 index 0000000..c14d018 Binary files /dev/null and b/src/resources/assets/images/screenshot-01.jpg differ diff --git a/src/resources/lib/__init__.py b/src/resources/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/service.py b/src/service.py new file mode 100644 index 0000000..488b9bf --- /dev/null +++ b/src/service.py @@ -0,0 +1,24 @@ +""" +Main entry point for the Themerr service. +""" + +# lib imports +from themerr import plugin + + +def main(): + """ + Main entry point for the Themerr service. + + Creates a Themerr instance and starts it. + + Examples + -------- + >>> main() + """ + themerr = plugin.Themerr() + themerr.start() + + +if __name__ == '__main__': + main() # pragma: no cover diff --git a/src/themerr/__init__.py b/src/themerr/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/themerr/constants.py b/src/themerr/constants.py new file mode 100644 index 0000000..ae2ebe3 --- /dev/null +++ b/src/themerr/constants.py @@ -0,0 +1,3 @@ +name = "Themerr" +addon_type = "service" +addon_id = f"{addon_type}.{name.lower()}" diff --git a/src/themerr/gui.py b/src/themerr/gui.py new file mode 100644 index 0000000..b6f72b5 --- /dev/null +++ b/src/themerr/gui.py @@ -0,0 +1,240 @@ +# standard imports +from datetime import datetime +import json +from typing import List, Optional + +# lib imports +import requests + +# kodi imports +import xbmc + +# local imports +from . import logger +from . import monitor +from . import player + + +class Window: + def __init__(self, player_instance=None): + self.log = logger.log + self.monitor = monitor.ThemerrMonitor() + + # allow providing a player for test purposes + self.player = player_instance if player_instance else player.Player() + + self.item_selected_for = 0 + self.playing_item_not_selected_for = 0 + self.current_selected_item_id = None + self.last_selected_item_id = None + self.kodi_id_mapping = {} + + def window_watcher(self): + self.log.debug("Window watcher started") + + # todo: make this configurable + timeout_factor = 3 # 3 seconds + + sleep_time = 50 # 50ms + timeout = timeout_factor * (1000 / sleep_time) + + while not self.monitor.abortRequested(): + selected_title = xbmc.getInfoLabel("ListItem.Label") + kodi_id = xbmc.getInfoLabel("ListItem.DBID") + kodi_id = int(kodi_id) if kodi_id else None + + # prefetch the YouTube url (if not already cached or cache is greater than 1 hour) + if kodi_id and (kodi_id not in list(self.kodi_id_mapping.keys()) + or (datetime.now().timestamp() - self.kodi_id_mapping[kodi_id]['timestamp']) > 3600): + self.kodi_id_mapping[kodi_id] = { + 'timestamp': datetime.now().timestamp(), + 'youtube_url': self.process_kodi_id(kodi_id=kodi_id) + } + + # this is used for our timeout counter + xbmc.sleep(sleep_time) + + if not self.pre_checks(): + continue + + if kodi_id == self.current_selected_item_id: + self.item_selected_for += 1 + else: + self.item_selected_for = 0 + self.current_selected_item_id = kodi_id + + # Logic for stopping theme and potentially starting a new one + if self.player.theme_is_playing: + if self.player.theme_playing_kodi_id != kodi_id: + self.playing_item_not_selected_for += 1 + if self.playing_item_not_selected_for >= timeout: + self.log.debug(f"Stopping theme due to {timeout} seconds of non-selection") + self.player.stop() + self.playing_item_not_selected_for = 0 + else: + self.playing_item_not_selected_for = 0 + if not self.player.theme_is_playing and self.item_selected_for >= timeout: + if not self.kodi_id_mapping.get(kodi_id): + continue + if not self.kodi_id_mapping[kodi_id].get('youtube_url'): + continue + self.log.debug(f"Playing theme for {selected_title}, ID: {kodi_id}") + self.player.play_url( + url=self.kodi_id_mapping[kodi_id]['youtube_url'], + kodi_id=kodi_id, + ) + + self.log.debug("Window watcher stopped") + + def pre_checks(self) -> bool: + try: + playing_item = self.player.getPlayingFile() + self.log.debug(f"playing item: {playing_item}") + except RuntimeError: + # we need to return now because item may not be playing even though the theme_playing_url was already set + return True # no item is playing + + # check if user started playing an item different that what we started playing + if playing_item != self.player.theme_playing_url: + self.log.debug(f"items are not equal, {playing_item} != {self.player.theme_playing_url}") + self.player.reset() + return False + + # check if a video is playing + if self.player.isPlayingVideo(): + self.log.debug("video is playing") + return False + + self.log.debug("pre-checks passed") + return True + + def process_kodi_id(self, kodi_id: int): + ids = None + database_type = None + if self.is_movies(): + ids = self.process_movie(kodi_id=kodi_id) + database_type = 'movies' + elif self.is_movie_set(): + database_type = 'movie_sets' + + if ids and database_type: + youtube_url = self.find_youtube_url_from_ids( + ids=ids, + db_type=database_type, + ) + + return youtube_url + + def process_movie(self, kodi_id: int): + # query the kodi database to get tmdb and imdb unique ids + rpc_query = { + "jsonrpc": "2.0", + "method": "VideoLibrary.GetMovieDetails", + "params": { + "movieid": int(kodi_id), + "properties": [ + "imdbnumber", + "uniqueid", + ], + }, + "id": "libMovies", + } + rpc_response = xbmc.executeJSONRPC(json.dumps(rpc_query)) + json_response = json.loads(rpc_response) + self.log.debug(f"JSON response: {json_response}") + + # get the supported: + ids = { + 'themoviedb': json_response['result']['moviedetails']['uniqueid'].get('tmdb'), + 'imdb': json_response['result']['moviedetails']['uniqueid'].get('imdb'), + } + self.log.debug(f"IDs: {ids}") + return ids + + def find_youtube_url_from_ids(self, ids: dict, db_type: str) -> Optional[str]: + for key, value in list(ids.items()): + if not value: + continue + self.log.debug(f"{key.upper()}_ID: {value}") + themerr_db_url = f"https://app.lizardbyte.dev/ThemerrDB/{db_type}/{key}/{value}.json" + self.log.debug(f"Themerr DB URL: {themerr_db_url}") + + try: + response_data = requests.get( + url=themerr_db_url, + ).json() + except requests.exceptions.RequestException as e: + self.log.debug(f"Exception getting data from {themerr_db_url}: {e}") + except json.decoder.JSONDecodeError: + self.log.debug(f"Exception decoding JSON from {themerr_db_url}") + else: + youtube_theme_url = response_data['youtube_theme_url'] + self.log.debug(f"Youtube theme URL: {youtube_theme_url}") + + return youtube_theme_url + + @staticmethod + def any_true(check: Optional[bool] = None, checks: Optional[List[bool]] = ()): + """ + Determine if the check is True or if any of the checks are True. + + Parameters + ---------- + check : Optional[bool] + The check to perform. + checks : Optional[List[bool]] + The checks to perform. + + Returns + ------- + bool + True if any of the checks are True, otherwise False. + + Examples + -------- + >>> Window().any_true(checks=[True, False, False]) + True + >>> Window().any_true(checks=[False, False, False]) + False + >>> Window().any_true(check=True) + True + >>> Window().any_true(check=False) + False + """ + if len(checks) == 0: + return check + + for check in checks: + if check: + return True + + def is_home(self): + return self.any_true(check=xbmc.getCondVisibility("Window.IsVisible(home)")) + + def is_movies(self): + return self.any_true(checks=[ + xbmc.getCondVisibility("Container.Content(movies)"), + (xbmc.getInfoLabel("ListItem.DBTYPE") == 'movie'), + ]) + + def is_movie_set(self): + # i.e. collections + return self.any_true(check=xbmc.getCondVisibility("ListItem.IsCollection")) + + def is_tv_shows(self): + return self.any_true(checks=[ + xbmc.getCondVisibility("Container.Content(tvshows)"), + (xbmc.getInfoLabel("ListItem.DBTYPE") == 'tvshow'), + ]) + + def is_seasons(self): + return self.any_true(checks=[ + xbmc.getCondVisibility("Container.Content(Seasons)"), + (xbmc.getInfoLabel("ListItem.DBTYPE") == 'season'), + ]) + + def is_episodes(self): + return self.any_true(checks=[ + xbmc.getCondVisibility("Container.Content(Episodes)"), + (xbmc.getInfoLabel("ListItem.DBTYPE") == 'episode'), + ]) diff --git a/src/themerr/logger.py b/src/themerr/logger.py new file mode 100644 index 0000000..79e6c50 --- /dev/null +++ b/src/themerr/logger.py @@ -0,0 +1,177 @@ +# standard imports +import os + +# kodi imports +import xbmc +import xbmcgui + +# local imports +from . import constants +from . import notifier + + +class Logger(object): + """ + Themerr's logger class. + + Creates a new logger to log to the Kodi log. + + Methods + ------- + log(msg: str, level: int = xbmc.LOGDEBUG) + Log a message to the Kodi log. + debug(msg: str) + Log a debug message to the Kodi log. + info(msg: str) + Log an info message to the Kodi log. + warning(msg: str) + Log a warning message to the Kodi log. + error(msg: str) + Log an error message to the Kodi log. + fatal(msg: str) + Log a fatal message to the Kodi log. + + Examples + -------- + >>> logger = Logger() + """ + def __init__(self): + self.notifier = notifier.Notifier() + self.icons = { + xbmc.LOGDEBUG: xbmcgui.NOTIFICATION_INFO, + xbmc.LOGINFO: xbmcgui.NOTIFICATION_INFO, + xbmc.LOGWARNING: xbmcgui.NOTIFICATION_WARNING, + xbmc.LOGERROR: xbmcgui.NOTIFICATION_ERROR, + xbmc.LOGFATAL: xbmcgui.NOTIFICATION_ERROR, + } + self.level_mapper = { + xbmc.LOGDEBUG: "DEBUG", + xbmc.LOGINFO: "INFO", + xbmc.LOGWARNING: "WARNING", + xbmc.LOGERROR: "ERROR", + xbmc.LOGFATAL: "FATAL", + } + + def log(self, msg: str, level: int = xbmc.LOGDEBUG): + """ + Log a message to the Kodi log. + + This method will log a debug message to the Kodi log. + The level parameter will be included in the log message. + Additionally, a notification will be displayed to the user if the addon is in development mode. + + Parameters + ---------- + msg : str + The message to log. + level : int + The log level to log the message at. + + Examples + -------- + >>> logger = Logger() + >>> logger.log("This is a debug message", xbmc.LOGDEBUG) + """ + xbmc.log( + msg=f"{constants.name}: [{self.level_mapper[level]}]: {msg}", + level=xbmc.LOGDEBUG if level < xbmc.LOGDEBUG else level, # kodi doesn't want us to log below debug + ) + + if os.getenv('THEMERR_DEV'): # todo: use a config option for this + self.notifier.notify( + message=msg, + icon=self.icons[level], + ) + + def debug(self, msg: str): + """ + Log a debug message to the Kodi log. + + Passes the message to the log method with the debug log level. + + Parameters + ---------- + msg : str + The message to log. + + Examples + -------- + >>> logger = Logger() + >>> logger.debug("This is a debug message") + """ + self.log(msg=msg, level=xbmc.LOGDEBUG) + + def info(self, msg: str): + """ + Log an info message to the Kodi log. + + Passes the message to the log method with the info log level. + + Parameters + ---------- + msg : str + The message to log. + + Examples + -------- + >>> logger = Logger() + >>> logger.info("This is an info message") + """ + self.log(msg=msg, level=xbmc.LOGINFO) + + def warning(self, msg: str): + """ + Log a warning message to the Kodi log. + + Passes the message to the log method with the warning log level. + + Parameters + ---------- + msg : str + The message to log. + + Examples + -------- + >>> logger = Logger() + >>> logger.warning("This is a warning message") + """ + self.log(msg=msg, level=xbmc.LOGWARNING) + + def error(self, msg: str): + """ + Log an error message to the Kodi log. + + Passes the message to the log method with the error log level. + + Parameters + ---------- + msg : str + The message to log. + + Examples + -------- + >>> logger = Logger() + >>> logger.error("This is an error message") + """ + self.log(msg=msg, level=xbmc.LOGERROR) + + def fatal(self, msg: str): + """ + Log a fatal message to the Kodi log. + + Passes the message to the log method with the fatal log level. + + Parameters + ---------- + msg : str + The message to log. + + Examples + -------- + >>> logger = Logger() + >>> logger.fatal("This is a fatal message") + """ + self.log(msg=msg, level=xbmc.LOGFATAL) + + +log = Logger() diff --git a/src/themerr/monitor.py b/src/themerr/monitor.py new file mode 100644 index 0000000..f40fe24 --- /dev/null +++ b/src/themerr/monitor.py @@ -0,0 +1,63 @@ +# kodi imports +import xbmc + +# local imports +from . import logger +from . import settings + + +class ThemerrMonitor(xbmc.Monitor): + """ + Kodi's monitor class. + + Creates a new monitor to notify addon about changes. + + Methods + ------- + abortRequested() -> bool + Check if Kodi is requesting an abort. + onSettingsChanged() + Check if Kodi settings have been modified. + + Examples + -------- + >>> monitor = ThemerrMonitor() + """ + def __init__(self): + super().__init__() + self.log = logger.log + + def abortRequested(self) -> bool: + """ + Check if Kodi is requesting an abort. + + Re-definition of the abortRequested method from xbmc.Monitor. + + Returns + ------- + bool + True if Kodi is requesting an abort, False otherwise. + + Examples + -------- + >>> monitor = ThemerrMonitor() + >>> monitor.abortRequested() + False + """ + return xbmc.Monitor.abortRequested(self) + + def onSettingsChanged(self): + """ + Check if Kodi settings have been modified. + + This method is automatically called when Kodi settings have been modified. + + Examples + -------- + >>> monitor = ThemerrMonitor() + >>> monitor.onSettingsChanged() + """ + self.log.debug("ThemerrMonitor: Settings have been modified") + + # reload the settings + settings.settings = settings.Settings() diff --git a/src/themerr/notifier.py b/src/themerr/notifier.py new file mode 100644 index 0000000..db901b0 --- /dev/null +++ b/src/themerr/notifier.py @@ -0,0 +1,61 @@ +# standard imports +from typing import Optional + +# kodi imports +import xbmcgui + +# local imports +from . import constants + + +class Notifier: + def __init__( + self, + heading: Optional[str] = constants.name, + icon: Optional[str] = xbmcgui.NOTIFICATION_INFO, + time: Optional[int] = 5000, + sound: Optional[bool] = True, + ): + self.dialog = xbmcgui.Dialog() + self.heading = heading + self.icon = icon + self.time = time + self.sound = sound + + def notify( + self, + message: str, + heading: Optional[str] = None, + icon: Optional[str] = None, + time: Optional[int] = None, + sound: Optional[bool] = None, + ): + """ + Show a notification dialog. + + Parameters + ---------- + message : str + The message of the notification dialog. + heading : Optional[str] + The heading of the notification dialog. + icon : Optional[str] + The icon of the notification dialog. + time : Optional[int] + The time to show the notification dialog. + sound : Optional[bool] + Whether to play a sound when showing the notification dialog. + """ + # get default values if not provided + heading = heading if heading is not None else self.heading + icon = icon if icon is not None else self.icon + time = time if time is not None else self.time + sound = sound if sound is not None else self.sound + + self.dialog.notification( + heading=heading, + message=message, + icon=icon, + time=time, + sound=sound, + ) diff --git a/src/themerr/player.py b/src/themerr/player.py new file mode 100644 index 0000000..20d9587 --- /dev/null +++ b/src/themerr/player.py @@ -0,0 +1,106 @@ +# standard imports +from typing import Optional + +# kodi imports +import xbmc + +# local imports +from . import logger +from . import youtube + + +class Player(xbmc.Player): + """ + Kodi's player class. + + Creates a new player to control playback. + + Methods + ------- + ytdl_extract_url(url: str) -> Optional[str] + Extract the audio URL from a YouTube URL. + play_url(url: str, kodi_id: int, windowed: bool = False) + Play a YouTube URL. + stop() + Stop playback. + reset() + Reset the player. + + Examples + -------- + >>> player = Player() + """ + def __init__(self): + super().__init__() + self.log = logger.log + self.theme_is_playing = False + self.theme_is_playing_for = 0 + self.theme_playing_kodi_id = None + self.theme_playing_url = None + + @staticmethod + def ytdl_extract_url(url: str) -> Optional[str]: + mp3_url = youtube.process_youtube(url=url) + return mp3_url if mp3_url else None + + def play_url( + self, + url: str, + kodi_id: int, + windowed: bool = False, + ): + """ + Play a YouTube URL. + + Given a user facing YouTube URL, extract the audio URL and play it. + + Parameters + ---------- + url : str + The url to play. + kodi_id : int + The Kodi ID of the item. + windowed : bool + True to play in a window, False otherwise. + + Examples + -------- + >>> player = Player() + >>> player.play_url(url="https://www.youtube.com/watch?v=dQw4w9WgXcQ", kodi_id=1) + """ + playable_url = self.ytdl_extract_url(url=url) + if playable_url: + self.play(item=playable_url, windowed=windowed) + self.theme_is_playing = True + self.theme_playing_kodi_id = kodi_id + self.theme_playing_url = playable_url + + def stop(self): + """ + Stop playback. + + This function will stop playback and reset the player. + + Examples + -------- + >>> player = Player() + >>> player.stop() + """ + xbmc.Player.stop(self) + self.reset() + + def reset(self): + """ + Reset the player. + + Reset class variables to their default values. + + Examples + -------- + >>> player = Player() + >>> player.reset() + """ + self.theme_is_playing = False + self.theme_is_playing_for = 0 + self.theme_playing_kodi_id = None + self.theme_playing_url = None diff --git a/src/themerr/plugin.py b/src/themerr/plugin.py new file mode 100644 index 0000000..e78f012 --- /dev/null +++ b/src/themerr/plugin.py @@ -0,0 +1,92 @@ +# standard imports +import os +import sys +from threading import Thread + +# kodi imports +import xbmcvfs + +# local imports +from . import constants +from . import logger +from . import monitor +from . import settings + + +class Themerr: + def __init__(self): + """ + Initialize the Themerr class. + """ + self.log = logger.Logger() + self.monitor = monitor.ThemerrMonitor() + self.settings = settings.Settings() + self.gui = None + self.add_on = self.settings.addon + self.cwd = self.add_on.getAddonInfo('path') + self.lib_dir = xbmcvfs.translatePath(os.path.join(self.cwd, 'resources', 'lib')) + + # add the lib directory to the python path + if self.lib_dir not in sys.path: + sys.path.insert(0, self.lib_dir) + + self.log.debug(f"Themerr lib directory: {self.lib_dir}") + self.log.debug(f"Themerr cwd: {self.cwd}") + for p in sys.path: + self.log.debug(f"Themerr sys.path: {p}") + + self.threads = [] + + def start(self): + """ + Start the Themerr addon. + + Returns + ------- + None + + Examples + -------- + >>> Themerr().start() + """ + # this must be imported after the lib directory has been added to the python path + from . import gui + self.gui = gui.Window() + + # todo: create a config option to enable development mode + # os.environ['THEMERR_DEV'] = 'true' + + self.log.debug(f"Starting {constants.name} Service {self.add_on.getAddonInfo('version')}") + + # start the window watcher + window_watcher = Thread( + name='ThemerrWindowWatcher', + target=self.gui.window_watcher, + daemon=True, # terminate the thread when the main thread terminates + ) + self.threads.append(window_watcher) + window_watcher.start() + + # wait for the addon to be stopped by kodi + self.monitor.waitForAbort() + self.terminate() + + def terminate(self): + """ + Terminate the Themerr addon. + + Returns + ------- + None + + Examples + -------- + >>> Themerr().terminate() + """ + self.log.debug(f"Terminating {constants.name} Service {self.add_on.getAddonInfo('version')}") + + del self.monitor + + # try to terminate all threads + for thread in self.threads: + thread.join() diff --git a/src/themerr/settings.py b/src/themerr/settings.py new file mode 100644 index 0000000..8d9a1e2 --- /dev/null +++ b/src/themerr/settings.py @@ -0,0 +1,16 @@ +# kodi imports +import xbmcaddon + +# local imports +from . import constants + + +class Settings: + def __init__(self): + """ + Initialize the Settings class. + """ + self.addon = xbmcaddon.Addon(id=constants.addon_id) + + +settings = Settings() diff --git a/src/themerr/youtube.py b/src/themerr/youtube.py new file mode 100644 index 0000000..102e2cb --- /dev/null +++ b/src/themerr/youtube.py @@ -0,0 +1,100 @@ +# standard imports +from typing import Optional + +# lib imports +import youtube_dl + +# local imports +from . import logger + +log = logger.log + + +def process_youtube(url: str) -> Optional[str]: + """ + Get URL using `youtube_dl`. + + The function will try to get a playable URL from the YouTube video. + + Parameters + ---------- + url : str + The URL of the YouTube video. + + Returns + ------- + Optional[str] + The URL of the audio object. + + Examples + -------- + >>> process_youtube(url='https://www.youtube.com/watch?v=dQw4w9WgXcQ') + ... + """ + youtube_dl_params = dict( + logger=logger.log, + socket_timeout=10, + youtube_include_dash_manifest=False, + ) + + ydl = youtube_dl.YoutubeDL(params=youtube_dl_params) + + with ydl: + try: + result = ydl.extract_info( + url=url, + download=False # We just want to extract the info + ) + except Exception as exc: + if isinstance(exc, youtube_dl.utils.ExtractorError) and exc.expected: + log.error('YDL returned YT error while downloading {}: {}'.format(url, exc)) + else: + log.error('YDL returned an unexpected error while downloading {}: {}'.format(url, exc)) + return None + + if 'entries' in result: + # Can be a playlist or a list of videos + video_data = result['entries'][0] + else: + # Just a video + video_data = result + + selected = { + 'opus': { + 'size': 0, + 'audio_url': None + }, + 'mp4a': { + 'size': 0, + 'audio_url': None + }, + } + if video_data: + for fmt in video_data['formats']: # loop through formats, select largest audio size for better quality + if 'audio only' in fmt['format']: + if 'opus' == fmt['acodec']: + temp_codec = 'opus' + elif 'mp4a' == fmt['acodec'].split('.')[0]: + temp_codec = 'mp4a' + else: + log.debug('Unknown codec: %s' % fmt['acodec']) + continue # unknown codec + filesize = int(fmt['filesize']) + if filesize > selected[temp_codec]['size']: + selected[temp_codec]['size'] = filesize + selected[temp_codec]['audio_url'] = fmt['url'] + + audio_url = None + + if 0 < selected['opus']['size'] > selected['mp4a']['size']: + audio_url = selected['opus']['audio_url'] + elif 0 < selected['mp4a']['size'] > selected['opus']['size']: + audio_url = selected['mp4a']['audio_url'] + + if audio_url: # mp4a codec is preferred + if selected['mp4a']['audio_url']: # mp4a codec is available + audio_url = selected['mp4a']['audio_url'] + elif selected['opus']['audio_url']: # fallback to opus :( + audio_url = selected['opus']['audio_url'] + + return audio_url # return None or url found diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/ci/__init__.py b/tests/ci/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/ci/test_docs.py b/tests/ci/test_docs.py new file mode 100644 index 0000000..03b3db1 --- /dev/null +++ b/tests/ci/test_docs.py @@ -0,0 +1,71 @@ +# standard imports +import os +import platform +import shutil +import subprocess + +# lib imports +import pytest + + +doc_matrix = [ + ('html', os.path.join('html', 'index.html')), + ('epub', os.path.join('epub', 'Themerr-kodi.epub')), +] + + +@pytest.mark.parametrize('doc_type, file_name', doc_matrix) +def test_make_docs(doc_type, file_name): + """Test building sphinx docs""" + # remove existing build directory + build_dir = os.path.join(os.getcwd(), 'docs', 'build') + if os.path.isdir(build_dir): + shutil.rmtree(path=build_dir) + + print('Building {} docs'.format(doc_type)) + result = subprocess.check_call( + args=['make', doc_type], + cwd=os.path.join(os.getcwd(), 'docs'), + shell=True if platform.system() == 'Windows' else False, + ) + assert result == 0, 'Failed to build {} docs'.format(doc_type) + + # ensure docs built + assert os.path.isfile(os.path.join(build_dir, file_name)), '{} docs not built'.format(doc_type) + + +@pytest.mark.parametrize('doc_type, file_name', doc_matrix) +def test_dummy_file(doc_type, file_name): + """Test building sphinx docs with known warnings""" + # create a dummy rst file + dummy_file = os.path.join(os.getcwd(), 'docs', 'source', 'dummy.rst') + + # write test to dummy file, creating the file if it doesn't exist + with open(dummy_file, 'w+') as f: + f.write('Dummy file\n') + f.write('==========\n') + + # ensure CalledProcessError is raised + with pytest.raises(subprocess.CalledProcessError): + test_make_docs(doc_type=doc_type, file_name=file_name) + + # remove the dummy rst file + os.remove(dummy_file) + + +def test_rstcheck(): + """Test rstcheck""" + # get list of all the rst files in the project (skip venv and Contents/Libraries) + rst_files = [] + for root, dirs, files in os.walk(os.getcwd()): + for f in files: + if f.lower().endswith('.rst') and 'venv' not in root and 'third-party' not in root: + rst_files.append(os.path.join(root, f)) + + assert rst_files, 'No rst files found' + + # run rstcheck on all the rst files + for rst_file in rst_files: + print('Checking {}'.format(rst_file)) + result = subprocess.check_call(['rstcheck', rst_file]) + assert result == 0, 'rstcheck failed on {}'.format(rst_file) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..be3516a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,85 @@ +# standard imports +import os +from unittest.mock import MagicMock, patch + +# kodi imports +import xbmc + +# lib imports +import pytest + +# script imports +from scripts.bootstrap_kodi_requirements import bootstrap_kodi_modules + +# bootstrap kodi modules +bootstrap_kodi_modules() + +from src.themerr.player import Player # noqa: E402 + + +@pytest.fixture(scope='function') +def mock_xbmc_log(): + with patch('xbmc.log', spec=True) as mock_log: + yield mock_log + + +@pytest.fixture(scope='function') +def mock_xbmcgui_dialog(): + with patch('xbmcgui.Dialog', spec=True) as mock_dialog: + mock_instance = mock_dialog.return_value + yield mock_instance + + +@pytest.fixture(scope='function') +def mock_xbmc_player(): + with patch.multiple(xbmc.Player, + getPlayingFile=MagicMock(), + isPlayingVideo=MagicMock()) as mocks: + def getPlayingFile_side_effect(): + if os.getenv('_KODI_GET_PLAYING_FILE'): + return os.getenv('_KODI_GET_PLAYING_FILE') + else: + raise RuntimeError('Simulated RuntimeError') + + xbmc.Player.getPlayingFile.side_effect = getPlayingFile_side_effect + + def isPlayingVideo_side_effect(): + if os.getenv('_KODI_IS_PLAYING_VIDEO'): + return True + else: + return False + + xbmc.Player.isPlayingVideo.side_effect = isPlayingVideo_side_effect + + yield Player() + + +@pytest.fixture(scope='function') +def mock_xbmcaddon_addon(): + with patch('xbmcaddon.Addon', spec=True) as mock_addon: + mock_instance = mock_addon.return_value + + # Define a side effect function for getAddonInfo + def getAddonInfo_side_effect(arg): + if arg == 'path': + return os.getcwd() # Return current working directory for 'path' + # Handle other arguments if necessary + return 'default_value' # Return a default value or raise an error + + # Set the side effect for getAddonInfo + mock_instance.getAddonInfo.side_effect = getAddonInfo_side_effect + + yield mock_instance + + +@pytest.fixture(scope='function') +def mock_xbmcvfs(): + with patch('xbmcvfs.translatePath', spec=True) as mock_vfs: + + # override the translatePath method + def translate_path(path: str): + return path + + mock_vfs.side_effect = translate_path + + yield mock_vfs diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_constants.py b/tests/unit/test_constants.py new file mode 100644 index 0000000..1bae68a --- /dev/null +++ b/tests/unit/test_constants.py @@ -0,0 +1,14 @@ +# local imports +from src.themerr import constants + + +def test_name(): + assert constants.name == "Themerr" + + +def test_addon_type(): + assert constants.addon_type == "service" + + +def test_addon_id(): + assert constants.addon_id == "service.themerr" diff --git a/tests/unit/test_gui.py b/tests/unit/test_gui.py new file mode 100644 index 0000000..34c7a37 --- /dev/null +++ b/tests/unit/test_gui.py @@ -0,0 +1,67 @@ +# standard imports +import os + +# kodi imports +import xbmc + +# lib imports +import pytest + +# local imports +from src.themerr import gui + + +@pytest.fixture(scope='function') +def window_obj(mock_xbmc_player): + """Return the Window object with a mocked player""" + return gui.Window(player_instance=mock_xbmc_player) + + +def test_window_init(window_obj): + """Test the Window object is initialized correctly""" + assert isinstance(window_obj.monitor, xbmc.Monitor) + + isinstance(window_obj.player, xbmc.Player) + assert type(window_obj.player).__name__ == 'Player' + + assert window_obj.item_selected_for == 0 + assert window_obj.playing_item_not_selected_for == 0 + assert window_obj.current_selected_item_id is None + assert window_obj.last_selected_item_id is None + assert window_obj.kodi_id_mapping == {} + + +def test_pre_checks_no_item_playing(window_obj): + # Scenario 1: No item playing + assert window_obj.pre_checks() is True + + +def test_pre_checks_mismatched_item_playing(window_obj): + # Scenario 2: Item playing does not match player.theme_playing_url + # i.e., The user starting playing something else + os.environ['_KODI_GET_PLAYING_FILE'] = 'https://www.youtube.com/watch?v=123' + window_obj.player.theme_playing_url = 'https://www.youtube.com/watch?v=456' + assert window_obj.pre_checks() is False + assert window_obj.player.theme_is_playing is False + assert window_obj.player.theme_playing_kodi_id is None + assert window_obj.player.theme_playing_url is None + assert window_obj.item_selected_for == 0 + + del os.environ['_KODI_GET_PLAYING_FILE'] + + +def test_pre_checks_video_is_playing(window_obj): + # Scenario 3: Video is playing + os.environ['_KODI_GET_PLAYING_FILE'] = 'https://www.youtube.com/watch?v=123' + os.environ['_KODI_IS_PLAYING_VIDEO'] = '1' + assert window_obj.pre_checks() is False + + del os.environ['_KODI_GET_PLAYING_FILE'] + del os.environ['_KODI_IS_PLAYING_VIDEO'] + + +def test_pre_checks_all_passing(window_obj): + # Scenario 4: Everything is fine + os.environ['_KODI_GET_PLAYING_FILE'] = 'https://www.youtube.com/watch?v=123' + window_obj.player.theme_playing_url = 'https://www.youtube.com/watch?v=123' + assert window_obj.pre_checks() is True diff --git a/tests/unit/test_logger.py b/tests/unit/test_logger.py new file mode 100644 index 0000000..7486f2b --- /dev/null +++ b/tests/unit/test_logger.py @@ -0,0 +1,107 @@ +# kodi imports +import xbmc + +# lib imports +import pytest + +# local imports +from src.themerr import constants +from src.themerr import logger + + +@pytest.fixture(scope='module') +def logger_obj(): + """Create a logger object""" + return logger.Logger() + + +def test_default_log(mock_xbmc_log, logger_obj): + """Test log method""" + message = 'Test message' + logger_obj.log(msg=message) + + expected_message = f'{constants.name}: [DEBUG]: {message}' + mock_xbmc_log.assert_called_once_with( + msg=expected_message, + level=xbmc.LOGDEBUG, + ) + + +@pytest.mark.parametrize('level', [ + xbmc.LOGDEBUG, + xbmc.LOGINFO, + xbmc.LOGWARNING, + xbmc.LOGERROR, + xbmc.LOGFATAL, +]) +def test_log(mock_xbmc_log, logger_obj, level): + """Test log method""" + message = 'Test message' + logger_obj.log(msg=message, level=level) + + expected_message = f'{constants.name}: [{logger_obj.level_mapper[level]}]: {message}' + expected_level = xbmc.LOGDEBUG if level < xbmc.LOGDEBUG else level + mock_xbmc_log.assert_called_once_with( + msg=expected_message, + level=expected_level, + ) + + +def test_debug(mock_xbmc_log, logger_obj): + """Test debug method""" + message = 'Test message' + logger_obj.debug(msg=message) + + expected_message = f'{constants.name}: [DEBUG]: {message}' + mock_xbmc_log.assert_called_once_with( + msg=expected_message, + level=xbmc.LOGDEBUG, + ) + + +def test_info(mock_xbmc_log, logger_obj): + """Test info method""" + message = 'Test message' + logger_obj.info(msg=message) + + expected_message = f'{constants.name}: [INFO]: {message}' + mock_xbmc_log.assert_called_once_with( + msg=expected_message, + level=xbmc.LOGINFO, + ) + + +def test_warning(mock_xbmc_log, logger_obj): + """Test warning method""" + message = 'Test message' + logger_obj.warning(msg=message) + + expected_message = f'{constants.name}: [WARNING]: {message}' + mock_xbmc_log.assert_called_once_with( + msg=expected_message, + level=xbmc.LOGWARNING, + ) + + +def test_error(mock_xbmc_log, logger_obj): + """Test error method""" + message = 'Test message' + logger_obj.error(msg=message) + + expected_message = f'{constants.name}: [ERROR]: {message}' + mock_xbmc_log.assert_called_once_with( + msg=expected_message, + level=xbmc.LOGERROR, + ) + + +def test_fatal(mock_xbmc_log, logger_obj): + """Test fatal method""" + message = 'Test message' + logger_obj.fatal(msg=message) + + expected_message = f'{constants.name}: [FATAL]: {message}' + mock_xbmc_log.assert_called_once_with( + msg=expected_message, + level=xbmc.LOGFATAL, + ) diff --git a/tests/unit/test_monitor.py b/tests/unit/test_monitor.py new file mode 100644 index 0000000..e1003a7 --- /dev/null +++ b/tests/unit/test_monitor.py @@ -0,0 +1,26 @@ +# lib imports +import pytest + +# local imports +from src.themerr import monitor +from src.themerr import settings + + +@pytest.fixture(scope='function') +def monitor_obj(): + """Return a new Monitor object""" + return monitor.ThemerrMonitor() + + +def test_abort_requested(monitor_obj): + """Test that abort_requested returns the correct value""" + assert monitor_obj.abortRequested() is True # kodistubs returns True only + + +def test_on_settings_changed(monitor_obj): + """Test that on_settings_changed updates the monitor's settings""" + og_settings = settings.settings + + monitor_obj.onSettingsChanged() + + assert settings.settings != og_settings diff --git a/tests/unit/test_notifier.py b/tests/unit/test_notifier.py new file mode 100644 index 0000000..e4ac165 --- /dev/null +++ b/tests/unit/test_notifier.py @@ -0,0 +1,54 @@ +# kodi imports +import xbmcgui + +# lib imports +import pytest + +# local imports +from src.themerr import notifier + + +@pytest.fixture(scope='function') +def notifier_obj(): + """Create a notifier object""" + return notifier.Notifier() + + +def test_default_notify(mock_xbmcgui_dialog, notifier_obj): + """Test notify method""" + message = 'Test message' + notifier_obj.notify(message=message) + + mock_xbmcgui_dialog.notification.assert_called_once_with( + heading=notifier_obj.heading, + message=message, + icon=notifier_obj.icon, + time=notifier_obj.time, + sound=notifier_obj.sound, + ) + + +@pytest.mark.parametrize('heading, icon, time, sound', [ + ('Test heading', xbmcgui.NOTIFICATION_INFO, 10000, False), + ('Test heading', xbmcgui.NOTIFICATION_INFO, -1, False), + ('Test heading', xbmcgui.NOTIFICATION_WARNING, 0, False), + ('Test heading', xbmcgui.NOTIFICATION_ERROR, 10000, True), +]) +def test_notify(mock_xbmcgui_dialog, notifier_obj, heading, icon, time, sound): + """Test notify method""" + message = 'Test message' + notifier_obj.notify( + message=message, + heading=heading, + icon=icon, + time=time, + sound=sound, + ) + + mock_xbmcgui_dialog.notification.assert_called_once_with( + heading=heading, + message=message, + icon=icon, + time=time, + sound=sound, + ) diff --git a/tests/unit/test_player.py b/tests/unit/test_player.py new file mode 100644 index 0000000..fc463e9 --- /dev/null +++ b/tests/unit/test_player.py @@ -0,0 +1,85 @@ +# lib imports +import pytest + +# local imports +from src.themerr import player + + +@pytest.fixture(scope='function') +def player_obj(): + """Return a Player object""" + return player.Player() + + +def test_player_init(player_obj): + """Test Player object initialization""" + assert not player_obj.theme_is_playing + assert not player_obj.theme_is_playing_for + assert not player_obj.theme_playing_kodi_id + assert not player_obj.theme_playing_url + + +@pytest.mark.parametrize('url', [ + 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + 'https://www.youtube.com/watch?v=Wb8j8Ojd4YQ&list=PLMYr5_xSeuXAbhxYHz86hA1eCDugoxXY0&pp=iAQB', # playlist test +]) +def test_ytdl_extract_url(player_obj, url): + """Test ytdl_extract_url""" + audio_url = player_obj.ytdl_extract_url(url=url) + assert audio_url is not None + assert audio_url.startswith('https://') + + +@pytest.mark.parametrize('url', [ + 'https://www.youtube.com/watch?v=notavideoid', + 'https://blahblahblah', +]) +def test_ytdl_extract_url_invalid(player_obj, url): + """Test ytdl_extract_url with invalid url""" + audio_url = player_obj.ytdl_extract_url(url=url) + assert audio_url is None + + +def test_play_url(player_obj, mock_xbmc_player): + """Test play_url""" + url = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' + kodi_id = 1 + player_obj.play_url( + url=url, + kodi_id=kodi_id, + windowed=False, + ) + assert player_obj.theme_is_playing + assert player_obj.theme_playing_kodi_id == kodi_id + assert player_obj.theme_playing_url + + assert mock_xbmc_player.play.called_once_with( + url=url, + kodi_id=kodi_id, + windowed=False, + ) + + +def test_stop(player_obj, mock_xbmc_player): + """Test stop""" + player_obj.stop() + assert not player_obj.theme_is_playing + assert not player_obj.theme_is_playing_for + assert not player_obj.theme_playing_kodi_id + assert not player_obj.theme_playing_url + + assert mock_xbmc_player.stop.called_once_with() + + +def test_reset(player_obj): + """Test reset""" + player_obj.theme_is_playing = True + player_obj.theme_is_playing_for = 1000 + player_obj.theme_playing_kodi_id = 1000 + player_obj.theme_playing_url = 'https://...' + + player_obj.reset() + assert not player_obj.theme_is_playing + assert not player_obj.theme_is_playing_for + assert not player_obj.theme_playing_kodi_id + assert not player_obj.theme_playing_url diff --git a/tests/unit/test_plugin.py b/tests/unit/test_plugin.py new file mode 100644 index 0000000..8a83cfd --- /dev/null +++ b/tests/unit/test_plugin.py @@ -0,0 +1,50 @@ +# standard imports +import os +import sys +from unittest.mock import MagicMock + +# lib imports +import pytest + +# local imports +from src.themerr import plugin + + +@pytest.fixture(scope='function') +def plugin_obj(): + """Return the plugin object""" + return plugin.Themerr() + + +def test_plugin_init(mock_xbmcaddon_addon, mock_xbmcvfs, plugin_obj): + """Test plugin object initialization""" + assert plugin_obj.monitor + assert plugin_obj.settings + assert not plugin_obj.gui + assert plugin_obj.add_on + assert plugin_obj.cwd == os.getcwd() + assert plugin_obj.lib_dir.endswith(f'resources{os.sep}lib') + + assert plugin_obj.lib_dir in sys.path + assert plugin_obj.threads == [] + + +def test_start(plugin_obj): + """Test plugin start method""" + plugin_obj.start() + assert plugin_obj.gui + assert plugin_obj.threads + + +def test_terminate(plugin_obj): + """Test plugin terminate method""" + plugin_obj.start() + plugin_obj.monitor = MagicMock() + + plugin_obj.terminate() + + for thread in plugin_obj.threads: + assert not thread.is_alive() + + with pytest.raises(AttributeError): + _ = plugin_obj.monitor diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py new file mode 100644 index 0000000..5a02e84 --- /dev/null +++ b/tests/unit/test_settings.py @@ -0,0 +1,16 @@ +# lib imports +import pytest + +# local imports +from src.themerr import settings + + +@pytest.fixture(scope='function') +def settings_obj(): + """Return the Settings object""" + return settings.Settings() + + +def test_settings_init(mock_xbmcaddon_addon, settings_obj): + """Test the Settings class __init__ method""" + assert settings_obj.addon == mock_xbmcaddon_addon diff --git a/tests/unit/test_youtube.py b/tests/unit/test_youtube.py new file mode 100644 index 0000000..cfa5997 --- /dev/null +++ b/tests/unit/test_youtube.py @@ -0,0 +1,26 @@ +# lib imports +import pytest + +# local imports +from src.themerr import youtube + + +@pytest.mark.parametrize('url', [ + 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + 'https://www.youtube.com/watch?v=Wb8j8Ojd4YQ&list=PLMYr5_xSeuXAbhxYHz86hA1eCDugoxXY0&pp=iAQB', # playlist test +]) +def test_process_youtube(url): + # test valid urls + audio_url = youtube.process_youtube(url=url) + assert audio_url is not None + assert audio_url.startswith('https://') + + +@pytest.mark.parametrize('url', [ + 'https://www.youtube.com/watch?v=notavideoid', + 'https://blahblahblah', +]) +def test_process_youtube_invalid(url): + # test invalid urls + audio_url = youtube.process_youtube(url=url) + assert audio_url is None diff --git a/themerr.png b/themerr.png new file mode 100644 index 0000000..3d00618 Binary files /dev/null and b/themerr.png differ diff --git a/third-party/repo-scripts b/third-party/repo-scripts new file mode 160000 index 0000000..17c1a1b --- /dev/null +++ b/third-party/repo-scripts @@ -0,0 +1 @@ +Subproject commit 17c1a1bd404e6f6904c4006eb029874ad70c46f0