Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 507b3aaaf2 | |||
| 879357473a | |||
| cc7d267494 | |||
| 685c99d023 | |||
| 69f596039d | |||
| cf55c9b464 | |||
| 8517212d0e | |||
| f435e60b80 | |||
| b51b189cd4 |
21
poetry.lock
generated
21
poetry.lock
generated
@ -625,6 +625,14 @@ category = "dev"
|
|||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.6"
|
python-versions = ">=3.6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "musicbrainzngs"
|
||||||
|
version = "0.7.1"
|
||||||
|
description = "Python bindings for the MusicBrainz NGS and the Cover Art Archive webservices"
|
||||||
|
category = "main"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mypy"
|
name = "mypy"
|
||||||
version = "0.961"
|
version = "0.961"
|
||||||
@ -1369,7 +1377,7 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools"
|
|||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "1.1"
|
lock-version = "1.1"
|
||||||
python-versions = "^3.8"
|
python-versions = "^3.8"
|
||||||
content-hash = "6105971e3adba942edffa16bd54f5822cdcabcd1e55dfecfc67410cf486a1a71"
|
content-hash = "f8d6b69cfb8ac53b7e03533e8fede68d323363bd73b9c7902d958dc433b1210e"
|
||||||
|
|
||||||
[metadata.files]
|
[metadata.files]
|
||||||
amqp = [
|
amqp = [
|
||||||
@ -1719,6 +1727,10 @@ mccabe = [
|
|||||||
{file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"},
|
{file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"},
|
||||||
{file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"},
|
{file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"},
|
||||||
]
|
]
|
||||||
|
musicbrainzngs = [
|
||||||
|
{file = "musicbrainzngs-0.7.1-py2.py3-none-any.whl", hash = "sha256:e841a8f975104c0a72290b09f59326050194081a5ae62ee512f41915090e1a10"},
|
||||||
|
{file = "musicbrainzngs-0.7.1.tar.gz", hash = "sha256:ab1c0100fd0b305852e65f2ed4113c6de12e68afd55186987b8ed97e0f98e627"},
|
||||||
|
]
|
||||||
mypy = [
|
mypy = [
|
||||||
{file = "mypy-0.961-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:697540876638ce349b01b6786bc6094ccdaba88af446a9abb967293ce6eaa2b0"},
|
{file = "mypy-0.961-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:697540876638ce349b01b6786bc6094ccdaba88af446a9abb967293ce6eaa2b0"},
|
||||||
{file = "mypy-0.961-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b117650592e1782819829605a193360a08aa99f1fc23d1d71e1a75a142dc7e15"},
|
{file = "mypy-0.961-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b117650592e1782819829605a193360a08aa99f1fc23d1d71e1a75a142dc7e15"},
|
||||||
@ -1769,6 +1781,13 @@ pbr = [
|
|||||||
{file = "pbr-5.11.0.tar.gz", hash = "sha256:b97bc6695b2aff02144133c2e7399d5885223d42b7912ffaec2ca3898e673bfe"},
|
{file = "pbr-5.11.0.tar.gz", hash = "sha256:b97bc6695b2aff02144133c2e7399d5885223d42b7912ffaec2ca3898e673bfe"},
|
||||||
]
|
]
|
||||||
pillow = [
|
pillow = [
|
||||||
|
{file = "Pillow-9.4.0-1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1b4b4e9dda4f4e4c4e6896f93e84a8f0bcca3b059de9ddf67dac3c334b1195e1"},
|
||||||
|
{file = "Pillow-9.4.0-1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:fb5c1ad6bad98c57482236a21bf985ab0ef42bd51f7ad4e4538e89a997624e12"},
|
||||||
|
{file = "Pillow-9.4.0-1-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:f0caf4a5dcf610d96c3bd32932bfac8aee61c96e60481c2a0ea58da435e25acd"},
|
||||||
|
{file = "Pillow-9.4.0-1-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:3f4cc516e0b264c8d4ccd6b6cbc69a07c6d582d8337df79be1e15a5056b258c9"},
|
||||||
|
{file = "Pillow-9.4.0-1-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:b8c2f6eb0df979ee99433d8b3f6d193d9590f735cf12274c108bd954e30ca858"},
|
||||||
|
{file = "Pillow-9.4.0-1-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:b70756ec9417c34e097f987b4d8c510975216ad26ba6e57ccb53bc758f490dab"},
|
||||||
|
{file = "Pillow-9.4.0-1-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:43521ce2c4b865d385e78579a082b6ad1166ebed2b1a2293c3be1d68dd7ca3b9"},
|
||||||
{file = "Pillow-9.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:2968c58feca624bb6c8502f9564dd187d0e1389964898f5e9e1fbc8533169157"},
|
{file = "Pillow-9.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:2968c58feca624bb6c8502f9564dd187d0e1389964898f5e9e1fbc8533169157"},
|
||||||
{file = "Pillow-9.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c5c1362c14aee73f50143d74389b2c158707b4abce2cb055b7ad37ce60738d47"},
|
{file = "Pillow-9.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c5c1362c14aee73f50143d74389b2c158707b4abce2cb055b7ad37ce60738d47"},
|
||||||
{file = "Pillow-9.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd752c5ff1b4a870b7661234694f24b1d2b9076b8bf337321a814c612665f343"},
|
{file = "Pillow-9.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd752c5ff1b4a870b7661234694f24b1d2b9076b8bf337321a814c612665f343"},
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "vrobbler"
|
name = "vrobbler"
|
||||||
version = "0.3.0"
|
version = "0.4.0"
|
||||||
description = ""
|
description = ""
|
||||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||||
|
|
||||||
@ -27,6 +27,7 @@ django-markdownify = "^0.9.1"
|
|||||||
gunicorn = "^20.1.0"
|
gunicorn = "^20.1.0"
|
||||||
django-simple-history = "^3.1.1"
|
django-simple-history = "^3.1.1"
|
||||||
whitenoise = "^6.3.0"
|
whitenoise = "^6.3.0"
|
||||||
|
musicbrainzngs = "^0.7.1"
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
Werkzeug = "2.0.3"
|
Werkzeug = "2.0.3"
|
||||||
|
|||||||
@ -9,6 +9,9 @@ class AlbumAdmin(admin.ModelAdmin):
|
|||||||
list_display = ("name", "year", "musicbrainz_id")
|
list_display = ("name", "year", "musicbrainz_id")
|
||||||
list_filter = ("year",)
|
list_filter = ("year",)
|
||||||
ordering = ("name",)
|
ordering = ("name",)
|
||||||
|
filter_horizontal = [
|
||||||
|
'artists',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Artist)
|
@admin.register(Artist)
|
||||||
|
|||||||
23
vrobbler/apps/music/migrations/0005_album_cover_image.py
Normal file
23
vrobbler/apps/music/migrations/0005_album_cover_image.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-01-12 04:22
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
(
|
||||||
|
'music',
|
||||||
|
'0004_alter_artist_options_alter_album_musicbrainz_id_and_more',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='album',
|
||||||
|
name='cover_image',
|
||||||
|
field=models.ImageField(
|
||||||
|
blank=True, null=True, upload_to='albums/'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
20
vrobbler/apps/music/migrations/0006_album_artists.py
Normal file
20
vrobbler/apps/music/migrations/0006_album_artists.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-01-12 05:04
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('music', '0005_album_cover_image'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='album',
|
||||||
|
name='artists',
|
||||||
|
field=models.ManyToManyField(
|
||||||
|
blank=True, null=True, to='music.artist'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
vrobbler/apps/music/migrations/0007_alter_album_artists.py
Normal file
18
vrobbler/apps/music/migrations/0007_alter_album_artists.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -2,39 +2,25 @@ import logging
|
|||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import musicbrainzngs
|
||||||
from django.apps.config import cached_property
|
from django.apps.config import cached_property
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_extensions.db.models import TimeStampedModel
|
from django_extensions.db.models import TimeStampedModel
|
||||||
|
from scrobbles.mixins import ScrobblableMixin
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
BNULL = {"blank": True, "null": True}
|
BNULL = {"blank": True, "null": True}
|
||||||
|
|
||||||
|
|
||||||
class Album(TimeStampedModel):
|
|
||||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
|
||||||
name = models.CharField(max_length=255)
|
|
||||||
year = models.IntegerField(**BNULL)
|
|
||||||
musicbrainz_id = models.CharField(max_length=255, unique=True, **BNULL)
|
|
||||||
musicbrainz_releasegroup_id = models.CharField(max_length=255, **BNULL)
|
|
||||||
musicbrainz_albumartist_id = models.CharField(max_length=255, **BNULL)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
@property
|
|
||||||
def mb_link(self):
|
|
||||||
return f"https://musicbrainz.org/release/{self.musicbrainz_id}"
|
|
||||||
|
|
||||||
|
|
||||||
class Artist(TimeStampedModel):
|
class Artist(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)
|
||||||
musicbrainz_id = models.CharField(max_length=255, **BNULL)
|
musicbrainz_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together=[['name', 'musicbrainz_id']]
|
unique_together = [['name', 'musicbrainz_id']]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
@ -44,20 +30,68 @@ class Artist(TimeStampedModel):
|
|||||||
return f"https://musicbrainz.org/artist/{self.musicbrainz_id}"
|
return f"https://musicbrainz.org/artist/{self.musicbrainz_id}"
|
||||||
|
|
||||||
|
|
||||||
class Track(TimeStampedModel):
|
class Album(TimeStampedModel):
|
||||||
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
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)
|
||||||
|
musicbrainz_albumartist_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
cover_image = models.ImageField(upload_to="albums/", **BNULL)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def primary_artist(self):
|
||||||
|
return self.artists.first()
|
||||||
|
|
||||||
|
def fix_metadata(self):
|
||||||
|
musicbrainzngs.set_useragent('vrobbler', '0.3.0')
|
||||||
|
mb_data = musicbrainzngs.get_release_by_id(
|
||||||
|
self.musicbrainz_id, includes=['artists']
|
||||||
|
)
|
||||||
|
if not self.musicbrainz_albumartist_id:
|
||||||
|
self.musicbrainz_albumartist_id = mb_data['release'][
|
||||||
|
'artist-credit'
|
||||||
|
][0]['artist']['id']
|
||||||
|
if not self.year:
|
||||||
|
self.year = mb_data['release']['date'][0:4]
|
||||||
|
self.save(update_fields=['musicbrainz_albumartist_id', 'year'])
|
||||||
|
|
||||||
|
new_artist = Artist.objects.filter(
|
||||||
|
musicbrainz_id=self.musicbrainz_albumartist_id
|
||||||
|
).first()
|
||||||
|
if self.musicbrainz_albumartist_id and new_artist:
|
||||||
|
self.artists.add(new_artist)
|
||||||
|
if not new_artist:
|
||||||
|
for t in self.track_set.all():
|
||||||
|
self.artists.add(t.artist)
|
||||||
|
|
||||||
|
def fetch_artwork(self):
|
||||||
|
try:
|
||||||
|
img_data = musicbrainzngs.get_image_front(self.musicbrainz_id)
|
||||||
|
name = f"{self.name}_{self.uuid}.jpg"
|
||||||
|
self.cover_image = ContentFile(img_data, name=name)
|
||||||
|
self.save()
|
||||||
|
except musicbrainzngs.ResponseError:
|
||||||
|
logger.warning(f'No cover art found for {self.name}')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mb_link(self):
|
||||||
|
return f"https://musicbrainz.org/release/{self.musicbrainz_id}"
|
||||||
|
|
||||||
|
|
||||||
|
class Track(ScrobblableMixin):
|
||||||
class Opinion(models.IntegerChoices):
|
class Opinion(models.IntegerChoices):
|
||||||
DOWN = -1, 'Thumbs down'
|
DOWN = -1, 'Thumbs down'
|
||||||
NEUTRAL = 0, 'No opinion'
|
NEUTRAL = 0, 'No opinion'
|
||||||
UP = 1, 'Thumbs up'
|
UP = 1, 'Thumbs up'
|
||||||
|
|
||||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
|
||||||
title = models.CharField(max_length=255, **BNULL)
|
|
||||||
artist = models.ForeignKey(Artist, on_delete=models.DO_NOTHING)
|
artist = models.ForeignKey(Artist, on_delete=models.DO_NOTHING)
|
||||||
album = models.ForeignKey(Album, on_delete=models.DO_NOTHING, **BNULL)
|
album = models.ForeignKey(Album, on_delete=models.DO_NOTHING, **BNULL)
|
||||||
musicbrainz_id = models.CharField(max_length=255, unique=True, **BNULL)
|
musicbrainz_id = models.CharField(max_length=255, unique=True, **BNULL)
|
||||||
run_time = models.CharField(max_length=8, **BNULL)
|
|
||||||
run_time_ticks = models.PositiveBigIntegerField(**BNULL)
|
|
||||||
# thumbs = models.IntegerField(default=Opinion.NEUTRAL, choices=Opinion.choices)
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.title} by {self.artist}"
|
return f"{self.title} by {self.artist}"
|
||||||
@ -83,9 +117,10 @@ class Track(TimeStampedModel):
|
|||||||
'musicbrainz_id'
|
'musicbrainz_id'
|
||||||
):
|
):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"No artist or artist musicbrainz ID found in message from Jellyfin, 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}")
|
||||||
@ -97,6 +132,8 @@ class Track(TimeStampedModel):
|
|||||||
logger.debug(f"Created new album {album}")
|
logger.debug(f"Created new album {album}")
|
||||||
else:
|
else:
|
||||||
logger.debug(f"Found album {album}")
|
logger.debug(f"Found album {album}")
|
||||||
|
album.fix_metadata()
|
||||||
|
album.fetch_artwork()
|
||||||
|
|
||||||
track_dict['album_id'] = getattr(album, "id", None)
|
track_dict['album_id'] = getattr(album, "id", None)
|
||||||
track_dict['artist_id'] = artist.id
|
track_dict['artist_id'] = artist.id
|
||||||
|
|||||||
0
vrobbler/apps/podcasts/__init__.py
Normal file
0
vrobbler/apps/podcasts/__init__.py
Normal file
33
vrobbler/apps/podcasts/admin.py
Normal file
33
vrobbler/apps/podcasts/admin.py
Normal 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",)
|
||||||
5
vrobbler/apps/podcasts/apps.py
Normal file
5
vrobbler/apps/podcasts/apps.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastsConfig(AppConfig):
|
||||||
|
name = 'podcasts'
|
||||||
158
vrobbler/apps/podcasts/migrations/0001_initial.py
Normal file
158
vrobbler/apps/podcasts/migrations/0001_initial.py
Normal 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,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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
vrobbler/apps/podcasts/migrations/0003_episode_pub_date.py
Normal file
18
vrobbler/apps/podcasts/migrations/0003_episode_pub_date.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
vrobbler/apps/podcasts/migrations/0004_episode_number.py
Normal file
18
vrobbler/apps/podcasts/migrations/0004_episode_number.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
vrobbler/apps/podcasts/migrations/__init__.py
Normal file
0
vrobbler/apps/podcasts/migrations/__init__.py
Normal file
85
vrobbler/apps/podcasts/models.py
Normal file
85
vrobbler/apps/podcasts/models.py
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
from vrobbler.apps.scrobbles.mixins import ScrobblableMixin
|
||||||
|
|
||||||
|
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(ScrobblableMixin):
|
||||||
|
podcast = models.ForeignKey(Podcast, on_delete=models.DO_NOTHING)
|
||||||
|
number = models.IntegerField(**BNULL)
|
||||||
|
pub_date = models.DateField(**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
|
||||||
@ -3,18 +3,33 @@ 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",
|
||||||
|
"is_paused",
|
||||||
)
|
)
|
||||||
list_filter = ("in_progress", "source", "track__artist")
|
list_filter = ("is_paused", "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"
|
||||||
|
|||||||
@ -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',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
17
vrobbler/apps/scrobbles/mixins.py
Normal file
17
vrobbler/apps/scrobbles/mixins.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
from django_extensions.db.models import TimeStampedModel
|
||||||
|
|
||||||
|
BNULL = {"blank": True, "null": True}
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
# thumbs = models.IntegerField(default=Opinion.NEUTRAL, choices=Opinion.choices)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
@ -8,6 +8,8 @@ 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 scrobbles.utils import check_scrobble_for_finish
|
||||||
from videos.models import Video
|
from videos.models import Video
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -22,6 +24,9 @@ TRACK_WAIT_PERIOD = getattr(settings, 'MUSIC_WAIT_PERIOD_MINUTES')
|
|||||||
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
|
||||||
)
|
)
|
||||||
@ -37,28 +42,25 @@ class Scrobble(TimeStampedModel):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def percent_played(self) -> int:
|
def percent_played(self) -> int:
|
||||||
if self.playback_position_ticks and self.media_run_time_ticks:
|
if (
|
||||||
|
self.playback_position_ticks
|
||||||
|
and self.media_obj.run_time_ticks
|
||||||
|
and source != 'Mopidy'
|
||||||
|
):
|
||||||
return int(
|
return int(
|
||||||
(self.playback_position_ticks / self.media_run_time_ticks)
|
(self.playback_position_ticks / self.media_obj.run_time_ticks)
|
||||||
* 100
|
* 100
|
||||||
)
|
)
|
||||||
# If we don't have media_run_time_ticks, let's guess from created time
|
# If we don't have media_obj.run_time_ticks, let's guess from created time
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
playback_duration = (now - self.created).seconds
|
playback_duration = (now - self.created).seconds
|
||||||
if playback_duration and self.track.run_time:
|
if playback_duration and self.media_obj.run_time:
|
||||||
return int((playback_duration / int(self.track.run_time)) * 100)
|
return int(
|
||||||
|
(playback_duration / int(self.media_obj.run_time)) * 100
|
||||||
|
)
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@property
|
|
||||||
def media_run_time_ticks(self) -> int:
|
|
||||||
if self.video:
|
|
||||||
return self.video.run_time_ticks
|
|
||||||
if self.track:
|
|
||||||
return self.track.run_time_ticks
|
|
||||||
# this is hacky, but want to avoid divide by zero
|
|
||||||
return 1
|
|
||||||
|
|
||||||
def is_stale(self, backoff, wait_period) -> bool:
|
def is_stale(self, backoff, wait_period) -> bool:
|
||||||
scrobble_is_stale = self.in_progress and self.modified > wait_period
|
scrobble_is_stale = self.in_progress and self.modified > wait_period
|
||||||
|
|
||||||
@ -79,16 +81,19 @@ class Scrobble(TimeStampedModel):
|
|||||||
|
|
||||||
return scrobble_is_stale
|
return scrobble_is_stale
|
||||||
|
|
||||||
def __str__(self):
|
@property
|
||||||
media = None
|
def media_obj(self):
|
||||||
|
media_obj = None
|
||||||
if self.video:
|
if self.video:
|
||||||
media = self.video
|
media_obj = self.video
|
||||||
if self.track:
|
if self.track:
|
||||||
media = self.track
|
media_obj = self.track
|
||||||
|
if self.podcast_episode:
|
||||||
|
media_obj = self.podcast_episode
|
||||||
|
return media_obj
|
||||||
|
|
||||||
return (
|
def __str__(self):
|
||||||
f"Scrobble of {media} {self.timestamp.year}-{self.timestamp.month}"
|
return f"Scrobble of {self.media_obj} {self.timestamp.year}-{self.timestamp.month}"
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create_or_update_for_video(
|
def create_or_update_for_video(
|
||||||
@ -134,6 +139,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,
|
||||||
@ -147,6 +174,13 @@ class Scrobble(TimeStampedModel):
|
|||||||
mopidy_status = scrobble_data.pop('status', None)
|
mopidy_status = scrobble_data.pop('status', None)
|
||||||
scrobble_is_stale = False
|
scrobble_is_stale = False
|
||||||
|
|
||||||
|
if scrobble:
|
||||||
|
logger.debug(f"Updating scrobble ticks")
|
||||||
|
scrobble.playback_position_ticks = scrobble_data.get(
|
||||||
|
"playback_position_ticks"
|
||||||
|
)
|
||||||
|
scrobble.save(update_fields=['playback_position_ticks'])
|
||||||
|
|
||||||
if mopidy_status == "stopped":
|
if mopidy_status == "stopped":
|
||||||
logger.info(f"Mopidy sent a message to stop {scrobble}")
|
logger.info(f"Mopidy sent a message to stop {scrobble}")
|
||||||
if not scrobble:
|
if not scrobble:
|
||||||
@ -157,7 +191,37 @@ class Scrobble(TimeStampedModel):
|
|||||||
|
|
||||||
# Mopidy finished a play, scrobble away
|
# Mopidy finished a play, scrobble away
|
||||||
scrobble.in_progress = False
|
scrobble.in_progress = False
|
||||||
scrobble.save(update_fields=['in_progress'])
|
scrobble.played_to_completion = True
|
||||||
|
scrobble.save(
|
||||||
|
update_fields=['in_progress', 'played_to_completion']
|
||||||
|
)
|
||||||
|
return scrobble
|
||||||
|
|
||||||
|
if mopidy_status == "paused":
|
||||||
|
logger.info(f"Mopidy sent a message to pause {scrobble}")
|
||||||
|
if not scrobble:
|
||||||
|
logger.info("Message to pause while not started, ignoring")
|
||||||
|
return
|
||||||
|
if scrobble.is_paused:
|
||||||
|
logger.info("Message to pause while paused, ignoring")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Mopidy finished a play, scrobble away
|
||||||
|
scrobble.is_paused = True
|
||||||
|
scrobble.save(update_fields=["is_paused"])
|
||||||
|
scrobble = check_scrobble_for_finish(scrobble)
|
||||||
|
return scrobble
|
||||||
|
|
||||||
|
if mopidy_status == "resumed":
|
||||||
|
logger.info(f"Mopidy sent a message to resume {scrobble}")
|
||||||
|
if not scrobble:
|
||||||
|
logger.info("Message to resume while not started, ignoring")
|
||||||
|
return
|
||||||
|
if not scrobble.is_paused:
|
||||||
|
logger.info("Message to resume while not paused, resuming")
|
||||||
|
# Mopidy finished a play, scrobble away
|
||||||
|
scrobble.is_paused = False
|
||||||
|
scrobble.save(update_fields=["is_paused"])
|
||||||
return scrobble
|
return scrobble
|
||||||
|
|
||||||
if scrobble and not mopidy_status:
|
if scrobble and not mopidy_status:
|
||||||
@ -185,16 +249,6 @@ class Scrobble(TimeStampedModel):
|
|||||||
|
|
||||||
# If we hit our completion threshold, save it and get ready
|
# If we hit our completion threshold, save it and get ready
|
||||||
# to scrobble again if we re-watch this.
|
# to scrobble again if we re-watch this.
|
||||||
if scrobble.percent_played >= getattr(
|
scrobble = check_scrobble_for_finish(scrobble)
|
||||||
settings, "PERCENT_FOR_COMPLETION", 95
|
|
||||||
):
|
|
||||||
scrobble.in_progress = False
|
|
||||||
scrobble.playback_position_ticks = scrobble.media_run_time_ticks
|
|
||||||
scrobble.save()
|
|
||||||
|
|
||||||
if scrobble.percent_played % 5 == 0:
|
|
||||||
if getattr(settings, "KEEP_DETAILED_SCROBBLE_LOGS", False):
|
|
||||||
scrobble.scrobble_log += f"\n{str(scrobble.timestamp)} - {scrobble.playback_position} - {str(scrobble.playback_position_ticks)} - {str(scrobble.percent_played)}%"
|
|
||||||
scrobble.save(update_fields=['scrobble_log'])
|
|
||||||
|
|
||||||
return scrobble
|
return scrobble
|
||||||
|
|||||||
93
vrobbler/apps/scrobbles/scrobblers.py
Normal file
93
vrobbler/apps/scrobbles/scrobblers.py
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
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 mopidy_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 = 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(),
|
||||||
|
"playback_position_ticks": data_dict.get("playback_time_ticks"),
|
||||||
|
"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 mopidy_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(),
|
||||||
|
"playback_position_ticks": data_dict.get("playback_time_ticks"),
|
||||||
|
"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
|
||||||
@ -1,3 +1,13 @@
|
|||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
|
from dateutil.parser import ParserError, parse
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
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 +15,77 @@ 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 = unquote(parsed_uri.pop(-1).strip(".mp3"))
|
||||||
|
podcast_str = unquote(parsed_uri.pop(-1))
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def check_scrobble_for_finish(scrobble: "Scrobble") -> None:
|
||||||
|
completion_percent = getattr(settings, "MUSIC_COMPLETION_PERCENT", 90)
|
||||||
|
if scrobble.podcast_episode:
|
||||||
|
completion_percent = getattr(
|
||||||
|
settings, "PODCAST_COMPLETION_PERCENT", 25
|
||||||
|
)
|
||||||
|
logger.debug(f"Completion set to {completion_percent}")
|
||||||
|
|
||||||
|
if scrobble.percent_played >= completion_percent:
|
||||||
|
logger.debug(
|
||||||
|
f"{scrobble} meets completion goal of {completion_percent}, finishing"
|
||||||
|
)
|
||||||
|
scrobble.in_progress = False
|
||||||
|
scrobble.is_paused = False
|
||||||
|
scrobble.playback_position_ticks = scrobble.media_obj.run_time_ticks
|
||||||
|
scrobble.save(
|
||||||
|
update_fields=[
|
||||||
|
"in_progress",
|
||||||
|
"is_paused",
|
||||||
|
"playback_position_ticks",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
if scrobble.percent_played % 5 == 0:
|
||||||
|
if getattr(settings, "KEEP_DETAILED_SCROBBLE_LOGS", False):
|
||||||
|
scrobble.scrobble_log += f"\n{str(scrobble.timestamp)} - {scrobble.playback_position} - {str(scrobble.playback_position_ticks)} - {str(scrobble.percent_played)}%"
|
||||||
|
scrobble.save(update_fields=['scrobble_log'])
|
||||||
|
|
||||||
|
return scrobble
|
||||||
|
|||||||
@ -27,6 +27,7 @@ from vrobbler.apps.music.aggregators import (
|
|||||||
top_tracks,
|
top_tracks,
|
||||||
week_of_scrobbles,
|
week_of_scrobbles,
|
||||||
)
|
)
|
||||||
|
from scrobbles.scrobblers import mopidy_scrobble_podcast, mopidy_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 = mopidy_scrobble_podcast(data_dict, request.user.id)
|
||||||
"musicbrainz_id": data_dict.get("musicbrainz_artist_id", None),
|
else:
|
||||||
}
|
scrobble = mopidy_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)
|
||||||
|
|||||||
@ -7,6 +7,7 @@ from django.urls import reverse
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_extensions.db.models import TimeStampedModel
|
from django_extensions.db.models import TimeStampedModel
|
||||||
from scrobbles.utils import convert_to_seconds
|
from scrobbles.utils import convert_to_seconds
|
||||||
|
from scrobbles.mixins import ScrobblableMixin
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
BNULL = {"blank": True, "null": True}
|
BNULL = {"blank": True, "null": True}
|
||||||
@ -30,15 +31,12 @@ class Series(TimeStampedModel):
|
|||||||
verbose_name_plural = "series"
|
verbose_name_plural = "series"
|
||||||
|
|
||||||
|
|
||||||
class Video(TimeStampedModel):
|
class Video(ScrobblableMixin):
|
||||||
class VideoType(models.TextChoices):
|
class VideoType(models.TextChoices):
|
||||||
UNKNOWN = 'U', _('Unknown')
|
UNKNOWN = 'U', _('Unknown')
|
||||||
TV_EPISODE = 'E', _('TV Episode')
|
TV_EPISODE = 'E', _('TV Episode')
|
||||||
MOVIE = 'M', _('Movie')
|
MOVIE = 'M', _('Movie')
|
||||||
|
|
||||||
# General fields
|
|
||||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
|
||||||
title = models.CharField(max_length=255, **BNULL)
|
|
||||||
video_type = models.CharField(
|
video_type = models.CharField(
|
||||||
max_length=1,
|
max_length=1,
|
||||||
choices=VideoType.choices,
|
choices=VideoType.choices,
|
||||||
@ -46,8 +44,6 @@ class Video(TimeStampedModel):
|
|||||||
)
|
)
|
||||||
overview = models.TextField(**BNULL)
|
overview = models.TextField(**BNULL)
|
||||||
tagline = models.TextField(**BNULL)
|
tagline = models.TextField(**BNULL)
|
||||||
run_time = models.CharField(max_length=8, **BNULL)
|
|
||||||
run_time_ticks = models.PositiveBigIntegerField(**BNULL)
|
|
||||||
year = models.IntegerField()
|
year = models.IntegerField()
|
||||||
|
|
||||||
# TV show specific fields
|
# TV show specific fields
|
||||||
|
|||||||
@ -37,6 +37,11 @@ KEEP_DETAILED_SCROBBLE_LOGS = os.getenv(
|
|||||||
"VROBBLER_KEEP_DETAILED_SCROBBLE_LOGS", False
|
"VROBBLER_KEEP_DETAILED_SCROBBLE_LOGS", False
|
||||||
)
|
)
|
||||||
|
|
||||||
|
PODCAST_COMPLETION_PERCENT = os.getenv(
|
||||||
|
"VROBBLER_PODCAST_COMPLETION_PERCENT", 25
|
||||||
|
)
|
||||||
|
MUSIC_COMPLETION_PERCENT = os.getenv("VROBBLER_MUSIC_COMPLETION_PERCENT", 90)
|
||||||
|
|
||||||
# Should we cull old in-progress scrobbles that are beyond the wait period for resuming?
|
# Should we cull old in-progress scrobbles that are beyond the wait period for resuming?
|
||||||
DELETE_STALE_SCROBBLES = os.getenv("VROBBLER_DELETE_STALE_SCROBBLES", True)
|
DELETE_STALE_SCROBBLES = os.getenv("VROBBLER_DELETE_STALE_SCROBBLES", True)
|
||||||
|
|
||||||
@ -86,6 +91,7 @@ INSTALLED_APPS = [
|
|||||||
"scrobbles",
|
"scrobbles",
|
||||||
"videos",
|
"videos",
|
||||||
"music",
|
"music",
|
||||||
|
"podcasts",
|
||||||
"rest_framework",
|
"rest_framework",
|
||||||
"allauth",
|
"allauth",
|
||||||
"allauth.account",
|
"allauth.account",
|
||||||
|
|||||||
@ -194,27 +194,16 @@
|
|||||||
<ul style="padding-right:10px;">
|
<ul style="padding-right:10px;">
|
||||||
<b>Now playing</b>
|
<b>Now playing</b>
|
||||||
{% for scrobble in now_playing_list %}
|
{% for scrobble in now_playing_list %}
|
||||||
{% if scrobble.video %}
|
|
||||||
<div>
|
<div>
|
||||||
{{scrobble.video.title}}<br/>
|
{{scrobble.media_obj.title}}<br/>
|
||||||
<small>{{scrobble.created|naturaltime}}<br/>
|
{% if scrobble.track %}<em>{{scrobble.track.artist}}</em><br/>{% endif %}
|
||||||
from {{scrobble.source}}</small>
|
{% if scrobble.podcast_episode%}<em>{{scrobble.podcast_episode.podcast}}</em><br/>{% endif %}
|
||||||
<div class="progress-bar">
|
|
||||||
<span class="progress-bar-fill" style="width: {{scrobble.percent_played}}%;"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if scrobble.track %}
|
|
||||||
<div>
|
|
||||||
{{scrobble.track.title}}<br/>
|
|
||||||
<em>{{scrobble.track.artist}}</em><br/>
|
|
||||||
<small>{{scrobble.created|naturaltime}}<br/>
|
<small>{{scrobble.created|naturaltime}}<br/>
|
||||||
from {{scrobble.source}}</small>
|
from {{scrobble.source}}</small>
|
||||||
<div class="progress-bar" style="margin-right:5px;">
|
<div class="progress-bar" style="margin-right:5px;">
|
||||||
<span class="progress-bar-fill" style="width: {{scrobble.percent_played}}%;"></span>
|
<span class="progress-bar-fill" style="width: {{scrobble.percent_played}}%;"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
<hr/>
|
<hr/>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
@ -225,23 +214,29 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link active" aria-current="page" href="#">
|
<a class="nav-link active" aria-current="page" href="#">
|
||||||
<span data-feather="music"></span>
|
<span data-feather="music"></span>
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" aria-current="page" href="/tracks/">
|
||||||
|
<span data-feather="music"></span>
|
||||||
Tracks
|
Tracks
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="#">
|
<a class="nav-link" href="/artists/">
|
||||||
<span data-feather="user"></span>
|
<span data-feather="user"></span>
|
||||||
Artists
|
Artists
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="#">
|
<a class="nav-link" href="/movies/">
|
||||||
<span data-feather="film"></span>
|
<span data-feather="film"></span>
|
||||||
Movies
|
Movies
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="#">
|
<a class="nav-link" href="/series/">
|
||||||
<span data-feather="tv"></span>
|
<span data-feather="tv"></span>
|
||||||
Series
|
Series
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
Reference in New Issue
Block a user