├── .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 |
23 |
24 |
--------------------------------------------------------------------------------
/.idea/codeStyles/Project.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/.idea/codeStyles/codeStyleConfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/.idea/encodings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/Project_Default.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/profiles_settings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/.idea/jsLibraryMappings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/.idea/jsLinters/eslint.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/jsLinters/jshint.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
--------------------------------------------------------------------------------
/.idea/jsLinters/jslint.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
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 |
4 |
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)
4 | [](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 |
--------------------------------------------------------------------------------