Jelajahi Sumber

Add podcasts as new media type

Colin Powell 2 tahun lalu
induk
melakukan
8517212d0e

+ 18 - 0
vrobbler/apps/music/migrations/0007_alter_album_artists.py

@@ -0,0 +1,18 @@
+# Generated by Django 4.1.5 on 2023-01-12 17:17
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('music', '0006_album_artists'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='album',
+            name='artists',
+            field=models.ManyToManyField(to='music.artist'),
+        ),
+    ]

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

@@ -32,7 +32,7 @@ class Artist(TimeStampedModel):
 class Album(TimeStampedModel):
     uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
     name = models.CharField(max_length=255)
-    artists = models.ManyToManyField(Artist, **BNULL)
+    artists = models.ManyToManyField(Artist)
     year = models.IntegerField(**BNULL)
     musicbrainz_id = models.CharField(max_length=255, unique=True, **BNULL)
     musicbrainz_releasegroup_id = models.CharField(max_length=255, **BNULL)
@@ -124,6 +124,7 @@ class Track(TimeStampedModel):
                 f"No artist or artist musicbrainz ID found in message from source, not scrobbling"
             )
             return
+
         artist, artist_created = Artist.objects.get_or_create(**artist_dict)
         if artist_created:
             logger.debug(f"Created new album {artist}")

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


+ 33 - 0
vrobbler/apps/podcasts/admin.py

@@ -0,0 +1,33 @@
+#!/usr/bin/env python3
+from django.contrib import admin
+from podcasts.models import Episode, Podcast, Producer
+
+
+@admin.register(Producer)
+class ProducerAdmin(admin.ModelAdmin):
+    date_hierarchy = "created"
+    list_display = ("name",)
+    ordering = ("name",)
+
+
+@admin.register(Podcast)
+class PodcastAdmin(admin.ModelAdmin):
+    date_hierarchy = "created"
+    list_display = (
+        "name",
+        "producer",
+        "active",
+    )
+    ordering = ("name",)
+
+
+@admin.register(Episode)
+class EpisodeAdmin(admin.ModelAdmin):
+    date_hierarchy = "created"
+    list_display = (
+        "title",
+        "podcast",
+        "run_time",
+    )
+    list_filter = ("podcast",)
+    ordering = ("-created",)

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

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

+ 158 - 0
vrobbler/apps/podcasts/migrations/0001_initial.py

@@ -0,0 +1,158 @@
+# Generated by Django 4.1.5 on 2023-01-12 17:18
+
+from django.db import migrations, models
+import django.db.models.deletion
+import django_extensions.db.fields
+import uuid
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = []
+
+    operations = [
+        migrations.CreateModel(
+            name='Producer',
+            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)),
+                (
+                    'uuid',
+                    models.UUIDField(
+                        blank=True,
+                        default=uuid.uuid4,
+                        editable=False,
+                        null=True,
+                    ),
+                ),
+            ],
+            options={
+                'get_latest_by': 'modified',
+                'abstract': False,
+            },
+        ),
+        migrations.CreateModel(
+            name='Podcast',
+            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)),
+                (
+                    'uuid',
+                    models.UUIDField(
+                        blank=True,
+                        default=uuid.uuid4,
+                        editable=False,
+                        null=True,
+                    ),
+                ),
+                ('active', models.BooleanField(default=True)),
+                ('url', models.URLField(blank=True, null=True)),
+                (
+                    'producer',
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.DO_NOTHING,
+                        to='podcasts.producer',
+                    ),
+                ),
+            ],
+            options={
+                'get_latest_by': 'modified',
+                'abstract': False,
+            },
+        ),
+        migrations.CreateModel(
+            name='Episode',
+            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(max_length=255)),
+                (
+                    'uuid',
+                    models.UUIDField(
+                        blank=True,
+                        default=uuid.uuid4,
+                        editable=False,
+                        null=True,
+                    ),
+                ),
+                (
+                    'mopidy_uri',
+                    models.CharField(blank=True, max_length=255, null=True),
+                ),
+                (
+                    'podcast',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.DO_NOTHING,
+                        to='podcasts.producer',
+                    ),
+                ),
+            ],
+            options={
+                'get_latest_by': 'modified',
+                'abstract': False,
+            },
+        ),
+    ]

