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

Revision 3be263c087c4ba9310b7f580d286e4b5dfb7d161

config.yaml Raw
1#!~/.config/platypush/config.yaml
2
3# Platypush configuration file for Termux Mopidy Notifier
4
5# A name for your device
6device_id: phone
7
8# Enable the Web server
9backend.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
15ntfy:
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 # NOTE: It must match the topic configured in your ntfy mobile app
22 - music-commands-<your-unique-id>
23
24music.mopidy:
25 host: localhost
26 # port: 6680
27
28# Optional, if you also want to enable the MPD bindings
29music.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_commands.py Raw
1"""
2This module integrates Mopidy music playback with ntfy notifications.
3It listens for music control commands sent via ntfy and executes them on the
4Mopidy server.
5"""
6
7import json
8import logging
9import time
10from collections import namedtuple
11from enum import Enum
12
13from platypush import Config, hook, run
14
15# Listen for ntfy message notifications
16from 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.
20music_commands_topic = "music-commands-<your-unique-id>"
21music_plugin = "music.mopidy" # Or "music.mpd" if using MPD
22logger = logging.getLogger("music_integrations")
23
24ClassCommand = namedtuple("ClassCommand", ["name", "action"])
25
26
27class 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
40supported_commands = {cmd.value.name: cmd.value.action for cmd in MusicCommands}
41
42
43def 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)
88def 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)
mopidy_notifications.py Raw
1"""
2This module integrates Mopidy music events with mobile notifications
3using the ntfy service. It listens for music playback events and updates
4the mobile notification with the current track information, including
5cover art if available.
6"""
7
8import json
9import logging
10import os
11import pathlib
12import time
13
14import requests
15
16from platypush import Config, hook, run
17
18# Music events to hook into
19from 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.
28music_notifications_topic = "music-notifications-<your-unique-id>"
29
30logger = logging.getLogger("music_integrations")
31
32
33def 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
87def 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": status.get("track", {}).get("time"),
121 "elapsed": status.get("time"),
122 "image": image,
123 "device_id": device_id,
124 "state": state,
125 "priority": "min",
126 }
127 ),
128 )
129
130
131@hook(MusicPlayEvent)
132def on_music_play():
133 update_mobile_music_notification()
134
135
136@hook(MusicPauseEvent)
137def on_music_pause():
138 update_mobile_music_notification()
139
140
141@hook(MusicStopEvent)
142def on_music_stop():
143 update_mobile_music_notification()
144
145
146@hook(NewPlayingTrackEvent)
147def on_new_track():
148 update_mobile_music_notification()