Skip to content

Beam -> Remote with less restrictions #1221

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jul 10, 2025
Merged

Beam -> Remote with less restrictions #1221

merged 4 commits into from
Jul 10, 2025

Conversation

aw-was-here
Copy link
Collaborator

@aw-was-here aw-was-here commented Jul 7, 2025

Summary by Sourcery

Introduce a new Remote input/output feature to replace legacy Beam functionality, including a Remote plugin, settings UI, webserver endpoint, and TrackPoll support; remove Beam-specific code, update docs, add tests, and bump Python support

New Features:

  • Add Remote input plugin to ingest track metadata from other WNP instances
  • Add remote output settings UI and integrate ‘remote’ into main settings UI
  • Add /v1/remoteinput POST endpoint in webserver with optional secret authentication
  • Implement TrackPoll remote transmission of metadata to configured remote server

Bug Fixes:

  • Properly shut down TrackPoll in tests to avoid Windows timing issues

Enhancements:

  • Remove legacy Beam-mode code paths and flags throughout CLI, config, subprocess manager and UI
  • Simplify conditional logic by unifying beam and remote functionality under new remote settings

Build:

  • Bump requires-python range to <3.14

CI:

  • Expand testing matrix to include Python 3.13

Documentation:

  • Add documentation for Remote input/output and update mkdocs navigation to include Remote
  • Remove obsolete Beam docs

Tests:

  • Add tests for remote plugin, trackpoll remote writes, and webserver remote endpoint covering auth, error and method validations
  • Refactor existing tests for improved parametrization and async cleanup

Copy link

sourcery-ai bot commented Jul 7, 2025

Reviewer's Guide

This PR replaces the legacy Beam output pathway with a generic Remote mechanism by removing Beam-specific branches, introducing a new Remote plugin and settings UI, implementing a /v1/remoteinput endpoint with optional authentication, integrating remote writes into TrackPoll, and extending test coverage for all new remote features.

Sequence diagram for remote metadata ingestion via /v1/remoteinput

sequenceDiagram
    participant Client as Remote Client
    participant Server as Webserver
    participant REMOTEDB as Remote MetadataDB

    Client->>Server: POST /v1/remoteinput (metadata, optional secret)
    Server->>Server: Validate JSON
    alt Secret required
        Server->>Server: Validate secret
        alt Secret invalid
            Server-->>Client: 403 Forbidden
        end
    end
    Server->>REMOTEDB: write_to_metadb(metadata)
    REMOTEDB-->>Server: (dbid)
    Server-->>Client: 200 OK (dbid)
Loading

Sequence diagram for TrackPoll sending metadata to remote server

sequenceDiagram
    participant TrackPoll
    participant RemoteServer as Remote Server
    participant RemoteDB as Remote MetadataDB

    TrackPoll->>RemoteServer: POST /v1/remoteinput (metadata, optional secret)
    alt Success
        RemoteServer->>RemoteDB: write_to_metadb(metadata)
        RemoteDB-->>RemoteServer: (dbid)
        RemoteServer-->>TrackPoll: 200 OK (dbid)
    else Auth failure
        RemoteServer-->>TrackPoll: 403 Forbidden
    else Server error
        RemoteServer-->>TrackPoll: 500 Error
    end
Loading

Class diagram for new Remote input plugin and settings

classDiagram
    class Plugin {
        +str displayname
        +str remotedbfile
        +MetadataDB remotedb
        +str mixmode
        +event_handler
        +TrackMetadata metadata
        +observer
        +__init__(config, qsettings)
        +install()
        +_reset_meta()
        +defaults(qsettings)
        +setup_watcher()
        +_read_track(event)
        +start()
        +getplayingtrack()
        +getrandomtrack(playlist)
        +stop()
        +on_m3u_dir_button()
        +connect_settingsui(qwidget, uihelp)
        +load_settingsui(qwidget)
        +verify_settingsui(qwidget)
        +save_settingsui(qwidget)
        +desc_settingsui(qwidget)
    }
    Plugin --|> InputPlugin

    class BeamSettings {
        +config
        +widget
        +connect(uihelp, widget)
        +load(config, widget)
        +_on_enable_toggled(enabled)
        +save_settings()
        +verify(widget)
    }
