From 8b677faf7c69edb355bb96f4beb040178bdece8b Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 9 Nov 2026 18:14:56 +0100 Subject: feat: music module --- deckit/modules/music.py | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ tests/test_music.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 deckit/modules/music.py create mode 100644 tests/test_music.py diff --git a/deckit/modules/music.py b/deckit/modules/music.py new file mode 100644 index 0000000..af13688 --- /dev/null +++ b/deckit/modules/music.py @@ -0,0 +1,49 @@ +"""Now playing and volume, via playerctl and wpctl.""" + +import subprocess + +from ..module import Module + +SEP = "\x1f" + +BASE_ACTIONS = { + "voldown": ["wpctl", "set-volume", "@DEFAULT_AUDIO_SINK@", "5%-"], + "mute": ["wpctl", "set-mute", "@DEFAULT_AUDIO_SINK@", "toggle"], + "volup": ["wpctl", "set-volume", "-l", "1.0", "@DEFAULT_AUDIO_SINK@", "5%+"], + "prev": ["playerctl", "previous"], + "play": ["playerctl", "play-pause"], + "next": ["playerctl", "next"], +} + + +def _run(cmd, timeout=2): + try: + return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout).stdout.strip() + except (OSError, subprocess.TimeoutExpired): + return "" + + +def _popen(cmd): + subprocess.Popen(cmd, start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +def make(cfg: dict) -> Module: + run = cfg.get("run", _run) + + def state(): + fmt = SEP.join(["{{status}}", "{{title}}", "{{artist}}", "{{album}}", "{{mpris:length}}", + "{{position}}", "{{mpris:artUrl}}", "{{playerName}}"]) + out = run(["playerctl", "metadata", "--format", fmt]) + vol = run(["wpctl", "get-volume", "@DEFAULT_AUDIO_SINK@"]) + volume = {"level": round(float(vol.split()[1]) * 100) if len(vol.split()) > 1 else 0, + "muted": "MUTED" in vol} + if not out: + return {"status": "Stopped", "volume": volume} + status, title, artist, album, length, pos, art, name = (out.split(SEP) + [""] * 8)[:8] + art_src = art if art.startswith(("http://", "https://")) else None + return {"status": status, "title": title, "artist": artist, "album": album, + "length": int(length or 0) / 1e6, "position": int(pos or 0) / 1e6, + "art": art_src, "player": name.split(".")[0], "volume": volume} + + actions = {name: (lambda cmd: (lambda body: _popen(cmd)))(cmd) for name, cmd in BASE_ACTIONS.items()} + return Module(name="music", title="Music", state=state, actions=actions) diff --git a/tests/test_music.py b/tests/test_music.py new file mode 100644 index 0000000..675e72b --- /dev/null +++ b/tests/test_music.py @@ -0,0 +1,30 @@ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import unittest + +import deckit.modules.music as music + + +class Music(unittest.TestCase): + def test_parses_metadata(self): + sep = "\x1f" + fake = lambda cmd, timeout=2: { + "metadata": sep.join(["Playing", "Song", "Artist", "Album", "200000000", + "1000000", "", "spotify"]), + "get-volume": "Volume: 0.42", + }[cmd[1]] + m = music.make({"run": fake}) + st = m.state() + self.assertEqual(st["title"], "Song") + self.assertEqual(st["status"], "Playing") + self.assertEqual(st["volume"]["level"], 42) + + def test_action_commands(self): + self.assertEqual(music.BASE_ACTIONS["next"], ["playerctl", "next"]) + + +if __name__ == "__main__": + unittest.main() -- cgit v1.2.3