Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add album covers demonstration for players that support it #32

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ name = "pypi"
[packages]
dbussy = "*"
pytoml = "*"
requests = "*"
watchdog = "*"

[dev-packages]

Expand Down
76 changes: 75 additions & 1 deletion Pipfile.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Discord Rich Presence through mpris

| User Modal | Popout Modal |
| ------------------- | --------------------- |
|---------------------|-----------------------|
| ![][img-user-modal] | ![][img-popout-modal] |

discordrp-mpris provides Rich Presence to Discord clients
Expand Down Expand Up @@ -92,6 +92,8 @@ Icons are available for:
When no player icon is available,
the playback state is used as the large icon.

You can configure discordrp-mpris the way it will show album covers as large image.

The following players are **not** supported:

- Spotify
Expand Down
83 changes: 62 additions & 21 deletions discordrp_mpris/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
import re
import sys
import time
import requests
from urllib import request
from functools import lru_cache
from textwrap import shorten
from typing import Any, DefaultDict, Dict, Iterable, List, Optional, Union

Expand Down Expand Up @@ -45,7 +48,7 @@
# Maximum allowed characters of Rich Presence's "details" field
DETAILS_MAX_CHARS = 128

# Relative weight for shortening when details exceeds max length
# Relative weight for shortening when details exceed max length
weigth_map: Dict[str, int] = DefaultDict(
lambda: 1,
title=4,
Expand All @@ -61,8 +64,7 @@ class DiscordMpris:
active_player: Optional[Player] = None
last_activity: Optional[JSON] = None

def __init__(self, mpris: Mpris2Dbussy, discord: AsyncDiscordRpc, config: Config,
) -> None:
def __init__(self, mpris: Mpris2Dbussy, discord: AsyncDiscordRpc, config: Config) -> None:
self.mpris = mpris
self.discord = discord
self.config = config
Expand Down Expand Up @@ -140,12 +142,8 @@ async def tick(self) -> None:
# position should already be an int, but some players (smplayer) return a float
replacements = self.build_replacements(player, metadata, position, length, state)

# TODO make format configurable
if replacements['artist']:
# details_fmt = "{artist} - {title}"
details_fmt = "{title}\nby {artist}"
else:
details_fmt = "{title}"
# details_fmt = "{artist} - {title}"
details_fmt = self.config.get("details", "{title}\nby {artist}")
details = self.format_details(details_fmt, replacements)

activity['details'] = details
Expand All @@ -169,7 +167,33 @@ async def tick(self) -> None:
activity['state'] = self.format_details("{state}", replacements)

# set icons and hover texts
if player.name in PLAYER_ICONS:
art_icon_url = metadata.get("mpris:artUrl", None)
if art_icon_url and art_icon_url.startswith(
"http") and self.config.get("upload_images", False):
activity['assets'] = {'large_text': player.name,
'large_image': art_icon_url,
'small_image': state.lower(),
'small_text': state}
elif art_icon_url and self.config.get("upload_images", False):
art_icon = request.urlopen(art_icon_url)

def upload(data):
""""Uploads files to host with POST."""
host_url = self.config.get("image_host", None)
if host_url:
try:
with requests.post(host_url, files={"file": data}) as response:
if response.ok:
return response.text
except requests.exceptions.ConnectionError:
logger.error("Can't connect to image host")
image_url = lru_cache(3)(upload)(art_icon).strip("\n")
activity['assets'] = {'large_text': player.name,
'large_image': image_url,
'small_image': state.lower(),
'small_text': state}

elif player.name in PLAYER_ICONS:
activity['assets'] = {'large_text': player.name,
'large_image': PLAYER_ICONS[player.name],
'small_image': state.lower(),
Expand Down Expand Up @@ -232,7 +256,7 @@ async def find_active_player(self) -> Optional[Player]:
return None

def _player_not_ignored(self, player: Player) -> bool:
return (not self.config.player_get(player, "ignore", False))
return not self.config.player_get(player, "ignore", False)

@classmethod
def build_replacements(
Expand All @@ -247,25 +271,34 @@ def build_replacements(

# aggregate artist and albumArtist fields
for key in ('artist', 'albumArtist'):
source = metadata.get(f'xesam:{key}', ())
source = metadata.get(f'xesam:{key}', ('N/A'))
if isinstance(source, str): # In case the server doesn't follow mpris specs
replacements[key] = source
else:
replacements[key] = " & ".join(source)
# shorthands
replacements['title'] = metadata.get('xesam:title', "")
replacements['album'] = metadata.get('xesam:album', "")
replacements['title'] = metadata.get('xesam:title', "N/A")
replacements['album'] = metadata.get('xesam:album', "N/A")

# other data
replacements['position'] = \
cls.format_timestamp(int(position)) if position is not None else ''
replacements['length'] = cls.format_timestamp(length)
cls.format_timestamp(int(position)) if position is not None else 'N/A'
replacements['length'] = cls.format_timestamp(length) if length is not None else 'N/A'
replacements['player'] = player.name
replacements['state'] = state

# replace invalid ident char
replacements = {key.replace(':', '_'): val for key, val in replacements.items()}

# replacements for future generations:
# artist
# albumArtist
# title
# album
# position
# length
# player
# state
# - will be formatted properly.
return replacements

@staticmethod
Expand Down Expand Up @@ -318,10 +351,17 @@ def format_details(template: str, replacements: Dict[str, Any]) -> str:
return details





async def main_async(loop: asyncio.AbstractEventLoop):
config = Config.load()
# TODO validate?
config = Config()
config.load()
config.setup_reloading()
configure_logging(config)
if not config.check():
logger.warning("you're using outdated config.")
logger.debug("Set all required things, starting service")

mpris = await Mpris2Dbussy.create(loop=loop)
async with AsyncDiscordRpc.for_platform(CLIENT_ID) as discord:
Expand Down Expand Up @@ -356,8 +396,9 @@ def configure_logging(config: Config) -> None:
if config.raw_get('global.debug', False):
log_level_name = 'DEBUG'
else:
log_level_name = config.raw_get('global.log_level')
if log_level_name and log_level_name.isupper():
log_level_name = config.raw_get('global.log_level').upper()
# there was questionable condition of isupper. Let human make their mistakes, if they are small ;-)
if log_level_name:
log_level = getattr(logging, log_level_name, log_level)

# set level of root logger
Expand Down
60 changes: 43 additions & 17 deletions discordrp_mpris/config/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import logging
import os
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from pathlib import Path
from typing import Any, Dict, Optional
from typing import Any, Dict
from atexit import register

import pytoml

Expand All @@ -12,12 +15,25 @@
default_file = Path(__file__).parent / "config.toml"


# TODO automatic reloading
class ConfigChangedEventHandler(FileSystemEventHandler):
def __init__(self, load_config_func):
super().__init__()
self.load_config_func = load_config_func

def on_modified(self, event):
self.load_config_func()
logger.debug("Modifications to config are loaded")


class Config:
_obj = object()
config_file = default_file

def __init__(self, raw_config: Dict[str, Any]) -> None:
def __init__(self, raw_config: Dict[str, Any] = None) -> None:
self.raw_config = raw_config
self.observer = Observer()
self.watch = None
self.config_handler = ConfigChangedEventHandler(self.load)

def raw_get(self, key: str, default: Any = None) -> Any:
segments = key.split('.')
Expand All @@ -37,27 +53,37 @@ def player_get(self, player: Player, key: str, default: Any = None) -> Any:
base = self.get(key, default)
return self.raw_get(f"player.{player.name}.{key}", base)

@classmethod
def load(cls) -> 'Config':
with default_file.open() as f:
config = pytoml.load(f)
user_config = cls._load_user_config()
if user_config:
config.update(user_config)
return Config(config)
def setup_reloading(self):
self.watch = self.observer.schedule(self.config_handler, str(self.config_file))
self.observer.start()
register(self.observer.stop)

@staticmethod
def _load_user_config() -> Optional[Dict[str, Any]]:
def load(self):
self._load_config()

def check(self):
wconf = self.raw_config
tconf = self._load_config_from_path(default_file)
flag = set(tconf["global"].keys()) == set(wconf["global"].keys()) and set(tconf["options"].keys()) == \
set(wconf["options"].keys())
return flag

def _load_config(self):
user_patterns = ("$XDG_CONFIG_HOME", "$HOME/.config")
user_file = None

for pattern in user_patterns:
parent = Path(os.path.expandvars(pattern))
if parent.is_dir():
user_file = parent / "discordrp-mpris" / "config.toml"
if user_file.is_file():
logging.debug(f"Loading user config: {user_file!s}")
with user_file.open() as f:
return pytoml.load(f)
self.config_file = user_file
self.raw_config = self._load_config_from_path(user_file)
return
self.config_file = default_file
self.raw_config = self._load_config_from_path(default_file)

return None
@staticmethod
def _load_config_from_path(path):
with path.open() as f:
return pytoml.load(f)
Loading