Loading

File-Level Changes

Change Details Files
Introduce generic Remote plugin and settings UI to replace Beam
  • Add nowplaying/remote_settings.py to handle remote output configuration
  • Remove Beam parameters and code paths in SettingsUI and connect remote settings
  • Add nowplaying/inputs/remote.py implementing Remote InputPlugin
nowplaying/settingsui.py
nowplaying/remote_settings.py
nowplaying/inputs/remote.py
Implement remote metadata ingestion endpoint in webserver
  • Add api_v1_remoteinput_handler with POST, JSON parsing, secret auth, and error handling
  • Register new /v1/remoteinput route and initialize separate remote DB on startup
  • Write integration tests for remote input endpoint covering methods, secrets, and JSON validation
nowplaying/processes/webserver.py
tests/test_webserver.py
Extend TrackPoll to optionally send metadata to remote server
  • Implement _strip_blobs_metadata to sanitize metadata for transfer
  • Add async _write_to_remote using aiohttp and debug JSON dumping
  • Invoke _write_to_remote in gettrack when remote is enabled
nowplaying/processes/trackpoll.py
tests/test_trackpoll.py
Remove legacy Beam flags and conditional code paths
  • Eliminate control/beam checks across ConfigFile, recognition helpers, subprocesses, and systemtray
  • Simplify initialization by removing beam parameters and methods in Tray and ConfigFile
  • Clean up beam modules, docs, and navigation entries
nowplaying/config.py
nowplaying/systemtray.py
nowplaying/subprocesses.py
nowplaying/recognition
pyproject.toml
mkdocs.yml
Extend and adjust test coverage for remote features
  • Add tests in tests/test_remote.py for Remote plugin behavior and integration
  • Extend test_trackpoll with scenarios for disabled, no-secret, secret-authenticated, auth failures, and server errors
  • Update existing tests to remove beam-mode parameters and assert new watcher behavior
tests/test_remote.py
tests/test_trackpoll.py
tests/test_webserver.py
tests-qt
Minor documentation and formatting tweaks
  • Fix formatting in webserver docs
  • Reorder and include Remote in mkdocs navigation
  • Bump Python version range in pyproject.toml
docs/output/webserver.md
docs/input/remote.md
mkdocs.yml
pyproject.toml

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link

codecov bot commented Jul 7, 2025

Codecov Report

Attention: Patch coverage is 87.70227% with 38 lines in your changes missing coverage. Please review.

Project coverage is 67.07%. Comparing base (efab6d3) to head (e6608d2).
Report is 5 commits behind head on main.

Files with missing lines Patch % Lines
nowplaying/processes/trackpoll.py 87.34% 10 Missing ⚠️
nowplaying/remote_settings.py 85.96% 8 Missing ⚠️
nowplaying/settingsui.py 86.36% 6 Missing ⚠️
nowplaying/inputs/remote.py 92.53% 5 Missing ⚠️
nowplaying/processes/webserver.py 87.09% 4 Missing ⚠️
nowplaying/inputs/icecast.py 83.33% 1 Missing ⚠️
nowplaying/inputs/jriver.py 50.00% 1 Missing ⚠️
nowplaying/musicbrainz/helper.py 80.00% 1 Missing ⚠️
nowplaying/plugin.py 50.00% 1 Missing ⚠️
nowplaying/trackrequests.py 50.00% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #1221      +/-   ##
==========================================
+ Coverage   66.73%   67.07%   +0.34%     
==========================================
  Files          68       70       +2     
  Lines       11527    11713     +186     
