diff --git a/vrobbler/apps/music/migrations/0007_alter_album_artists.py b/vrobbler/apps/music/migrations/0007_alter_album_artists.py new file mode 100644 index 0000000..e4f482c --- /dev/null +++ b/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'), + ), + ] diff --git a/vrobbler/apps/music/models.py b/vrobbler/apps/music/models.py index 654201c..814a9aa 100644 --- a/vrobbler/apps/music/models.py +++ b/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}") diff --git a/vrobbler/apps/podcasts/__init__.py b/vrobbler/apps/podcasts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vrobbler/apps/podcasts/admin.py b/vrobbler/apps/podcasts/admin.py new file mode 100644 index 0000000..1624471 --- /dev/null +++ b/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",) diff --git a/vrobbler/apps/podcasts/apps.py b/vrobbler/apps/podcasts/apps.py new file mode 100644 index 0000000..f6e2f1e --- /dev/null +++ b/vrobbler/apps/podcasts/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class PodcastsConfig(AppConfig): + name = 'podcasts' diff --git a/vrobbler/apps/podcasts/migrations/0001_initial.py b/vrobbler/apps/podcasts/migrations/0001_initial.py new file mode 100644 index 0000000..51b27e1 --- /dev/null +++ b/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, + }, + ), + ] diff --git a/vrobbler/apps/podcasts/migrations/0002_episode_run_time_episode_run_time_ticks_and_more.py b/vrobbler/apps/podcasts/migrations/0002_episode_run_time_episode_run_time_ticks_and_more.py new file mode 100644 index 0000000..286d525 --- /dev/null +++ b/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', + ), + ), + ] diff --git a/vrobbler/apps/podcasts/migrations/0003_episode_pub_date.py b/vrobbler/apps/podcasts/migrations/0003_episode_pub_date.py new file mode 100644 index 0000000..6c0fdc5 --- /dev/null +++ b/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), + ), + ] diff --git a/vrobbler/apps/podcasts/migrations/0004_episode_number.py b/vrobbler/apps/podcasts/migrations/0004_episode_number.py new file mode 100644 index 0000000..99fce73 --- /dev/null +++ b/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), + ), + ] diff --git a/vrobbler/apps/podcasts/migrations/__init__.py b/vrobbler/apps/podcasts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vrobbler/apps/podcasts/models.py b/vrobbler/apps/podcasts/models.py new file mode 100644 index 0000000..5b1e0c9 --- /dev/null +++ b/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 diff --git a/vrobbler/apps/scrobbles/admin.py b/vrobbler/apps/scrobbles/admin.py index 117e9e6..b9f0285 100644 --- a/vrobbler/apps/scrobbles/admin.py +++ b/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" diff --git a/vrobbler/apps/scrobbles/migrations/0007_scrobble_podcast_episode.py b/vrobbler/apps/scrobbles/migrations/0007_scrobble_podcast_episode.py new file mode 100644 index 0000000..f365ca0 --- /dev/null +++ b/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', + ), + ), + ] diff --git a/vrobbler/apps/scrobbles/models.py b/vrobbler/apps/scrobbles/models.py index 4faf61e..96671d1 100644 --- a/vrobbler/apps/scrobbles/models.py +++ b/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, diff --git a/vrobbler/apps/scrobbles/scrobblers.py b/vrobbler/apps/scrobbles/scrobblers.py new file mode 100644 index 0000000..f6dce47 --- /dev/null +++ b/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 diff --git a/vrobbler/apps/scrobbles/utils.py b/vrobbler/apps/scrobbles/utils.py index e9fe569..a297b0e 100644 --- a/vrobbler/apps/scrobbles/utils.py +++ b/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, + } diff --git a/vrobbler/apps/scrobbles/views.py b/vrobbler/apps/scrobbles/views.py index 99bd92c..06ca3fc 100644 --- a/vrobbler/apps/scrobbles/views.py +++ b/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) diff --git a/vrobbler/settings.py b/vrobbler/settings.py index a15e814..138aad6 100644 --- a/vrobbler/settings.py +++ b/vrobbler/settings.py @@ -86,6 +86,7 @@ INSTALLED_APPS = [ "scrobbles", "videos", "music", + "podcasts", "rest_framework", "allauth", "allauth.account",