Bläddra i källkod

Refactor scrobbling code and add Music

If you send Track data from the Jellyfin Webhook plugin, we'll do the
right thing with it. Lots more to do to clean this up, but it also
involved moduralizing the code for scrobbling so it's a little simpler
to understand what's going on.
Colin Powell 2 år sedan
förälder
incheckning
638be0b56a

+ 0 - 0
vrobbler/apps/music/__init__.py


+ 29 - 0
vrobbler/apps/music/admin.py

@@ -0,0 +1,29 @@
+from django.contrib import admin
+
+from music.models import Artist, Album, Track
+
+@admin.register(Album)
+class AlbumAdmin(admin.ModelAdmin):
+    date_hierarchy = "created"
+    list_display = ("name", "year", "musicbrainz_id")
+    list_filter = ("year",)
+    ordering = ("name",)
+
+@admin.register(Artist)
+class ArtistAdmin(admin.ModelAdmin):
+    date_hierarchy = "created"
+    list_display = ("name", "musicbrainz_id")
+    ordering = ("name",)
+
+@admin.register(Track)
+class TrackAdmin(admin.ModelAdmin):
+    date_hierarchy = "created"
+    list_display = (
+        "title",
+        "album",
+        "artist",
+        "run_time",
+        "musicbrainz_id",
+    )
+    list_filter = ("album", "artist")
+    ordering = ("-created",)

+ 5 - 0
vrobbler/apps/music/apps.py

@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class MusicConfig(AppConfig):
+    name = 'music'

+ 16 - 0
vrobbler/apps/music/constants.py

@@ -0,0 +1,16 @@
+JELLYFIN_POST_KEYS = {
+    'ITEM_TYPE': 'ItemType',
+    'RUN_TIME_TICKS': 'RunTimeTicks',
+    'RUN_TIME': 'RunTime',
+    'TITLE': 'Name',
+    'TIMESTAMP': 'UtcTimestamp',
+    'YEAR': 'Year',
+    'PLAYBACK_POSITION_TICKS': 'PlaybackPositionTicks',
+    'PLAYBACK_POSITION': 'PlaybackPosition',
+    'ARTIST_MB_ID': 'Provider_musicbrainzartist',
+    'ALBUM_MB_ID': 'Provider_musicbrainzalbum',
+    'RELEASEGROUP_MB_ID': 'Provider_musicbrainzreleasegroup',
+    'TRACK_MB_ID': 'Provider_musicbrainztrack',
+    'ALBUM_NAME': 'Album',
+    'ARTIST_NAME': 'Artist',
+}

+ 156 - 0
vrobbler/apps/music/migrations/0001_initial.py

