Compare commits

...

15 Commits
0.7.2 ... 0.7.5

Author SHA1 Message Date
4b4fbf4777 Bump version to 0.7.5 2023-01-31 10:41:40 -05:00
ca57eabf87 Another attempt to fix the Jellyfin issue
This time, we simplify the progress updates, aggressively mark tracks as
100 played if they are marked played_to_completion, and implement a hack
for Jellyfin spamming us with progress updates less than a second apart.
2023-01-31 10:28:54 -05:00
6fc51d9296 Fix resurrecting past tracks
This may also be hack, but I think that if the playback position ticks
are automatically jumped to the run time ticks of the media object, it
should stop the resurrection of past scrobbles, because they will be
appropriately marked as being 100 played when the scrobble is finished.

The only weirdness here is that "in progress" scrobbles will suddenly
complete once the media type's threshold is met (90, 95 percent,
whatever). But it should be better than overwriting old scrobbles.
2023-01-30 23:18:42 -05:00
6e582e25e3 Bump version to 0.7.4 2023-01-30 18:32:31 -05:00
eed344ae46 Add the beginnings of charts
This commit adds a lot of files, but most of them have no impact on any
other code. The thrust here is to start creating chart pages showing
which tracks and artists were most played for various time periods. Lots
still not working, but we're getting there.
2023-01-30 18:29:18 -05:00
41570dc2f9 Fix duplicate Jellyfin music scrobbling bug
Solution is identical to what we were already doing with videos.  When
looking for existing scrobbles, don't filter by completion, but just
check if the scrobble was played to completion.  This does create
another irritating situation where old scrobbles from days, months or
even years ago that were not played to completion will be resurrected
and made current here. But that's way less annoying than having spam
scrobbles at the end of every track.
2023-01-30 18:25:27 -05:00
24c3f5b4d8 Bump version to 0.7.3 2023-01-29 14:41:05 -05:00
703dc3c181 Add a sample envrc file 2023-01-29 14:32:25 -05:00
93550c5734 Allow looking up artwork by release group
Historically we'd just fail if the specific MB release did not have
artwork, but this is silly. If the release itself does not have artwork,
we should also check the release group failing.
2023-01-29 14:31:31 -05:00
951fa225bb Add todos and get them updated 2023-01-29 14:31:12 -05:00
2e7470688d Update TODOs and fix tests 2023-01-24 16:44:11 -05:00
8ac938bd12 Should probably avoid bug fixing under hydrocodone 2023-01-24 15:25:19 -05:00
160f15a101 Add cover art to latest listening 2023-01-24 15:24:41 -05:00
b6e0607aab Fix jellyfin video scrobbles for real 2023-01-24 02:41:48 -05:00
bbbcfca04f Add a rudimentary todo doc 2023-01-23 12:01:26 -05:00
26 changed files with 1023 additions and 51 deletions

4
envrc.sample Normal file
View File

@ -0,0 +1,4 @@
export ENV_PATH=$(poetry env info --path)
source "${ENV_PATH}/bin/activate"
export PYPI_PASSWORD="$(pass personal/apikey/pypi)"

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "vrobbler"
version = "0.7.2"
version = "0.7.5"
description = ""
authors = ["Colin Powell <colin@unbl.ink>"]

View File

@ -2,6 +2,10 @@ import json
import pytest
from scrobbles.models import Scrobble
from rest_framework.authtoken.models import Token
from django.contrib.auth import get_user_model
User = get_user_model()
class MopidyRequest:
@ -55,6 +59,12 @@ class MopidyRequest:
return json.dumps(self.request_data)
@pytest.fixture
def valid_auth_token():
user = User.objects.create(email='test@exmaple.com')
return Token.objects.create(user=user).key
@pytest.fixture
def mopidy_track_request_data():
return MopidyRequest().request_json

View File

