Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e4501cad3 | |||
| 71e4ff28c8 | |||
| 9f272df99c | |||
| 8ba8ceefb8 | |||
| 9590cd0f60 | |||
| 5e7c8ff137 | |||
| fae59849f8 | |||
| 837e1280bd | |||
| 8f9c825903 | |||
| 541073aae3 | |||
| b63ec6b15f | |||
| 117157e3ae | |||
| 0c10e78d5e | |||
| 6b7359707b | |||
| e0295cbd56 | |||
| 5271cfaea4 | |||
| 0370b64351 | |||
| 9ec31ba0f5 | |||
| a9de298057 | |||
| 9d303b1b94 | |||
| 4c434aeb7c | |||
| 64d9cac09c | |||
| c21d6a96fe | |||
| e392477dc7 | |||
| 12087460f6 | |||
| 4b4fbf4777 | |||
| ca57eabf87 | |||
| 6fc51d9296 | |||
| 6e582e25e3 | |||
| eed344ae46 | |||
| 41570dc2f9 | |||
| 24c3f5b4d8 | |||
| 703dc3c181 | |||
| 93550c5734 | |||
| 951fa225bb | |||
| 2e7470688d | |||
| 8ac938bd12 | |||
| 160f15a101 | |||
| b6e0607aab | |||
| bbbcfca04f | |||
| ace0d1d9fe | |||
| b0fb62bdb9 | |||
| 7796ff5786 | |||
| 2285c5bfd6 |
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)"
|
||||||
21
poetry.lock
generated
21
poetry.lock
generated
@ -440,6 +440,21 @@ category = "main"
|
|||||||
optional = false
|
optional = false
|
||||||
python-versions = "*"
|
python-versions = "*"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "django-redis"
|
||||||
|
version = "5.2.0"
|
||||||
|
description = "Full featured redis cache backend for Django."
|
||||||
|
category = "main"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.6"
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
Django = ">=2.2"
|
||||||
|
redis = ">=3,<4.0.0 || >4.0.0,<4.0.1 || >4.0.1"
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
hiredis = ["redis[hiredis] (>=3,!=4.0.0,!=4.0.1)"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django-simple-history"
|
name = "django-simple-history"
|
||||||
version = "3.2.0"
|
version = "3.2.0"
|
||||||
@ -1491,7 +1506,7 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools"
|
|||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "1.1"
|
lock-version = "1.1"
|
||||||
python-versions = "^3.8"
|
python-versions = "^3.8"
|
||||||
content-hash = "32f195ac129f5d8984ac82d42fa970f127ebea8f86f42d6d7cefc443c788b46a"
|
content-hash = "0e23dbecb64cbef4dfe51bdf47e0f6b1357aab1d34342fef5341eaead2c26f1e"
|
||||||
|
|
||||||
[metadata.files]
|
[metadata.files]
|
||||||
amqp = [
|
amqp = [
|
||||||
@ -1867,6 +1882,10 @@ django-mathfilters = [
|
|||||||
{file = "django-mathfilters-1.0.0.tar.gz", hash = "sha256:c9b892ef6dfc893683e75cfd0279c187a601ca68f4684c38f9da44657fb64b07"},
|
{file = "django-mathfilters-1.0.0.tar.gz", hash = "sha256:c9b892ef6dfc893683e75cfd0279c187a601ca68f4684c38f9da44657fb64b07"},
|
||||||
{file = "django_mathfilters-1.0.0-py3-none-any.whl", hash = "sha256:64200a21bb249fbf27be601d4bbb788779e09c6e063170c097cd82c4d18ebb83"},
|
{file = "django_mathfilters-1.0.0-py3-none-any.whl", hash = "sha256:64200a21bb249fbf27be601d4bbb788779e09c6e063170c097cd82c4d18ebb83"},
|
||||||
]
|
]
|
||||||
|
django-redis = [
|
||||||
|
{file = "django-redis-5.2.0.tar.gz", hash = "sha256:8a99e5582c79f894168f5865c52bd921213253b7fd64d16733ae4591564465de"},
|
||||||
|
{file = "django_redis-5.2.0-py3-none-any.whl", hash = "sha256:1d037dc02b11ad7aa11f655d26dac3fb1af32630f61ef4428860a2e29ff92026"},
|
||||||
|
]
|
||||||
django-simple-history = [
|
django-simple-history = [
|
||||||
{file = "django-simple-history-3.2.0.tar.gz", hash = "sha256:bff0a756238b2fa048ea3ffe8224b4edd421559123ff9ce5c27682c37c6a7702"},
|
{file = "django-simple-history-3.2.0.tar.gz", hash = "sha256:bff0a756238b2fa048ea3ffe8224b4edd421559123ff9ce5c27682c37c6a7702"},
|
||||||
{file = "django_simple_history-3.2.0-py3-none-any.whl", hash = "sha256:516e1872c2028c31f77208f542967e81bd3bf75623e69fe7008d5d3d15e33534"},
|
{file = "django_simple_history-3.2.0-py3-none-any.whl", hash = "sha256:516e1872c2028c31f77208f542967e81bd3bf75623e69fe7008d5d3d15e33534"},
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "vrobbler"
|
name = "vrobbler"
|
||||||
version = "0.7.0"
|
version = "0.8.6"
|
||||||
description = ""
|
description = ""
|
||||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||||
|
|
||||||
@ -16,7 +16,7 @@ djangorestframework = "^3.13.1"
|
|||||||
Markdown = "^3.3.6"
|
Markdown = "^3.3.6"
|
||||||
django-filter = "^21.1"
|
django-filter = "^21.1"
|
||||||
Pillow = "^9.0.1"
|
Pillow = "^9.0.1"
|
||||||
psycopg2 = {version = "^2.9.3", extras = ["production"]}
|
psycopg2 = "^2.9.3"
|
||||||
dj-database-url = "^0.5.0"
|
dj-database-url = "^0.5.0"
|
||||||
django-mathfilters = "^1.0.0"
|
django-mathfilters = "^1.0.0"
|
||||||
django-allauth = "^0.50.0"
|
django-allauth = "^0.50.0"
|
||||||
@ -32,6 +32,7 @@ cinemagoer = "^2022.12.27"
|
|||||||
pysportsdb = "^0.1.0"
|
pysportsdb = "^0.1.0"
|
||||||
django-cachalot = "^2.5.2"
|
django-cachalot = "^2.5.2"
|
||||||
pytz = "^2022.7.1"
|
pytz = "^2022.7.1"
|
||||||
|
django-redis = "^5.2.0"
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
Werkzeug = "2.0.3"
|
Werkzeug = "2.0.3"
|
||||||
|
|||||||
@ -2,6 +2,10 @@ import json
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from scrobbles.models import Scrobble
|
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:
|
class MopidyRequest:
|
||||||
@ -55,6 +59,12 @@ class MopidyRequest:
|
|||||||
return json.dumps(self.request_data)
|
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
|
@pytest.fixture
|
||||||
def mopidy_track_request_data():
|
def mopidy_track_request_data():
|
||||||
return MopidyRequest().request_json
|
return MopidyRequest().request_json
|
||||||
|
|||||||
@ -8,15 +8,19 @@ from music.models import Track
|
|||||||
from podcasts.models import Episode
|
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')
|
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
|
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')
|
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.status_code == 400
|
||||||
assert (
|
assert (
|
||||||
response.data['detail']
|
response.data['detail']
|
||||||
@ -25,10 +29,16 @@ def test_bad_mopidy_request_data(client):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@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')
|
url = reverse('scrobbles:mopidy-websocket')
|
||||||
|
headers = {'Authorization': f'Token {valid_auth_token}'}
|
||||||
response = client.post(
|
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.status_code == 200
|
||||||
assert response.data == {'scrobble_id': 1}
|
assert response.data == {'scrobble_id': 1}
|
||||||
@ -40,11 +50,18 @@ def test_scrobble_mopidy_track(client, mopidy_track_request_data):
|
|||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_scrobble_mopidy_same_track_different_album(
|
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')
|
url = reverse('scrobbles:mopidy-websocket')
|
||||||
|
headers = {'Authorization': f'Token {valid_auth_token}'}
|
||||||
response = client.post(
|
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.status_code == 200
|
||||||
assert response.data == {'scrobble_id': 1}
|
assert response.data == {'scrobble_id': 1}
|
||||||
@ -64,10 +81,16 @@ def test_scrobble_mopidy_same_track_different_album(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@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')
|
url = reverse('scrobbles:mopidy-websocket')
|
||||||
|
headers = {'Authorization': f'Token {valid_auth_token}'}
|
||||||
response = client.post(
|
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.status_code == 200
|
||||||
assert response.data == {'scrobble_id': 1}
|
assert response.data == {'scrobble_id': 1}
|
||||||
|
|||||||
380
todos.org
Normal file
380
todos.org
Normal file
@ -0,0 +1,380 @@
|
|||||||
|
#+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 percent :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)
|
||||||
|
* DONE [#A] Add support for Audioscrobbler tab-separated file uploads :improvement:
|
||||||
|
CLOSED: [2023-02-03 Fri 16:52]
|
||||||
|
|
||||||
|
An example of the format:
|
||||||
|
#+begin_src csv
|
||||||
|
,
|
||||||
|
#AUDIOSCROBBLER/1.1
|
||||||
|
#TZ/UNKNOWN
|
||||||
|
#CLIENT/Rockbox sansaclipplus $Revision$
|
||||||
|
75 Dollar Bill I Was Real I Was Real 4 1015 S 1740494944 64ff5f53-d187-4512-827e-7606c69e66ff
|
||||||
|
75 Dollar Bill I Was Real I Was Real 4 1015 S 1740494990 64ff5f53-d187-4512-827e-7606c69e66ff
|
||||||
|
311 311 Down 1 173 S 1740495003 00476c23-fd9e-464b-9b27-a62d69f3d4f4
|
||||||
|
311 311 Down 1 173 L 1740495049 00476c23-fd9e-464b-9b27-a62d69f3d4f4
|
||||||
|
311 311 Down 1 173 L 1740495113 00476c23-fd9e-464b-9b27-a62d69f3d4f4
|
||||||
|
311 311 Random 2 187 S 1740495190 530c09f3-46fe-4d90-b11f-7b63bcb4b373
|
||||||
|
311 311 Random 2 187 L 1740495194 530c09f3-46fe-4d90-b11f-7b63bcb4b373
|
||||||
|
311 311 Jackolantern’s Weather 3 204 L 1740495382 cc3b2dec-5d99-47ea-8930-20bf258be4ea
|
||||||
|
311 311 All Mixed Up 4 182 L 1740495586 980a78b5-5bdd-4f50-9e3a-e13261e2817b
|
||||||
|
311 311 Hive 5 179 L 1740495768 18f6dc98-d3a2-4f81-b967-97359d14c68c
|
||||||
|
311 311 Guns (Are for Pussies) 6 137 L 1740495948 5e97ed9f-c8cc-4282-9cbe-f8e17aee5128
|
||||||
|
311 311 Misdirected Hostility 7 179 S 1740496085 61ff2c1a-fc9c-44c3-8da1-5e50a44245af
|
||||||
|
,
|
||||||
|
#+end_src
|
||||||
|
* TODO [#A] Add ability to manually scrobble albums or tracks from MB :improvement:
|
||||||
|
|
||||||
|
Given a UUID from musicbrainz, we should be able to scrobble an album or
|
||||||
|
individual track.
|
||||||
|
|
||||||
|
* 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
|
||||||
|
|
||||||
|
* TODO When updating musicbrainz IDs, clear and run fetch artwrok :improvement:
|
||||||
@ -3,10 +3,10 @@ from typing import Dict, Optional
|
|||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import musicbrainzngs
|
import musicbrainzngs
|
||||||
from django.apps.config import cached_property
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.urls import reverse
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_extensions.db.models import TimeStampedModel
|
from django_extensions.db.models import TimeStampedModel
|
||||||
from scrobbles.mixins import ScrobblableMixin
|
from scrobbles.mixins import ScrobblableMixin
|
||||||
@ -30,6 +30,29 @@ class Artist(TimeStampedModel):
|
|||||||
def mb_link(self):
|
def mb_link(self):
|
||||||
return f"https://musicbrainz.org/artist/{self.musicbrainz_id}"
|
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):
|
class Album(TimeStampedModel):
|
||||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||||
@ -49,11 +72,19 @@ class Album(TimeStampedModel):
|
|||||||
return self.artists.first()
|
return self.artists.first()
|
||||||
|
|
||||||
def fix_metadata(self):
|
def fix_metadata(self):
|
||||||
if not self.musicbrainz_albumartist_id or not self.year:
|
if (
|
||||||
|
not self.musicbrainz_albumartist_id
|
||||||
|
or not self.year
|
||||||
|
or not self.musicbrainz_releasegroup_id
|
||||||
|
):
|
||||||
musicbrainzngs.set_useragent('vrobbler', '0.3.0')
|
musicbrainzngs.set_useragent('vrobbler', '0.3.0')
|
||||||
mb_data = musicbrainzngs.get_release_by_id(
|
mb_data = musicbrainzngs.get_release_by_id(
|
||||||
self.musicbrainz_id, includes=['artists']
|
self.musicbrainz_id, includes=['artists', 'release-groups']
|
||||||
)
|
)
|
||||||
|
if not self.musicbrainz_releasegroup_id:
|
||||||
|
self.musicbrainz_releasegroup_id = mb_data['release'][
|
||||||
|
'release-group'
|
||||||
|
]['id']
|
||||||
if not self.musicbrainz_albumartist_id:
|
if not self.musicbrainz_albumartist_id:
|
||||||
self.musicbrainz_albumartist_id = mb_data['release'][
|
self.musicbrainz_albumartist_id = mb_data['release'][
|
||||||
'artist-credit'
|
'artist-credit'
|
||||||
@ -66,7 +97,13 @@ class Album(TimeStampedModel):
|
|||||||
except IndexError:
|
except IndexError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
self.save(update_fields=['musicbrainz_albumartist_id', 'year'])
|
self.save(
|
||||||
|
update_fields=[
|
||||||
|
'musicbrainz_albumartist_id',
|
||||||
|
'musicbrainz_releasegroup_id',
|
||||||
|
'year',
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
new_artist = Artist.objects.filter(
|
new_artist = Artist.objects.filter(
|
||||||
musicbrainz_id=self.musicbrainz_albumartist_id
|
musicbrainz_id=self.musicbrainz_albumartist_id
|
||||||
@ -76,16 +113,46 @@ class Album(TimeStampedModel):
|
|||||||
if not new_artist:
|
if not new_artist:
|
||||||
for t in self.track_set.all():
|
for t in self.track_set.all():
|
||||||
self.artists.add(t.artist)
|
self.artists.add(t.artist)
|
||||||
|
if (
|
||||||
|
not self.cover_image
|
||||||
|
or self.cover_image == 'default-image-replace-me'
|
||||||
|
):
|
||||||
|
self.fetch_artwork()
|
||||||
|
|
||||||
def fetch_artwork(self):
|
def fetch_artwork(self, force=False):
|
||||||
if not self.cover_image:
|
if not self.cover_image and not force:
|
||||||
try:
|
if self.musicbrainz_id:
|
||||||
img_data = musicbrainzngs.get_image_front(self.musicbrainz_id)
|
try:
|
||||||
name = f"{self.name}_{self.uuid}.jpg"
|
img_data = musicbrainzngs.get_image_front(
|
||||||
self.cover_image = ContentFile(img_data, name=name)
|
self.musicbrainz_id
|
||||||
except musicbrainzngs.ResponseError:
|
)
|
||||||
logger.warning(f'No cover art found for {self.name}')
|
name = f"{self.name}_{self.uuid}.jpg"
|
||||||
self.cover_image = 'default-image-replace-me'
|
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
|
||||||
|
or self.cover_image == "default-image-replace-me"
|
||||||
|
) 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"
|
||||||
|
)
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -111,14 +178,13 @@ class Track(ScrobblableMixin):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.title} by {self.artist}"
|
return f"{self.title} by {self.artist}"
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse('music:track_detail', kwargs={'slug': self.uuid})
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def mb_link(self):
|
def mb_link(self):
|
||||||
return f"https://musicbrainz.org/recording/{self.musicbrainz_id}"
|
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
|
@classmethod
|
||||||
def find_or_create(
|
def find_or_create(
|
||||||
cls, artist_dict: Dict, album_dict: Dict, track_dict: Dict
|
cls, artist_dict: Dict, album_dict: Dict, track_dict: Dict
|
||||||
@ -137,16 +203,7 @@ class Track(ScrobblableMixin):
|
|||||||
return
|
return
|
||||||
|
|
||||||
artist, artist_created = Artist.objects.get_or_create(**artist_dict)
|
artist, artist_created = Artist.objects.get_or_create(**artist_dict)
|
||||||
if artist_created:
|
|
||||||
logger.debug(f"Created new artist {artist}")
|
|
||||||
else:
|
|
||||||
logger.debug(f"Found album {artist}")
|
|
||||||
|
|
||||||
album, album_created = Album.objects.get_or_create(**album_dict)
|
album, album_created = Album.objects.get_or_create(**album_dict)
|
||||||
if album_created:
|
|
||||||
logger.debug(f"Created new album {album}")
|
|
||||||
else:
|
|
||||||
logger.debug(f"Found album {album}")
|
|
||||||
|
|
||||||
album.fix_metadata()
|
album.fix_metadata()
|
||||||
if not album.cover_image:
|
if not album.cover_image:
|
||||||
@ -156,9 +213,5 @@ class Track(ScrobblableMixin):
|
|||||||
track_dict['artist_id'] = artist.id
|
track_dict['artist_id'] = artist.id
|
||||||
|
|
||||||
track, created = cls.objects.get_or_create(**track_dict)
|
track, created = cls.objects.get_or_create(**track_dict)
|
||||||
if created:
|
|
||||||
logger.debug(f"Created new track: {track}")
|
|
||||||
else:
|
|
||||||
logger.debug(f"Found track {track}")
|
|
||||||
|
|
||||||
return track
|
return track
|
||||||
|
|||||||
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
|
||||||
@ -1,3 +1,5 @@
|
|||||||
|
import pytz
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django_extensions.db.models import TimeStampedModel
|
from django_extensions.db.models import TimeStampedModel
|
||||||
@ -16,3 +18,7 @@ class UserProfile(TimeStampedModel):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"User profile for {self.user}"
|
return f"User profile for {self.user}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tzinfo(self):
|
||||||
|
return pytz.timezone(self.timezone)
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from scrobbles.models import Scrobble
|
from scrobbles.models import AudioScrobblerTSVImport, Scrobble
|
||||||
|
|
||||||
|
|
||||||
class ScrobbleInline(admin.TabularInline):
|
class ScrobbleInline(admin.TabularInline):
|
||||||
@ -10,6 +10,13 @@ class ScrobbleInline(admin.TabularInline):
|
|||||||
exclude = ('source_id', 'scrobble_log')
|
exclude = ('source_id', 'scrobble_log')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(AudioScrobblerTSVImport)
|
||||||
|
class AudioScrobblerTSVImportAdmin(admin.ModelAdmin):
|
||||||
|
date_hierarchy = "created"
|
||||||
|
list_display = ("uuid", "created", "process_count", "tsv_file")
|
||||||
|
ordering = ("-created",)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Scrobble)
|
@admin.register(Scrobble)
|
||||||
class ScrobbleAdmin(admin.ModelAdmin):
|
class ScrobbleAdmin(admin.ModelAdmin):
|
||||||
date_hierarchy = "timestamp"
|
date_hierarchy = "timestamp"
|
||||||
|
|||||||
67
vrobbler/apps/scrobbles/export.py
Normal file
67
vrobbler/apps/scrobbles/export.py
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import csv
|
||||||
|
import tempfile
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
|
|
||||||
|
def export_scrobbles(start_date=None, end_date=None, format="AS"):
|
||||||
|
start_query = Q()
|
||||||
|
end_query = Q()
|
||||||
|
if start_date:
|
||||||
|
start_query = Q(timestamp__gte=start_date)
|
||||||
|
if start_date:
|
||||||
|
end_query = Q(timestamp__lte=end_date)
|
||||||
|
|
||||||
|
scrobble_qs = Scrobble.objects.filter(
|
||||||
|
start_query, end_query, track__isnull=False
|
||||||
|
)
|
||||||
|
headers = []
|
||||||
|
extension = 'tsv'
|
||||||
|
delimiter = '\t'
|
||||||
|
|
||||||
|
if format == "as":
|
||||||
|
headers = [
|
||||||
|
['#AUDIOSCROBBLER/1.1'],
|
||||||
|
['#TZ/UTC'],
|
||||||
|
['#CLIENT/Vrobbler 1.0.0'],
|
||||||
|
]
|
||||||
|
|
||||||
|
if format == "csv":
|
||||||
|
delimiter = ','
|
||||||
|
extension = 'csv'
|
||||||
|
headers = [
|
||||||
|
[
|
||||||
|
"artists",
|
||||||
|
"album",
|
||||||
|
"title",
|
||||||
|
"track_number",
|
||||||
|
"run_time",
|
||||||
|
"rating",
|
||||||
|
"timestamp",
|
||||||
|
"musicbrainz_id",
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(mode='w', delete=False) as outfile:
|
||||||
|
writer = csv.writer(outfile, delimiter=delimiter)
|
||||||
|
for row in headers:
|
||||||
|
writer.writerow(row)
|
||||||
|
|
||||||
|
for scrobble in scrobble_qs:
|
||||||
|
track = scrobble.track
|
||||||
|
track_number = 0 # TODO Add track number
|
||||||
|
track_rating = "S" # TODO implement ratings?
|
||||||
|
track_artist = track.artist or track.album.primary_artist
|
||||||
|
row = [
|
||||||
|
track_artist,
|
||||||
|
track.album.name,
|
||||||
|
track.title,
|
||||||
|
track_number,
|
||||||
|
track.run_time,
|
||||||
|
track_rating,
|
||||||
|
scrobble.timestamp.strftime('%s'),
|
||||||
|
track.musicbrainz_id,
|
||||||
|
]
|
||||||
|
writer.writerow(row)
|
||||||
|
return outfile.name, extension
|
||||||
@ -1,6 +1,17 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
|
|
||||||
|
|
||||||
|
class ExportScrobbleForm(forms.Form):
|
||||||
|
"""Provide options for downloading scrobbles"""
|
||||||
|
|
||||||
|
EXPORT_TYPES = (
|
||||||
|
('as', 'Audioscrobbler'),
|
||||||
|
('csv', 'CSV'),
|
||||||
|
('html', 'HTML'),
|
||||||
|
)
|
||||||
|
export_type = forms.ChoiceField(choices=EXPORT_TYPES)
|
||||||
|
|
||||||
|
|
||||||
class ScrobbleForm(forms.Form):
|
class ScrobbleForm(forms.Form):
|
||||||
item_id = forms.CharField(
|
item_id = forms.CharField(
|
||||||
label="",
|
label="",
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from imdb import Cinemagoer
|
from imdb import Cinemagoer
|
||||||
from videos.models import Video
|
|
||||||
|
|
||||||
imdb_client = Cinemagoer()
|
imdb_client = Cinemagoer()
|
||||||
|
|
||||||
|
|||||||
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,52 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-02-03 19:50
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django_extensions.db.fields
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('scrobbles', '0011_chartrecord_user'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='AudioScrobblerTSVImport',
|
||||||
|
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'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'tsv_file',
|
||||||
|
models.FileField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
upload_to='audioscrobbler-uploads/%Y/%m-%d/',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'get_latest_by': 'modified',
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-02-03 20:20
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('scrobbles', '0012_audioscrobblertsvimport'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='audioscrobblertsvimport',
|
||||||
|
name='processed_on',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-02-03 22:53
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('scrobbles', '0013_audioscrobblertsvimport_processed_on'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='audioscrobblertsvimport',
|
||||||
|
name='process_log',
|
||||||
|
field=models.TextField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-02-03 23:36
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import scrobbles.models
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('scrobbles', '0014_audioscrobblertsvimport_process_log'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='audioscrobblertsvimport',
|
||||||
|
name='uuid',
|
||||||
|
field=models.UUIDField(default=uuid.uuid4, editable=False),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='audioscrobblertsvimport',
|
||||||
|
name='tsv_file',
|
||||||
|
field=models.FileField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
upload_to=scrobbles.models.AudioScrobblerTSVImport.get_path,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-02-03 23:44
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('scrobbles', '0015_audioscrobblertsvimport_uuid_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='audioscrobblertsvimport',
|
||||||
|
name='process_count',
|
||||||
|
field=models.IntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-02-07 00:07
|
||||||
|
|
||||||
|
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', '0016_audioscrobblertsvimport_process_count'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='audioscrobblertsvimport',
|
||||||
|
name='user',
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -7,6 +7,8 @@ BNULL = {"blank": True, "null": True}
|
|||||||
|
|
||||||
|
|
||||||
class ScrobblableMixin(TimeStampedModel):
|
class ScrobblableMixin(TimeStampedModel):
|
||||||
|
SECONDS_TO_STALE = 1600
|
||||||
|
|
||||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||||
title = models.CharField(max_length=255, **BNULL)
|
title = models.CharField(max_length=255, **BNULL)
|
||||||
run_time = models.CharField(max_length=8, **BNULL)
|
run_time = models.CharField(max_length=8, **BNULL)
|
||||||
|
|||||||
@ -1,23 +1,160 @@
|
|||||||
|
import calendar
|
||||||
import logging
|
import logging
|
||||||
from datetime import timedelta
|
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django_extensions.db.models import TimeStampedModel
|
from django_extensions.db.models import TimeStampedModel
|
||||||
from music.models import Track
|
from music.models import Artist, Track
|
||||||
from podcasts.models import Episode
|
from podcasts.models import Episode
|
||||||
from scrobbles.utils import check_scrobble_for_finish
|
from scrobbles.utils import check_scrobble_for_finish
|
||||||
from sports.models import SportEvent
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
BNULL = {"blank": True, "null": True}
|
BNULL = {"blank": True, "null": True}
|
||||||
|
|
||||||
|
|
||||||
|
class AudioScrobblerTSVImport(TimeStampedModel):
|
||||||
|
def get_path(instance, filename):
|
||||||
|
extension = filename.split('.')[-1]
|
||||||
|
uuid = instance.uuid
|
||||||
|
return f'audioscrobbler-uploads/{uuid}.{extension}'
|
||||||
|
|
||||||
|
user = models.ForeignKey(User, on_delete=models.DO_NOTHING, **BNULL)
|
||||||
|
uuid = models.UUIDField(editable=False, default=uuid4)
|
||||||
|
tsv_file = models.FileField(upload_to=get_path, **BNULL)
|
||||||
|
processed_on = models.DateTimeField(**BNULL)
|
||||||
|
process_log = models.TextField(**BNULL)
|
||||||
|
process_count = models.IntegerField(**BNULL)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
if self.tsv_file:
|
||||||
|
return f"Audioscrobbler TSV upload: {self.tsv_file.path}"
|
||||||
|
return f"Audioscrobbler TSV upload {self.id}"
|
||||||
|
|
||||||
|
def save(self, **kwargs):
|
||||||
|
"""On save, attempt to import the TSV file"""
|
||||||
|
super().save(**kwargs)
|
||||||
|
self.process()
|
||||||
|
return
|
||||||
|
|
||||||
|
def process(self, force=False):
|
||||||
|
from scrobbles.tsv import process_audioscrobbler_tsv_file
|
||||||
|
|
||||||
|
if self.processed_on and not force:
|
||||||
|
logger.info(f"{self} already processed on {self.processed_on}")
|
||||||
|
return
|
||||||
|
|
||||||
|
tz = None
|
||||||
|
if self.user:
|
||||||
|
tz = self.user.profile.tzinfo
|
||||||
|
scrobbles = process_audioscrobbler_tsv_file(
|
||||||
|
self.tsv_file.path, user_tz=tz
|
||||||
|
)
|
||||||
|
if scrobbles:
|
||||||
|
self.process_log = f"Created {len(scrobbles)} scrobbles"
|
||||||
|
for scrobble in scrobbles:
|
||||||
|
scrobble_str = f"{scrobble.id}\t{scrobble.timestamp}\t{scrobble.track.title}"
|
||||||
|
self.process_log += f"\n{scrobble_str}"
|
||||||
|
self.process_count = len(scrobbles)
|
||||||
|
else:
|
||||||
|
self.process_log = f"Created no new scrobbles"
|
||||||
|
self.process_count = 0
|
||||||
|
|
||||||
|
self.processed_on = timezone.now()
|
||||||
|
self.save(
|
||||||
|
update_fields=['processed_on', 'process_count', 'process_log']
|
||||||
|
)
|
||||||
|
|
||||||
|
def undo(self, dryrun=True):
|
||||||
|
from scrobbles.tsv import undo_audioscrobbler_tsv_import
|
||||||
|
|
||||||
|
undo_audioscrobbler_tsv_import(self.process_log, dryrun)
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
class Scrobble(TimeStampedModel):
|
||||||
|
"""A scrobble tracks played media items by a user."""
|
||||||
|
|
||||||
uuid = models.UUIDField(editable=False, **BNULL)
|
uuid = models.UUIDField(editable=False, **BNULL)
|
||||||
video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL)
|
video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL)
|
||||||
track = models.ForeignKey(Track, on_delete=models.DO_NOTHING, **BNULL)
|
track = models.ForeignKey(Track, on_delete=models.DO_NOTHING, **BNULL)
|
||||||
@ -56,24 +193,44 @@ class Scrobble(TimeStampedModel):
|
|||||||
return 'in-progress'
|
return 'in-progress'
|
||||||
return 'zombie'
|
return 'zombie'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_stale(self) -> bool:
|
||||||
|
"""Mark scrobble as stale if it's been more than an hour since it was updated"""
|
||||||
|
is_stale = False
|
||||||
|
now = timezone.now()
|
||||||
|
seconds_since_last_update = (now - self.modified).seconds
|
||||||
|
if seconds_since_last_update >= self.media_obj.SECONDS_TO_STALE:
|
||||||
|
is_stale = True
|
||||||
|
return is_stale
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def percent_played(self) -> int:
|
def percent_played(self) -> int:
|
||||||
if not self.media_obj.run_time_ticks:
|
if not self.media_obj.run_time_ticks:
|
||||||
logger.warning(
|
return 100
|
||||||
f"{self} has no run_time_ticks value, cannot show percent played"
|
|
||||||
)
|
if not self.playback_position_ticks and self.played_to_completion:
|
||||||
return 100
|
return 100
|
||||||
|
|
||||||
playback_ticks = self.playback_position_ticks
|
playback_ticks = self.playback_position_ticks
|
||||||
if not playback_ticks:
|
if not playback_ticks:
|
||||||
playback_ticks = (timezone.now() - self.timestamp).seconds * 1000
|
playback_ticks = (timezone.now() - self.timestamp).seconds * 1000
|
||||||
|
|
||||||
if self.played_to_completion:
|
|
||||||
playback_ticks = self.media_obj.run_time_ticks
|
|
||||||
|
|
||||||
percent = int((playback_ticks / self.media_obj.run_time_ticks) * 100)
|
percent = int((playback_ticks / self.media_obj.run_time_ticks) * 100)
|
||||||
|
if percent > 100:
|
||||||
|
percent = 100
|
||||||
return percent
|
return percent
|
||||||
|
|
||||||
|
@property
|
||||||
|
def can_be_updated(self) -> bool:
|
||||||
|
updatable = True
|
||||||
|
if self.percent_played > 100:
|
||||||
|
logger.info(f"No - 100% played - {self.id} - {self.source}")
|
||||||
|
updatable = False
|
||||||
|
if self.is_stale:
|
||||||
|
logger.info(f"No - stale - {self.id} - {self.source}")
|
||||||
|
updatable = False
|
||||||
|
return updatable
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def media_obj(self):
|
def media_obj(self):
|
||||||
media_obj = None
|
media_obj = None
|
||||||
@ -92,151 +249,71 @@ class Scrobble(TimeStampedModel):
|
|||||||
return f"Scrobble of {self.media_obj} ({timestamp})"
|
return f"Scrobble of {self.media_obj} ({timestamp})"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create_or_update_for_video(
|
def create_or_update(
|
||||||
cls, video: "Video", user_id: int, scrobble_data: dict
|
cls, media, user_id: int, scrobble_data: dict
|
||||||
) -> "Scrobble":
|
) -> "Scrobble":
|
||||||
scrobble_data['video_id'] = video.id
|
|
||||||
|
if media.__class__.__name__ == 'Track':
|
||||||
|
media_query = models.Q(track=media)
|
||||||
|
scrobble_data['track_id'] = media.id
|
||||||
|
if media.__class__.__name__ == 'Video':
|
||||||
|
media_query = models.Q(video=media)
|
||||||
|
scrobble_data['video_id'] = media.id
|
||||||
|
if media.__class__.__name__ == 'Episode':
|
||||||
|
media_query = models.Q(podcast_episode=media)
|
||||||
|
scrobble_data['podcast_episode_id'] = media.id
|
||||||
|
if media.__class__.__name__ == 'SportEvent':
|
||||||
|
media_query = models.Q(sport_event=media)
|
||||||
|
scrobble_data['sport_event_id'] = media.id
|
||||||
|
|
||||||
scrobble = (
|
scrobble = (
|
||||||
cls.objects.filter(
|
cls.objects.filter(
|
||||||
video=video,
|
media_query,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
.order_by('-modified')
|
.order_by('-modified')
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
if scrobble and scrobble.playback_perecnt <= 100:
|
if scrobble and scrobble.can_be_updated:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Found existing scrobble for video {video}, updating",
|
f"Updating {scrobble.id}",
|
||||||
{"scrobble_data": scrobble_data},
|
{"scrobble_data": scrobble_data, "media": media},
|
||||||
)
|
)
|
||||||
return cls.update(scrobble, scrobble_data)
|
return scrobble.update(scrobble_data)
|
||||||
|
|
||||||
logger.debug(
|
source = scrobble_data['source']
|
||||||
f"No existing scrobble for video {video}, creating",
|
logger.info(
|
||||||
{"scrobble_data": scrobble_data},
|
f"Creating for {media.id} - {source}",
|
||||||
)
|
{"scrobble_data": scrobble_data, "media": media},
|
||||||
# If creating a new scrobble, we don't need status
|
|
||||||
scrobble_data.pop('jellyfin_status')
|
|
||||||
return cls.create(scrobble_data)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def create_or_update_for_track(
|
|
||||||
cls, track: "Track", user_id: int, scrobble_data: dict
|
|
||||||
) -> "Scrobble":
|
|
||||||
"""Look up any existing scrobbles for a track and compare
|
|
||||||
the appropriate backoff time for music tracks to the setting
|
|
||||||
so we can avoid duplicating scrobbles."""
|
|
||||||
scrobble_data['track_id'] = track.id
|
|
||||||
|
|
||||||
scrobble = (
|
|
||||||
cls.objects.filter(
|
|
||||||
track=track,
|
|
||||||
user_id=user_id,
|
|
||||||
played_to_completion=False,
|
|
||||||
)
|
|
||||||
.order_by('-modified')
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if scrobble:
|
|
||||||
logger.debug(
|
|
||||||
f"Found existing scrobble for track {track}, updating",
|
|
||||||
{"scrobble_data": scrobble_data},
|
|
||||||
)
|
|
||||||
return cls.update(scrobble, scrobble_data)
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
f"No existing scrobble for track {track}, creating",
|
|
||||||
{"scrobble_data": scrobble_data},
|
|
||||||
)
|
)
|
||||||
# If creating a new scrobble, we don't need status
|
# If creating a new scrobble, we don't need status
|
||||||
scrobble_data.pop('mopidy_status', None)
|
scrobble_data.pop('mopidy_status', None)
|
||||||
scrobble_data.pop('jellyfin_status', None)
|
scrobble_data.pop('jellyfin_status', None)
|
||||||
return cls.create(scrobble_data)
|
return cls.create(scrobble_data)
|
||||||
|
|
||||||
@classmethod
|
def update(self, scrobble_data: dict) -> "Scrobble":
|
||||||
def create_or_update_for_podcast_episode(
|
|
||||||
cls, episode: "Episode", user_id: int, scrobble_data: dict
|
|
||||||
) -> "Scrobble":
|
|
||||||
scrobble_data['podcast_episode_id'] = episode.id
|
|
||||||
|
|
||||||
scrobble = (
|
|
||||||
cls.objects.filter(
|
|
||||||
podcast_episode=episode,
|
|
||||||
user_id=user_id,
|
|
||||||
played_to_completion=False,
|
|
||||||
)
|
|
||||||
.order_by('-modified')
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if scrobble:
|
|
||||||
logger.debug(
|
|
||||||
f"Found existing scrobble for podcast {episode}, updating",
|
|
||||||
{"scrobble_data": scrobble_data},
|
|
||||||
)
|
|
||||||
return cls.update(scrobble, scrobble_data)
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
f"No existing scrobble for podcast epsiode {episode}, creating",
|
|
||||||
{"scrobble_data": scrobble_data},
|
|
||||||
)
|
|
||||||
# If creating a new scrobble, we don't need status
|
|
||||||
scrobble_data.pop('mopidy_status')
|
|
||||||
return cls.create(scrobble_data)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def create_or_update_for_sport_event(
|
|
||||||
cls, event: "SportEvent", user_id: int, scrobble_data: dict
|
|
||||||
) -> "Scrobble":
|
|
||||||
scrobble_data['sport_event_id'] = event.id
|
|
||||||
scrobble = (
|
|
||||||
cls.objects.filter(
|
|
||||||
sport_event=event,
|
|
||||||
user_id=user_id,
|
|
||||||
played_to_completion=False,
|
|
||||||
)
|
|
||||||
.order_by('-modified')
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if scrobble:
|
|
||||||
logger.debug(
|
|
||||||
f"Found existing scrobble for sport event {event}, updating",
|
|
||||||
{"scrobble_data": scrobble_data},
|
|
||||||
)
|
|
||||||
return cls.update(scrobble, scrobble_data)
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
f"No existing scrobble for sport event {event}, creating",
|
|
||||||
{"scrobble_data": scrobble_data},
|
|
||||||
)
|
|
||||||
# If creating a new scrobble, we don't need status
|
|
||||||
scrobble_data.pop('jellyfin_status')
|
|
||||||
return cls.create(scrobble_data)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def update(cls, scrobble: "Scrobble", scrobble_data: dict) -> "Scrobble":
|
|
||||||
# Status is a field we get from Mopidy, which refuses to poll us
|
# Status is a field we get from Mopidy, which refuses to poll us
|
||||||
scrobble_status = scrobble_data.pop('mopidy_status', None)
|
scrobble_status = scrobble_data.pop('mopidy_status', None)
|
||||||
if not scrobble_status:
|
if not scrobble_status:
|
||||||
scrobble_status = scrobble_data.pop('jellyfin_status', None)
|
scrobble_status = scrobble_data.pop('jellyfin_status', None)
|
||||||
|
|
||||||
logger.debug(f"Scrobbling to {scrobble} with status {scrobble_status}")
|
if self.percent_played < 100:
|
||||||
scrobble.update_ticks(scrobble_data)
|
# Only worry about ticks if we haven't gotten to the end
|
||||||
|
self.update_ticks(scrobble_data)
|
||||||
|
|
||||||
# On stop, stop progress and send it to the check for completion
|
# On stop, stop progress and send it to the check for completion
|
||||||
if scrobble_status == "stopped":
|
if scrobble_status == "stopped":
|
||||||
scrobble.stop()
|
self.stop()
|
||||||
# On pause, set is_paused and stop scrobbling
|
# On pause, set is_paused and stop scrobbling
|
||||||
if scrobble_status == "paused":
|
if scrobble_status == "paused":
|
||||||
scrobble.pause()
|
self.pause()
|
||||||
if scrobble_status == "resumed":
|
if scrobble_status == "resumed":
|
||||||
scrobble.resume()
|
self.resume()
|
||||||
|
|
||||||
for key, value in scrobble_data.items():
|
for key, value in scrobble_data.items():
|
||||||
setattr(scrobble, key, value)
|
setattr(self, key, value)
|
||||||
scrobble.save()
|
self.save()
|
||||||
check_scrobble_for_finish(scrobble)
|
return self
|
||||||
return scrobble
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(
|
def create(
|
||||||
@ -251,24 +328,26 @@ class Scrobble(TimeStampedModel):
|
|||||||
|
|
||||||
def stop(self, force_finish=False) -> None:
|
def stop(self, force_finish=False) -> None:
|
||||||
if not self.in_progress:
|
if not self.in_progress:
|
||||||
logger.warning("Scrobble already stopped")
|
|
||||||
return
|
return
|
||||||
self.in_progress = False
|
self.in_progress = False
|
||||||
self.save(update_fields=['in_progress'])
|
self.save(update_fields=['in_progress'])
|
||||||
|
logger.info(f"{self.id} - {self.source}")
|
||||||
check_scrobble_for_finish(self, force_finish)
|
check_scrobble_for_finish(self, force_finish)
|
||||||
|
|
||||||
def pause(self) -> None:
|
def pause(self) -> None:
|
||||||
if self.is_paused:
|
if self.is_paused:
|
||||||
logger.warning("Scrobble already paused")
|
logger.warning(f"{self.id} - already paused - {self.source}")
|
||||||
return
|
return
|
||||||
self.is_paused = True
|
self.is_paused = True
|
||||||
self.save(update_fields=["is_paused"])
|
self.save(update_fields=["is_paused"])
|
||||||
|
logger.info(f"{self.id} - pausing - {self.source}")
|
||||||
check_scrobble_for_finish(self)
|
check_scrobble_for_finish(self)
|
||||||
|
|
||||||
def resume(self) -> None:
|
def resume(self) -> None:
|
||||||
if self.is_paused or not self.in_progress:
|
if self.is_paused or not self.in_progress:
|
||||||
self.is_paused = False
|
self.is_paused = False
|
||||||
self.in_progress = True
|
self.in_progress = True
|
||||||
|
logger.info(f"{self.id} - resuming - {self.source}")
|
||||||
return self.save(update_fields=["is_paused", "in_progress"])
|
return self.save(update_fields=["is_paused", "in_progress"])
|
||||||
|
|
||||||
def cancel(self) -> None:
|
def cancel(self) -> None:
|
||||||
@ -278,8 +357,8 @@ class Scrobble(TimeStampedModel):
|
|||||||
def update_ticks(self, data) -> None:
|
def update_ticks(self, data) -> None:
|
||||||
self.playback_position_ticks = data.get("playback_position_ticks")
|
self.playback_position_ticks = data.get("playback_position_ticks")
|
||||||
self.playback_position = data.get("playback_position")
|
self.playback_position = data.get("playback_position")
|
||||||
logger.debug(
|
logger.info(
|
||||||
f"Updating scrobble ticks to {self.playback_position_ticks}"
|
f"{self.id} - {self.playback_position_ticks} - {self.source}"
|
||||||
)
|
)
|
||||||
self.save(
|
self.save(
|
||||||
update_fields=['playback_position_ticks', 'playback_position']
|
update_fields=['playback_position_ticks', 'playback_position']
|
||||||
|
|||||||
87
vrobbler/apps/scrobbles/musicbrainz.py
Normal file
87
vrobbler/apps/scrobbles/musicbrainz.py
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
import musicbrainzngs
|
||||||
|
from dateutil.parser import parse
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_album_from_mb(musicbrainz_id: str) -> dict:
|
||||||
|
release_dict = {}
|
||||||
|
|
||||||
|
musicbrainzngs.set_useragent('vrobbler', '0.3.0')
|
||||||
|
release_data = musicbrainzngs.get_release_by_id(
|
||||||
|
musicbrainz_id,
|
||||||
|
includes=['artists', 'release-groups', 'recordings'],
|
||||||
|
).get('release')
|
||||||
|
|
||||||
|
if not release_data:
|
||||||
|
return release_dict
|
||||||
|
|
||||||
|
primary_artist = release_data.get('artist-credit')[0]
|
||||||
|
release_dict = {
|
||||||
|
'artist': {
|
||||||
|
'name': primary_artist.get('name'),
|
||||||
|
'musicbrainz_id': primary_artist.get('id'),
|
||||||
|
},
|
||||||
|
'album': {
|
||||||
|
'name': release_data.get('title'),
|
||||||
|
'musicbrainz_id': musicbrainz_id,
|
||||||
|
'musicbrainz_releasegroup_id': release_data.get(
|
||||||
|
'release-group'
|
||||||
|
).get('id'),
|
||||||
|
'musicbrainz_albumaritist_id': primary_artist.get('id'),
|
||||||
|
'year': release_data.get('year')[0:4],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
release_dict['tracks'] = []
|
||||||
|
for track in release_data.get('medium-list')[0]['track-list']:
|
||||||
|
recording = track['recording']
|
||||||
|
release_dict['tracks'].append(
|
||||||
|
{
|
||||||
|
'title': recording['title'],
|
||||||
|
'musicbrainz_id': recording['id'],
|
||||||
|
'run_time_ticks': track['length'],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return release_dict
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_album_dict_from_mb(release_name: str, artist_name: str) -> dict:
|
||||||
|
musicbrainzngs.set_useragent('vrobbler', '0.3.0')
|
||||||
|
|
||||||
|
top_result = musicbrainzngs.search_releases(
|
||||||
|
release_name, artist=artist_name
|
||||||
|
)['release-list'][0]
|
||||||
|
score = int(top_result.get('ext:score'))
|
||||||
|
if score < 85:
|
||||||
|
logger.debug(
|
||||||
|
"Album lookup score below 85 threshold",
|
||||||
|
extra={"result": top_result},
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
print(top_result)
|
||||||
|
return {
|
||||||
|
"year": parse(top_result["date"]).year,
|
||||||
|
"mb_id": top_result["id"],
|
||||||
|
"mb_group_id": top_result["release-group"]["id"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_artist_id_from_mb(artist_name: str) -> str:
|
||||||
|
musicbrainzngs.set_useragent('vrobbler', '0.3.0')
|
||||||
|
|
||||||
|
top_result = musicbrainzngs.search_artists(artist=artist_name)[
|
||||||
|
'artist-list'
|
||||||
|
][0]
|
||||||
|
score = int(top_result.get('ext:score'))
|
||||||
|
if score < 85:
|
||||||
|
logger.debug(
|
||||||
|
"Artist lookup score below 85 threshold",
|
||||||
|
extra={"result": top_result},
|
||||||
|
)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
return top_result["id"]
|
||||||
@ -50,9 +50,7 @@ def mopidy_scrobble_podcast(
|
|||||||
|
|
||||||
scrobble = None
|
scrobble = None
|
||||||
if episode:
|
if episode:
|
||||||
scrobble = Scrobble.create_or_update_for_podcast_episode(
|
scrobble = Scrobble.create_or_update(episode, user_id, mopidy_data)
|
||||||
episode, user_id, mopidy_data
|
|
||||||
)
|
|
||||||
return scrobble
|
return scrobble
|
||||||
|
|
||||||
|
|
||||||
@ -90,35 +88,27 @@ def mopidy_scrobble_track(
|
|||||||
track.musicbrainz_id = data_dict.get("musicbrainz_track_id")
|
track.musicbrainz_id = data_dict.get("musicbrainz_track_id")
|
||||||
track.save()
|
track.save()
|
||||||
|
|
||||||
scrobble = Scrobble.create_or_update_for_track(track, user_id, mopidy_data)
|
scrobble = Scrobble.create_or_update(track, user_id, mopidy_data)
|
||||||
|
|
||||||
return scrobble
|
return scrobble
|
||||||
|
|
||||||
|
|
||||||
def create_jellyfin_scrobble_dict(data_dict: dict, user_id: int) -> dict:
|
def build_scrobble_dict(data_dict: dict, user_id: int) -> dict:
|
||||||
jellyfin_status = "resumed"
|
jellyfin_status = "resumed"
|
||||||
if data_dict.get("IsPaused"):
|
if data_dict.get("IsPaused"):
|
||||||
jellyfin_status = "paused"
|
jellyfin_status = "paused"
|
||||||
if data_dict.get("PlayedToCompletion"):
|
elif data_dict.get("NotificationType") == 'PlaybackStop':
|
||||||
jellyfin_status = "stopped"
|
jellyfin_status = "stopped"
|
||||||
|
|
||||||
playback_position_ticks = None
|
playback_ticks = data_dict.get("PlaybackPositionTicks", "")
|
||||||
if data_dict.get("PlaybackPositionTicks"):
|
if playback_ticks:
|
||||||
playback_position_ticks = (
|
playback_ticks = playback_ticks // 10000
|
||||||
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 {
|
return {
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"timestamp": parse(data_dict.get("UtcTimestamp")),
|
"timestamp": parse(data_dict.get("UtcTimestamp")),
|
||||||
"playback_position_ticks": playback_position_ticks,
|
"playback_position_ticks": playback_ticks,
|
||||||
"playback_position": playback_position,
|
"playback_position": data_dict.get("PlaybackPosition", ""),
|
||||||
"source": data_dict.get("ClientName", "Vrobbler"),
|
"source": data_dict.get("ClientName", "Vrobbler"),
|
||||||
"source_id": data_dict.get('MediaSourceId'),
|
"source_id": data_dict.get('MediaSourceId'),
|
||||||
"jellyfin_status": jellyfin_status,
|
"jellyfin_status": jellyfin_status,
|
||||||
@ -128,12 +118,24 @@ def create_jellyfin_scrobble_dict(data_dict: dict, user_id: int) -> dict:
|
|||||||
def jellyfin_scrobble_track(
|
def jellyfin_scrobble_track(
|
||||||
data_dict: dict, user_id: Optional[int]
|
data_dict: dict, user_id: Optional[int]
|
||||||
) -> Optional[Scrobble]:
|
) -> Optional[Scrobble]:
|
||||||
|
|
||||||
if not data_dict.get("Provider_musicbrainztrack", None):
|
if not data_dict.get("Provider_musicbrainztrack", None):
|
||||||
|
# TODO we should be able to look up tracks via MB rather than error out
|
||||||
logger.error(
|
logger.error(
|
||||||
"No MBrainz Track ID received. This is likely because all metadata is bad, not scrobbling"
|
"No MBrainz Track ID received. This is likely because all metadata is bad, not scrobbling"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
null_position_on_progress = (
|
||||||
|
data_dict.get("PlaybackPosition") == "00:00:00"
|
||||||
|
and data_dict.get("NotificationType") == "PlaybackProgress"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Jellyfin has some race conditions with it's webhooks, these hacks fix some of them
|
||||||
|
if not data_dict.get("PlaybackPositionTicks") or null_position_on_progress:
|
||||||
|
logger.error("No playback position tick from Jellyfin, aborting")
|
||||||
|
return
|
||||||
|
|
||||||
artist_dict = {
|
artist_dict = {
|
||||||
'name': data_dict.get(JELLYFIN_POST_KEYS["ARTIST_NAME"], None),
|
'name': data_dict.get(JELLYFIN_POST_KEYS["ARTIST_NAME"], None),
|
||||||
'musicbrainz_id': data_dict.get(
|
'musicbrainz_id': data_dict.get(
|
||||||
@ -167,9 +169,13 @@ def jellyfin_scrobble_track(
|
|||||||
)
|
)
|
||||||
track.save()
|
track.save()
|
||||||
|
|
||||||
scrobble_dict = create_jellyfin_scrobble_dict(data_dict, user_id)
|
scrobble_dict = build_scrobble_dict(data_dict, user_id)
|
||||||
|
|
||||||
return Scrobble.create_or_update_for_track(track, user_id, scrobble_dict)
|
# A hack to make Jellyfin work more like Mopidy for music tracks
|
||||||
|
scrobble_dict["playback_position_ticks"] = 0
|
||||||
|
scrobble_dict["playback_position"] = ""
|
||||||
|
|
||||||
|
return Scrobble.create_or_update(track, user_id, scrobble_dict)
|
||||||
|
|
||||||
|
|
||||||
def jellyfin_scrobble_video(data_dict: dict, user_id: Optional[int]):
|
def jellyfin_scrobble_video(data_dict: dict, user_id: Optional[int]):
|
||||||
@ -180,9 +186,9 @@ def jellyfin_scrobble_video(data_dict: dict, user_id: Optional[int]):
|
|||||||
return
|
return
|
||||||
video = Video.find_or_create(data_dict)
|
video = Video.find_or_create(data_dict)
|
||||||
|
|
||||||
scrobble_dict = create_jellyfin_scrobble_dict(data_dict, user_id)
|
scrobble_dict = build_scrobble_dict(data_dict, user_id)
|
||||||
|
|
||||||
return Scrobble.create_or_update_for_video(video, user_id, scrobble_dict)
|
return Scrobble.create_or_update(video, user_id, scrobble_dict)
|
||||||
|
|
||||||
|
|
||||||
def manual_scrobble_video(data_dict: dict, user_id: Optional[int]):
|
def manual_scrobble_video(data_dict: dict, user_id: Optional[int]):
|
||||||
@ -193,9 +199,9 @@ def manual_scrobble_video(data_dict: dict, user_id: Optional[int]):
|
|||||||
return
|
return
|
||||||
video = Video.find_or_create(data_dict)
|
video = Video.find_or_create(data_dict)
|
||||||
|
|
||||||
scrobble_dict = create_jellyfin_scrobble_dict(data_dict, user_id)
|
scrobble_dict = build_scrobble_dict(data_dict, user_id)
|
||||||
|
|
||||||
return Scrobble.create_or_update_for_video(video, user_id, scrobble_dict)
|
return Scrobble.create_or_update(video, user_id, scrobble_dict)
|
||||||
|
|
||||||
|
|
||||||
def manual_scrobble_event(data_dict: dict, user_id: Optional[int]):
|
def manual_scrobble_event(data_dict: dict, user_id: Optional[int]):
|
||||||
@ -206,8 +212,6 @@ def manual_scrobble_event(data_dict: dict, user_id: Optional[int]):
|
|||||||
return
|
return
|
||||||
event = SportEvent.find_or_create(data_dict)
|
event = SportEvent.find_or_create(data_dict)
|
||||||
|
|
||||||
scrobble_dict = create_jellyfin_scrobble_dict(data_dict, user_id)
|
scrobble_dict = build_scrobble_dict(data_dict, user_id)
|
||||||
|
|
||||||
return Scrobble.create_or_update_for_sport_event(
|
return Scrobble.create_or_update(event, user_id, scrobble_dict)
|
||||||
event, user_id, scrobble_dict
|
|
||||||
)
|
|
||||||
|
|||||||
@ -1,5 +1,11 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from scrobbles.models import Scrobble
|
from scrobbles.models import Scrobble, AudioScrobblerTSVImport
|
||||||
|
|
||||||
|
|
||||||
|
class AudioScrobblerTSVImportSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = AudioScrobblerTSVImport
|
||||||
|
fields = ('tsv_file',)
|
||||||
|
|
||||||
|
|
||||||
class ScrobbleSerializer(serializers.ModelSerializer):
|
class ScrobbleSerializer(serializers.ModelSerializer):
|
||||||
|
|||||||
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)
|
||||||
@ -19,9 +19,10 @@ def lookup_event_from_thesportsdb(event_id: str) -> dict:
|
|||||||
return {}
|
return {}
|
||||||
league = {} # client.lookup_league(league_id=event.get('idLeague'))
|
league = {} # client.lookup_league(league_id=event.get('idLeague'))
|
||||||
event_type = "Game"
|
event_type = "Game"
|
||||||
sport = Sport.objects.filter(thesportsdb_id=event.get('strSport')).first()
|
sport, _created = Sport.objects.get_or_create(
|
||||||
|
thesportsdb_id=event.get('strSport')
|
||||||
|
)
|
||||||
|
|
||||||
logger.debug(event)
|
|
||||||
data_dict = {
|
data_dict = {
|
||||||
"ItemType": sport.default_event_type,
|
"ItemType": sport.default_event_type,
|
||||||
"Name": event.get('strEvent'),
|
"Name": event.get('strEvent'),
|
||||||
|
|||||||
137
vrobbler/apps/scrobbles/tsv.py
Normal file
137
vrobbler/apps/scrobbles/tsv.py
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import csv
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
from music.models import Album, Artist, Track
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
|
from vrobbler.apps.scrobbles.musicbrainz import (
|
||||||
|
lookup_album_dict_from_mb,
|
||||||
|
lookup_artist_id_from_mb,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def process_audioscrobbler_tsv_file(file_path, user_tz=None):
|
||||||
|
"""Takes a path to a file of TSV data and imports it as past scrobbles"""
|
||||||
|
new_scrobbles = []
|
||||||
|
if not user_tz:
|
||||||
|
user_tz = pytz.utc
|
||||||
|
|
||||||
|
with open(file_path) as infile:
|
||||||
|
source = 'Audioscrobbler File'
|
||||||
|
rows = csv.reader(infile, delimiter="\t")
|
||||||
|
|
||||||
|
source_id = ""
|
||||||
|
for row_num, row in enumerate(rows):
|
||||||
|
if row_num in [0, 1, 2]:
|
||||||
|
source_id += row[0] + "\n"
|
||||||
|
continue
|
||||||
|
if len(row) > 8:
|
||||||
|
logger.warning(
|
||||||
|
'Improper row length during Audioscrobbler import',
|
||||||
|
extra={'row': row},
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
artist, artist_created = Artist.objects.get_or_create(name=row[0])
|
||||||
|
if artist_created:
|
||||||
|
artist.musicbrainz_id = lookup_artist_id_from_mb(artist.name)
|
||||||
|
artist.save(update_fields=["musicbrainz_id"])
|
||||||
|
|
||||||
|
album = None
|
||||||
|
album_created = False
|
||||||
|
albums = Album.objects.filter(name=row[1])
|
||||||
|
if albums.count() == 1:
|
||||||
|
album = albums.first()
|
||||||
|
else:
|
||||||
|
for potential_album in albums:
|
||||||
|
if artist in album.artist_set.all():
|
||||||
|
album = potential_album
|
||||||
|
if not album:
|
||||||
|
album_created = True
|
||||||
|
album = Album.objects.create(name=row[1])
|
||||||
|
album.save()
|
||||||
|
album.artists.add(artist)
|
||||||
|
|
||||||
|
if album_created:
|
||||||
|
album_dict = lookup_album_dict_from_mb(
|
||||||
|
album.name, artist_name=artist.name
|
||||||
|
)
|
||||||
|
album.year = album_dict["year"]
|
||||||
|
album.musicbrainz_id = album_dict["mb_id"]
|
||||||
|
album.musicbrainz_releasegroup_id = album_dict["mb_group_id"]
|
||||||
|
album.musicbrainz_albumartist_id = artist.musicbrainz_id
|
||||||
|
album.save(
|
||||||
|
update_fields=[
|
||||||
|
"year",
|
||||||
|
"musicbrainz_id",
|
||||||
|
"musicbrainz_releasegroup_id",
|
||||||
|
"musicbrainz_albumartist_id",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
album.artists.add(artist)
|
||||||
|
album.fetch_artwork()
|
||||||
|
|
||||||
|
track, track_created = Track.objects.get_or_create(
|
||||||
|
title=row[2],
|
||||||
|
artist=artist,
|
||||||
|
album=album,
|
||||||
|
)
|
||||||
|
|
||||||
|
if track_created:
|
||||||
|
track.musicbrainz_id = row[7]
|
||||||
|
track.run_time = int(row[4])
|
||||||
|
track.run_time_ticks = int(row[4]) * 1000
|
||||||
|
track.save()
|
||||||
|
|
||||||
|
timestamp = (
|
||||||
|
datetime.utcfromtimestamp(int(row[6]))
|
||||||
|
.replace(tzinfo=user_tz)
|
||||||
|
.astimezone(pytz.utc)
|
||||||
|
)
|
||||||
|
source = 'Audioscrobbler File'
|
||||||
|
|
||||||
|
new_scrobble = Scrobble(
|
||||||
|
timestamp=timestamp,
|
||||||
|
source=source,
|
||||||
|
source_id=source_id,
|
||||||
|
track=track,
|
||||||
|
played_to_completion=True,
|
||||||
|
in_progress=False,
|
||||||
|
)
|
||||||
|
existing = Scrobble.objects.filter(
|
||||||
|
timestamp=timestamp, track=track
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
logger.debug(f"Skipping existing scrobble {new_scrobble}")
|
||||||
|
continue
|
||||||
|
logger.debug(f"Queued scrobble {new_scrobble} for creation")
|
||||||
|
new_scrobbles.append(new_scrobble)
|
||||||
|
|
||||||
|
created = Scrobble.objects.bulk_create(new_scrobbles)
|
||||||
|
logger.info(
|
||||||
|
f"Created {len(created)} scrobbles",
|
||||||
|
extra={'created_scrobbles': created},
|
||||||
|
)
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
def undo_audioscrobbler_tsv_import(process_log, dryrun=True):
|
||||||
|
"""Accepts the log from a TSV import and removes the scrobbles"""
|
||||||
|
if not process_log:
|
||||||
|
logger.warning("No lines in process log found to undo")
|
||||||
|
return
|
||||||
|
|
||||||
|
for line_num, line in enumerate(process_log.split('\n')):
|
||||||
|
if line_num == 0:
|
||||||
|
continue
|
||||||
|
scrobble_id = line.split("\t")[0]
|
||||||
|
scrobble = Scrobble.objects.filter(id=scrobble_id).first()
|
||||||
|
if not scrobble:
|
||||||
|
logger.warning(f"Could not find scrobble {scrobble_id} to undo")
|
||||||
|
continue
|
||||||
|
logger.info(f"Removing scrobble {scrobble_id}")
|
||||||
|
if not dryrun:
|
||||||
|
scrobble.delete()
|
||||||
@ -7,6 +7,12 @@ urlpatterns = [
|
|||||||
path('', views.scrobble_endpoint, name='api-list'),
|
path('', views.scrobble_endpoint, name='api-list'),
|
||||||
path('finish/<slug:uuid>', views.scrobble_finish, name='finish'),
|
path('finish/<slug:uuid>', views.scrobble_finish, name='finish'),
|
||||||
path('cancel/<slug:uuid>', views.scrobble_cancel, name='cancel'),
|
path('cancel/<slug:uuid>', views.scrobble_cancel, name='cancel'),
|
||||||
|
path(
|
||||||
|
'upload/',
|
||||||
|
views.AudioScrobblerImportCreateView.as_view(),
|
||||||
|
name='audioscrobbler-file-upload',
|
||||||
|
),
|
||||||
path('jellyfin/', views.jellyfin_websocket, name='jellyfin-websocket'),
|
path('jellyfin/', views.jellyfin_websocket, name='jellyfin-websocket'),
|
||||||
path('mopidy/', views.mopidy_websocket, name='mopidy-websocket'),
|
path('mopidy/', views.mopidy_websocket, name='mopidy-websocket'),
|
||||||
|
path('export/', views.export, name='export'),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -67,18 +67,36 @@ def parse_mopidy_uri(uri: str) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def check_scrobble_for_finish(
|
def check_scrobble_for_finish(
|
||||||
scrobble: "Scrobble", force_finish=False
|
scrobble: "Scrobble", force_to_100=False, force_finish=False
|
||||||
) -> None:
|
) -> None:
|
||||||
completion_percent = scrobble.media_obj.COMPLETION_PERCENT
|
completion_percent = scrobble.media_obj.COMPLETION_PERCENT
|
||||||
|
|
||||||
if scrobble.percent_played >= completion_percent or force_finish:
|
if scrobble.percent_played >= completion_percent or force_finish:
|
||||||
logger.debug(f"Completion percent {completion_percent} met, finishing")
|
logger.info(f"{scrobble.id} {completion_percent} met, finishing")
|
||||||
|
|
||||||
|
if (
|
||||||
|
scrobble.playback_position_ticks
|
||||||
|
and scrobble.media_obj.run_time_ticks
|
||||||
|
and force_to_100
|
||||||
|
):
|
||||||
|
scrobble.playback_position_ticks = (
|
||||||
|
scrobble.media_obj.run_time_ticks
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"{scrobble.playback_position_ticks} set to {scrobble.media_obj.run_time_ticks}"
|
||||||
|
)
|
||||||
|
|
||||||
scrobble.in_progress = False
|
scrobble.in_progress = False
|
||||||
scrobble.is_paused = False
|
scrobble.is_paused = False
|
||||||
scrobble.played_to_completion = True
|
scrobble.played_to_completion = True
|
||||||
|
|
||||||
scrobble.save(
|
scrobble.save(
|
||||||
update_fields=["in_progress", "is_paused", "played_to_completion"]
|
update_fields=[
|
||||||
|
"in_progress",
|
||||||
|
"is_paused",
|
||||||
|
"played_to_completion",
|
||||||
|
'playback_position_ticks',
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
if scrobble.percent_played % 5 == 0:
|
if scrobble.percent_played % 5 == 0:
|
||||||
|
|||||||
@ -1,26 +1,34 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.db.models.fields import timezone
|
from django.db.models.fields import timezone
|
||||||
from django.http import HttpResponseRedirect
|
from django.http import FileResponse, HttpResponseRedirect, JsonResponse
|
||||||
from django.urls import reverse
|
from django.urls import reverse, reverse_lazy
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from django.views.generic import FormView
|
from django.views.generic import FormView
|
||||||
|
from django.views.generic.edit import CreateView
|
||||||
from django.views.generic.list import ListView
|
from django.views.generic.list import ListView
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.decorators import api_view, permission_classes
|
from rest_framework.decorators import (
|
||||||
|
api_view,
|
||||||
|
parser_classes,
|
||||||
|
permission_classes,
|
||||||
|
)
|
||||||
|
from rest_framework.parsers import MultiPartParser
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from scrobbles.constants import (
|
from scrobbles.constants import (
|
||||||
JELLYFIN_AUDIO_ITEM_TYPES,
|
JELLYFIN_AUDIO_ITEM_TYPES,
|
||||||
JELLYFIN_VIDEO_ITEM_TYPES,
|
JELLYFIN_VIDEO_ITEM_TYPES,
|
||||||
)
|
)
|
||||||
from scrobbles.forms import ScrobbleForm
|
from scrobbles.forms import ExportScrobbleForm, ScrobbleForm
|
||||||
from scrobbles.imdb import lookup_video_from_imdb
|
from scrobbles.imdb import lookup_video_from_imdb
|
||||||
from scrobbles.models import Scrobble
|
from scrobbles.models import AudioScrobblerTSVImport, Scrobble
|
||||||
from scrobbles.scrobblers import (
|
from scrobbles.scrobblers import (
|
||||||
jellyfin_scrobble_track,
|
jellyfin_scrobble_track,
|
||||||
jellyfin_scrobble_video,
|
jellyfin_scrobble_video,
|
||||||
@ -29,7 +37,10 @@ from scrobbles.scrobblers import (
|
|||||||
mopidy_scrobble_podcast,
|
mopidy_scrobble_podcast,
|
||||||
mopidy_scrobble_track,
|
mopidy_scrobble_track,
|
||||||
)
|
)
|
||||||
from scrobbles.serializers import ScrobbleSerializer
|
from scrobbles.serializers import (
|
||||||
|
AudioScrobblerTSVImportSerializer,
|
||||||
|
ScrobbleSerializer,
|
||||||
|
)
|
||||||
from scrobbles.thesportsdb import lookup_event_from_thesportsdb
|
from scrobbles.thesportsdb import lookup_event_from_thesportsdb
|
||||||
|
|
||||||
from vrobbler.apps.music.aggregators import (
|
from vrobbler.apps.music.aggregators import (
|
||||||
@ -38,6 +49,7 @@ from vrobbler.apps.music.aggregators import (
|
|||||||
top_tracks,
|
top_tracks,
|
||||||
week_of_scrobbles,
|
week_of_scrobbles,
|
||||||
)
|
)
|
||||||
|
from vrobbler.apps.scrobbles.export import export_scrobbles
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -87,6 +99,7 @@ class RecentScrobbleList(ListView):
|
|||||||
|
|
||||||
data['counts'] = scrobble_counts(user)
|
data['counts'] = scrobble_counts(user)
|
||||||
data['imdb_form'] = ScrobbleForm
|
data['imdb_form'] = ScrobbleForm
|
||||||
|
data['export_form'] = ExportScrobbleForm
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
@ -118,7 +131,49 @@ class ManualScrobbleView(FormView):
|
|||||||
if data_dict:
|
if data_dict:
|
||||||
manual_scrobble_event(data_dict, self.request.user.id)
|
manual_scrobble_event(data_dict, self.request.user.id)
|
||||||
|
|
||||||
return HttpResponseRedirect(reverse("home"))
|
return HttpResponseRedirect(reverse("vrobbler-home"))
|
||||||
|
|
||||||
|
|
||||||
|
class JsonableResponseMixin:
|
||||||
|
"""
|
||||||
|
Mixin to add JSON support to a form.
|
||||||
|
Must be used with an object-based FormView (e.g. CreateView)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def form_invalid(self, form):
|
||||||
|
response = super().form_invalid(form)
|
||||||
|
if self.request.accepts('text/html'):
|
||||||
|
return response
|
||||||
|
else:
|
||||||
|
return JsonResponse(form.errors, status=400)
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
# We make sure to call the parent's form_valid() method because
|
||||||
|
# it might do some processing (in the case of CreateView, it will
|
||||||
|
# call form.save() for example).
|
||||||
|
response = super().form_valid(form)
|
||||||
|
if self.request.accepts('text/html'):
|
||||||
|
return response
|
||||||
|
else:
|
||||||
|
data = {
|
||||||
|
'pk': self.object.pk,
|
||||||
|
}
|
||||||
|
return JsonResponse(data)
|
||||||
|
|
||||||
|
|
||||||
|
class AudioScrobblerImportCreateView(
|
||||||
|
LoginRequiredMixin, JsonableResponseMixin, CreateView
|
||||||
|
):
|
||||||
|
model = AudioScrobblerTSVImport
|
||||||
|
fields = ['tsv_file']
|
||||||
|
template_name = 'scrobbles/upload_form.html'
|
||||||
|
success_url = reverse_lazy('vrobbler-home')
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
self.object = form.save(commit=False)
|
||||||
|
self.object.user = self.request.user
|
||||||
|
self.object.save()
|
||||||
|
return HttpResponseRedirect(self.get_success_url())
|
||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
@ -131,11 +186,17 @@ def scrobble_endpoint(request):
|
|||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
@api_view(['POST'])
|
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
|
@api_view(['POST'])
|
||||||
def jellyfin_websocket(request):
|
def jellyfin_websocket(request):
|
||||||
data_dict = request.data
|
data_dict = request.data
|
||||||
|
|
||||||
|
if (
|
||||||
|
data_dict['NotificationType'] == 'PlaybackProgress'
|
||||||
|
and data_dict['ItemType'] == 'Audio'
|
||||||
|
):
|
||||||
|
return Response({}, status=status.HTTP_304_NOT_MODIFIED)
|
||||||
|
|
||||||
# For making things easier to build new input processors
|
# For making things easier to build new input processors
|
||||||
if getattr(settings, "DUMP_REQUEST_DATA", False):
|
if getattr(settings, "DUMP_REQUEST_DATA", False):
|
||||||
json_data = json.dumps(data_dict, indent=4)
|
json_data = json.dumps(data_dict, indent=4)
|
||||||
@ -157,8 +218,8 @@ def jellyfin_websocket(request):
|
|||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
@api_view(['POST'])
|
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
|
@api_view(['POST'])
|
||||||
def mopidy_websocket(request):
|
def mopidy_websocket(request):
|
||||||
try:
|
try:
|
||||||
data_dict = json.loads(request.data)
|
data_dict = json.loads(request.data)
|
||||||
@ -183,8 +244,31 @@ def mopidy_websocket(request):
|
|||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
@api_view(['GET'])
|
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
|
@api_view(['POST'])
|
||||||
|
@parser_classes([MultiPartParser])
|
||||||
|
def import_audioscrobbler_file(request):
|
||||||
|
"""Takes a TSV file in the Audioscrobbler format, saves it and processes the
|
||||||
|
scrobbles.
|
||||||
|
"""
|
||||||
|
scrobbles_created = []
|
||||||
|
# tsv_file = request.FILES[0]
|
||||||
|
|
||||||
|
file_serializer = AudioScrobblerTSVImportSerializer(data=request.data)
|
||||||
|
if file_serializer.is_valid():
|
||||||
|
import_file = file_serializer.save()
|
||||||
|
return Response(
|
||||||
|
{'scrobble_ids': scrobbles_created}, status=status.HTTP_200_OK
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return Response(
|
||||||
|
file_serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
@api_view(['GET'])
|
||||||
def scrobble_finish(request, uuid):
|
def scrobble_finish(request, uuid):
|
||||||
user = request.user
|
user = request.user
|
||||||
if not user.is_authenticated:
|
if not user.is_authenticated:
|
||||||
@ -201,8 +285,8 @@ def scrobble_finish(request, uuid):
|
|||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
@api_view(['GET'])
|
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
|
@api_view(['GET'])
|
||||||
def scrobble_cancel(request, uuid):
|
def scrobble_cancel(request, uuid):
|
||||||
user = request.user
|
user = request.user
|
||||||
if not user.is_authenticated:
|
if not user.is_authenticated:
|
||||||
@ -215,3 +299,23 @@ def scrobble_cancel(request, uuid):
|
|||||||
return Response(
|
return Response(
|
||||||
{'id': scrobble.id, 'status': 'cancelled'}, status=status.HTTP_200_OK
|
{'id': scrobble.id, 'status': 'cancelled'}, status=status.HTTP_200_OK
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
@api_view(['GET'])
|
||||||
|
def export(request):
|
||||||
|
format = request.GET.get('export_type', 'csv')
|
||||||
|
start = request.GET.get('start')
|
||||||
|
end = request.GET.get('end')
|
||||||
|
logger.debug(f"Exporting all scrobbles in format {format}")
|
||||||
|
|
||||||
|
temp_file, extension = export_scrobbles(
|
||||||
|
start_date=start, end_date=end, format=format
|
||||||
|
)
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
filename = f"vrobbler-export-{str(now)}.{extension}"
|
||||||
|
response = FileResponse(open(temp_file, 'rb'))
|
||||||
|
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||||||
|
|
||||||
|
return response
|
||||||
|
|||||||
@ -49,7 +49,12 @@ class Sport(TheSportsDbMixin):
|
|||||||
# run_time_ticks = run_time_seconds * 1000
|
# run_time_ticks = run_time_seconds * 1000
|
||||||
@property
|
@property
|
||||||
def default_event_run_time_ticks(self):
|
def default_event_run_time_ticks(self):
|
||||||
return self.default_event_run_time * 1000
|
default_run_time = getattr(
|
||||||
|
settings, 'DEFAULT_EVENT_RUNTIME_SECONDS', 14400
|
||||||
|
)
|
||||||
|
if self.default_event_run_time:
|
||||||
|
default_run_time = self.default_event_run_time
|
||||||
|
return default_run_time * 1000
|
||||||
|
|
||||||
|
|
||||||
class League(TheSportsDbMixin):
|
class League(TheSportsDbMixin):
|
||||||
@ -157,7 +162,7 @@ class SportEvent(ScrobblableMixin):
|
|||||||
)
|
)
|
||||||
if se_created:
|
if se_created:
|
||||||
season.name = seid
|
season.name = seid
|
||||||
season.save(update_fields['name'])
|
season.save(update_fields=['name'])
|
||||||
|
|
||||||
# Find or create our Round
|
# Find or create our Round
|
||||||
rid = data_dict.get('RoundId')
|
rid = data_dict.get('RoundId')
|
||||||
|
|||||||
@ -34,6 +34,7 @@ class Series(TimeStampedModel):
|
|||||||
|
|
||||||
class Video(ScrobblableMixin):
|
class Video(ScrobblableMixin):
|
||||||
COMPLETION_PERCENT = getattr(settings, 'VIDEO_COMPLETION_PERCENT', 90)
|
COMPLETION_PERCENT = getattr(settings, 'VIDEO_COMPLETION_PERCENT', 90)
|
||||||
|
SECONDS_TO_STALE = getattr(settings, 'VIDEO_SECONDS_TO_STALE', 14400)
|
||||||
|
|
||||||
class VideoType(models.TextChoices):
|
class VideoType(models.TextChoices):
|
||||||
UNKNOWN = 'U', _('Unknown')
|
UNKNOWN = 'U', _('Unknown')
|
||||||
@ -91,10 +92,6 @@ class Video(ScrobblableMixin):
|
|||||||
series, series_created = Series.objects.get_or_create(
|
series, series_created = Series.objects.get_or_create(
|
||||||
name=series_name
|
name=series_name
|
||||||
)
|
)
|
||||||
if series_created:
|
|
||||||
logger.debug(f"Created new series {series}")
|
|
||||||
else:
|
|
||||||
logger.debug(f"Found series {series}")
|
|
||||||
video_dict['video_type'] = Video.VideoType.TV_EPISODE
|
video_dict['video_type'] = Video.VideoType.TV_EPISODE
|
||||||
|
|
||||||
video, created = cls.objects.get_or_create(**video_dict)
|
video, created = cls.objects.get_or_create(**video_dict)
|
||||||
@ -119,11 +116,8 @@ class Video(ScrobblableMixin):
|
|||||||
video_extra_dict["tv_series_id"] = series.id
|
video_extra_dict["tv_series_id"] = series.id
|
||||||
|
|
||||||
if not video.run_time_ticks:
|
if not video.run_time_ticks:
|
||||||
logger.debug(f"Created new video: {video}")
|
|
||||||
for key, value in video_extra_dict.items():
|
for key, value in video_extra_dict.items():
|
||||||
setattr(video, key, value)
|
setattr(video, key, value)
|
||||||
video.save()
|
video.save()
|
||||||
else:
|
|
||||||
logger.debug(f"Found video {video}")
|
|
||||||
|
|
||||||
return video
|
return video
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
from videos import views
|
from videos import views
|
||||||
|
|
||||||
app_name = 'scrobbles'
|
app_name = 'videos'
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
|||||||
@ -83,6 +83,7 @@ INSTALLED_APPS = [
|
|||||||
"music",
|
"music",
|
||||||
"podcasts",
|
"podcasts",
|
||||||
"sports",
|
"sports",
|
||||||
|
"mathfilters",
|
||||||
"rest_framework",
|
"rest_framework",
|
||||||
"allauth",
|
"allauth",
|
||||||
"allauth.account",
|
"allauth.account",
|
||||||
@ -145,9 +146,7 @@ CACHES = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if REDIS_URL:
|
if REDIS_URL:
|
||||||
CACHES["default"][
|
CACHES["default"]["BACKEND"] = "django_redis.cache.RedisCache"
|
||||||
"BACKEND"
|
|
||||||
] = "django.core.cache.backends.redis.RedisCache"
|
|
||||||
CACHES["default"]["LOCATION"] = REDIS_URL
|
CACHES["default"]["LOCATION"] = REDIS_URL
|
||||||
|
|
||||||
SESSION_ENGINE = "django.contrib.sessions.backends.cached_db"
|
SESSION_ENGINE = "django.contrib.sessions.backends.cached_db"
|
||||||
|
|||||||
@ -271,7 +271,6 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% block extra_js %}
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/feather-icons@4.28.0/dist/feather.min.js" integrity="sha384-uO3SXW5IuS1ZpFPKugNNWqTZRRglnUJK6UAZ/gxOX80nxEkN9NcGZTftn6RzhGWE" crossorigin="anonymous"></script><script src="https://cdn.jsdelivr.net/npm/chart.js@2.9.4/dist/Chart.min.js" integrity="sha384-zNy6FEbO50N+Cg5wap8IKA4M/ZnLJgzc6w2NqACZaK0u0FXfOWRRJOnQtpZun8ha" crossorigin="anonymous"></script>
|
<script src="https://cdn.jsdelivr.net/npm/feather-icons@4.28.0/dist/feather.min.js" integrity="sha384-uO3SXW5IuS1ZpFPKugNNWqTZRRglnUJK6UAZ/gxOX80nxEkN9NcGZTftn6RzhGWE" crossorigin="anonymous"></script><script src="https://cdn.jsdelivr.net/npm/chart.js@2.9.4/dist/Chart.min.js" integrity="sha384-zNy6FEbO50N+Cg5wap8IKA4M/ZnLJgzc6w2NqACZaK0u0FXfOWRRJOnQtpZun8ha" crossorigin="anonymous"></script>
|
||||||
<script><!-- comment ------------------------------------------------->
|
<script><!-- comment ------------------------------------------------->
|
||||||
/* globals Chart:false, feather:false */
|
/* globals Chart:false, feather:false */
|
||||||
@ -320,6 +319,7 @@
|
|||||||
})()
|
})()
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
{% block extra_js %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
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 %}
|
||||||
@ -7,19 +7,20 @@
|
|||||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
<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">Dashboard</h1>
|
<h1 class="h2">Dashboard</h1>
|
||||||
<div class="btn-toolbar mb-2 mb-md-0">
|
<div class="btn-toolbar mb-2 mb-md-0">
|
||||||
|
{% if user.is_authenticated %}
|
||||||
<div class="btn-group me-2">
|
<div class="btn-group me-2">
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary">Share</button>
|
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#importModal">Import</button>
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary">Export</button>
|
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#exportModal">Export</button>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div class="dropdown">
|
<div class="dropdown">
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary dropdown-toggle" id="graphDateButton" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
<button type="button" class="btn btn-sm btn-outline-secondary dropdown-toggle" id="graphDateButton" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||||
<span data-feather="calendar"></span>
|
<span data-feather="calendar"></span>
|
||||||
This week
|
This week
|
||||||
</button>
|
</button>
|
||||||
<div class="dropdown-menu" aria-labelledby="graphDateButton">
|
<div class="dropdown-menu" data-bs-toggle="#graphDataChange" aria-labelledby="graphDateButton">
|
||||||
<a class="dropdown-item" href="#">Action</a>
|
<a class="dropdown-item" href="#">This month</a>
|
||||||
<a class="dropdown-item" href="#">Another action</a>
|
<a class="dropdown-item" href="#">This year</a>
|
||||||
<a class="dropdown-item" href="#">Something else here</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -170,6 +171,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">Time</th>
|
<th scope="col">Time</th>
|
||||||
|
<th scope="col">Album</th>
|
||||||
<th scope="col">Track</th>
|
<th scope="col">Track</th>
|
||||||
<th scope="col">Artist</th>
|
<th scope="col">Artist</th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -178,6 +180,11 @@
|
|||||||
{% for scrobble in object_list %}
|
{% for scrobble in object_list %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{scrobble.timestamp|naturaltime}}</td>
|
<td>{{scrobble.timestamp|naturaltime}}</td>
|
||||||
|
{% if scrobble.track.album.cover_image %}
|
||||||
|
<td><img src="{{scrobble.track.album.cover_image.url}}" width=50 height=50 style="border:1px solid black;" /></td>
|
||||||
|
{% else %}
|
||||||
|
<td>{{scrobble.track.album.name}}</td>
|
||||||
|
{% endif %}
|
||||||
<td>{{scrobble.track.title}}</td>
|
<td>{{scrobble.track.title}}</td>
|
||||||
<td>{{scrobble.track.artist.name}}</td>
|
<td>{{scrobble.track.artist.name}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -261,9 +268,65 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<div class="modal fade" id="importModal" tabindex="-1" role="dialog" aria-labelledby="importModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="importModalLabel">Import scrobbles</h5>
|
||||||
|
<button type="button" class="close" data-bs-dismiss="modal" aria-label="Close">
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form action="{% url 'audioscrobbler-file-upload' %}" method="post" enctype="multipart/form-data">
|
||||||
|
<div class="modal-body">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="tsv_file" class="col-form-label">Audioscrobbler TSV file:</label>
|
||||||
|
<input type="file" name="tsv_file" class="form-control" id="id_tsv_file">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="submit" class="btn btn-primary">Import</button>
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="exportModal" tabindex="-1" role="dialog" aria-labelledby="exportModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="exportModalLabel">Export scrobbles</h5>
|
||||||
|
<button type="button" class="close" data-bs-dismiss="modal" aria-label="Close">
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form action="{% url 'scrobbles:export' %}" method="get">
|
||||||
|
<div class="modal-body">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="form-group">
|
||||||
|
{{export_form.as_div}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="submit" class="btn btn-primary">Export</button>
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
$('#importModal').on('shown.bs.modal', function () { $('#importInput').trigger('focus') });
|
||||||
|
$('#exportModal').on('shown.bs.modal', function () { $('#exportInput').trigger('focus') });
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
13
vrobbler/templates/scrobbles/upload_form.html
Normal file
13
vrobbler/templates/scrobbles/upload_form.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{% 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">Manual scrobble</h1>
|
||||||
|
<form action="{% url 'audioscrobbler-file-upload' %}" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form }}
|
||||||
|
<input type="submit" value="Submit">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
@ -3,8 +3,8 @@ from django.conf import settings
|
|||||||
from django.conf.urls.static import static
|
from django.conf.urls.static import static
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
from rest_framework import routers
|
|
||||||
from scrobbles import urls as scrobble_urls
|
from scrobbles import urls as scrobble_urls
|
||||||
|
from music import urls as music_urls
|
||||||
from videos import urls as video_urls
|
from videos import urls as video_urls
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
@ -19,8 +19,16 @@ urlpatterns = [
|
|||||||
scrobbles_views.ManualScrobbleView.as_view(),
|
scrobbles_views.ManualScrobbleView.as_view(),
|
||||||
name='imdb-manual-scrobble',
|
name='imdb-manual-scrobble',
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
'manual/audioscrobbler/',
|
||||||
|
scrobbles_views.AudioScrobblerImportCreateView.as_view(),
|
||||||
|
name='audioscrobbler-file-upload',
|
||||||
|
),
|
||||||
|
path("", include(music_urls, namespace="music")),
|
||||||
path("", include(video_urls, namespace="videos")),
|
path("", include(video_urls, namespace="videos")),
|
||||||
path("", scrobbles_views.RecentScrobbleList.as_view(), name="home"),
|
path(
|
||||||
|
"", scrobbles_views.RecentScrobbleList.as_view(), name="vrobbler-home"
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
|
|||||||
Reference in New Issue
Block a user