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 127ec39195169a5ff775b29279572edaa5699ff6

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
install.sh Raw
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
6CONFDIR="${CONFDIR:-$HOME/.config/platypush}"
7mkdir -p "$CONFDIR/scripts"
8
9install -m 600 config.yaml "$CONFDIR"
10install -m 600 mopidy_commands.py "$CONFDIR/scripts"
11install -m 600 mopidy_notifications.py "$CONFDIR/scripts"
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
9from collections import namedtuple
10from enum import Enum
11
12from platypush import Config, hook, run
13
14# Listen for ntfy message notifications
15from platypush.message.event.ntfy import NotificationEvent
16
17# NOTE: The topic used to send music commands to Mopidy via ntfy.
18# It must match the one configured in your ntfy mobile app.
19music_commands_topic = "music-commands-<your-unique-id>"
20music_plugin = "music.mopidy" # Or "music.mpd" if using MPD
21logger = logging.getLogger("music_integrations")
22
23ClassCommand = namedtuple("ClassCommand", ["name", "action"])
24
25
26class MusicCommands(Enum):
27 """
28 Enum representing music control commands and their corresponding actions.
29 """
30
31 PLAY = ClassCommand("play", f"{music_plugin}.play")
32 PAUSE = ClassCommand("pause", f"{music_plugin}.pause")
33 STOP = ClassCommand("stop", f"{music_plugin}.stop")
34 NEXT = ClassCommand("next", f"{music_plugin}.next")
35 PREVIOUS = ClassCommand("previous", f"{music_plugin}.previous")
36 TOGGLE = ClassCommand("toggle", f"{music_plugin}.toggle")
37
38
39supported_commands = {cmd.value.name: cmd.value.action for cmd in MusicCommands}
40
41
42@hook(NotificationEvent, topic=music_commands_topic)
43def on_music_command(event: NotificationEvent):
44 """
45 Handles incoming music commands from ntfy notifications.
46 """
47
48 try:
49 payload = json.loads(event.message)
50 except Exception as e:
51 logger.warning("Invalid music command payload: %s", e)
52 return
53
54 # Ignore the command if it's not for this device.
55 # This is useful if multiple devices share the same ntfy topic.
56 device_id = payload.get("device_id")
57 if device_id and device_id != Config.get_device_id():
58 return
59
60 command = next(
61 (cmd for cmd in MusicCommands if cmd.value.name == payload.get("command")), None
62 )
63
64 if not command:
65 logger.warning(
66 "Unsupported music command: %s. Supported commands: %s",
67 payload.get("command"),
68 list(supported_commands.keys()),
69 )
70 return
71
72 # Handle the TOGGLE case depending on current playback state
73 if command == MusicCommands.TOGGLE:
74 status = run("music.mopidy.status")
75 if status.get("state") == "play":
76 action = MusicCommands.PAUSE.value.action
77 else:
78 action = MusicCommands.PLAY.value.action
79 else:
80 action = command.value.action
81
82 logger.info(
83 "Executing music command on %s@%s: %s",
84 music_plugin,
85 device_id,
86 command.value.name,
87 )
88
89 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()