From c1952b0002992c37cb763a20a0dc74b13e1de57a Mon Sep 17 00:00:00 2001 From: joelilas Date: Mon, 26 Jan 2026 18:45:48 +0100 Subject: [PATCH] feat(scrobble): send Subsonic scrobble on playback start (now-playing) --- Dockerfile | 25 +++++++++++++++++++++++++ player.py | 7 ++++++- subsonic.py | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..658f698 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM python:3.11-alpine + +# Ensure deterministic, unbuffered logs +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY requirements.txt requirements.txt + +RUN apk add --no-cache build-base ffmpeg libffi libffi-dev libsodium libsodium-dev \ + && pip3 install --no-cache-dir -r requirements.txt \ + && apk del build-base libffi-dev libsodium-dev + +RUN adduser -D -h /app app \ + && chown -R app:app /app + +COPY --chown=app:app . . + +USER app + +# Set stop signal to SIGTERM to ensure clean shutdown +STOPSIGNAL SIGTERM + +CMD ["python3", "/app/discodrome.py"] \ No newline at end of file diff --git a/player.py b/player.py index 89da4ca..29c12d0 100644 --- a/player.py +++ b/player.py @@ -7,7 +7,7 @@ import data import ui import logging -from subsonic import Song, APIError, get_random_songs, get_similar_songs, stream +from subsonic import Song, APIError, get_random_songs, get_similar_songs, stream, scrobble logger = logging.getLogger(__name__) @@ -152,6 +152,11 @@ class Player(): voice_client.play(audio_src, after=lambda e: loop.create_task(playback_finished(e))) logger.info(f"Started playing: {song.title} by {song.artist}") + # Fire-and-forget scrobble (now-playing ping with submission=False) + try: + asyncio.create_task(scrobble(song.song_id, submission=False)) + except Exception as e: + logger.error(f"Failed to schedule scrobble for {song.title}: {e}") return # Success, exit the function except discord.ClientException as e: logger.error(f"Discord client exception while playing audio (attempt {attempt+1}): {e}") diff --git a/subsonic.py b/subsonic.py index 44372dd..290498e 100644 --- a/subsonic.py +++ b/subsonic.py @@ -571,3 +571,38 @@ async def stream(stream_id: str): logger.error("Failed to stream song: %s", await response.text()) return None return str(response.url) + +async def scrobble(song_id: str, *, submission: bool=True, timestamp: int=None) -> bool: + ''' Submit a scrobble event for a song to the Subsonic/Navidrome API. + + Parameters: + - song_id: The Subsonic song id to scrobble + - submission: Whether this is a final submission (True) or a now-playing ping (False) + - timestamp: Optional Unix epoch seconds for when the song was played + ''' + + scrobble_params: dict[str, any] = { + "id": song_id, + "submission": "true" if submission else "false", + } + if timestamp is not None: + scrobble_params["time"] = str(timestamp) + + params = SUBSONIC_REQUEST_PARAMS | scrobble_params + + try: + session = await get_session() + async with await session.get(f"{env.SUBSONIC_SERVER}/rest/scrobble.view", params=params) as response: + response.raise_for_status() + data = await response.json() + if await check_subsonic_error(data): + logger.warning("Scrobble returned a Subsonic error for song %s", song_id) + return False + logger.debug("Scrobble response: %s", data) + return True + except aiohttp.ClientError as e: + logger.error("HTTP error during scrobble for song %s: %s", song_id, e) + return False + except Exception as e: + logger.error("Unexpected error during scrobble for song %s: %s", song_id, e) + return False