Просмотр исходного кода

[scrobbles] Flatten scrobble table to use just item

This is a big one, and there's two commits. This one and the next.
All the migrations in this commit need to be run before the changes
to the media models in the next commit can be applied.
Colin Powell 3 недель назад
Родитель
Сommit
c9859b5a68
33 измененных файлов с 894 добавлено и 159 удалено
  1. 22 0
      vrobbler/apps/beers/migrations/0007_add_scrobblableitem_ptr.py
  2. 22 0
      vrobbler/apps/boardgames/migrations/0014_add_scrobblableitem_ptr.py
  3. 2 1
      vrobbler/apps/boardgames/sources/lichess.py
  4. 4 1
      vrobbler/apps/books/koreader.py
  5. 5 3
      vrobbler/apps/books/management/commands/migrate_koreader_data_to_json.py
  6. 32 0
      vrobbler/apps/books/migrations/0033_add_scrobblableitem_ptr.py
  7. 22 0
      vrobbler/apps/bricksets/migrations/0004_add_scrobblableitem_ptr.py
  8. 22 0
      vrobbler/apps/foods/migrations/0005_add_scrobblableitem_ptr.py
  9. 22 0
      vrobbler/apps/lifeevents/migrations/0004_add_scrobblableitem_ptr.py
  10. 22 0
      vrobbler/apps/locations/migrations/0009_add_scrobblableitem_ptr.py
  11. 22 0
      vrobbler/apps/moods/migrations/0005_add_scrobblableitem_ptr.py
  12. 2 1
      vrobbler/apps/music/aggregators.py
  13. 22 0
      vrobbler/apps/music/migrations/0030_add_scrobblableitem_ptr.py
  14. 22 0
      vrobbler/apps/podcasts/migrations/0019_add_scrobblableitem_ptr.py
  15. 22 0
      vrobbler/apps/puzzles/migrations/0005_add_scrobblableitem_ptr.py
  16. 27 0
      vrobbler/apps/scrobbles/constants.py
  17. 2 1
      vrobbler/apps/scrobbles/importers/lastfm.py
  18. 2 2
      vrobbler/apps/scrobbles/importers/tsv.py
  19. 86 0
      vrobbler/apps/scrobbles/migrations/0071_scrobblableitem.py
  20. 85 0
      vrobbler/apps/scrobbles/migrations/0072_migrate_scrobblablemixin_data.py
  21. 72 0
      vrobbler/apps/scrobbles/migrations/0073_alter_scrobble_options_scrobble_item_and_more.py
  22. 100 0
      vrobbler/apps/scrobbles/mixins.py
  23. 87 118
      vrobbler/apps/scrobbles/models.py
  24. 28 27
      vrobbler/apps/scrobbles/scrobblers.py
  25. 3 3
      vrobbler/apps/scrobbles/utils.py
  26. 2 1
      vrobbler/apps/scrobbles/views.py
  27. 22 0
      vrobbler/apps/sports/migrations/0017_add_scrobblableitem_ptr.py
  28. 22 0
      vrobbler/apps/tasks/migrations/0006_add_scrobblableitem_ptr.py
  29. 22 0
      vrobbler/apps/trails/migrations/0007_add_scrobblableitem_ptr.py
  30. 22 0
      vrobbler/apps/videogames/migrations/0014_add_scrobblableitem_ptr.py
  31. 3 1
      vrobbler/apps/videogames/retroarch.py
  32. 22 0
      vrobbler/apps/videos/migrations/0025_add_scrobblableitem_ptr.py
  33. 22 0
      vrobbler/apps/webpages/migrations/0007_add_scrobblableitem_ptr.py

+ 22 - 0
vrobbler/apps/beers/migrations/0007_add_scrobblableitem_ptr.py

@@ -0,0 +1,22 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("beers", "0006_remove_beer_run_time_seconds_and_more"),
+        ("scrobbles", "0071_scrobblableitem"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="beer",
+            name="scrobblableitem_ptr",
+            field=models.OneToOneField(
+                to="scrobbles.Scrobblableitem",
+                null=True,
+                blank=True,
+                on_delete=django.db.models.deletion.CASCADE,
+            ),
+        ),
+    ]

+ 22 - 0
vrobbler/apps/boardgames/migrations/0014_add_scrobblableitem_ptr.py

@@ -0,0 +1,22 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("boardgames", "0013_boardgame_publishers"),
+        ("scrobbles", "0071_scrobblableitem"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="boardgame",
+            name="scrobblableitem_ptr",
+            field=models.OneToOneField(
+                to="scrobbles.Scrobblableitem",
+                null=True,
+                blank=True,
+                on_delete=django.db.models.deletion.CASCADE,
+            ),
+        ),
+    ]

+ 2 - 1
vrobbler/apps/boardgames/sources/lichess.py

@@ -2,6 +2,7 @@ import berserk
 from boardgames.models import BoardGame
 from django.conf import settings
 from django.contrib.auth import get_user_model
+from scrobbles.constants import MediaType
 from scrobbles.models import Scrobble
 from scrobbles.notifications import ScrobbleNtfyNotification
 
