fabio revised this gist . 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 revised this gist . 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 revised this gist . 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 revised this gist . 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 revised this gist . 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 revised this gist . 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 | |