@@ -0,0 +1,156 @@
+# Generated by Django 4.1.5 on 2023-01-07 19:37
+
+from django.db import migrations, models
+import django.db.models.deletion
+import django_extensions.db.fields
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = []
+
+    operations = [
+        migrations.CreateModel(
+            name='Album',
+            fields=[
+                (
+                    'id',
+                    models.BigAutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name='ID',
+                    ),
+                ),
+                (
+                    'created',
+                    django_extensions.db.fields.CreationDateTimeField(
+                        auto_now_add=True, verbose_name='created'
+                    ),
+                ),
+                (
+                    'modified',
+                    django_extensions.db.fields.ModificationDateTimeField(
+                        auto_now=True, verbose_name='modified'
+                    ),
+                ),
+                ('name', models.CharField(max_length=255)),
+                ('year', models.IntegerField()),
+                (
+                    'musicbrainz_id',
+                    models.CharField(blank=True, max_length=255, null=True),
+                ),
+                (
+                    'musicbrainz_releasegroup_id',
+                    models.CharField(blank=True, max_length=255, null=True),
+                ),
+                (
+                    'musicbrainz_albumartist_id',
+                    models.CharField(blank=True, max_length=255, null=True),
+                ),
+            ],
+            options={
+                'get_latest_by': 'modified',
+                'abstract': False,
+            },
+        ),
+        migrations.CreateModel(
+            name='Artist',
+            fields=[
+                (
+                    'id',
+                    models.BigAutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name='ID',
+                    ),
+                ),
+                (
+                    'created',
+                    django_extensions.db.fields.CreationDateTimeField(
+                        auto_now_add=True, verbose_name='created'
+                    ),
+                ),
+                (
+                    'modified',
+                    django_extensions.db.fields.ModificationDateTimeField(
+                        auto_now=True, verbose_name='modified'
+                    ),
+                ),
+                ('name', models.CharField(max_length=255)),
+                (
+                    'musicbrainz_id',
+                    models.CharField(blank=True, max_length=255, null=True),
+                ),
+            ],
+            options={
+                'get_latest_by': 'modified',
+                'abstract': False,
+            },
+        ),
+        migrations.CreateModel(
+            name='Track',
+            fields=[
+                (
+                    'id',
+                    models.BigAutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name='ID',
+                    ),
+                ),
+                (
+                    'created',
+                    django_extensions.db.fields.CreationDateTimeField(
+                        auto_now_add=True, verbose_name='created'
+                    ),
+                ),
+                (
+                    'modified',
+                    django_extensions.db.fields.ModificationDateTimeField(
+                        auto_now=True, verbose_name='modified'
+                    ),
+                ),
+                (
+                    'title',
+                    models.CharField(blank=True, max_length=255, null=True),
+                ),
+                (
+                    'musicbrainz_id',
+                    models.CharField(blank=True, max_length=255, null=True),
+                ),
+                (
+                    'run_time',
+                    models.CharField(blank=True, max_length=8, null=True),
+                ),
+                (
+                    'run_time_ticks',
+                    models.PositiveBigIntegerField(blank=True, null=True),
+                ),
+                (
+                    'album',
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.DO_NOTHING,
+                        to='music.album',
+                    ),
+                ),
+                (
+                    'artist',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.DO_NOTHING,
+                        to='music.artist',
+                    ),
+                ),
+            ],
+            options={
+                'get_latest_by': 'modified',
+                'abstract': False,
+            },
+        ),
+    ]

+ 0 - 0
vrobbler/apps/music/migrations/__init__.py


+ 99 - 0
vrobbler/apps/music/models.py

@@ -0,0 +1,99 @@
+import logging
+from typing import Dict, Optional
+from django.db import models
+from django_extensions.db.models import TimeStampedModel
+from django.utils.translation import gettext_lazy as _
+from vrobbler.apps.music.constants import JELLYFIN_POST_KEYS as KEYS
+
+logger = logging.getLogger(__name__)
+BNULL = {"blank": True, "null": True}
+
+
+class Album(TimeStampedModel):
+    name = models.CharField(max_length=255)
+    year = models.IntegerField()
+    musicbrainz_id = models.CharField(max_length=255, **BNULL)
+    musicbrainz_releasegroup_id = models.CharField(max_length=255, **BNULL)
+    musicbrainz_albumartist_id = models.CharField(max_length=255, **BNULL)
+
+    def __str__(self):
+        return self.name
+
+
+class Artist(TimeStampedModel):
+    name = models.CharField(max_length=255)
+    musicbrainz_id = models.CharField(max_length=255, **BNULL)
+
+    def __str__(self):
+        return self.name
+
+
+class Track(TimeStampedModel):
+    title = models.CharField(max_length=255, **BNULL)
+    artist = models.ForeignKey(Artist, on_delete=models.DO_NOTHING)
+    album = models.ForeignKey(Album, on_delete=models.DO_NOTHING, **BNULL)
+    musicbrainz_id = models.CharField(max_length=255, **BNULL)
+    run_time = models.CharField(max_length=8, **BNULL)
+    run_time_ticks = models.PositiveBigIntegerField(**BNULL)
+
+    def __str__(self):
+        return f"{self.title} by {self.artist}"
+
+    @classmethod
+    def find_or_create(cls, data_dict: Dict) -> Optional["Track"]:
+        """Given a data dict from Jellyfin, does the heavy lifting of looking up
+        the video and, if need, TV Series, creating both if they don't yet
+        exist.
+
+        """
+        artist = data_dict.get(KEYS["ARTIST_NAME"], None)
+        artist_musicbrainz_id = data_dict.get(KEYS["ARTIST_MB_ID"], None)
+        if not artist or not artist_musicbrainz_id:
+            logger.warning(
+                f"No artist or artist musicbrainz ID found in message from Jellyfin, not scrobbling"
+            )
+            return
+        artist, artist_created = Artist.objects.get_or_create(
+            name=artist, musicbrainz_id=artist_musicbrainz_id
+        )
+        if artist_created:
+            logger.debug(f"Created new album {artist}")
+        else:
+            logger.debug(f"Found album {artist}")
+
+        album = None
+        album_name = data_dict.get(KEYS["ALBUM_NAME"], None)
+        if album_name:
+            album_dict = {
+                "name": album_name,
+                "year": data_dict.get(KEYS["YEAR"], ""),
+                "musicbrainz_id": data_dict.get(KEYS['ALBUM_MB_ID']),
+                "musicbrainz_releasegroup_id": data_dict.get(
+                    KEYS["RELEASEGROUP_MB_ID"]
+                ),
+                "musicbrainz_albumartist_id": data_dict.get(
+                    KEYS["ARTIST_MB_ID"]
+                ),
+            }
+            album, album_created = Album.objects.get_or_create(**album_dict)
+            if album_created:
+                logger.debug(f"Created new album {album}")
+            else:
+                logger.debug(f"Found album {album}")
+
+        track_dict = {
+            "title": data_dict.get("Name", ""),
+            "musicbrainz_id": data_dict.get(KEYS["TRACK_MB_ID"], None),
+            "run_time_ticks": data_dict.get(KEYS["RUN_TIME_TICKS"], None),
+            "run_time": data_dict.get(KEYS["RUN_TIME"], None),
+            "album_id": getattr(album, "id", None),
+            "artist_id": artist.id,
+        }
+
+        track, created = cls.objects.get_or_create(**track_dict)
+        if created:
+            logger.debug(f"Created new track: {track}")
+        else:
+            logger.debug(f"Found track{track}")
+
+        return track

