├── .depfu.yml ├── .dockerignore ├── .editorconfig ├── .github ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml └── workflows │ ├── build.sh │ ├── dependabot-auto-merge.yml │ ├── is-semantic-pr.yml │ ├── lock-issues.yml │ ├── release-bot.yml │ ├── stale-issues.yml │ ├── template-updater.yml │ └── test-release.yml ├── .gitignore ├── .husky └── pre-commit ├── .idea ├── codeStyleSettings.xml ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── encodings.xml ├── inspectionProfiles │ ├── Project_Default.xml │ └── profiles_settings.xml ├── jsLibraryMappings.xml ├── jsLinters │ ├── eslint.xml │ ├── jshint.xml │ └── jslint.xml ├── misc.xml ├── modules.xml ├── node-pyatv.iml ├── scopes │ └── scope_settings.xml ├── vcs.xml ├── watcherTasks.xml └── webResources.xml ├── .mocharc.yml ├── .npmignore ├── .nycrc ├── .prettierignore ├── .prettierrc ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── check.sh ├── eslint.config.js ├── package-lock.json ├── package.json ├── release.config.cjs ├── src ├── bin │ └── check.ts ├── examples │ └── push.ts └── lib │ ├── device-event.ts │ ├── device-events.ts │ ├── device.ts │ ├── fake-spawn.ts │ ├── index.ts │ ├── instance.ts │ ├── tools.ts │ └── types.ts ├── test ├── device-event.ts ├── device-events.ts ├── device.ts ├── instance.ts └── tools.ts ├── tsconfig.json ├── tsup.config.ts └── typedoc.json /.depfu.yml: -------------------------------------------------------------------------------- 1 | update_strategy: 'individual' 2 | 3 | dev_update_strategy: 'grouped' 4 | grouped_dev_update_schedule: 'weekly' 5 | grouped_dev_update_start_date: '2023-11-21' 6 | grouped_dev_update_time: '08:00' 7 | 8 | reasonably_up_to_date: true 9 | update_out_of_spec: true 10 | 11 | automerge_strategy: 'minor' 12 | automerge_dev_strategy: 'major' 13 | automerge_method: 'rebase' 14 | 15 | engine_update_strategy: 'major' 16 | 17 | commit_message: 'chore(deps): update {{dependency}} to version {{version}}' 18 | commit_message_grouped: 'chore(deps): Update {{update_type}} {{project_type}} dependencies ({{date}})' 19 | labels: 'dependencies' 20 | auto_assign: 'sebbo2002' 21 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .github 2 | .nyc_output 3 | dist 4 | docs 5 | node_modules 6 | /src 7 | test 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.yml] 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### About this Pull Request 2 | - **What kind of change does this PR introduce?** (Bug fix, feature, docs update, ...) 3 | - … 4 | - **What is the current behavior?** (You can also link to an open issue here) 5 | - … 6 | - **What is the new behavior (if this is a feature change)?** 7 | - … 8 | - **Does this PR introduce a breaking change?** (What changes might users need to make in their application due to this PR?) 9 | - … 10 | - **Other information**: 11 | - … 12 | 13 | 14 | ### Pull Request Checklist 15 | 16 | - [ ] My code follows the code style of this project 17 | - Run `npm run lint` to double check 18 | - [ ] My change requires a change to the documentation 19 | - [ ] I have updated the documentation accordingly 20 | - [ ] I have added tests to cover my changes 21 | - Run `npm run test` to run the unit tests and `npm run coverage` to generate a coverage report 22 | - [ ] All new and existing tests passed 23 | - [ ] My commit messages follow the [commit guidelines](https://github.com/angular/angular/blob/master/CONTRIBUTING.md#commit) 24 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | assignees: 6 | - sebbo2002 7 | commit-message: 8 | prefix: "ci" 9 | include: scope 10 | schedule: 11 | interval: weekly 12 | - package-ecosystem: docker 13 | directory: / 14 | assignees: 15 | - sebbo2002 16 | commit-message: 17 | prefix: "build" 18 | include: scope 19 | schedule: 20 | interval: weekly 21 | 22 | -------------------------------------------------------------------------------- /.github/workflows/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | echo "########################" 5 | echo "# build.sh" 6 | echo "# Branch = ${BRANCH}" 7 | echo "# node version = $(node -v)" 8 | echo "# npm version = $(npm -v)" 9 | echo "########################" 10 | 11 | # Typescript Build in ./dist 12 | npm run build 13 | 14 | if [ "$BRANCH" != "develop" ] && [ "$BRANCH" != "main" ] && [ "$BRANCH" != "" ]; then 15 | echo "Skip documentation as branch is not develop and not main (is: ${BRANCH})."; 16 | exit 0; 17 | fi; 18 | 19 | 20 | mkdir -p ./docs/ 21 | rm -rf ./docs/coverage/ ./docs/reference/ ./docs/tests/ 22 | 23 | 24 | # TypeDoc in ./docs/referece 25 | npx typedoc 26 | 27 | # Test Report in ./docs/tests 28 | npx mocha --reporter mochawesome 29 | mv -f ./mochawesome-report/mochawesome.html ./mochawesome-report/index.html 30 | mv -f ./mochawesome-report ./docs/tests 31 | 32 | # Coverage Report in ./doc/coverage 33 | npm run coverage 34 | 35 | # Run eslint and prettier 36 | npm run lint 37 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-auto-merge.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot auto-merge 2 | on: pull_request 3 | 4 | permissions: 5 | contents: write 6 | pull-requests: write 7 | 8 | jobs: 9 | dependabot: 10 | runs-on: ubuntu-latest 11 | if: github.actor == 'dependabot[bot]' 12 | steps: 13 | - name: ℹ️ Dependabot metadata 14 | id: metadata 15 | uses: dependabot/fetch-metadata@v2 16 | with: 17 | github-token: "${{ secrets.GITHUB_TOKEN }}" 18 | - name: 🔃 Enable auto-merge for Dependabot PRs 19 | if: steps.metadata.outputs.update-type == 'version-update:semver-minor' || steps.metadata.outputs.update-type == 'version-update:semver-patch' 20 | run: gh pr merge --auto --merge "$PR_URL" 21 | env: 22 | PR_URL: ${{github.event.pull_request.html_url}} 23 | GH_TOKEN: ${{secrets.GITHUB_TOKEN}} 24 | -------------------------------------------------------------------------------- /.github/workflows/is-semantic-pr.yml: -------------------------------------------------------------------------------- 1 | name: is-semantic-pr 2 | on: 3 | pull_request: null 4 | 5 | jobs: 6 | is-semantic-release: 7 | runs-on: ubuntu-latest 8 | if: ${{ startsWith(github.head_ref, 'depfu/batch_dev/') != true }} 9 | steps: 10 | - name: 🤖 is-semantic-release 11 | uses: sebbo2002/action-is-semantic-pr@main 12 | with: 13 | token: ${{ secrets.GITHUB_TOKEN }} 14 | -------------------------------------------------------------------------------- /.github/workflows/lock-issues.yml: -------------------------------------------------------------------------------- 1 | name: 'Lock Threads' 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' 6 | 7 | concurrency: 8 | group: lock-threads 9 | 10 | permissions: {} 11 | jobs: 12 | lock: 13 | permissions: 14 | issues: write 15 | pull-requests: write 16 | discussions: write 17 | 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: dessant/lock-threads@v5 21 | with: 22 | github-token: ${{ github.token }} 23 | process-only: 'issues, prs' 24 | issue-inactive-days: '60' 25 | issue-comment: > 26 | This issue has been automatically locked since there has not been any recent activity after it was closed. 27 | Please open a new issue for related issues and reference this one. 28 | 29 | pr-inactive-days: '60' 30 | pr-comment: > 31 | This pull request has been automatically locked since there has not been any recent activity after it was 32 | closed. Please open a new issue for related bugs and reference this one. 33 | -------------------------------------------------------------------------------- /.github/workflows/release-bot.yml: -------------------------------------------------------------------------------- 1 | name: ReleaseBot 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: ['develop'] 6 | schedule: 7 | - cron: '30 8 * * 3' 8 | 9 | jobs: 10 | release-bot: 11 | runs-on: ubuntu-latest 12 | if: ${{ github.repository != 'sebbo2002/js-template' }} 13 | steps: 14 | - name: ☁️ Checkout Project 15 | uses: actions/checkout@v5 16 | - name: ☁️ Checkout ReleaseBot 17 | uses: actions/checkout@v5 18 | with: 19 | repository: sebbo2002/release-bot 20 | path: ./.actions/release-bot 21 | - name: 🔧 Setup node 22 | uses: actions/setup-node@v6 23 | with: 24 | node-version: '20' 25 | cache: 'npm' 26 | - name: 📦 Install Dependencies 27 | run: npm ci 28 | working-directory: ./.actions/release-bot 29 | - name: 🤖 Run ReleaseBot 30 | uses: ./.actions/release-bot 31 | with: 32 | token: ${{ secrets.GH_TOKEN }} 33 | -------------------------------------------------------------------------------- /.github/workflows/stale-issues.yml: -------------------------------------------------------------------------------- 1 | name: Cleanup issues and PRs 2 | on: 3 | schedule: 4 | - cron: '0 10 * * *' 5 | 6 | jobs: 7 | stale: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | issues: write 11 | pull-requests: write 12 | steps: 13 | - name: 🧹 Cleanup issues & pull requests 14 | uses: actions/stale@v10 15 | with: 16 | repo-token: ${{ secrets.GITHUB_TOKEN }} 17 | stale-issue-message: | 18 | This issue has been automatically marked as stale because it has not had recent activity :sleeping: 19 | It will be closed in 14 days if no further activity occurs. To unstale this issue, add a comment with detailed explanation. 20 | Thank you for your contributions :heart: 21 | stale-pr-message: | 22 | This pull request has been automatically marked as stale because it has not had recent activity :sleeping: 23 | It will be closed in 14 days if no further activity occurs. To unstale this pull request, add a comment with detailed explanation. 24 | Thank you for your contributions :heart: 25 | days-before-stale: 60 26 | days-before-close: 14 27 | stale-issue-label: stale 28 | stale-pr-label: stale 29 | exempt-issue-labels: do-not-close 30 | exempt-pr-labels: do-not-close 31 | -------------------------------------------------------------------------------- /.github/workflows/template-updater.yml: -------------------------------------------------------------------------------- 1 | name: TemplateUpdater 2 | on: 3 | workflow_dispatch: 4 | schedule: 5 | - cron: '15 8 * * 3' 6 | 7 | jobs: 8 | TemplateUpdater: 9 | runs-on: ubuntu-latest 10 | if: ${{ github.repository != 'sebbo2002/js-template' }} 11 | steps: 12 | - name: 🤖 template-updater 13 | uses: sebbo2002/action-template-updater@develop 14 | with: 15 | token: ${{ secrets.GH_TOKEN }} 16 | bot-token: ${{ secrets.GITHUB_TOKEN }} 17 | template: sebbo2002/js-template/typescript 18 | assignees: sebbo2002 19 | -------------------------------------------------------------------------------- /.github/workflows/test-release.yml: -------------------------------------------------------------------------------- 1 | name: Test & Release 2 | on: 3 | push: 4 | branches-ignore: 5 | - 'gh-pages' 6 | - 'depfu/**' 7 | - 'dependabot/**' 8 | - 'template-updater/**' 9 | pull_request: null 10 | 11 | jobs: 12 | tests: 13 | name: Unit Tests 14 | runs-on: ubuntu-latest 15 | if: github.repository != 'sebbo2002/js-template' && (contains(toJson(github.event.commits.*.message), '[skip ci]') == false || github.ref == 'refs/heads/main') 16 | strategy: 17 | matrix: 18 | node: [20.x, 22.x, 23.x, current] 19 | steps: 20 | - name: ☁️ Checkout Project 21 | uses: actions/checkout@v5 22 | - name: 🔧 Set up Python 23 | uses: actions/setup-python@v6 24 | with: 25 | # https://github.com/aio-libs/aiohttp/issues/7739#issuecomment-1773868351 26 | python-version: 3.11 27 | - name: 🔧 Setup pip cache 28 | uses: actions/cache@v4 29 | id: pip-cache 30 | with: 31 | path: ~/.cache/pip 32 | key: pip 33 | - name: 📦 Install pyatv 34 | run: pip3 install --upgrade git+https://github.com/postlund/pyatv.git 35 | - name: 🔧 Setup node.js 36 | uses: actions/setup-node@v6 37 | with: 38 | node-version: ${{ matrix.node }} 39 | cache: 'npm' 40 | - name: 📦 Install dependencies 41 | run: npm ci 42 | - name: ⏳ Run tests 43 | run: npm run test 44 | 45 | coverage: 46 | name: Code Coverage / Lint 47 | runs-on: ubuntu-latest 48 | if: github.repository != 'sebbo2002/js-template' && (contains(toJson(github.event.commits.*.message), '[skip ci]') == false || github.ref == 'refs/heads/main') 49 | steps: 50 | - name: ☁️ Checkout Project 51 | uses: actions/checkout@v5 52 | - name: 🔧 Set up Python 53 | uses: actions/setup-python@v6 54 | with: 55 | python-version: 3.11 56 | - name: 🔧 Setup pip cache 57 | uses: actions/cache@v4 58 | id: pip-cache 59 | with: 60 | path: ~/.cache/pip 61 | key: pip 62 | - name: 📦 Install pyatv 63 | run: pip3 install --upgrade git+https://github.com/postlund/pyatv.git 64 | - name: 🔧 Setup node.js 65 | uses: actions/setup-node@v6 66 | with: 67 | node-version: 23.x 68 | cache: 'npm' 69 | - name: 📦 Install dependencies 70 | run: npm ci 71 | - name: 🔍 Run linter 72 | run: npm run lint 73 | - name: ⚙️ Build project 74 | run: npm run build-all 75 | 76 | license-checker: 77 | name: License Checker 78 | runs-on: ubuntu-latest 79 | if: contains(toJson(github.event.commits.*.message), '[skip ci]') == false || github.ref == 'refs/heads/main' 80 | steps: 81 | - name: ☁️ Checkout Project 82 | uses: actions/checkout@v5 83 | - name: 🔧 Setup node.js 84 | uses: actions/setup-node@v6 85 | with: 86 | cache: 'npm' 87 | - name: 📦 Install dependencies 88 | run: npm ci 89 | - name: 🕵️‍♀️ Run license checker 90 | run: npm run license-check 91 | 92 | release: 93 | name: Release 94 | runs-on: ubuntu-latest 95 | concurrency: release 96 | permissions: 97 | contents: write 98 | issues: write 99 | pull-requests: write 100 | id-token: write 101 | needs: 102 | - coverage 103 | - tests 104 | - license-checker 105 | if: ${{ github.repository != 'sebbo2002/js-template' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop') }} 106 | steps: 107 | - name: ☁️ Checkout Project 108 | uses: actions/checkout@v5 109 | - name: 🔧 Set up Python 110 | uses: actions/setup-python@v6 111 | with: 112 | python-version: 3.11 113 | - name: 🔧 Setup pip cache 114 | uses: actions/cache@v4 115 | id: pip-cache 116 | with: 117 | path: ~/.cache/pip 118 | key: pip 119 | - name: 📦 Install pyatv 120 | run: pip3 install --upgrade git+https://github.com/postlund/pyatv.git 121 | - name: 🔧 Setup node.js 122 | uses: actions/setup-node@v6 123 | with: 124 | node-version: 22.x 125 | cache: 'npm' 126 | - name: 📦 Install dependencies 127 | run: npm ci 128 | - name: 📂 Create docs folder 129 | run: mkdir ./docs 130 | - name: 🪄 Run semantic-release 131 | run: BRANCH=${GITHUB_REF#refs/heads/} npx semantic-release 132 | env: 133 | GH_REPO: ${{ github.repository }} 134 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 135 | GH_OWNER: ${{ github.repository_owner }} 136 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 137 | NPM_CONFIG_PROVENANCE: true 138 | - name: 🔃 Merge main back into develop 139 | if: ${{ github.ref == 'refs/heads/main' }} 140 | uses: everlytic/branch-merge@1.1.5 141 | with: 142 | github_token: ${{ secrets.GH_TOKEN }} 143 | source_ref: 'main' 144 | target_branch: 'develop' 145 | commit_message_template: 'Merge branch {source_ref} into {target_branch} [skip ci]' 146 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.nyc_output 2 | /dist 3 | /docs 4 | /node_modules 5 | /mochawesome-report 6 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | eslint $(git diff --cached --name-only --diff-filter=ACMR | sed 's| |\\ |g') --fix 2 | prettier $(git diff --cached --name-only --diff-filter=ACMR | sed 's| |\\ |g') --write --ignore-unknown 3 | git update-index --again 4 | -------------------------------------------------------------------------------- /.idea/codeStyleSettings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 22 | 24 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 21 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/encodings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 68 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | -------------------------------------------------------------------------------- /.idea/jsLibraryMappings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /.idea/jsLinters/eslint.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/jsLinters/jshint.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 85 | -------------------------------------------------------------------------------- /.idea/jsLinters/jslint.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/node-pyatv.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /.idea/scopes/scope_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/watcherTasks.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.idea/webResources.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.mocharc.yml: -------------------------------------------------------------------------------- 1 | extension: 2 | - ts 3 | full-trace: true 4 | node-option: 5 | - import=tsx 6 | recursive: true 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /.dockerignore 2 | /Dockerfile 3 | /node_modules/ 4 | /dist/test/ 5 | /release.config.js 6 | /.eslintrc.json 7 | /doc 8 | /.idea/ 9 | /.github 10 | /.nyc_output 11 | /test-result 12 | -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "cache": false, 3 | "check-coverage": true, 4 | "extension": [".ts"], 5 | "include": ["src/lib/*.ts"], 6 | "exclude": [ 7 | "coverage/**", 8 | "node_modules/**", 9 | "src/examples/**", 10 | "src/bin/check.ts" 11 | ], 12 | "report-dir": "./docs/coverage/", 13 | "temp-directory": "./.nyc_output", 14 | "sourceMap": true, 15 | "reporter": ["text", "text-summary", "cobertura", "html"], 16 | "all": true, 17 | "instrument": true, 18 | "branches": 80, 19 | "lines": 92, 20 | "functions": 85, 21 | "statements": 92, 22 | "per-file": true 23 | } 24 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /.github 2 | /.nyc_output 3 | /dist 4 | /docs 5 | /node_modules 6 | /mochawesome-report 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": true 4 | } 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [9.0.2](https://github.com/sebbo2002/node-pyatv/compare/v9.0.1...v9.0.2) (2025-10-28) 2 | 3 | ## [9.0.1](https://github.com/sebbo2002/node-pyatv/compare/v9.0.0...v9.0.1) (2025-06-18) 4 | 5 | # [9.0.0](https://github.com/sebbo2002/node-pyatv/compare/v8.1.2...v9.0.0) (2025-05-12) 6 | 7 | ### chore 8 | 9 | - Drop node v18 support ([3e18405](https://github.com/sebbo2002/node-pyatv/commit/3e18405ac1e1be738a414623f97169c802567f99)) 10 | 11 | ### Features 12 | 13 | - Connect via MAC address instead of host ([a9e6614](https://github.com/sebbo2002/node-pyatv/commit/a9e66148aa1d7805745bed3b3fef2e3b15fcb1c5)), closes [#353](https://github.com/sebbo2002/node-pyatv/issues/353) 14 | 15 | ### BREAKING CHANGES 16 | 17 | - Drop node.js v18 Support 18 | 19 | This node.js version is no longer supported. For more information see https://nodejs.dev/en/about/releases/ 20 | 21 | - This change means that `NodePyATVDevice.host()` no longer necessarily returns a string, but possibly `undefined`. The signature of `toJSON()` has also been adjusted accordingly. 22 | 23 | ## [8.1.2](https://github.com/sebbo2002/node-pyatv/compare/v8.1.1...v8.1.2) (2025-02-07) 24 | 25 | ## [8.1.1](https://github.com/sebbo2002/node-pyatv/compare/v8.1.0...v8.1.1) (2025-01-09) 26 | 27 | ### Bug Fixes 28 | 29 | - **deps:** downgrade eslint to v9.13.0 to resolve typescript-eslint issue ([e6f5a09](https://github.com/sebbo2002/node-pyatv/commit/e6f5a09df8a65caf496d75d6ff2574ac164c46d5)), closes [#10353](https://github.com/sebbo2002/node-pyatv/issues/10353) [typescript-eslint/typescript-eslint#10353](https://github.com/typescript-eslint/typescript-eslint/issues/10353) 30 | 31 | # [8.1.0](https://github.com/sebbo2002/node-pyatv/compare/v8.0.0...v8.1.0) (2024-11-15) 32 | 33 | ### Bug Fixes 34 | 35 | - Support for `seriesName` fixed ([dc5bf58](https://github.com/sebbo2002/node-pyatv/commit/dc5bf5873eb31b9703517221e7d2b8c30709c3a5)) 36 | 37 | ### Features 38 | 39 | - Add iTunes Store Identifier ([73e809e](https://github.com/sebbo2002/node-pyatv/commit/73e809ec6582c8197997357201fae70ce5884f26)) 40 | - Add Support for `contentIdentifier`, `episodeNumber`, `seasonNumber` and `seasonName` ([fdd935d](https://github.com/sebbo2002/node-pyatv/commit/fdd935d007a74d19f05e9d868b95eeea1916fdda)), closes [#339](https://github.com/sebbo2002/node-pyatv/issues/339) 41 | - **Device:** Add missing state getters ([e80c4f7](https://github.com/sebbo2002/node-pyatv/commit/e80c4f7b99f7ddbcd717ffc12a2b5a0b86092249)) 42 | 43 | # [8.0.0](https://github.com/sebbo2002/node-pyatv/compare/v7.4.0...v8.0.0) (2024-08-26) 44 | 45 | ### chore 46 | 47 | - Drop support for node.js v19 and v21 ([2fff079](https://github.com/sebbo2002/node-pyatv/commit/2fff079040a377fbe9ecc340388f6a29b863cf80)) 48 | 49 | ### BREAKING CHANGES 50 | 51 | - Drop node.js v21 Support 52 | 53 | These node.js versions are no longer supported. For more information see https://nodejs.dev/en/about/releases/ 54 | 55 | # [7.4.0](https://github.com/sebbo2002/node-pyatv/compare/v7.3.0...v7.4.0) (2024-08-04) 56 | 57 | ### Bug Fixes 58 | 59 | - Do not trigger "special" events when they are not received ([c63dfe6](https://github.com/sebbo2002/node-pyatv/commit/c63dfe6a55c71f92905f1b881e520c30c028c5f1)), closes [#326](https://github.com/sebbo2002/node-pyatv/issues/326) 60 | 61 | ### Features 62 | 63 | - Export `NodePyATVFindResponseObject` and `NodePyATVRequestOptions` ([c668148](https://github.com/sebbo2002/node-pyatv/commit/c668148b823fe945a3f53e379fffd4b7cf2079ca)), closes [#324](https://github.com/sebbo2002/node-pyatv/issues/324) 64 | - Support unicast scans ([2b25f7e](https://github.com/sebbo2002/node-pyatv/commit/2b25f7ef9686c7d1d424ffd5acb9f29c3b6eac9d)), closes [#324](https://github.com/sebbo2002/node-pyatv/issues/324) 65 | 66 | # [7.3.0](https://github.com/sebbo2002/node-pyatv/compare/v7.2.1...v7.3.0) (2024-01-30) 67 | 68 | ### Bug Fixes 69 | 70 | - **core:** fix typo getParameters ([e28b86b](https://github.com/sebbo2002/node-pyatv/commit/e28b86b12be91ccd032336a1dd5099150339c6cb)) 71 | - **core:** fix unintentional event trigger ([c4bd9a5](https://github.com/sebbo2002/node-pyatv/commit/c4bd9a5b8fdd98a23698e3810cb425783ad51f77)) 72 | - Do not reset state to initial default state on unsupported messages ([876c9b4](https://github.com/sebbo2002/node-pyatv/commit/876c9b4c87724ebb7333c288786025d7fe7fca80)), closes [#295](https://github.com/sebbo2002/node-pyatv/issues/295) 73 | - **Types:** Fix `NodePyATVFocusState` typo ([cc96a83](https://github.com/sebbo2002/node-pyatv/commit/cc96a837bbe9a9aa1aaf839ece31cf2febdb89b2)) 74 | 75 | ### Features 76 | 77 | - Add `outputDevices` Support ([cf194fd](https://github.com/sebbo2002/node-pyatv/commit/cf194fda9216f2790ca24e4622263e60d37964a6)), closes [#295](https://github.com/sebbo2002/node-pyatv/issues/295) 78 | 79 | ## [7.2.1](https://github.com/sebbo2002/node-pyatv/compare/v7.2.0...v7.2.1) (2023-12-29) 80 | 81 | ### Bug Fixes 82 | 83 | - **DeviceEvents:** Handle power_state/focus_state/volume events properly ([48010f3](https://github.com/sebbo2002/node-pyatv/commit/48010f397c7f361a1e5ee8de25941a71db739e2e)), closes [/github.com/sebbo2002/pyatv-mqtt-bridge/issues/285#issuecomment-1797978006](https://github.com//github.com/sebbo2002/pyatv-mqtt-bridge/issues/285/issues/issuecomment-1797978006) [#291](https://github.com/sebbo2002/node-pyatv/issues/291) 84 | 85 | # [7.2.0](https://github.com/sebbo2002/node-pyatv/compare/v7.1.0...v7.2.0) (2023-12-26) 86 | 87 | ### Features 88 | 89 | - **core:** add allIDs attribute to device ([02a9c05](https://github.com/sebbo2002/node-pyatv/commit/02a9c055bdb6589a1947ff41204497a92dd87c89)), closes [postlund/pyatv#2282](https://github.com/postlund/pyatv/issues/2282) 90 | - **core:** add mac attribute to device ([5b0f58c](https://github.com/sebbo2002/node-pyatv/commit/5b0f58ce1f18871fe482da699fbe8bb1d5508af5)), closes [postlund/pyatv#2282](https://github.com/postlund/pyatv/issues/2282) 91 | 92 | # [7.1.0](https://github.com/sebbo2002/node-pyatv/compare/v7.0.3...v7.1.0) (2023-11-23) 93 | 94 | ### Features 95 | 96 | - Log pyatv output of push_updates ([fc2618c](https://github.com/sebbo2002/node-pyatv/commit/fc2618c52e6b4de4202db8a77c72130ee114385d)) 97 | 98 | ## [7.0.4-develop.2](https://github.com/sebbo2002/node-pyatv/compare/v7.0.4-develop.1...v7.0.4-develop.2) (2023-10-18) 99 | 100 | ### Reverts 101 | 102 | - Revert "ci: Run tests with node.js v18, v20 and v21" ([1b245a5](https://github.com/sebbo2002/node-pyatv/commit/1b245a58587bc6871e8b1633beff1f1bca05970f)) 103 | 104 | ## [7.0.4-develop.1](https://github.com/sebbo2002/node-pyatv/compare/v7.0.3...v7.0.4-develop.1) (2023-10-17) 105 | 106 | ## [7.0.3](https://github.com/sebbo2002/node-pyatv/compare/v7.0.2...v7.0.3) (2023-09-18) 107 | 108 | ### Reverts 109 | 110 | - Revert "ci: Downgrade is-semantic-release till it's fixed" ([91c2ab5](https://github.com/sebbo2002/node-pyatv/commit/91c2ab59d0559a060c11d07973382c465dd3345d)) 111 | 112 | ## [7.0.3-develop.2](https://github.com/sebbo2002/node-pyatv/compare/v7.0.3-develop.1...v7.0.3-develop.2) (2023-09-01) 113 | 114 | ## [7.0.3-develop.1](https://github.com/sebbo2002/node-pyatv/compare/v7.0.2...v7.0.3-develop.1) (2023-08-24) 115 | 116 | ### Reverts 117 | 118 | - Revert "ci: Downgrade is-semantic-release till it's fixed" ([91c2ab5](https://github.com/sebbo2002/node-pyatv/commit/91c2ab59d0559a060c11d07973382c465dd3345d)) 119 | 120 | ## [7.0.2](https://github.com/sebbo2002/node-pyatv/compare/v7.0.1...v7.0.2) (2023-08-17) 121 | 122 | ## [7.0.2-develop.2](https://github.com/sebbo2002/node-pyatv/compare/v7.0.2-develop.1...v7.0.2-develop.2) (2023-08-17) 123 | 124 | ## [7.0.2-develop.1](https://github.com/sebbo2002/node-pyatv/compare/v7.0.1...v7.0.2-develop.1) (2023-07-14) 125 | 126 | ## [7.0.1](https://github.com/sebbo2002/node-pyatv/compare/v7.0.0...v7.0.1) (2023-07-11) 127 | 128 | ## [7.0.1-develop.1](https://github.com/sebbo2002/node-pyatv/compare/v7.0.0...v7.0.1-develop.1) (2023-06-22) 129 | 130 | # [7.0.0](https://github.com/sebbo2002/node-pyatv/compare/v6.0.3...v7.0.0) (2023-06-14) 131 | 132 | ### Build System 133 | 134 | - Deprecate node.js v14 / v17 ([7a2de45](https://github.com/sebbo2002/node-pyatv/commit/7a2de45c12f19a1ec441b3a004f4aa935efc197c)) 135 | 136 | ### Features 137 | 138 | - Support focus state and volume events ([9fb10c6](https://github.com/sebbo2002/node-pyatv/commit/9fb10c641ee7384f1165b98cbe66997688d34835)) 139 | 140 | ### BREAKING CHANGES 141 | 142 | - The node.js versions v14 and v17 are no longer maintained and are therefore no longer supported. See https://nodejs.dev/en/about/releases/ for more details on node.js release cycles. 143 | 144 | # [7.0.0-develop.2](https://github.com/sebbo2002/node-pyatv/compare/v7.0.0-develop.1...v7.0.0-develop.2) (2023-06-14) 145 | 146 | ### Features 147 | 148 | - Support focus state and volume events ([9fb10c6](https://github.com/sebbo2002/node-pyatv/commit/9fb10c641ee7384f1165b98cbe66997688d34835)) 149 | 150 | # [7.0.0-develop.1](https://github.com/sebbo2002/node-pyatv/compare/v6.0.3...v7.0.0-develop.1) (2023-06-14) 151 | 152 | ### Build System 153 | 154 | - Deprecate node.js v14 / v17 ([7a2de45](https://github.com/sebbo2002/node-pyatv/commit/7a2de45c12f19a1ec441b3a004f4aa935efc197c)) 155 | 156 | ### BREAKING CHANGES 157 | 158 | - The node.js versions v14 and v17 are no longer maintained and are therefore no longer supported. See https://nodejs.dev/en/about/releases/ for more details on node.js release cycles. 159 | 160 | ## [6.0.3](https://github.com/sebbo2002/node-pyatv/compare/v6.0.2...v6.0.3) (2023-05-19) 161 | 162 | ## [6.0.3-develop.2](https://github.com/sebbo2002/node-pyatv/compare/v6.0.3-develop.1...v6.0.3-develop.2) (2023-05-19) 163 | 164 | ## [6.0.3-develop.1](https://github.com/sebbo2002/node-pyatv/compare/v6.0.2...v6.0.3-develop.1) (2023-05-02) 165 | 166 | ## [6.0.2](https://github.com/sebbo2002/node-pyatv/compare/v6.0.1...v6.0.2) (2023-04-20) 167 | 168 | ## [6.0.2-develop.1](https://github.com/sebbo2002/node-pyatv/compare/v6.0.1...v6.0.2-develop.1) (2023-04-18) 169 | 170 | ## [6.0.1](https://github.com/sebbo2002/node-pyatv/compare/v6.0.0...v6.0.1) (2023-03-31) 171 | 172 | ### Bug Fixes 173 | 174 | - Add `check.sh` to npm module ([ac7b04e](https://github.com/sebbo2002/node-pyatv/commit/ac7b04e1c850952eae0eac0ad2f60f39b91b68da)) 175 | 176 | # [6.0.0](https://github.com/sebbo2002/node-pyatv/compare/v5.1.1...v6.0.0) (2023-03-31) 177 | 178 | ### Build System 179 | 180 | - Deprecate node.js 12 ([426588b](https://github.com/sebbo2002/node-pyatv/commit/426588b4bb7bde2924bbc92006ca839e960872e1)) 181 | 182 | ### Features 183 | 184 | - List and launch AppleTV Apps ([680d1de](https://github.com/sebbo2002/node-pyatv/commit/680d1deb83882a87b246e621cbce6237c9741825)) 185 | 186 | ### BREAKING CHANGES 187 | 188 | - From now on, only node.js ^14.8.0 || >=16.0.0 are supported 189 | 190 | # [6.0.0-develop.3](https://github.com/sebbo2002/node-pyatv/compare/v6.0.0-develop.2...v6.0.0-develop.3) (2023-03-30) 191 | 192 | # [6.0.0-develop.2](https://github.com/sebbo2002/node-pyatv/compare/v6.0.0-develop.1...v6.0.0-develop.2) (2023-03-21) 193 | 194 | ### Features 195 | 196 | - List and launch AppleTV Apps ([680d1de](https://github.com/sebbo2002/node-pyatv/commit/680d1deb83882a87b246e621cbce6237c9741825)) 197 | 198 | # [6.0.0-develop.1](https://github.com/sebbo2002/node-pyatv/compare/v5.1.2-develop.3...v6.0.0-develop.1) (2023-02-12) 199 | 200 | ### Build System 201 | 202 | - Deprecate node.js 12 ([426588b](https://github.com/sebbo2002/node-pyatv/commit/426588b4bb7bde2924bbc92006ca839e960872e1)) 203 | 204 | ### BREAKING CHANGES 205 | 206 | - From now on, only node.js ^14.8.0 || >=16.0.0 are supported 207 | 208 | ## [5.1.2-develop.3](https://github.com/sebbo2002/node-pyatv/compare/v5.1.2-develop.2...v5.1.2-develop.3) (2022-12-07) 209 | 210 | ## [5.1.2-develop.2](https://github.com/sebbo2002/node-pyatv/compare/v5.1.2-develop.1...v5.1.2-develop.2) (2022-12-04) 211 | 212 | ## [5.1.2-develop.1](https://github.com/sebbo2002/node-pyatv/compare/v5.1.1...v5.1.2-develop.1) (2022-12-04) 213 | 214 | ## [5.1.1](https://github.com/sebbo2002/node-pyatv/compare/v5.1.0...v5.1.1) (2022-10-18) 215 | 216 | ## [5.1.1-develop.2](https://github.com/sebbo2002/node-pyatv/compare/v5.1.1-develop.1...v5.1.1-develop.2) (2022-10-11) 217 | 218 | ## [5.1.1-develop.1](https://github.com/sebbo2002/node-pyatv/compare/v5.1.0...v5.1.1-develop.1) (2022-09-12) 219 | 220 | # [5.1.0](https://github.com/sebbo2002/node-pyatv/compare/v5.0.0...v5.1.0) (2022-08-11) 221 | 222 | ### Features 223 | 224 | - Add Support for `device_info` and `device.services` ([1f60980](https://github.com/sebbo2002/node-pyatv/commit/1f6098006ec76a018e385f44c8567e465eedbbce)) 225 | 226 | # [5.1.0-develop.1](https://github.com/sebbo2002/node-pyatv/compare/v5.0.0...v5.1.0-develop.1) (2022-08-04) 227 | 228 | ### Features 229 | 230 | - Add Support for `device_info` and `device.services` ([1f60980](https://github.com/sebbo2002/node-pyatv/commit/1f6098006ec76a018e385f44c8567e465eedbbce)) 231 | 232 | # [5.0.0](https://github.com/sebbo2002/node-pyatv/compare/v4.3.3...v5.0.0) (2022-07-25) 233 | 234 | ### Build System 235 | 236 | - Native ESM support ([7b86a4f](https://github.com/sebbo2002/node-pyatv/commit/7b86a4f1187c387a3a5792e1fb72d822b04e3631)) 237 | 238 | ### BREAKING CHANGES 239 | 240 | - Only Support for node.js ^12.20.0 || >=14.13.1 241 | 242 | # [5.0.0-develop.1](https://github.com/sebbo2002/node-pyatv/compare/v4.3.4-develop.4...v5.0.0-develop.1) (2022-07-17) 243 | 244 | ### Build System 245 | 246 | - Native ESM support ([7b86a4f](https://github.com/sebbo2002/node-pyatv/commit/7b86a4f1187c387a3a5792e1fb72d822b04e3631)) 247 | 248 | ### BREAKING CHANGES 249 | 250 | - Only Support for node.js ^12.20.0 || >=14.13.1 251 | 252 | ## [4.3.4-develop.4](https://github.com/sebbo2002/node-pyatv/compare/v4.3.4-develop.3...v4.3.4-develop.4) (2022-06-10) 253 | 254 | ## [4.3.4-develop.3](https://github.com/sebbo2002/node-pyatv/compare/v4.3.4-develop.2...v4.3.4-develop.3) (2022-06-09) 255 | 256 | ## [4.3.4-develop.2](https://github.com/sebbo2002/node-pyatv/compare/v4.3.4-develop.1...v4.3.4-develop.2) (2022-06-05) 257 | 258 | ## [4.3.4-develop.1](https://github.com/sebbo2002/node-pyatv/compare/v4.3.3...v4.3.4-develop.1) (2022-06-03) 259 | 260 | ## [4.3.3](https://github.com/sebbo2002/node-pyatv/compare/v4.3.2...v4.3.3) (2022-05-14) 261 | 262 | ## [4.3.3-develop.2](https://github.com/sebbo2002/node-pyatv/compare/v4.3.3-develop.1...v4.3.3-develop.2) (2022-05-14) 263 | 264 | ## [4.3.3-develop.1](https://github.com/sebbo2002/node-pyatv/compare/v4.3.2...v4.3.3-develop.1) (2022-05-01) 265 | 266 | ## [4.3.2](https://github.com/sebbo2002/node-pyatv/compare/v4.3.1...v4.3.2) (2022-04-28) 267 | 268 | ## [4.3.2-develop.1](https://github.com/sebbo2002/node-pyatv/compare/v4.3.1...v4.3.2-develop.1) (2022-04-13) 269 | 270 | ## [4.3.1](https://github.com/sebbo2002/node-pyatv/compare/v4.3.0...v4.3.1) (2022-03-31) 271 | 272 | ## [4.3.1-develop.1](https://github.com/sebbo2002/node-pyatv/compare/v4.3.0...v4.3.1-develop.1) (2022-03-31) 273 | 274 | # [4.3.0](https://github.com/sebbo2002/node-pyatv/compare/v4.2.1...v4.3.0) (2022-01-24) 275 | 276 | ### Bug Fixes 277 | 278 | - Add `turnOn` and `turnOff` to NodePyATVKeys ([7497591](https://github.com/sebbo2002/node-pyatv/commit/7497591f0254687384004f04399b7a29eeba46d9)) 279 | - Handle turnOn/turnOff in pressKey() correctly ([7e6eefa](https://github.com/sebbo2002/node-pyatv/commit/7e6eefa8df87d73c4129bdc6d24148fe8ea9976d)) 280 | 281 | ### Features 282 | 283 | - Add `turnOn()` and `turnOff()` commands ([82e52d4](https://github.com/sebbo2002/node-pyatv/commit/82e52d417b191dc5001d0f332140b6746578062f)) 284 | 285 | # [4.3.0-develop.3](https://github.com/sebbo2002/node-pyatv/compare/v4.3.0-develop.2...v4.3.0-develop.3) (2022-01-21) 286 | 287 | ### Bug Fixes 288 | 289 | - Handle turnOn/turnOff in pressKey() correctly ([7e6eefa](https://github.com/sebbo2002/node-pyatv/commit/7e6eefa8df87d73c4129bdc6d24148fe8ea9976d)) 290 | 291 | # [4.3.0-develop.2](https://github.com/sebbo2002/node-pyatv/compare/v4.3.0-develop.1...v4.3.0-develop.2) (2022-01-21) 292 | 293 | ### Bug Fixes 294 | 295 | - Add `turnOn` and `turnOff` to NodePyATVKeys ([7497591](https://github.com/sebbo2002/node-pyatv/commit/7497591f0254687384004f04399b7a29eeba46d9)) 296 | 297 | # [4.3.0-develop.1](https://github.com/sebbo2002/node-pyatv/compare/v4.2.1...v4.3.0-develop.1) (2022-01-21) 298 | 299 | ### Features 300 | 301 | - Add `turnOn()` and `turnOff()` commands ([82e52d4](https://github.com/sebbo2002/node-pyatv/commit/82e52d417b191dc5001d0f332140b6746578062f)) 302 | 303 | ## [4.2.1](https://github.com/sebbo2002/node-pyatv/compare/v4.2.0...v4.2.1) (2021-12-14) 304 | 305 | ### Bug Fixes 306 | 307 | - **CI:** Fix DockerHub container release ([01b7534](https://github.com/sebbo2002/node-pyatv/commit/01b753406d1f1ef24a949c7d7b946d99b779d013)) 308 | 309 | ## [4.2.1-develop.1](https://github.com/sebbo2002/node-pyatv/compare/v4.2.0...v4.2.1-develop.1) (2021-12-06) 310 | 311 | ### Bug Fixes 312 | 313 | - **CI:** Fix DockerHub container release ([01b7534](https://github.com/sebbo2002/node-pyatv/commit/01b753406d1f1ef24a949c7d7b946d99b779d013)) 314 | 315 | # [4.2.0](https://github.com/sebbo2002/node-pyatv/compare/v4.1.0...v4.2.0) (2021-11-29) 316 | 317 | ### Features 318 | 319 | - Add previously inaccessible types and classes to exports ([9443c02](https://github.com/sebbo2002/node-pyatv/commit/9443c02aad02406d465b9ead91a6e88d8f171c44)) 320 | - Handle pyatv exceptions for `getState()` and event listeners ([a695e11](https://github.com/sebbo2002/node-pyatv/commit/a695e113258d53dcc6aef5f06e6815b9045aa502)), closes [#105](https://github.com/sebbo2002/node-pyatv/issues/105) 321 | 322 | # [4.2.0-develop.2](https://github.com/sebbo2002/node-pyatv/compare/v4.2.0-develop.1...v4.2.0-develop.2) (2021-11-29) 323 | 324 | ### Features 325 | 326 | - Add previously inaccessible types and classes to exports ([9443c02](https://github.com/sebbo2002/node-pyatv/commit/9443c02aad02406d465b9ead91a6e88d8f171c44)) 327 | 328 | # [4.2.0-develop.1](https://github.com/sebbo2002/node-pyatv/compare/v4.1.1-develop.1...v4.2.0-develop.1) (2021-11-29) 329 | 330 | ### Features 331 | 332 | - Handle pyatv exceptions for `getState()` and event listeners ([a695e11](https://github.com/sebbo2002/node-pyatv/commit/a695e113258d53dcc6aef5f06e6815b9045aa502)), closes [#105](https://github.com/sebbo2002/node-pyatv/issues/105) 333 | 334 | ## [4.1.1-develop.1](https://github.com/sebbo2002/node-pyatv/compare/v4.1.0...v4.1.1-develop.1) (2021-10-22) 335 | 336 | # [4.1.0](https://github.com/sebbo2002/node-pyatv/compare/v4.0.0...v4.1.0) (2021-10-09) 337 | 338 | ### Features 339 | 340 | - Add support for companion and raop credentials ([6c68d6b](https://github.com/sebbo2002/node-pyatv/commit/6c68d6b0741785eab8619a4c84b8c779a001b987)) 341 | 342 | # [4.0.0-develop.4](https://github.com/sebbo2002/node-pyatv/compare/v4.0.0-develop.3...v4.0.0-develop.4) (2021-10-09) 343 | 344 | ### Features 345 | 346 | - Add support for companion and raop credentials ([6c68d6b](https://github.com/sebbo2002/node-pyatv/commit/6c68d6b0741785eab8619a4c84b8c779a001b987)) 347 | 348 | # [4.0.0-develop.3](https://github.com/sebbo2002/node-pyatv/compare/v4.0.0-develop.2...v4.0.0-develop.3) (2021-06-18) 349 | 350 | # [4.0.0-develop.2](https://github.com/sebbo2002/node-pyatv/compare/v4.0.0-develop.1...v4.0.0-develop.2) (2021-06-08) 351 | 352 | # [4.0.0-develop.1](https://github.com/sebbo2002/node-pyatv/compare/v3.1.1-develop.1...v4.0.0-develop.1) (2021-06-08) 353 | 354 | ### chore 355 | 356 | - Remove node.js 10 Support ([2b910c0](https://github.com/sebbo2002/node-pyatv/commit/2b910c09bc8a41085fc4472159494d8738d5521e)) 357 | 358 | ### BREAKING CHANGES 359 | 360 | - Removed support for node.js v10 361 | 362 | ## [3.1.1-develop.1](https://github.com/sebbo2002/node-pyatv/compare/v3.1.0...v3.1.1-develop.1) (2021-05-13) 363 | 364 | # [3.1.0](https://github.com/sebbo2002/node-pyatv/compare/v3.0.1...v3.1.0) (2021-05-13) 365 | 366 | ### Features 367 | 368 | - Use repo template (@sebbo2002/js-template) ([696a474](https://github.com/sebbo2002/node-pyatv/commit/696a4741fc36ed3976ec2ac41e422e3763aeeda8)) 369 | 370 | # [3.1.0-develop.1](https://github.com/sebbo2002/node-pyatv/compare/v3.0.1...v3.1.0-develop.1) (2021-05-13) 371 | 372 | ### Features 373 | 374 | - Use repo template (@sebbo2002/js-template) ([696a474](https://github.com/sebbo2002/node-pyatv/commit/696a4741fc36ed3976ec2ac41e422e3763aeeda8)) 375 | 376 | ## [3.0.1](https://github.com/sebbo2002/node-pyatv/compare/v3.0.0...v3.0.1) (2020-12-15) 377 | 378 | # [3.0.0](https://github.com/sebbo2002/node-pyatv/compare/v2.0.0...v3.0.0) (2020-11-29) 379 | 380 | ### Bug Fixes 381 | 382 | - **CI:** Fix build environment variable ([da77a9c](https://github.com/sebbo2002/node-pyatv/commit/da77a9c2de96303cf2a14409469e6f954208bc37)) 383 | - **CI:** Fix build.sh ([16088cf](https://github.com/sebbo2002/node-pyatv/commit/16088cf88dbb1d674323d32647e4db37da224059)) 384 | - **CI:** Fix docs branch filter for release ([71b6879](https://github.com/sebbo2002/node-pyatv/commit/71b68797b89e411470bb5f3afdbb30d8a46e423c)) 385 | - **CI:** Fix gh-pages branch name ([f2423ca](https://github.com/sebbo2002/node-pyatv/commit/f2423cad8b384f1d1b51d7ec13b2f01253238d10)) 386 | - **CI:** Fix release process ([5249ce4](https://github.com/sebbo2002/node-pyatv/commit/5249ce49590cb732c4e729574e365534abb98fea)) 387 | - **CI:** multibranch gh-pages ([4f3cb0e](https://github.com/sebbo2002/node-pyatv/commit/4f3cb0e290b1fdb51dcd2cf6d60ff5dd3d8875b2)) 388 | - **CI:** multibranch gh-pages ([737c216](https://github.com/sebbo2002/node-pyatv/commit/737c216c1815f10fbb8f5c6b53210afa48131af1)) 389 | - **CI:** multibranch gh-pages ([b82302a](https://github.com/sebbo2002/node-pyatv/commit/b82302a02d8b68678528cde5d437d2f7ced0f2c4)) 390 | - postinstall file check ([39777b3](https://github.com/sebbo2002/node-pyatv/commit/39777b3d2ad0b19936ba5cd6b3d93111b667c9dd)) 391 | - **module:** remove prepare step, use semantic release exec ([54f6e28](https://github.com/sebbo2002/node-pyatv/commit/54f6e2890e9f3906631efdf810b692f0216093ed)) 392 | 393 | ### Continuous Integration 394 | 395 | - **release:** fix NPM_Token for deprecation ([5c988b9](https://github.com/sebbo2002/node-pyatv/commit/5c988b99ef3d254c8aa729b41a6d0321054e4f90)) 396 | 397 | ### Features 398 | 399 | - **Events:** Add powerState events ([0890009](https://github.com/sebbo2002/node-pyatv/commit/0890009737be4c6dae1212336a643a919643eb8e)) 400 | - Add semantic release ([2ff70d6](https://github.com/sebbo2002/node-pyatv/commit/2ff70d6375ee40f6f939b9a9997fcf9922d21480)) 401 | - DeviceEvents ([e28fbf9](https://github.com/sebbo2002/node-pyatv/commit/e28fbf960f1ff58e245cfd0a0680cf9e5056c5e6)) 402 | - Get Device State ([c2cfa60](https://github.com/sebbo2002/node-pyatv/commit/c2cfa60ad09f9b2165f9d2193ce257f01551bde1)) 403 | - Instance, Device & Tools ([95c2b77](https://github.com/sebbo2002/node-pyatv/commit/95c2b77070136402a3166a849a8ef8b580ad2935)) 404 | 405 | ### BREAKING CHANGES 406 | 407 | - **release:** as the merge wasn't marked as one successfully… 408 | 409 | # [2.0.0-develop.5](https://github.com/sebbo2002/node-pyatv/compare/v2.0.0-develop.4...v2.0.0-develop.5) (2020-11-15) 410 | 411 | ### Bug Fixes 412 | 413 | - **CI:** Fix docs branch filter for release ([71b6879](https://github.com/sebbo2002/node-pyatv/commit/71b68797b89e411470bb5f3afdbb30d8a46e423c)) 414 | 415 | # [2.0.0-develop.4](https://github.com/sebbo2002/node-pyatv/compare/v2.0.0-develop.3...v2.0.0-develop.4) (2020-11-15) 416 | 417 | ### Bug Fixes 418 | 419 | - **CI:** Fix gh-pages branch name ([f2423ca](https://github.com/sebbo2002/node-pyatv/commit/f2423cad8b384f1d1b51d7ec13b2f01253238d10)) 420 | 421 | # [2.0.0-develop.3](https://github.com/sebbo2002/node-pyatv/compare/v2.0.0-develop.2...v2.0.0-develop.3) (2020-11-15) 422 | 423 | ### Bug Fixes 424 | 425 | - **CI:** Fix release process ([5249ce4](https://github.com/sebbo2002/node-pyatv/commit/5249ce49590cb732c4e729574e365534abb98fea)) 426 | 427 | # [2.0.0-develop.2](https://github.com/sebbo2002/node-pyatv/compare/v2.0.0-develop.1...v2.0.0-develop.2) (2020-11-15) 428 | 429 | # [2.0.0-develop.1](https://github.com/sebbo2002/node-pyatv/compare/v1.1.0-develop.1...v2.0.0-develop.1) (2020-11-13) 430 | 431 | ### Continuous Integration 432 | 433 | - **release:** fix NPM_Token for deprecation ([5c988b9](https://github.com/sebbo2002/node-pyatv/commit/5c988b99ef3d254c8aa729b41a6d0321054e4f90)) 434 | 435 | ### BREAKING CHANGES 436 | 437 | - **release:** as the merge wasn't marked as one successfully… 438 | 439 | # [1.1.0-develop.1](https://github.com/sebbo2002/node-pyatv/compare/v1.0.0...v1.1.0-develop.1) (2020-11-13) 440 | 441 | ### Bug Fixes 442 | 443 | - postinstall file check ([39777b3](https://github.com/sebbo2002/node-pyatv/commit/39777b3d2ad0b19936ba5cd6b3d93111b667c9dd)) 444 | 445 | ### Features 446 | 447 | - Add semantic release ([2ff70d6](https://github.com/sebbo2002/node-pyatv/commit/2ff70d6375ee40f6f939b9a9997fcf9922d21480)) 448 | - DeviceEvents ([e28fbf9](https://github.com/sebbo2002/node-pyatv/commit/e28fbf960f1ff58e245cfd0a0680cf9e5056c5e6)) 449 | - Get Device State ([c2cfa60](https://github.com/sebbo2002/node-pyatv/commit/c2cfa60ad09f9b2165f9d2193ce257f01551bde1)) 450 | - Instance, Device & Tools ([95c2b77](https://github.com/sebbo2002/node-pyatv/commit/95c2b77070136402a3166a849a8ef8b580ad2935)) 451 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:latest 2 | 3 | ARG PYATV_VERSION=0.16.0 4 | ARG NODE_VERSION=22 5 | 6 | # ensure local python is preferred over distribution python 7 | ENV PATH=/usr/local/bin:$PATH 8 | 9 | RUN apt-get update && \ 10 | apt-get install -y build-essential libssl-dev libffi-dev python3-dev pipx curl sudo && \ 11 | pipx install pyatv~=${PYATV_VERSION} && \ 12 | curl -sL https://deb.nodesource.com/setup_${NODE_VERSION}.x | sudo -E bash - && \ 13 | sudo apt-get install -y nodejs 14 | 15 | COPY package*.json "/app/" 16 | COPY check.sh "/app/" 17 | WORKDIR "/app" 18 | RUN npm ci 19 | 20 | COPY . "/app/" 21 | CMD ["npm", "run", "test"] 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Sebastian Pekarek 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 6 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 7 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit 8 | persons to whom the Software is furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 11 | Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 14 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 15 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 16 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # node-pyatv 2 | 3 | [![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](LICENSE) 4 | [![Unit Tests](https://img.shields.io/github/actions/workflow/status/sebbo2002/node-pyatv/test-release.yml?style=flat-square)](https://github.com/sebbo2002/node-pyatv/actions?query=workflow%3ARelease+branch%3Amain) 5 | 6 | A lightweight wrapper around pyatv's which also supports realtime notifications. 7 | 8 | ## 📝 Content 9 | 10 | - [Installation](#-installation) 11 | - [Quick Start](#-quick-start) 12 | - [API Reference](#-api-reference) 13 | - [Changelog](https://github.com/sebbo2002/node-pyatv/blob/main/CHANGELOG.md) 14 | - [FAQ](#-faq) 15 | 16 | ## ☁ Installation 17 | 18 | Before you use `node-pyatv` you need to install pyatv. This module woun't do this for you. Run `atvremote --version` to 19 | double check your installation. See FAQ section for installation tips. 20 | 21 | To install the javascript module via npm just run: 22 | 23 | npm install @sebbo2002/node-pyatv 24 | 25 | ## ⚒ Quick Start 26 | 27 | ```typescript 28 | import pyatv, { NodePyATVDeviceEvent } from '@sebbo2002/node-pyatv'; 29 | 30 | const devices = await pyatv.find(/*{debug: true}*/); 31 | if (!devices.length) { 32 | throw new Error('Oh no.'); 33 | } 34 | 35 | const device = devices[0]; 36 | 37 | // request current title 38 | console.log(await device.getTitle()); 39 | 40 | // request full state 41 | console.log(await device.getState()); 42 | 43 | // subscribe to events 44 | device.on('update:deviceState', (event: NodePyATVDeviceEvent | Error) => { 45 | if (event instanceof Error) return; 46 | console.log(`Current device state is ${event.value}`); 47 | }); 48 | ``` 49 | 50 | ## 📑 API Reference 51 | 52 | The API documentation is automatically generated from the code comments and can be found 53 | [here](https://sebbo2002.github.io/node-pyatv/main/reference/). 54 | 55 | ## 📑 Changelog 56 | 57 | Please have a look at [CHANGELOG.md](https://github.com/sebbo2002/node-pyatv/blob/main/CHANGELOG.md) to see the changelog. 58 | 59 | ## 🤨 FAQ 60 | 61 | #### How to install pyatv 62 | 63 | ```bash 64 | pip3 install pyatv 65 | ``` 66 | 67 | #### How to debug things 68 | 69 | You can pass `"debug": true` for any command called. Some debug information is then printed via console.log. Additionaly 70 | you can pass a function to process debugs logs. 71 | 72 | #### Why are some tests skipped? 73 | 74 | Some unit tests require a responding apple tv to pass. These tests are disabled by default. You can set the environment 75 | variable `ENABLE_INTEGRATION=1` to enable them. 76 | 77 | #### Is this secure? 78 | 79 | Defenitely not. For example, there's no escaping for parameters passed to pyatv. So be sure to double check the data you 80 | pass to this library, otherwise it may be possible to run code on your machine. 81 | 82 | ## Copyright and license 83 | 84 | Copyright (c) Sebastian Pekarek under the [MIT license](LICENSE). 85 | -------------------------------------------------------------------------------- /check.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ -f "./dist/bin/check.cjs" ]; then 4 | node "./dist/bin/check.cjs" 5 | fi 6 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import eslint from '@eslint/js'; 2 | import eslintConfigPrettier from 'eslint-config-prettier/flat'; 3 | import eslintPluginJsonc from 'eslint-plugin-jsonc'; 4 | import perfectionist from 'eslint-plugin-perfectionist'; 5 | import globals from 'globals'; 6 | import tseslint from 'typescript-eslint'; 7 | 8 | export default [ 9 | eslint.configs.recommended, 10 | ...tseslint.configs.recommended, 11 | ...eslintPluginJsonc.configs['flat/recommended-with-jsonc'], 12 | eslintConfigPrettier, 13 | perfectionist.configs['recommended-natural'], 14 | { 15 | files: ['test/**/*.ts'], 16 | rules: { 17 | '@typescript-eslint/ban-ts-comment': 'off', 18 | '@typescript-eslint/ban-ts-ignore': 'off', 19 | }, 20 | }, 21 | { 22 | languageOptions: { 23 | ecmaVersion: 2022, 24 | globals: { 25 | ...globals.node, 26 | ...globals.es6, 27 | ...globals.mocha, 28 | }, 29 | sourceType: 'module', 30 | }, 31 | }, 32 | { 33 | ignores: [ 34 | 'node_modules/**', 35 | 'dist/**', 36 | 'docs/**', 37 | 'package-lock.json', 38 | '.nyc_output/**', 39 | 'venv', 40 | 'mochawesome-report/**', 41 | ], 42 | }, 43 | ]; 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "Sebastian Pekarek ", 3 | "bugs": { 4 | "email": "sheeboothohmoolaiquu@e.sebbo.net", 5 | "url": "http://github.com/sebbo2002/node-pyatv/issues" 6 | }, 7 | "dependencies": { 8 | "semver": "^7.7.3" 9 | }, 10 | "description": "A lightweight wrapper around pyatv…", 11 | "devDependencies": { 12 | "@eslint/js": "^9.37.0", 13 | "@qiwi/semantic-release-gh-pages-plugin": "^5.4.3", 14 | "@semantic-release/changelog": "^6.0.3", 15 | "@semantic-release/exec": "^7.1.0", 16 | "@semantic-release/git": "^10.0.1", 17 | "@semantic-release/npm": "^13.1.1", 18 | "@types/express": "^5.0.5", 19 | "@types/mocha": "^10.0.10", 20 | "@types/node": "^24.9.1", 21 | "@types/semver": "^7.7.1", 22 | "c8": "^10.1.3", 23 | "eslint": "^9.37.0", 24 | "eslint-config-prettier": "^10.1.8", 25 | "eslint-plugin-jsonc": "^2.21.0", 26 | "eslint-plugin-perfectionist": "^4.15.1", 27 | "esm": "^3.2.25", 28 | "husky": "^9.1.7", 29 | "license-checker": "^25.0.1", 30 | "mocha": "^11.7.4", 31 | "mochawesome": "^7.1.4", 32 | "prettier": "^3.6.2", 33 | "semantic-release": "^25.0.1", 34 | "semantic-release-license": "^1.0.3", 35 | "source-map-support": "^0.5.21", 36 | "tsup": "^8.5.0", 37 | "tsx": "^4.20.6", 38 | "typedoc": "^0.28.14", 39 | "typescript": "^5.9.3", 40 | "typescript-eslint": "^8.46.2" 41 | }, 42 | "engines": { 43 | "node": "20 || >=22.0.0" 44 | }, 45 | "exports": { 46 | "import": "./dist/lib/index.js", 47 | "require": "./dist/lib/index.cjs" 48 | }, 49 | "files": [ 50 | "/dist", 51 | "check.sh" 52 | ], 53 | "homepage": "https://github.com/sebbo2002/node-pyatv#readme", 54 | "license": "MIT", 55 | "main": "./dist/lib/index.cjs", 56 | "module": "./dist/lib/index.js", 57 | "name": "@sebbo2002/node-pyatv", 58 | "preferGlobal": false, 59 | "repository": { 60 | "type": "git", 61 | "url": "https://github.com/sebbo2002/node-pyatv.git" 62 | }, 63 | "scripts": { 64 | "build": "tsup && cp ./dist/lib/index.d.ts ./dist/lib/index.d.cts", 65 | "build-all": "./.github/workflows/build.sh", 66 | "check": "./check.sh", 67 | "coverage": "c8 mocha", 68 | "develop": "tsx src/bin/start.ts", 69 | "example": "node ./dist/examples/push.js", 70 | "license-check": "license-checker --production --summary", 71 | "lint": "npx eslint . --fix && npx prettier . --write", 72 | "postinstall": "./check.sh", 73 | "test": "mocha" 74 | }, 75 | "type": "module", 76 | "version": "3.0.1" 77 | } 78 | -------------------------------------------------------------------------------- /release.config.cjs: -------------------------------------------------------------------------------- 1 | const configuration = { 2 | branches: [ 3 | 'main', 4 | { 5 | channel: 'next', 6 | name: 'develop', 7 | prerelease: true, 8 | }, 9 | ], 10 | plugins: [], 11 | }; 12 | 13 | configuration.plugins.push([ 14 | '@semantic-release/commit-analyzer', 15 | { 16 | parserOpts: { 17 | noteKeywords: ['BREAKING CHANGE', 'BREAKING CHANGES'], 18 | }, 19 | releaseRules: [ 20 | { release: 'patch', scope: 'deps', type: 'chore' }, 21 | { release: 'patch', scope: 'package', type: 'chore' }, 22 | { release: 'patch', scope: 'deps', type: 'build' }, 23 | { release: 'patch', type: 'docs' }, 24 | ], 25 | }, 26 | ]); 27 | 28 | configuration.plugins.push('@semantic-release/release-notes-generator'); 29 | 30 | if (process.env.BRANCH === 'main') { 31 | configuration.plugins.push('@semantic-release/changelog'); 32 | } 33 | 34 | configuration.plugins.push('semantic-release-license'); 35 | 36 | configuration.plugins.push('@semantic-release/npm'); 37 | 38 | configuration.plugins.push([ 39 | '@semantic-release/exec', 40 | { 41 | prepareCmd: './.github/workflows/build.sh', 42 | }, 43 | ]); 44 | 45 | configuration.plugins.push([ 46 | '@semantic-release/github', 47 | { 48 | assignees: process.env.GH_OWNER, 49 | labels: false, 50 | }, 51 | ]); 52 | 53 | configuration.plugins.push([ 54 | '@semantic-release/git', 55 | { 56 | assets: ['CHANGELOG.md', 'LICENSE'], 57 | message: 58 | 'chore(release): :bookmark: ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}', 59 | }, 60 | ]); 61 | 62 | configuration.plugins.push([ 63 | '@qiwi/semantic-release-gh-pages-plugin', 64 | { 65 | dst: `./${process.env.BRANCH}`, 66 | msg: 'docs: Updated for <%= nextRelease.gitTag %>', 67 | pullTagsBranch: 'main', 68 | src: './docs', 69 | }, 70 | ]); 71 | 72 | module.exports = configuration; 73 | -------------------------------------------------------------------------------- /src/bin/check.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import NodePyATVInstance from '../lib/instance.js'; 4 | 5 | (async () => { 6 | try { 7 | await NodePyATVInstance.check(); 8 | 9 | console.log(''); 10 | console.log('✔ node-pyatv seems to be ready to use!'); 11 | console.log(' Thank you and have a great day. Or night. Whatever.'); 12 | console.log(''); 13 | } catch (error) { 14 | console.log(''); 15 | console.log('#'.repeat(60)); 16 | console.log('#' + ' '.repeat(58) + '#'); 17 | console.log( 18 | '# ⚠ Warning: node-pyatv is not ready to be used!' + 19 | ' '.repeat(10) + 20 | '#', 21 | ); 22 | console.log('#' + ' '.repeat(58) + '#'); 23 | console.log( 24 | '# To use the JavaScript module, pyatv >= 0.6.0 must be #', 25 | ); 26 | console.log( 27 | '# installed. Unfortunately this could not be found. During #', 28 | ); 29 | console.log( 30 | '# the check following error message was reported: #', 31 | ); 32 | console.log('#' + ' '.repeat(58) + '#'); 33 | console.log( 34 | String(error) 35 | .replace('Error: ', '') 36 | .replace(/(.{1,54})/g, '$1\n') 37 | .split('\n') 38 | .map((l) => l.trim()) 39 | .filter((l) => Boolean(l)) 40 | .map((l) => `# > ${l}${' '.repeat(55 - l.length)}#`) 41 | .join('\n'), 42 | ); 43 | console.log('#' + ' '.repeat(58) + '#'); 44 | console.log( 45 | '# You can probably find more information here: #', 46 | ); 47 | console.log( 48 | '# https://github.com/sebbo2002/node-pyatv #', 49 | ); 50 | console.log('#' + ' '.repeat(58) + '#'); 51 | console.log('#'.repeat(60)); 52 | console.log(''); 53 | } 54 | })(); 55 | -------------------------------------------------------------------------------- /src/examples/push.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // import pyatv, { type NodePyATVDeviceEvent } from '@sebbo2002/node-pyatv'; 4 | import pyatv, { type NodePyATVDeviceEvent } from '../lib/index.js'; 5 | 6 | (async () => { 7 | const devices = await pyatv.find(/*{debug: true}*/); 8 | if (!devices.length) { 9 | throw new Error( 10 | 'Oh no. Unable to find any devices. If you find devices with atvremote, please enable the debug log.', 11 | ); 12 | } 13 | 14 | console.log( 15 | `🔍 Found ${devices.length} device${devices.length > 1 ? 's' : ''}:`, 16 | ); 17 | if (devices.length > 1) { 18 | for (const i in devices) { 19 | const device = devices[i]; 20 | console.log(` - ${device.name} (${device.host})`); 21 | } 22 | } 23 | 24 | const device = devices.find((d) => d.name === 'Wohnzimmer') || devices[0]; 25 | console.log( 26 | `\n⚡️ Subscribe to live events on ${device.name} (press any key to stop)`, 27 | ); 28 | await new Promise((resolve, reject) => { 29 | const errorListener = (error: Error | NodePyATVDeviceEvent) => 30 | reject(error); 31 | const updateListener = (event: Error | NodePyATVDeviceEvent) => { 32 | if (event instanceof Error || event.key === 'dateTime') return; 33 | console.log( 34 | ` ${event.key}: ${event?.value} (was ${event?.oldValue})`, 35 | ); 36 | }; 37 | const keyPressListener = () => { 38 | console.log('\n⏳ Remove event listeners…'); 39 | device.off('update', updateListener); 40 | device.off('error', errorListener); 41 | process.stdin.off('data', keyPressListener); 42 | resolve(undefined); 43 | }; 44 | 45 | device.on('update', updateListener); 46 | device.on('error', errorListener); 47 | process.stdin.on('data', keyPressListener); 48 | }); 49 | 50 | console.log('🎉 Thank you'); 51 | process.exit(0); 52 | })().catch((error) => { 53 | console.log(`🚨 Error: ${error.stack || error}`); 54 | process.exit(1); 55 | }); 56 | -------------------------------------------------------------------------------- /src/lib/device-event.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import type { NodePyATVEventValueType, NodePyATVStateIndex } from './types.js'; 4 | 5 | import { NodePyATVDevice } from '../lib/index.js'; 6 | 7 | export default class NodePyATVDeviceEvent { 8 | /** 9 | * References the device instance this 10 | * event originates from 11 | */ 12 | get device(): NodePyATVDevice { 13 | return this.values.device; 14 | } 15 | 16 | /** 17 | * References the attribute name which was changed. So if the 18 | * title has been updated, this would be `title`. 19 | */ 20 | get key(): NodePyATVStateIndex { 21 | return this.values.key; 22 | } 23 | 24 | /** 25 | * @alias value 26 | */ 27 | get newValue(): NodePyATVEventValueType { 28 | return this.values.new; 29 | } 30 | 31 | /** 32 | * Holds the old value which was there 33 | * before the value was changed. 34 | */ 35 | get oldValue(): NodePyATVEventValueType { 36 | return this.values.old; 37 | } 38 | 39 | /** 40 | * New, current value for `key` 41 | */ 42 | get value(): NodePyATVEventValueType { 43 | return this.values.new; 44 | } 45 | 46 | protected readonly values: { 47 | device: NodePyATVDevice; 48 | key: NodePyATVStateIndex; 49 | new: NodePyATVEventValueType; 50 | old: NodePyATVEventValueType; 51 | }; 52 | 53 | /** 54 | * 55 | * @param values 56 | * @internal 57 | */ 58 | constructor(values: { 59 | device: NodePyATVDevice; 60 | key: NodePyATVStateIndex; 61 | new: NodePyATVEventValueType; 62 | old: NodePyATVEventValueType; 63 | }) { 64 | this.values = Object.assign({}, values, { 65 | key: values.key as NodePyATVStateIndex, 66 | }); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/lib/device-events.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { ChildProcess } from 'child_process'; 4 | import { EventEmitter } from 'events'; 5 | 6 | import { NodePyATVDevice, NodePyATVDeviceEvent } from '../lib/index.js'; 7 | import { FakeChildProcess } from './fake-spawn.js'; 8 | import { 9 | addRequestId, 10 | compareOutputDevices, 11 | debug, 12 | execute, 13 | getParameters, 14 | parseState, 15 | removeRequestId, 16 | } from './tools.js'; 17 | import { 18 | type NodePyATVDeviceOptions, 19 | NodePyATVExecutableType, 20 | type NodePyATVInternalState, 21 | NodePyATVListenerState, 22 | type NodePyATVState, 23 | type NodePyATVStateIndex, 24 | } from './types.js'; 25 | 26 | /** 27 | * @internal 28 | */ 29 | export default class NodePyATVDeviceEvents extends EventEmitter { 30 | private readonly device: NodePyATVDevice; 31 | private listenerState: NodePyATVListenerState; 32 | private readonly options: NodePyATVDeviceOptions; 33 | private pyatv: ChildProcess | FakeChildProcess | undefined; 34 | private readonly state: NodePyATVState; 35 | private timeout: NodeJS.Timeout | undefined; 36 | 37 | constructor( 38 | state: NodePyATVState, 39 | device: NodePyATVDevice, 40 | options: NodePyATVDeviceOptions, 41 | ) { 42 | super(); 43 | 44 | this.state = state; 45 | this.device = device; 46 | this.options = Object.assign({}, options); 47 | this.listenerState = NodePyATVListenerState.stopped; 48 | } 49 | 50 | addListener( 51 | event: string | symbol, 52 | listener: (event: NodePyATVDeviceEvent) => void, 53 | ): this { 54 | super.addListener(event, listener); 55 | this.checkListener(); 56 | return this; 57 | } 58 | 59 | applyStateAndEmitEvents(newState: NodePyATVState): void { 60 | let keys = Object.keys(this.state); 61 | 62 | // Remove special fields from the list 63 | keys = keys.filter( 64 | (k) => 65 | ![ 66 | 'focusState', 67 | 'outputDevices', 68 | 'powerState', 69 | 'volume', 70 | ].includes(k), 71 | ); 72 | 73 | if ('powerState' in newState && newState.powerState) { 74 | keys = ['powerState']; 75 | } 76 | if ('focusState' in newState && newState.focusState) { 77 | keys = ['focusState']; 78 | } 79 | if ('outputDevices' in newState && newState.outputDevices) { 80 | keys = ['outputDevices']; 81 | } 82 | 83 | // Volume events don't hold the complete state… 84 | // see https://github.com/sebbo2002/node-pyatv/pull/291 85 | if ('volume' in newState && newState.volume !== null) { 86 | keys = ['volume']; 87 | } 88 | 89 | // If all values are null, we don't need to emit events at all 90 | // https://github.com/sebbo2002/node-pyatv/issues/295#issuecomment-1888640079 91 | if ( 92 | !Object.entries(newState).find( 93 | ([k, v]) => !['dateTime', 'result'].includes(k) && v !== null, 94 | ) 95 | ) { 96 | return; 97 | } 98 | 99 | keys.forEach((key: string) => { 100 | // @ts-expect-error this.state has no index signature 101 | const oldValue = this.state[key]; 102 | 103 | // @ts-expect-error same here 104 | const newValue = newState[key]; 105 | 106 | if ( 107 | oldValue === undefined || 108 | newValue === undefined || 109 | oldValue === newValue 110 | ) { 111 | return; 112 | } 113 | if ( 114 | key === 'outputDevices' && 115 | compareOutputDevices(oldValue, newValue) 116 | ) { 117 | return; 118 | } 119 | 120 | const event = new NodePyATVDeviceEvent({ 121 | device: this.device, 122 | key: key as NodePyATVStateIndex, 123 | new: newValue, 124 | old: oldValue, 125 | }); 126 | 127 | // @ts-expect-error and here 128 | this.state[key] = newState[key]; 129 | 130 | try { 131 | this.emit('update:' + key, event); 132 | this.emit('update', event); 133 | } catch (error) { 134 | this.emit('error', error); 135 | } 136 | }); 137 | } 138 | 139 | listenerCount(event?: string | symbol): number { 140 | if (event !== undefined) { 141 | return super.listenerCount(event); 142 | } 143 | 144 | return this.eventNames() 145 | .map((event) => this.listenerCount(event)) 146 | .reduce((a, b) => a + b, 0); 147 | } 148 | 149 | off( 150 | event: string | symbol, 151 | listener: (event: NodePyATVDeviceEvent) => void, 152 | ): this { 153 | super.off(event, listener); 154 | this.checkListener(); 155 | return this; 156 | } 157 | 158 | on( 159 | event: string | symbol, 160 | listener: (event: NodePyATVDeviceEvent) => void, 161 | ): this { 162 | super.on(event, listener); 163 | this.checkListener(); 164 | return this; 165 | } 166 | 167 | once( 168 | event: string | symbol, 169 | listener: (event: NodePyATVDeviceEvent) => void, 170 | ): this { 171 | super.once(event, (event: NodePyATVDeviceEvent) => { 172 | listener(event); 173 | setTimeout(() => this.checkListener(), 0); 174 | }); 175 | this.checkListener(); 176 | return this; 177 | } 178 | 179 | prependListener( 180 | event: string | symbol, 181 | listener: (event: NodePyATVDeviceEvent) => void, 182 | ): this { 183 | super.prependListener(event, listener); 184 | this.checkListener(); 185 | return this; 186 | } 187 | 188 | removeAllListeners(event?: string | symbol): this { 189 | super.removeAllListeners(event); 190 | this.checkListener(); 191 | return this; 192 | } 193 | 194 | removeListener( 195 | event: string | symbol, 196 | listener: (event: NodePyATVDeviceEvent) => void, 197 | ): this { 198 | super.removeListener(event, listener); 199 | this.checkListener(); 200 | return this; 201 | } 202 | 203 | protected async stopListening(reqId: string): Promise { 204 | if ( 205 | this.listenerState !== NodePyATVListenerState.starting && 206 | this.listenerState !== NodePyATVListenerState.started 207 | ) { 208 | return; 209 | } 210 | 211 | this.listenerState = NodePyATVListenerState.stopping; 212 | if (this.pyatv === undefined) { 213 | throw new Error( 214 | "Unable to stop listening due to internal error: state is stopping, but there's no child process. " + 215 | 'This should never happen, please report this.', 216 | ); 217 | } 218 | 219 | if (this.pyatv.stdin) { 220 | debug(reqId, 'Pressing enter to close atvscript…', this.options); 221 | this.pyatv.stdin.write('\n'); 222 | 223 | await new Promise((cb) => (this.timeout = setTimeout(cb, 250))); 224 | } 225 | 226 | if ( 227 | this.listenerState === NodePyATVListenerState.stopping && 228 | this.pyatv 229 | ) { 230 | this.pyatv.kill(); 231 | } 232 | 233 | this.listenerState = NodePyATVListenerState.stopped; 234 | return; 235 | } 236 | 237 | private applyPushUpdate( 238 | update: NodePyATVInternalState, 239 | reqId: string, 240 | ): void { 241 | try { 242 | const newState = parseState(update, reqId, this.options); 243 | this.applyStateAndEmitEvents(newState); 244 | } catch (error) { 245 | this.emit('error', error); 246 | } 247 | } 248 | 249 | private checkListener(): void { 250 | if ( 251 | this.listenerState === NodePyATVListenerState.stopped && 252 | this.listenerCount() === 0 && 253 | this.timeout 254 | ) { 255 | clearTimeout(this.timeout); 256 | this.timeout = undefined; 257 | } else if ( 258 | this.listenerState === NodePyATVListenerState.stopped && 259 | this.listenerCount() > 0 260 | ) { 261 | const id = addRequestId(); 262 | debug( 263 | id, 264 | `Start listeing to events from device ${this.options.name}`, 265 | this.options, 266 | ); 267 | 268 | this.startListening(id); 269 | removeRequestId(id); 270 | } else if ( 271 | [ 272 | NodePyATVListenerState.started, 273 | NodePyATVListenerState.starting, 274 | ].includes(this.listenerState) && 275 | this.listenerCount() === 0 276 | ) { 277 | const id = addRequestId(); 278 | debug( 279 | id, 280 | `Stop listening to events from device ${this.options.name}`, 281 | this.options, 282 | ); 283 | 284 | this.stopListening(id) 285 | .catch((error) => 286 | debug( 287 | id, 288 | `Unable to stop listeing: ${error}`, 289 | this.options, 290 | ), 291 | ) 292 | .finally(() => removeRequestId(id)); 293 | } 294 | } 295 | 296 | private parsePushUpdate(reqId: string, data: string): void { 297 | let json: NodePyATVInternalState; 298 | 299 | try { 300 | json = JSON.parse(data); 301 | } catch (error) { 302 | const msg = `Unable to parse stdout json: ${error}`; 303 | debug(reqId, msg, this.options); 304 | this.emit('error', new Error(msg)); 305 | return; 306 | } 307 | 308 | this.applyPushUpdate(json, reqId); 309 | 310 | if (this.listenerState === NodePyATVListenerState.starting) { 311 | this.listenerState = NodePyATVListenerState.started; 312 | this.checkListener(); 313 | } 314 | } 315 | 316 | private startListening(reqId: string): void { 317 | if (this.listenerState !== NodePyATVListenerState.stopped) { 318 | return; 319 | } 320 | 321 | this.listenerState = NodePyATVListenerState.starting; 322 | 323 | const listenStart = new Date().getTime(); 324 | const parameters = getParameters(this.options); 325 | this.pyatv = execute( 326 | reqId, 327 | NodePyATVExecutableType.atvscript, 328 | [...parameters, 'push_updates'], 329 | this.options, 330 | ); 331 | if (!this.pyatv) { 332 | throw new Error( 333 | 'Unable to start listener: Unable to start atvscript', 334 | ); 335 | } 336 | 337 | const onError = (error: Error) => { 338 | debug( 339 | reqId, 340 | `Got error from child process: ${error}`, 341 | this.options, 342 | ); 343 | this.emit('error', error); 344 | }; 345 | 346 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 347 | const onStdErr = (data: any) => { 348 | const error = new Error(`Got stderr output from pyatv: ${data}`); 349 | debug(reqId, data.toString(), this.options); 350 | this.emit('error', error); 351 | }; 352 | 353 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 354 | const onStdOut = (data: any) => { 355 | String(data) 356 | .split('\n') 357 | .map((s) => s.trim()) 358 | .filter(Boolean) 359 | .forEach((s) => { 360 | debug(reqId, `> ${s}`, this.options); 361 | this.parsePushUpdate(reqId, s); 362 | }); 363 | }; 364 | const onClose = (code: number) => { 365 | if (this.pyatv === undefined) { 366 | // this should never happen… :/ 367 | return; 368 | } 369 | 370 | this.listenerState = NodePyATVListenerState.stopped; 371 | debug( 372 | reqId, 373 | `Listening with atvscript exited with code ${code}`, 374 | this.options, 375 | ); 376 | if (this.timeout !== undefined) { 377 | clearTimeout(this.timeout); 378 | this.timeout = undefined; 379 | } 380 | 381 | if (this.pyatv.stdout) { 382 | this.pyatv.stdout.off('data', onStdOut); 383 | } 384 | if (this.pyatv.stderr) { 385 | this.pyatv.stderr.off('data', onStdErr); 386 | } 387 | this.pyatv.off('error', onError); 388 | this.pyatv.off('close', onClose); 389 | 390 | if ( 391 | this.listenerCount() > 0 && 392 | new Date().getTime() - listenStart < 30000 393 | ) { 394 | debug( 395 | reqId, 396 | `Wait 15s and restart listeing to events from device ${this.options.name}`, 397 | this.options, 398 | ); 399 | 400 | this.timeout = setTimeout(() => { 401 | this.checkListener(); 402 | }, 15000); 403 | } else if (this.listenerCount() > 0) { 404 | debug( 405 | reqId, 406 | `Restart listeing to events from device ${this.options.name}`, 407 | this.options, 408 | ); 409 | this.checkListener(); 410 | } 411 | 412 | removeRequestId(reqId); 413 | }; 414 | 415 | this.pyatv.on('error', onError); 416 | this.pyatv.on('close', onClose); 417 | 418 | if (this.pyatv.stdout) { 419 | this.pyatv.stdout.on('data', onStdOut); 420 | } 421 | if (this.pyatv.stderr) { 422 | this.pyatv.stderr.on('data', onStdErr); 423 | } 424 | } 425 | } 426 | -------------------------------------------------------------------------------- /src/lib/device.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { EventEmitter } from 'events'; 4 | 5 | import { NodePyATVDeviceEvent, NodePyATVDeviceEvents } from '../lib/index.js'; 6 | import { 7 | addRequestId, 8 | getParameters, 9 | parseState, 10 | removeRequestId, 11 | request, 12 | } from './tools.js'; 13 | import { 14 | type NodePyATVApp, 15 | type NodePyATVDeviceOptions, 16 | NodePyATVDeviceState, 17 | NodePyATVExecutableType, 18 | NodePyATVFocusState, 19 | type NodePyATVGetStateOptions, 20 | NodePyATVInternalKeys, 21 | NodePyATVKeys, 22 | NodePyATVMediaType, 23 | NodePyATVPowerState, 24 | NodePyATVProtocol, 25 | NodePyATVRepeatState, 26 | type NodePyATVService, 27 | NodePyATVShuffleState, 28 | type NodePyATVState, 29 | } from './types.js'; 30 | 31 | /** 32 | * Represents an Apple TV. Use [[getState]] to query the current state (e.g. media 33 | * type and title). You can also use the attribute methods (e.g. [[getTitle]] to get 34 | * the state. If you want realtime updates, subscribe to it's events with an 35 | * `EventEmitter` like API, so for example by using [[on]], [[once]] or [[addListener]]. 36 | * It's also possible to send key commands by using [[pressKey]] or methods like [[pause]]. 37 | */ 38 | export default class NodePyATVDevice implements EventEmitter { 39 | /** 40 | * Get all IDs of the Apple TV. 41 | * Requires pyatv >= 0.14.5. 42 | */ 43 | get allIDs(): string[] | undefined { 44 | return this.options.allIDs; 45 | } 46 | /** 47 | * Returns true, if debugging is enabled. Returns the custom 48 | * logging method, if one was specified. Otherwise, if debug 49 | * log is disabled, returns undefined. 50 | */ 51 | get debug(): ((msg: string) => void) | true | undefined { 52 | return this.options.debug; 53 | } 54 | /** 55 | * Enable or disable debugging or set a custom 56 | * debugging method to use. 57 | * 58 | * @param debug 59 | */ 60 | set debug(debug: ((msg: string) => void) | true | undefined) { 61 | if (typeof debug === 'function') { 62 | this.options.debug = debug; 63 | } else { 64 | this.options.debug = Boolean(debug) || undefined; 65 | } 66 | } 67 | 68 | /** 69 | * Get the IP address of the Apple TV. 70 | */ 71 | get host(): string | undefined { 72 | return this.options.host; 73 | } 74 | 75 | /** 76 | * Get the ID of the Apple TV. 77 | */ 78 | get id(): string | undefined { 79 | return this.options.id; 80 | } 81 | 82 | /** 83 | * Get the MAC address of the Apple TV. 84 | * Requires pyatv >= 0.14.5. 85 | */ 86 | get mac(): string | undefined { 87 | return this.options.mac; 88 | } 89 | 90 | /** 91 | * Get the model identifier of the device. Only set, if the 92 | * device was found using [[find()]]. Requires pyatv ≧ 0.10.3. 93 | * 94 | * @example device.model → "Gen4K" 95 | */ 96 | get model(): string | undefined { 97 | return this.options.model; 98 | } 99 | 100 | /** 101 | * Get the model name of the device. Only set, if the device 102 | * was found with [[find()]]. Requires pyatv ≧ 0.10.3. 103 | * 104 | * @example device.modelName → "Apple TV 4K" 105 | */ 106 | get modelName(): string | undefined { 107 | return this.options.modelName; 108 | } 109 | 110 | /** 111 | * Get the name of the Apple TV. 112 | * 113 | * ```typescript 114 | * import pyatv from '@sebbo2002/node-pyatv'; 115 | * const devices = await pyatv.find(); 116 | * devices.forEach(device => 117 | * console.log(device.name) 118 | * ); 119 | * ``` 120 | */ 121 | get name(): string { 122 | return this.options.name; 123 | } 124 | 125 | /** 126 | * Get the operating system of the device. Only set, if the 127 | * device was found with [[find()]]. Requires pyatv ≧ 0.10.3. 128 | * 129 | * @example device.os → "TvOS" 130 | */ 131 | get os(): string | undefined { 132 | return this.options.os; 133 | } 134 | 135 | /** 136 | * Get the used protocol to connect to the Apple TV. 137 | */ 138 | get protocol(): NodePyATVProtocol | undefined { 139 | return this.options.protocol; 140 | } 141 | 142 | /** 143 | * Returns a list of services supported by the device. Ony set, if 144 | * the device was found during a scan using [[find()]]. Requires 145 | * pyatv ≧ 0.10.3. 146 | * 147 | * @example device.services → [ 148 | * { 149 | * "protocol": "airplay", 150 | * "port": 7000 151 | * }, 152 | * { 153 | * "protocol": "dmap", 154 | * "port": 3689 155 | * } 156 | * ] 157 | */ 158 | get services(): NodePyATVService[] | undefined { 159 | return this.options.services; 160 | } 161 | 162 | /** 163 | * Get the device version. Only set, if the device was found 164 | * during a scan using [[find()]]. Requires pyatv ≧ 0.10.3. 165 | * 166 | * @example device.version → "15.5.1" 167 | */ 168 | get version(): string | undefined { 169 | return this.options.version; 170 | } 171 | 172 | private readonly events: NodePyATVDeviceEvents; 173 | 174 | private readonly options: NodePyATVDeviceOptions; 175 | 176 | private readonly state: NodePyATVState; 177 | 178 | constructor(options: NodePyATVDeviceOptions) { 179 | if (!options.host && !options.id && !options.mac) { 180 | throw new Error('Either host, id or mac must be set!'); 181 | } 182 | 183 | this.options = Object.assign({}, options); 184 | this.state = parseState({}, '', {}); 185 | this.events = new NodePyATVDeviceEvents(this.state, this, this.options); 186 | 187 | // @todo basic validation 188 | } 189 | 190 | /** 191 | * Add an event listener. Will start the event subscription with the 192 | * Apple TV as long as there are listeners for any event registered. 193 | * @param event 194 | * @param listener 195 | * @category Event 196 | */ 197 | addListener( 198 | event: string | symbol, 199 | listener: (event: NodePyATVDeviceEvent) => void, 200 | ): this { 201 | this.events.addListener(event, listener); 202 | return this; 203 | } 204 | 205 | /** 206 | * Removes the state node-pyatv cached for this device. 207 | * 208 | * @category State 209 | */ 210 | clearState(): void { 211 | this.applyState(parseState({}, '', {})); 212 | } 213 | 214 | /** 215 | * Send the "down" command 216 | * @category Control 217 | */ 218 | async down(): Promise { 219 | await this._pressKeyWithScript(NodePyATVInternalKeys.down); 220 | } 221 | 222 | /** 223 | * Emit an event. 224 | * @param event 225 | * @param payload 226 | * @category Event 227 | */ 228 | emit(event: string | symbol, payload: NodePyATVDeviceEvent): boolean { 229 | return this.events.emit(event, payload); 230 | } 231 | 232 | /** 233 | * Get all event names which are currently known. 234 | * @category Event 235 | */ 236 | eventNames(): Array { 237 | return this.events.eventNames(); 238 | } 239 | 240 | /** 241 | * Returns the album of the current playing media 242 | * @param options 243 | * @category State 244 | */ 245 | async getAlbum( 246 | options: NodePyATVGetStateOptions = {}, 247 | ): Promise { 248 | const state = await this.getState(options); 249 | return state.album; 250 | } 251 | 252 | /** 253 | * Returns the currently used app 254 | * @param options 255 | * @category State 256 | */ 257 | async getApp( 258 | options: NodePyATVGetStateOptions = {}, 259 | ): Promise { 260 | const state = await this.getState(options); 261 | return state.app; 262 | } 263 | 264 | /** 265 | * Returns the id of the currently used app 266 | * @param options 267 | * @category State 268 | */ 269 | async getAppId( 270 | options: NodePyATVGetStateOptions = {}, 271 | ): Promise { 272 | const state = await this.getState(options); 273 | return state.appId; 274 | } 275 | 276 | /** 277 | * Returns the artist of the current playing media 278 | * @param options 279 | * @category State 280 | */ 281 | async getArtist( 282 | options: NodePyATVGetStateOptions = {}, 283 | ): Promise { 284 | const state = await this.getState(options); 285 | return state.artist; 286 | } 287 | 288 | /** 289 | * Returns the app specific content identifier 290 | * @param options 291 | * @category State 292 | */ 293 | async getContentIdentifier( 294 | options: NodePyATVGetStateOptions = {}, 295 | ): Promise { 296 | return this.getState(options).then((state) => state.contentIdentifier); 297 | } 298 | 299 | /** 300 | * Get the date and time when the state was last updated. 301 | * @param options 302 | * @category State 303 | */ 304 | async getDateTime( 305 | options: NodePyATVGetStateOptions = {}, 306 | ): Promise { 307 | const state = await this.getState(options); 308 | return state.dateTime; 309 | } 310 | 311 | /** 312 | * Get the state of this device (e.g. playing, etc.) 313 | * @param options 314 | * @category State 315 | */ 316 | async getDeviceState( 317 | options: NodePyATVGetStateOptions = {}, 318 | ): Promise { 319 | const state = await this.getState(options); 320 | return state.deviceState; 321 | } 322 | 323 | /** 324 | * Returns the episode number. 325 | * Probably only set [if MRP is used](https://pyatv.dev/development/metadata/#currently-playing). 326 | * @param options 327 | * @category State 328 | */ 329 | async getEpisodeNumber( 330 | options: NodePyATVGetStateOptions = {}, 331 | ): Promise { 332 | return this.getState(options).then((state) => state.episodeNumber); 333 | } 334 | 335 | /** 336 | * Returns the current focus state of the device 337 | * @param options 338 | * @category State 339 | */ 340 | async getFocusState( 341 | options: NodePyATVGetStateOptions = {}, 342 | ): Promise { 343 | return this.getState(options).then((state) => state.focusState); 344 | } 345 | 346 | /** 347 | * Returns the genre of the current playing media 348 | * @param options 349 | * @category State 350 | */ 351 | async getGenre( 352 | options: NodePyATVGetStateOptions = {}, 353 | ): Promise { 354 | const state = await this.getState(options); 355 | return state.genre; 356 | } 357 | 358 | /** 359 | * Get the hash of the current media 360 | * @param options 361 | * @category State 362 | */ 363 | async getHash( 364 | options: NodePyATVGetStateOptions = {}, 365 | ): Promise { 366 | const state = await this.getState(options); 367 | return state.hash; 368 | } 369 | 370 | /** 371 | * Returns the iTunes Store identifier if available. 372 | * Requires pyatv >= 0.16.0 373 | * @param options 374 | * @category State 375 | * @alias getiTunesStoreIdentifier 376 | */ 377 | async getITunesStoreIdentifier( 378 | options: NodePyATVGetStateOptions = {}, 379 | ): Promise { 380 | return this.getState(options).then( 381 | (state) => state.iTunesStoreIdentifier, 382 | ); 383 | } 384 | 385 | async getiTunesStoreIdentifier( 386 | options: NodePyATVGetStateOptions = {}, 387 | ): Promise { 388 | return this.getITunesStoreIdentifier(options); 389 | } 390 | 391 | /** 392 | * Get max number of listeners allowed 393 | * @category Event 394 | */ 395 | getMaxListeners(): number { 396 | return this.events.getMaxListeners(); 397 | } 398 | 399 | /** 400 | * Get the media type of the current media 401 | * @param options 402 | * @category State 403 | */ 404 | async getMediaType( 405 | options: NodePyATVGetStateOptions = {}, 406 | ): Promise { 407 | const state = await this.getState(options); 408 | return state.mediaType; 409 | } 410 | 411 | /** 412 | * Returns the current output devices of the device 413 | * @param options 414 | * @category State 415 | */ 416 | async getOutputDevices( 417 | options: NodePyATVGetStateOptions = {}, 418 | ): Promise | null> { 419 | return this.getState(options).then((state) => state.outputDevices); 420 | } 421 | 422 | /** 423 | * Returns the title of the current playing media 424 | * @param options 425 | * @category State 426 | */ 427 | async getPosition( 428 | options: NodePyATVGetStateOptions = {}, 429 | ): Promise { 430 | const state = await this.getState(options); 431 | return state.position; 432 | } 433 | 434 | /** 435 | * Returns the current power state (= is it on or off, see [[NodePyATVPowerState]]) of the device. 436 | * @param options 437 | * @category State 438 | */ 439 | async getPowerState( 440 | options: NodePyATVGetStateOptions = {}, 441 | ): Promise { 442 | return this.getState(options).then((state) => state.powerState); 443 | } 444 | 445 | /** 446 | * Returns the repeat state 447 | * @param options 448 | * @category State 449 | */ 450 | async getRepeat( 451 | options: NodePyATVGetStateOptions = {}, 452 | ): Promise { 453 | const state = await this.getState(options); 454 | return state.repeat; 455 | } 456 | 457 | /** 458 | * Returns the season number. 459 | * Probably only set [if MRP is used](https://pyatv.dev/development/metadata/#currently-playing). 460 | * @param options 461 | * @category State 462 | */ 463 | async getSeasonNumber( 464 | options: NodePyATVGetStateOptions = {}, 465 | ): Promise { 466 | return this.getState(options).then((state) => state.seasonNumber); 467 | } 468 | /** 469 | * Returns the season name. 470 | * Probably only set [if MRP is used](https://pyatv.dev/development/metadata/#currently-playing). 471 | * @param options 472 | * @category State 473 | */ 474 | async getSeriesName( 475 | options: NodePyATVGetStateOptions = {}, 476 | ): Promise { 477 | return this.getState(options).then((state) => state.seriesName); 478 | } 479 | 480 | /** 481 | * Returns the shuffle state 482 | * @param options 483 | * @category State 484 | */ 485 | async getShuffle( 486 | options: NodePyATVGetStateOptions = {}, 487 | ): Promise { 488 | const state = await this.getState(options); 489 | return state.shuffle; 490 | } 491 | 492 | /** 493 | * Returns an [[NodePyATVState]] object representing the current state 494 | * of the device. Has an internal cache, which has a default TTL of 5s. 495 | * You can change this default value by passing the `maxAge` option. 496 | * 497 | * ```typescript 498 | * await device.getState({maxAge: 10000}); // cache TTL: 10s 499 | * ``` 500 | * 501 | * @param options 502 | * @category State 503 | */ 504 | async getState( 505 | options: NodePyATVGetStateOptions = {}, 506 | ): Promise { 507 | if ( 508 | this.state?.dateTime && 509 | new Date().getTime() - this.state.dateTime.getTime() < 510 | (options.maxAge || 5000) 511 | ) { 512 | let position = null; 513 | if (this.state.position && this.state.dateTime) { 514 | position = Math.round( 515 | this.state.position + 516 | (new Date().getTime() - this.state.dateTime.getTime()) / 517 | 1000, 518 | ); 519 | } 520 | 521 | return Object.assign({}, this.state, { position }); 522 | } 523 | 524 | const id = addRequestId(); 525 | 526 | try { 527 | const parameters = getParameters(this.options); 528 | 529 | const result = await request( 530 | id, 531 | NodePyATVExecutableType.atvscript, 532 | [...parameters, 'playing'], 533 | this.options, 534 | ); 535 | const newState = parseState(result, id, this.options); 536 | 537 | this.applyState(newState); 538 | return newState; 539 | } finally { 540 | removeRequestId(id); 541 | } 542 | } 543 | 544 | /** 545 | * Returns the title of the current playing media 546 | * @param options 547 | * @category State 548 | */ 549 | async getTitle( 550 | options: NodePyATVGetStateOptions = {}, 551 | ): Promise { 552 | const state = await this.getState(options); 553 | return state.title; 554 | } 555 | 556 | /** 557 | * Returns the media length of the current playing media 558 | * @param options 559 | * @category State 560 | */ 561 | async getTotalTime( 562 | options: NodePyATVGetStateOptions = {}, 563 | ): Promise { 564 | const state = await this.getState(options); 565 | return state.totalTime; 566 | } 567 | 568 | /** 569 | * Returns the current volume of the device in percent (0 - 100) 570 | * @param options 571 | * @category State 572 | */ 573 | async getVolume( 574 | options: NodePyATVGetStateOptions = {}, 575 | ): Promise { 576 | return this.getState(options).then((state) => state.volume); 577 | } 578 | 579 | /** 580 | * Send the "home" command 581 | * @category Control 582 | */ 583 | async home(): Promise { 584 | await this._pressKeyWithScript(NodePyATVInternalKeys.home); 585 | } 586 | 587 | /** 588 | * Send the "homeHold" command 589 | * @category Control 590 | */ 591 | async homeHold(): Promise { 592 | await this._pressKeyWithScript(NodePyATVInternalKeys.homeHold); 593 | } 594 | 595 | /** 596 | * Launch an application. Probably requires `companionCredentials`, see 597 | * https://pyatv.dev/documentation/atvremote/#apps for more details. 598 | * @param id App identifier, e.g. `com.netflix.Netflix` 599 | */ 600 | async launchApp(id: string): Promise { 601 | await this._pressKeyWithRemote('launch_app=' + id); 602 | } 603 | 604 | /** 605 | * Send the "left" command 606 | * @category Control 607 | */ 608 | async left(): Promise { 609 | await this._pressKeyWithScript(NodePyATVInternalKeys.left); 610 | } 611 | 612 | /** 613 | * Returns the list of installed apps on the Apple TV. Probably requires `companionCredentials`, 614 | * see https://pyatv.dev/documentation/atvremote/#apps for more details. 615 | */ 616 | async listApps(): Promise { 617 | const id = addRequestId(); 618 | const parameters = getParameters(this.options); 619 | 620 | const result = await request( 621 | id, 622 | NodePyATVExecutableType.atvremote, 623 | [...parameters, 'app_list'], 624 | this.options, 625 | ); 626 | if (typeof result !== 'string' || !result.startsWith('App: ')) { 627 | throw new Error('Unexpected atvremote response: ' + result); 628 | } 629 | 630 | removeRequestId(id); 631 | const regex = /(.+) \(([^)]+)\)$/i; 632 | const items = result 633 | .substring(5) 634 | .split(', App: ') 635 | .map((i) => { 636 | const m = i.match(regex); 637 | if (m !== null) { 638 | return { 639 | id: m[2], 640 | launch: () => this.launchApp(m[2]), 641 | name: m[1], 642 | }; 643 | } 644 | }) as Array; 645 | 646 | return items.filter(Boolean) as NodePyATVApp[]; 647 | } 648 | 649 | /** 650 | * Get number of listeners for event 651 | * @param event 652 | * @category Event 653 | */ 654 | listenerCount(event: string | symbol): number { 655 | return this.events.listenerCount(event); 656 | } 657 | 658 | /** 659 | * Get listeners for event. Will also return 660 | * node-pyatv wrappers (e.g. once) 661 | * 662 | * @param event 663 | * @category Event 664 | */ 665 | // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type 666 | listeners(event: string | symbol): Function[] { 667 | return this.events.listeners(event); 668 | } 669 | 670 | /** 671 | * Send the "menu" command 672 | * @category Control 673 | */ 674 | async menu(): Promise { 675 | await this._pressKeyWithScript(NodePyATVInternalKeys.menu); 676 | } 677 | 678 | /** 679 | * Send the "next" command 680 | * @category Control 681 | */ 682 | async next(): Promise { 683 | await this._pressKeyWithScript(NodePyATVInternalKeys.next); 684 | } 685 | 686 | /** 687 | * Remove an event listener. Will stop the event subscription with the 688 | * Apple TV if this was the last event listener. 689 | * @param event 690 | * @param listener 691 | * @category Event 692 | */ 693 | off( 694 | event: string | symbol, 695 | listener: (event: Error | NodePyATVDeviceEvent) => void, 696 | ): this { 697 | this.events.off(event, listener); 698 | return this; 699 | } 700 | 701 | /** 702 | * Add an event listener. Will start the event subscription with the 703 | * Apple TV as long as there are listeners for any event registered. 704 | * @param event 705 | * @param listener 706 | * @category Event 707 | */ 708 | on( 709 | event: string | symbol, 710 | listener: (event: Error | NodePyATVDeviceEvent) => void, 711 | ): this { 712 | this.events.on(event, listener); 713 | return this; 714 | } 715 | 716 | /** 717 | * Add an event listener. Will start the event subscription with the 718 | * Apple TV as long as there are listeners for any event registered. 719 | * Removes the listener automatically after the first occurrence. 720 | * @param event 721 | * @param listener 722 | * @category Event 723 | */ 724 | once( 725 | event: string | symbol, 726 | listener: (event: Error | NodePyATVDeviceEvent) => void, 727 | ): this { 728 | this.events.once(event, listener); 729 | return this; 730 | } 731 | 732 | /** 733 | * Send the "pause" command 734 | * @category Control 735 | */ 736 | async pause(): Promise { 737 | await this._pressKeyWithScript(NodePyATVInternalKeys.pause); 738 | } 739 | 740 | /** 741 | * Send the "play" command 742 | * @category Control 743 | */ 744 | async play(): Promise { 745 | await this._pressKeyWithScript(NodePyATVInternalKeys.play); 746 | } 747 | 748 | /** 749 | * Send the "playPause" command 750 | * @category Control 751 | */ 752 | async playPause(): Promise { 753 | await this._pressKeyWithScript(NodePyATVInternalKeys.playPause); 754 | } 755 | 756 | /** 757 | * @param event 758 | * @param listener 759 | * @category Event 760 | */ 761 | prependListener( 762 | event: string | symbol, 763 | listener: (event: Error | NodePyATVDeviceEvent) => void, 764 | ): this { 765 | this.events.prependListener(event, listener); 766 | return this; 767 | } 768 | 769 | /** 770 | * @param event 771 | * @param listener 772 | * @category Event 773 | */ 774 | prependOnceListener( 775 | event: string | symbol, 776 | listener: (event: Error | NodePyATVDeviceEvent) => void, 777 | ): this { 778 | this.events.prependOnceListener(event, listener); 779 | return this; 780 | } 781 | 782 | /** 783 | * Send a key press to the Apple TV 784 | * 785 | * ```typescript 786 | * await device.pressKey(NodePyATVKeys.home); 787 | * ``` 788 | * 789 | *
790 | * 791 | * ```javascript 792 | * await device.pressKey('home'); 793 | * ``` 794 | * 795 | * @param key 796 | * @category Control 797 | */ 798 | async pressKey(key: NodePyATVKeys): Promise { 799 | const internalKeyEntry = Object.entries(NodePyATVInternalKeys).find( 800 | ([k]) => key === k, 801 | ); 802 | 803 | if (!internalKeyEntry) { 804 | throw new Error(`Unsupported key value ${key}!`); 805 | } 806 | 807 | const internalKey = internalKeyEntry[1]; 808 | if ([NodePyATVKeys.turnOff, NodePyATVKeys.turnOn].includes(key)) { 809 | await this._pressKeyWithRemote(internalKey); 810 | } else { 811 | await this._pressKeyWithScript(internalKey); 812 | } 813 | } 814 | 815 | /** 816 | * Send the "previous" command 817 | * @category Control 818 | */ 819 | async previous(): Promise { 820 | await this._pressKeyWithScript(NodePyATVInternalKeys.previous); 821 | } 822 | 823 | /** 824 | * @param event 825 | * @category Event 826 | */ 827 | // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type 828 | rawListeners(event: string | symbol): Function[] { 829 | return this.events.rawListeners(event); 830 | } 831 | 832 | /** 833 | * Removes all listeners, either for the given event or 834 | * for every event. Will stop the event subscription with 835 | * the Apple TV if this was the last event listener. 836 | * 837 | * @param event 838 | * @category Event 839 | */ 840 | removeAllListeners(event?: string | symbol): this { 841 | this.events.removeAllListeners(event); 842 | return this; 843 | } 844 | 845 | /** 846 | * Remove an event listener. Will stop the event subscription with the 847 | * Apple TV if this was the last event listener. 848 | * @param event 849 | * @param listener 850 | * @category Event 851 | */ 852 | removeListener( 853 | event: string | symbol, 854 | listener: (event: NodePyATVDeviceEvent) => void, 855 | ): this { 856 | this.events.removeListener(event, listener); 857 | return this; 858 | } 859 | 860 | /** 861 | * Send the "right" command 862 | * @category Control 863 | */ 864 | async right(): Promise { 865 | await this._pressKeyWithScript(NodePyATVInternalKeys.right); 866 | } 867 | 868 | /** 869 | * Send the "select" command 870 | * @category Control 871 | */ 872 | async select(): Promise { 873 | await this._pressKeyWithScript(NodePyATVInternalKeys.select); 874 | } 875 | 876 | /** 877 | * @param n 878 | * @category Event 879 | */ 880 | setMaxListeners(n: number): this { 881 | this.events.setMaxListeners(n); 882 | return this; 883 | } 884 | 885 | /** 886 | * Send the "skipBackward" command 887 | * @category Control 888 | */ 889 | async skipBackward(): Promise { 890 | await this._pressKeyWithScript(NodePyATVInternalKeys.skipBackward); 891 | } 892 | 893 | /** 894 | * Send the "skipForward" command 895 | * @category Control 896 | */ 897 | async skipForward(): Promise { 898 | await this._pressKeyWithScript(NodePyATVInternalKeys.skipForward); 899 | } 900 | 901 | /** 902 | * Send the "stop" command 903 | * @category Control 904 | */ 905 | async stop(): Promise { 906 | await this._pressKeyWithScript(NodePyATVInternalKeys.stop); 907 | } 908 | 909 | /** 910 | * Send the "suspend" command 911 | * @category Control 912 | * @deprecated 913 | */ 914 | async suspend(): Promise { 915 | await this._pressKeyWithScript(NodePyATVInternalKeys.suspend); 916 | } 917 | 918 | /** 919 | * Returns an object with `name`, `host`, `mac`, `id` and `protocol`. 920 | * Can be used to initiate a new device instance. 921 | * 922 | * @category Basic 923 | */ 924 | toJSON(): Pick< 925 | NodePyATVDeviceOptions, 926 | 'host' | 'id' | 'mac' | 'name' | 'protocol' 927 | > { 928 | return { 929 | host: this.host, 930 | id: this.id, 931 | mac: this.mac, 932 | name: this.name, 933 | protocol: this.protocol, 934 | }; 935 | } 936 | 937 | /** 938 | * Send the "topMenu" command 939 | * @category Control 940 | */ 941 | async topMenu(): Promise { 942 | await this._pressKeyWithScript(NodePyATVInternalKeys.topMenu); 943 | } 944 | 945 | /** 946 | * Returns a string. Just for debugging, etc. 947 | * 948 | * @category Basic 949 | */ 950 | toString(): string { 951 | return `NodePyATVDevice(${this.name}, ${this.host})`; 952 | } 953 | 954 | /** 955 | * Send the "turn_off" command 956 | * @category Control 957 | */ 958 | async turnOff(): Promise { 959 | await this._pressKeyWithRemote(NodePyATVInternalKeys.turnOff); 960 | } 961 | 962 | /** 963 | * Send the "turn_on" command 964 | * @category Control 965 | */ 966 | async turnOn(): Promise { 967 | await this._pressKeyWithRemote(NodePyATVInternalKeys.turnOn); 968 | } 969 | 970 | /** 971 | * Send the "up" command 972 | * @category Control 973 | */ 974 | async up(): Promise { 975 | await this._pressKeyWithScript(NodePyATVInternalKeys.up); 976 | } 977 | 978 | /** 979 | * Send the "volumeDown" command 980 | * @category Control 981 | */ 982 | async volumeDown(): Promise { 983 | await this._pressKeyWithScript(NodePyATVInternalKeys.volumeDown); 984 | } 985 | 986 | /** 987 | * Send the "volumeUp" command 988 | * @category Control 989 | */ 990 | async volumeUp(): Promise { 991 | await this._pressKeyWithScript(NodePyATVInternalKeys.volumeUp); 992 | } 993 | 994 | /** 995 | * Send the "wakeup" command 996 | * @category Control 997 | * @deprecated 998 | */ 999 | async wakeup(): Promise { 1000 | await this._pressKeyWithScript(NodePyATVInternalKeys.wakeup); 1001 | } 1002 | 1003 | private async _pressKeyWithRemote(key: NodePyATVInternalKeys | string) { 1004 | const id = addRequestId(); 1005 | const parameters = getParameters(this.options); 1006 | 1007 | await request( 1008 | id, 1009 | NodePyATVExecutableType.atvremote, 1010 | [...parameters, key], 1011 | this.options, 1012 | ); 1013 | removeRequestId(id); 1014 | } 1015 | 1016 | private async _pressKeyWithScript(key: NodePyATVInternalKeys | string) { 1017 | const id = addRequestId(); 1018 | const parameters = getParameters(this.options); 1019 | 1020 | const result = await request( 1021 | id, 1022 | NodePyATVExecutableType.atvscript, 1023 | [...parameters, key], 1024 | this.options, 1025 | ); 1026 | if (typeof result !== 'object' || result.result !== 'success') { 1027 | throw new Error( 1028 | `Unable to parse pyatv response: ${JSON.stringify(result, null, ' ')}`, 1029 | ); 1030 | } 1031 | 1032 | removeRequestId(id); 1033 | } 1034 | 1035 | private applyState(newState: NodePyATVState): void { 1036 | this.events.applyStateAndEmitEvents(newState); 1037 | } 1038 | } 1039 | -------------------------------------------------------------------------------- /src/lib/fake-spawn.ts: -------------------------------------------------------------------------------- 1 | import { type SpawnOptions } from 'child_process'; 2 | import { EventEmitter } from 'events'; 3 | 4 | /** 5 | * @internal 6 | */ 7 | export class FakeChildProcess extends EventEmitter { 8 | args: ReadonlyArray; 9 | cmd: string; 10 | stderr: EventEmitter; 11 | stdin: FakeChildProcessStdIn; 12 | stdout: EventEmitter; 13 | timeout: NodeJS.Timeout | undefined; 14 | 15 | constructor( 16 | command: string, 17 | args: ReadonlyArray, 18 | options: SpawnOptions, 19 | callback: (cp: FakeChildProcessController) => void, 20 | ) { 21 | super(); 22 | 23 | this.cmd = command; 24 | this.args = args; 25 | this.timeout = setTimeout(() => { 26 | console.error( 27 | new Error( 28 | `FakeSpawn: Timeout for ${this.cmd} ${this.args.join(' ')}!`, 29 | ), 30 | ); 31 | }, 5000); 32 | 33 | this.stdout = new EventEmitter(); 34 | this.stderr = new EventEmitter(); 35 | this.stdin = new FakeChildProcessStdIn(); 36 | 37 | const controller = new FakeChildProcessController(this); 38 | setTimeout(() => callback(controller), 0); 39 | } 40 | 41 | kill(): void { 42 | this.emit('close', 0); 43 | if (this.timeout !== undefined) { 44 | clearTimeout(this.timeout); 45 | this.timeout = undefined; 46 | } 47 | } 48 | } 49 | 50 | /** 51 | * @internal 52 | */ 53 | export class FakeChildProcessController { 54 | _code: null | number; 55 | _cp: FakeChildProcess; 56 | 57 | constructor(cp: FakeChildProcess) { 58 | this._cp = cp; 59 | this._code = null; 60 | } 61 | 62 | cmd(): string { 63 | return this._cp.cmd; 64 | } 65 | 66 | code(exitCode: number): this { 67 | this._code = exitCode; 68 | return this; 69 | } 70 | 71 | end(content?: Record | string): this { 72 | if (content !== undefined) { 73 | this.stdout(content); 74 | } 75 | 76 | this._cp.emit('close', this._code || 0); 77 | if (this._cp.timeout !== undefined) { 78 | clearTimeout(this._cp.timeout); 79 | this._cp.timeout = undefined; 80 | } 81 | 82 | return this; 83 | } 84 | 85 | error(error: Error): this { 86 | this._cp.emit('error', error); 87 | return this; 88 | } 89 | 90 | // eslint-disable-next-line 91 | onStdIn(listener: (...args: any[]) => void): this { 92 | this._cp.stdin.on('data', listener); 93 | return this; 94 | } 95 | 96 | stderr(content: string): this { 97 | this._cp.stderr.emit('data', content); 98 | return this; 99 | } 100 | 101 | stdout(content: Record | string): this { 102 | this._cp.stdout.emit( 103 | 'data', 104 | typeof content === 'string' ? content : JSON.stringify(content), 105 | ); 106 | return this; 107 | } 108 | } 109 | 110 | /** 111 | * @internal 112 | */ 113 | export class FakeChildProcessStdIn extends EventEmitter { 114 | write(data: string): void { 115 | this.emit('data', Buffer.from(data)); 116 | } 117 | } 118 | 119 | /** 120 | * @internal 121 | */ 122 | export function createFakeSpawn( 123 | callback: (cp: FakeChildProcessController) => void, 124 | ): ( 125 | command: string, 126 | args: ReadonlyArray, 127 | options: SpawnOptions, 128 | ) => FakeChildProcess { 129 | return ( 130 | command: string, 131 | args: ReadonlyArray, 132 | options: SpawnOptions, 133 | ) => new FakeChildProcess(command, args, options, callback); 134 | } 135 | -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | export { default as NodePyATVDeviceEvent } from './device-event.js'; 4 | 5 | export { default as NodePyATVDeviceEvents } from './device-events.js'; 6 | export { default as NodePyATVDevice } from './device.js'; 7 | export { default as NodePyATVInstance } from './instance.js'; 8 | export { default } from './instance.js'; 9 | 10 | export { 11 | type NodePyATVDeviceOptions, 12 | NodePyATVDeviceState, 13 | type NodePyATVEventValueType, 14 | NodePyATVExecutableType, 15 | type NodePyATVFindAndInstanceOptions, 16 | type NodePyATVFindOptions, 17 | type NodePyATVFindResponseObject, 18 | type NodePyATVGetStateOptions, 19 | type NodePyATVInstanceOptions, 20 | NodePyATVKeys, 21 | NodePyATVListenerState, 22 | NodePyATVMediaType, 23 | NodePyATVPowerState, 24 | NodePyATVProtocol, 25 | NodePyATVRepeatState, 26 | type NodePyATVRequestOptions, 27 | type NodePyATVService, 28 | NodePyATVShuffleState, 29 | type NodePyATVState, 30 | type NodePyATVVersionResponse, 31 | } from './types.js'; 32 | -------------------------------------------------------------------------------- /src/lib/instance.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { promises as fsPromises } from 'fs'; 4 | import { dirname, join } from 'path'; 5 | import semver from 'semver'; 6 | import { fileURLToPath } from 'url'; 7 | 8 | import { NodePyATVDevice } from '../lib/index.js'; 9 | import { 10 | addRequestId, 11 | debug, 12 | getParameters, 13 | removeRequestId, 14 | request, 15 | } from './tools.js'; 16 | import { 17 | type NodePyATVDeviceOptions, 18 | NodePyATVExecutableType, 19 | type NodePyATVFindAndInstanceOptions, 20 | type NodePyATVFindResponseObject, 21 | type NodePyATVInstanceOptions, 22 | type NodePyATVInternalScanDevice, 23 | type NodePyATVVersionResponse, 24 | } from './types.js'; 25 | 26 | /** 27 | * Default class exported by `@sebbo2002/node-pyatv`. Use [[find]] to scan for devices in your local network. Use 28 | * [[device]] to connect to a known device by passing (at least) it's name and IP. 29 | * 30 | * ```typescript 31 | * import pyatv from '@sebbo2002/node-pyatv'; 32 | * ``` 33 | */ 34 | export default class NodePyATVInstance { 35 | private readonly options: NodePyATVInstanceOptions = {}; 36 | 37 | /** 38 | * Use this to apply [[NodePyATVInstanceOptions]] 39 | * (e.g. debug log method) to all further requests 40 | * 41 | * ```typescript 42 | * import pyatv from '@sebbo2002/node-pyatv'; 43 | * const myPyatv = new pyatv({debug: true}); 44 | * const devices = myPyatv.find(); 45 | * console.log(devices); 46 | * ``` 47 | * @param options 48 | */ 49 | public constructor(options: NodePyATVInstanceOptions = {}) { 50 | this.options = Object.assign({}, options); 51 | } 52 | 53 | /** 54 | * Checks if pyatv is installed and ready to be used. 55 | * Will throw an error if not. 56 | * 57 | * @param options 58 | */ 59 | public static async check( 60 | options: NodePyATVInstanceOptions = {}, 61 | ): Promise { 62 | const versions = await this.version(options); 63 | if (!versions.pyatv) { 64 | throw new Error('Unable to find pyatv. Is it installed?'); 65 | } 66 | if (semver.lt(versions.pyatv, '0.6.0')) { 67 | throw new Error( 68 | "Found pyatv, but unforunately it's too old. Please update pyatv.", 69 | ); 70 | } 71 | 72 | try { 73 | await this.find(options); 74 | } catch (error) { 75 | throw new Error( 76 | `Unable to scan for devices: ${String(error).replace('Error: ', '')}`, 77 | ); 78 | } 79 | } 80 | 81 | /** 82 | * Create a [[NodePyATVDevice]] to query the state and control it. 83 | * At least `host` and `name` are required. 84 | * 85 | * @param options 86 | */ 87 | public static device(options: NodePyATVDeviceOptions): NodePyATVDevice { 88 | return new NodePyATVDevice(options); 89 | } 90 | /** 91 | * Scan the network for Apple TVs by using pyatv's atvscript. See [[NodePyATVFindAndInstanceOptions]] 92 | * for the options allowed. Use the `host` / `hosts` attribute to filter by IP addresses. Resolves with 93 | * an array of [[NodePyATVDevice]]. 94 | * 95 | * ```typescript 96 | * import pyatv from '@sebbo2002/node-pyatv'; 97 | * const devices = await pyatv.find(); 98 | * console.log(devices); 99 | * ``` 100 | * 101 | * Normally `node-pyatv` ignores error messages if at least one device has been found, but if you 102 | * always want to receive the error messages, you can set the second argument to `true`: 103 | * 104 | * ```typescript 105 | * const result = await pyatv.find({}, true); 106 | * console.log(result.devices); 107 | * console.log(result.errors); 108 | * ``` 109 | * 110 | * @param options 111 | */ 112 | public static async find( 113 | options?: NodePyATVFindAndInstanceOptions, 114 | ): Promise; 115 | public static async find( 116 | options: NodePyATVFindAndInstanceOptions, 117 | returnDevicesAndErrors: true, 118 | ): Promise; 119 | public static async find( 120 | options: NodePyATVFindAndInstanceOptions = {}, 121 | returnDevicesAndErrors?: boolean, 122 | ): Promise { 123 | const id = addRequestId(); 124 | const parameters = getParameters(options); 125 | 126 | const devices: NodePyATVDevice[] = []; 127 | const errors: Record[] = []; 128 | const response = await request( 129 | id, 130 | NodePyATVExecutableType.atvscript, 131 | [...parameters, 'scan'], 132 | { ...(options || {}), allowMultipleResponses: true }, 133 | ); 134 | 135 | for (const item of response) { 136 | if ( 137 | typeof item === 'object' && 138 | 'result' in item && 139 | item.result === 'failure' 140 | ) { 141 | errors.push(item); 142 | } else if ( 143 | typeof item === 'object' && 144 | 'result' in item && 145 | item.result === 'success' && 146 | Array.isArray(item.devices) 147 | ) { 148 | devices.push( 149 | ...item.devices.map((device: NodePyATVInternalScanDevice) => 150 | this.device( 151 | Object.assign({}, options, { 152 | allIDs: device.all_identifiers, 153 | host: device.address, 154 | id: device.identifier, 155 | mac: device.device_info?.mac || undefined, 156 | model: device.device_info?.model, 157 | modelName: device.device_info?.model_str, 158 | name: device.name, 159 | os: device.device_info?.operating_system, 160 | services: device.services, 161 | version: device.device_info?.version, 162 | }), 163 | ), 164 | ), 165 | ); 166 | } else { 167 | throw new Error( 168 | `Unable to parse pyatv response: ${JSON.stringify(item, null, ' ')}`, 169 | ); 170 | } 171 | } 172 | removeRequestId(id); 173 | 174 | if (returnDevicesAndErrors) { 175 | return { devices, errors }; 176 | } 177 | if (!devices.length && errors.length) { 178 | throw new Error( 179 | `Unable to find any devices, but received ${errors.length} error${errors.length !== 1 ? 's' : ''}: ${JSON.stringify(errors, null, ' ')}`, 180 | ); 181 | } 182 | 183 | return devices; 184 | } 185 | 186 | /** 187 | * Resolves with the version of pyatv and of the module itself. 188 | * If a value can't be found, null is returned instead. 189 | * 190 | * @param options 191 | */ 192 | public static async version( 193 | options: NodePyATVInstanceOptions = {}, 194 | ): Promise { 195 | const id = addRequestId(); 196 | let pyatv = null; 197 | let module = null; 198 | 199 | try { 200 | pyatv = (await request( 201 | id, 202 | NodePyATVExecutableType.atvremote, 203 | ['--version'], 204 | options, 205 | )) as string; 206 | } catch (error) { 207 | debug(id, `Unable to get pyatv version due to ${error}`, options); 208 | } 209 | 210 | if (pyatv && pyatv.substr(0, 10) === 'atvremote ') { 211 | pyatv = pyatv.substr(10); 212 | } 213 | if (!semver.valid(pyatv)) { 214 | debug( 215 | id, 216 | `String "${pyatv}" is not a valid pyatv version, set it to null`, 217 | options, 218 | ); 219 | pyatv = null; 220 | } 221 | 222 | try { 223 | const packageJsonPath = join( 224 | dirname(fileURLToPath(import.meta.url)), 225 | '..', 226 | '..', 227 | 'package.json', 228 | ); 229 | const json = JSON.parse( 230 | await fsPromises.readFile(packageJsonPath, 'utf8'), 231 | ); 232 | module = json?.version || null; 233 | } catch (error) { 234 | debug(id, `Unable to get module version due to ${error}`, options); 235 | } 236 | if (module && !semver.valid(module)) { 237 | debug( 238 | id, 239 | `String "${module}" is not a valid module version, set it to null`, 240 | options, 241 | ); 242 | module = null; 243 | } 244 | 245 | removeRequestId(id); 246 | return { 247 | module, 248 | pyatv, 249 | }; 250 | } 251 | 252 | /** 253 | * Checks if pyatv is installed and ready to be used. 254 | * Will throw an error if not. 255 | * 256 | * @param options 257 | */ 258 | public async check(options: NodePyATVInstanceOptions = {}): Promise { 259 | return NodePyATVInstance.check( 260 | Object.assign({}, this.options, options), 261 | ); 262 | } 263 | 264 | /** 265 | * Create a [[NodePyATVDevice]] to query the state and control it. 266 | * At least `host` and `name` are required. 267 | * 268 | * @param options 269 | */ 270 | public device(options: NodePyATVDeviceOptions): NodePyATVDevice { 271 | return NodePyATVInstance.device( 272 | Object.assign({}, this.options, options), 273 | ); 274 | } 275 | 276 | /** 277 | * Scan the network for Apple TVs by using pyatv's atvscript. See [[NodePyATVFindAndInstanceOptions]] 278 | * for the options allowed. Use the `host` / `hosts` attribute to filter by IP addresses. Resolves with 279 | * an array of [[NodePyATVDevice]]. 280 | * 281 | * ```typescript 282 | * import pyatv from '@sebbo2002/node-pyatv'; 283 | * const myPyATV = new pyatv({debug: true}); 284 | * const devices = await myPyATV.find(); 285 | * console.log(devices); 286 | * ``` 287 | * 288 | * @param options 289 | */ 290 | public async find( 291 | options: NodePyATVFindAndInstanceOptions = {}, 292 | ): Promise { 293 | return NodePyATVInstance.find(Object.assign({}, this.options, options)); 294 | } 295 | 296 | /** 297 | * Resolves with the version of pyatv and of the module itself. 298 | * If a value can't be found, null is returned instead. 299 | * 300 | * @param options 301 | */ 302 | public async version( 303 | options: NodePyATVInstanceOptions = {}, 304 | ): Promise { 305 | return NodePyATVInstance.version( 306 | Object.assign({}, this.options, options), 307 | ); 308 | } 309 | } 310 | -------------------------------------------------------------------------------- /src/lib/tools.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { ChildProcess, spawn, type SpawnOptions } from 'child_process'; 4 | 5 | import { FakeChildProcess } from './fake-spawn.js'; 6 | import { 7 | NodePyATVDeviceState, 8 | NodePyATVExecutableType, 9 | type NodePyATVFindAndInstanceOptions, 10 | NodePyATVFocusState, 11 | type NodePyATVInstanceOptions, 12 | type NodePyATVInternalState, 13 | NodePyATVMediaType, 14 | NodePyATVPowerState, 15 | NodePyATVRepeatState, 16 | type NodePyATVRequestOptions, 17 | NodePyATVShuffleState, 18 | type NodePyATVState, 19 | } from './types.js'; 20 | 21 | const requestIds: string[] = []; 22 | 23 | type NodePyATVScriptResponse = 24 | O['allowMultipleResponses'] extends true 25 | ? Record[] 26 | : Record; 27 | 28 | export function addRequestId(): string { 29 | let id = '?'; 30 | 31 | for (let i = 0; i < 1000; i += 1) { 32 | id = Math.round(Math.random() * (i + 6) * 36) 33 | .toString(36) 34 | .toUpperCase(); 35 | if (!requestIds.includes(id)) { 36 | requestIds.push(id); 37 | break; 38 | } 39 | } 40 | 41 | return id; 42 | } 43 | 44 | export function compareOutputDevices( 45 | a: NodePyATVState['outputDevices'], 46 | b: NodePyATVState['outputDevices'], 47 | ) { 48 | return Boolean( 49 | Array.isArray(a) && 50 | Array.isArray(b) && 51 | JSON.stringify( 52 | a.sort((a, b) => (a.identifier < b.identifier ? -1 : 1)), 53 | ) === 54 | JSON.stringify( 55 | b.sort((a, b) => (a.identifier < b.identifier ? -1 : 1)), 56 | ), 57 | ); 58 | } 59 | 60 | export function debug( 61 | id: string, 62 | message: string, 63 | options: NodePyATVInstanceOptions, 64 | ): void { 65 | if (options.debug) { 66 | const log = 67 | typeof options.debug === 'function' ? options.debug : console.log; 68 | const enableColors = !process.env.NO_COLOR && !options.noColors; 69 | 70 | const parts = [ 71 | enableColors ? '\x1b[0m' : '', // Color Reset 72 | enableColors ? '\x1b[90m' : '', // Grey 73 | '[node-pyatv][', 74 | enableColors ? '\x1b[37m' : '', // Light Grey 75 | id, 76 | enableColors ? '\x1b[90m' : '', // Grey 77 | '] ', 78 | enableColors ? '\x1b[0m' : '', // Color Reset 79 | message, 80 | ]; 81 | 82 | log.apply(null, [parts.join('')]); 83 | } 84 | } 85 | 86 | export function execute( 87 | requestId: string, 88 | executableType: NodePyATVExecutableType, 89 | parameters: string[], 90 | options: NodePyATVInstanceOptions, 91 | ): ChildProcess | FakeChildProcess { 92 | const executable = getExecutable(executableType, options); 93 | const mySpawn: ( 94 | command: string, 95 | args: Array, 96 | options: SpawnOptions, 97 | ) => ChildProcess | FakeChildProcess = 98 | typeof options.spawn === 'function' ? options.spawn : spawn; 99 | 100 | debug(requestId, `${executable} ${parameters.join(' ')}`, options); 101 | const child = mySpawn(executable, parameters, { 102 | env: process.env, 103 | }); 104 | 105 | /* eslint-disable @typescript-eslint/no-explicit-any*/ 106 | const onStdOut = (data: any) => 107 | debug(requestId, `stdout: ${String(data).trim()}`, options); 108 | const onStdErr = (data: any) => 109 | debug(requestId, `stderr: ${String(data).trim()}`, options); 110 | const onError = (data: any) => 111 | debug(requestId, `error: ${String(data).trim()}`, options); 112 | /* eslint-enable @typescript-eslint/no-explicit-any*/ 113 | 114 | const onClose = (code: number) => { 115 | debug(requestId, `${executable} exited with code: ${code}`, options); 116 | 117 | if (child.stdout) { 118 | child.stdout.off('data', onStdOut); 119 | } 120 | if (child.stderr) { 121 | child.stderr.off('data', onStdErr); 122 | } 123 | 124 | child.off('error', onError); 125 | child.off('close', onClose); 126 | }; 127 | 128 | if (child.stdout) { 129 | child.stdout.on('data', onStdOut); 130 | } 131 | if (child.stderr) { 132 | child.stderr.on('data', onStdErr); 133 | } 134 | 135 | child.on('error', onError); 136 | child.on('close', onClose); 137 | 138 | return child; 139 | } 140 | 141 | export function getExecutable( 142 | executable: NodePyATVExecutableType, 143 | options: NodePyATVInstanceOptions, 144 | ): string { 145 | if ( 146 | executable === NodePyATVExecutableType.atvremote && 147 | typeof options.atvremotePath === 'string' 148 | ) { 149 | return options.atvremotePath; 150 | } else if ( 151 | executable === NodePyATVExecutableType.atvscript && 152 | typeof options.atvscriptPath === 'string' 153 | ) { 154 | return options.atvscriptPath; 155 | } else { 156 | return executable; 157 | } 158 | } 159 | 160 | export function getParameters( 161 | options: NodePyATVFindAndInstanceOptions = {}, 162 | ): string[] { 163 | const parameters: string[] = []; 164 | 165 | if (options.hosts) { 166 | parameters.push('-s', options.hosts.join(',')); 167 | } else if (options.host) { 168 | parameters.push('-s', options.host); 169 | } 170 | if (options.id) { 171 | parameters.push('-i', options.id); 172 | } 173 | if (options.protocol) { 174 | parameters.push('--protocol', options.protocol); 175 | } 176 | if (options.dmapCredentials) { 177 | parameters.push('--dmap-credentials', options.dmapCredentials); 178 | } 179 | if (options.mrpCredentials) { 180 | parameters.push('--mrp-credentials', options.mrpCredentials); 181 | } 182 | if (options.airplayCredentials) { 183 | parameters.push('--airplay-credentials', options.airplayCredentials); 184 | } 185 | if (options.companionCredentials) { 186 | parameters.push( 187 | '--companion-credentials', 188 | options.companionCredentials, 189 | ); 190 | } 191 | if (options.raopCredentials) { 192 | parameters.push('--raop-credentials', options.raopCredentials); 193 | } 194 | 195 | return parameters; 196 | } 197 | export function parseState( 198 | input: NodePyATVInternalState, 199 | id: string, 200 | options: NodePyATVInstanceOptions, 201 | ): NodePyATVState { 202 | const d = (msg: string) => debug(id, msg, options); 203 | const result: NodePyATVState = { 204 | album: null, 205 | app: null, 206 | appId: null, 207 | artist: null, 208 | contentIdentifier: null, 209 | dateTime: null, 210 | deviceState: null, 211 | episodeNumber: null, 212 | focusState: null, 213 | genre: null, 214 | hash: null, 215 | iTunesStoreIdentifier: null, 216 | mediaType: null, 217 | outputDevices: null, 218 | position: null, 219 | powerState: null, 220 | repeat: null, 221 | seasonNumber: null, 222 | seriesName: null, 223 | shuffle: null, 224 | title: null, 225 | totalTime: null, 226 | volume: null, 227 | }; 228 | if (!input || typeof input !== 'object') { 229 | return result; 230 | } 231 | if (input.exception) { 232 | let errorStr = 'Got pyatv Error: ' + input.exception; 233 | if (input.stacktrace) { 234 | errorStr += '\n\npyatv Stacktrace:\n' + input.stacktrace; 235 | } 236 | 237 | throw new Error(errorStr); 238 | } 239 | 240 | // datetime 241 | if (typeof input.datetime === 'string') { 242 | const date = new Date(input.datetime); 243 | if (!isNaN(date.getTime())) { 244 | result.dateTime = date; 245 | } else { 246 | d(`Invalid datetime value ${input.datetime}, ignore attribute`); 247 | } 248 | } else { 249 | d(`No datetime value found in input (${JSON.stringify(input)})`); 250 | } 251 | 252 | // hash 253 | parseStateStringAttr(input, result, 'hash', 'hash', d); 254 | 255 | // mediaType 256 | if (typeof input.media_type === 'string') { 257 | const validValues = Object.keys(NodePyATVMediaType).map((o) => 258 | String(o), 259 | ); 260 | if (validValues.includes(input.media_type)) { 261 | result.mediaType = 262 | NodePyATVMediaType[input.media_type as NodePyATVMediaType]; 263 | } else { 264 | d( 265 | `Unsupported mediaType value ${input.media_type}, ignore attribute`, 266 | ); 267 | } 268 | } else { 269 | d(`No mediaType value found in input (${JSON.stringify(input)})`); 270 | } 271 | 272 | // deviceState 273 | if (typeof input.device_state === 'string') { 274 | const validValues = Object.keys(NodePyATVDeviceState).map((o) => 275 | String(o), 276 | ); 277 | if (validValues.includes(input.device_state)) { 278 | result.deviceState = 279 | NodePyATVDeviceState[ 280 | input.device_state as NodePyATVDeviceState 281 | ]; 282 | } else { 283 | d( 284 | `Unsupported deviceState value ${input.device_state}, ignore attribute`, 285 | ); 286 | } 287 | } else { 288 | d(`No deviceState value found in input (${JSON.stringify(input)})`); 289 | } 290 | 291 | // title 292 | parseStateStringAttr(input, result, 'title', 'title', d); 293 | 294 | // artist 295 | parseStateStringAttr(input, result, 'artist', 'artist', d); 296 | 297 | // album 298 | parseStateStringAttr(input, result, 'album', 'album', d); 299 | 300 | // genre 301 | parseStateStringAttr(input, result, 'genre', 'genre', d); 302 | 303 | // totalTime 304 | parseStateNumberAttr(input, result, 'total_time', 'totalTime', d); 305 | 306 | // position 307 | parseStateNumberAttr(input, result, 'position', 'position', d); 308 | 309 | // shuffle 310 | if (typeof input.shuffle === 'string') { 311 | const validValues = Object.keys(NodePyATVShuffleState).map((o) => 312 | String(o), 313 | ); 314 | if (validValues.includes(input.shuffle)) { 315 | result.shuffle = 316 | NodePyATVShuffleState[input.shuffle as NodePyATVShuffleState]; 317 | } else { 318 | d(`Unsupported shuffle value ${input.shuffle}, ignore attribute`); 319 | } 320 | } else { 321 | d(`No shuffle value found in input (${JSON.stringify(input)})`); 322 | } 323 | 324 | // repeat 325 | if (typeof input.repeat === 'string') { 326 | const validValues = Object.keys(NodePyATVRepeatState).map((o) => 327 | String(o), 328 | ); 329 | if (validValues.includes(input.repeat)) { 330 | result.repeat = 331 | NodePyATVRepeatState[input.repeat as NodePyATVRepeatState]; 332 | } else { 333 | d(`Unsupported repeat value ${input.repeat}, ignore attribute`); 334 | } 335 | } else { 336 | d(`No repeat value found in input (${JSON.stringify(input)})`); 337 | } 338 | 339 | // app 340 | parseStateStringAttr(input, result, 'app', 'app', d); 341 | parseStateStringAttr(input, result, 'app_id', 'appId', d); 342 | 343 | // powerState 344 | if (typeof input.power_state === 'string') { 345 | const validValues = Object.keys(NodePyATVPowerState).map((o) => 346 | String(o), 347 | ); 348 | if (validValues.includes(input.power_state)) { 349 | result.powerState = 350 | NodePyATVPowerState[input.power_state as NodePyATVPowerState]; 351 | } else { 352 | d( 353 | `Unsupported powerState value ${input.power_state}, ignore attribute`, 354 | ); 355 | } 356 | } else { 357 | d(`No powerState value found in input (${JSON.stringify(input)})`); 358 | } 359 | 360 | // volume 361 | parseStateNumberAttr(input, result, 'volume', 'volume', d); 362 | 363 | // focusState 364 | if (typeof input.focus_state === 'string') { 365 | const validValues = Object.keys(NodePyATVFocusState).map((o) => 366 | String(o), 367 | ); 368 | if (validValues.includes(input.focus_state)) { 369 | result.focusState = 370 | NodePyATVFocusState[input.focus_state as NodePyATVFocusState]; 371 | } else { 372 | d( 373 | `Unsupported focusState value ${input.focus_state}, ignore attribute`, 374 | ); 375 | } 376 | } else { 377 | d(`No focusState value found in input (${JSON.stringify(input)})`); 378 | } 379 | 380 | // outputDevices 381 | if (Array.isArray(input.output_devices)) { 382 | result.outputDevices = input.output_devices; 383 | } 384 | 385 | // contentIdentifier 386 | parseStateStringAttr( 387 | input, 388 | result, 389 | 'content_identifier', 390 | 'contentIdentifier', 391 | d, 392 | ); 393 | 394 | // iTunesStoreIdentifier 395 | parseStateNumberAttr( 396 | input, 397 | result, 398 | 'itunes_store_identifier', 399 | 'iTunesStoreIdentifier', 400 | d, 401 | ); 402 | 403 | // episodeNumber 404 | parseStateNumberAttr(input, result, 'episode_number', 'episodeNumber', d); 405 | 406 | // seasonNumber 407 | parseStateNumberAttr(input, result, 'season_number', 'seasonNumber', d); 408 | 409 | // seriesName 410 | parseStateStringAttr(input, result, 'series_name', 'seriesName', d); 411 | 412 | return result; 413 | } 414 | export function removeRequestId(id: string | undefined): void { 415 | if (id && requestIds.includes(id)) { 416 | requestIds.splice(requestIds.indexOf(id), 1); 417 | } 418 | } 419 | 420 | export async function request( 421 | requestId: string, 422 | executableType: NodePyATVExecutableType.atvscript, 423 | parameters: string[], 424 | options: O, 425 | ): Promise>; 426 | 427 | export async function request( 428 | requestId: string, 429 | executableType: NodePyATVExecutableType.atvremote, 430 | parameters: string[], 431 | options: O, 432 | ): Promise; 433 | 434 | export async function request( 435 | requestId: string, 436 | executableType: NodePyATVExecutableType, 437 | parameters: string[], 438 | options: O, 439 | ): Promise | Record[] | string> { 440 | const result = { 441 | code: 0, 442 | stderr: '', 443 | stdout: '', 444 | }; 445 | 446 | await new Promise((resolve, reject) => { 447 | const pyatv = execute(requestId, executableType, parameters, options); 448 | 449 | /* eslint-disable @typescript-eslint/no-explicit-any*/ 450 | const onStdOut = (data: any) => (result.stdout += String(data).trim()); 451 | const onStdErr = (data: any) => (result.stderr += String(data).trim()); 452 | const onError = (data: any) => 453 | reject(data instanceof Error ? data : new Error(String(data))); 454 | /* eslint-enable @typescript-eslint/no-explicit-any*/ 455 | 456 | const onClose: (code: number) => void = (code: number) => { 457 | result.code = code; 458 | 459 | if (pyatv.stdout) { 460 | pyatv.stdout.off('data', onStdOut); 461 | } 462 | if (pyatv.stderr) { 463 | pyatv.stderr.off('data', onStdErr); 464 | } 465 | 466 | pyatv.off('error', onError); 467 | pyatv.off('close', onClose); 468 | resolve(undefined); 469 | }; 470 | 471 | if (pyatv.stdout) { 472 | pyatv.stdout.on('data', onStdOut); 473 | } 474 | if (pyatv.stderr) { 475 | pyatv.stderr.on('data', onStdErr); 476 | } 477 | 478 | pyatv.on('error', onError); 479 | pyatv.on('close', onClose); 480 | }); 481 | 482 | if (result.stderr.length > 0) { 483 | const msg = `Unable to execute request ${requestId}: ${result.stderr}`; 484 | debug(requestId, msg, options); 485 | throw new Error(msg); 486 | } 487 | 488 | if (executableType === NodePyATVExecutableType.atvscript) { 489 | try { 490 | // response with multiple lines 491 | // https://github.com/sebbo2002/node-pyatv/issues/324 492 | if (options.allowMultipleResponses) { 493 | return result.stdout 494 | .split('\n') 495 | .filter((line) => line.trim().length > 0) 496 | .map((line) => JSON.parse(line)); 497 | } 498 | 499 | return JSON.parse(result.stdout); 500 | } catch (error) { 501 | const msg = `Unable to parse result ${requestId} json: ${error}`; 502 | debug(requestId, msg, options); 503 | throw new Error(msg); 504 | } 505 | } 506 | 507 | return result.stdout; 508 | } 509 | 510 | function parseStateNumberAttr( 511 | input: NodePyATVInternalState, 512 | output: NodePyATVState, 513 | inputAttr: 514 | | 'episode_number' 515 | | 'itunes_store_identifier' 516 | | 'position' 517 | | 'season_number' 518 | | 'total_time' 519 | | 'volume', 520 | outputAttr: 521 | | 'episodeNumber' 522 | | 'iTunesStoreIdentifier' 523 | | 'position' 524 | | 'seasonNumber' 525 | | 'totalTime' 526 | | 'volume', 527 | d: (msg: string) => void, 528 | ): void { 529 | if (typeof input[inputAttr] === 'number') { 530 | output[outputAttr] = input[inputAttr]; 531 | return; 532 | } 533 | 534 | d(`No ${outputAttr} value found in input (${JSON.stringify(input)})`); 535 | } 536 | 537 | function parseStateStringAttr( 538 | input: NodePyATVInternalState, 539 | output: NodePyATVState, 540 | inputAttr: 541 | | 'album' 542 | | 'app' 543 | | 'app_id' 544 | | 'artist' 545 | | 'content_identifier' 546 | | 'genre' 547 | | 'hash' 548 | | 'series_name' 549 | | 'title', 550 | outputAttr: 551 | | 'album' 552 | | 'app' 553 | | 'appId' 554 | | 'artist' 555 | | 'contentIdentifier' 556 | | 'genre' 557 | | 'hash' 558 | | 'seriesName' 559 | | 'title', 560 | d: (msg: string) => void, 561 | ): void { 562 | if (typeof input[inputAttr] === 'string') { 563 | output[outputAttr] = input[inputAttr] as string; 564 | return; 565 | } 566 | 567 | d(`No ${outputAttr} value found in input (${JSON.stringify(input)})`); 568 | } 569 | -------------------------------------------------------------------------------- /src/lib/types.ts: -------------------------------------------------------------------------------- 1 | import { ChildProcess, type SpawnOptions } from 'child_process'; 2 | 3 | import type NodePyATVDevice from './device.js'; 4 | 5 | import { FakeChildProcess } from './fake-spawn.js'; 6 | 7 | export enum NodePyATVDeviceState { 8 | idle = 'idle', 9 | loading = 'loading', 10 | paused = 'paused', 11 | playing = 'playing', 12 | seeking = 'seeking', 13 | stopped = 'stopped', 14 | } 15 | 16 | export enum NodePyATVExecutableType { 17 | atvremote = 'atvremote', 18 | atvscript = 'atvscript', 19 | } 20 | 21 | export enum NodePyATVFocusState { 22 | // @deprecated Please use `NodePyATVFocusState.focused` instead 23 | focued = 'focused', 24 | 25 | // Doublicate enum value due to typo, will be removed in next breaking change 26 | // @todo remove in next breaking change 27 | // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values 28 | focused = 'focused', 29 | 30 | unfocused = 'unfocused', 31 | } 32 | 33 | /** 34 | * @internal 35 | */ 36 | export enum NodePyATVInternalKeys { 37 | down = 'down', 38 | home = 'home', 39 | homeHold = 'home_hold', 40 | left = 'left', 41 | menu = 'menu', 42 | next = 'next', 43 | pause = 'pause', 44 | play = 'play', 45 | playPause = 'play_pause', 46 | previous = 'previous', 47 | right = 'right', 48 | select = 'select', 49 | skipBackward = 'skip_backward', 50 | skipForward = 'skip_forward', 51 | stop = 'stop', 52 | suspend = 'suspend', 53 | topMenu = 'top_menu', 54 | turnOff = 'turn_off', 55 | turnOn = 'turn_on', 56 | up = 'up', 57 | volumeDown = 'volume_down', 58 | volumeUp = 'volume_up', 59 | wakeup = 'wakeup', 60 | } 61 | 62 | export enum NodePyATVKeys { 63 | down = 'down', 64 | home = 'home', 65 | homeHold = 'homeHold', 66 | left = 'left', 67 | menu = 'menu', 68 | next = 'next', 69 | pause = 'pause', 70 | play = 'play', 71 | playPause = 'playPause', 72 | previous = 'previous', 73 | right = 'right', 74 | select = 'select', 75 | skipBackward = 'skipBackward', 76 | skipForward = 'skipForward', 77 | stop = 'stop', 78 | suspend = 'suspend', 79 | topMenu = 'topMenu', 80 | turnOff = 'turnOff', 81 | turnOn = 'turnOn', 82 | up = 'up', 83 | volumeDown = 'volumeDown', 84 | volumeUp = 'volumeUp', 85 | wakeup = 'wakeup', 86 | } 87 | 88 | export enum NodePyATVListenerState { 89 | stopped, 90 | starting, 91 | started, 92 | stopping, 93 | } 94 | 95 | export enum NodePyATVMediaType { 96 | music = 'music', 97 | tv = 'tv', 98 | unknown = 'unknown', 99 | video = 'video', 100 | } 101 | 102 | export enum NodePyATVPowerState { 103 | off = 'off', 104 | on = 'on', 105 | } 106 | 107 | export enum NodePyATVProtocol { 108 | airplay = 'airplay', 109 | dmap = 'dmap', 110 | mdns = 'mdns', 111 | mrp = 'mrp', 112 | } 113 | 114 | export enum NodePyATVRepeatState { 115 | all = 'all', 116 | off = 'off', 117 | track = 'track', 118 | } 119 | 120 | export enum NodePyATVShuffleState { 121 | albums = 'albums', 122 | off = 'off', 123 | songs = 'songs', 124 | } 125 | 126 | export interface NodePyATVApp { 127 | id: string; 128 | launch: () => Promise; 129 | name: string; 130 | } 131 | 132 | export interface NodePyATVDeviceOptions 133 | extends NodePyATVFindAndInstanceOptions { 134 | allIDs?: string[]; 135 | host?: string; 136 | mac?: string; 137 | model?: string; 138 | modelName?: string; 139 | name: string; 140 | os?: string; 141 | services?: NodePyATVService[]; 142 | version?: string; 143 | } 144 | 145 | export type NodePyATVEventValueType = 146 | | NodePyATVDeviceState 147 | | NodePyATVMediaType 148 | | NodePyATVRepeatState 149 | | NodePyATVShuffleState 150 | | number 151 | | string; 152 | 153 | export interface NodePyATVFindAndInstanceOptions 154 | extends NodePyATVFindOptions, 155 | NodePyATVInstanceOptions {} 156 | 157 | export interface NodePyATVFindOptions { 158 | airplayCredentials?: string; 159 | companionCredentials?: string; 160 | dmapCredentials?: string; 161 | host?: string; 162 | hosts?: string[]; 163 | id?: string; 164 | mrpCredentials?: string; 165 | protocol?: NodePyATVProtocol; 166 | raopCredentials?: string; 167 | } 168 | 169 | export interface NodePyATVFindResponseObject { 170 | devices: NodePyATVDevice[]; 171 | errors: Record[]; 172 | } 173 | 174 | export interface NodePyATVGetStateOptions { 175 | maxAge?: number; 176 | } 177 | 178 | export interface NodePyATVInstanceOptions { 179 | atvremotePath?: string; 180 | atvscriptPath?: string; 181 | debug?: ((msg: string) => void) | true; 182 | noColors?: true; 183 | spawn?: ( 184 | command: string, 185 | args: Array, 186 | options: SpawnOptions, 187 | ) => ChildProcess | FakeChildProcess; 188 | } 189 | 190 | /** 191 | * @internal 192 | */ 193 | export interface NodePyATVInternalScanDevice { 194 | address: string; 195 | all_identifiers: string[]; 196 | device_info?: { 197 | mac: null | string; 198 | model: string; 199 | model_str: string; 200 | operating_system: string; 201 | version: string; 202 | }; 203 | identifier: string; 204 | name: string; 205 | services?: NodePyATVService[]; 206 | } 207 | 208 | /** 209 | * @internal 210 | */ 211 | export interface NodePyATVInternalState { 212 | album?: string | unknown; 213 | app?: string | unknown; 214 | app_id?: string | unknown; 215 | artist?: string | unknown; 216 | connection?: string | unknown; 217 | content_identifier?: null | string; 218 | datetime?: string | unknown; 219 | device_state?: string | unknown; 220 | episode_number?: null | number; 221 | exception?: string | unknown; 222 | focus_state?: string | unknown; 223 | genre?: string | unknown; 224 | hash?: string | unknown; 225 | itunes_store_identifier?: null | number; 226 | media_type?: string | unknown; 227 | output_devices?: Array<{ identifier: string; name: string }> | null; 228 | position?: 1 | unknown; 229 | power_state?: string | unknown; 230 | push_updates?: string | unknown; 231 | repeat?: string | unknown; 232 | result?: string | unknown; 233 | season_number?: null | number; 234 | series_name?: null | string; 235 | shuffle?: string | unknown; 236 | stacktrace?: string | unknown; 237 | title?: string | unknown; 238 | total_time?: number | unknown; 239 | volume?: number | unknown; 240 | } 241 | 242 | export interface NodePyATVRequestOptions extends NodePyATVInstanceOptions { 243 | allowMultipleResponses?: boolean; 244 | } 245 | 246 | export interface NodePyATVService { 247 | port: number; 248 | protocol: NodePyATVProtocol; 249 | } 250 | 251 | export interface NodePyATVState { 252 | album: null | string; 253 | app: null | string; 254 | appId: null | string; 255 | artist: null | string; 256 | contentIdentifier: null | string; 257 | dateTime: Date | null; 258 | deviceState: NodePyATVDeviceState | null; 259 | episodeNumber: null | number; 260 | focusState: NodePyATVFocusState | null; 261 | genre: null | string; 262 | hash: null | string; 263 | iTunesStoreIdentifier: null | number; 264 | mediaType: NodePyATVMediaType | null; 265 | outputDevices: Array<{ identifier: string; name: string }> | null; 266 | position: null | number; 267 | powerState: NodePyATVPowerState | null; 268 | repeat: NodePyATVRepeatState | null; 269 | seasonNumber: null | number; 270 | seriesName: null | string; 271 | shuffle: NodePyATVShuffleState | null; 272 | title: null | string; 273 | totalTime: null | number; 274 | volume: null | number; 275 | } 276 | 277 | export type NodePyATVStateIndex = keyof NodePyATVState; 278 | 279 | export interface NodePyATVVersionResponse { 280 | module: null | string; 281 | pyatv: null | string; 282 | } 283 | -------------------------------------------------------------------------------- /test/device-event.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import assert from 'assert'; 4 | 5 | import NodePyATVDeviceEvent from '../src/lib/device-event.js'; 6 | import NodePyATVDevice from '../src/lib/device.js'; 7 | 8 | describe('NodePyATVDeviceEvent', function () { 9 | describe('get key()', function () { 10 | it('should work', function () { 11 | const event = new NodePyATVDeviceEvent({ 12 | device: new NodePyATVDevice({ 13 | host: '192.168.178.2', 14 | name: 'My Testinstance', 15 | }), 16 | key: 'genre', 17 | new: 'Rock', 18 | old: 'Jazz', 19 | }); 20 | 21 | assert.strictEqual(event.key, 'genre'); 22 | }); 23 | }); 24 | 25 | describe('get oldValue()', function () { 26 | it('should work', function () { 27 | const event = new NodePyATVDeviceEvent({ 28 | device: new NodePyATVDevice({ 29 | host: '192.168.178.2', 30 | name: 'My Testinstance', 31 | }), 32 | key: 'genre', 33 | new: 'Rock', 34 | old: 'Jazz', 35 | }); 36 | 37 | assert.strictEqual(event.oldValue, 'Jazz'); 38 | }); 39 | }); 40 | 41 | describe('get newValue()', function () { 42 | it('should work', function () { 43 | const event = new NodePyATVDeviceEvent({ 44 | device: new NodePyATVDevice({ 45 | host: '192.168.178.2', 46 | name: 'My Testinstance', 47 | }), 48 | key: 'genre', 49 | new: 'Rock', 50 | old: 'Jazz', 51 | }); 52 | 53 | assert.strictEqual(event.newValue, 'Rock'); 54 | }); 55 | }); 56 | 57 | describe('get value()', function () { 58 | it('should work', function () { 59 | const event = new NodePyATVDeviceEvent({ 60 | device: new NodePyATVDevice({ 61 | host: '192.168.178.2', 62 | name: 'My Testinstance', 63 | }), 64 | key: 'genre', 65 | new: 'Rock', 66 | old: 'Jazz', 67 | }); 68 | 69 | assert.strictEqual(event.value, 'Rock'); 70 | }); 71 | }); 72 | 73 | describe('get device()', function () { 74 | it('should work', function () { 75 | const device = new NodePyATVDevice({ 76 | host: '192.168.178.2', 77 | name: 'My Testinstance', 78 | }); 79 | const event = new NodePyATVDeviceEvent({ 80 | device, 81 | key: 'genre', 82 | new: 'Rock', 83 | old: 'Jazz', 84 | }); 85 | 86 | assert.deepEqual(event.device, device); 87 | }); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /test/device-events.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import assert from 'assert'; 4 | 5 | import { createFakeSpawn } from '../src/lib/fake-spawn.js'; 6 | import { 7 | NodePyATVDevice, 8 | NodePyATVDeviceEvent, 9 | NodePyATVPowerState, 10 | } from '../src/lib/index.js'; 11 | import { NodePyATVFocusState } from '../src/lib/types.js'; 12 | 13 | describe('NodePyATVDeviceEvents', function () { 14 | describe('applyStateAndEmitEvents()', function () { 15 | it('should emit update:key event', async function () { 16 | const device = new NodePyATVDevice({ 17 | host: '192.168.178.2', 18 | name: 'My Testdevice', 19 | spawn: createFakeSpawn((cp) => { 20 | cp.onStdIn(() => cp.end()); 21 | cp.stdout({ 22 | result: 'success', 23 | title: 'My Movie', 24 | }); 25 | }), 26 | }); 27 | 28 | await new Promise((cb) => { 29 | device.once('update:title', (event) => { 30 | assert.ok(event instanceof NodePyATVDeviceEvent); 31 | assert.strictEqual(event.key, 'title'); 32 | assert.strictEqual(event.oldValue, null); 33 | assert.strictEqual(event.newValue, 'My Movie'); 34 | assert.strictEqual(event.value, 'My Movie'); 35 | assert.deepStrictEqual(event.device, device); 36 | cb(undefined); 37 | }); 38 | }); 39 | }); 40 | it('should emit update event', async function () { 41 | const device = new NodePyATVDevice({ 42 | host: '192.168.178.2', 43 | name: 'My Testdevice', 44 | spawn: createFakeSpawn((cp) => { 45 | cp.onStdIn(() => cp.end()); 46 | cp.stdout({ 47 | result: 'success', 48 | title: 'My Movie', 49 | }); 50 | }), 51 | }); 52 | 53 | await new Promise((cb) => { 54 | device.once('update', (event) => { 55 | assert.ok(event instanceof NodePyATVDeviceEvent); 56 | assert.strictEqual(event.key, 'title'); 57 | assert.strictEqual(event.oldValue, null); 58 | assert.strictEqual(event.newValue, 'My Movie'); 59 | assert.strictEqual(event.value, 'My Movie'); 60 | assert.deepStrictEqual(event.device, device); 61 | cb(undefined); 62 | }); 63 | }); 64 | }); 65 | it('should emit update:key event before update', async function () { 66 | const device = new NodePyATVDevice({ 67 | host: '192.168.178.2', 68 | name: 'My Testdevice', 69 | spawn: createFakeSpawn((cp) => { 70 | cp.onStdIn(() => cp.end()); 71 | cp.stdout({ 72 | result: 'success', 73 | title: 'My Movie', 74 | }); 75 | }), 76 | }); 77 | 78 | const sort: string[] = []; 79 | await Promise.race([ 80 | new Promise((cb) => { 81 | device.once('update', () => { 82 | sort.push('update'); 83 | cb(undefined); 84 | }); 85 | }), 86 | new Promise((cb) => { 87 | device.once('update:title', () => { 88 | sort.push('update:title'); 89 | cb(undefined); 90 | }); 91 | }), 92 | ]); 93 | 94 | assert.deepStrictEqual(sort, ['update:title', 'update']); 95 | }); 96 | it('should emit error events on failures', async function () { 97 | const device = new NodePyATVDevice({ 98 | host: '192.168.178.2', 99 | name: 'My Testdevice', 100 | spawn: createFakeSpawn((cp) => { 101 | cp.onStdIn(() => cp.end()); 102 | cp.stdout({ 103 | datetime: '2021-11-24T21:13:36.424576+03:00', 104 | exception: 'invalid credentials: 321', 105 | result: 'failure', 106 | stacktrace: 107 | 'Traceback (most recent call last):\n File "/Users/free/Library/Python/3.8/lib/python/site-packages/pyatv/scripts/atvscript.py", line 302, in appstart\n print(args.output(await _handle_command(args, abort_sem, loop)), flush=True)\n File "/Users/free/Library/Python/3.8/lib/python/site-packages/pyatv/scripts/atvscript.py", line 196, in _handle_command\n atv = await connect(config, loop, protocol=Protocol.MRP)\n File "/Users/free/Library/Python/3.8/lib/python/site-packages/pyatv/__init__.py", line 96, in connect\n for setup_data in proto_methods.setup(\n File "/Users/free/Library/Python/3.8/lib/python/site-packages/pyatv/protocols/airplay/__init__.py", line 192, in setup\n stream = AirPlayStream(config, service)\n File "/Users/free/Library/Python/3.8/lib/python/site-packages/pyatv/protocols/airplay/__init__.py", line 79, in __init__\n self._credentials: HapCredentials = parse_credentials(self.service.credentials)\n File "/Users/free/Library/Python/3.8/lib/python/site-packages/pyatv/auth/hap_pairing.py", line 139, in parse_credentials\n raise exceptions.InvalidCredentialsError("invalid credentials: " + detail_string)\npyatv.exceptions.InvalidCredentialsError: invalid credentials: 321\n', 108 | }); 109 | }), 110 | }); 111 | 112 | await new Promise((cb) => { 113 | device.once('error', (error) => { 114 | assert.ok(error instanceof Error); 115 | assert.ok( 116 | error.toString().includes('invalid credentials: 321'), 117 | ); 118 | cb(undefined); 119 | }); 120 | }); 121 | }); 122 | it('should not emit an update if new value is same as old one', async function () { 123 | let spawnCounter = 0; 124 | let eventCounter = 0; 125 | const device = new NodePyATVDevice({ 126 | host: '192.168.178.2', 127 | name: 'My Testdevice', 128 | spawn: createFakeSpawn((cp) => { 129 | if (spawnCounter === 0) { 130 | cp.onStdIn(() => cp.end()); 131 | } 132 | 133 | cp.stdout({ 134 | result: 'success', 135 | title: 'My Movie', 136 | }); 137 | 138 | spawnCounter++; 139 | if (spawnCounter >= 2) { 140 | cp.end(); 141 | } 142 | }), 143 | }); 144 | 145 | const listener = () => { 146 | eventCounter++; 147 | }; 148 | 149 | device.on('update', listener); 150 | await new Promise((cb) => setTimeout(cb, 0)); 151 | await device.getState(); 152 | 153 | device.off('update', listener); 154 | 155 | assert.strictEqual(spawnCounter, 2); 156 | assert.strictEqual(eventCounter, 1); 157 | }); 158 | it('should emit error event if event listener throws an exception', async function () { 159 | const device = new NodePyATVDevice({ 160 | host: '192.168.178.2', 161 | name: 'My Testdevice', 162 | spawn: createFakeSpawn((cp) => { 163 | cp.onStdIn(() => cp.end()); 164 | cp.stdout({ 165 | result: 'success', 166 | title: 'My Movie', 167 | }); 168 | }), 169 | }); 170 | 171 | let callCounter = 0; 172 | const error = new Error('This is an error. Be nice.'); 173 | device.once('error', (err) => { 174 | assert.strictEqual(err, error); 175 | callCounter++; 176 | }); 177 | 178 | const listener = () => { 179 | throw error; 180 | }; 181 | device.on('update', listener); 182 | 183 | await new Promise((cb) => setTimeout(cb, 0)); 184 | device.off('update', listener); 185 | 186 | assert.strictEqual(callCounter, 1); 187 | }); 188 | it('should also work with powerState', async function () { 189 | const device = new NodePyATVDevice({ 190 | host: '192.168.178.2', 191 | name: 'My Testdevice', 192 | spawn: createFakeSpawn((cp) => { 193 | cp.onStdIn(() => cp.end()); 194 | cp.stdout({ 195 | datetime: new Date().toJSON(), 196 | power_state: 'off', 197 | result: 'success', 198 | }); 199 | }), 200 | }); 201 | 202 | await new Promise((cb) => { 203 | device.once('update:powerState', (event) => { 204 | assert.ok(event instanceof NodePyATVDeviceEvent); 205 | assert.strictEqual(event.key, 'powerState'); 206 | assert.strictEqual(event.oldValue, null); 207 | assert.strictEqual(event.newValue, 'off'); 208 | assert.strictEqual(event.newValue, NodePyATVPowerState.off); 209 | assert.strictEqual(event.value, 'off'); 210 | assert.strictEqual(event.value, NodePyATVPowerState.off); 211 | assert.deepStrictEqual(event.device, device); 212 | cb(undefined); 213 | }); 214 | }); 215 | }); 216 | it('should only one event for powerState changes', async function () { 217 | const device = new NodePyATVDevice({ 218 | host: '192.168.178.2', 219 | name: 'My Testdevice', 220 | spawn: createFakeSpawn((cp) => { 221 | cp.onStdIn(() => cp.end()); 222 | cp.stdout({ 223 | datetime: new Date().toJSON(), 224 | power_state: 'off', 225 | result: 'success', 226 | }); 227 | cp.end(); 228 | }), 229 | }); 230 | 231 | let counter = 0; 232 | device.on('update', (event) => { 233 | assert.ok(event instanceof NodePyATVDeviceEvent); 234 | assert.strictEqual(event.key, 'powerState'); 235 | assert.strictEqual(event.oldValue, null); 236 | assert.strictEqual(event.newValue, 'off'); 237 | assert.strictEqual(event.newValue, NodePyATVPowerState.off); 238 | assert.strictEqual(event.value, 'off'); 239 | assert.strictEqual(event.value, NodePyATVPowerState.off); 240 | assert.deepStrictEqual(event.device, device); 241 | counter++; 242 | }); 243 | 244 | await new Promise((cb) => setTimeout(cb, 10)); 245 | assert.strictEqual(counter, 1); 246 | device.removeAllListeners('update'); 247 | }); 248 | it('should only one event for focusState changes', async function () { 249 | const device = new NodePyATVDevice({ 250 | host: '192.168.178.2', 251 | name: 'My Testdevice', 252 | spawn: createFakeSpawn((cp) => { 253 | cp.onStdIn(() => cp.end()); 254 | cp.stdout({ 255 | datetime: new Date().toJSON(), 256 | focus_state: 'unfocused', 257 | result: 'success', 258 | }); 259 | cp.end(); 260 | }), 261 | }); 262 | 263 | let counter = 0; 264 | device.on('update', (event) => { 265 | assert.ok(event instanceof NodePyATVDeviceEvent); 266 | assert.strictEqual(event.key, 'focusState'); 267 | assert.strictEqual(event.oldValue, null); 268 | assert.strictEqual(event.newValue, 'unfocused'); 269 | assert.strictEqual( 270 | event.newValue, 271 | NodePyATVFocusState.unfocused, 272 | ); 273 | assert.strictEqual(event.value, 'unfocused'); 274 | assert.strictEqual(event.value, NodePyATVFocusState.unfocused); 275 | assert.deepStrictEqual(event.device, device); 276 | counter++; 277 | }); 278 | 279 | await new Promise((cb) => setTimeout(cb, 10)); 280 | assert.strictEqual(counter, 1); 281 | device.removeAllListeners('update'); 282 | }); 283 | it('should only one event for outputDevices changes', async function () { 284 | const device = new NodePyATVDevice({ 285 | host: '192.168.178.2', 286 | name: 'My Testdevice', 287 | spawn: createFakeSpawn((cp) => { 288 | cp.onStdIn(() => cp.end()); 289 | cp.stdout({ 290 | datetime: new Date().toJSON(), 291 | output_devices: [ 292 | { 293 | identifier: 294 | 'AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE', 295 | name: 'Living room', 296 | }, 297 | ], 298 | result: 'success', 299 | }); 300 | cp.end(); 301 | }), 302 | }); 303 | 304 | let counter = 0; 305 | device.on('update', (event) => { 306 | assert.ok(event instanceof NodePyATVDeviceEvent); 307 | assert.strictEqual(event.key, 'outputDevices'); 308 | assert.strictEqual(event.oldValue, null); 309 | assert.deepStrictEqual(event.newValue, [ 310 | { 311 | identifier: 'AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE', 312 | name: 'Living room', 313 | }, 314 | ]); 315 | assert.deepStrictEqual(event.value, [ 316 | { 317 | identifier: 'AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE', 318 | name: 'Living room', 319 | }, 320 | ]); 321 | assert.deepStrictEqual(event.device, device); 322 | 323 | counter++; 324 | }); 325 | 326 | await new Promise((cb) => setTimeout(cb, 10)); 327 | assert.strictEqual(counter, 1); 328 | device.removeAllListeners('update'); 329 | }); 330 | it('should only one event for volume changes', async function () { 331 | const device = new NodePyATVDevice({ 332 | host: '192.168.178.2', 333 | name: 'My Testdevice', 334 | spawn: createFakeSpawn((cp) => { 335 | cp.onStdIn(() => cp.end()); 336 | cp.stdout({ 337 | datetime: new Date().toJSON(), 338 | result: 'success', 339 | volume: 20.0, 340 | }); 341 | cp.end(); 342 | }), 343 | }); 344 | 345 | let counter = 0; 346 | device.on('update', (event) => { 347 | assert.ok(event instanceof NodePyATVDeviceEvent); 348 | assert.strictEqual(event.key, 'volume'); 349 | assert.strictEqual(event.oldValue, null); 350 | assert.strictEqual(event.newValue, 20); 351 | assert.strictEqual(event.value, 20); 352 | assert.deepStrictEqual(event.device, device); 353 | counter++; 354 | }); 355 | 356 | await new Promise((cb) => setTimeout(cb, 10)); 357 | assert.strictEqual(counter, 1); 358 | device.removeAllListeners('update'); 359 | }); 360 | it('should not trigger any events for newly added fields', async function () { 361 | const device = new NodePyATVDevice({ 362 | host: '192.168.178.2', 363 | name: 'My Testdevice', 364 | spawn: createFakeSpawn((cp) => { 365 | cp.onStdIn(() => cp.end()); 366 | cp.stdout({ 367 | datetime: new Date().toJSON(), 368 | foo: 'bar', 369 | result: 'success', 370 | }); 371 | cp.end(); 372 | }), 373 | }); 374 | 375 | device.on('update', (event) => { 376 | assert.fail(`Got an update event for a new field: ${event}`); 377 | }); 378 | 379 | await new Promise((cb) => setTimeout(cb, 10)); 380 | device.removeAllListeners('update'); 381 | }); 382 | }); 383 | 384 | describe('start|stopListening()', function () { 385 | it('should emit error if spawn fails', async function () { 386 | const error = new Error(); 387 | const device = new NodePyATVDevice({ 388 | host: '192.168.178.2', 389 | name: 'My Testdevice', 390 | spawn: createFakeSpawn((cp) => { 391 | cp.error(error).end(); 392 | }), 393 | }); 394 | const listener = () => { 395 | // empty listener 396 | }; 397 | 398 | device.on('update', listener); 399 | 400 | await new Promise((cb) => { 401 | device.once('error', (err) => { 402 | assert.strictEqual(err, error); 403 | cb(undefined); 404 | }); 405 | }); 406 | 407 | device.off('update', listener); 408 | }); 409 | it('should emit error on stderr data', async function () { 410 | const device = new NodePyATVDevice({ 411 | host: '192.168.178.2', 412 | name: 'My Testdevice', 413 | spawn: createFakeSpawn((cp) => { 414 | cp.stderr('Hello World!').end(); 415 | }), 416 | }); 417 | const listener = () => { 418 | // empty listener 419 | }; 420 | 421 | device.on('update', listener); 422 | 423 | await new Promise((cb) => { 424 | device.once('error', (err) => { 425 | assert.ok(err instanceof Error); 426 | assert.ok( 427 | err 428 | .toString() 429 | .includes( 430 | 'Got stderr output from pyatv: Hello World!', 431 | ), 432 | ); 433 | cb(undefined); 434 | }); 435 | }); 436 | 437 | device.off('update', listener); 438 | }); 439 | it('should emit error if stdout is not valid json', async function () { 440 | const device = new NodePyATVDevice({ 441 | host: '192.168.178.2', 442 | name: 'My Testdevice', 443 | spawn: createFakeSpawn((cp) => { 444 | cp.stdout('#').end(); 445 | }), 446 | }); 447 | const listener = () => { 448 | // empty listener 449 | }; 450 | 451 | device.on('update', listener); 452 | 453 | await new Promise((cb) => { 454 | device.once('error', (err) => { 455 | assert.ok(err instanceof Error); 456 | assert.ok( 457 | err 458 | .toString() 459 | .includes( 460 | 'Unable to parse stdout json: SyntaxError', 461 | ), 462 | ); 463 | cb(undefined); 464 | }); 465 | }); 466 | 467 | device.off('update', listener); 468 | }); 469 | it('should restart the process if it gets killed'); 470 | }); 471 | 472 | describe('addListener() / removeAllListeners()', function () { 473 | it('should work without any exceptions', async function () { 474 | const device = new NodePyATVDevice({ 475 | host: '192.168.178.2', 476 | name: 'My Testdevice', 477 | spawn: createFakeSpawn((cp) => { 478 | cp.onStdIn(() => cp.end()); 479 | cp.stdout({ 480 | result: 'success', 481 | title: 'My Movie', 482 | }); 483 | }), 484 | }); 485 | 486 | const listener = () => { 487 | // empty listener 488 | }; 489 | device.addListener('update', listener); 490 | device.removeAllListeners('update'); 491 | }); 492 | }); 493 | 494 | describe('emit()', function () { 495 | it('should work', function (done) { 496 | const device = new NodePyATVDevice({ 497 | host: '192.168.178.2', 498 | name: 'My Testdevice', 499 | spawn: createFakeSpawn((cp) => { 500 | cp.onStdIn(() => cp.end()); 501 | }), 502 | }); 503 | const event = new NodePyATVDeviceEvent({ 504 | device, 505 | key: 'dateTime', 506 | new: 'bar', 507 | old: 'foo', 508 | }); 509 | 510 | let executions = 0; 511 | device.once('test', (e) => { 512 | executions++; 513 | assert.strictEqual(e, event); 514 | assert.strictEqual(executions, 1); 515 | done(); 516 | }); 517 | 518 | device.emit('test', event); 519 | }); 520 | }); 521 | 522 | describe('eventNames()', function () { 523 | it('should work', function () { 524 | const device = new NodePyATVDevice({ 525 | host: '192.168.178.2', 526 | name: 'My Testdevice', 527 | spawn: createFakeSpawn((cp) => { 528 | cp.onStdIn(() => cp.end()); 529 | }), 530 | }); 531 | 532 | const listener = () => { 533 | // ignore 534 | }; 535 | 536 | device.on('test', listener); 537 | assert.deepStrictEqual(device.eventNames(), ['test']); 538 | device.off('test', listener); 539 | }); 540 | }); 541 | 542 | describe('getMaxListeners()', function () { 543 | it('should work', function () { 544 | const device = new NodePyATVDevice({ 545 | host: '192.168.178.2', 546 | name: 'My Testdevice', 547 | spawn: createFakeSpawn((cp) => { 548 | cp.onStdIn(() => cp.end()); 549 | }), 550 | }); 551 | 552 | const result = device.getMaxListeners(); 553 | assert.ok(typeof result, 'number'); 554 | assert.ok(result >= 10); 555 | }); 556 | }); 557 | 558 | describe('listenerCount()', function () { 559 | it('should work', function () { 560 | const device = new NodePyATVDevice({ 561 | host: '192.168.178.2', 562 | name: 'My Testdevice', 563 | spawn: createFakeSpawn((cp) => { 564 | cp.onStdIn(() => cp.end()); 565 | }), 566 | }); 567 | 568 | const listener = () => { 569 | // ignore 570 | }; 571 | 572 | assert.deepStrictEqual(device.listenerCount('test'), 0); 573 | device.on('test', listener); 574 | assert.deepStrictEqual(device.listenerCount('test'), 1); 575 | device.off('test', listener); 576 | }); 577 | }); 578 | 579 | describe('listeners()', function () { 580 | it('should work', function () { 581 | const device = new NodePyATVDevice({ 582 | host: '192.168.178.2', 583 | name: 'My Testdevice', 584 | spawn: createFakeSpawn((cp) => { 585 | cp.onStdIn(() => cp.end()); 586 | }), 587 | }); 588 | 589 | const listener = () => { 590 | // ignore 591 | }; 592 | 593 | assert.deepStrictEqual(device.listeners('test').length, 0); 594 | device.on('test', listener); 595 | assert.deepStrictEqual(device.listeners('test').length, 1); 596 | assert.deepStrictEqual(device.listeners('test')[0], listener); 597 | device.off('test', listener); 598 | }); 599 | }); 600 | 601 | describe('prependListener()', function () { 602 | it('should work', function (done) { 603 | const device = new NodePyATVDevice({ 604 | host: '192.168.178.2', 605 | name: 'My Testdevice', 606 | spawn: createFakeSpawn((cp) => { 607 | cp.onStdIn(() => cp.end()); 608 | cp.stdout({ 609 | result: 'success', 610 | title: 'My Movie', 611 | }); 612 | }), 613 | }); 614 | 615 | const listener = () => { 616 | device.removeAllListeners('update'); 617 | done(); 618 | }; 619 | device.prependListener('update', listener); 620 | }); 621 | }); 622 | 623 | describe('prependOnceListener()', function () { 624 | it('should work', function (done) { 625 | const device = new NodePyATVDevice({ 626 | host: '192.168.178.2', 627 | name: 'My Testdevice', 628 | spawn: createFakeSpawn((cp) => { 629 | cp.onStdIn(() => cp.end()); 630 | cp.stdout({ 631 | result: 'success', 632 | title: 'My Movie', 633 | }); 634 | }), 635 | }); 636 | 637 | device.prependOnceListener('update', () => done()); 638 | }); 639 | }); 640 | 641 | describe('rawListeners()', function () { 642 | it('should work', function () { 643 | const device = new NodePyATVDevice({ 644 | host: '192.168.178.2', 645 | name: 'My Testdevice', 646 | spawn: createFakeSpawn((cp) => { 647 | cp.onStdIn(() => cp.end()); 648 | }), 649 | }); 650 | 651 | const listener = () => { 652 | // ignore 653 | }; 654 | 655 | assert.deepStrictEqual(device.rawListeners('test').length, 0); 656 | device.on('test', listener); 657 | assert.deepStrictEqual(device.rawListeners('test').length, 1); 658 | assert.deepStrictEqual(device.rawListeners('test')[0], listener); 659 | device.off('test', listener); 660 | }); 661 | }); 662 | 663 | describe('removeListener()', function () { 664 | it('should work without any exceptions', async function () { 665 | const device = new NodePyATVDevice({ 666 | host: '192.168.178.2', 667 | name: 'My Testdevice', 668 | spawn: createFakeSpawn((cp) => { 669 | cp.onStdIn(() => cp.end()); 670 | cp.stdout({ 671 | result: 'success', 672 | title: 'My Movie', 673 | }); 674 | }), 675 | }); 676 | 677 | const listener = () => { 678 | // empty listener 679 | }; 680 | device.addListener('update', listener); 681 | assert.deepStrictEqual(device.listenerCount('update'), 1); 682 | device.removeListener('update', listener); 683 | assert.deepStrictEqual(device.listenerCount('update'), 0); 684 | }); 685 | }); 686 | }); 687 | -------------------------------------------------------------------------------- /test/tools.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import assert from 'assert'; 4 | 5 | import { 6 | addRequestId, 7 | debug, 8 | getExecutable, 9 | getParameters, 10 | parseState, 11 | removeRequestId, 12 | } from '../src/lib/tools.js'; 13 | import { 14 | NodePyATVDeviceState, 15 | NodePyATVExecutableType, 16 | NodePyATVInternalState, 17 | NodePyATVMediaType, 18 | NodePyATVProtocol, 19 | NodePyATVRepeatState, 20 | NodePyATVShuffleState, 21 | } from '../src/lib/types.js'; 22 | 23 | describe('Tools', function () { 24 | describe('addRequestId() / removeRequestId()', function () { 25 | it('should return a string', function () { 26 | const id = addRequestId(); 27 | assert.strictEqual(typeof id, 'string'); 28 | removeRequestId(id); 29 | }); 30 | it('should work if given id is not in index', function () { 31 | removeRequestId('FOO'); 32 | }); 33 | }); 34 | 35 | describe('debug()', function () { 36 | it('should work without any options', function () { 37 | debug('TEST', 'Hello World.', {}); 38 | }); 39 | it('should work with default logger', function () { 40 | debug('TEST', 'Hello World.', { debug: true }); 41 | }); 42 | it('should work with custom logger', function () { 43 | debug('TEST', 'Hello World.', { 44 | debug: function (msg) { 45 | assert.strictEqual(this, null); 46 | assert.ok(msg.includes('Hello World')); 47 | }, 48 | }); 49 | }); 50 | it('should work with colors disabled', function () { 51 | debug('TEST', 'Hello World.', { noColors: true }); 52 | }); 53 | it('should work with custom logger and colors disabled', function () { 54 | debug('TEST', 'Hello World.', { 55 | debug: function (msg) { 56 | assert.strictEqual(this, null); 57 | assert.strictEqual(msg, '[node-pyatv][TEST] Hello World.'); 58 | }, 59 | noColors: true, 60 | }); 61 | }); 62 | }); 63 | 64 | describe('getExecutable()', function () { 65 | it('should handle atvremotePath if set', function () { 66 | const result = getExecutable(NodePyATVExecutableType.atvremote, { 67 | atvremotePath: '/tmp/1', 68 | atvscriptPath: '/tmp/2', 69 | }); 70 | 71 | assert.strictEqual(result, '/tmp/1'); 72 | }); 73 | it('should handle atvscriptPath if set', function () { 74 | const result = getExecutable(NodePyATVExecutableType.atvscript, { 75 | atvremotePath: '/tmp/1', 76 | atvscriptPath: '/tmp/2', 77 | }); 78 | 79 | assert.strictEqual(result, '/tmp/2'); 80 | }); 81 | it('should handle default for atvremote', function () { 82 | const result = getExecutable(NodePyATVExecutableType.atvremote, { 83 | atvscriptPath: '/tmp', 84 | }); 85 | 86 | assert.strictEqual(result, 'atvremote'); 87 | }); 88 | it('should handle default for atvscript', function () { 89 | const result = getExecutable(NodePyATVExecutableType.atvscript, { 90 | atvremotePath: '/tmp', 91 | }); 92 | 93 | assert.strictEqual(result, 'atvscript'); 94 | }); 95 | }); 96 | 97 | describe('getParameters()', function () { 98 | it('empty case', async function () { 99 | const result = await getParameters(); 100 | assert.deepEqual(result, []); 101 | }); 102 | it('easy case', async function () { 103 | const result = await getParameters({ 104 | host: '192.168.178.2', 105 | }); 106 | assert.deepEqual(result, ['-s', '192.168.178.2']); 107 | }); 108 | it('full case', async function () { 109 | const result = await getParameters({ 110 | airplayCredentials: '****', 111 | companionCredentials: '1234', 112 | dmapCredentials: '****', 113 | hosts: ['192.168.178.2', '192.168.178.3'], 114 | id: '****', 115 | mrpCredentials: '****', 116 | protocol: NodePyATVProtocol.mrp, 117 | raopCredentials: '::foo:', 118 | }); 119 | assert.deepEqual(result, [ 120 | '-s', 121 | '192.168.178.2,192.168.178.3', 122 | '-i', 123 | '****', 124 | '--protocol', 125 | 'mrp', 126 | '--dmap-credentials', 127 | '****', 128 | '--mrp-credentials', 129 | '****', 130 | '--airplay-credentials', 131 | '****', 132 | '--companion-credentials', 133 | '1234', 134 | '--raop-credentials', 135 | '::foo:', 136 | ]); 137 | }); 138 | }); 139 | 140 | describe('parseState()', function () { 141 | it('should work with empty data', function () { 142 | const input = {}; 143 | const result = parseState(input, '', {}); 144 | assert.deepStrictEqual(result, { 145 | album: null, 146 | app: null, 147 | appId: null, 148 | artist: null, 149 | contentIdentifier: null, 150 | dateTime: null, 151 | deviceState: null, 152 | episodeNumber: null, 153 | focusState: null, 154 | genre: null, 155 | hash: null, 156 | iTunesStoreIdentifier: null, 157 | mediaType: null, 158 | outputDevices: null, 159 | position: null, 160 | powerState: null, 161 | repeat: null, 162 | seasonNumber: null, 163 | seriesName: null, 164 | shuffle: null, 165 | title: null, 166 | totalTime: null, 167 | volume: null, 168 | }); 169 | }); 170 | it('should work without data', function () { 171 | // @ts-ignore 172 | const result = parseState(null, '', {}); 173 | assert.deepStrictEqual(result, { 174 | album: null, 175 | app: null, 176 | appId: null, 177 | artist: null, 178 | contentIdentifier: null, 179 | dateTime: null, 180 | deviceState: null, 181 | episodeNumber: null, 182 | focusState: null, 183 | genre: null, 184 | hash: null, 185 | iTunesStoreIdentifier: null, 186 | mediaType: null, 187 | outputDevices: null, 188 | position: null, 189 | powerState: null, 190 | repeat: null, 191 | seasonNumber: null, 192 | seriesName: null, 193 | shuffle: null, 194 | title: null, 195 | totalTime: null, 196 | volume: null, 197 | }); 198 | }); 199 | it('should work with example data', function () { 200 | const input: NodePyATVInternalState = { 201 | album: null, 202 | app: 'Disney+', 203 | app_id: 'com.disney.disneyplus', 204 | artist: null, 205 | content_identifier: null, 206 | datetime: '2020-11-07T22:38:43.608030+01:00', 207 | device_state: 'playing', 208 | episode_number: null, 209 | focus_state: null, 210 | genre: null, 211 | hash: '100e0ab6-6ff5-4199-9c04-a7107ff78712', 212 | itunes_store_identifier: null, 213 | media_type: 'video', 214 | output_devices: null, 215 | position: 27, 216 | power_state: null, 217 | repeat: 'off', 218 | result: 'success', 219 | season_number: null, 220 | series_name: null, 221 | shuffle: 'off', 222 | title: 'Solo: A Star Wars Story', 223 | total_time: 8097, 224 | volume: null, 225 | }; 226 | const result = parseState(input, '', {}); 227 | assert.deepStrictEqual(result, { 228 | album: null, 229 | app: 'Disney+', 230 | appId: 'com.disney.disneyplus', 231 | artist: null, 232 | contentIdentifier: null, 233 | dateTime: new Date('2020-11-07T22:38:43.608030+01:00'), 234 | deviceState: NodePyATVDeviceState.playing, 235 | episodeNumber: null, 236 | focusState: null, 237 | genre: null, 238 | hash: '100e0ab6-6ff5-4199-9c04-a7107ff78712', 239 | iTunesStoreIdentifier: null, 240 | mediaType: NodePyATVMediaType.video, 241 | outputDevices: null, 242 | position: 27, 243 | powerState: null, 244 | repeat: NodePyATVRepeatState.off, 245 | seasonNumber: null, 246 | seriesName: null, 247 | shuffle: NodePyATVShuffleState.off, 248 | title: 'Solo: A Star Wars Story', 249 | totalTime: 8097, 250 | volume: null, 251 | }); 252 | }); 253 | it('should throw an error for pyatv exceptions', function () { 254 | const input: NodePyATVInternalState = { 255 | datetime: '2021-11-24T21:13:36.424576+03:00', 256 | exception: 'invalid credentials: 321', 257 | result: 'failure', 258 | stacktrace: 259 | 'Traceback (most recent call last):\n File "/Users/free/Library/Python/3.8/lib/python/site-packages/pyatv/scripts/atvscript.py", line 302, in appstart\n print(args.output(await _handle_command(args, abort_sem, loop)), flush=True)\n File "/Users/free/Library/Python/3.8/lib/python/site-packages/pyatv/scripts/atvscript.py", line 196, in _handle_command\n atv = await connect(config, loop, protocol=Protocol.MRP)\n File "/Users/free/Library/Python/3.8/lib/python/site-packages/pyatv/__init__.py", line 96, in connect\n for setup_data in proto_methods.setup(\n File "/Users/free/Library/Python/3.8/lib/python/site-packages/pyatv/protocols/airplay/__init__.py", line 192, in setup\n stream = AirPlayStream(config, service)\n File "/Users/free/Library/Python/3.8/lib/python/site-packages/pyatv/protocols/airplay/__init__.py", line 79, in __init__\n self._credentials: HapCredentials = parse_credentials(self.service.credentials)\n File "/Users/free/Library/Python/3.8/lib/python/site-packages/pyatv/auth/hap_pairing.py", line 139, in parse_credentials\n raise exceptions.InvalidCredentialsError("invalid credentials: " + detail_string)\npyatv.exceptions.InvalidCredentialsError: invalid credentials: 321\n', 260 | }; 261 | assert.throws(() => { 262 | parseState(input, '', {}); 263 | }, /Got pyatv Error: invalid credentials: 321/); 264 | }); 265 | it("should ignore date if it's an invalid date", function () { 266 | const input: NodePyATVInternalState = { datetime: 'today' }; 267 | const result = parseState(input, '', {}); 268 | assert.deepStrictEqual(result, { 269 | album: null, 270 | app: null, 271 | appId: null, 272 | artist: null, 273 | contentIdentifier: null, 274 | dateTime: null, 275 | deviceState: null, 276 | episodeNumber: null, 277 | focusState: null, 278 | genre: null, 279 | hash: null, 280 | iTunesStoreIdentifier: null, 281 | mediaType: null, 282 | outputDevices: null, 283 | position: null, 284 | powerState: null, 285 | repeat: null, 286 | seasonNumber: null, 287 | seriesName: null, 288 | shuffle: null, 289 | title: null, 290 | totalTime: null, 291 | volume: null, 292 | }); 293 | }); 294 | it('should ignore data if unsupported type', function () { 295 | const input: NodePyATVInternalState = { 296 | album: Infinity, 297 | app: 0, 298 | app_id: 891645381647289, 299 | artist: 90, 300 | content_identifier: null, 301 | datetime: true, 302 | device_state: 43, 303 | episode_number: null, 304 | focus_state: null, 305 | genre: Math.PI, 306 | hash: 1337, 307 | itunes_store_identifier: null, 308 | media_type: false, 309 | output_devices: null, 310 | position: '0:30.123', 311 | power_state: null, 312 | repeat: true, 313 | result: 'success', 314 | season_number: null, 315 | series_name: null, 316 | shuffle: false, 317 | title: undefined, 318 | total_time: '23min', 319 | volume: null, 320 | }; 321 | const result = parseState(input, '', {}); 322 | assert.deepStrictEqual(result, { 323 | album: null, 324 | app: null, 325 | appId: null, 326 | artist: null, 327 | contentIdentifier: null, 328 | dateTime: null, 329 | deviceState: null, 330 | episodeNumber: null, 331 | focusState: null, 332 | genre: null, 333 | hash: null, 334 | iTunesStoreIdentifier: null, 335 | mediaType: null, 336 | outputDevices: null, 337 | position: null, 338 | powerState: null, 339 | repeat: null, 340 | seasonNumber: null, 341 | seriesName: null, 342 | shuffle: null, 343 | title: null, 344 | totalTime: null, 345 | volume: null, 346 | }); 347 | }); 348 | it('should ignore enums with unsupported valid', function () { 349 | const input: NodePyATVInternalState = { 350 | device_state: 'initiating', 351 | media_type: '3d-experience', 352 | repeat: 'nothing', 353 | shuffle: 'everything', 354 | }; 355 | const result = parseState(input, '', {}); 356 | assert.deepStrictEqual(result, { 357 | album: null, 358 | app: null, 359 | appId: null, 360 | artist: null, 361 | contentIdentifier: null, 362 | dateTime: null, 363 | deviceState: null, 364 | episodeNumber: null, 365 | focusState: null, 366 | genre: null, 367 | hash: null, 368 | iTunesStoreIdentifier: null, 369 | mediaType: null, 370 | outputDevices: null, 371 | position: null, 372 | powerState: null, 373 | repeat: null, 374 | seasonNumber: null, 375 | seriesName: null, 376 | shuffle: null, 377 | title: null, 378 | totalTime: null, 379 | volume: null, 380 | }); 381 | }); 382 | }); 383 | }); 384 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "allowImportingTsExtensions": true, 5 | "declaration": true, 6 | "esModuleInterop": true, 7 | "lib": ["es2023"], 8 | "module": "node16", 9 | "moduleResolution": "node16", 10 | "outDir": "./dist", 11 | "skipLibCheck": true, 12 | "sourceMap": true, 13 | "strict": true, 14 | "target": "es2023", 15 | "verbatimModuleSyntax": true 16 | }, 17 | "exclude": ["node_modules", "**/*.spec.ts"], 18 | "include": ["src/**/*"], 19 | "ts-node": { 20 | "esm": true, 21 | "experimentalResolver": true, 22 | "files": true 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | clean: true, 5 | dts: true, 6 | entry: ['src/lib/index.ts', 'src/bin/check.ts'], 7 | format: ['esm', 'cjs'], 8 | minify: true, 9 | shims: true, 10 | sourcemap: true, 11 | }); 12 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "entryPoints": ["./src/lib/index.ts"], 3 | "exclude": ["./lib/tools.ts"], 4 | "excludeInternal": true, 5 | "excludePrivate": true, 6 | "excludeProtected": true, 7 | "includeVersion": true, 8 | "name": "node-pyatv", 9 | "out": "./docs/reference", 10 | "plugin": [], 11 | "readme": "README.md", 12 | "theme": "default" 13 | } 14 | --------------------------------------------------------------------------------