==========================================
+ Hits         7692     7856     +164     
- Misses       3835     3857      +22     
Flag Coverage Δ
unittests 67.07% <87.70%> (+0.34%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
nowplaying/__main__.py 94.73% <ø> (ø)
nowplaying/config.py 88.62% <100.00%> (+0.38%) ⬆️
nowplaying/inputs/mpris2.py 62.20% <100.00%> (ø)
nowplaying/metadata.py 82.05% <100.00%> (ø)
nowplaying/recognition/acoustidmb.py 69.67% <ø> (+0.12%) ⬆️
nowplaying/subprocesses.py 80.00% <100.00%> (-0.54%) ⬇️
nowplaying/systemtray.py 74.00% <100.00%> (-0.56%) ⬇️
nowplaying/inputs/icecast.py 68.29% <83.33%> (-0.42%) ⬇️
nowplaying/inputs/jriver.py 92.17% <50.00%> (-0.32%) ⬇️
nowplaying/musicbrainz/helper.py 80.22% <80.00%> (-1.12%) ⬇️
... and 7 more

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@aw-was-here aw-was-here marked this pull request as ready for review July 8, 2025 21:10
Copy link

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @aw-was-here - I've reviewed your changes - here's some feedback:

  • Consider removing or gating the /tmp/remote_debug.json dump in _write_to_remote behind a verbose or debug flag to avoid writing unexpected files in production.
  • The BeamSettings class in nowplaying/remote_settings.py now configures remote output—renaming it to RemoteSettings (and updating references) would improve clarity and avoid confusion.
  • There’s a lot of repeated code in SettingsUI around looping over plugin keys; extracting the widget setup/connect loops into a helper could reduce boilerplate and make future maintenance easier.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- Consider removing or gating the `/tmp/remote_debug.json` dump in `_write_to_remote` behind a verbose or debug flag to avoid writing unexpected files in production.
- The `BeamSettings` class in `nowplaying/remote_settings.py` now configures remote output—renaming it to `RemoteSettings` (and updating references) would improve clarity and avoid confusion.
- There’s a lot of repeated code in `SettingsUI` around looping over plugin keys; extracting the widget setup/connect loops into a helper could reduce boilerplate and make future maintenance easier.

## Individual Comments

### Comment 1
<location> `nowplaying/processes/trackpoll.py:426` </location>
<code_context>
+        async with aiohttp.ClientSession() as session:
</code_context>

<issue_to_address>
No error handling for network failures or unreachable remote server.

Wrap the session.post call in a try/except block to handle potential aiohttp exceptions and ensure graceful error handling.
</issue_to_address>

### Comment 2
<location> `nowplaying/remote_settings.py:72` </location>
<code_context>
+
+    def save_settings(self):
+        """Save settings to configuration."""
+        if not self.config or not self.widget:
+            return
+
</code_context>

<issue_to_address>
The save_settings method silently returns if config or widget is missing.

Consider logging a warning or raising an exception when config or widget is missing to improve error visibility.
</issue_to_address>

<suggested_fix>
<<<<<<< SEARCH
    def save_settings(self):
        """Save settings to configuration."""
        if not self.config or not self.widget:
            return
=======
    def save_settings(self):
        """Save settings to configuration."""
        if not self.config or not self.widget:
            logging.warning(
                "Cannot save settings: %s is missing.",
                "config" if not self.config else "widget"
            )
            return
>>>>>>> REPLACE

</suggested_fix>

### Comment 3
<location> `nowplaying/remote_settings.py:104` </location>
<code_context>
+    def verify(widget: 'QWidget') -> bool:
+        """Verify beam settings."""
+        # Basic validation - could be extended later
+        if widget.enable_checkbox.isChecked():
+            if not widget.server_lineedit.text().strip():
+                raise PluginVerifyError("Server field is required when beam output is enabled")
+        return True
</code_context>

<issue_to_address>
The verify method only checks for a non-empty server field when enabled, but does not validate port or secret.

Also validate that the port is a valid integer within the allowed range and ensure the secret is not empty if required, to avoid misconfiguration.
</issue_to_address>

### Comment 4
<location> `nowplaying/inputs/remote.py:27` </location>
<code_context>
+                 qsettings: QWidget | None = None):
+        super().__init__(config=config, qsettings=qsettings)
+        self.displayname = "Remote"
+        self.remotedbfile: str = config.cparser.value('remote/remotedb', type=str)
+        self.remotedb: nowplaying.db.MetadataDB | None = None
+        self.mixmode = "newest"
</code_context>