+ 2 - 1
vrobbler/apps/scrobbles/admin.py

@@ -6,8 +6,9 @@ from scrobbles.models import Scrobble
 class ScrobbleAdmin(admin.ModelAdmin):
     date_hierarchy = "timestamp"
     list_display = (
-        "video",
         "timestamp",
+        "video",
+        "track",
         "source",
         "playback_position",
         "in_progress",

+ 3 - 0
vrobbler/apps/scrobbles/constants.py

@@ -0,0 +1,3 @@
+#!/usr/bin/env python3
+JELLYFIN_VIDEO_ITEM_TYPES = ["Episode", "Movie"]
+JELLYFIN_AUDIO_ITEM_TYPES = ["Audio"]

+ 36 - 0
vrobbler/apps/scrobbles/migrations/0006_scrobble_track_alter_scrobble_video.py

@@ -0,0 +1,36 @@
+# Generated by Django 4.1.5 on 2023-01-07 20:03
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('music', '0001_initial'),
+        ('videos', '0003_alter_video_run_time_ticks'),
+        ('scrobbles', '0005_alter_scrobble_playback_position_ticks'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='scrobble',
+            name='track',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.DO_NOTHING,
+                to='music.track',
+            ),
+        ),
+        migrations.AlterField(
+            model_name='scrobble',
+            name='video',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.DO_NOTHING,
+                to='videos.video',
+            ),
+        ),
+    ]

+ 146 - 4
vrobbler/apps/scrobbles/models.py

@@ -1,15 +1,27 @@
+import logging
+from datetime import timedelta
+from typing import Optional
+
+from django.conf import settings
 from django.contrib.auth import get_user_model
 from django.db import models
+from django.utils import timezone
 from django_extensions.db.models import TimeStampedModel
-
+from music.models import Track
 from videos.models import Video
 
+logger = logging.getLogger('__name__')
 User = get_user_model()
 BNULL = {"blank": True, "null": True}
+VIDEO_BACKOFF = getattr(settings, 'VIDEO_BACKOFF_MINUTES')
+TRACK_BACKOFF = getattr(settings, 'MUSIC_BACKOFF_SECONDS')
+VIDEO_WAIT_PERIOD = getattr(settings, 'VIDEO_WAIT_PERIOD_DAYS')
+TRACK_WAIT_PERIOD = getattr(settings, 'MUSIC_WAIT_PERIOD_MINUTES')
 
 
 class Scrobble(TimeStampedModel):