@@ -96,7 +97,7 @@ def import_chess_games_for_user_id(user_id: int, commit: bool = False) -> dict:
         log_data["players"].append(other_player)
 
         scrobble_dict = {
-            "media_type": Scrobble.MediaType.BOARD_GAME,
+            "media_type": MediaType.BOARD_GAME,
             "user_id": user.id,
             "playback_position_seconds": (
                 game_dict.get("lastMoveAt") - game_dict.get("createdAt")

+ 4 - 1
vrobbler/apps/books/koreader.py

@@ -9,6 +9,7 @@ import requests
 from books.constants import BOOKS_TITLES_TO_IGNORE
 from django.apps import apps
 from django.contrib.auth import get_user_model
+from scrobbles.constants import MediaType
 from scrobbles.notifications import ScrobbleNtfyNotification
 from stream_sqlite import stream_sqlite
 from webdav.client import get_webdav_client
@@ -291,6 +292,8 @@ def build_scrobbles_from_book_map(
                 #) or stop_timestamp.dst() == timedelta(0):
                 #    timestamp = timestamp - timedelta(hours=1)
                 #    stop_timestamp = stop_timestamp - timedelta(hours=1)
+                timestamp -= timedelta(seconds=timestamp.dst())
+                stop_timestamp -= timedelta(seconds=timestamp.dst())
 
                 scrobble = Scrobble.objects.filter(
                     timestamp=timestamp,
@@ -312,7 +315,7 @@ def build_scrobbles_from_book_map(
                             book_id=book_id,
                             user_id=user.id,
                             source="KOReader",
-                            media_type=Scrobble.MediaType.BOOK,
+                            media_type=MediaType.BOOK,
                             timestamp=timestamp,
                             log=log_data,
                             stop_timestamp=stop_timestamp,

+ 5 - 3
vrobbler/apps/books/management/commands/migrate_koreader_data_to_json.py

@@ -1,13 +1,15 @@
 import logging
-import pytz
 from datetime import datetime, timedelta
+
+import pytz
 from books.models import Book
 from django.core.management.base import BaseCommand
+from scrobbles.constants import MediaType
 from scrobbles.models import Scrobble
+
 from vrobbler.apps.books.koreader import fix_long_play_stats_for_scrobbles
 from vrobbler.apps.scrobbles.utils import timestamp_user_tz_to_utc
 
-
 logger = logging.getLogger(__name__)
 
 # Grace period between page reads for it to be a new scrobble
@@ -135,7 +137,7 @@ class Command(BaseCommand):
                                 book_id=book_id,
                                 user_id=user.id,
                                 source="KOReader",
-                                media_type=Scrobble.MediaType.BOOK,
+                                media_type=MediaType.BOOK,
                                 timestamp=timestamp,
                                 stop_timestamp=stop_timestamp,
                                 playback_position_seconds=playback_position_seconds,

+ 32 - 0
vrobbler/apps/books/migrations/0033_add_scrobblableitem_ptr.py

@@ -0,0 +1,32 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("books", "0032_remove_book_run_time_seconds_and_more"),
+        ("scrobbles", "0071_scrobblableitem"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="book",
+            name="scrobblableitem_ptr",
+            field=models.OneToOneField(
+                to="scrobbles.Scrobblableitem",
+                null=True,
+                blank=True,
+                on_delete=django.db.models.deletion.CASCADE,
+            ),
+        ),
+        migrations.AddField(
+            model_name="paper",
+            name="scrobblableitem_ptr",
+            field=models.OneToOneField(
+                to="scrobbles.Scrobblableitem",
+                null=True,
+                blank=True,
+                on_delete=django.db.models.deletion.CASCADE,
+            ),
+        ),
+    ]

+ 22 - 0
vrobbler/apps/bricksets/migrations/0004_add_scrobblableitem_ptr.py

@@ -0,0 +1,22 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("bricksets", "0003_remove_brickset_run_time_seconds_and_more"),
+        ("scrobbles", "0071_scrobblableitem"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="brickset",
+            name="scrobblableitem_ptr",
+            field=models.OneToOneField(
+                to="scrobbles.Scrobblableitem",
+                null=True,
+                blank=True,
+                on_delete=django.db.models.deletion.CASCADE,
+            ),
+        ),
+    ]

+ 22 - 0
vrobbler/apps/foods/migrations/0005_add_scrobblableitem_ptr.py

@@ -0,0 +1,22 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("foods", "0004_remove_food_run_time_seconds_and_more"),
+        ("scrobbles", "0071_scrobblableitem"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="food",
+            name="scrobblableitem_ptr",
+            field=models.OneToOneField(
+                to="scrobbles.Scrobblableitem",
+                null=True,
+                blank=True,
+                on_delete=django.db.models.deletion.CASCADE,
+            ),
+        ),
+    ]

+ 22 - 0
vrobbler/apps/lifeevents/migrations/0004_add_scrobblableitem_ptr.py

@@ -0,0 +1,22 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("lifeevents", "0003_remove_lifeevent_run_time_seconds_and_more"),
+        ("scrobbles", "0071_scrobblableitem"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="lifeevent",
+            name="scrobblableitem_ptr",
+            field=models.OneToOneField(
+                to="scrobbles.Scrobblableitem",
+                null=True,
+                blank=True,
+                on_delete=django.db.models.deletion.CASCADE,
+            ),
+        ),
+    ]

+ 22 - 0
vrobbler/apps/locations/migrations/0009_add_scrobblableitem_ptr.py

@@ -0,0 +1,22 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("locations", "0008_remove_geolocation_run_time_seconds_and_more"),
+        ("scrobbles", "0071_scrobblableitem"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="geolocation",
+            name="scrobblableitem_ptr",
+            field=models.OneToOneField(
+                to="scrobbles.Scrobblableitem",
+                null=True,
+                blank=True,
+                on_delete=django.db.models.deletion.CASCADE,
+            ),
+        ),
+    ]

+ 22 - 0
vrobbler/apps/moods/migrations/0005_add_scrobblableitem_ptr.py

@@ -0,0 +1,22 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("moods", "0004_remove_mood_run_time_seconds_and_more"),
+        ("scrobbles", "0071_scrobblableitem"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="mood",
+            name="scrobblableitem_ptr",
+            field=models.OneToOneField(
+                to="scrobbles.Scrobblableitem",
+                null=True,
+                blank=True,
+                on_delete=django.db.models.deletion.CASCADE,
+            ),
+        ),
+    ]

+ 2 - 1
vrobbler/apps/music/aggregators.py

@@ -5,6 +5,7 @@ from django.db.models import Count, Q, QuerySet
 from django.utils import timezone
 from profiles.utils import now_user_timezone
 from scrobbles.models import Scrobble
+from scrobbles.constants import MediaType
 from videos.models import Video
 
 
@@ -28,7 +29,7 @@ def scrobble_counts(user=None):
     finished_scrobbles_qs = Scrobble.objects.filter(
         user_filter,
         played_to_completion=True,
-        media_type=Scrobble.MediaType.TRACK,
+        media_type=MediaType.TRACK,
     )
     data = {}
     data["today"] = finished_scrobbles_qs.filter(

+ 22 - 0
vrobbler/apps/music/migrations/0030_add_scrobblableitem_ptr.py

@@ -0,0 +1,22 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("music", "0029_remove_track_run_time_seconds_and_more"),
+        ("scrobbles", "0071_scrobblableitem"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="track",
+            name="scrobblableitem_ptr",
+            field=models.OneToOneField(
+                to="scrobbles.Scrobblableitem",
+                null=True,
+                blank=True,
+                on_delete=django.db.models.deletion.CASCADE,
+            ),
+        ),
+    ]

+ 22 - 0
vrobbler/apps/podcasts/migrations/0019_add_scrobblableitem_ptr.py

@@ -0,0 +1,22 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("podcasts", "0018_remove_podcastepisode_run_time_seconds_and_more"),
+        ("scrobbles", "0071_scrobblableitem"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="podcastepisode",
+            name="scrobblableitem_ptr",
+            field=models.OneToOneField(
+                to="scrobbles.Scrobblableitem",
+                null=True,
+                blank=True,
+                on_delete=django.db.models.deletion.CASCADE,
+            ),
+        ),
+    ]

+ 22 - 0
vrobbler/apps/puzzles/migrations/0005_add_scrobblableitem_ptr.py

@@ -0,0 +1,22 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("puzzles", "0004_remove_puzzle_run_time_seconds_and_more"),
+        ("scrobbles", "0071_scrobblableitem"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="puzzle",
+            name="scrobblableitem_ptr",
+            field=models.OneToOneField(
+                to="scrobbles.Scrobblableitem",
+                null=True,
+                blank=True,
+                on_delete=django.db.models.deletion.CASCADE,
+            ),
+        ),
+    ]

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

@@ -1,4 +1,31 @@
 from enum import Enum
+from django.db.models import TextChoices
+
+class MediaType(TextChoices):
+    """Enum mapping a media model type to a string"""
+
+    VIDEO = "Video", "Video"
+    TRACK = "Track", "Track"
+    PODCAST_EPISODE = "PodcastEpisode", "Podcast episode"
+    SPORT_EVENT = "SportEvent", "Sport event"
+    BOOK = "Book", "Book"
+    PAPER = "Paper", "Paper"
+    VIDEO_GAME = "VideoGame", "Video game"
+    BOARD_GAME = "BoardGame", "Board game"
+    GEO_LOCATION = "GeoLocation", "GeoLocation"
+    TRAIL = "Trail", "Trail"
+    BEER = "Beer", "Beer"
+    PUZZLE = "Puzzle", "Puzzle"
+    FOOD = "Food", "Food"
+    TASK = "Task", "Task"
+    WEBPAGE = "WebPage", "Web Page"
+    LIFE_EVENT = "LifeEvent", "Life event"
+    MOOD = "Mood", "Mood"
+    BRICKSET = "BrickSet", "Brick set"
+
+    @classmethod
+    def list(cls):
+        return list(map(lambda c: c.value, cls))
 
 JELLYFIN_VIDEO_ITEM_TYPES = ["Episode", "Movie"]
 JELLYFIN_AUDIO_ITEM_TYPES = ["Audio"]

