From 6ade1f890bcbf261113f199918005dc49f1fa2da Mon Sep 17 00:00:00 2001 From: Daniel Peukert Date: Fri, 16 Feb 2024 20:53:37 +0100 Subject: [PATCH] music modules: implement sanitize_words support for all music modules --- py3status/constants.py | 13 +++++++ py3status/modules/cmus.py | 12 ++++++ py3status/modules/deadbeef.py | 17 ++++++++ py3status/modules/moc.py | 17 ++++++++ py3status/modules/mpd_status.py | 69 +++++++++++++++++++-------------- py3status/modules/mpris.py | 18 ++++++++- py3status/modules/playerctl.py | 16 +++++++- py3status/modules/spotify.py | 56 ++++---------------------- py3status/py3.py | 35 +++++++++++++++++ 9 files changed, 173 insertions(+), 80 deletions(-) diff --git a/py3status/constants.py b/py3status/constants.py index 0a03a9b75a..c4e7103e73 100644 --- a/py3status/constants.py +++ b/py3status/constants.py @@ -270,3 +270,16 @@ MARKUP_LANGUAGES = ["pango", "none"] ON_ERROR_VALUES = ["hide", "show"] + +DEFAULT_SANITIZE_WORDS = [ + "bonus", + "demo", + "edit", + "explicit", + "extended", + "feat", + "mono", + "remaster", + "stereo", + "version", +] diff --git a/py3status/modules/cmus.py b/py3status/modules/cmus.py index 11e3229a84..58a4a3586f 100644 --- a/py3status/modules/cmus.py +++ b/py3status/modules/cmus.py @@ -20,6 +20,11 @@ this interval will be used. this allows some flexible timing where one might want to refresh constantly with some placeholders... or to refresh only once every minute rather than every few seconds. (default 20) + sanitize_titles: whether to remove meta data from album/track title + (default True) + sanitize_words: which meta data to remove + *(default ['bonus', 'demo', 'edit', 'explicit', 'extended', + 'feat', 'mono', 'remaster', 'stereo', 'version'])* Control placeholders: {is_paused} a boolean based on cmus status @@ -83,6 +88,8 @@ {'color': '#FF0000', 'full_text': '.. cmus: waiting for user input'} """ +from py3status.constants import DEFAULT_SANITIZE_WORDS + STRING_NOT_INSTALLED = "not installed" @@ -101,6 +108,8 @@ class Py3status: r"|\?show cmus: waiting for user input]]" ) sleep_timeout = 20 + sanitize_titles = True + sanitize_words = DEFAULT_SANITIZE_WORDS def post_config_hook(self): if not self.py3.check_commands("cmus-remote"): @@ -149,6 +158,9 @@ def _manipulate_data(self, data): temporary[key] = True elif value in ("false", "disabled"): temporary[key] = False + # sanitize album and title + elif self.sanitize_titles and key in ("album", "title"): + temporary[key] = self.py3.sanitize_title(self.sanitize_words, value) # string not modified else: temporary[key] = value diff --git a/py3status/modules/deadbeef.py b/py3status/modules/deadbeef.py index e20ddb9d17..d7abd01ab8 100644 --- a/py3status/modules/deadbeef.py +++ b/py3status/modules/deadbeef.py @@ -8,6 +8,11 @@ to allow faster refreshes with time-related placeholders and/or to refresh few times per minute rather than every few seconds (default 20) + sanitize_titles: whether to remove meta data from album/track title + (default True) + sanitize_words: which meta data to remove + *(default ['bonus', 'demo', 'edit', 'explicit', 'extended', + 'feat', 'mono', 'remaster', 'stereo', 'version'])* Format placeholders: {album} name of the album @@ -49,6 +54,8 @@ {'color': '#ffff00', 'full_text': 'Music For Programming - Lackluster'} """ +from py3status.constants import DEFAULT_SANITIZE_WORDS + STRING_NOT_INSTALLED = "not installed" @@ -59,6 +66,8 @@ class Py3status: cache_timeout = 5 format = "[{artist} - ][{title}]" sleep_timeout = 20 + sanitize_titles = True + sanitize_words = DEFAULT_SANITIZE_WORDS class Meta: deprecated = { @@ -119,6 +128,14 @@ def deadbeef(self): beef_data = dict(zip(self.placeholders, line.split(self.separator))) cached_until = self.cache_timeout + # Sanitize album and title + if self.sanitize_titles: + if "album" in beef_data: + beef_data["album"] = self.py3.sanitize_title(self.sanitize_words, beef_data["album"]) + + if "title" in beef_data: + beef_data["title"] = self.py3.sanitize_title(self.sanitize_words, beef_data["title"]) + if beef_data["isplaying"]: color = self.color_playing else: diff --git a/py3status/modules/moc.py b/py3status/modules/moc.py index 585e962be1..340acaa33a 100644 --- a/py3status/modules/moc.py +++ b/py3status/modules/moc.py @@ -18,6 +18,11 @@ allow one to refresh constantly with time placeholders and/or to refresh once every minute rather than every few seconds (default 20) + sanitize_titles: whether to remove meta data from album/track title + (default True) + sanitize_words: which meta data to remove + *(default ['bonus', 'demo', 'edit', 'explicit', 'extended', + 'feat', 'mono', 'remaster', 'stereo', 'version'])* Control placeholders: {is_paused} a boolean based on moc status @@ -72,6 +77,8 @@ {'color': '#FF0000', 'full_text': '[] moc'} """ +from py3status.constants import DEFAULT_SANITIZE_WORDS + STRING_NOT_INSTALLED = "not installed" @@ -89,6 +96,8 @@ class Py3status: r"[\?if=is_paused \|\|][\?if=is_playing >] {title}]" ) sleep_timeout = 20 + sanitize_titles = True + sanitize_words = DEFAULT_SANITIZE_WORDS def post_config_hook(self): if not self.py3.check_commands("mocp"): @@ -122,6 +131,14 @@ def moc(self): category, value = line.split(": ", 1) data[category.lower()] = value + # Sanitize album and title + if self.sanitize_titles: + if "album" in data: + data["album"] = self.py3.sanitize_title(self.sanitize_words, data["album"]) + + if "title" in data: + data["title"] = self.py3.sanitize_title(self.sanitize_words, data["title"]) + self.state = data["state"] if self.state == "PLAY": is_playing = True diff --git a/py3status/modules/mpd_status.py b/py3status/modules/mpd_status.py index fbf73f8bb0..bbfc136ec3 100644 --- a/py3status/modules/mpd_status.py +++ b/py3status/modules/mpd_status.py @@ -24,6 +24,11 @@ state_stop: label to display for "stopped" state (default '[stop]') use_idle: whether to use idling instead of polling. None to autodetect (default None) + sanitize_titles: whether to remove meta data from album/track title + (default True) + sanitize_words: which meta data to remove + *(default ['bonus', 'demo', 'edit', 'explicit', 'extended', + 'feat', 'mono', 'remaster', 'stereo', 'version'])* Format placeholders: {state} state (paused, playing. stopped) can be defined via `state_..` @@ -74,33 +79,7 @@ from mpd import CommandError, ConnectionError, MPDClient from py3status.composite import Composite - - -def song_attr(song, attr): - def parse_mtime(date_str): - return datetime.datetime.strptime(date_str, "%Y-%m-%dT%H:%M:%SZ") - - if attr == "time": - try: - duration = int(song["time"]) - if duration > 0: - minutes, seconds = divmod(duration, 60) - return f"{minutes:d}:{seconds:02d}" - raise ValueError - except (KeyError, ValueError): - return "" - elif attr == "position": - try: - return "{}".format(int(song["pos"]) + 1) - except (KeyError, ValueError): - return "" - elif attr == "mtime": - return parse_mtime(song["last-modified"]).strftime("%c") - elif attr == "mdate": - return parse_mtime(song["last-modified"]).strftime("%x") - - return song.get(attr, "") - +from py3status.constants import DEFAULT_SANITIZE_WORDS class Py3status: """ """ @@ -121,6 +100,8 @@ class Py3status: state_play = "[play]" state_stop = "[stop]" use_idle = None + sanitize_titles = True + sanitize_words = DEFAULT_SANITIZE_WORDS def post_config_hook(self): # Convert from %placeholder% to {placeholder} @@ -133,6 +114,34 @@ def post_config_hook(self): self.current_status = None self.idle_thread = Thread() + def _song_attr(self, song, attr): + def parse_mtime(date_str): + return datetime.datetime.strptime(date_str, "%Y-%m-%dT%H:%M:%SZ") + + if attr == "time": + try: + duration = int(song["time"]) + if duration > 0: + minutes, seconds = divmod(duration, 60) + return f"{minutes:d}:{seconds:02d}" + raise ValueError + except (KeyError, ValueError): + return "" + elif attr == "position": + try: + return "{}".format(int(song["pos"]) + 1) + except (KeyError, ValueError): + return "" + elif attr == "mtime": + return parse_mtime(song["last-modified"]).strftime("%c") + elif attr == "mdate": + return parse_mtime(song["last-modified"]).strftime("%x") + # Sanitize album and title + elif self.sanitize_titles and attr in ("album", "title"): + return self.py3.sanitize_title(self.sanitize_words, song.get(attr, "")) + + return song.get(attr, "") + def _get_mpd(self, disconnect=False): if disconnect: try: @@ -229,8 +238,8 @@ def _get_status(self): def attr_getter(attr): if attr.startswith("next_"): - return song_attr(next_song, attr[5:]) - return song_attr(song, attr) + return self._song_attr(next_song, attr[5:]) + return self._song_attr(song, attr) text = self.py3.safe_format(self.format, attr_getter=attr_getter) if isinstance(text, Composite): @@ -277,4 +286,4 @@ def kill(self): """ from py3status.module_test import module_test - module_test(Py3status) + module_test(Py3status, config={"format": "{artist} - {title}"}) diff --git a/py3status/modules/mpris.py b/py3status/modules/mpris.py index bd58555a47..818a0c7872 100644 --- a/py3status/modules/mpris.py +++ b/py3status/modules/mpris.py @@ -32,10 +32,15 @@ state_pause: specify icon for pause state (default u'\u25eb') state_play: specify icon for play state (default u'\u25b7') state_stop: specify icon for stop state (default u'\u25a1') + sanitize_titles: whether to remove meta data from album/track title + (default True) + sanitize_words: which meta data to remove + *(default ['bonus', 'demo', 'edit', 'explicit', 'extended', + 'feat', 'mono', 'remaster', 'stereo', 'version'])* Format placeholders: {album} album name - {artist} artiste name (first one) + {artist} artist name (first one) {length} time duration of the song {player} show name of the player {player_shortname} show name of the player from busname (usually command line name) @@ -117,6 +122,7 @@ from mpris2 import get_players_uri from mpris2.types import Metadata_Map +from py3status.constants import DEFAULT_SANITIZE_WORDS class STATE(IntEnum): Playing = 0 @@ -282,6 +288,14 @@ def metadata(self, metadata=None): self._metadata["nowplaying"] = metadata.get("vlc:nowplaying", None) + # Sanitize album and title + if self.parent.sanitize_titles: + if "album" in self._metadata: + self._metadata["album"] = self.parent.py3.sanitize_title(self.parent.sanitize_words, self._metadata["album"]) + + if "title" in self._metadata: + self._metadata["title"] = self.parent.py3.sanitize_title(self.parent.sanitize_words, self._metadata["title"]) + if not self._metadata.get("title"): self._metadata["title"] = "No Track" @@ -385,6 +399,8 @@ class Py3status: state_pause = "\u25eb" state_play = "\u25b7" state_stop = "\u25a1" + sanitize_titles = True + sanitize_words = DEFAULT_SANITIZE_WORDS def post_config_hook(self): self._name_owner_change_match = None diff --git a/py3status/modules/playerctl.py b/py3status/modules/playerctl.py index 605317a884..aba57ccb62 100644 --- a/py3status/modules/playerctl.py +++ b/py3status/modules/playerctl.py @@ -1,7 +1,7 @@ r""" Display song/video and control players supported by playerctl -Playerctl is a command-line utility for controlling media players +Playerctl is a command-line utility for controlling media players that implement the MPRIS D-Bus Interface Specification. With Playerctl you can bind player actions to keys and get metadata about the currently playing song or video. @@ -29,6 +29,11 @@ thresholds: specify color thresholds to use for different placeholders (default {"status": [("Playing", "good"), ("Paused", "degraded"), ("Stopped", "bad")]}) volume_delta: percentage (from 0 to 100) to change the player's volume by (default 10) + sanitize_titles: whether to remove meta data from album/track title + (default True) + sanitize_words: which meta data to remove + *(default ['bonus', 'demo', 'edit', 'explicit', 'extended', + 'feat', 'mono', 'remaster', 'stereo', 'version'])* Not all players support every button action @@ -73,6 +78,8 @@ import gi from gi.repository import GLib, Playerctl +from py3status.constants import DEFAULT_SANITIZE_WORDS + gi.require_version("Playerctl", "2.0") @@ -102,6 +109,8 @@ class Py3status: seek_delta = 5 thresholds = {"status": [("Playing", "good"), ("Paused", "degraded"), ("Stopped", "bad")]} volume_delta = 10 + sanitize_titles = True + sanitize_words = DEFAULT_SANITIZE_WORDS class Meta: update_config = { @@ -234,6 +243,11 @@ def _get_player_data(self, player): data["status"] = player.props.status data["volume"] = int(player.props.volume * 100) + # Sanitize album and title + if self.sanitize_titles: + data["album"] = self.py3.sanitize_title(self.sanitize_words, data["album"]) + data["title"] = self.py3.sanitize_title(self.sanitize_words, data["title"]) + return data def _get_player_from_index(self, index): diff --git a/py3status/modules/spotify.py b/py3status/modules/spotify.py index 29b87e7a8a..dc5ea28005 100644 --- a/py3status/modules/spotify.py +++ b/py3status/modules/spotify.py @@ -23,7 +23,7 @@ Format placeholders: {album} album name - {artist} artiste name (first one) + {artist} artist name (first one) {playback} state of the playback: Playing, Paused {time} time duration of the song {title} name of the song @@ -60,12 +60,13 @@ {'color': '#FF0000', 'full_text': 'Spotify stopped'} """ -import re from datetime import timedelta from time import sleep import dbus +from py3status.constants import DEFAULT_SANITIZE_WORDS + SPOTIFY_CMD = """dbus-send --print-reply --dest={dbus_client} /org/mpris/MediaPlayer2 org.mpris.MediaPlayer2.Player.{cmd}""" @@ -83,46 +84,11 @@ class Py3status: format_down = "Spotify not running" format_stopped = "Spotify stopped" sanitize_titles = True - sanitize_words = [ - "bonus", - "demo", - "edit", - "explicit", - "extended", - "feat", - "mono", - "remaster", - "stereo", - "version", - ] + sanitize_words = DEFAULT_SANITIZE_WORDS def _spotify_cmd(self, action): return SPOTIFY_CMD.format(dbus_client=self.dbus_client, cmd=action) - def post_config_hook(self): - """ """ - # Match string after hyphen, comma, semicolon or slash containing any metadata word - # examples: - # - Remastered 2012 - # / Radio Edit - # ; Remastered - self.after_delimiter = self._compile_re(r"([\-,;/])([^\-,;/])*(META_WORDS_HERE).*") - - # Match brackets with their content containing any metadata word - # examples: - # (Remastered 2017) - # [Single] - # (Bonus Track) - self.inside_brackets = self._compile_re(r"([\(\[][^)\]]*?(META_WORDS_HERE)[^)\]]*?[\)\]])") - - def _compile_re(self, expression): - """ - Compile given regular expression for current sanitize words - """ - meta_words = "|".join(self.sanitize_words) - expression = expression.replace("META_WORDS_HERE", meta_words) - return re.compile(expression, re.IGNORECASE) - def _get_playback_status(self): """ Get the playback status. One of: "Playing", "Paused" or "Stopped". @@ -145,9 +111,11 @@ def _get_text(self): microtime = metadata.get("mpris:length") rtime = str(timedelta(seconds=microtime // 1_000_000)) title = metadata.get("xesam:title") + + # Sanitize album and title if self.sanitize_titles: - album = self._sanitize_title(album) - title = self._sanitize_title(title) + album = self.py3.sanitize_title(self.sanitize_words, album) + title = self.py3.sanitize_title(self.sanitize_words, title) playback_status = self._get_playback_status() if playback_status == "Playing": @@ -176,14 +144,6 @@ def _get_text(self): except Exception: return (self.format_down, self.py3.COLOR_OFFLINE or self.py3.COLOR_BAD) - def _sanitize_title(self, title): - """ - Remove redundant metadata from title and return it - """ - title = re.sub(self.inside_brackets, "", title) - title = re.sub(self.after_delimiter, "", title) - return title.strip() - def spotify(self): """ Get the current "artist - title" and return it. diff --git a/py3status/py3.py b/py3status/py3.py index 8550f89f1b..d573f150ed 100644 --- a/py3status/py3.py +++ b/py3status/py3.py @@ -1,4 +1,5 @@ import os +import re import shlex import sys import time @@ -102,6 +103,7 @@ def __init__(self, module=None): self._format_placeholders = {} self._format_placeholders_cache = {} self._module = module + self._sanitize_title_regex_cache = {} self._report_exception_cache = set() self._thresholds = None self._threshold_gradients = {} @@ -1291,3 +1293,36 @@ def get_http_response(): self.log(f"HTTP request retry {retry_times}/{retry_times}") sleep(retry_wait) return get_http_response() + + def sanitize_title(self, words, title): + """ + Remove the provided redundant metadata from the title and return it + """ + + # If our title is None or an empty string, we can return early + if title is None or title == "": + return title + + meta_words = "|".join(words) + + # If our regexes haven't already been compiled, do that and store them in the cache + if meta_words not in self._sanitize_title_regex_cache: + self._sanitize_title_regex_cache[meta_words] = { + # Match brackets with their content containing any metadata word + # examples: + # (Remastered 2017) + # [Single] + # (Bonus Track) + "inside_brackets": re.compile(r"([\(\[][^)\]]*?({})[^)\]]*?[\)\]])".format(meta_words), re.IGNORECASE), + # Match string after hyphen, comma, semicolon or slash containing any metadata word + # examples: + # - Remastered 2012 + # / Radio Edit + # ; Remastered + "after_delimiter": re.compile(r"([\-,;/])([^\-,;/])*({}).*".format(meta_words), re.IGNORECASE), + } + + title = re.sub(self._sanitize_title_regex_cache[meta_words]["inside_brackets"], "", title) + title = re.sub(self._sanitize_title_regex_cache[meta_words]["after_delimiter"], "", title) + + return title.strip()