+ 32 - 0
vrobbler/apps/podcasts/migrations/0002_episode_run_time_episode_run_time_ticks_and_more.py

@@ -0,0 +1,32 @@
+# Generated by Django 4.1.5 on 2023-01-12 17:48
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('podcasts', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='episode',
+            name='run_time',
+            field=models.CharField(blank=True, max_length=8, null=True),
+        ),
+        migrations.AddField(
+            model_name='episode',
+            name='run_time_ticks',
+            field=models.PositiveBigIntegerField(blank=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='episode',
+            name='podcast',
+            field=models.ForeignKey(
+                on_delete=django.db.models.deletion.DO_NOTHING,
+                to='podcasts.podcast',
+            ),
+        ),
+    ]

+ 18 - 0
vrobbler/apps/podcasts/migrations/0003_episode_pub_date.py

@@ -0,0 +1,18 @@
+# Generated by Django 4.1.5 on 2023-01-12 18:08
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('podcasts', '0002_episode_run_time_episode_run_time_ticks_and_more'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='episode',
+            name='pub_date',
+            field=models.DateField(blank=True, null=True),
+        ),
+    ]

+ 18 - 0
vrobbler/apps/podcasts/migrations/0004_episode_number.py

@@ -0,0 +1,18 @@
+# Generated by Django 4.1.5 on 2023-01-12 18:32
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('podcasts', '0003_episode_pub_date'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='episode',
+            name='number',
+            field=models.IntegerField(blank=True, null=True),
+        ),
+    ]

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


+ 87 - 0
vrobbler/apps/podcasts/models.py

@@ -0,0 +1,87 @@
+import logging
+from typing import Dict, Optional
+from uuid import uuid4
+
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+from django_extensions.db.models import TimeStampedModel
+
+logger = logging.getLogger(__name__)
+BNULL = {"blank": True, "null": True}
+
+
+class Producer(TimeStampedModel):
+    name = models.CharField(max_length=255)
+    uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
+
+    def __str__(self):
+        return f"{self.name}"
+
+
+class Podcast(TimeStampedModel):
+    name = models.CharField(max_length=255)
+    uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
+    producer = models.ForeignKey(
+        Producer, on_delete=models.DO_NOTHING, **BNULL
+    )
+    active = models.BooleanField(default=True)
+    url = models.URLField(**BNULL)
+
+    def __str__(self):
+        return f"{self.name}"
+
+
+class Episode(TimeStampedModel):
+    title = models.CharField(max_length=255)
+    uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
+    podcast = models.ForeignKey(Podcast, on_delete=models.DO_NOTHING)
+    number = models.IntegerField(**BNULL)
+    pub_date = models.DateField(**BNULL)
+    run_time = models.CharField(max_length=8, **BNULL)
+    run_time_ticks = models.PositiveBigIntegerField(**BNULL)
+    mopidy_uri = models.CharField(max_length=255, **BNULL)
+
+    def __str__(self):
+        return f"{self.title}"
+
+    @classmethod
+    def find_or_create(
+        cls, podcast_dict: Dict, producer_dict: Dict, episode_dict: Dict
+    ) -> Optional["Episode"]:
+        """Given a data dict from Mopidy, finds or creates a podcast and
+        producer before saving the epsiode so it can be scrobbled.
+
+        """
+        if not podcast_dict.get('name'):
+            logger.warning(f"No name from source for podcast, not scrobbling")
+            return
+
+        producer = None
+        if producer_dict.get('name'):
+            producer, producer_created = Producer.objects.get_or_create(
+                **producer_dict
+            )
+            if producer_created:
+                logger.debug(f"Created new producer {producer}")
+            else:
+                logger.debug(f"Found producer {producer}")
+
+        if producer:
+            podcast_dict["producer_id"] = producer.id
+        podcast, podcast_created = Podcast.objects.get_or_create(
+            **podcast_dict
+        )
+        if podcast_created:
+            logger.debug(f"Created new podcast {podcast}")
+        else:
+            logger.debug(f"Found podcast {podcast}")
+
+        episode_dict['podcast_id'] = podcast.id
+
+        episode, created = cls.objects.get_or_create(**episode_dict)
+        if created:
+            logger.debug(f"Created new episode: {episode}")
+        else:
+            logger.debug(f"Found episode {episode}")
+
+        return episode

