[scrobblers] Use MediaSourceId to avoid multiple Jellyfin scrobbles

This commit is contained in:
2026-03-02 08:41:11 -05:00
parent 82a7fd8673
commit a027e877f7
5 changed files with 40 additions and 0 deletions

View File

@ -90,6 +90,7 @@ JELLYFIN_POST_KEYS = {
"TAGLINE": "TAGLINE", "TAGLINE": "TAGLINE",
"PLAYBACK_POSITION_TICKS": "PlaybackPositionTicks", "PLAYBACK_POSITION_TICKS": "PlaybackPositionTicks",
"PLAYBACK_POSITION": "PlaybackPosition", "PLAYBACK_POSITION": "PlaybackPosition",
"MEDIA_SOURCE_ID": "MediaSourceId",
"ARTIST_MB_ID": "Provider_musicbrainzartist", "ARTIST_MB_ID": "Provider_musicbrainzartist",
"ALBUM_MB_ID": "Provider_musicbrainzalbum", "ALBUM_MB_ID": "Provider_musicbrainzalbum",
"RELEASEGROUP_MB_ID": "Provider_musicbrainzreleasegroup", "RELEASEGROUP_MB_ID": "Provider_musicbrainzreleasegroup",

View File

@ -0,0 +1,18 @@
# Generated manually for deduplication fix
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0070_rename_brickset_scrobble_brick_set"),
]
operations = [
migrations.AddField(
model_name="scrobble",
name="source_id",
field=models.CharField(max_length=255, null=True, blank=True),
),
]

View File

@ -79,6 +79,7 @@ class ScrobblableMixin(TimeStampedModel):
self, self,
user_id, user_id,
source: str = "Vrobbler", source: str = "Vrobbler",
source_id: Optional[str] = None,
playback_position_seconds: int = 0, playback_position_seconds: int = 0,
status: str = "started", status: str = "started",
log: Optional[dict] = None, log: Optional[dict] = None,
@ -88,6 +89,7 @@ class ScrobblableMixin(TimeStampedModel):
"user_id": user_id, "user_id": user_id,
"timestamp": timezone.now(), "timestamp": timezone.now(),
"source": source, "source": source,
"source_id": source_id,
"status": status, "status": status,
"playback_position_seconds": playback_position_seconds, "playback_position_seconds": playback_position_seconds,
} }

View File

@ -582,6 +582,7 @@ class Scrobble(TimeStampedModel):
# Metadata # Metadata
source = models.CharField(max_length=255, **BNULL) source = models.CharField(max_length=255, **BNULL)
source_id = models.CharField(max_length=255, **BNULL)
log = models.JSONField( log = models.JSONField(
**BNULL, **BNULL,
default=dict, default=dict,
@ -1136,6 +1137,23 @@ class Scrobble(TimeStampedModel):
skip_in_progress_check = kwargs.get("skip_in_progress_check", False) skip_in_progress_check = kwargs.get("skip_in_progress_check", False)
read_log_page = kwargs.get("read_log_page", None) read_log_page = kwargs.get("read_log_page", None)
source_id = scrobble_data.get("source_id")
# If source_id is provided (e.g., MediaSourceId from Jellyfin), check for existing scrobble first
# This prevents duplicates when webhooks arrive faster than scrobble creation
if source_id:
existing_by_source_id = cls.objects.filter(
media_query,
user_id=user_id,
source_id=source_id,
).first()
if existing_by_source_id:
logger.info(
"[create_or_update] found existing scrobble by source_id, updating",
extra={"scrobble_id": existing_by_source_id.id, "source_id": source_id},
)
scrobble_data["playback_status"] = scrobble_data.pop("status", None)
return existing_by_source_id.update(scrobble_data)
# Find our last scrobble of this media item (track, video, etc) # Find our last scrobble of this media item (track, video, etc)
scrobble = ( scrobble = (

View File

@ -153,6 +153,7 @@ def jellyfin_scrobble_media(
return media_obj.scrobble_for_user( return media_obj.scrobble_for_user(
user_id, user_id,
source=post_data.get(JELLYFIN_POST_KEYS.get("SOURCE")), source=post_data.get(JELLYFIN_POST_KEYS.get("SOURCE")),
source_id=post_data.get(JELLYFIN_POST_KEYS.get("MEDIA_SOURCE_ID")),
playback_position_seconds=playback_position_seconds, playback_position_seconds=playback_position_seconds,
status=playback_status, status=playback_status,
) )