+ 2 - 1
vrobbler/apps/scrobbles/importers/lastfm.py

@@ -5,6 +5,7 @@ import pylast
 import pytz
 from django.conf import settings
 from music.models import Track
+from scrobbles.constants import MediaType
 
 logger = logging.getLogger(__name__)
 
@@ -65,7 +66,7 @@ class LastFM:
                 track=track,
                 played_to_completion=True,
                 in_progress=False,
-                media_type=Scrobble.MediaType.TRACK,
+                media_type=MediaType.TRACK,
                 timezone=tz_timestamp.tzinfo.name,
             )
             # Vrobbler scrobbles on finish, LastFM scrobbles on start

+ 2 - 2
vrobbler/apps/scrobbles/importers/tsv.py

@@ -7,7 +7,7 @@ from zoneinfo import ZoneInfo
 import requests
 from django.contrib.auth import get_user_model
 from music.models import Track
-from scrobbles.constants import AsTsvColumn
+from scrobbles.constants import AsTsvColumn, MediaType
 from scrobbles.models import Scrobble
 
 logger = logging.getLogger(__name__)
@@ -78,7 +78,7 @@ def import_audioscrobbler_tsv_file(file_path, user_id):
             track=track,
             played_to_completion=True,
             in_progress=False,
-            media_type=Scrobble.MediaType.TRACK,
+            media_type=MediaType.TRACK,
             timezone=timestamp.tzinfo.name,
         )
         existing = Scrobble.objects.filter(

+ 86 - 0
vrobbler/apps/scrobbles/migrations/0071_scrobblableitem.py

@@ -0,0 +1,86 @@
+# Generated by Django 4.2.26 on 2025-12-03 00:16
+
+from django.db import migrations, models
+import django_extensions.db.fields
+import taggit.managers
+import uuid
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("scrobbles", "0070_rename_brickset_scrobble_brick_set"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="ScrobblableItem",
+            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"
+                    ),
+                ),
+                (
+                    "uuid",
+                    models.UUIDField(
+                        blank=True,
+                        default=uuid.uuid4,
+                        editable=False,
+                        null=True,
+                    ),
+                ),
+                (
+                    "title",
+                    models.CharField(blank=True, max_length=255, null=True),
+                ),
+                ("description", models.TextField(blank=True, null=True)),
+                (
+                    "base_run_time_seconds",
+                    models.IntegerField(blank=True, null=True),
+                ),
+                (
+                    "tags",
+                    taggit.managers.TaggableManager(
+                        blank=True,
+                        help_text="A comma-separated list of tags.",
+                        through="scrobbles.ObjectWithGenres",
+                        to="scrobbles.Genre",
+                        verbose_name="Tags",
+                    ),
+                ),
+            ],
+            options={
+                "get_latest_by": "modified",
+                "abstract": False,
+            },
+        ),
+        migrations.AddField(
+            model_name="scrobble",
+            name="item",
+            field=models.ForeignKey(
+                default=None,
+                null=True,
+                on_delete=models.deletion.CASCADE,
+                related_name="scrobbles",
+                to="scrobbles.scrobblableitem",
+            ),
+            preserve_default=False,
+        ),
+    ]

+ 85 - 0
vrobbler/apps/scrobbles/migrations/0072_migrate_scrobblablemixin_data.py

@@ -0,0 +1,85 @@
+from django.db import migrations
+from django.apps import apps
+
+def create_and_copy_data(app_and_model, cursor):
+    Model = apps.get_model(*app_and_model)
+    table = Model._meta.db_table
+    ScrobblableItem = apps.get_model('scrobbles', 'Scrobblableitem')
+    ObjectWithGenres = apps.get_model('scrobbles', 'ObjectWithGenres')
+    print(f"\nMigrating {Model}")
+
+    for old in Model.objects.all():
+        # Create base ScrobblableItem
+        base = ScrobblableItem.objects.create(
+            uuid=old.uuid,
+            title=old.title,
+            description=getattr(old, 'description', ''),
+            base_run_time_seconds=getattr(old, 'base_run_time_seconds', None)
+        )
+        cursor.execute(
+            f"UPDATE {table} SET scrobblableitem_ptr_id = %s WHERE id = %s",
+            [base.id, old.id],
+        )
+        old.scrobble_set.update(item_id=base.id)
+
+        # Copy tags
+        for tag in old.genre.through.objects.filter(object_id=old.id):
+            ObjectWithGenres.objects.create(
+                tag_id=tag.tag_id,
+                content_object=base
+            )
+
+def backfill_media(apps, schema_editor):
+    del apps
+
+    connection = schema_editor.connection
+    cursor = connection.cursor()
+
+    app_and_models = (
+        ('beers', 'Beer'),
+        ('boardgames', 'BoardGame'),
+        ('books', 'Book'),
+        ('books', 'Paper'),
+        ('bricksets', 'BrickSet'),
+        ('foods', 'Food'),
+        ('lifeevents', 'LifeEvent'),
+        ('locations', 'GeoLocation'),
+        ('moods', 'Mood'),
+        ('music', 'Track'),
+        ('podcasts', 'PodcastEpisode'),
+        ('puzzles', 'Puzzle'),
+        ('sports', 'SportEvent'),
+        ('tasks', 'Task'),
+        ('videos', 'Video'),
+        ('webpages', 'WebPage'),
+    )
+
+    for app_and_model in app_and_models:
+        create_and_copy_data(app_and_model, cursor)
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('beers', '0007_add_scrobblableitem_ptr'),
+        ('boardgames', '0014_add_scrobblableitem_ptr'),
+        ('books', '0033_add_scrobblableitem_ptr'),
+        ('bricksets', '0004_add_scrobblableitem_ptr'),
+        ('foods', '0005_add_scrobblableitem_ptr'),
+        ('lifeevents', '0004_add_scrobblableitem_ptr'),
+        ('locations', '0009_add_scrobblableitem_ptr'),
+        ('moods', '0005_add_scrobblableitem_ptr'),
+        ('music', '0030_add_scrobblableitem_ptr'),
+        ('podcasts', '0019_add_scrobblableitem_ptr'),
+        ('puzzles', '0005_add_scrobblableitem_ptr'),
+        ('sports', '0017_add_scrobblableitem_ptr'),
+        ('tasks', '0006_add_scrobblableitem_ptr'),
+        ('trails', '0007_add_scrobblableitem_ptr'),
+        ('videogames', '0014_add_scrobblableitem_ptr'),
+        ('videos', '0025_add_scrobblableitem_ptr'),
+        ('webpages', '0007_add_scrobblableitem_ptr'),
+        ('scrobbles', '0071_scrobblableitem'),
+    ]
+
+    operations = [
+        migrations.RunPython(backfill_media, reverse_code=migrations.RunPython.noop)
+    ]

+ 72 - 0
vrobbler/apps/scrobbles/migrations/0073_alter_scrobble_options_scrobble_item_and_more.py