-    video = models.ForeignKey(Video, on_delete=models.DO_NOTHING)
+    video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL)
+    track = models.ForeignKey(Track, on_delete=models.DO_NOTHING, **BNULL)
     user = models.ForeignKey(
         User, blank=True, null=True, on_delete=models.DO_NOTHING
     )
@@ -26,8 +38,138 @@ class Scrobble(TimeStampedModel):
     @property
     def percent_played(self) -> int:
         return int(
-            (self.playback_position_ticks / self.video.run_time_ticks) * 100
+            (self.playback_position_ticks / self.media_run_time_ticks) * 100
         )
 
+    @property
+    def media_run_time_ticks(self) -> int:
+        if self.video:
+            return self.video.run_time_ticks
+        if self.track:
+            return self.track.run_time_ticks
+        # this is hacky, but want to avoid divide by zero
+        return 1
+
+    def is_stale(self, backoff, wait_period) -> bool:
+        scrobble_is_stale = self.in_progress and self.modified > wait_period
+
+        # Check if found in progress scrobble is more than a day old
+        if scrobble_is_stale:
+            logger.info(
+                'Found a in-progress scrobble for this item more than a day old, creating a new scrobble'
+            )
+            delete_stale_scrobbles = getattr(
+                settings, "DELETE_STALE_SCROBBLES", True
+            )
+
+            if delete_stale_scrobbles:
+                logger.info(
+                    'Deleting {scrobble} that has been in-progress too long'
+                )
+                self.delete()
+
+        return scrobble_is_stale
+
     def __str__(self):
-        return f"Scrobble of {self.video} {self.timestamp.year}-{self.timestamp.month}"
+        media = None
+        if self.video:
+            media = self.video
+        if self.track:
+            media = self.track
+
+        return (
+            f"Scrobble of {media} {self.timestamp.year}-{self.timestamp.month}"
+        )
+
+    @classmethod
+    def create_or_update_for_video(
+        cls, video: "Video", user_id: int, jellyfin_data: dict
+    ) -> "Scrobble":
+        jellyfin_data['video_id'] = video.id
+        logger.debug(
+            f"Creating or updating scrobble for video {video} with data {jellyfin_data}"
+        )
+        scrobble = (
+            Scrobble.objects.filter(video=video, user_id=user_id)
+            .order_by('-modified')
+            .first()
+        )
+
+        # Backoff is how long until we consider this a new scrobble
+        backoff = timezone.now() + timedelta(minutes=VIDEO_BACKOFF)
+        wait_period = timezone.now() + timedelta(days=VIDEO_WAIT_PERIOD)
+
+        return cls.update_or_create(
+            scrobble, backoff, wait_period, jellyfin_data
+        )
+
+    @classmethod
+    def create_or_update_for_track(
+        cls, track: "Track", user_id: int, jellyfin_data: dict
+    ) -> "Scrobble":
+        jellyfin_data['track_id'] = track.id
+        logger.debug(
+            f"Creating or updating scrobble for track {track}",
+            {"jellyfin_data": jellyfin_data},
+        )
+        scrobble = (
+            Scrobble.objects.filter(track=track, user_id=user_id)
+            .order_by('-modified')
+            .first()
+        )
+
+        backoff = timezone.now() + timedelta(minutes=TRACK_BACKOFF)
+        wait_period = timezone.now() + timedelta(minutes=TRACK_WAIT_PERIOD)
+
+        return cls.update_or_create(
+            scrobble, backoff, wait_period, jellyfin_data
+        )
+
+    @classmethod
+    def update_or_create(
+        cls,
+        scrobble: Optional["Scrobble"],
+        backoff,
+        wait_period,
+        jellyfin_data: dict,
+    ) -> Optional["Scrobble"]:
+        scrobble_is_stale = False
+
+        if scrobble:
+            scrobble_is_finished = (
+                not scrobble.in_progress and scrobble.modified < backoff
+            )
+            if scrobble_is_finished:
+                logger.info(
+                    'Found a very recent scrobble for this item, holding off scrobbling again'
+                )
+                return
+
+            scrobble_is_stale = scrobble.is_stale(backoff, wait_period)
+
+        if not scrobble or scrobble_is_stale:
+            # If we default this to "" we can probably remove this
+            jellyfin_data['scrobble_log'] = ""
+            scrobble = cls.objects.create(
+                **jellyfin_data,
+            )
+        else:
+            for key, value in jellyfin_data.items():
+                setattr(scrobble, key, value)
+            scrobble.save()
+
+        # If we hit our completion threshold, save it and get ready
+        # to scrobble again if we re-watch this.
+        if scrobble.percent_played >= getattr(
+            settings, "PERCENT_FOR_COMPLETION", 95
+        ):
+            scrobble.in_progress = False
+            scrobble.playback_position_ticks = scrobble.media_run_time_ticks
+            scrobble.save()
+
+        if scrobble.percent_played % 5 == 0:
+            if getattr(settings, "KEEP_DETAILED_SCROBBLE_LOGS", False):
+                scrobble.scrobble_log += f"\n{str(scrobble.timestamp)} - {scrobble.playback_position} - {str(scrobble.playback_position_ticks)} - {str(scrobble.percent_played)}%"
+                scrobble.save(update_fields=['scrobble_log'])
+
+        return scrobble

