Compare commits

...

9 Commits
0.7.2 ... 0.7.3

Author SHA1 Message Date
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
10 changed files with 432 additions and 25 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.3"
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}

344
todos.org Normal file
View File

@ -0,0 +1,344 @@
#+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.
* TODO [#A] Fix Jellyfin music scrobbling N+1 past 90 completion perecnt :bug:
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] 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] Implement keeping track of week/month/year chart-toppers :improvement:
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] 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

@ -77,14 +77,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()

View File

@ -105,7 +105,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},

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

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