@@ -0,0 +1,72 @@
+# Generated by Django 4.2.26 on 2025-12-03 03:24
+
+from django.db import migrations, models
+import django.db.models.deletion
+import taggit.managers
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("taggit", "0004_alter_taggeditem_content_type_alter_taggeditem_tag"),
+        ("scrobbles", "0072_migrate_scrobblablemixin_data"),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name="scrobble",
+            options={"ordering": ["-timestamp"]},
+        ),
+        migrations.AlterField(
+            model_name="scrobblableitem",
+            name="tags",
+            field=taggit.managers.TaggableManager(
+                blank=True,
+                help_text="A comma-separated list of tags.",
+                through="taggit.TaggedItem",
+                to="taggit.Tag",
+                verbose_name="Tags",
+            ),
+        ),
+        migrations.AlterField(
+            model_name="scrobble",
+            name="media_type",
+            field=models.CharField(
+                choices=[
+                    ("Video", "Video"),
+                    ("Track", "Track"),
+                    ("PodcastEpisode", "Podcast episode"),
+                    ("SportEvent", "Sport event"),
+                    ("Book", "Book"),
+                    ("Paper", "Paper"),
+                    ("VideoGame", "Video game"),
+                    ("BoardGame", "Board game"),
+                    ("GeoLocation", "GeoLocation"),
+                    ("Trail", "Trail"),
+                    ("Beer", "Beer"),
+                    ("Puzzle", "Puzzle"),
+                    ("Food", "Food"),
+                    ("Task", "Task"),
+                    ("WebPage", "Web Page"),
+                    ("LifeEvent", "Life event"),
+                    ("Mood", "Mood"),
+                    ("BrickSet", "Brick set"),
+                ],
+                db_index=True,
+                editable=False,
+                max_length=14,
+            ),
+        ),
+        migrations.AddIndex(
+            model_name="scrobble",
+            index=models.Index(
+                fields=["timestamp"], name="scrobbles_s_timesta_b24a39_idx"
+            ),
+        ),
+        migrations.AddIndex(
+            model_name="scrobble",
+            index=models.Index(
+                fields=["media_type"], name="scrobbles_s_media_t_76e16b_idx"
+            ),
+        ),
+    ]

+ 100 - 0
vrobbler/apps/scrobbles/mixins.py

@@ -51,6 +51,106 @@ class ObjectWithGenres(GenericTaggedItemBase):
     )
 
 
+class ScrobblableItem(TimeStampedModel):
+    SECONDS_TO_STALE = 1600
+    COMPLETION_PERCENT = 100
+
+    uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
+    title = models.CharField(max_length=255, **BNULL)
+    description = models.TextField(**BNULL)
+    base_run_time_seconds = models.IntegerField(**BNULL)
+
+    tags = TaggableManager(blank=True)
+
+    # Cache for subclass related names
+    _subclass_related_names_cache = None
+
+    @classmethod
+    def get_subclass_related_names(cls):
+        """Return cached related names for all subclasses."""
+        if cls._subclass_related_names_cache is not None:
+            return cls._subclass_related_names_cache
+
+        related_names = []
+        for model in apps.get_models():
+            if issubclass(model, cls) and model is not cls:
+                for field in model._meta.fields:
+                    if (
+                        isinstance(field, models.OneToOneField)
+                        and field.remote_field.model == cls
+                    ):
+                        if field.remote_field.related_name:
+                            related_names.append(field.remote_field.related_name)
+                        else:
+                            related_names.append(f"{model._meta.model_name}_ptr")
+        cls._subclass_related_names_cache = related_names
+        return related_names
+
+    def get_concrete_instance(self):
+        """Return the actual subclass instance (the "media object")."""
+        for name in self.get_subclass_related_names():
+            try:
+                return getattr(self, name)
+            except (AttributeError, self.__class__.DoesNotExist):
+                continue
+        return self
+
+    def get_media_type(self):
+        """Return the media type string from the concrete subclass."""
+        concrete = self.get_concrete_instance()
+        if concrete is None:
+            return "ScrobblableItem"
+        # Access class-level MEDIA_TYPE
+        return getattr(concrete.__class__, "MEDIA_TYPE", concrete.__class__.__name__)
+
+    @property
+    def run_time_seconds(self) -> int:
+        if self.base_run_time_seconds:
+            return self.base_run_time_seconds
+        return 900  # TODO this should be a constant
+
+    @classmethod
+    def find_or_create(cls):
+        logger.warning("find_or_create() not implemented yet")
+
+    def __str__(self) -> str:
+        if self.title:
+            return str(self.title)
+        return str(self.uuid)
+
+    def scrobble_for_user(
+        self,
+        user_id,
+        source: str = "Vrobbler",
+        playback_position_seconds: int = 0,
+        status: str = "started",
+        log: Optional[dict] = None,
+    ):
+        Scrobble = apps.get_model("scrobbles", "Scrobble")
+        scrobble_data = {
+            "user_id": user_id,
+            "timestamp": timezone.now(),
+            "source": source,
+            "status": status,
+            "playback_position_seconds": playback_position_seconds,
+        }
+
+        if log:
+            scrobble_data["log"] = log
+
+        logger.info(
+            "[scrobble_for_user] called",
+            extra={
+                "id": self.id,
+                "media_type": self.__class__.__name__,
+                "user_id": user_id,
+                "scrobble_data": scrobble_data,
+            },
+        )
+        return Scrobble.create_or_update(self, user_id, scrobble_data)
+
+
+# DEPRECATED
 class ScrobblableMixin(TimeStampedModel):
     SECONDS_TO_STALE = 1600
     COMPLETION_PERCENT = 100

+ 87 - 118
vrobbler/apps/scrobbles/models.py

@@ -17,6 +17,7 @@ from bricksets.models import BrickSet
 from dataclass_wizard.errors import ParseError
 from django.conf import settings
 from django.contrib.auth import get_user_model
+from django.utils.functional import cached_property
 from django.core.files import File
 from django.db import models
 from django.urls import reverse
@@ -41,9 +42,11 @@ from profiles.utils import (
 )
 from puzzles.models import Puzzle
 from scrobbles import dataclasses as logdata
+from scrobbles import constants
 from scrobbles.constants import LONG_PLAY_MEDIA, MEDIA_END_PADDING_SECONDS
 from scrobbles.importers.lastfm import LastFM
 from scrobbles.notifications import ScrobbleNtfyNotification
+from scrobbles.mixins import ScrobblableItem
 from scrobbles.stats import build_charts
 from scrobbles.utils import get_file_md5_hash, media_class_to_foreign_key
 from sports.models import SportEvent
@@ -503,68 +506,18 @@ class ChartRecord(TimeStampedModel):
 
 class Scrobble(TimeStampedModel):
     """A scrobble tracks played media items by a user."""