+ 55 - 117
vrobbler/apps/scrobbles/views.py

@@ -5,17 +5,20 @@ from datetime import datetime, timedelta
 from dateutil.parser import parse
 from django.conf import settings
 from django.db.models.fields import timezone
+from django.utils import timezone
 from django.views.decorators.csrf import csrf_exempt
 from django.views.generic.list import ListView
+from music.models import Track
 from rest_framework import status
 from rest_framework.decorators import api_view
 from rest_framework.response import Response
-
+from scrobbles.constants import (
+    JELLYFIN_AUDIO_ITEM_TYPES,
+    JELLYFIN_VIDEO_ITEM_TYPES,
+)
 from scrobbles.models import Scrobble
 from scrobbles.serializers import ScrobbleSerializer
-from videos.models import Series, Video
-from vrobbler.settings import DELETE_STALE_SCROBBLES
-from django.utils import timezone
+from videos.models import Video
 
 logger = logging.getLogger(__name__)
 
@@ -38,11 +41,11 @@ class RecentScrobbleList(ListView):
     def get_context_data(self, **kwargs):
         data = super().get_context_data(**kwargs)
         now = timezone.now()
-        last_ten_minutes = timezone.now() - timedelta(minutes=10)
+        last_three_minutes = timezone.now() - timedelta(minutes=3)
         # Find scrobbles from the last 10 minutes
         data['now_playing_list'] = Scrobble.objects.filter(
             in_progress=True,
-            timestamp__gte=last_ten_minutes,
+            timestamp__gte=last_three_minutes,
             timestamp__lte=now,
         )
         return data
@@ -66,122 +69,57 @@ def scrobble_endpoint(request):
 @api_view(['POST'])
 def jellyfin_websocket(request):
     data_dict = request.data
-    media_type = data_dict["ItemType"]
-    imdb_id = data_dict.get("Provider_imdb", None)
-    if not imdb_id:
-        logger.error(
-            "No IMDB ID received. This is likely because all metadata is bad, not scrobbling"
-        )
-        return Response({}, status=status.HTTP_400_BAD_REQUEST)
-    # Check if it's a TV Episode
-    video_dict = {
-        "title": data_dict.get("Name", ""),
-        "imdb_id": imdb_id,
-        "video_type": Video.VideoType.MOVIE,
-        "year": data_dict.get("Year", ""),
-    }
-    if media_type == 'Episode':
-        series_name = data_dict["SeriesName"]
-        series, series_created = Series.objects.get_or_create(name=series_name)
-
-        video_dict['video_type'] = Video.VideoType.TV_EPISODE
-        video_dict["tv_series_id"] = series.id
-        video_dict["episode_number"] = data_dict.get("EpisodeNumber", "")
-        video_dict["season_number"] = data_dict.get("SeasonNumber", "")
-        video_dict["tvdb_id"] = data_dict.get("Provider_tvdb", None)
-        video_dict["tvrage_id"] = data_dict.get("Provider_tvrage", None)
-
-    video, video_created = Video.objects.get_or_create(**video_dict)
-
-    if video_created:
-        video.overview = data_dict["Overview"]
-        video.tagline = data_dict["Tagline"]
-        video.run_time_ticks = data_dict["RunTimeTicks"]
-        video.run_time = data_dict["RunTime"]
-        video.save()
+
+    # For making things easier to build new input processors
+    if getattr(settings, "DUMP_REQUEST_DATA", False):
+        json_data = json.dumps(data_dict, indent=4)
+        logger.debug(f"{json_data}")
+
+    media_type = data_dict.get("ItemType", "")
+
+    track = None
+    video = None
+    existing_scrobble = False
+    if media_type in JELLYFIN_AUDIO_ITEM_TYPES:
+        if not data_dict.get("Provider_musicbrainztrack", None):
+            logger.error(
+                "No MBrainz Track ID received. This is likely because all metadata is bad, not scrobbling"
+            )
+            return Response({}, status=status.HTTP_400_BAD_REQUEST)
+        track = Track.find_or_create(data_dict)
+
+    if media_type in JELLYFIN_VIDEO_ITEM_TYPES:
+        if not data_dict.get("Provider_imdb", None):
+            logger.error(
+                "No IMDB ID received. This is likely because all metadata is bad, not scrobbling"
+            )
+            return Response({}, status=status.HTTP_400_BAD_REQUEST)
+        video = Video.find_or_create(data_dict)
 
     # Now we run off a scrobble