@ -8,15 +8,19 @@ from music.models import Track
from podcasts.models import Episode
def test_get_not_allowed_from_mopidy(client):
@pytest.mark.django_db
def test_get_not_allowed_from_mopidy(client, valid_auth_token):
url = reverse('scrobbles:mopidy-websocket')
response = client.get(url)
headers = {'Authorization': f'Token {valid_auth_token}'}
response = client.get(url, headers=headers)
assert response.status_code == 405
def test_bad_mopidy_request_data(client):
@pytest.mark.django_db
def test_bad_mopidy_request_data(client, valid_auth_token):
url = reverse('scrobbles:mopidy-websocket')
response = client.post(url)
headers = {'Authorization': f'Token {valid_auth_token}'}
response = client.post(url, headers)
assert response.status_code == 400
assert (
response.data['detail']
@ -25,10 +29,16 @@ def test_bad_mopidy_request_data(client):
@pytest.mark.django_db
def test_scrobble_mopidy_track(client, mopidy_track_request_data):
def test_scrobble_mopidy_track(
client, mopidy_track_request_data, valid_auth_token
):
url = reverse('scrobbles:mopidy-websocket')
headers = {'Authorization': f'Token {valid_auth_token}'}
response = client.post(
url, mopidy_track_request_data, content_type='application/json'
url,
mopidy_track_request_data,
content_type='application/json',
headers=headers,
)
assert response.status_code == 200
assert response.data == {'scrobble_id': 1}
@ -40,11 +50,18 @@ def test_scrobble_mopidy_track(client, mopidy_track_request_data):
@pytest.mark.django_db
def test_scrobble_mopidy_same_track_different_album(
client, mopidy_track_request_data, mopidy_track_diff_album_request_data
client,
mopidy_track_request_data,
mopidy_track_diff_album_request_data,
valid_auth_token,
):
url = reverse('scrobbles:mopidy-websocket')
headers = {'Authorization': f'Token {valid_auth_token}'}
response = client.post(
url, mopidy_track_request_data, content_type='application/json'
url,
mopidy_track_request_data,
content_type='application/json',
headers=headers,
)
assert response.status_code == 200
assert response.data == {'scrobble_id': 1}
@ -64,10 +81,16 @@ def test_scrobble_mopidy_same_track_different_album(
@pytest.mark.django_db
def test_scrobble_mopidy_podcast(client, mopidy_podcast_request_data):
def test_scrobble_mopidy_podcast(
client, mopidy_podcast_request_data, valid_auth_token
):
url = reverse('scrobbles:mopidy-websocket')
headers = {'Authorization': f'Token {valid_auth_token}'}
response = client.post(
url, mopidy_podcast_request_data, content_type='application/json'
url,
mopidy_podcast_request_data,
content_type='application/json',
headers=headers,
)
assert response.status_code == 200
assert response.data == {'scrobble_id': 1}

351
todos.org Normal file
View File

@ -0,0 +1,351 @@
#+title: TODOs
A fun way to keep track of things in the project to fix or improve.
* DONE [#A] Fix fetching artwork without release group :bug:
CLOSED: [2023-01-29 Sun 14:27]
When we get artwork from Musicbrianz, and it's not found, we should check for
release groups as well. This will stop issues with missing artwork because of
obscure MB release matches.
* DONE [#A] Fix Jellyfin music scrobbling N+1 past 90 completion perecnt :bug:
CLOSED: [2023-01-30 Mon 18:31]
:LOGBOOK:
CLOCK: [2023-01-30 Mon 18:00]--[2023-01-30 Mon 18:31] => 0:31
:END:
If we play music from Jellyfin and the track reaches 90% completion, the
scrobbling goes crazy and starts creating new scrobbles with every update.
The cause is pretty simple, but the solution is hard. We want to mark a scrobble
as complete for the following conditions:
- Play stopped and percent played beyond 90%
- Play completely finished
But if we keep listening beyond 90, we should basically ignore updates (or just
update the existing scrobble)
* TODO [#A] Add django-storage to store files on S3 :improvement:
* TODO [#B] Adjust cancel/finish task to use javascript to submit :improvement:
* TODO [#B] Implement a detail view for TV shows :improvement:
* TODO [#B] Implement a detail view for Moviews :improvement:
* TODO [#B] Allow scrobbling music without MB IDs by grabbing them before scrobble :improvement:
This would allow a few nice flows. One, you'd be able to record the play of an
entire album by just dropping the muscibrainz_id in. This could be helpful for
offline listening. It would also mean bad metadata from mopidy would not break
scrobbling.
* TODO [#C] Implement keeping track of week/month/year chart-toppers :improvement:
:LOGBOOK:
CLOCK: [2023-01-30 Mon 16:30]--[2023-01-30 Mon 18:00] => 1:30
:END:
Maloja does this cool thing where artists and tracks get recorded as the top
track of a given week, month or year. They get gold, silver or bronze stars for
their place in the time period.
I could see this being implemented as a separate Chart table which gets
populated at the end of a time period and has a start and end date that defines
a period, along with a one, two, three instance.
Of course, it could also be a data model without a table, where it runs some fun
calculations, stores it's values in Redis as a long-term lookup table and just
has to re-populate when the server restarts.
* TODO [#C] Move to using more robust mopidy-webhooks pacakge form pypi :improvement:
** Example payloads from mopidy-webhooks
*** Podcast playback ended
#+begin_src json
{
"type": "event",
"event": "track_playback_ended",
"data": {
"tl_track": {
"__model__": "TlTrack",
"tlid": 13,
"track": {
"__model__": "Track",
"uri": "file:///var/lib/mopidy/media/podcasts/The%20Prince/2022-09-28-Wolf-warriors.mp3",
"name": "Wolf warriors",
"artists": [
{
"__model__": "Artist",
"name": "The Economist"
}
],
"album": {
"__model__": "Album",
"name": "The Prince",
"date": "2022"
},
"genre": "Blues",
"date": "2022",
"length": 2437778,
"bitrate": 127988
}
},
"time_position": 3290
}
}
#+end_src
*** Podcast playback state changes
#+begin_src json
{
"type": "event",
"event": "playback_state_changed",
"data": {
"old_state": "paused",
"new_state": "playing"
}
}
#+end_src
#+begin_src json
{
"type": "event",
"event": "playback_state_changed",
"data": {
"old_state": "stopped",
"new_state": "playing"
}
}
#+end_src
*** Podcast playback started
#+begin_src json
{
"type": "event",
"event": "track_playback_started",
"data": {
"tl_track": {
"__model__": "TlTrack",
"tlid": 13,
"track": {
"__model__": "Track",
"uri": "file:///var/lib/mopidy/media/podcasts/The%20Prince/2022-09-28-Wolf-warriors.mp3",
"name": "Wolf warriors",
"artists": [
{
"__model__": "Artist",
"name": "The Economist"
}
],
"album": {
"__model__": "Album",
"name": "The Prince",
"date": "2022"
},
"genre": "Blues",
"date": "2022",
"length": 2437778,
"bitrate": 127988
}
}
}
}
#+end_src
*** Podcast playback paused
#+begin_src json
{
"type": "status",
"data": {
"state": "paused",
"current_track": {
"__model__": "Track",
"uri": "file:///var/lib/mopidy/media/podcasts/The%20Prince/2022-09-28-Wolf-warriors.mp3",
"name": "Wolf warriors",
"artists": [
{
"__model__": "Artist",
"name": "The Economist"
}
],
"album": {
"__model__": "Album",
"name": "The Prince",
"date": "2022"
},
"genre": "Blues",
"date": "2022",
"length": 2437778,
"bitrate": 127988
},
"time_position": 2350
}
}
#+end_src
*** Track playback started
#+begin_src json
{
"type": "event",
"event": "track_playback_started",
"data": {
"tl_track": {
"__model__": "TlTrack",
"tlid": 14,
"track": {
"__model__": "Track",
"uri": "local:track:Various%20Artists%20-%202008%20-%20Twilight%20OST/01-muse-supermassive_black_hole.mp3",
"name": "Supermassive Black Hole",
"artists": [
{
"__model__": "Artist",
"uri": "local:artist:md5:250dd6551b66a58a6b4897aa697f200c",
"name": "Muse",
"musicbrainz_id": "9c9f1380-2516-4fc9-a3e6-f9f61941d090"
}
],
"album": {
"__model__": "Album",
"uri": "local:album:md5:455343d54cdd89cb5a3b5ad537ea99d0",
"name": "Twilight: Original Motion Picture Soundtrack",
"artists": [
{
"__model__": "Artist",
"uri": "local:artist:md5:54e4db2d5624f80b0cc290346e696756",
"name": "Various Artists",
"musicbrainz_id": "89ad4ac3-39f7-470e-963a-56509c546377"
}
],
"num_tracks": 12,
"num_discs": 1,
"date": "2008-11-04",
"musicbrainz_id": "b4889eaf-d9f4-434c-a68d-69227b12b6a4"
},
"composers": [
{
"__model__": "Artist",
"uri": "local:artist:md5:4d49cbca0b347e0a89047bb019d2779d",
"name": "Matt Bellamy"
}
],
"genre": "Rock",
"track_no": 1,
"disc_no": 1,
"date": "2008-11-04",
"length": 211121,
"musicbrainz_id": "ff1e3e1a-f6e8-4692-b426-355880383bb6",
"last_modified": 1672712949510
}
}
}
}
#+end_src
*** Track playback in progress
#+begin_src json
{
"type": "status",
"data": {
"state": "playing",
"current_track": {
"__model__": "Track",
"uri": "local:track:Various%20Artists%20-%202008%20-%20Twilight%20OST/01-muse-supermassive_black_hole.mp3",
"name": "Supermassive Black Hole",
"artists": [
{
"__model__": "Artist",
"uri": "local:artist:md5:250dd6551b66a58a6b4897aa697f200c",
"name": "Muse",
"musicbrainz_id": "9c9f1380-2516-4fc9-a3e6-f9f61941d090"
}
],
"album": {
"__model__": "Album",
"uri": "local:album:md5:455343d54cdd89cb5a3b5ad537ea99d0",
"name": "Twilight: Original Motion Picture Soundtrack",
"artists": [
{
"__model__": "Artist",
"uri": "local:artist:md5:54e4db2d5624f80b0cc290346e696756",
"name": "Various Artists",
"musicbrainz_id": "89ad4ac3-39f7-470e-963a-56509c546377"
}
],
"num_tracks": 12,
"num_discs": 1,
"date": "2008-11-04",
"musicbrainz_id": "b4889eaf-d9f4-434c-a68d-69227b12b6a4"
},
"composers": [
{
"__model__": "Artist",
"uri": "local:artist:md5:4d49cbca0b347e0a89047bb019d2779d",
"name": "Matt Bellamy"
}
],
"genre": "Rock",
"track_no": 1,
"disc_no": 1,
"date": "2008-11-04",
"length": 211121,
"musicbrainz_id": "ff1e3e1a-f6e8-4692-b426-355880383bb6",
"last_modified": 1672712949510
},
"time_position": 17031
}
}
#+end_src
*** Track event playback paused
#+begin_src json
{
"type": "event",
"event": "track_playback_paused",
"data": {
"tl_track": {
"__model__": "TlTrack",
"tlid": 14,
"track": {
"__model__": "Track",
"uri": "local:track:Various%20Artists%20-%202008%20-%20Twilight%20OST/01-muse-supermassive_black_hole.mp3",
"name": "Supermassive Black Hole",
"artists": [
{
"__model__": "Artist",
"uri": "local:artist:md5:250dd6551b66a58a6b4897aa697f200c",
"name": "Muse",
"musicbrainz_id": "9c9f1380-2516-4fc9-a3e6-f9f61941d090"
}
],
"album": {
"__model__": "Album",
"uri": "local:album:md5:455343d54cdd89cb5a3b5ad537ea99d0",
"name": "Twilight: Original Motion Picture Soundtrack",
"artists": [
{
"__model__": "Artist",
"uri": "local:artist:md5:54e4db2d5624f80b0cc290346e696756",
"name": "Various Artists",
"musicbrainz_id": "89ad4ac3-39f7-470e-963a-56509c546377"
}
],
"num_tracks": 12,
"num_discs": 1,
"date": "2008-11-04",
"musicbrainz_id": "b4889eaf-d9f4-434c-a68d-69227b12b6a4"
},
"composers": [
{
"__model__": "Artist",
"uri": "local:artist:md5:4d49cbca0b347e0a89047bb019d2779d",
"name": "Matt Bellamy"
}
],
"genre": "Rock",
"track_no": 1,
"disc_no": 1,
"date": "2008-11-04",
"length": 211121,
"musicbrainz_id": "ff1e3e1a-f6e8-4692-b426-355880383bb6",
"last_modified": 1672712949510
}
},
"time_position": 67578
}
}
#+end_src
* TODO [#C] Consider a purge command for duplicated and stuck in-progress scrobbles :improvement:
* TODO Figure out how to add to web-scrobbler :imropvement:
An example:
https://github.com/web-scrobbler/web-scrobbler/blob/master/src/core/background/scrobbler/maloja-scrobbler.js

View File

@ -3,10 +3,10 @@ from typing import Dict, Optional
from uuid import uuid4
import musicbrainzngs
from django.apps.config import cached_property
from django.conf import settings
from django.core.files.base import ContentFile
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django_extensions.db.models import TimeStampedModel
from scrobbles.mixins import ScrobblableMixin
@ -30,6 +30,29 @@ class Artist(TimeStampedModel):
def mb_link(self):
return f"https://musicbrainz.org/artist/{self.musicbrainz_id}"
def get_absolute_url(self):
return reverse('music:artist_detail', kwargs={'slug': self.uuid})
def scrobbles(self):
from scrobbles.models import Scrobble
return Scrobble.objects.filter(
track__in=self.track_set.all()
).order_by('-timestamp')
@property
def tracks(self):
return (
self.track_set.all()
.annotate(scrobble_count=models.Count('scrobble'))
.order_by('-scrobble_count')
)
def charts(self):
from scrobbles.models import ChartRecord
return ChartRecord.objects.filter(track__artist=self).order_by('-year')
class Album(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
@ -77,14 +100,38 @@ class Album(TimeStampedModel):
for t in self.track_set.all():
self.artists.add(t.artist)
def fetch_artwork(self):
if not self.cover_image:
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)
except musicbrainzngs.ResponseError:
logger.warning(f'No cover art found for {self.name}')
def fetch_artwork(self, force=False):
if not self.cover_image and not force:
if self.musicbrainz_id:
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)
logger.info(f'Setting image to {name}')
except musicbrainzngs.ResponseError:
logger.warning(
f'No cover art found for {self.name} by release'
)
if not self.cover_image and self.musicbrainz_releasegroup_id:
try:
img_data = musicbrainzngs.get_release_group_image_front(
self.musicbrainz_releasegroup_id
)
name = f"{self.name}_{self.uuid}.jpg"
self.cover_image = ContentFile(img_data, name=name)
logger.info(f'Setting image to {name}')
except musicbrainzngs.ResponseError:
logger.warning(
f'No cover art found for {self.name} by release group'
)
if not self.cover_image:
logger.debug(
f"No cover art found for release or release group for {self.name}, setting to default"
)
# TODO Get a placeholder image in here
self.cover_image = 'default-image-replace-me'
self.save()
@ -111,14 +158,13 @@ class Track(ScrobblableMixin):
def __str__(self):
return f"{self.title} by {self.artist}"
def get_absolute_url(self):
return reverse('music:track_detail', kwargs={'slug': self.uuid})
@property
def mb_link(self):
return f"https://musicbrainz.org/recording/{self.musicbrainz_id}"
@cached_property
def scrobble_count(self):
return self.scrobble_set.filter(in_progress=False).count()
@classmethod
def find_or_create(
cls, artist_dict: Dict, album_dict: Dict, track_dict: Dict

View File

@ -0,0 +1,21 @@
from django.urls import path
from music import views
app_name = 'music'
urlpatterns = [
path('albums/', views.AlbumListView.as_view(), name='albums_list'),
path("tracks/", views.TrackListView.as_view(), name='tracks_list'),
path(
'tracks/<slug:slug>/',
views.TrackDetailView.as_view(),
name='track_detail',
),
path('artists/', views.ArtistListView.as_view(), name='artist_list'),
path(
'artists/<slug:slug>/',
views.ArtistDetailView.as_view(),
name='artist_detail',
),
]

View File

@ -0,0 +1,33 @@
from django.views import generic
from music.models import Track, Artist, Album
from scrobbles.stats import get_scrobble_count_qs
class TrackListView(generic.ListView):
model = Track
def get_queryset(self):
return get_scrobble_count_qs(user=self.request.user).order_by(
"-scrobble_count"
)
class TrackDetailView(generic.DetailView):
model = Track
slug_field = 'uuid'
class ArtistListView(generic.ListView):
model = Artist
def get_queryset(self):
return super().get_queryset().order_by("name")
class ArtistDetailView(generic.DetailView):
model = Artist
slug_field = 'uuid'
class AlbumListView(generic.ListView):
model = Album

View File

@ -0,0 +1,88 @@
# Generated by Django 4.1.5 on 2023-01-30 17:01
from django.db import migrations, models
import django.db.models.deletion
import django_extensions.db.fields
class Migration(migrations.Migration):
dependencies = [
('music', '0009_alter_track_musicbrainz_id_and_more'),
('videos', '0006_alter_video_year'),
('scrobbles', '0009_scrobble_uuid'),
]
operations = [
migrations.CreateModel(
name='ChartRecord',
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'
),
),
('rank', models.IntegerField()),
('year', models.IntegerField(default=2023)),
('month', models.IntegerField(blank=True, null=True)),
('week', models.IntegerField(blank=True, null=True)),
('day', models.IntegerField(blank=True, null=True)),
(
'artist',
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to='music.artist',
),
),
(
'series',
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to='videos.series',
),
),
(
'track',
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to='music.track',
),
),
(
'video',
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to='videos.video',
),
),
],
options={
'get_latest_by': 'modified',
'abstract': False,
},
),
]

View File

@ -0,0 +1,26 @@
# Generated by Django 4.1.5 on 2023-01-30 17:44
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('scrobbles', '0010_chartrecord'),
]
operations = [
migrations.AddField(
model_name='chartrecord',
name='user',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to=settings.AUTH_USER_MODEL,
),
),
]

View File

@ -1,3 +1,4 @@
import calendar
import logging
from datetime import timedelta
from uuid import uuid4
@ -6,17 +7,94 @@ from django.contrib.auth import get_user_model
from django.db import models
from django.utils import timezone
from django_extensions.db.models import TimeStampedModel
from music.models import Track
from music.models import Artist, Track
from podcasts.models import Episode
from scrobbles.utils import check_scrobble_for_finish
from sports.models import SportEvent
from videos.models import Video
from videos.models import Series, Video
from vrobbler.apps.profiles.utils import now_user_timezone
logger = logging.getLogger(__name__)
User = get_user_model()
BNULL = {"blank": True, "null": True}
class ChartRecord(TimeStampedModel):
"""Sort of like a materialized view for what we could dynamically generate,
but would kill the DB as it gets larger. Collects time-based records
generated by a cron-like archival job
1972 by Josh Rouse - #3 in 2023, January
"""
user = models.ForeignKey(User, on_delete=models.DO_NOTHING, **BNULL)
rank = models.IntegerField()
year = models.IntegerField(default=timezone.now().year)
month = models.IntegerField(**BNULL)
week = models.IntegerField(**BNULL)
day = models.IntegerField(**BNULL)
video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL)
series = models.ForeignKey(Series, on_delete=models.DO_NOTHING, **BNULL)
artist = models.ForeignKey(Artist, on_delete=models.DO_NOTHING, **BNULL)
track = models.ForeignKey(Track, on_delete=models.DO_NOTHING, **BNULL)
@property
def media_obj(self):
media_obj = None
if self.video:
media_obj = self.video
if self.track:
media_obj = self.track
return media_obj
@property
def month_str(self) -> str:
month_str = ""
if self.month:
month_str = calendar.month_name[self.month]
return month_str
@property
def day_str(self) -> str:
day_str = ""
if self.day:
day_str = str(self.day)
return day_str
@property
def week_str(self) -> str:
week_str = ""
if self.week:
week_str = str(self.week)
return "Week " + week_str
@property
def period(self) -> str:
period = str(self.year)
if self.month:
period = " ".join([self.month_str, period])
if self.week:
period = " ".join([self.week_str, period])
if self.day:
period = " ".join([self.day_str, period])
return period
@property
def period_type(self) -> str:
period = 'year'
if self.month:
period = 'month'
if self.week:
period = 'week'
if self.day:
period = 'day'
return period
def __str__(self):
return f"#{self.rank} in {self.period} - {self.media_obj}"
class Scrobble(TimeStampedModel):
uuid = models.UUIDField(editable=False, **BNULL)
video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL)
@ -69,7 +147,7 @@ class Scrobble(TimeStampedModel):
playback_ticks = (timezone.now() - self.timestamp).seconds * 1000
if self.played_to_completion:
playback_ticks = self.media_obj.run_time_ticks
return 100
percent = int((playback_ticks / self.media_obj.run_time_ticks) * 100)
return percent
@ -105,7 +183,7 @@ class Scrobble(TimeStampedModel):
.order_by('-modified')
.first()
)
if scrobble and scrobble.playback_percent <= 100:
if scrobble and scrobble.percent_played <= 100:
logger.info(
f"Found existing scrobble for video {video}, updating",
{"scrobble_data": scrobble_data},
@ -145,6 +223,14 @@ class Scrobble(TimeStampedModel):
)
return cls.update(scrobble, scrobble_data)
if 'jellyfin_status' in scrobble_data.keys():
last_scrobble = Scrobble.objects.last()
if (
scrobble_data['timestamp'] - last_scrobble.timestamp
).seconds <= 1:
logger.warning('Jellyfin spammed us with duplicate updates')
return last_scrobble
logger.debug(
f"No existing scrobble for track {track}, creating",
{"scrobble_data": scrobble_data},
@ -235,7 +321,6 @@ class Scrobble(TimeStampedModel):
for key, value in scrobble_data.items():
setattr(scrobble, key, value)
scrobble.save()
check_scrobble_for_finish(scrobble)
return scrobble
@classmethod
@ -258,6 +343,7 @@ class Scrobble(TimeStampedModel):
check_scrobble_for_finish(self, force_finish)
def pause(self) -> None:
print('Trying to pause it')
if self.is_paused:
logger.warning("Scrobble already paused")
return
@ -270,6 +356,7 @@ class Scrobble(TimeStampedModel):
self.is_paused = False
self.in_progress = True
return self.save(update_fields=["is_paused", "in_progress"])
logger.warning("Resume called but in progress or not paused")
def cancel(self) -> None:
check_scrobble_for_finish(self, force_finish=True)

View File

@ -99,26 +99,15 @@ def create_jellyfin_scrobble_dict(data_dict: dict, user_id: int) -> dict:
jellyfin_status = "resumed"
if data_dict.get("IsPaused"):
jellyfin_status = "paused"
if data_dict.get("PlayedToCompletion"):
if data_dict.get("NotificationType") == 'PlaybackStop':
jellyfin_status = "stopped"
playback_position_ticks = None
if data_dict.get("PlaybackPositionTicks"):
playback_position_ticks = (
data_dict.get("PlaybackPositionTicks") // 10000
)
if playback_position_ticks <= 0:
playback_position_ticks = None
playback_position = data_dict.get("PlaybackPosition")
if playback_position:
playback_position = convert_to_seconds(playback_position)
return {
"user_id": user_id,
"timestamp": parse(data_dict.get("UtcTimestamp")),
"playback_position_ticks": playback_position_ticks,
"playback_position": playback_position,
"playback_position_ticks": data_dict.get("PlaybackPositionTicks", "")
// 10000,
"playback_position": data_dict.get("PlaybackPosition", ""),
"source": data_dict.get("ClientName", "Vrobbler"),
"source_id": data_dict.get('MediaSourceId'),
"jellyfin_status": jellyfin_status,
@ -128,6 +117,7 @@ def create_jellyfin_scrobble_dict(data_dict: dict, user_id: int) -> dict:
def jellyfin_scrobble_track(
data_dict: dict, user_id: Optional[int]
) -> Optional[Scrobble]:
if not data_dict.get("Provider_musicbrainztrack", None):
logger.error(
"No MBrainz Track ID received. This is likely because all metadata is bad, not scrobbling"

View File

@ -0,0 +1,115 @@
import calendar
import logging
from datetime import datetime, timedelta
from typing import Optional
import pytz
from django.apps import apps
from django.conf import settings
from django.db.models import Count, Q
logger = logging.getLogger(__name__)
def get_start_end_dates_by_week(year, week, tz):
d = datetime(year, 1, 1, tzinfo=tz)
if d.weekday() <= 3:
d = d - timedelta(d.weekday())
else:
d = d + timedelta(7 - d.weekday())
dlt = timedelta(days=(week - 1) * 7)
return d + dlt, d + dlt + timedelta(days=6)
def get_scrobble_count_qs(
year: Optional[int] = None,
month: Optional[int] = None,
week: Optional[int] = None,
day: Optional[int] = None,
user=None,
model_str="Track",
) -> dict[str, int]:
tz = settings.TIME_ZONE
if user and user.is_authenticated:
tz = pytz.timezone(user.profile.timezone)
data_model = apps.get_model(app_label='music', model_name='Track')
if model_str == "Video":
data_model = apps.get_model(app_label='videos', model_name='Video')
if model_str == "SportEvent":
data_model = apps.get_model(
app_label='sports', model_name='SportEvent'
)
base_qs = data_model.objects.filter(
scrobble__user=user,
scrobble__played_to_completion=True,
)
# Returna all media items with scrobble count annotated
if not year:
return base_qs.annotate(scrobble_count=Count("scrobble")).order_by(
"-scrobble_count"
)
start = datetime(year, 1, 1, tzinfo=tz)
end = datetime(year, 12, 31, tzinfo=tz)
if month:
end_day = calendar.monthrange(year, month)[1]
start = datetime(year, month, 1, tzinfo=tz)
end = datetime(year, month, end_day, tzinfo=tz)
elif week:
start, end = get_start_end_dates_by_week(year, week, tz)
elif day and month:
start = datetime(year, month, day, 0, 0, tzinfo=tz)
end = datetime(year, month, day, 23, 59, tzinfo=tz)
elif day and not month:
logger.warning('Day provided with month, defaulting ot all-time')
date_filter = Q(
scrobble__timestamp__gte=start, scrobble__timestamp__lte=end
)
return (
base_qs.annotate(
scrobble_count=Count("scrobble", filter=Q(date_filter))
)
.filter(date_filter)
.order_by("-scrobble_count")
)
def build_charts(
year: Optional[int] = None,
month: Optional[int] = None,
week: Optional[int] = None,
day: Optional[int] = None,
user=None,
model_str="Track",
):
ChartRecord = apps.get_model(
app_label='scrobbles', model_name='ChartRecord'
)
results = get_scrobble_count_qs(year, month, week, day, user, model_str)
unique_counts = list(set([result.scrobble_count for result in results]))
unique_counts.sort(reverse=True)
ranks = {}
for rank, count in enumerate(unique_counts, start=1):
ranks[count] = rank
chart_records = []
for result in results:
chart_record = {
'year': year,
'week': week,
'month': month,
'day': day,
'user': user,
}
chart_record['rank'] = ranks[result.scrobble_count]
if model_str == 'Track':
chart_record['track'] = result
chart_records.append(ChartRecord(**chart_record))
ChartRecord.objects.bulk_create(chart_records, ignore_conflicts=True)

View File

@ -131,8 +131,8 @@ def scrobble_endpoint(request):
@csrf_exempt
@api_view(['POST'])
@permission_classes([IsAuthenticated])
@api_view(['POST'])
def jellyfin_websocket(request):
data_dict = request.data
@ -157,8 +157,8 @@ def jellyfin_websocket(request):
@csrf_exempt
@api_view(['POST'])
@permission_classes([IsAuthenticated])
@api_view(['POST'])
def mopidy_websocket(request):
try:
data_dict = json.loads(request.data)
@ -183,8 +183,8 @@ def mopidy_websocket(request):
@csrf_exempt
@api_view(['GET'])
@permission_classes([IsAuthenticated])
@api_view(['GET'])
def scrobble_finish(request, uuid):
user = request.user
if not user.is_authenticated:
@ -201,8 +201,8 @@ def scrobble_finish(request, uuid):
@csrf_exempt
@api_view(['GET'])
@permission_classes([IsAuthenticated])
@api_view(['GET'])
def scrobble_cancel(request, uuid):
user = request.user
if not user.is_authenticated:

View File

@ -157,7 +157,7 @@ class SportEvent(ScrobblableMixin):
)
if se_created:
season.name = seid
season.save(update_fields['name'])
season.save(update_fields=['name'])
# Find or create our Round
rid = data_dict.get('RoundId')

View File

@ -1,7 +1,7 @@
from django.urls import path
from videos import views
app_name = 'scrobbles'
app_name = 'videos'
urlpatterns = [

View File

@ -83,6 +83,7 @@ INSTALLED_APPS = [
"music",
"podcasts",
"sports",
"mathfilters",
"rest_framework",
"allauth",
"allauth.account",

View File

@ -0,0 +1,18 @@
{% extends "base.html" %}
{% block content %}
<main class="col-md-4 ms-sm-auto col-lg-10 px-md-4">
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">{% block title %}{% endblock %} </h1>
<div class="btn-toolbar mb-2 mb-md-0">
<div class="btn-group me-2">
<button type="button" class="btn btn-sm btn-outline-secondary">Export</button>
</div>
</div>
</div>
<div class="container">
{% block details %}{% endblock %}
</div>
</main>
{% endblock %}

View File

@ -0,0 +1,18 @@
{% extends "base.html" %}
{% block content %}
<main class="col-md-4 ms-sm-auto col-lg-10 px-md-4">
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">{% block title %}{% endblock %} </h1>
<div class="btn-toolbar mb-2 mb-md-0">
<div class="btn-group me-2">
<button type="button" class="btn btn-sm btn-outline-secondary">Export</button>
</div>
</div>
</div>
<div class="container">
{% block lists %}{% endblock %}
</div>
</main>
{% endblock %}

View File

@ -0,0 +1,11 @@
{% extends "base_list.html" %}
{% block title %}Albums{% endblock %}
{% block lists %}
{% for album in object_list %}
<dl style="width: 130px; float: left; margin-right:10px;">
<dd><img src="{{album.cover_image.url}}" width=120 height=120 /></dd>
</dl>
{% endfor %}
{% endblock %}

View File

@ -0,0 +1,72 @@
{% extends "base_detail.html" %}
{% load mathfilters %}
{% block title %}{{object.name}}{% endblock %}
{% block details %}
<div class="row">
{% for album in artist.album_set.all %}
{% if album.cover_image %}
<p style="width:150px; float:left;"><img src="{{album.cover_image.url}}" width=150 height=150 /></p>
{% endif %}
{% endfor %}
</div>
<div class="row">
<p>{{artist.scrobbles.count}} scrobbles</p>
<div class="col-md">
<h3>Top tracks</h3>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Rank</th>
<th scope="col">Track</th>
<th scope="col">Count</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{% for track in object.tracks %}
<tr>
<td>{{rank}}#1</td>
<td>{{track.title}}</td>
<td>{{track.scrobble_count}}</td>
<td>
<div class="progress-bar" style="margin-right:5px;">
<span class="progress-bar-fill" style="width: {{track.scrobble_count|mul:10}}%;"></span>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="row">
<div class="col-md">
<h3>Last scrobbles</h3>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">Track</th>
<th scope="col">Album</th>
</tr>
</thead>
<tbody>
{% for scrobble in object.scrobbles %}
<tr>
<td>{{scrobble.timestamp}}</td>
<td>{{scrobble.track.title}}</td>
<td>{{scrobble.track.album.name}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,30 @@
{% extends "base_list.html" %}
{% block title %}Artists{% endblock %}
{% block lists %}
<div class="row">
<div class="col-md">
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Artist</th>
<th scope="col">Scrobbles</th>
<th scope="col">All time</th>
</tr>
</thead>
<tbody>
{% for artist in object_list %}
<tr>
<td><a href="{{artist.get_absolute_url}}">{{artist}}</a></td>
<td>{{artist.scrobbles.count}}</td>
<td></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,13 @@
{% extends "base_detail.html" %}
{% block title %}{{object.title}}{% endblock %}
{% block details %}
<h2>Last scrobbles</h2>
{% for scrobble in object.scrobble_set.all %}
<ul>
<li>{{scrobble.timestamp|date:"d M Y h:m"}} - <img src="{{object.album.cover_image.url}}" width=25 height=25 /> - {{object}}</li>
</ul>
{% endfor %}
{% endblock %}

View File

@ -0,0 +1,12 @@
{% extends "base_list.html" %}
{% block title %}Tracks{% endblock %}
{% block lists %}
<h2>All time</h2>
{% for track in object_list %}
<ul>
<li><a href="{{track.get_absolute_url}}">{{track}}</a></li>
</ul>
{% endfor %}
{% endblock %}

View File

@ -170,6 +170,7 @@
<thead>
<tr>
<th scope="col">Time</th>
<th scope="col">Album</th>
<th scope="col">Track</th>
<th scope="col">Artist</th>
</tr>
@ -178,6 +179,7 @@
{% for scrobble in object_list %}
<tr>
<td>{{scrobble.timestamp|naturaltime}}</td>
<td><img src="{{scrobble.track.album.cover_image.url}}" width=50 height=50 style="border:1px solid black;" /></td>
<td>{{scrobble.track.title}}</td>
<td>{{scrobble.track.artist.name}}</td>
</tr>

View File

@ -3,8 +3,8 @@ from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include, path
from rest_framework import routers
from scrobbles import urls as scrobble_urls
from music import urls as music_urls
from videos import urls as video_urls
urlpatterns = [
@ -19,6 +19,7 @@ urlpatterns = [
scrobbles_views.ManualScrobbleView.as_view(),
name='imdb-manual-scrobble',
),
path("", include(music_urls, namespace="music")),
path("", include(video_urls, namespace="videos")),
path("", scrobbles_views.RecentScrobbleList.as_view(), name="home"),
]