-
-    class MediaType(models.TextChoices):
-        """Enum mapping a media model type to a string"""
-
-        VIDEO = "Video", "Video"
-        TRACK = "Track", "Track"
-        PODCAST_EPISODE = "PodcastEpisode", "Podcast episode"
-        SPORT_EVENT = "SportEvent", "Sport event"
-        BOOK = "Book", "Book"
-        PAPER = "Paper", "Paper"
-        VIDEO_GAME = "VideoGame", "Video game"
-        BOARD_GAME = "BoardGame", "Board game"
-        GEO_LOCATION = "GeoLocation", "GeoLocation"
-        TRAIL = "Trail", "Trail"
-        BEER = "Beer", "Beer"
-        PUZZLE = "Puzzle", "Puzzle"
-        FOOD = "Food", "Food"
-        TASK = "Task", "Task"
-        WEBPAGE = "WebPage", "Web Page"
-        LIFE_EVENT = "LifeEvent", "Life event"
-        MOOD = "Mood", "Mood"
-        BRICKSET = "BrickSet", "Brick set"
-
-        @classmethod
-        def list(cls):
-            return list(map(lambda c: c.value, cls))
-
     uuid = models.UUIDField(editable=False, **BNULL)
-    video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL)
-    track = models.ForeignKey(Track, on_delete=models.DO_NOTHING, **BNULL)
-    podcast_episode = models.ForeignKey(
-        PodcastEpisode, on_delete=models.DO_NOTHING, **BNULL
-    )
-    sport_event = models.ForeignKey(
-        SportEvent, on_delete=models.DO_NOTHING, **BNULL
-    )
-    book = models.ForeignKey(Book, on_delete=models.DO_NOTHING, **BNULL)
-    paper = models.ForeignKey(Paper, on_delete=models.DO_NOTHING, **BNULL)
-    video_game = models.ForeignKey(
-        VideoGame, on_delete=models.DO_NOTHING, **BNULL
-    )
-    board_game = models.ForeignKey(
-        BoardGame, on_delete=models.DO_NOTHING, **BNULL
-    )
-    geo_location = models.ForeignKey(
-        GeoLocation, on_delete=models.DO_NOTHING, **BNULL
-    )
-    beer = models.ForeignKey(Beer, on_delete=models.DO_NOTHING, **BNULL)
-    puzzle = models.ForeignKey(Puzzle, on_delete=models.DO_NOTHING, **BNULL)
-    food = models.ForeignKey(Food, on_delete=models.DO_NOTHING, **BNULL)
-    trail = models.ForeignKey(Trail, on_delete=models.DO_NOTHING, **BNULL)
-    task = models.ForeignKey(Task, on_delete=models.DO_NOTHING, **BNULL)
-    web_page = models.ForeignKey(WebPage, on_delete=models.DO_NOTHING, **BNULL)
-    life_event = models.ForeignKey(
-        LifeEvent, on_delete=models.DO_NOTHING, **BNULL
-    )
-    mood = models.ForeignKey(Mood, on_delete=models.DO_NOTHING, **BNULL)
-    brick_set = models.ForeignKey(
-        BrickSet, on_delete=models.DO_NOTHING, **BNULL
+    item = models.ForeignKey(
+        ScrobblableItem,
+        null=True,
+        on_delete=models.CASCADE,
+        related_name="scrobbles",
     )
     media_type = models.CharField(
-        max_length=14, choices=MediaType.choices, default=MediaType.VIDEO
+        max_length=14,
+        choices=constants.MediaType.choices,
+        db_index=True,
+        editable=False,
     )
     user = models.ForeignKey(
         User, blank=True, null=True, on_delete=models.DO_NOTHING
@@ -610,9 +563,60 @@ class Scrobble(TimeStampedModel):
         format="JPEG",
         options={"quality": 75},
     )
+
+    @cached_property
+    def media_obj(self):
+        """
+        Return the concrete media instance (Book, Video, Track, etc.).
+        Cached for the lifetime of this model instance.
+        """
+        if not self.item:
+            return None
+        return self.item.get_concrete_instance()
+
+    # Deprecated
+    video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL)
+    track = models.ForeignKey(Track, on_delete=models.DO_NOTHING, **BNULL)
+    podcast_episode = models.ForeignKey(
+        PodcastEpisode, on_delete=models.DO_NOTHING, **BNULL
+    )
+    sport_event = models.ForeignKey(
+        SportEvent, on_delete=models.DO_NOTHING, **BNULL
+    )
+    book = models.ForeignKey(Book, on_delete=models.DO_NOTHING, **BNULL)
+    paper = models.ForeignKey(Paper, on_delete=models.DO_NOTHING, **BNULL)
+    video_game = models.ForeignKey(
+        VideoGame, on_delete=models.DO_NOTHING, **BNULL
+    )
+    board_game = models.ForeignKey(
+        BoardGame, on_delete=models.DO_NOTHING, **BNULL
+    )
+    geo_location = models.ForeignKey(
+        GeoLocation, on_delete=models.DO_NOTHING, **BNULL
+    )
+    beer = models.ForeignKey(Beer, on_delete=models.DO_NOTHING, **BNULL)
+    puzzle = models.ForeignKey(Puzzle, on_delete=models.DO_NOTHING, **BNULL)
+    food = models.ForeignKey(Food, on_delete=models.DO_NOTHING, **BNULL)
+    trail = models.ForeignKey(Trail, on_delete=models.DO_NOTHING, **BNULL)
+    task = models.ForeignKey(Task, on_delete=models.DO_NOTHING, **BNULL)
+    web_page = models.ForeignKey(WebPage, on_delete=models.DO_NOTHING, **BNULL)
+    life_event = models.ForeignKey(
+        LifeEvent, on_delete=models.DO_NOTHING, **BNULL
+    )
+    mood = models.ForeignKey(Mood, on_delete=models.DO_NOTHING, **BNULL)
+    brick_set = models.ForeignKey(
+        BrickSet, on_delete=models.DO_NOTHING, **BNULL
+    )
     long_play_seconds = models.BigIntegerField(**BNULL)
     long_play_complete = models.BooleanField(**BNULL)
 
+    class Meta:
+        ordering = ["-timestamp"]
+        indexes = [
+            models.Index(fields=["timestamp"]),
+            models.Index(fields=["media_type"]),
+        ]
+
     @classmethod
     def for_year(cls, user, year):
         return cls.objects.filter(timestamp__year=year, user=user).order_by(
@@ -674,13 +678,16 @@ class Scrobble(TimeStampedModel):
         )
 
     @property
-    def last_serial_scrobble(self) -> Optional["Scrobble"]:
+    def last_serial_scrobble(self) -> "Scrobble | None":
         from scrobbles.models import Scrobble
 
-        if self.logdata and self.logdata.serial_scrobble_id:
-            return Scrobble.objects.filter(
-                id=self.logdata.serial_scrobble_id
-            ).first()
+        try:
+            if self.logdata and self.logdata.serial_scrobble_id:
+                    return Scrobble.objects.filter(
+                        id=self.logdata.serial_scrobble_id
+                    ).first()
+        except AttributeError:
+            return
 
     @property
     def finish_url(self) -> str:
@@ -690,6 +697,9 @@ class Scrobble(TimeStampedModel):
         if not self.uuid:
             self.uuid = uuid4()
 
+        if self.item:
+            self.media_type = self.item.MEDIA_TYPE
+
         if not self.timezone:
             timezone = settings.TIME_ZONE
             if self.user and self.user.profile:
@@ -699,10 +709,8 @@ class Scrobble(TimeStampedModel):
         # Microseconds mess up Django's filtering, and we don't need be that specific
         if self.timestamp:
             self.timestamp = self.timestamp.replace(microsecond=0)
-        if self.media_obj:
-            self.media_type = self.MediaType(self.media_obj.__class__.__name__)
 
-        return super(Scrobble, self).save(*args, **kwargs)
+        return super().save(*args, **kwargs)
 
     def get_absolute_url(self):
         if not self.uuid:
@@ -770,7 +778,7 @@ class Scrobble(TimeStampedModel):
         redirect_url = self.media_obj.get_absolute_url()
 
         if (
-            self.media_type == self.MediaType.WEBPAGE
+            self.media_type == constants.MediaType.WEBPAGE
             and user
             and user.profile.redirect_to_webpage
         ):
@@ -778,7 +786,7 @@ class Scrobble(TimeStampedModel):
             redirect_url = self.media_obj.url
 
         if (
-            self.media_type == self.MediaType.VIDEO
+            self.media_type == constants.MediaType.VIDEO
             and self.media_obj.youtube_id
         ):
             redirect_url = self.media_obj.youtube_link
@@ -1043,45 +1051,6 @@ class Scrobble(TimeStampedModel):
             "-count",
         )
 
-    @property
-    def media_obj(self):
-        media_obj = None
-        if self.video:
-            media_obj = self.video
-        if self.track:
-            media_obj = self.track
-        if self.podcast_episode:
-            media_obj = self.podcast_episode
-        if self.sport_event:
-            media_obj = self.sport_event
-        if self.book:
-            media_obj = self.book
-        if self.video_game:
-            media_obj = self.video_game
-        if self.board_game:
-            media_obj = self.board_game
-        if self.geo_location:
-            media_obj = self.geo_location
-        if self.web_page:
-            media_obj = self.web_page
-        if self.life_event:
-            media_obj = self.life_event
-        if self.mood:
-            media_obj = self.mood
-        if self.brick_set:
-            media_obj = self.brick_set
-        if self.trail:
-            media_obj = self.trail
-        if self.beer:
-            media_obj = self.beer
-        if self.puzzle:
-            media_obj = self.puzzle
-        if self.task:
-            media_obj = self.task
-        if self.food:
-            media_obj = self.food
-        return media_obj
-
     def __str__(self):
         return f"Scrobble of {self.media_obj} ({self.timestamp})"
 
@@ -1147,7 +1116,7 @@ class Scrobble(TimeStampedModel):
         mtype = media.__class__.__name__
 
         # GeoLocations are a special case scrobble
-        if mtype == cls.MediaType.GEO_LOCATION:
+        if mtype == constants.MediaType.GEO_LOCATION:
             logger.warning(
                 f"[create_or_update] geoloc requires create_or_update_location"
             )
@@ -1210,7 +1179,7 @@ class Scrobble(TimeStampedModel):
                 "source": source,
             },
         )
-        if mtype == cls.MediaType.FOOD and not scrobble_data.get("log", {}).get("calories", None):
+        if mtype == constants.MediaType.FOOD and not scrobble_data.get("log", {}).get("calories", None):
             if media.calories:
                 scrobble_data["log"] = FoodLogData(calories=media.calories)
 
@@ -1231,7 +1200,7 @@ class Scrobble(TimeStampedModel):
 
         scrobble = (
             cls.objects.filter(
-                media_type=cls.MediaType.GEO_LOCATION,
+                media_type=constants.MediaType.GEO_LOCATION,
                 user_id=user_id,
                 timestamp__lte=scrobble_data.get("timestamp"),
             )
@@ -1243,7 +1212,7 @@ class Scrobble(TimeStampedModel):
             f"[scrobbling] fetching last location scrobble",
             extra={
                 "scrobble_id": scrobble.id if scrobble else None,
-                "media_type": cls.MediaType.GEO_LOCATION,
+                "media_type": constants.MediaType.GEO_LOCATION,
                 "media_id": location.id,
                 "scrobble_data": scrobble_data,
             },
@@ -1254,7 +1223,7 @@ class Scrobble(TimeStampedModel):
                 f"[scrobbling] finished - no existing location scrobbles found",
                 extra={
                     "media_id": location.id,
-                    "media_type": cls.MediaType.GEO_LOCATION,
+                    "media_type": constants.MediaType.GEO_LOCATION,
                 },
             )
             return cls.create(scrobble_data)
@@ -1263,7 +1232,7 @@ class Scrobble(TimeStampedModel):
             logger.info(
                 f"[scrobbling] finished - same location - not moved",
                 extra={
-                    "media_type": cls.MediaType.GEO_LOCATION,
+                    "media_type": constants.MediaType.GEO_LOCATION,
                     "media_id": location.id,
                     "scrobble_id": scrobble.id,
                     "scrobble_media_id": scrobble.media_obj.id,
@@ -1277,7 +1246,7 @@ class Scrobble(TimeStampedModel):
             extra={
                 "scrobble_id": scrobble.id,
                 "scrobble_media_id": scrobble.media_obj.id,
-                "media_type": cls.MediaType.GEO_LOCATION,
+                "media_type": constants.MediaType.GEO_LOCATION,
                 "media_id": location.id,
                 "has_moved": has_moved,
             },
@@ -1288,7 +1257,7 @@ class Scrobble(TimeStampedModel):
                 extra={
                     "scrobble_id": scrobble.id,
                     "media_id": location.id,
-                    "media_type": cls.MediaType.GEO_LOCATION,
+                    "media_type": constants.MediaType.GEO_LOCATION,
                     "old_media__id": scrobble.media_obj.id,
                 },
             )
@@ -1305,7 +1274,7 @@ class Scrobble(TimeStampedModel):
                 f"[scrobbling] finished - found existing named location",
                 extra={
                     "media_id": location.id,
-                    "media_type": cls.MediaType.GEO_LOCATION,
+                    "media_type": constants.MediaType.GEO_LOCATION,
                     "old_media_id": existing_location.id,
                 },
             )
@@ -1319,7 +1288,7 @@ class Scrobble(TimeStampedModel):
                 "scrobble_id": scrobble.id,
                 "media_id": location.id,
                 "scrobble_data": scrobble_data,
-                "media_type": cls.MediaType.GEO_LOCATION,
+                "media_type": constants.MediaType.GEO_LOCATION,
                 "source": scrobble_data.get("source"),
             },
         )

+ 28 - 27
vrobbler/apps/scrobbles/scrobblers.py

@@ -26,14 +26,15 @@ from scrobbles.constants import (
     JELLYFIN_AUDIO_ITEM_TYPES,
     MANUAL_SCROBBLE_FNS,
     SCROBBLE_CONTENT_URLS,
+    MediaType,
 )
 from scrobbles.models import Scrobble
 from scrobbles.notifications import ScrobbleNtfyNotification
 from scrobbles.utils import (
     convert_to_seconds,
     extract_domain,
-    remove_last_part,
     next_url_if_exists,
+    remove_last_part,
 )
 from sports.models import SportEvent
 from sports.thesportsdb import lookup_event_from_thesportsdb
@@ -48,9 +49,9 @@ logger = logging.getLogger(__name__)
 
 
 def mopidy_scrobble_media(post_data: dict, user_id: int) -> Scrobble:
-    media_type = Scrobble.MediaType.TRACK
+    media_type = MediaType.TRACK
     if "podcast" in post_data.get("mopidy_uri", ""):
-        media_type = Scrobble.MediaType.PODCAST_EPISODE
+        media_type = MediaType.PODCAST_EPISODE
 
     logger.info(
         "[mopidy_webhook] called",
@@ -61,7 +62,7 @@ def mopidy_scrobble_media(post_data: dict, user_id: int) -> Scrobble:
         },
     )
 
