feat(scrobble): send Subsonic scrobble on playback start (now-playing)

This commit is contained in:
joelilas
2026-01-26 18:45:48 +01:00
parent 2bd20b397d
commit c1952b0002
3 changed files with 66 additions and 1 deletions

25
Dockerfile Normal file
View File

@@ -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"]

View File

@@ -7,7 +7,7 @@ import data
import ui import ui
import logging 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__) logger = logging.getLogger(__name__)
@@ -152,6 +152,11 @@ class Player():
voice_client.play(audio_src, after=lambda e: loop.create_task(playback_finished(e))) voice_client.play(audio_src, after=lambda e: loop.create_task(playback_finished(e)))
logger.info(f"Started playing: {song.title} by {song.artist}") 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 return # Success, exit the function
except discord.ClientException as e: except discord.ClientException as e:
logger.error(f"Discord client exception while playing audio (attempt {attempt+1}): {e}") logger.error(f"Discord client exception while playing audio (attempt {attempt+1}): {e}")

View File

@@ -571,3 +571,38 @@ async def stream(stream_id: str):
logger.error("Failed to stream song: %s", await response.text()) logger.error("Failed to stream song: %s", await response.text())
return None return None
return str(response.url) 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