+ 17 - 3
vrobbler/apps/scrobbles/admin.py

@@ -3,12 +3,13 @@ from django.contrib import admin
 from scrobbles.models import Scrobble
 
 
+@admin.register(Scrobble)
 class ScrobbleAdmin(admin.ModelAdmin):
     date_hierarchy = "timestamp"
     list_display = (
         "timestamp",
-        "video",
-        "track",
+        "media_name",
+        "media_type",
         "source",
         "playback_position",
         "in_progress",
@@ -16,5 +17,18 @@ class ScrobbleAdmin(admin.ModelAdmin):
     list_filter = ("in_progress", "source", "track__artist")
     ordering = ("-timestamp",)
 
+    def media_name(self, obj):
+        if obj.video:
+            return obj.video
+        if obj.track:
+            return obj.track
+        if obj.podcast_episode:
+            return obj.podcast_episode
 
-admin.site.register(Scrobble, ScrobbleAdmin)
+    def media_type(self, obj):
+        if obj.video:
+            return "Video"
+        if obj.track:
+            return "Track"
+        if obj.podcast_episode:
+            return "Podcast"

+ 25 - 0
vrobbler/apps/scrobbles/migrations/0007_scrobble_podcast_episode.py

@@ -0,0 +1,25 @@
+# Generated by Django 4.1.5 on 2023-01-12 17:48
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('podcasts', '0002_episode_run_time_episode_run_time_ticks_and_more'),
+        ('scrobbles', '0006_scrobble_track_alter_scrobble_video'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='scrobble',
+            name='podcast_episode',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.DO_NOTHING,
+                to='podcasts.episode',
+            ),
+        ),
+    ]

+ 37 - 0
vrobbler/apps/scrobbles/models.py

@@ -1,6 +1,7 @@
 import logging
 from datetime import timedelta
 from typing import Optional
+from uuid import uuid4
 
 from django.conf import settings
 from django.contrib.auth import get_user_model
@@ -8,6 +9,7 @@ from django.db import models
 from django.utils import timezone
 from django_extensions.db.models import TimeStampedModel
 from music.models import Track
+from podcasts.models import Episode
 from videos.models import Video
 
 logger = logging.getLogger(__name__)
@@ -19,9 +21,22 @@ VIDEO_WAIT_PERIOD = getattr(settings, 'VIDEO_WAIT_PERIOD_DAYS')
 TRACK_WAIT_PERIOD = getattr(settings, 'MUSIC_WAIT_PERIOD_MINUTES')
 
 
+class ScrobblableMixin(TimeStampedModel):
+    uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
+    title = models.CharField(max_length=255, **BNULL)
+    run_time = models.CharField(max_length=8, **BNULL)
+    run_time_ticks = models.PositiveBigIntegerField(**BNULL)
+
+    class Meta:
+        abstract = True
+
+
 class Scrobble(TimeStampedModel):
     video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL)
     track = models.ForeignKey(Track, on_delete=models.DO_NOTHING, **BNULL)
+    podcast_episode = models.ForeignKey(
+        Episode, on_delete=models.DO_NOTHING, **BNULL
+    )
     user = models.ForeignKey(
         User, blank=True, null=True, on_delete=models.DO_NOTHING
     )
@@ -134,6 +149,28 @@ class Scrobble(TimeStampedModel):
             scrobble, backoff, wait_period, scrobble_data
         )
 