-    if media_type == Scrobble.MediaType.PODCAST_EPISODE:
+    if media_type == MediaType.PODCAST_EPISODE:
         parsed_data = parse_mopidy_uri(post_data.get("mopidy_uri", ""))
         if not parsed_data:
             logger.warning("Tried to scrobble podcast but no uri found", extra={"post_data": post_data})
@@ -97,9 +98,9 @@ def mopidy_scrobble_media(post_data: dict, user_id: int) -> Scrobble:
 def jellyfin_scrobble_media(
     post_data: dict, user_id: int
 ) -> Optional[Scrobble]:
-    media_type = Scrobble.MediaType.VIDEO
+    media_type = MediaType.VIDEO
     if post_data.pop("ItemType", "") in JELLYFIN_AUDIO_ITEM_TYPES:
-        media_type = Scrobble.MediaType.TRACK
+        media_type = MediaType.TRACK
 
     null_position_on_progress = (
         post_data.get("PlaybackPosition") == "00:00:00"
@@ -122,7 +123,7 @@ def jellyfin_scrobble_media(
         post_data.get(JELLYFIN_POST_KEYS.get("PLAYBACK_POSITION_TICKS"), 1)
         / 10000000
     )
-    if media_type == Scrobble.MediaType.VIDEO:
+    if media_type == MediaType.VIDEO:
         imdb_id = post_data.get("Provider_imdb", "")
         media_obj = Video.find_or_create(imdb_id)
     else:
@@ -186,7 +187,7 @@ def manual_scrobble_video(
             "video_id": video.id,
             "user_id": user_id,
             "scrobble_dict": scrobble_dict,
-            "media_type": Scrobble.MediaType.VIDEO,
+            "media_type": MediaType.VIDEO,
         },
     )
 
@@ -225,7 +226,7 @@ def manual_scrobble_video_game(
                 extra={
                     "hltb_id": hltb_id,
                     "user_id": user_id,
-                    "media_type": Scrobble.MediaType.VIDEO_GAME,
+                    "media_type": MediaType.VIDEO_GAME,
                 },
             )
             return
@@ -246,7 +247,7 @@ def manual_scrobble_video_game(
             "videogame_id": game.id,
             "user_id": user_id,
             "scrobble_dict": scrobble_dict,
-            "media_type": Scrobble.MediaType.VIDEO_GAME,
+            "media_type": MediaType.VIDEO_GAME,
         },
     )
 
@@ -269,7 +270,7 @@ def manual_scrobble_book(
                 extra={
                     "title": title,
                     "user_id": user_id,
-                    "media_type": Scrobble.MediaType.BOOK,
+                    "media_type": MediaType.BOOK,
                 },
             )
             return
@@ -301,7 +302,7 @@ def manual_scrobble_book(
             "book_id": book.id,
             "user_id": user_id,
             "scrobble_dict": scrobble_dict,
-            "media_type": Scrobble.MediaType.BOOK,
+            "media_type": MediaType.BOOK,
         },
     )
 
@@ -341,7 +342,7 @@ def manual_scrobble_board_game(
             "boardgame_id": boardgame.id,
             "user_id": user_id,
             "scrobble_dict": scrobble_dict,
-            "media_type": Scrobble.MediaType.BOARD_GAME,
+            "media_type": MediaType.BOARD_GAME,
         },
     )
 
@@ -596,7 +597,7 @@ def todoist_scrobble_update_task(
             extra={
                 "todoist_note": todoist_note,
                 "user_id": user_id,
-                "media_type": Scrobble.MediaType.TASK,
+                "media_type": MediaType.TASK,
             },
         )
         return
@@ -610,7 +611,7 @@ def todoist_scrobble_update_task(
         extra={
             "todoist_note": todoist_note,
             "user_id": user_id,
-            "media_type": Scrobble.MediaType.TASK,
+            "media_type": MediaType.TASK,
         },
     )
 
@@ -691,7 +692,7 @@ def todoist_scrobble_task(
             "task_id": task.id,
             "user_id": user_id,
             "scrobble_dict": scrobble_dict,
-            "media_type": Scrobble.MediaType.TASK,
+            "media_type": MediaType.TASK,
         },
     )
     scrobble = Scrobble.create_or_update(task, user_id, scrobble_dict)
@@ -714,7 +715,7 @@ def emacs_scrobble_update_task(
             extra={
                 "emacs_notes": emacs_notes,
                 "user_id": user_id,
-                "media_type": Scrobble.MediaType.TASK,
+                "media_type": MediaType.TASK,
             },
         )
         return
@@ -743,7 +744,7 @@ def emacs_scrobble_update_task(
             extra={
                 "emacs_note": emacs_notes,
                 "user_id": user_id,
-                "media_type": Scrobble.MediaType.TASK,
+                "media_type": MediaType.TASK,
             },
         )
 
@@ -832,7 +833,7 @@ def emacs_scrobble_task(
             "task_id": task.id,
             "user_id": user_id,
             "scrobble_dict": scrobble_dict,
-            "media_type": Scrobble.MediaType.TASK,
+            "media_type": MediaType.TASK,
         },
     )
     scrobble = Scrobble.create_or_update(task, user_id, scrobble_dict)
@@ -863,7 +864,7 @@ def manual_scrobble_task(url: str, user_id: int, source: str = "Vrobbler", actio
             "task_id": task.id,
             "user_id": user_id,
             "scrobble_dict": scrobble_dict,
-            "media_type": Scrobble.MediaType.TASK,
+            "media_type": MediaType.TASK,
         },
     )
     scrobble = Scrobble.create_or_update(task, user_id, scrobble_dict)
@@ -887,7 +888,7 @@ def manual_scrobble_webpage(
             "webpage_id": webpage.id,
             "user_id": user_id,
             "scrobble_dict": scrobble_dict,
-            "media_type": Scrobble.MediaType.WEBPAGE,
+            "media_type": MediaType.WEBPAGE,
         },
     )
 
@@ -910,7 +911,7 @@ def gpslogger_scrobble_location(data_dict: dict, user_id: int) -> Scrobble:
         "user_id": user_id,
         "timestamp": timestamp,
         "source": "GPSLogger",
-        "media_type": Scrobble.MediaType.GEO_LOCATION,
+        "media_type": MediaType.GEO_LOCATION,
     }
 
     scrobble = Scrobble.create_or_update_location(
@@ -944,7 +945,7 @@ def gpslogger_scrobble_location(data_dict: dict, user_id: int) -> Scrobble:
             "user_id": user_id,
             "timestamp": extra_data.get("timestamp"),
             "raw_timestamp": data_dict.get("time"),
-            "media_type": Scrobble.MediaType.GEO_LOCATION,
+            "media_type": MediaType.GEO_LOCATION,
         },
     )
 
@@ -987,7 +988,7 @@ def web_scrobbler_scrobble_video_or_song(
             "episode_id": episode.id if episode else None,
             "user_id": user_id,
             "scrobble_dict": mopidy_data,
-            "media_type": Scrobble.MediaType.PODCAST_EPISODE,
+            "media_type": MediaType.PODCAST_EPISODE,
         },
     )
 
@@ -1018,7 +1019,7 @@ def manual_scrobble_beer(
             "beer_id": beer.id,
             "user_id": user_id,
             "scrobble_dict": scrobble_dict,
-            "media_type": Scrobble.MediaType.BEER,
+            "media_type": MediaType.BEER,
         },
     )
 
