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):
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}")

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

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

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:
"""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,
}

View File

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

View File

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