Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b4fbf4777 | |||
| ca57eabf87 | |||
| 6fc51d9296 | |||
| 6e582e25e3 | |||
| eed344ae46 | |||
| 41570dc2f9 | |||
| 24c3f5b4d8 | |||
| 703dc3c181 | |||
| 93550c5734 | |||
| 951fa225bb | |||
| 2e7470688d | |||
| 8ac938bd12 | |||
| 160f15a101 | |||
| b6e0607aab | |||
| bbbcfca04f |
4
envrc.sample
Normal file
4
envrc.sample
Normal file
@ -0,0 +1,4 @@
|
||||
export ENV_PATH=$(poetry env info --path)
|
||||
source "${ENV_PATH}/bin/activate"
|
||||
|
||||
export PYPI_PASSWORD="$(pass personal/apikey/pypi)"
|
||||
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "vrobbler"
|
||||
version = "0.7.2"
|
||||
version = "0.7.5"
|
||||
description = ""
|
||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
351
todos.org
Normal 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
|
||||
@ -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
|
||||
|
||||
21
vrobbler/apps/music/urls.py
Normal file
21
vrobbler/apps/music/urls.py
Normal 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',
|
||||
),
|
||||
]
|
||||
33
vrobbler/apps/music/views.py
Normal file
33
vrobbler/apps/music/views.py
Normal 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
|
||||
88
vrobbler/apps/scrobbles/migrations/0010_chartrecord.py
Normal file
88
vrobbler/apps/scrobbles/migrations/0010_chartrecord.py
Normal 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,
|
||||
},
|
||||
),
|
||||
]
|
||||
26
vrobbler/apps/scrobbles/migrations/0011_chartrecord_user.py
Normal file
26
vrobbler/apps/scrobbles/migrations/0011_chartrecord_user.py
Normal 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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
115
vrobbler/apps/scrobbles/stats.py
Normal file
115
vrobbler/apps/scrobbles/stats.py
Normal 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)
|
||||
@ -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:
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
from django.urls import path
|
||||
from videos import views
|
||||
|
||||
app_name = 'scrobbles'
|
||||
app_name = 'videos'
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
|
||||
@ -83,6 +83,7 @@ INSTALLED_APPS = [
|
||||
"music",
|
||||
"podcasts",
|
||||
"sports",
|
||||
"mathfilters",
|
||||
"rest_framework",
|
||||
"allauth",
|
||||
"allauth.account",
|
||||
|
||||
18
vrobbler/templates/base_detail.html
Normal file
18
vrobbler/templates/base_detail.html
Normal 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 %}
|
||||
18
vrobbler/templates/base_list.html
Normal file
18
vrobbler/templates/base_list.html
Normal 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 %}
|
||||
11
vrobbler/templates/music/album_list.html
Normal file
11
vrobbler/templates/music/album_list.html
Normal 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 %}
|
||||
72
vrobbler/templates/music/artist_detail.html
Normal file
72
vrobbler/templates/music/artist_detail.html
Normal 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 %}
|
||||
30
vrobbler/templates/music/artist_list.html
Normal file
30
vrobbler/templates/music/artist_list.html
Normal 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 %}
|
||||
13
vrobbler/templates/music/track_detail.html
Normal file
13
vrobbler/templates/music/track_detail.html
Normal 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 %}
|
||||
12
vrobbler/templates/music/track_list.html
Normal file
12
vrobbler/templates/music/track_list.html
Normal 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 %}
|
||||
@ -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>
|
||||
|
||||
@ -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"),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user