<issue_to_address>
No default value is provided for remotedbfile if the config is missing the key.

If 'remote/remotedb' is missing, remotedbfile will be None, which may cause errors. Please add a default value or handle the missing key appropriately.
</issue_to_address>

### Comment 5
<location> `nowplaying/inputs/remote.py:57` </location>
<code_context>
+            return
+
+        logging.info("Opening %s for input", self.remotedbfile)
+        self.observer = nowplaying.db.DBWatcher(databasefile=self.remotedbfile)
+        self.observer.start(customhandler=self._read_track)
+
</code_context>

<issue_to_address>
No error handling if the database file does not exist or is inaccessible.

Add error handling for cases where remotedbfile is invalid or missing to ensure DBWatcher fails gracefully with a clear message or fallback.
</issue_to_address>

### Comment 6
<location> `tests/test_trackpoll.py:211` </location>
<code_context>
+    config.cparser.setValue('remote/enabled', False)
+    config.cparser.sync()
+
+    trackpoll = nowplaying.processes.trackpoll.TrackPoll(stopevent=threading.Event(),
+                                                         config=config,
+                                                         testmode=True)
</code_context>

<issue_to_address>
Tests use aioresponses to mock HTTP requests, but do not assert on the request payload.

Consider adding assertions to verify that the payload sent matches the expected metadata, both when the secret is present and absent, to ensure correct data transmission.

Suggested implementation:

```python
from aioresponses import aioresponses

@pytest.mark.asyncio
async def test_trackpoll_write_to_remote_payload(trackpollbootstrap):
    ''' test _write_to_remote payload with and without secret '''
    config = trackpollbootstrap
    config.cparser.setValue('remote/enabled', True)
    config.cparser.setValue('remote/url', 'http://example.com/metadata')
    config.cparser.sync()

    # Test metadata
    metadata = {
        "artist": "Test Artist",
        "title": "Test Title",
        "album": "Test Album"
    }

    # Case 1: Secret present
    config.cparser.setValue('remote/secret', 'supersecret')
    config.cparser.sync()

    with aioresponses() as m:
        m.post('http://example.com/metadata', status=200)
        trackpoll = nowplaying.processes.trackpoll.TrackPoll(
            stopevent=threading.Event(),
            config=config,
            testmode=True
        )
        await trackpoll._write_to_remote(metadata)
        assert m.called
        request = m.requests[('POST', 'http://example.com/metadata')][0]
        sent_json = request.kwargs['json']
        assert sent_json['artist'] == "Test Artist"
        assert sent_json['title'] == "Test Title"
        assert sent_json['album'] == "Test Album"
        assert sent_json['secret'] == "supersecret"

    # Case 2: Secret absent
    config.cparser.removeOption('remote', 'secret')
    config.cparser.sync()

    with aioresponses() as m:
        m.post('http://example.com/metadata', status=200)
        trackpoll = nowplaying.processes.trackpoll.TrackPoll(
            stopevent=threading.Event(),
            config=config,
            testmode=True
        )
        await trackpoll._write_to_remote(metadata)
        assert m.called
        request = m.requests[('POST', 'http://example.com/metadata')][0]
        sent_json = request.kwargs['json']
        assert sent_json['artist'] == "Test Artist"
        assert sent_json['title'] == "Test Title"
        assert sent_json['album'] == "Test Album"
        assert 'secret' not in sent_json

    # Properly shut down trackpoll to avoid Windows timing issues
    await trackpoll.stop()
    await asyncio.sleep(0.1)  # Brief pause to let cleanup finish

```

- Ensure that `nowplaying.processes.trackpoll.TrackPoll._write_to_remote` is an async method and can be called directly in the test.
- If the remote URL or payload structure is different in your codebase, adjust the assertions accordingly.
- If you use a different config or mocking setup, adapt the test to fit your conventions.
</issue_to_address>

