Add podcasts as new media type

This commit is contained in:
2023-01-12 13:52:30 -05:00
parent f435e60b80
commit 8517212d0e
18 changed files with 599 additions and 38 deletions

View File

@ -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'),
),
]

View File

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

View File

View File

@ -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",)

View File

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

View File

@ -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,
},
),
]

View File

@ -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',
),
),
]

View File

@ -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),
),
]

View File

@ -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),
),
]

View File

@ -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

View File

@ -3,12 +3,13 @@ from django.contrib import admin
from scrobbles.models import Scrobble from scrobbles.models import Scrobble
@admin.register(Scrobble)
class ScrobbleAdmin(admin.ModelAdmin): class ScrobbleAdmin(admin.ModelAdmin):
date_hierarchy = "timestamp" date_hierarchy = "timestamp"
list_display = ( list_display = (
"timestamp", "timestamp",
"video", "media_name",
"track", "media_type",
"source", "source",
"playback_position", "playback_position",
"in_progress", "in_progress",
@ -16,5 +17,18 @@ class ScrobbleAdmin(admin.ModelAdmin):
list_filter = ("in_progress", "source", "track__artist") list_filter = ("in_progress", "source", "track__artist")
ordering = ("-timestamp",) 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"

View File

@ -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',
),
),
]

View File

@ -1,6 +1,7 @@
import logging import logging
from datetime import timedelta from datetime import timedelta
from typing import Optional from typing import Optional
from uuid import uuid4
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
@ -8,6 +9,7 @@ from django.db import models
from django.utils import timezone from django.utils import timezone
from django_extensions.db.models import TimeStampedModel from django_extensions.db.models import TimeStampedModel
from music.models import Track from music.models import Track
from podcasts.models import Episode
from videos.models import Video from videos.models import Video
logger = logging.getLogger(__name__) 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') 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): class Scrobble(TimeStampedModel):
video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL) video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL)
track = models.ForeignKey(Track, 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 = models.ForeignKey(
User, blank=True, null=True, on_delete=models.DO_NOTHING User, blank=True, null=True, on_delete=models.DO_NOTHING
) )
@ -134,6 +149,28 @@ class Scrobble(TimeStampedModel):
scrobble, backoff, wait_period, scrobble_data 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 @classmethod
def update_or_create( def update_or_create(
cls, cls,

View File

@ -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

View File

@ -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: def convert_to_seconds(run_time: str) -> int:
"""Jellyfin sends run time as 00:00:00 string. We want the run time to """Jellyfin sends run time as 00:00:00 string. We want the run time to
actually be in seconds so we'll convert it""" 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_list = run_time.split(":")
run_time = (int(run_time_list[1]) * 60) + int(run_time_list[2]) run_time = (int(run_time_list[1]) * 60) + int(run_time_list[2])
return int(run_time) 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,
}

View File

@ -27,6 +27,7 @@ from vrobbler.apps.music.aggregators import (
top_tracks, top_tracks,
week_of_scrobbles, week_of_scrobbles,
) )
from scrobbles.scrobblers import scrobble_podcast, scrobble_track
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -187,41 +188,12 @@ def mopidy_websocket(request):
# For making things easier to build new input processors # For making things easier to build new input processors
if getattr(settings, "DUMP_REQUEST_DATA", False): if getattr(settings, "DUMP_REQUEST_DATA", False):
json_data = json.dumps(data_dict, indent=4) json_data = json.dumps(data_dict, indent=4)
logger.debug(f"{json_data}")
artist_dict = { if 'podcast' in data_dict.get('mopidy_uri'):
"name": data_dict.get("artist", None), scrobble = scrobble_podcast(data_dict, request.user.id)
"musicbrainz_id": data_dict.get("musicbrainz_artist_id", None), else:
} scrobble = scrobble_track(data_dict, request.user.id)
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 not scrobble: if not scrobble:
return Response({}, status=status.HTTP_400_BAD_REQUEST) return Response({}, status=status.HTTP_400_BAD_REQUEST)

View File

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