macos-mediaremote-python 0.1.0a1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. macos_mediaremote_python-0.1.0a1/LICENSE +28 -0
  2. macos_mediaremote_python-0.1.0a1/MANIFEST.in +8 -0
  3. macos_mediaremote_python-0.1.0a1/PKG-INFO +345 -0
  4. macos_mediaremote_python-0.1.0a1/README.md +327 -0
  5. macos_mediaremote_python-0.1.0a1/examples/read_once.py +18 -0
  6. macos_mediaremote_python-0.1.0a1/examples/verify_installed.py +99 -0
  7. macos_mediaremote_python-0.1.0a1/examples/watch.py +17 -0
  8. macos_mediaremote_python-0.1.0a1/licenses/mediaremote-adapter.txt +28 -0
  9. macos_mediaremote_python-0.1.0a1/native_build.py +66 -0
  10. macos_mediaremote_python-0.1.0a1/pyproject.toml +34 -0
  11. macos_mediaremote_python-0.1.0a1/release_checks.py +51 -0
  12. macos_mediaremote_python-0.1.0a1/setup.cfg +4 -0
  13. macos_mediaremote_python-0.1.0a1/setup.py +35 -0
  14. macos_mediaremote_python-0.1.0a1/src/macos_mediaremote/__init__.py +12 -0
  15. macos_mediaremote_python-0.1.0a1/src/macos_mediaremote/_process.py +120 -0
  16. macos_mediaremote_python-0.1.0a1/src/macos_mediaremote/client.py +175 -0
  17. macos_mediaremote_python-0.1.0a1/src/macos_mediaremote/errors.py +30 -0
  18. macos_mediaremote_python-0.1.0a1/src/macos_mediaremote/models.py +78 -0
  19. macos_mediaremote_python-0.1.0a1/src/macos_mediaremote/py.typed +0 -0
  20. macos_mediaremote_python-0.1.0a1/src/macos_mediaremote_python.egg-info/PKG-INFO +345 -0
  21. macos_mediaremote_python-0.1.0a1/src/macos_mediaremote_python.egg-info/SOURCES.txt +26 -0
  22. macos_mediaremote_python-0.1.0a1/src/macos_mediaremote_python.egg-info/dependency_links.txt +1 -0
  23. macos_mediaremote_python-0.1.0a1/src/macos_mediaremote_python.egg-info/top_level.txt +1 -0
  24. macos_mediaremote_python-0.1.0a1/tests/conftest.py +34 -0
  25. macos_mediaremote_python-0.1.0a1/tests/test_client.py +283 -0
  26. macos_mediaremote_python-0.1.0a1/tests/test_models.py +51 -0
  27. macos_mediaremote_python-0.1.0a1/tests/test_release_checks.py +56 -0
  28. macos_mediaremote_python-0.1.0a1/upstream.lock.json +11 -0
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, macos-mediaremote-python contributors
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,8 @@
1
+ include native_build.py release_checks.py upstream.lock.json
2
+ recursive-include licenses *
3
+ recursive-include examples *.py
4
+ recursive-include tests *.py
5
+ global-exclude *.md
6
+ include README.md
7
+ prune .build
8
+ prune .venv
@@ -0,0 +1,345 @@
1
+ Metadata-Version: 2.4
2
+ Name: macos-mediaremote-python
3
+ Version: 0.1.0a1
4
+ Summary: Unofficial asyncio wrapper for ungive/mediaremote-adapter
5
+ License-Expression: BSD-3-Clause
6
+ Project-URL: Homepage, https://github.com/Mai-icy/macos-mediaremote-python
7
+ Project-URL: Repository, https://github.com/Mai-icy/macos-mediaremote-python
8
+ Project-URL: Issues, https://github.com/Mai-icy/macos-mediaremote-python/issues
9
+ Project-URL: Documentation, https://github.com/Mai-icy/macos-mediaremote-python#readme
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Operating System :: MacOS :: MacOS X
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Requires-Python: >=3.11
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ License-File: licenses/mediaremote-adapter.txt
17
+ Dynamic: license-file
18
+
19
+ # macos-mediaremote-python
20
+
21
+ An **unofficial** Python wrapper for
22
+ [ungive/mediaremote-adapter](https://github.com/ungive/mediaremote-adapter).
23
+ It provides asyncio APIs to read macOS Now Playing metadata, subscribe to
24
+ updates, and control the current player.
25
+
26
+ Import it as `macos_mediaremote`. The distribution name is
27
+ `macos-mediaremote-python`, and the current alpha version is `0.1.0a1`.
28
+
29
+ ## Installation
30
+
31
+ Requires macOS and Python 3.11+. Install the alpha release in a virtual environment:
32
+
33
+ ```sh
34
+ python3 -m venv /tmp/mediaremote-demo
35
+ /tmp/mediaremote-demo/bin/python -m pip install macos-mediaremote-python==0.1.0a1
36
+ ```
37
+
38
+ Compatible macOS installations use the bundled universal2 wheel. For local
39
+ development, see [Building from source](#building-from-source).
40
+
41
+ The wheel includes the framework, upstream Perl script, and licenses. Installing,
42
+ importing, and running an installed wheel never downloads native code. Wheel users
43
+ do not need Git, CMake, Xcode, or Homebrew.
44
+
45
+ At runtime, the wrapper invokes macOS's `/usr/bin/perl`, preserving the upstream
46
+ `DynaLoader` mechanism for loading the framework. Python does not load the private
47
+ framework directly.
48
+
49
+ ## Read the current state
50
+
51
+ ```python
52
+ import asyncio
53
+ from macos_mediaremote import MediaRemote
54
+
55
+ async def main():
56
+ remote = MediaRemote()
57
+ state = await remote.get()
58
+ if state is not None:
59
+ print(state.title, state.artist, state.playing)
60
+ print(state.bundle_identifier)
61
+ print(state.elapsed_time, state.duration) # Seconds
62
+ print(state.raw.get("parentApplicationBundleIdentifier"))
63
+
64
+ asyncio.run(main())
65
+ ```
66
+
67
+ Inside an existing asyncio application, use `await remote.get()` directly.
68
+ Each `get()` call starts a short-lived helper process. Importing the package and
69
+ constructing `MediaRemote()` have no process or network side effects.
70
+
71
+ ## Subscribe to updates
72
+
73
+ ```python
74
+ import asyncio
75
+ from macos_mediaremote import MediaRemote
76
+
77
+ async def main():
78
+ async with MediaRemote().stream() as events:
79
+ async for state in events:
80
+ if state is None:
81
+ print("No current media session")
82
+ else:
83
+ print(state.title, state.playing)
84
+
85
+ asyncio.run(main())
86
+ ```
87
+
88
+ Each `stream()` owns one persistent helper process and must be used with
89
+ `async with`. Entering the context waits up to `initialization_timeout` seconds
90
+ for the first valid snapshot, which is retained for iteration. Subsequent silence
91
+ has no idle timeout. Every event is a complete snapshot (`--no-diff` upstream).
92
+
93
+ Upstream may emit an empty snapshot before the current media state arrives.
94
+ The first `None` does not mean initialization has settled.
95
+
96
+ - Leaving the context after `break`, a consumer exception, or cancellation closes
97
+ the helper. Cleanup sends SIGTERM, escalates to SIGKILL after one second if
98
+ necessary, and reaps the process.
99
+ - Cancellation preserves `asyncio.CancelledError`. `await events.aclose()` is
100
+ idempotent. Do not read the same iterator concurrently or call `aclose()` while
101
+ another task is still awaiting `anext()`. Cancel and await that task first.
102
+ - Clean EOF before the first snapshot is a protocol error. Clean EOF after a
103
+ snapshot ends iteration. A nonzero exit raises `HelperError`; it is not retried.
104
+ - stdout uses upstream's newline-delimited JSON protocol. UTF-8 characters may
105
+ span reads, and one read may contain multiple events. The stream does not use
106
+ `--human-readable` or accept a single pretty-printed JSON event spanning lines.
107
+ - The default limit is 4 MiB per event or complete command response. Exceeding it
108
+ raises an error and closes the helper. Slow consumers apply backpressure through
109
+ bounded pipes instead of accumulating an unbounded Python event queue.
110
+ - stderr is continuously drained, retaining only its last 64 KiB. Nonfatal stderr
111
+ does not stop successful operations. Inspect `events.stderr` during a stream,
112
+ or `HelperError.stderr` / `HelperTimeoutError.stderr` on failure. Successful
113
+ short-lived commands do not expose stderr.
114
+
115
+ ## Control the current player
116
+
117
+ These methods change real playback state. Call them only when intended:
118
+
119
+ ```python
120
+ await remote.play()
121
+ await remote.pause()
122
+ await remote.toggle_play_pause()
123
+ await remote.previous_track()
124
+ await remote.next_track()
125
+ await remote.seek(42.5) # Seconds; sends 42500000 microseconds upstream
126
+ ```
127
+
128
+ You can also import `Command` from the package and call
129
+ `await remote.send(Command.PLAY)`.
130
+
131
+ Arguments are validated and passed as a subprocess argument list, never through
132
+ a shell. `seek()` accepts finite, nonnegative seconds, rounded to the nearest
133
+ integer microsecond using Python's `round()`, within the upstream signed 64-bit
134
+ range. Seeking to zero is supported.
135
+
136
+ **Commands target the player selected by the system at dispatch time.** The API
137
+ does not enumerate or lock arbitrary sessions. If the current player changes
138
+ from A to B after `get()`, the next control may go to B. This version does not
139
+ provide target-player checks; even a future check would leave a race between
140
+ checking and dispatching.
141
+
142
+ Success means the upstream command exited successfully, not that the player
143
+ acknowledged or applied it. In particular, seeking has no player acknowledgement.
144
+ Upstream's implicit application-launch behavior is preserved, so some commands
145
+ may launch a player.
146
+
147
+ ## Types, units, and errors
148
+
149
+ `get()` and stream events return `NowPlaying | None`. A null or empty snapshot
150
+ means no session is reported. Nonempty snapshots preserve missing and unknown
151
+ fields. `--allow-missing-title` is enabled by default, so a session can have no
152
+ title. Missing fields are not replaced with empty strings or zero.
153
+
154
+ | Attribute | Type and unit |
155
+ | --- | --- |
156
+ | `bundle_identifier`, `title`, `artist`, `album` | `str` or `None` |
157
+ | `playing` | `bool` or `None` |
158
+ | `duration`, `elapsed_time` | `float` or `None`, in seconds; elapsed time is the position at `timestamp`, not a live clock |
159
+ | `timestamp` | `float` or `None`, Unix epoch seconds |
160
+ | `playback_rate` | `float` or `None`, playback multiplier |
161
+ | `raw` | Top-level read-only mapping preserving upstream keys, nulls, and original units; unknown nested objects are not recursively frozen |
162
+
163
+ Public time attributes, `seek()`, and all timeouts use seconds. `raw` deliberately
164
+ preserves upstream microsecond fields such as `durationMicros`, `elapsedTimeMicros`,
165
+ and `timestampEpochMicros`. This version does not extrapolate playback progress.
166
+
167
+ Artwork is omitted by default. With `MediaRemote(include_artwork=True)`, `raw` may
168
+ include base64-encoded `artworkData`, depending on the player.
169
+
170
+ The complete configuration is:
171
+
172
+ ```python
173
+ remote = MediaRemote(
174
+ timeout=5.0,
175
+ initialization_timeout=5.0,
176
+ include_artwork=False,
177
+ max_output_bytes=4 * 1024 * 1024,
178
+ )
179
+ ```
180
+
181
+ The output limit can be set between 1 KiB and 64 MiB.
182
+
183
+ All public exceptions are available from the package root:
184
+
185
+ - `MediaRemoteError`: common runtime error base class.
186
+ - `HelperError`: helper startup or exit failure, with `returncode` and `stderr`.
187
+ - `HelperTimeoutError`: command or initialization timeout; also a `TimeoutError`.
188
+ - `ProtocolError`: invalid or oversized protocol output.
189
+ - `UnsupportedPlatformError`: attempted use on a non-macOS platform.
190
+
191
+ Invalid API arguments raise `ValueError`.
192
+
193
+ ## Upstream and packaging
194
+
195
+ Package version `0.1.0a1` pins upstream
196
+ [v0.7.7](https://github.com/ungive/mediaremote-adapter/releases/tag/v0.7.7), commit
197
+ [`e3ff5021eb0875858bd05f48d2e9ba2e962d1cf6`](https://github.com/ungive/mediaremote-adapter/tree/e3ff5021eb0875858bd05f48d2e9ba2e962d1cf6).
198
+ [upstream.lock.json](https://github.com/Mai-icy/macos-mediaremote-python/blob/main/upstream.lock.json) records the archive SHA-256, deployment
199
+ target, and package-to-upstream version mapping. Each build extracts verified
200
+ archive bytes into a fresh directory. It does not build from an edited checkout
201
+ or fetch a moving `latest` version at runtime.
202
+
203
+ The build uses standard `setuptools.build_meta` with small hooks to build the
204
+ native resources and set the wheel tag. See the
205
+ [setuptools customization documentation](https://setuptools.pypa.io/en/latest/userguide/extension.html)
206
+ and [platform tag specification](https://packaging.python.org/en/latest/specifications/platform-compatibility-tags/).
207
+
208
+ Upstream CMake builds both arm64 and x86_64. This package produces a universal2
209
+ wheel tagged `py3-none-macosx_11_0_universal2`, with `Root-Is-Purelib: false`.
210
+ `none` means the helper is independent of the CPython ABI; the platform tag must
211
+ not be changed to `any`.
212
+
213
+ The upstream native and Perl sources are unmodified. Packaging flattens the
214
+ framework's `Versions/A` directory to avoid relying on wheel installers to
215
+ preserve symlinks, changes its dylib install name to
216
+ `@rpath/MediaRemoteAdapter.framework/MediaRemoteAdapter`, and applies and verifies
217
+ an ad-hoc signature. The upstream test client is not bundled, and the `test`
218
+ command, which may create a synthetic Now Playing entry, is not exposed or run.
219
+
220
+ ## Building from source
221
+
222
+ Requires macOS, Xcode Command Line Tools, CMake, and Python 3.11.8+:
223
+
224
+ ```sh
225
+ python3 -m venv .venv
226
+ .venv/bin/python -m pip install 'setuptools>=77,<85' build pytest pytest-asyncio
227
+ .venv/bin/python -m pytest -q
228
+ .venv/bin/python -m build
229
+ ```
230
+
231
+ Install the resulting wheel into a separate environment to verify it:
232
+
233
+ ```sh
234
+ python3 -m venv /tmp/mediaremote-local
235
+ /tmp/mediaremote-local/bin/python -m pip install --no-index --no-deps dist/*.whl
236
+ /tmp/mediaremote-local/bin/python examples/read_once.py
237
+ ```
238
+
239
+ `python -m build` creates an sdist, then builds a wheel from it. The sdist includes
240
+ this project's build scripts, lock file, licenses, tests, and examples, but not
241
+ the upstream source archive. Source installation requires native build tools and
242
+ network access to the pinned GitHub archive. Build isolation also installs the
243
+ build dependencies declared in `pyproject.toml`.
244
+
245
+ In an existing source directory, `.build/upstream/source.tar.gz` can be reused as
246
+ a SHA-256-verified cache. Building from an sdist downloads it again. These steps
247
+ only happen at build time.
248
+
249
+ ## Support and limitations
250
+
251
+ - The native deployment target is macOS 11.0. This is a binary build target,
252
+ not a runtime guarantee for every macOS 11+ release. Python's own deployment
253
+ requirements also apply.
254
+ - Live media metadata and streaming have been verified on the development Mac,
255
+ running macOS 26.5.1 on Apple Silicon. Hosted CI separately checks installation
256
+ and read-only helper execution on Intel and Apple Silicon; it does not exercise
257
+ real desktop players.
258
+ - This package depends on private MediaRemote APIs and the system Perl access
259
+ mechanism. Future macOS updates may break it.
260
+ - Browsers and other players may report different fields, omit titles or artwork,
261
+ or ignore some commands.
262
+ - The pinned upstream `stream.m` still calls `requestAll()` before registering
263
+ notifications. This leaves a potential initialization window for missed updates.
264
+ The first event is not an atomic subscription-ready barrier. This package
265
+ preserves upstream initialization behavior.
266
+ - `get()` returning `None` alone cannot prove private API access is working: no
267
+ active session and an access failure may be hard to distinguish. Read-only
268
+ verification does not create synthetic media to resolve that uncertainty.
269
+
270
+ ## Validation status
271
+
272
+ Validated on 2026-09-11 with macOS 26.5.1, Apple Silicon, and Python 3.14.5:
273
+
274
+ - 88 automated tests passed, covering protocol parsing, time units, control
275
+ arguments, timeouts, cancellation, stderr, backpressure, and process cleanup.
276
+ Playback controls were tested using simulated helpers.
277
+ - A universal2 wheel was built from the sdist and installed offline in a new
278
+ virtual environment outside the repository. The read example and a read-only
279
+ subscription ran from that installation.
280
+ - Installed resources were checked for both architectures, the macOS 11.0
281
+ deployment target, framework signature, system library dependencies, resource
282
+ lookup, and wheel RECORD integrity.
283
+ - Live reads returned a nonempty Now Playing snapshot, the stream received
284
+ updates, and cancellation reaped the helper. No real playback controls or
285
+ upstream `test` command were executed.
286
+ - Interactive Intel desktops, real playback controls, a player compatibility
287
+ matrix, Developer ID signing, and notarized distribution remain unverified.
288
+ See the CI workflow below for automated Python and hosted macOS coverage.
289
+
290
+ `examples/verify_installed.py` performs the installed-wheel read-only audit. It
291
+ does not send playback controls or print actual track metadata values.
292
+
293
+ ## License and release status
294
+
295
+ The wrapper uses BSD-3-Clause; see [LICENSE](https://github.com/Mai-icy/macos-mediaremote-python/blob/main/LICENSE).
296
+ Upstream copyright belongs to Jonas van den Berg and contributors. Its original
297
+ license is preserved in [licenses/mediaremote-adapter.txt](https://github.com/Mai-icy/macos-mediaremote-python/blob/main/licenses/mediaremote-adapter.txt)
298
+ and bundled with the wheel. The wrapper is independently implemented against the
299
+ public CLI/JSON protocol.
300
+
301
+ Framework layout changes, install-name changes, and re-signing are packaging
302
+ steps performed by this project, not official upstream artifacts.
303
+
304
+ Version `0.1.0a1` is an alpha release. The public API may change before a stable
305
+ release. See the support and validation sections above for the tested platforms
306
+ and remaining limitations.
307
+
308
+ ## CI and release workflow
309
+
310
+ `.github/workflows/release.yml` runs on pushes to `main`, pull requests, and
311
+ manual dispatch. Ordinary pushes and pull requests never upload to PyPI.
312
+
313
+ The workflow tests Python 3.11 through 3.14, builds a universal2 wheel from the
314
+ sdist, runs `twine check --strict`, and installs that same wheel in fresh
315
+ Apple Silicon and Intel macOS 15 environments on Python 3.11 and 3.14. Runtime
316
+ tests run outside the source checkout. The installed-wheel audit verifies
317
+ resources, signatures, deployment targets, library dependencies, and a read-only
318
+ `get()` call. A successful empty read on a hosted runner is not evidence of live
319
+ player compatibility; desktop playback and streaming still need separate tests.
320
+
321
+ `release_checks.py` rejects inconsistent package versions, upstream pins, and
322
+ architecture settings. The wheel tag is derived from the locked deployment
323
+ target. You can run the metadata checks locally without invoking the helper:
324
+
325
+ ```sh
326
+ python release_checks.py
327
+ ```
328
+
329
+ Publishing uses PyPI Trusted Publishing with these identifiers:
330
+
331
+ | Setting | Value |
332
+ | --- | --- |
333
+ | Project | `macos-mediaremote-python` |
334
+ | GitHub owner | `Mai-icy` |
335
+ | Repository | `macos-mediaremote-python` |
336
+ | Workflow filename | `release.yml` |
337
+ | GitHub environment | `pypi` |
338
+
339
+ For an authorized release, create a `vVERSION` tag matching the package version,
340
+ then manually dispatch this workflow at that tag with `publish=true`. The input
341
+ defaults to false. Publishing requires all checks to pass and approval in the
342
+ `pypi` environment. Only the publishing job receives OIDC permissions; it uploads
343
+ the already-tested artifacts without rebuilding. No persistent PyPI token or
344
+ TestPyPI workflow is used. Creating or pushing a tag alone does not upload a
345
+ release.