+    @classmethod
+    def create_or_update_for_podcast_episode(
+        cls, episode: "Episode", user_id: int, scrobble_data: dict
+    ) -> "Scrobble":
+        scrobble_data['podcast_episode_id'] = episode.id
+        scrobble = (
+            cls.objects.filter(podcast_episode=episode, user_id=user_id)
+            .order_by('-modified')
+            .first()
+        )
+        logger.debug(
+            f"Found existing scrobble for podcast {episode}, updating",
+            {"scrobble_data": scrobble_data},
+        )
+
+        backoff = timezone.now() + timedelta(seconds=TRACK_BACKOFF)
+        wait_period = timezone.now() + timedelta(minutes=TRACK_WAIT_PERIOD)
+
+        return cls.update_or_create(
+            scrobble, backoff, wait_period, scrobble_data
+        )
+
     @classmethod
     def update_or_create(
         cls,

+ 91 - 0
vrobbler/apps/scrobbles/scrobblers.py

@@ -0,0 +1,91 @@
+import logging
+from typing import Optional
+
+from django.utils import timezone
+from music.models import Track
+from podcasts.models import Episode
+from scrobbles.models import Scrobble
+from scrobbles.utils import parse_mopidy_uri
+
+logger = logging.getLogger(__name__)
+
+
+def scrobble_podcast(data_dict: dict, user_id: Optional[int]) -> Scrobble:
+    mopidy_uri = data_dict.get("mopidy_uri", "")
+    parsed_data = parse_mopidy_uri(mopidy_uri)
+
+    producer_dict = {"name": data_dict.get("artist")}
+
+    podcast_name = data_dict.get("album")
+    if not podcast_name:
+        podcast_name = parsed_data.get("podcast_name")
+    podcast_dict = {"name": podcast_name}
+
+    episode_name = data_dict.get("name")
+    if not episode_name or '.mp3' in episode_name:
+        episode_name = parsed_data.get("episode_filename")
+    episode_dict = {
+        "title": episode_name,
+        "run_time_ticks": data_dict.get("run_time_ticks"),
+        "run_time": data_dict.get("run_time"),
+        "number": parsed_data.get("episode_num"),
+        "pub_date": parsed_data.get("pub_date"),
+        "mopidy_uri": mopidy_uri,
+    }
+
+    episode = Episode.find_or_create(podcast_dict, producer_dict, episode_dict)
+
+    # Now we run off a scrobble
+    mopidy_data = {
+        "user_id": user_id,
+        "timestamp": timezone.now(),
+        "source": "Mopidy",
+        "status": data_dict.get("status"),
+    }
+
+    scrobble = None
+    if episode:
+        scrobble = Scrobble.create_or_update_for_podcast_episode(
+            episode, user_id, mopidy_data
+        )
+    return scrobble
+
+
+def scrobble_track(
+    data_dict: dict, user_id: Optional[int]
+) -> Optional[Scrobble]:
+    artist_dict = {
+        "name": data_dict.get("artist", None),
+        "musicbrainz_id": data_dict.get("musicbrainz_artist_id", None),
+    }
+
+    album_dict = {
+        "name": data_dict.get("album"),
+        "musicbrainz_id": data_dict.get("musicbrainz_album_id"),
+    }
+
+    track_dict = {
+        "title": data_dict.get("name"),
+        "run_time_ticks": data_dict.get("run_time_ticks"),
+        "run_time": data_dict.get("run_time"),
+    }
+
+    track = Track.find_or_create(artist_dict, album_dict, track_dict)
+
+    # Now we run off a scrobble
+    mopidy_data = {
+        "user_id": user_id,
+        "timestamp": timezone.now(),
+        "source": "Mopidy",
+        "status": data_dict.get("status"),
+    }
+
+    scrobble = None
+    if track:
+        # Jellyfin MB ids suck, so always overwrite with Mopidy if they're offering
+        track.musicbrainz_id = data_dict.get("musicbrainz_track_id")
+        track.save()
+        scrobble = Scrobble.create_or_update_for_track(
+            track, user_id, mopidy_data
+        )
+    return scrobble

+ 51 - 0
vrobbler/apps/scrobbles/utils.py

@@ -1,3 +1,11 @@
+import logging
+from typing import Any
+
+from dateutil.parser import ParserError, parse
+
+logger = logging.getLogger(__name__)
+
+
 def convert_to_seconds(run_time: str) -> int:
     """Jellyfin sends run time as 00:00:00 string. We want the run time to
     actually be in seconds so we'll convert it"""
@@ -5,3 +13,46 @@ def convert_to_seconds(run_time: str) -> int:
         run_time_list = run_time.split(":")
         run_time = (int(run_time_list[1]) * 60) + int(run_time_list[2])
     return int(run_time)
+
+
+def parse_mopidy_uri(uri: str) -> dict:
+    logger.debug(f"Parsing URI: {uri}")
+    parsed_uri = uri.split('/')
+
+    episode_str = parsed_uri.pop(-1).strip(".mp3")
+    podcast_str = parsed_uri.pop(-1).replace("%20", " ")
+    possible_date_str = episode_str[0:10]
+
+    try:
+        pub_date = parse(possible_date_str)
+    except ParserError:
+        pub_date = ""
+    logger.debug(f"Found pub date {pub_date} from Mopidy URI")
+
+    try:
+        if pub_date:
+            episode_num = int(episode_str.split('-')[3])
+        else:
+            episode_num = int(episode_str.split('-')[0])
+    except IndexError:
+        episode_num = None
+    except ValueError:
+        episode_num = None
+    logger.debug(f"Found episode num {episode_num} from Mopidy URI")
+
+    if pub_date:
+        episode_str = episode_str.strip(episode_str[:11])
+
+    if type(episode_num) is int:
+        episode_num_gap = len(str(episode_num)) + 1
+        episode_str = episode_str.strip(episode_str[:episode_num_gap])
+
+    episode_str = episode_str.replace('-', ' ')
+    logger.debug(f"Found episode name {episode_str} from Mopidy URI")
+
+    return {
+        'episode_filename': episode_str,
+        'episode_num': episode_num,
+        'podcast_name': podcast_str,
+        'pub_date': pub_date,
+    }

+ 6 - 34
vrobbler/apps/scrobbles/views.py

@@ -27,6 +27,7 @@ from vrobbler.apps.music.aggregators import (
     top_tracks,
     week_of_scrobbles,
 )
+from scrobbles.scrobblers import scrobble_podcast, scrobble_track
 
 logger = logging.getLogger(__name__)
 
@@ -187,41 +188,12 @@ def mopidy_websocket(request):
     # 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}")
 
-    artist_dict = {
-        "name": data_dict.get("artist", None),
-        "musicbrainz_id": data_dict.get("musicbrainz_artist_id", None),
-    }
-
-    album_dict = {
-        "name": data_dict.get("album"),
-        "musicbrainz_id": data_dict.get("musicbrainz_album_id"),
-    }
-
-    track_dict = {
-        "title": data_dict.get("name"),
-        "run_time_ticks": data_dict.get("run_time_ticks"),
-        "run_time": data_dict.get("run_time"),
-    }
-
-    track = Track.find_or_create(artist_dict, album_dict, track_dict)
-
-    # Now we run off a scrobble
-    mopidy_data = {
-        "user_id": request.user.id,
-        "timestamp": timezone.now(),
-        "source": "Mopidy",
-        "status": data_dict.get("status"),
-    }
-
-    scrobble = None
-    if track:
-        # Jellyfin MB ids suck, so always overwrite with Mopidy if they're offering
-        track.musicbrainz_id = data_dict.get("musicbrainz_track_id")
-        track.save()
-        scrobble = Scrobble.create_or_update_for_track(
-            track, request.user.id, mopidy_data
-        )
+    if 'podcast' in data_dict.get('mopidy_uri'):
+        scrobble = scrobble_podcast(data_dict, request.user.id)
+    else:
+        scrobble = scrobble_track(data_dict, request.user.id)
 
     if not scrobble:
         return Response({}, status=status.HTTP_400_BAD_REQUEST)

+ 1 - 0
vrobbler/settings.py

@@ -86,6 +86,7 @@ INSTALLED_APPS = [
     "scrobbles",
     "videos",
     "music",
+    "podcasts",
     "rest_framework",
     "allauth",
     "allauth.account",