Last active 1765754500

Platypush configuration for a Termux setup on Android with a local Mopidy music server and support for notifications and music commands over ntfy

fabio's Avatar fabio revised this gist 1765754500. Go to revision

1 file changed, 11 insertions

install.sh(file created)

@@ -0,0 +1,11 @@
1 + #!/data/data/com.termux/files/usr/bin/sh
2 +
3 + # A utility script to install the required configuration files
4 + # in the Platypush configuration prefix
5 +
6 + CONFDIR="${CONFDIR:-$HOME/.config/platypush}"
7 + mkdir -p "$CONFDIR/scripts"
8 +
9 + install -m 600 config.yaml "$CONFDIR"
10 + install -m 600 mopidy_commands.py "$CONFDIR/scripts"
11 + install -m 600 mopidy_notifications.py "$CONFDIR/scripts"

fabio's Avatar fabio revised this gist 1765754201. Go to revision

1 file changed, 45 deletions

mopidy_commands.py

@@ -6,7 +6,6 @@ Mopidy server.
6 6
7 7 import json
8 8 import logging
9 - import time
10 9 from collections import namedtuple
11 10 from enum import Enum
12 11
@@ -40,50 +39,6 @@ class MusicCommands(Enum):
40 39 supported_commands = {cmd.value.name: cmd.value.action for cmd in MusicCommands}
41 40
42 41
43 - def update_mobile_music_notification():
44 - """
45 - Updates the mobile music notification with the current track info
46 - retrieved from Mopidy, sending it via ntfy.
47 - """
48 -
49 - # This matches the device_id configured in config.yaml
50 - device_id = Config.get_device_id()
51 - track = run("music.mopidy.current_track")
52 - status = run("music.mopidy.status")
53 - state = status.get("state")
54 - image = None
55 -
56 - if not state:
57 - return
58 -
59 - # Retrieve cover image if available
60 - if track.get("x-albumuri"):
61 - start_t = time.time()
62 - image = get_cover_by_uri(track["x-albumuri"])
63 - logger.info("Retrieved track image info in %.2f seconds", time.time() - start_t)
64 -
65 - # Send the music notification via ntfy
66 - run(
67 - "ntfy.send_message",
68 - topic=music_notifications_topic,
69 - message=json.dumps(
70 - {
71 - "file": track.get("file"),
72 - "artist": track.get("artist"),
73 - "title": track.get("title"),
74 - "album": track.get("album"),
75 - "date": track.get("date"),
76 - "duration": status.get("track", {}).get("time"),
77 - "elapsed": status.get("time"),
78 - "image": image,
79 - "device_id": device_id,
80 - "state": state,
81 - "priority": "min",
82 - }
83 - ),
84 - )
85 -
86 -
87 42 @hook(NotificationEvent, topic=music_commands_topic)
88 43 def on_music_command(event: NotificationEvent):
89 44 """

fabio's Avatar fabio revised this gist 1765753549. Go to revision

1 file changed, 134 insertions

mopidy_commands.py(file created)

@@ -0,0 +1,134 @@
1 + """
2 + This module integrates Mopidy music playback with ntfy notifications.
3 + It listens for music control commands sent via ntfy and executes them on the
4 + Mopidy server.
5 + """
6 +
7 + import json
8 + import logging
9 + import time
10 + from collections import namedtuple
11 + from enum import Enum
12 +
13 + from platypush import Config, hook, run
14 +
15 + # Listen for ntfy message notifications
16 + from platypush.message.event.ntfy import NotificationEvent
17 +
18 + # NOTE: The topic used to send music commands to Mopidy via ntfy.
19 + # It must match the one configured in your ntfy mobile app.
20 + music_commands_topic = "music-commands-<your-unique-id>"
21 + music_plugin = "music.mopidy" # Or "music.mpd" if using MPD
22 + logger = logging.getLogger("music_integrations")
23 +
24 + ClassCommand = namedtuple("ClassCommand", ["name", "action"])
25 +
26 +
27 + class MusicCommands(Enum):
28 + """
29 + Enum representing music control commands and their corresponding actions.
30 + """
31 +
32 + PLAY = ClassCommand("play", f"{music_plugin}.play")
33 + PAUSE = ClassCommand("pause", f"{music_plugin}.pause")
34 + STOP = ClassCommand("stop", f"{music_plugin}.stop")
35 + NEXT = ClassCommand("next", f"{music_plugin}.next")
36 + PREVIOUS = ClassCommand("previous", f"{music_plugin}.previous")
37 + TOGGLE = ClassCommand("toggle", f"{music_plugin}.toggle")
38 +
39 +
40 + supported_commands = {cmd.value.name: cmd.value.action for cmd in MusicCommands}
41 +
42 +
43 + def update_mobile_music_notification():
44 + """
45 + Updates the mobile music notification with the current track info
46 + retrieved from Mopidy, sending it via ntfy.
47 + """
48 +
49 + # This matches the device_id configured in config.yaml
50 + device_id = Config.get_device_id()
51 + track = run("music.mopidy.current_track")
52 + status = run("music.mopidy.status")
53 + state = status.get("state")
54 + image = None
55 +
56 + if not state:
57 + return
58 +
59 + # Retrieve cover image if available
60 + if track.get("x-albumuri"):
61 + start_t = time.time()
62 + image = get_cover_by_uri(track["x-albumuri"])
63 + logger.info("Retrieved track image info in %.2f seconds", time.time() - start_t)
64 +
65 + # Send the music notification via ntfy
66 + run(
67 + "ntfy.send_message",
68 + topic=music_notifications_topic,
69 + message=json.dumps(
70 + {
71 + "file": track.get("file"),
72 + "artist": track.get("artist"),
73 + "title": track.get("title"),
74 + "album": track.get("album"),
75 + "date": track.get("date"),
76 + "duration": status.get("track", {}).get("time"),
77 + "elapsed": status.get("time"),
78 + "image": image,
79 + "device_id": device_id,
80 + "state": state,
81 + "priority": "min",
82 + }
83 + ),
84 + )
85 +
86 +
87 + @hook(NotificationEvent, topic=music_commands_topic)
88 + def on_music_command(event: NotificationEvent):
89 + """
90 + Handles incoming music commands from ntfy notifications.
91 + """
92 +
93 + try:
94 + payload = json.loads(event.message)
95 + except Exception as e:
96 + logger.warning("Invalid music command payload: %s", e)
97 + return
98 +
99 + # Ignore the command if it's not for this device.
100 + # This is useful if multiple devices share the same ntfy topic.
101 + device_id = payload.get("device_id")
102 + if device_id and device_id != Config.get_device_id():
103 + return
104 +
105 + command = next(
106 + (cmd for cmd in MusicCommands if cmd.value.name == payload.get("command")), None
107 + )
108 +
109 + if not command:
110 + logger.warning(
111 + "Unsupported music command: %s. Supported commands: %s",
112 + payload.get("command"),
113 + list(supported_commands.keys()),
114 + )
115 + return
116 +
117 + # Handle the TOGGLE case depending on current playback state
118 + if command == MusicCommands.TOGGLE:
119 + status = run("music.mopidy.status")
120 + if status.get("state") == "play":
121 + action = MusicCommands.PAUSE.value.action
122 + else:
123 + action = MusicCommands.PLAY.value.action
124 + else:
125 + action = command.value.action
126 +
127 + logger.info(
128 + "Executing music command on %s@%s: %s",
129 + music_plugin,
130 + device_id,
131 + command.value.name,
132 + )
133 +
134 + run(action)

fabio's Avatar fabio revised this gist 1765752618. Go to revision

1 file changed, 2 insertions, 4 deletions

mopidy_notifications.py

@@ -117,10 +117,8 @@ def update_mobile_music_notification():
117 117 "title": track.get("title"),
118 118 "album": track.get("album"),
119 119 "date": track.get("date"),
120 - "duration": int(float(track["time"])) if track.get("time") else None,
121 - "elapsed": (
122 - int(float(status["elapsed"])) if status.get("elapsed") else None
123 - ),
120 + "duration": status.get("track", {}).get("time"),
121 + "elapsed": status.get("time"),
124 122 "image": image,
125 123 "device_id": device_id,
126 124 "state": state,

fabio's Avatar fabio revised this gist 1765752151. Go to revision

2 files changed, 160 insertions, 13 deletions

config.yaml

@@ -17,22 +17,19 @@ ntfy:
17 17 # Otherwise, the default public server (https://ntfy.sh) will be used.
18 18 # server_url: https://your-ntfy-server.com
19 19 subscriptions:
20 - # Subscribe to the topic where music commands will be sent
20 + # Subscribe to the topic where music commands will be sent.
21 + # NOTE: It must match the topic configured in your ntfy mobile app
21 22 - music-commands-<your-unique-id>
22 23
23 24 music.mopidy:
24 25 host: localhost
25 26 # port: 6680
26 27
27 - # Optional, if you also want to enable the MPD interface.
28 - # The MPD interface provides better compatibility with MPD clients,
29 - # but it will process events slower than the Mopidy interface
30 - # (Mopidy dispatches events over WebSocket, while MPD clients poll for
31 - # changes).
32 - # Keep in mind that, on a mobile device, the MPD interface may consume more
33 - # battery.
34 - #
35 - # music.mpd:
36 - # host: localhost
37 - # # port: 6600
38 - # poll_interval: 20.0
28 + # Optional, if you also want to enable the MPD bindings
29 + music.mpd:
30 + host: localhost
31 + # port: 6600
32 + # Set poll_interval to null - music.mopidy is already enabled and it
33 + # receives updates over WebSocket. Avoid polling the MPD interface to
34 + # prevent battery drain.
35 + poll_interval: null

mopidy_notifications.py(file created)

@@ -0,0 +1,150 @@
1 + """
2 + This module integrates Mopidy music events with mobile notifications
3 + using the ntfy service. It listens for music playback events and updates
4 + the mobile notification with the current track information, including
5 + cover art if available.
6 + """
7 +
8 + import json
9 + import logging
10 + import os
11 + import pathlib
12 + import time
13 +
14 + import requests
15 +
16 + from platypush import Config, hook, run
17 +
18 + # Music events to hook into
19 + from platypush.message.event.music import (
20 + MusicPlayEvent,
21 + MusicPauseEvent,
22 + MusicStopEvent,
23 + NewPlayingTrackEvent,
24 + )
25 +
26 + # NOTE: The topic that will be used to send music notifications.
27 + # It must match the one configured in your ntfy mobile app.
28 + music_notifications_topic = "music-notifications-<your-unique-id>"
29 +
30 + logger = logging.getLogger("music_integrations")
31 +
32 +
33 + def get_cover_by_uri(uri):
34 + """
35 + A utility method that retrieves the cover image URL for a given Mopidy URI,
36 + using directly the Mopidy HTTP JSON-RPC API. It also caches the results
37 + locally to avoid repeated requests.
38 + """
39 + cache_path = os.path.join(
40 + os.path.expanduser("~"), ".cache", "platypush", "mopidy", "covers.json"
41 + )
42 +
43 + cached_images = {}
44 + pathlib.Path(cache_path).parent.mkdir(exist_ok=True, parents=True)
45 + if os.path.isfile(cache_path):
46 + try:
47 + with open(cache_path, "r") as f:
48 + cached_images = json.load(f)
49 + except Exception as e:
50 + logger.warning("Could not load cached cover images: %s", e)
51 +
52 + # Return cached image URL if available
53 + if cached_images.get(uri):
54 + return cached_images[uri]
55 +
56 + resp = None
57 +
58 + try:
59 + # Make a POST request to the Mopidy JSON-RPC API to get the image URL
60 + resp = requests.post(
61 + "http://localhost:6680/mopidy/rpc",
62 + timeout=5.0,
63 + json={
64 + "jsonrpc": "2.0",
65 + "id": 1,
66 + "method": "core.library.get_images",
67 + "params": {"uris": [uri]},
68 + },
69 + )
70 +
71 + resp.raise_for_status()
72 + except Exception as e:
73 + logger.warning("Could not retrieve cover for %s: %s", uri, e)
74 + return None
75 +
76 + image_url = cached_images[uri] = (
77 + resp.json().get("result", {}).get(uri, [{}])[0].get("uri")
78 + )
79 +
80 + # Update the cache file with the new image URL
81 + with open(cache_path, "w") as f:
82 + f.write(json.dumps(cached_images))
83 +
84 + return image_url
85 +
86 +
87 + def update_mobile_music_notification():
88 + """
89 + Updates the mobile music notification with the current track info
90 + retrieved from Mopidy, sending it via ntfy.
91 + """
92 +
93 + # This matches the device_id configured in config.yaml
94 + device_id = Config.get_device_id()
95 + track = run("music.mopidy.current_track")
96 + status = run("music.mopidy.status")
97 + state = status.get("state")
98 + image = None
99 +
100 + if not state:
101 + return
102 +
103 + # Retrieve cover image if available
104 + if track.get("x-albumuri"):
105 + start_t = time.time()
106 + image = get_cover_by_uri(track["x-albumuri"])
107 + logger.info("Retrieved track image info in %.2f seconds", time.time() - start_t)
108 +
109 + # Send the music notification via ntfy
110 + run(
111 + "ntfy.send_message",
112 + topic=music_notifications_topic,
113 + message=json.dumps(
114 + {
115 + "file": track.get("file"),
116 + "artist": track.get("artist"),
117 + "title": track.get("title"),
118 + "album": track.get("album"),
119 + "date": track.get("date"),
120 + "duration": int(float(track["time"])) if track.get("time") else None,
121 + "elapsed": (
122 + int(float(status["elapsed"])) if status.get("elapsed") else None
123 + ),
124 + "image": image,
125 + "device_id": device_id,
126 + "state": state,
127 + "priority": "min",
128 + }
129 + ),
130 + )
131 +
132 +
133 + @hook(MusicPlayEvent)
134 + def on_music_play():
135 + update_mobile_music_notification()
136 +
137 +
138 + @hook(MusicPauseEvent)
139 + def on_music_pause():
140 + update_mobile_music_notification()
141 +
142 +
143 + @hook(MusicStopEvent)
144 + def on_music_stop():
145 + update_mobile_music_notification()
146 +
147 +
148 + @hook(NewPlayingTrackEvent)
149 + def on_new_track():
150 + update_mobile_music_notification()

fabio's Avatar fabio revised this gist 1765751175. Go to revision

1 file changed, 38 insertions

config.yaml(file created)

@@ -0,0 +1,38 @@
1 + #!~/.config/platypush/config.yaml
2 +
3 + # Platypush configuration file for Termux Mopidy Notifier
4 +
5 + # A name for your device
6 + device_id: phone
7 +
8 + # Enable the Web server
9 + backend.http:
10 + # port: 8008
11 + # Reduce the number of workers to avoid phantom process SIGKILL
12 + # See https://ivonblog.com/en-us/posts/fix-termux-signal9-error/
13 + num_workers: 1
14 +
15 + ntfy:
16 + # Uncomment and set your custom server URL if you're using a self-hosted ntfy server
17 + # Otherwise, the default public server (https://ntfy.sh) will be used.
18 + # server_url: https://your-ntfy-server.com
19 + subscriptions:
20 + # Subscribe to the topic where music commands will be sent
21 + - music-commands-<your-unique-id>
22 +
23 + music.mopidy:
24 + host: localhost
25 + # port: 6680
26 +
27 + # Optional, if you also want to enable the MPD interface.
28 + # The MPD interface provides better compatibility with MPD clients,
29 + # but it will process events slower than the Mopidy interface
30 + # (Mopidy dispatches events over WebSocket, while MPD clients poll for
31 + # changes).
32 + # Keep in mind that, on a mobile device, the MPD interface may consume more
33 + # battery.
34 + #
35 + # music.mpd:
36 + # host: localhost
37 + # # port: 6600
38 + # poll_interval: 20.0
Newer Older