@@ -1047,7 +1048,7 @@ def manual_scrobble_puzzle(
             "puzzle_id": puzzle.id,
             "user_id": user_id,
             "scrobble_dict": scrobble_dict,
-            "media_type": Scrobble.MediaType.PUZZLE,
+            "media_type": MediaType.PUZZLE,
         },
     )
 
@@ -1077,7 +1078,7 @@ def manual_scrobble_brickset(
             "brickset_id": brickset.id,
             "user_id": user_id,
             "scrobble_dict": scrobble_dict,
-            "media_type": Scrobble.MediaType.BRICKSET,
+            "media_type": MediaType.BRICKSET,
         },
     )
 

+ 3 - 3
vrobbler/apps/scrobbles/utils.py

@@ -1,6 +1,5 @@
 import hashlib
 import logging
-import requests
 import re
 from datetime import date, datetime, timedelta
 from typing import TYPE_CHECKING, Optional
@@ -9,6 +8,7 @@ from zoneinfo import ZoneInfo
 
 import pendulum
 import pytz
+import requests
 from django.apps import apps
 from django.contrib.auth import get_user_model
 from django.db import models
@@ -17,7 +17,7 @@ from django.db.models.functions import Cast, TruncDate
 from django.utils import timezone
 from profiles.models import UserProfile
 from profiles.utils import now_user_timezone
-from scrobbles.constants import LONG_PLAY_MEDIA
+from scrobbles.constants import LONG_PLAY_MEDIA, MediaType
 from scrobbles.notifications import (
     MoodNtfyNotification,
     ScrobbleNtfyNotification,
@@ -320,7 +320,7 @@ def send_stop_notifications_for_in_progress_scrobbles() -> int:
 
     scrobbles_in_progress_qs = Scrobble.objects.filter(
         played_to_completion=False, in_progress=True
-    ).exclude(media_type=Scrobble.MediaType.GEO_LOCATION)
+    ).exclude(media_type=MediaType.GEO_LOCATION)
 
     notifications_sent = 0
     for scrobble in scrobbles_in_progress_qs:

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

@@ -37,6 +37,7 @@ from scrobbles.constants import (
     LONG_PLAY_MEDIA,
     MANUAL_SCROBBLE_FNS,
     PLAY_AGAIN_MEDIA,
+    MediaType,
 )
 from scrobbles.export import export_scrobbles
 from scrobbles.forms import ExportScrobbleForm, ScrobbleForm
@@ -625,7 +626,7 @@ def scrobble_start(request, uuid):
 
     if (
         user.profile.redirect_to_webpage
-        and (media_obj.__class__.__name__ == Scrobble.MediaType.WEBPAGE or media_obj.__class__.__name__ == Scrobble.MediaType.BOOK)
+        and (media_obj.__class__.__name__ == MediaType.WEBPAGE or media_obj.__class__.__name__ == MediaType.BOOK)
     ):
         logger.info(f"Redirecting to {media_obj} detail page")
         return HttpResponseRedirect(media_obj.url)

+ 22 - 0
vrobbler/apps/sports/migrations/0017_add_scrobblableitem_ptr.py

@@ -0,0 +1,22 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("sports", "0016_remove_sportevent_run_time_seconds_and_more"),
+        ("scrobbles", "0071_scrobblableitem"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="sportevent",
+            name="scrobblableitem_ptr",
+            field=models.OneToOneField(
+                to="scrobbles.Scrobblableitem",
+                null=True,
+                blank=True,
+                on_delete=django.db.models.deletion.CASCADE,
+            ),
+        ),
+    ]

+ 22 - 0
vrobbler/apps/tasks/migrations/0006_add_scrobblableitem_ptr.py

@@ -0,0 +1,22 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("tasks", "0005_remove_task_run_time_seconds_and_more"),
+        ("scrobbles", "0071_scrobblableitem"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="task",
+            name="scrobblableitem_ptr",
+            field=models.OneToOneField(
+                to="scrobbles.Scrobblableitem",
+                null=True,
+                blank=True,
+                on_delete=django.db.models.deletion.CASCADE,
+            ),
+        ),
+    ]

+ 22 - 0
vrobbler/apps/trails/migrations/0007_add_scrobblableitem_ptr.py

@@ -0,0 +1,22 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("trails", "0006_remove_trail_run_time_seconds_and_more"),
+        ("scrobbles", "0071_scrobblableitem"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="trail",
+            name="scrobblableitem_ptr",
+            field=models.OneToOneField(
+                to="scrobbles.Scrobblableitem",
+                null=True,
+                blank=True,
+                on_delete=django.db.models.deletion.CASCADE,
+            ),
+        ),
+    ]

+ 22 - 0
vrobbler/apps/videogames/migrations/0014_add_scrobblableitem_ptr.py

@@ -0,0 +1,22 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("videogames", "0013_remove_videogame_run_time_seconds_and_more"),
+        ("scrobbles", "0071_scrobblableitem"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="videogame",
+            name="scrobblableitem_ptr",
+            field=models.OneToOneField(
+                to="scrobbles.Scrobblableitem",
+                null=True,
+                blank=True,
+                on_delete=django.db.models.deletion.CASCADE,
+            ),
+        ),
+    ]

+ 3 - 1
vrobbler/apps/videogames/retroarch.py

@@ -9,10 +9,12 @@ from dateutil.parser import ParserError, parse
 from django.apps import apps
 from django.conf import settings
 from django.contrib.auth import get_user_model
+from scrobbles.constants import MediaType
 from scrobbles.utils import convert_to_seconds
 from videogames.models import VideoGame
 from videogames.scrapers import scrape_game_name_from_adb
 from videogames.utils import get_or_create_videogame
+
 from vrobbler.apps.scrobbles.exceptions import UserNotFound
 from vrobbler.apps.videogames.exceptions import GameNotFound
 
@@ -169,7 +171,7 @@ def import_retroarch_lrtl_files(playlog_path: str, user_id: int) -> List[dict]:
                 long_play_complete=long_play_complete,
                 user_id=user_id,
                 source="Retroarch",
-                media_type=Scrobble.MediaType.VIDEO_GAME,
+                media_type=MediaType.VIDEO_GAME,
             )
         )
     created_scrobbles = Scrobble.objects.bulk_create(new_scrobbles)

+ 22 - 0
vrobbler/apps/videos/migrations/0025_add_scrobblableitem_ptr.py

@@ -0,0 +1,22 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("videos", "0024_remove_video_run_time_seconds_and_more"),
+        ("scrobbles", "0071_scrobblableitem"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="video",
+            name="scrobblableitem_ptr",
+            field=models.OneToOneField(
+                to="scrobbles.Scrobblableitem",
+                null=True,
+                blank=True,
+                on_delete=django.db.models.deletion.CASCADE,
+            ),
+        ),
+    ]

+ 22 - 0
vrobbler/apps/webpages/migrations/0007_add_scrobblableitem_ptr.py

@@ -0,0 +1,22 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("webpages", "0006_remove_webpage_run_time_seconds_and_more"),
+        ("scrobbles", "0071_scrobblableitem"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="webpage",
+            name="scrobblableitem_ptr",
+            field=models.OneToOneField(
+                to="scrobbles.Scrobblableitem",
+                null=True,
+                blank=True,
+                on_delete=django.db.models.deletion.CASCADE,
+            ),
+        ),
+    ]