-    timestamp = parse(data_dict["UtcTimestamp"])
-    scrobble_dict = {
-        'video_id': video.id,
-        'user_id': request.user.id,
-        'in_progress': True,
+    jellyfin_data = {
+        "user_id": request.user.id,
+        "timestamp": parse(data_dict.get("UtcTimestamp")),
+        "playback_position_ticks": data_dict.get("PlaybackPositionTicks"),
+        "playback_position": data_dict.get("PlaybackPosition"),
+        "source": data_dict.get('ClientName'),
+        "source_id": data_dict.get('MediaSourceId'),
+        "is_paused": data_dict.get("IsPaused") in TRUTHY_VALUES,
     }
 
-    existing_scrobble = (
-        Scrobble.objects.filter(video=video, user_id=request.user.id)
-        .order_by('-modified')
-        .first()
-    )
-
-    minutes_from_now = timezone.now() + timedelta(minutes=15)
-    a_day_from_now = timezone.now() + timedelta(days=1)
-
-    existing_finished_scrobble = (
-        existing_scrobble
-        and not existing_scrobble.in_progress
-        and existing_scrobble.modified < minutes_from_now
-    )
-    existing_in_progress_scrobble = (
-        existing_scrobble
-        and existing_scrobble.in_progress
-        and existing_scrobble.modified > a_day_from_now
-    )
-    delete_stale_scrobbles = getattr(settings, "DELETE_STALE_SCROBBLES", True)
-
-    if existing_finished_scrobble:
-        logger.info(
-            'Found a scrobble for this video less than 15 minutes ago, holding off scrobbling again'
+    scrobble = None
+    if video:
+        scrobble = Scrobble.create_or_update_for_video(
+            video, request.user.id, jellyfin_data
         )
-        return Response(video_dict, status=status.HTTP_204_NO_CONTENT)
-
-    # Check if found in progress scrobble is more than a day old
-    if existing_in_progress_scrobble:
-        logger.info(
-            'Found a scrobble for this video more than a day old, creating a new scrobble'
-        )
-        scrobble = existing_in_progress_scrobble
-        scrobble_created = False
-    else:
-        if existing_in_progress_scrobble and delete_stale_scrobbles:
-            existing_in_progress_scrobble.delete()
-        scrobble, scrobble_created = Scrobble.objects.get_or_create(
-            **scrobble_dict
+    if track:
+        scrobble = Scrobble.create_or_update_for_track(
+            track, request.user.id, jellyfin_data
         )
 
-    if scrobble_created:
-        # If we newly created this, capture the client we're watching from
-        scrobble.source = data_dict['ClientName']
-        scrobble.source_id = data_dict['MediaSourceId']
-        scrobble.scrobble_log = ""
-
-    # Update a found scrobble with new position and timestamp
-    scrobble.playback_position_ticks = data_dict["PlaybackPositionTicks"]
-    scrobble.playback_position = data_dict["PlaybackPosition"]
-    scrobble.timestamp = parse(data_dict["UtcTimestamp"])
-    scrobble.is_paused = data_dict["IsPaused"] in TRUTHY_VALUES
-    scrobble.save(
-        update_fields=[
-            'playback_position_ticks',
-            'playback_position',
-            'timestamp',
-            'is_paused',
-        ]
-    )
+    if not scrobble:
+        return Response({}, status=status.HTTP_400_BAD_REQUEST)
 
-    # If we hit our completion threshold, save it and get ready
-    # to scrobble again if we re-watch this.
-    if scrobble.percent_played >= getattr(
-        settings, "PERCENT_FOR_COMPLETION", 95
-    ):
-        scrobble.in_progress = False
-        scrobble.playback_position_ticks = video.run_time_ticks
-        scrobble.save()
-
-    if scrobble.percent_played % 5 == 0:
-        if getattr(settings, "KEEP_DETAILED_SCROBBLE_LOGS", False):
-            scrobble.scrobble_log += f"\n{str(scrobble.timestamp)} - {scrobble.playback_position} - {str(scrobble.playback_position_ticks)} - {str(scrobble.percent_played)}%"
-            scrobble.save(update_fields=['scrobble_log'])
-        logger.debug(f"You are {scrobble.percent_played}% through {video}")
-
-    return Response(video_dict, status=status.HTTP_201_CREATED)
+    return Response(
+        {'scrobble_id': scrobble.id}, status=status.HTTP_201_CREATED
+    )

+ 45 - 0
vrobbler/apps/videos/models.py

@@ -1,7 +1,10 @@
+import logging
+from typing import Dict, Tuple
 from django.db import models
 from django_extensions.db.models import TimeStampedModel
 from django.utils.translation import gettext_lazy as _
 
+logger = logging.getLogger(__name__)
 BNULL = {"blank": True, "null": True}
 
 
@@ -50,3 +53,45 @@ class Video(TimeStampedModel):
         if self.video_type == self.VideoType.TV_EPISODE:
             return f"{self.tv_series} - Season {self.season_number}, Episode {self.episode_number}"
         return self.title
+
+
+    @classmethod
+    def find_or_create(cls, data_dict: Dict) -> "Video":
+        """Given a data dict from Jellyfin, does the heavy lifting of looking up
+        the video and, if need, TV Series, creating both if they don't yet
+        exist.
+
+        """
+        video_dict = {
+            "title": data_dict.get("Name", ""),
+            "imdb_id": data_dict.get("Provider_imdb", None),
+            "video_type": Video.VideoType.MOVIE,
+            "year": data_dict.get("Year", ""),
+            "overview": data_dict.get("Overview", None),
+            "tagline": data_dict.get("Tagline", None),
+            "run_time_ticks": data_dict.get("RunTimeTicks", None),
+            "run_time": data_dict.get("RunTime", None),
+        }
+
+        if data_dict.get("ItemType", "") == "Episode":
+            series_name = data_dict.get("SeriesName", "")
+            series, series_created = Series.objects.get_or_create(name=series_name)
+            if series_created:
+                logger.debug(f"Created new series {series}")
+            else:
+                logger.debug(f"Found series {series}")
+            video_dict['video_type'] = Video.VideoType.TV_EPISODE
+            video_dict["tv_series_id"] = series.id
+            video_dict["tvdb_id"] = data_dict.get("Provider_tvdb", None)
+            video_dict["tvrage_id"] = data_dict.get("Provider_tvrage", None)
+            video_dict["episode_number"] = data_dict.get("EpisodeNumber", "")
+            video_dict["season_number"] = data_dict.get("SeasonNumber", "")
+
+
+        video, created = cls.objects.get_or_create(**video_dict)
+        if created:
+            logger.debug(f"Created new video: {video}")
+        else:
+            logger.debug(f"Found video {video}")
+
+        return video

+ 15 - 0
vrobbler/settings.py

@@ -37,8 +37,22 @@ KEEP_DETAILED_SCROBBLE_LOGS = os.getenv(
     "VROBBLER_KEEP_DETAILED_SCROBBLE_LOGS", False
 )
 
+# Should we cull old in-progress scrobbles that are beyond the wait period for resuming?
 DELETE_STALE_SCROBBLES = os.getenv("VROBBLER_DELETE_STALE_SCROBBLES", True)
 
+# Used to dump data coming from srobbling sources, helpful for building new inputs
+DUMP_REQUEST_DATA = os.getenv("VROBBLER_DUMP_REQUEST_DATA", False)
+
+VIDEO_BACKOFF_MINUTES = os.getenv("VROBBLER_VIDEO_BACKOFF_MINUTES", 15)
+MUSIC_BACKOFF_SECONDS = os.getenv("VROBBLER_VIDEO_BACKOFF_SECONDS", 5)
+
+# If you stop waching or listening to a track, how long should we wait before we
+# give up on the old scrobble and start a new one? This could also be considered
+# a "continue in progress scrobble" time period. So if you pause the media and
+# start again, should it be a new scrobble.
+VIDEO_WAIT_PERIOD_DAYS = os.getenv("VROBBLER_VIDEO_WAIT_PERIOD_DAYS", 1)
+MUSIC_WAIT_PERIOD_MINUTES = os.getenv("VROBBLER_VIDEO_BACKOFF_MINUTES", 1)
+
 TMDB_API_KEY = os.getenv("VROBBLER_TMDB_API_KEY", "")
 
 DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
@@ -70,6 +84,7 @@ INSTALLED_APPS = [
     'rest_framework.authtoken',
     "scrobbles",
     "videos",
+    "music",
     "rest_framework",
     "allauth",
     "allauth.account",

+ 32 - 11
vrobbler/templates/scrobbles/scrobble_list.html

@@ -6,23 +6,44 @@
     {% if now_playing_list %}
     <h2>Now playing</h2>
     {% for scrobble in now_playing_list %}
-        <dl class="latest-scrobble">
-            <dt>{{scrobble.video.title}} - {{scrobble.video}}</dt>
-            <dd>
-                {{scrobble.timestamp|date:"D, M j Y"}} |
-                <a href="https://www.imdb.com/title/{{scrobble.video.imdb_id}}">IMDB</a>
-                <div class="progress-bar">
-                    <span class="progress-bar-fill" style="width: {{scrobble.percent_played}}%;"></span>
-                </div>
-            </dd>
-        </dl>
+        {% if scrobble.video %}
+            <dl class="latest-scrobble">
+                <dt>{{scrobble.video.title}} - {{scrobble.video}}</dt>
+                <dd>
+                    {{scrobble.timestamp|date:"D, M j Y"}} |
+                    <a href="https://www.imdb.com/title/{{scrobble.video.imdb_id}}">IMDB</a>
+                    <div class="progress-bar">
+                        <span class="progress-bar-fill" style="width: {{scrobble.percent_played}}%;"></span>
+                    </div>
+                </dd>
+            </dl>
+        {% endif %}
+        {% if scrobble.track %}
+            <dl class="latest-scrobble">
+                <dt>{{scrobble.track.title}} by {{scrobble.track.artist}} from {{scrobble.track.album}}</dt>
+                <dd>
+                    {{scrobble.timestamp|date:"D, M j Y"}} |
+                    <a href="https://www.imdb.com/title/{{scrobble.track.musicbrainz_id}}">MusicBrainz</a>
+                    <div class="progress-bar">
+                        <span class="progress-bar-fill" style="width: {{scrobble.percent_played}}%;"></span>
+                    </div>
+                </dd>
+            </dl>
+        {% endif %}
         <br />
     {% endfor %}
     {% endif %}
     <h2>Last scrobbles</h2>
     <ul>
         {% for scrobble in object_list %}
-        <li>{{scrobble.timestamp|date:"D, M j Y"}}: <a href="https://www.imdb.com/title/{{scrobble.video.imdb_id}}">{{scrobble.video}}{% if scrobble.video.video_type == 'E' %} - {{scrobble.video.title}}{% endif %}</a></li>
+        <li>
+            {{scrobble.timestamp|date:"D, M j Y"}}:
+            {% if scrobble.video %}
+            📼 <a href="https://www.imdb.com/title/{{scrobble.video.imdb_id}}">{{scrobble.video}}{% if scrobble.video.video_type == 'E' %} - {{scrobble.video.title}}{% endif %}</a></li>
+            {% endif %}
+            {% if scrobble.track %}
+            🎶 <a href="https://musicbrainz.org/recording/{{scrobble.track.album.musicbrainz_id}}">{{scrobble.track}} by {{scrobble.track.artist}}</a></li>
+            {% endif %}
         {% endfor %}
     </ul>
 {% endblock %}