### Comment 7
<location> `docs/input/remote.md:10` </location>
<code_context>
+forth!
+
+**What's Now Playing** supports a configuration where each setup has
+their own app configuration running.  One or more installation on
+DJ computers sending the track information to a central one.  That
+central one will then perform any additional lookups and send the
</code_context>

<issue_to_address>
Typo: 'installation' should be 'installations'.

The sentence should read: 'One or more installations on DJ computers send the track information to a central one.'
</issue_to_address>

<suggested_fix>
<<<<<<< SEARCH
their own app configuration running.  One or more installation on
DJ computers sending the track information to a central one.  That
central one will then perform any additional lookups and send the
=======
their own app configuration running.  One or more installations on
DJ computers send the track information to a central one.  That
central one will then perform any additional lookups and send the
>>>>>>> REPLACE

</suggested_fix>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

config.cparser.setValue('remote/enabled', False)
config.cparser.sync()

trackpoll = nowplaying.processes.trackpoll.TrackPoll(stopevent=threading.Event(),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Tests use aioresponses to mock HTTP requests, but do not assert on the request payload.

Consider adding assertions to verify that the payload sent matches the expected metadata, both when the secret is present and absent, to ensure correct data transmission.

Suggested implementation:

from aioresponses import aioresponses

@pytest.mark.asyncio
async def test_trackpoll_write_to_remote_payload(trackpollbootstrap):
    ''' test _write_to_remote payload with and without secret '''
    config = trackpollbootstrap
    config.cparser.setValue('remote/enabled', True)
    config.cparser.setValue('remote/url', 'http://example.com/metadata')
    config.cparser.sync()

    # Test metadata
    metadata = {
        "artist": "Test Artist",
        "title": "Test Title",
        "album": "Test Album"
    }

    # Case 1: Secret present
    config.cparser.setValue('remote/secret', 'supersecret')
    config.cparser.sync()

    with aioresponses() as m:
        m.post('http://example.com/metadata', status=200)
        trackpoll = nowplaying.processes.trackpoll.TrackPoll(
            stopevent=threading.Event(),
            config=config,
            testmode=True
        )
        await trackpoll._write_to_remote(metadata)
        assert m.called
        request = m.requests[('POST', 'http://example.com/metadata')][0]
        sent_json = request.kwargs['json']
        assert sent_json['artist'] == "Test Artist"
        assert sent_json['title'] == "Test Title"
        assert sent_json['album'] == "Test Album"
        assert sent_json['secret'] == "supersecret"

    # Case 2: Secret absent
    config.cparser.removeOption('remote', 'secret')
    config.cparser.sync()

    with aioresponses() as m:
        m.post('http://example.com/metadata', status=200)
        trackpoll = nowplaying.processes.trackpoll.TrackPoll(
            stopevent=threading.Event(),
            config=config,
            testmode=True
        )
        await trackpoll._write_to_remote(metadata)
        assert m.called
        request = m.requests[('POST', 'http://example.com/metadata')][0]
        sent_json = request.kwargs['json']
        assert sent_json['artist'] == "Test Artist"
        assert sent_json['title'] == "Test Title"
        assert sent_json['album'] == "Test Album"
        assert 'secret' not in sent_json

    # Properly shut down trackpoll to avoid Windows timing issues
    await trackpoll.stop()
    await asyncio.sleep(0.1)  # Brief pause to let cleanup finish
  • Ensure that nowplaying.processes.trackpoll.TrackPoll._write_to_remote is an async method and can be called directly in the test.
  • If the remote URL or payload structure is different in your codebase, adjust the assertions accordingly.
  • If you use a different config or mocking setup, adapt the test to fit your conventions.

@aw-was-here aw-was-here merged commit 90d538b into main Jul 10, 2025
17 checks passed
@aw-was-here aw-was-here deleted the beam branch July 10, 2025 16:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant