Compare commits
182 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fd6e0f49b6 | |||
| 93299a1abd | |||
| a58ddebd23 | |||
| 41cdb96e94 | |||
| 5a8e828b81 | |||
| c84a3072be | |||
| 0bd7ed4463 | |||
| ee232aa103 | |||
| 7151646600 | |||
| 1d7cf965ef | |||
| 0a9279dbd4 | |||
| bf3479dbc7 | |||
| a99dca246b | |||
| f76aaf6a9c | |||
| ce1541bb2d | |||
| d34e56aa89 | |||
| 6316d4bead | |||
| 56e5728245 | |||
| 6ff170e169 | |||
| 86d1cf0d65 | |||
| a0101bf1ae | |||
| 457afdc9ef | |||
| d5bf6440b0 | |||
| 803ed7d8d7 | |||
| 93c4dd3d3b | |||
| ab728de75f | |||
| 04b7214795 | |||
| 479fee6a5c | |||
| 40a126cf8b | |||
| 83c02aa00f | |||
| 0f44df2b9b | |||
| 16d1dcc125 | |||
| 927d0be1b8 | |||
| f6b9245b8b | |||
| 39e035b460 | |||
| cf9da39967 | |||
| 2e98850494 | |||
| 5d315b4834 | |||
| 6ef8238442 | |||
| f4a444354d | |||
| 0db5bbe36c | |||
| 69b6364f88 | |||
| 966aeefbdd | |||
| d944fdd0c0 | |||
| e345631e27 | |||
| 59d0108fe5 | |||
| 8d67b672f9 | |||
| 376650f937 | |||
| 485fbd63a3 | |||
| d3f059caab | |||
| bb9936af65 | |||
| 9568726bf3 | |||
| 4ae70ef1f1 | |||
| 21df4e0a77 | |||
| cc82504262 | |||
| c7b84b27b2 | |||
| 20528b576b | |||
| 817ad3f67f | |||
| b47ca53c5d | |||
| 7a7c1caecc | |||
| 87f068dccd | |||
| 31907ed1b2 | |||
| 36d7950859 | |||
| 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 | |||
| 132d63bb5d | |||
| 49bf57dd58 | |||
| 506de848d7 | |||
| d05256f249 | |||
| 646c7ab99c | |||
| 7fc3705455 | |||
| cbe4abfb5f | |||
| 13bdc201f0 | |||
| 4f5ea7cd25 | |||
| b3b3b28b92 | |||
| 9ed3d034cf | |||
| 8e4a41a279 | |||
| 0cdde59de4 | |||
| 65e713e43e | |||
| 0d95f8fee8 | |||
| 6712e38689 | |||
| 4ed5fde672 | |||
| 1c5f721723 | |||
| 39547c6e5c | |||
| 7447a97117 | |||
| 6aa933d13d | |||
| 3bb73ae4be | |||
| a57269b09a | |||
| 68423488ff | |||
| e75c22d583 | |||
| a0af0bce05 | |||
| fd984d7460 | |||
| 065fc98a87 | |||
| 6db5a00917 | |||
| 734aa6073b | |||
| 77362d3207 | |||
| 9d4db65b3c | |||
| e850d46539 | |||
| 907ef802bc | |||
| d700b581a1 | |||
| 7605c672f6 | |||
| 8d1df806d7 | |||
| 0f562b7c58 | |||
| fe53b68714 | |||
| 7e2915850f | |||
| 90687a6b43 | |||
| 07cfb03eb6 | |||
| 58be8d26a0 | |||
| 0fede269b1 | |||
| 6cdcf4ff6f | |||
| 0634b94368 | |||
| 0ab7c563cf | |||
| 363a132df2 | |||
| c484905d11 | |||
| 0378dfe6eb | |||
| c39443e35b | |||
| bb3dfdf7ba | |||
| fdfb8a635e | |||
| 290e6dc8d9 | |||
| 499546503c | |||
| bd3a381346 | |||
| e206a7fbf3 | |||
| 6313da9868 | |||
| f7c69a6763 | |||
| ab88fcb9a7 | |||
| 1d868d3075 | |||
| 5b07c70ca2 | |||
| 9607fb2d1e | |||
| e6cf126f5c | |||
| eeee6eea4e | |||
| 1f26931215 | |||
| 610ec63cbd | |||
| 72fded4097 | |||
| d5eea53a01 | |||
| a49eb31276 | |||
| c1e1160db3 | |||
| 0e17831724 | |||
| 045fad8552 | |||
| a09c6d6b92 | |||
| 3f8b29f5ee |
7
.coveragerc
Normal file
7
.coveragerc
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
[run]
|
||||||
|
omit=
|
||||||
|
vrobbler/wsgi.py
|
||||||
|
vrobbler/asgi.py
|
||||||
|
vrobbler/cli.py
|
||||||
|
*admin.py
|
||||||
|
migrations/*
|
||||||
@ -8,15 +8,15 @@ name: run_tests
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
# Run tests against Python/Flask engine backend (with pytest)
|
# Run tests against Python/Flask engine backend (with pytest)
|
||||||
- name: django_tests
|
- name: pytest with coverage
|
||||||
image: python:3.10.4
|
image: python:3.10.4
|
||||||
commands:
|
commands:
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
- cp vrobbler.conf.example vrobbler.conf
|
- cp vrobbler.conf.test vrobbler.conf
|
||||||
- pip install poetry
|
- pip install poetry
|
||||||
- poetry install
|
- poetry install
|
||||||
# Start with a fresh database (which is already running as a service from Drone)
|
# Start with a fresh database (which is already running as a service from Drone)
|
||||||
- poetry run python manage.py test
|
- poetry run pytest --cov-report term:skip-covered --cov=vrobbler tests
|
||||||
environment:
|
environment:
|
||||||
VROBBLER_DATABASE_URL: sqlite:///test.db
|
VROBBLER_DATABASE_URL: sqlite:///test.db
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@ -1,4 +1,5 @@
|
|||||||
db.sqlite3
|
db.sqlite3
|
||||||
vrobbler.conf
|
vrobbler.conf
|
||||||
/media/
|
media/
|
||||||
/dist/
|
dist/
|
||||||
|
.coverage
|
||||||
|
|||||||
2
Procfile
Normal file
2
Procfile
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
web: python manage.py runserver 0.0.0.0:8014
|
||||||
|
worker: celery -A vrobbler worker -l DEBUG
|
||||||
@ -1,4 +1,7 @@
|
|||||||
#+title: Readme
|
Vrobbler
|
||||||
|
========
|
||||||
|
|
||||||
|
[](https://ci.unbl.ink/secstate/vrobbler)
|
||||||
|
|
||||||
Vrobbler is a pretty simple Django-powered web app for scrobbling video plays from you favorite Jellyfin installation.
|
Vrobbler is a pretty simple Django-powered web app for scrobbling video plays from you favorite Jellyfin installation.
|
||||||
|
|
||||||
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)"
|
||||||
888
poetry.lock
generated
888
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "vrobbler"
|
name = "vrobbler"
|
||||||
version = "0.4.0"
|
version = "0.11.5"
|
||||||
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"
|
||||||
@ -28,24 +28,39 @@ gunicorn = "^20.1.0"
|
|||||||
django-simple-history = "^3.1.1"
|
django-simple-history = "^3.1.1"
|
||||||
whitenoise = "^6.3.0"
|
whitenoise = "^6.3.0"
|
||||||
musicbrainzngs = "^0.7.1"
|
musicbrainzngs = "^0.7.1"
|
||||||
|
cinemagoer = "^2022.12.27"
|
||||||
|
pysportsdb = "^0.1.0"
|
||||||
|
pytz = "^2022.7.1"
|
||||||
|
django-redis = "^5.2.0"
|
||||||
|
pylast = "^5.1.0"
|
||||||
|
django-encrypted-field = "^1.0.5"
|
||||||
|
celery = "^5.2.7"
|
||||||
|
honcho = "^1.1.0"
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
Werkzeug = "2.0.3"
|
Werkzeug = "2.0.3"
|
||||||
black = "^22.3"
|
black = "^22.3"
|
||||||
freezegun = "^1.2"
|
coverage = "^7.0.5"
|
||||||
mypy = "^0.961"
|
mypy = "^0.961"
|
||||||
pytest = "^7.1"
|
pytest = "^7.1"
|
||||||
pytest-black = "^0.3.12"
|
pytest-black = "^0.3.12"
|
||||||
pytest-cov = "^3.0"
|
pytest-cov = "^3.0"
|
||||||
|
pytest-django = "^4.5.2"
|
||||||
pytest-flake8 = "^1.1"
|
pytest-flake8 = "^1.1"
|
||||||
pytest-isort = "^3.0"
|
pytest-isort = "^3.0"
|
||||||
pytest-runner = "^6.0"
|
pytest-runner = "^6.0"
|
||||||
pytest-selenium = "^2.0.1"
|
pytest-selenium = "^2.0.1"
|
||||||
|
time-machine = "^2.9.0"
|
||||||
types-pytz = "^2022.1"
|
types-pytz = "^2022.1"
|
||||||
types-requests = "^2.27"
|
types-requests = "^2.27"
|
||||||
types-freezegun = "^1.1"
|
|
||||||
bandit = "^1.7.4"
|
bandit = "^1.7.4"
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
minversion = "6.0"
|
||||||
|
addopts = "-ra -q"
|
||||||
|
testpaths = ["tests"]
|
||||||
|
DJANGO_SETTINGS_MODULE='vrobbler.settings'
|
||||||
|
|
||||||
[tool.black]
|
[tool.black]
|
||||||
line-length = 79
|
line-length = 79
|
||||||
skip-string-normalization = true
|
skip-string-normalization = true
|
||||||
|
|||||||
@ -1,25 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html class="no-js" lang="">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
|
||||||
<title>Untitled</title>
|
|
||||||
<meta name="description" content="">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
|
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
|
||||||
<!-- Place favicon.ico in the root directory -->
|
|
||||||
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<!--[if lt IE 8]>
|
|
||||||
<p class="browserupgrade">
|
|
||||||
You are using an <strong>outdated</strong> browser. Please
|
|
||||||
<a href="http://browsehappy.com/">upgrade your browser</a> to improve
|
|
||||||
your experience.
|
|
||||||
</p>
|
|
||||||
<![endif]-->
|
|
||||||
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
0
tests/scrobbles_tests/__init__.py
Normal file
0
tests/scrobbles_tests/__init__.py
Normal file
0
tests/scrobbles_tests/__init__py
Normal file
0
tests/scrobbles_tests/__init__py
Normal file
84
tests/scrobbles_tests/conftest.py
Normal file
84
tests/scrobbles_tests/conftest.py
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
from rest_framework.authtoken.models import Token
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class MopidyRequest:
|
||||||
|
name = "Same in the End"
|
||||||
|
artist = "Sublime"
|
||||||
|
album = "Sublime"
|
||||||
|
track_number = 4
|
||||||
|
run_time_ticks = 156604
|
||||||
|
run_time = "156"
|
||||||
|
playback_time_ticks = 15045
|
||||||
|
musicbrainz_track_id = "54214d63-5adf-4909-87cd-c65c37a6d558"
|
||||||
|
musicbrainz_album_id = "03b864cd-7761-314c-a892-05a89ddff00d"
|
||||||
|
musicbrainz_artist_id = "95f5b748-d370-47fe-85bd-0af2dc450bc0"
|
||||||
|
mopidy_uri = "local:track:Sublime%20-%20Sublime/Disc%201%20-%2004%20-%20Same%20in%20the%20End.mp3"
|
||||||
|
status = "resumed"
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
self.request_data = {
|
||||||
|
"name": kwargs.get('name', self.name),
|
||||||
|
"artist": kwargs.get("artist", self.artist),
|
||||||
|
"album": kwargs.get("album", self.album),
|
||||||
|
"track_number": int(kwargs.get("track_number", self.track_number)),
|
||||||
|
"run_time_ticks": int(
|
||||||
|
kwargs.get("run_time_ticks", self.run_time_ticks)
|
||||||
|
),
|
||||||
|
"run_time": int(kwargs.get("run_time", self.run_time)),
|
||||||
|
"playback_time_ticks": int(
|
||||||
|
kwargs.get("playback_time_ticks", self.playback_time_ticks)
|
||||||
|
),
|
||||||
|
"musicbrainz_track_id": kwargs.get(
|
||||||
|
"musicbrainz_track_id", self.musicbrainz_track_id
|
||||||
|
),
|
||||||
|
"musicbrainz_album_id": kwargs.get(
|
||||||
|
"musicbrainz_album_id", self.musicbrainz_album_id
|
||||||
|
),
|
||||||
|
"musicbrainz_artist_id": kwargs.get(
|
||||||
|
"musicbrainz_artist_id", self.musicbrainz_artist_id
|
||||||
|
),
|
||||||
|
"mopidy_uri": kwargs.get("mopidy_uri", self.mopidy_uri),
|
||||||
|
"status": kwargs.get("status", self.status),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
for key in self.request_data.keys():
|
||||||
|
if self.request_data[key] != getattr(self, key):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def request_json(self):
|
||||||
|
return json.dumps(self.request_data)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def valid_auth_token():
|
||||||
|
user = User.objects.create(email='test@exmaple.com')
|
||||||
|
return Token.objects.create(user=user).key
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mopidy_track_request_data():
|
||||||
|
return MopidyRequest().request_json
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mopidy_track_diff_album_request_data(**kwargs):
|
||||||
|
mb_album_id = "0c56c457-afe1-4679-baab-759ba8dd2a58"
|
||||||
|
return MopidyRequest(
|
||||||
|
album="Gold", musicbrainz_album_id=mb_album_id
|
||||||
|
).request_json
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mopidy_podcast_request_data():
|
||||||
|
mopidy_uri = "local:podcast:Up%20First/2022-01-01%20Up%20First.mp3"
|
||||||
|
return MopidyRequest(mopidy_uri=mopidy_uri).request_json
|
||||||
102
tests/scrobbles_tests/test_aggregators.py
Normal file
102
tests/scrobbles_tests/test_aggregators.py
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import time_machine
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
from music.aggregators import live_charts, scrobble_counts, week_of_scrobbles
|
||||||
|
from profiles.models import UserProfile
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
|
|
||||||
|
def build_scrobbles(client, request_data, num=7, spacing=2):
|
||||||
|
url = reverse('scrobbles:mopidy-webhook')
|
||||||
|
user = get_user_model().objects.create(username='Test User')
|
||||||
|
UserProfile.objects.create(user=user, timezone='US/Eastern')
|
||||||
|
for i in range(num):
|
||||||
|
client.post(url, request_data, content_type='application/json')
|
||||||
|
s = Scrobble.objects.last()
|
||||||
|
s.user = user
|
||||||
|
s.timestamp = timezone.now() - timedelta(days=i * spacing)
|
||||||
|
s.played_to_completion = True
|
||||||
|
s.save()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@time_machine.travel(datetime(2022, 3, 4, 1, 24))
|
||||||
|
def test_scrobble_counts_data(client, mopidy_track_request_data):
|
||||||
|
build_scrobbles(client, mopidy_track_request_data)
|
||||||
|
user = get_user_model().objects.first()
|
||||||
|
count_dict = scrobble_counts(user)
|
||||||
|
assert count_dict == {
|
||||||
|
'alltime': 7,
|
||||||
|
'month': 2,
|
||||||
|
'today': 1,
|
||||||
|
'week': 3,
|
||||||
|
'year': 7,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_week_of_scrobbles_data(client, mopidy_track_request_data):
|
||||||
|
build_scrobbles(client, mopidy_track_request_data, 7, 1)
|
||||||
|
user = get_user_model().objects.first()
|
||||||
|
week = week_of_scrobbles(user)
|
||||||
|
assert list(week.values()) == [1, 1, 1, 1, 1, 1, 1]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_top_tracks_by_day(client, mopidy_track_request_data):
|
||||||
|
build_scrobbles(client, mopidy_track_request_data, 7, 1)
|
||||||
|
user = get_user_model().objects.first()
|
||||||
|
tops = live_charts(user)
|
||||||
|
assert tops[0].title == "Same in the End"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_top_tracks_by_week(client, mopidy_track_request_data):
|
||||||
|
build_scrobbles(client, mopidy_track_request_data, 7, 1)
|
||||||
|
user = get_user_model().objects.first()
|
||||||
|
tops = live_charts(user, chart_period='week')
|
||||||
|
assert tops[0].title == "Same in the End"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_top_tracks_by_month(client, mopidy_track_request_data):
|
||||||
|
build_scrobbles(client, mopidy_track_request_data, 7, 1)
|
||||||
|
user = get_user_model().objects.first()
|
||||||
|
tops = live_charts(user, chart_period='month')
|
||||||
|
assert tops[0].title == "Same in the End"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_top_tracks_by_year(client, mopidy_track_request_data):
|
||||||
|
build_scrobbles(client, mopidy_track_request_data, 7, 1)
|
||||||
|
user = get_user_model().objects.first()
|
||||||
|
tops = live_charts(user, chart_period='year')
|
||||||
|
assert tops[0].title == "Same in the End"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_top__artists_by_week(client, mopidy_track_request_data):
|
||||||
|
build_scrobbles(client, mopidy_track_request_data, 7, 1)
|
||||||
|
user = get_user_model().objects.first()
|
||||||
|
tops = live_charts(user, chart_period='week', media_type="Artist")
|
||||||
|
assert tops[0].name == "Sublime"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_top__artists_by_month(client, mopidy_track_request_data):
|
||||||
|
build_scrobbles(client, mopidy_track_request_data, 7, 1)
|
||||||
|
user = get_user_model().objects.first()
|
||||||
|
tops = live_charts(user, chart_period='month', media_type="Artist")
|
||||||
|
assert tops[0].name == "Sublime"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_top__artists_by_year(client, mopidy_track_request_data):
|
||||||
|
build_scrobbles(client, mopidy_track_request_data, 7, 1)
|
||||||
|
user = get_user_model().objects.first()
|
||||||
|
tops = live_charts(user, chart_period='year', media_type="Artist")
|
||||||
|
assert tops[0].name == "Sublime"
|
||||||
11
tests/scrobbles_tests/test_imdb.py
Normal file
11
tests/scrobbles_tests/test_imdb.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from vrobbler.apps.scrobbles.imdb import lookup_video_from_imdb
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="Need to sort out third party API testing")
|
||||||
|
def test_lookup_imdb_bad_id(caplog):
|
||||||
|
data = lookup_video_from_imdb('3409324')
|
||||||
|
assert data is None
|
||||||
|
assert caplog.records[0].levelname == "WARNING"
|
||||||
|
assert caplog.records[0].msg == "IMDB ID should begin with 'tt' 3409324"
|
||||||
100
tests/scrobbles_tests/test_views.py
Normal file
100
tests/scrobbles_tests/test_views.py
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
from music.models import Track
|
||||||
|
from podcasts.models import Episode
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_get_not_allowed_from_mopidy(client, valid_auth_token):
|
||||||
|
url = reverse('scrobbles:mopidy-webhook')
|
||||||
|
headers = {'Authorization': f'Token {valid_auth_token}'}
|
||||||
|
response = client.get(url, headers=headers)
|
||||||
|
assert response.status_code == 405
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_bad_mopidy_request_data(client, valid_auth_token):
|
||||||
|
url = reverse('scrobbles:mopidy-webhook')
|
||||||
|
headers = {'Authorization': f'Token {valid_auth_token}'}
|
||||||
|
response = client.post(url, headers)
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert (
|
||||||
|
response.data['detail']
|
||||||
|
== 'JSON parse error - Expecting value: line 1 column 1 (char 0)'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_scrobble_mopidy_track(
|
||||||
|
client, mopidy_track_request_data, valid_auth_token
|
||||||
|
):
|
||||||
|
url = reverse('scrobbles:mopidy-webhook')
|
||||||
|
headers = {'Authorization': f'Token {valid_auth_token}'}
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
mopidy_track_request_data,
|
||||||
|
content_type='application/json',
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data == {'scrobble_id': 1}
|
||||||
|
|
||||||
|
scrobble = Scrobble.objects.get(id=1)
|
||||||
|
assert scrobble.media_obj.__class__ == Track
|
||||||
|
assert scrobble.media_obj.title == "Same in the End"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_scrobble_mopidy_same_track_different_album(
|
||||||
|
client,
|
||||||
|
mopidy_track_request_data,
|
||||||
|
mopidy_track_diff_album_request_data,
|
||||||
|
valid_auth_token,
|
||||||
|
):
|
||||||
|
url = reverse('scrobbles:mopidy-webhook')
|
||||||
|
headers = {'Authorization': f'Token {valid_auth_token}'}
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
mopidy_track_request_data,
|
||||||
|
content_type='application/json',
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data == {'scrobble_id': 1}
|
||||||
|
scrobble = Scrobble.objects.get(id=1)
|
||||||
|
assert scrobble.media_obj.album.name == "Sublime"
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
mopidy_track_diff_album_request_data,
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
|
||||||
|
scrobble = Scrobble.objects.get(id=2)
|
||||||
|
assert scrobble.media_obj.__class__ == Track
|
||||||
|
assert scrobble.media_obj.album.name == "Gold"
|
||||||
|
assert scrobble.media_obj.title == "Same in the End"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_scrobble_mopidy_podcast(
|
||||||
|
client, mopidy_podcast_request_data, valid_auth_token
|
||||||
|
):
|
||||||
|
url = reverse('scrobbles:mopidy-webhook')
|
||||||
|
headers = {'Authorization': f'Token {valid_auth_token}'}
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
mopidy_podcast_request_data,
|
||||||
|
content_type='application/json',
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data == {'scrobble_id': 1}
|
||||||
|
|
||||||
|
scrobble = Scrobble.objects.get(id=1)
|
||||||
|
assert scrobble.media_obj.__class__ == Episode
|
||||||
|
assert scrobble.media_obj.title == "Up First"
|
||||||
382
todos.org
Normal file
382
todos.org
Normal file
@ -0,0 +1,382 @@
|
|||||||
|
#+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
|
||||||
|
* DONE [#B] Allow scrobbling music without MB IDs by grabbing them before scrobble :improvement:
|
||||||
|
CLOSED: [2023-02-17 Fri 00:10]
|
||||||
|
|
||||||
|
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.
|
||||||
|
* DONE When updating musicbrainz IDs, clear and run fetch artwrok :improvement:
|
||||||
|
CLOSED: [2023-02-17 Fri 00:11]
|
||||||
|
* 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 [#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 [#C] 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
|
||||||
|
|
||||||
8
vrobbler.conf.test
Normal file
8
vrobbler.conf.test
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
# Local configuration for Emus
|
||||||
|
|
||||||
|
VROBBLER_DUMP_REQUEST_DATA=True
|
||||||
|
VROBBLER_LOG_TO_CONSOLE=True
|
||||||
|
VROBBLER_DEBUG=True
|
||||||
|
VROBBLER_LOG_LEVEL="DEBUG"
|
||||||
|
VROBBLER_MEDIA_ROOT = "/tmp/media/"
|
||||||
|
VROBBLER_KEEP_DETAILED_SCROBBLE_LOGS=True
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
# This will make sure the app is always imported when
|
||||||
|
# Django starts so that shared_task will use this app.
|
||||||
|
from .celery import app as celery_app
|
||||||
|
|
||||||
|
__all__ = ('celery_app',)
|
||||||
|
|||||||
25
vrobbler/apps/books/admin.py
Normal file
25
vrobbler/apps/books/admin.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from books.models import Author, Book
|
||||||
|
|
||||||
|
from scrobbles.admin import ScrobbleInline
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Author)
|
||||||
|
class AlbumAdmin(admin.ModelAdmin):
|
||||||
|
date_hierarchy = "created"
|
||||||
|
list_display = ("name", "openlibrary_id")
|
||||||
|
ordering = ("name",)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Book)
|
||||||
|
class ArtistAdmin(admin.ModelAdmin):
|
||||||
|
date_hierarchy = "created"
|
||||||
|
list_display = (
|
||||||
|
"title",
|
||||||
|
"isbn",
|
||||||
|
"first_publish_year",
|
||||||
|
"pages",
|
||||||
|
"openlibrary_id",
|
||||||
|
)
|
||||||
|
ordering = ("title",)
|
||||||
14
vrobbler/apps/books/api/serializers.py
Normal file
14
vrobbler/apps/books/api/serializers.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
from books.models import Author, Book
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
|
||||||
|
class AuthorSerializer(serializers.HyperlinkedModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Author
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
|
class BookSerializer(serializers.HyperlinkedModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Book
|
||||||
|
fields = "__all__"
|
||||||
19
vrobbler/apps/books/api/views.py
Normal file
19
vrobbler/apps/books/api/views.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
from rest_framework import permissions, viewsets
|
||||||
|
|
||||||
|
from books.api.serializers import (
|
||||||
|
AuthorSerializer,
|
||||||
|
BookSerializer,
|
||||||
|
)
|
||||||
|
from books.models import Author, Book
|
||||||
|
|
||||||
|
|
||||||
|
class AuthorViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Author.objects.all().order_by('-created')
|
||||||
|
serializer_class = AuthorSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
|
||||||
|
class BookViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Book.objects.all().order_by('-created')
|
||||||
|
serializer_class = BookSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
128
vrobbler/apps/books/migrations/0001_initial.py
Normal file
128
vrobbler/apps/books/migrations/0001_initial.py
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-02-19 20:17
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django_extensions.db.fields
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Author',
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
'id',
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name='ID',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'created',
|
||||||
|
django_extensions.db.fields.CreationDateTimeField(
|
||||||
|
auto_now_add=True, verbose_name='created'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'modified',
|
||||||
|
django_extensions.db.fields.ModificationDateTimeField(
|
||||||
|
auto_now=True, verbose_name='modified'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
('name', models.CharField(max_length=255)),
|
||||||
|
(
|
||||||
|
'openlibrary_id',
|
||||||
|
models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'get_latest_by': 'modified',
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Book',
|
||||||
|
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'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'uuid',
|
||||||
|
models.UUIDField(
|
||||||
|
blank=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'run_time',
|
||||||
|
models.CharField(blank=True, max_length=8, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'run_time_ticks',
|
||||||
|
models.PositiveBigIntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
('title', models.CharField(max_length=255)),
|
||||||
|
(
|
||||||
|
'openlibrary_id',
|
||||||
|
models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'goodreads_id',
|
||||||
|
models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
('koreader_id', models.IntegerField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
'koreader_authors',
|
||||||
|
models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'koreader_md5',
|
||||||
|
models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'isbn',
|
||||||
|
models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
('pages', models.IntegerField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
'language',
|
||||||
|
models.CharField(blank=True, max_length=4, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'first_publish_year',
|
||||||
|
models.IntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
('authors', models.ManyToManyField(to='books.author')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
vrobbler/apps/books/migrations/__init__.py
Normal file
0
vrobbler/apps/books/migrations/__init__.py
Normal file
73
vrobbler/apps/books/models.py
Normal file
73
vrobbler/apps/books/models.py
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import logging
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db import models
|
||||||
|
from django.urls import reverse
|
||||||
|
from django_extensions.db.models import TimeStampedModel
|
||||||
|
from scrobbles.mixins import ScrobblableMixin
|
||||||
|
|
||||||
|
from books.utils import lookup_book_from_openlibrary
|
||||||
|
from scrobbles.utils import get_scrobbles_for_media
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
User = get_user_model()
|
||||||
|
BNULL = {"blank": True, "null": True}
|
||||||
|
|
||||||
|
|
||||||
|
class Author(TimeStampedModel):
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
openlibrary_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name}"
|
||||||
|
|
||||||
|
def fix_metadata(self):
|
||||||
|
logger.warn("Not implemented yet")
|
||||||
|
|
||||||
|
|
||||||
|
class Book(ScrobblableMixin):
|
||||||
|
COMPLETION_PERCENT = getattr(settings, 'BOOK_COMPLETION_PERCENT', 95)
|
||||||
|
|
||||||
|
title = models.CharField(max_length=255)
|
||||||
|
authors = models.ManyToManyField(Author)
|
||||||
|
openlibrary_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
goodreads_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
koreader_id = models.IntegerField(**BNULL)
|
||||||
|
koreader_authors = models.CharField(max_length=255, **BNULL)
|
||||||
|
koreader_md5 = models.CharField(max_length=255, **BNULL)
|
||||||
|
isbn = models.CharField(max_length=255, **BNULL)
|
||||||
|
pages = models.IntegerField(**BNULL)
|
||||||
|
language = models.CharField(max_length=4, **BNULL)
|
||||||
|
first_publish_year = models.IntegerField(**BNULL)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.title} by {self.author}"
|
||||||
|
|
||||||
|
def fix_metadata(self):
|
||||||
|
if not self.openlibrary_id:
|
||||||
|
book_meta = lookup_book_from_openlibrary(self.title, self.author)
|
||||||
|
self.openlibrary_id = book_meta.get("openlibrary_id")
|
||||||
|
self.isbn = book_meta.get("isbn")
|
||||||
|
self.goodreads_id = book_meta.get("goodreads_id")
|
||||||
|
self.first_pubilsh_year = book_meta.get("first_publish_year")
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def author(self):
|
||||||
|
return self.authors.first()
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse("books:book_detail", kwargs={'slug': self.uuid})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pages_for_completion(self) -> int:
|
||||||
|
if not self.pages:
|
||||||
|
logger.warn(f"{self} has no pages, no completion percentage")
|
||||||
|
return 0
|
||||||
|
return int(self.pages * (self.COMPLETION_PERCENT / 100))
|
||||||
|
|
||||||
|
def progress_for_user(self, user: User) -> int:
|
||||||
|
last_scrobble = get_scrobbles_for_media(self, user).last()
|
||||||
|
return int((last_scrobble.book_pages_read / self.pages) * 100)
|
||||||
47
vrobbler/apps/books/utils.py
Normal file
47
vrobbler/apps/books/utils.py
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import json
|
||||||
|
from typing import Optional
|
||||||
|
import requests
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SEARCH_URL = "https://openlibrary.org/search.json?title={title}"
|
||||||
|
ISBN_URL = "https://openlibrary.org/isbn/{isbn}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def get_first(key: str, result: dict) -> str:
|
||||||
|
obj = ""
|
||||||
|
if obj_list := result.get(key):
|
||||||
|
obj = obj_list[0]
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_book_from_openlibrary(title: str, author: str = None) -> dict:
|
||||||
|
search_url = SEARCH_URL.format(title=title)
|
||||||
|
response = requests.get(search_url)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.warn(f"Bad response from OL: {response.status_code}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
results = json.loads(response.content)
|
||||||
|
|
||||||
|
if len(results.get('docs')) == 0:
|
||||||
|
logger.warn(f"No results found from OL for {title}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
top = results.get('docs')[0]
|
||||||
|
if author and author not in top['author_name']:
|
||||||
|
logger.warn(
|
||||||
|
f"Lookup for {title} found top result with mismatched author"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"title": top.get("title"),
|
||||||
|
"isbn": top.get("isbn")[0],
|
||||||
|
"openlibrary_id": top.get("cover_edition_key"),
|
||||||
|
"author_name": get_first("author_name", top),
|
||||||
|
"author_openlibrary_id": get_first("author_key", top),
|
||||||
|
"goodreads_id": get_first("id_goodreads", top),
|
||||||
|
"first_publish_year": top.get("first_publish_year"),
|
||||||
|
}
|
||||||
@ -2,12 +2,24 @@ from django.contrib import admin
|
|||||||
|
|
||||||
from music.models import Artist, Album, Track
|
from music.models import Artist, Album, Track
|
||||||
|
|
||||||
|
from scrobbles.admin import ScrobbleInline
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Album)
|
@admin.register(Album)
|
||||||
class AlbumAdmin(admin.ModelAdmin):
|
class AlbumAdmin(admin.ModelAdmin):
|
||||||
date_hierarchy = "created"
|
date_hierarchy = "created"
|
||||||
list_display = ("name", "year", "musicbrainz_id")
|
list_display = (
|
||||||
list_filter = ("year",)
|
"name",
|
||||||
|
"year",
|
||||||
|
"primary_artist",
|
||||||
|
"theaudiodb_genre",
|
||||||
|
"theaudiodb_mood",
|
||||||
|
"musicbrainz_id",
|
||||||
|
)
|
||||||
|
list_filter = (
|
||||||
|
"theaudiodb_score",
|
||||||
|
"theaudiodb_genre",
|
||||||
|
)
|
||||||
ordering = ("name",)
|
ordering = ("name",)
|
||||||
filter_horizontal = [
|
filter_horizontal = [
|
||||||
'artists',
|
'artists',
|
||||||
@ -33,3 +45,6 @@ class TrackAdmin(admin.ModelAdmin):
|
|||||||
)
|
)
|
||||||
list_filter = ("album", "artist")
|
list_filter = ("album", "artist")
|
||||||
ordering = ("-created",)
|
ordering = ("-created",)
|
||||||
|
inlines = [
|
||||||
|
ScrobbleInline,
|
||||||
|
]
|
||||||
|
|||||||
@ -1,102 +1,147 @@
|
|||||||
from django.db.models import Q, Count, Sum
|
|
||||||
from typing import List, Optional
|
|
||||||
from scrobbles.models import Scrobble
|
|
||||||
from music.models import Track, Artist
|
|
||||||
from videos.models import Video
|
|
||||||
|
|
||||||
from django.utils import timezone
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from django.apps import apps
|
||||||
|
from django.db.models import Count, Q, QuerySet
|
||||||
|
from django.utils import timezone
|
||||||
|
from music.models import Artist, Track
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
from videos.models import Video
|
||||||
|
from vrobbler.apps.profiles.utils import now_user_timezone
|
||||||
|
|
||||||
|
|
||||||
NOW = timezone.now()
|
def scrobble_counts(user=None):
|
||||||
START_OF_TODAY = datetime.combine(NOW.date(), datetime.min.time(), NOW.tzinfo)
|
|
||||||
STARTING_DAY_OF_CURRENT_WEEK = NOW.date() - timedelta(
|
|
||||||
days=NOW.today().isoweekday() % 7
|
|
||||||
)
|
|
||||||
STARTING_DAY_OF_CURRENT_MONTH = NOW.date().replace(day=1)
|
|
||||||
STARTING_DAY_OF_CURRENT_YEAR = NOW.date().replace(month=1, day=1)
|
|
||||||
|
|
||||||
|
now = timezone.now()
|
||||||
|
user_filter = Q()
|
||||||
|
if user and user.is_authenticated:
|
||||||
|
now = now_user_timezone(user.profile)
|
||||||
|
user_filter = Q(user=user)
|
||||||
|
|
||||||
def scrobble_counts():
|
start_of_today = datetime.combine(
|
||||||
finished_scrobbles_qs = Scrobble.objects.filter(in_progress=False)
|
now.date(), datetime.min.time(), now.tzinfo
|
||||||
|
)
|
||||||
|
starting_day_of_current_week = now.date() - timedelta(
|
||||||
|
days=now.today().isoweekday() % 7
|
||||||
|
)
|
||||||
|
starting_day_of_current_month = now.date().replace(day=1)
|
||||||
|
starting_day_of_current_year = now.date().replace(month=1, day=1)
|
||||||
|
|
||||||
|
finished_scrobbles_qs = Scrobble.objects.filter(
|
||||||
|
user_filter, played_to_completion=True
|
||||||
|
)
|
||||||
data = {}
|
data = {}
|
||||||
data['today'] = finished_scrobbles_qs.filter(
|
data['today'] = finished_scrobbles_qs.filter(
|
||||||
timestamp__gte=START_OF_TODAY
|
timestamp__gte=start_of_today
|
||||||
).count()
|
).count()
|
||||||
data['week'] = finished_scrobbles_qs.filter(
|
data['week'] = finished_scrobbles_qs.filter(
|
||||||
timestamp__gte=STARTING_DAY_OF_CURRENT_WEEK
|
timestamp__gte=starting_day_of_current_week
|
||||||
).count()
|
).count()
|
||||||
data['month'] = finished_scrobbles_qs.filter(
|
data['month'] = finished_scrobbles_qs.filter(
|
||||||
timestamp__gte=STARTING_DAY_OF_CURRENT_MONTH
|
timestamp__gte=starting_day_of_current_month
|
||||||
).count()
|
).count()
|
||||||
data['year'] = finished_scrobbles_qs.filter(
|
data['year'] = finished_scrobbles_qs.filter(
|
||||||
timestamp__gte=STARTING_DAY_OF_CURRENT_YEAR
|
timestamp__gte=starting_day_of_current_year
|
||||||
).count()
|
).count()
|
||||||
data['alltime'] = finished_scrobbles_qs.count()
|
data['alltime'] = finished_scrobbles_qs.count()
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
def week_of_scrobbles(media: str = 'tracks') -> dict[str, int]:
|
def week_of_scrobbles(
|
||||||
|
user=None, start=None, media: str = 'tracks'
|
||||||
|
) -> dict[str, int]:
|
||||||
|
|
||||||
|
now = timezone.now()
|
||||||
|
user_filter = Q()
|
||||||
|
if user and user.is_authenticated:
|
||||||
|
now = now_user_timezone(user.profile)
|
||||||
|
user_filter = Q(user=user)
|
||||||
|
|
||||||
|
if not start:
|
||||||
|
start = datetime.combine(now.date(), datetime.min.time(), now.tzinfo)
|
||||||
|
|
||||||
scrobble_day_dict = {}
|
scrobble_day_dict = {}
|
||||||
|
base_qs = Scrobble.objects.filter(user_filter, played_to_completion=True)
|
||||||
|
|
||||||
media_filter = Q(track__isnull=False)
|
media_filter = Q(track__isnull=False)
|
||||||
|
if media == 'movies':
|
||||||
|
media_filter = Q(video__video_type=Video.VideoType.MOVIE)
|
||||||
|
if media == 'series':
|
||||||
|
media_filter = Q(video__video_type=Video.VideoType.TV_EPISODE)
|
||||||
|
|
||||||
for day in range(6, -1, -1):
|
for day in range(6, -1, -1):
|
||||||
start = START_OF_TODAY - timedelta(days=day)
|
start_day = start - timedelta(days=day)
|
||||||
end = datetime.combine(start, datetime.max.time(), NOW.tzinfo)
|
end = datetime.combine(start_day, datetime.max.time(), now.tzinfo)
|
||||||
day_of_week = start.strftime('%A')
|
day_of_week = start_day.strftime('%A')
|
||||||
if media == 'movies':
|
|
||||||
media_filter = Q(video__videotype=Video.VideoType.MOVIE)
|
scrobble_day_dict[day_of_week] = base_qs.filter(
|
||||||
if media == 'series':
|
media_filter,
|
||||||
media_filter = Q(video__videotype=Video.VideoType.TV_EPISODE)
|
timestamp__gte=start_day,
|
||||||
scrobble_day_dict[day_of_week] = (
|
timestamp__lte=end,
|
||||||
Scrobble.objects.filter(media_filter)
|
played_to_completion=True,
|
||||||
.filter(
|
).count()
|
||||||
timestamp__gte=start,
|
|
||||||
timestamp__lte=end,
|
|
||||||
in_progress=False,
|
|
||||||
)
|
|
||||||
.count()
|
|
||||||
)
|
|
||||||
|
|
||||||
return scrobble_day_dict
|
return scrobble_day_dict
|
||||||
|
|
||||||
|
|
||||||
def top_tracks(filter: str = "today", limit: int = 15) -> List["Track"]:
|
def live_charts(
|
||||||
time_filter = Q(scrobble__timestamp__gte=START_OF_TODAY)
|
user: "User",
|
||||||
if filter == "week":
|
media_type: str = "Track",
|
||||||
time_filter = Q(scrobble__timestamp__gte=STARTING_DAY_OF_CURRENT_WEEK)
|
chart_period: str = "all",
|
||||||
if filter == "month":
|
limit: int = 15,
|
||||||
time_filter = Q(scrobble__timestamp__gte=STARTING_DAY_OF_CURRENT_MONTH)
|
) -> QuerySet:
|
||||||
if filter == "year":
|
now = timezone.now()
|
||||||
time_filter = Q(scrobble__timestamp__gte=STARTING_DAY_OF_CURRENT_YEAR)
|
tzinfo = now.tzinfo
|
||||||
|
now = now.date()
|
||||||
|
if user.is_authenticated:
|
||||||
|
now = now_user_timezone(user.profile)
|
||||||
|
tzinfo = now.tzinfo
|
||||||
|
|
||||||
return (
|
start_of_today = datetime.combine(now, datetime.min.time(), tzinfo)
|
||||||
Track.objects.annotate(num_scrobbles=Count("scrobble", distinct=True))
|
start_day_of_week = now - timedelta(days=now.today().isoweekday() % 7)
|
||||||
.filter(time_filter)
|
start_day_of_month = now.replace(day=1)
|
||||||
.order_by("-num_scrobbles")[:limit]
|
start_day_of_year = now.replace(month=1, day=1)
|
||||||
|
|
||||||
|
media_model = apps.get_model(app_label='music', model_name=media_type)
|
||||||
|
|
||||||
|
period_queries = {
|
||||||
|
'today': {'scrobble__timestamp__gte': start_of_today},
|
||||||
|
'week': {'scrobble__timestamp__gte': start_day_of_week},
|
||||||
|
'month': {'scrobble__timestamp__gte': start_day_of_month},
|
||||||
|
'year': {'scrobble__timestamp__gte': start_day_of_year},
|
||||||
|
'all': {},
|
||||||
|
}
|
||||||
|
|
||||||
|
time_filter = Q()
|
||||||
|
completion_filter = Q(
|
||||||
|
scrobble__user=user, scrobble__played_to_completion=True
|
||||||
)
|
)
|
||||||
|
user_filter = Q(scrobble__user=user)
|
||||||
|
count_field = "scrobble"
|
||||||
|
|
||||||
|
if media_type == "Artist":
|
||||||
|
for period, query_dict in period_queries.items():
|
||||||
|
period_queries[period] = {
|
||||||
|
"track__" + k: v for k, v in query_dict.items()
|
||||||
|
}
|
||||||
|
completion_filter = Q(
|
||||||
|
track__scrobble__user=user,
|
||||||
|
track__scrobble__played_to_completion=True,
|
||||||
|
)
|
||||||
|
count_field = "track__scrobble"
|
||||||
|
user_filter = Q(track__scrobble__user=user)
|
||||||
|
|
||||||
def top_artists(filter: str = "today", limit: int = 15) -> List["Artist"]:
|
time_filter = Q(**period_queries[chart_period])
|
||||||
time_filter = Q(track__scrobble__timestamp__gte=START_OF_TODAY)
|
|
||||||
if filter == "week":
|
|
||||||
time_filter = Q(
|
|
||||||
track__scrobble__timestamp__gte=STARTING_DAY_OF_CURRENT_WEEK
|
|
||||||
)
|
|
||||||
if filter == "month":
|
|
||||||
time_filter = Q(
|
|
||||||
track__scrobble__timestamp__gte=STARTING_DAY_OF_CURRENT_MONTH
|
|
||||||
)
|
|
||||||
if filter == "year":
|
|
||||||
time_filter = Q(
|
|
||||||
track__scrobble__timestamp__gte=STARTING_DAY_OF_CURRENT_YEAR
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
Artist.objects.annotate(
|
media_model.objects.filter(user_filter, time_filter)
|
||||||
num_scrobbles=Count("track__scrobble", distinct=True)
|
.annotate(
|
||||||
|
num_scrobbles=Count(
|
||||||
|
count_field,
|
||||||
|
filter=completion_filter,
|
||||||
|
distinct=True,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
.filter(time_filter)
|
|
||||||
.order_by("-num_scrobbles")[:limit]
|
.order_by("-num_scrobbles")[:limit]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
0
vrobbler/apps/music/api/__init__.py
Normal file
0
vrobbler/apps/music/api/__init__.py
Normal file
20
vrobbler/apps/music/api/serializers.py
Normal file
20
vrobbler/apps/music/api/serializers.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
from music.models import Album, Artist, Track
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
|
||||||
|
class ArtistSerializer(serializers.HyperlinkedModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Artist
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
|
class AlbumSerializer(serializers.HyperlinkedModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Album
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
|
class TrackSerializer(serializers.HyperlinkedModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Track
|
||||||
|
fields = "__all__"
|
||||||
26
vrobbler/apps/music/api/views.py
Normal file
26
vrobbler/apps/music/api/views.py
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
from rest_framework import permissions, viewsets
|
||||||
|
|
||||||
|
from music.api.serializers import (
|
||||||
|
TrackSerializer,
|
||||||
|
ArtistSerializer,
|
||||||
|
AlbumSerializer,
|
||||||
|
)
|
||||||
|
from music.models import Artist, Album, Track
|
||||||
|
|
||||||
|
|
||||||
|
class ArtistViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Artist.objects.all().order_by('-created')
|
||||||
|
serializer_class = ArtistSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
|
||||||
|
class AlbumViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Album.objects.all().order_by('-created')
|
||||||
|
serializer_class = AlbumSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
|
||||||
|
class TrackViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Track.objects.all().order_by('-created')
|
||||||
|
serializer_class = TrackSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
17
vrobbler/apps/music/migrations/0008_alter_track_options.py
Normal file
17
vrobbler/apps/music/migrations/0008_alter_track_options.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-01-13 01:47
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('music', '0007_alter_album_artists'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='track',
|
||||||
|
options={},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-01-19 20:03
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('music', '0008_alter_track_options'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='track',
|
||||||
|
name='musicbrainz_id',
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='track',
|
||||||
|
unique_together={('album', 'musicbrainz_id')},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-02-27 03:57
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('music', '0009_alter_track_musicbrainz_id_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='artist',
|
||||||
|
name='biography',
|
||||||
|
field=models.TextField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='artist',
|
||||||
|
name='theaudiodb_genre',
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='artist',
|
||||||
|
name='theaudiodb_mood',
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
20
vrobbler/apps/music/migrations/0011_artist_thumbnail.py
Normal file
20
vrobbler/apps/music/migrations/0011_artist_thumbnail.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-02-27 04:04
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('music', '0010_artist_biography_artist_theaudiodb_genre_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='artist',
|
||||||
|
name='thumbnail',
|
||||||
|
field=models.ImageField(
|
||||||
|
blank=True, null=True, upload_to='artist/'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,85 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-03-02 19:00
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('music', '0011_artist_thumbnail'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='album',
|
||||||
|
name='allmusic_id',
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='album',
|
||||||
|
name='discogs_id',
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='album',
|
||||||
|
name='rateyourmusic_id',
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='album',
|
||||||
|
name='theaudiodb_description',
|
||||||
|
field=models.TextField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='album',
|
||||||
|
name='theaudiodb_genre',
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='album',
|
||||||
|
name='theaudiodb_id',
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True, max_length=255, null=True, unique=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='album',
|
||||||
|
name='theaudiodb_mood',
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='album',
|
||||||
|
name='theaudiodb_score',
|
||||||
|
field=models.IntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='album',
|
||||||
|
name='theaudiodb_score_votes',
|
||||||
|
field=models.IntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='album',
|
||||||
|
name='theaudiodb_speed',
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='album',
|
||||||
|
name='theaudiodb_style',
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='album',
|
||||||
|
name='theaudiodb_theme',
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='album',
|
||||||
|
name='wikidata_id',
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='album',
|
||||||
|
name='wikipedia_slug',
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-03-02 19:23
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('music', '0012_album_allmusic_id_album_discogs_id_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='album',
|
||||||
|
name='theaudiodb_score',
|
||||||
|
field=models.FloatField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-03-02 19:27
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('music', '0013_alter_album_theaudiodb_score'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='album',
|
||||||
|
name='theaudiodb_year_released',
|
||||||
|
field=models.IntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -1,14 +1,19 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from tempfile import NamedTemporaryFile
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
|
from urllib.request import urlopen
|
||||||
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.core.files.base import ContentFile
|
from django.core.files.base import ContentFile, File
|
||||||
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
|
||||||
|
from scrobbles.theaudiodb import lookup_artist_from_tadb
|
||||||
|
from vrobbler.apps.scrobbles.theaudiodb import lookup_album_from_tadb
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
BNULL = {"blank": True, "null": True}
|
BNULL = {"blank": True, "null": True}
|
||||||
@ -17,7 +22,11 @@ BNULL = {"blank": True, "null": True}
|
|||||||
class Artist(TimeStampedModel):
|
class Artist(TimeStampedModel):
|
||||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
|
biography = models.TextField(**BNULL)
|
||||||
|
theaudiodb_genre = models.CharField(max_length=255, **BNULL)
|
||||||
|
theaudiodb_mood = models.CharField(max_length=255, **BNULL)
|
||||||
musicbrainz_id = models.CharField(max_length=255, **BNULL)
|
musicbrainz_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
thumbnail = models.ImageField(upload_to="artist/", **BNULL)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = [['name', 'musicbrainz_id']]
|
unique_together = [['name', 'musicbrainz_id']]
|
||||||
@ -29,6 +38,45 @@ 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')
|
||||||
|
|
||||||
|
def fix_metadata(self):
|
||||||
|
tadb_info = lookup_artist_from_tadb(self.name)
|
||||||
|
if not tadb_info:
|
||||||
|
logger.warn(f"No response from TADB for artist {self.name}")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.biography = tadb_info['biography']
|
||||||
|
self.theaudiodb_genre = tadb_info['genre']
|
||||||
|
self.theaudiodb_mood = tadb_info['mood']
|
||||||
|
|
||||||
|
img_temp = NamedTemporaryFile(delete=True)
|
||||||
|
img_temp.write(urlopen(tadb_info['thumb_url']).read())
|
||||||
|
img_temp.flush()
|
||||||
|
img_filename = f"{self.name}_{self.uuid}.jpg"
|
||||||
|
self.thumbnail.save(img_filename, File(img_temp))
|
||||||
|
|
||||||
|
|
||||||
class Album(TimeStampedModel):
|
class Album(TimeStampedModel):
|
||||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||||
@ -39,51 +87,167 @@ class Album(TimeStampedModel):
|
|||||||
musicbrainz_releasegroup_id = models.CharField(max_length=255, **BNULL)
|
musicbrainz_releasegroup_id = models.CharField(max_length=255, **BNULL)
|
||||||
musicbrainz_albumartist_id = models.CharField(max_length=255, **BNULL)
|
musicbrainz_albumartist_id = models.CharField(max_length=255, **BNULL)
|
||||||
cover_image = models.ImageField(upload_to="albums/", **BNULL)
|
cover_image = models.ImageField(upload_to="albums/", **BNULL)
|
||||||
|
theaudiodb_id = models.CharField(max_length=255, unique=True, **BNULL)
|
||||||
|
theaudiodb_description = models.TextField(**BNULL)
|
||||||
|
theaudiodb_year_released = models.IntegerField(**BNULL)
|
||||||
|
theaudiodb_score = models.FloatField(**BNULL)
|
||||||
|
theaudiodb_score_votes = models.IntegerField(**BNULL)
|
||||||
|
theaudiodb_genre = models.CharField(max_length=255, **BNULL)
|
||||||
|
theaudiodb_style = models.CharField(max_length=255, **BNULL)
|
||||||
|
theaudiodb_mood = models.CharField(max_length=255, **BNULL)
|
||||||
|
theaudiodb_speed = models.CharField(max_length=255, **BNULL)
|
||||||
|
theaudiodb_theme = models.CharField(max_length=255, **BNULL)
|
||||||
|
allmusic_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
rateyourmusic_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
wikipedia_slug = models.CharField(max_length=255, **BNULL)
|
||||||
|
discogs_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
wikidata_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse("music:album_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')
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def primary_artist(self):
|
def primary_artist(self):
|
||||||
return self.artists.first()
|
return self.artists.first()
|
||||||
|
|
||||||
|
def scrape_theaudiodb(self) -> None:
|
||||||
|
artist = self.primary_artist.name
|
||||||
|
album_data = lookup_album_from_tadb(self.name, artist)
|
||||||
|
if not album_data.get('theaudiodb_id'):
|
||||||
|
logger.info(f"No data for {self} found in TheAudioDB")
|
||||||
|
return
|
||||||
|
|
||||||
|
Album.objects.filter(pk=self.pk).update(**album_data)
|
||||||
|
|
||||||
def fix_metadata(self):
|
def fix_metadata(self):
|
||||||
musicbrainzngs.set_useragent('vrobbler', '0.3.0')
|
if (
|
||||||
mb_data = musicbrainzngs.get_release_by_id(
|
not self.musicbrainz_albumartist_id
|
||||||
self.musicbrainz_id, includes=['artists']
|
or not self.year
|
||||||
)
|
or not self.musicbrainz_releasegroup_id
|
||||||
if not self.musicbrainz_albumartist_id:
|
):
|
||||||
self.musicbrainz_albumartist_id = mb_data['release'][
|
musicbrainzngs.set_useragent('vrobbler', '0.3.0')
|
||||||
'artist-credit'
|
mb_data = musicbrainzngs.get_release_by_id(
|
||||||
][0]['artist']['id']
|
self.musicbrainz_id, includes=['artists', 'release-groups']
|
||||||
if not self.year:
|
)
|
||||||
self.year = mb_data['release']['date'][0:4]
|
if not self.musicbrainz_releasegroup_id:
|
||||||
self.save(update_fields=['musicbrainz_albumartist_id', 'year'])
|
self.musicbrainz_releasegroup_id = mb_data['release'][
|
||||||
|
'release-group'
|
||||||
|
]['id']
|
||||||
|
if not self.musicbrainz_albumartist_id:
|
||||||
|
self.musicbrainz_albumartist_id = mb_data['release'][
|
||||||
|
'artist-credit'
|
||||||
|
][0]['artist']['id']
|
||||||
|
if not self.year:
|
||||||
|
try:
|
||||||
|
self.year = mb_data['release']['date'][0:4]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
except IndexError:
|
||||||
|
pass
|
||||||
|
|
||||||
new_artist = Artist.objects.filter(
|
self.save(
|
||||||
musicbrainz_id=self.musicbrainz_albumartist_id
|
update_fields=[
|
||||||
).first()
|
'musicbrainz_albumartist_id',
|
||||||
if self.musicbrainz_albumartist_id and new_artist:
|
'musicbrainz_releasegroup_id',
|
||||||
self.artists.add(new_artist)
|
'year',
|
||||||
if not new_artist:
|
]
|
||||||
for t in self.track_set.all():
|
)
|
||||||
self.artists.add(t.artist)
|
|
||||||
|
|
||||||
def fetch_artwork(self):
|
new_artist = Artist.objects.filter(
|
||||||
try:
|
musicbrainz_id=self.musicbrainz_albumartist_id
|
||||||
img_data = musicbrainzngs.get_image_front(self.musicbrainz_id)
|
).first()
|
||||||
name = f"{self.name}_{self.uuid}.jpg"
|
if self.musicbrainz_albumartist_id and new_artist:
|
||||||
self.cover_image = ContentFile(img_data, name=name)
|
self.artists.add(new_artist)
|
||||||
|
if not new_artist:
|
||||||
|
for t in self.track_set.all():
|
||||||
|
self.artists.add(t.artist)
|
||||||
|
if (
|
||||||
|
not self.cover_image
|
||||||
|
or self.cover_image == 'default-image-replace-me'
|
||||||
|
):
|
||||||
|
self.fetch_artwork()
|
||||||
|
self.scrape_theaudiodb()
|
||||||
|
|
||||||
|
def fetch_artwork(self, force=False):
|
||||||
|
if not self.cover_image and not force:
|
||||||
|
if self.musicbrainz_id:
|
||||||
|
try:
|
||||||
|
img_data = musicbrainzngs.get_image_front(
|
||||||
|
self.musicbrainz_id
|
||||||
|
)
|
||||||
|
name = f"{self.name}_{self.uuid}.jpg"
|
||||||
|
self.cover_image = ContentFile(img_data, name=name)
|
||||||
|
logger.info(f'Setting image to {name}')
|
||||||
|
except musicbrainzngs.ResponseError:
|
||||||
|
logger.warning(
|
||||||
|
f'No cover art found for {self.name} by release'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
not self.cover_image
|
||||||
|
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()
|
||||||
except musicbrainzngs.ResponseError:
|
|
||||||
logger.warning(f'No cover art found for {self.name}')
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def mb_link(self):
|
def mb_link(self) -> str:
|
||||||
return f"https://musicbrainz.org/release/{self.musicbrainz_id}"
|
return f"https://musicbrainz.org/release/{self.musicbrainz_id}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def allmusic_link(self) -> str:
|
||||||
|
if self.allmusic_id:
|
||||||
|
return f"https://www.allmusic.com/artist/{self.allmusic_id}"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def wikipedia_link(self):
|
||||||
|
if self.wikipedia_slug:
|
||||||
|
return f"https://www.wikipedia.org/en/{self.wikipedia_slug}"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tadb_link(self):
|
||||||
|
if self.theaudiodb_id:
|
||||||
|
return f"https://www.theaudiodb.com/album/{self.theaudiodb_id}"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
class Track(ScrobblableMixin):
|
class Track(ScrobblableMixin):
|
||||||
|
COMPLETION_PERCENT = getattr(settings, 'MUSIC_COMPLETION_PERCENT', 90)
|
||||||
|
|
||||||
class Opinion(models.IntegerChoices):
|
class Opinion(models.IntegerChoices):
|
||||||
DOWN = -1, 'Thumbs down'
|
DOWN = -1, 'Thumbs down'
|
||||||
NEUTRAL = 0, 'No opinion'
|
NEUTRAL = 0, 'No opinion'
|
||||||
@ -91,18 +255,28 @@ class Track(ScrobblableMixin):
|
|||||||
|
|
||||||
artist = models.ForeignKey(Artist, on_delete=models.DO_NOTHING)
|
artist = models.ForeignKey(Artist, on_delete=models.DO_NOTHING)
|
||||||
album = models.ForeignKey(Album, on_delete=models.DO_NOTHING, **BNULL)
|
album = models.ForeignKey(Album, on_delete=models.DO_NOTHING, **BNULL)
|
||||||
musicbrainz_id = models.CharField(max_length=255, unique=True, **BNULL)
|
musicbrainz_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = [['album', 'musicbrainz_id']]
|
||||||
|
|
||||||
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
|
||||||
|
def subtitle(self):
|
||||||
|
return self.artist
|
||||||
|
|
||||||
@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
|
@property
|
||||||
def scrobble_count(self):
|
def info_link(self):
|
||||||
return self.scrobble_set.filter(in_progress=False).count()
|
return self.mb_link
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def find_or_create(
|
def find_or_create(
|
||||||
@ -122,26 +296,15 @@ 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 album {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()
|
||||||
album.fetch_artwork()
|
if not album.cover_image:
|
||||||
|
album.fetch_artwork()
|
||||||
|
|
||||||
track_dict['album_id'] = getattr(album, "id", None)
|
track_dict['album_id'] = getattr(album, "id", None)
|
||||||
track_dict['artist_id'] = artist.id
|
track_dict['artist_id'] = artist.id
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
1
vrobbler/apps/music/serializers.py
Normal file
1
vrobbler/apps/music/serializers.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
26
vrobbler/apps/music/urls.py
Normal file
26
vrobbler/apps/music/urls.py
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from music import views
|
||||||
|
|
||||||
|
app_name = 'music'
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('albums/', views.AlbumListView.as_view(), name='albums_list'),
|
||||||
|
path(
|
||||||
|
'album/<slug:slug>/',
|
||||||
|
views.AlbumDetailView.as_view(),
|
||||||
|
name='album_detail',
|
||||||
|
),
|
||||||
|
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',
|
||||||
|
),
|
||||||
|
]
|
||||||
109
vrobbler/apps/music/utils.py
Normal file
109
vrobbler/apps/music/utils.py
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
|
from musicbrainzngs.caa import musicbrainz
|
||||||
|
|
||||||
|
from scrobbles.musicbrainz import (
|
||||||
|
lookup_album_dict_from_mb,
|
||||||
|
lookup_artist_from_mb,
|
||||||
|
lookup_track_from_mb,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
from music.models import Album, Artist, Track
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_artist(name: str, mbid: str = None) -> Artist:
|
||||||
|
artist = None
|
||||||
|
logger.debug(f'Got artist {name} and mbid: {mbid}')
|
||||||
|
|
||||||
|
if 'feat.' in name.lower():
|
||||||
|
name = re.split("feat.", name, flags=re.IGNORECASE)[0].strip()
|
||||||
|
if 'featuring' in name.lower():
|
||||||
|
name = re.split("featuring", name, flags=re.IGNORECASE)[0].strip()
|
||||||
|
if '&' in name.lower():
|
||||||
|
name = re.split("&", name, flags=re.IGNORECASE)[0].strip()
|
||||||
|
|
||||||
|
artist_dict = lookup_artist_from_mb(name)
|
||||||
|
mbid = mbid or artist_dict['id']
|
||||||
|
|
||||||
|
logger.debug(f'Looking up artist {name} and mbid: {mbid}')
|
||||||
|
artist = Artist.objects.filter(musicbrainz_id=mbid).first()
|
||||||
|
if not artist:
|
||||||
|
artist = Artist.objects.create(name=name, musicbrainz_id=mbid)
|
||||||
|
logger.debug(
|
||||||
|
f"Created artist {artist.name} ({artist.musicbrainz_id}) "
|
||||||
|
)
|
||||||
|
artist.fix_metadata()
|
||||||
|
|
||||||
|
return artist
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_album(name: str, artist: Artist, mbid: str = None) -> Album:
|
||||||
|
album = None
|
||||||
|
album_created = False
|
||||||
|
albums = Album.objects.filter(name__iexact=name)
|
||||||
|
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=name, musicbrainz_id=mbid)
|
||||||
|
album.save()
|
||||||
|
album.artists.add(artist)
|
||||||
|
|
||||||
|
if album_created or not mbid:
|
||||||
|
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()
|
||||||
|
return album
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_track(
|
||||||
|
title: str,
|
||||||
|
artist: Artist,
|
||||||
|
album: Album,
|
||||||
|
mbid: str = None,
|
||||||
|
run_time=None,
|
||||||
|
run_time_ticks=None,
|
||||||
|
) -> Track:
|
||||||
|
track = None
|
||||||
|
if not mbid:
|
||||||
|
mbid = lookup_track_from_mb(
|
||||||
|
title,
|
||||||
|
artist.musicbrainz_id,
|
||||||
|
album.musicbrainz_id,
|
||||||
|
)['id']
|
||||||
|
|
||||||
|
track = Track.objects.filter(musicbrainz_id=mbid).first()
|
||||||
|
|
||||||
|
if not track:
|
||||||
|
track = Track.objects.create(
|
||||||
|
title=title,
|
||||||
|
artist=artist,
|
||||||
|
album=album,
|
||||||
|
musicbrainz_id=mbid,
|
||||||
|
run_time=run_time,
|
||||||
|
run_time_ticks=run_time_ticks,
|
||||||
|
)
|
||||||
|
|
||||||
|
return track
|
||||||
83
vrobbler/apps/music/views.py
Normal file
83
vrobbler/apps/music/views.py
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.views import generic
|
||||||
|
from music.models import Album, Artist, Track
|
||||||
|
from scrobbles.models import ChartRecord
|
||||||
|
from scrobbles.stats import get_scrobble_count_qs
|
||||||
|
|
||||||
|
|
||||||
|
class TrackListView(generic.ListView):
|
||||||
|
model = Track
|
||||||
|
paginate_by = 200
|
||||||
|
|
||||||
|
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'
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context_data = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
context_data['charts'] = ChartRecord.objects.filter(
|
||||||
|
track=self.object, rank__in=[1, 2, 3]
|
||||||
|
)
|
||||||
|
return context_data
|
||||||
|
|
||||||
|
|
||||||
|
class ArtistListView(generic.ListView):
|
||||||
|
model = Artist
|
||||||
|
paginate_by = 100
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return super().get_queryset().order_by("name")
|
||||||
|
|
||||||
|
def get_context_data(self, *, object_list=None, **kwargs):
|
||||||
|
context_data = super().get_context_data(
|
||||||
|
object_list=object_list, **kwargs
|
||||||
|
)
|
||||||
|
context_data['view'] = self.request.GET.get('view')
|
||||||
|
return context_data
|
||||||
|
|
||||||
|
|
||||||
|
class ArtistDetailView(generic.DetailView):
|
||||||
|
model = Artist
|
||||||
|
slug_field = 'uuid'
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context_data = super().get_context_data(**kwargs)
|
||||||
|
context_data['charts'] = ChartRecord.objects.filter(
|
||||||
|
artist=self.object, rank__in=[1, 2, 3]
|
||||||
|
)
|
||||||
|
return context_data
|
||||||
|
|
||||||
|
|
||||||
|
class AlbumListView(generic.ListView):
|
||||||
|
model = Album
|
||||||
|
paginate_by = 50
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return super().get_queryset().order_by("name")
|
||||||
|
|
||||||
|
def get_context_data(self, *, object_list=None, **kwargs):
|
||||||
|
context_data = super().get_context_data(
|
||||||
|
object_list=object_list, **kwargs
|
||||||
|
)
|
||||||
|
context_data['view'] = self.request.GET.get('view')
|
||||||
|
return context_data
|
||||||
|
|
||||||
|
|
||||||
|
class AlbumDetailView(generic.DetailView):
|
||||||
|
model = Album
|
||||||
|
slug_field = 'uuid'
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context_data = super().get_context_data(**kwargs)
|
||||||
|
# context_data['charts'] = ChartRecord.objects.filter(
|
||||||
|
# track__album=self.object, rank__in=[1, 2, 3]
|
||||||
|
# )
|
||||||
|
return context_data
|
||||||
@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from podcasts.models import Episode, Podcast, Producer
|
from podcasts.models import Episode, Podcast, Producer
|
||||||
|
from scrobbles.admin import ScrobbleInline
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Producer)
|
@admin.register(Producer)
|
||||||
@ -31,3 +31,6 @@ class EpisodeAdmin(admin.ModelAdmin):
|
|||||||
)
|
)
|
||||||
list_filter = ("podcast",)
|
list_filter = ("podcast",)
|
||||||
ordering = ("-created",)
|
ordering = ("-created",)
|
||||||
|
inlines = [
|
||||||
|
ScrobbleInline,
|
||||||
|
]
|
||||||
|
|||||||
@ -0,0 +1,22 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-01-13 01:47
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('podcasts', '0004_episode_number'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='episode',
|
||||||
|
options={},
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='episode',
|
||||||
|
name='title',
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -2,11 +2,12 @@ import logging
|
|||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_extensions.db.models import TimeStampedModel
|
from django_extensions.db.models import TimeStampedModel
|
||||||
|
|
||||||
from vrobbler.apps.scrobbles.mixins import ScrobblableMixin
|
from scrobbles.mixins import ScrobblableMixin
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
BNULL = {"blank": True, "null": True}
|
BNULL = {"blank": True, "null": True}
|
||||||
@ -34,6 +35,8 @@ class Podcast(TimeStampedModel):
|
|||||||
|
|
||||||
|
|
||||||
class Episode(ScrobblableMixin):
|
class Episode(ScrobblableMixin):
|
||||||
|
COMPLETION_PERCENT = getattr(settings, 'PODCAST_COMPLETION_PERCENT', 90)
|
||||||
|
|
||||||
podcast = models.ForeignKey(Podcast, on_delete=models.DO_NOTHING)
|
podcast = models.ForeignKey(Podcast, on_delete=models.DO_NOTHING)
|
||||||
number = models.IntegerField(**BNULL)
|
number = models.IntegerField(**BNULL)
|
||||||
pub_date = models.DateField(**BNULL)
|
pub_date = models.DateField(**BNULL)
|
||||||
@ -42,6 +45,14 @@ class Episode(ScrobblableMixin):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.title}"
|
return f"{self.title}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def subtitle(self):
|
||||||
|
return self.podcast
|
||||||
|
|
||||||
|
@property
|
||||||
|
def info_link(self):
|
||||||
|
return ""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def find_or_create(
|
def find_or_create(
|
||||||
cls, podcast_dict: Dict, producer_dict: Dict, episode_dict: Dict
|
cls, podcast_dict: Dict, producer_dict: Dict, episode_dict: Dict
|
||||||
|
|||||||
0
vrobbler/apps/profiles/__init__.py
Normal file
0
vrobbler/apps/profiles/__init__.py
Normal file
9
vrobbler/apps/profiles/admin.py
Normal file
9
vrobbler/apps/profiles/admin.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from profiles.models import UserProfile
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(UserProfile)
|
||||||
|
class UserProfileAdmin(admin.ModelAdmin):
|
||||||
|
date_hierarchy = "created"
|
||||||
|
ordering = ("-created",)
|
||||||
1
vrobbler/apps/profiles/api/__init__.py
Normal file
1
vrobbler/apps/profiles/api/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
18
vrobbler/apps/profiles/api/serializers.py
Normal file
18
vrobbler/apps/profiles/api/serializers.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
from profiles.models import UserProfile
|
||||||
|
|
||||||
|
|
||||||
|
class UserSerializer(serializers.HyperlinkedModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
exclude = ('password',)
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileSerializer(serializers.HyperlinkedModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = UserProfile
|
||||||
|
exclude = ('lastfm_password',)
|
||||||
28
vrobbler/apps/profiles/api/views.py
Normal file
28
vrobbler/apps/profiles/api/views.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
from rest_framework import permissions, viewsets
|
||||||
|
|
||||||
|
from profiles.api.serializers import UserSerializer, UserProfileSerializer
|
||||||
|
from profiles.models import UserProfile
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class UserViewSet(viewsets.ModelViewSet):
|
||||||
|
"""
|
||||||
|
API endpoint that allows users to be viewed or edited.
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = User.objects.all().order_by('-date_joined')
|
||||||
|
serializer_class = UserSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileViewSet(viewsets.ModelViewSet):
|
||||||
|
"""
|
||||||
|
API endpoint that allows users to be viewed or edited.
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = UserProfile.objects.all().order_by('-created')
|
||||||
|
serializer_class = UserProfileSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
17
vrobbler/apps/profiles/constants.py
Normal file
17
vrobbler/apps/profiles/constants.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
ALL_TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones))
|
||||||
|
COMMON_TIMEZONE_CHOICES = tuple(
|
||||||
|
zip(pytz.common_timezones, pytz.common_timezones)
|
||||||
|
)
|
||||||
|
PRETTY_TIMEZONE_CHOICES = []
|
||||||
|
|
||||||
|
for tz in pytz.common_timezones:
|
||||||
|
now = datetime.now(pytz.timezone(tz))
|
||||||
|
ofs = now.strftime("%z")
|
||||||
|
PRETTY_TIMEZONE_CHOICES.append((int(ofs), tz, "(GMT%s) %s" % (ofs, tz)))
|
||||||
|
PRETTY_TIMEZONE_CHOICES.sort()
|
||||||
|
for i in range(len(PRETTY_TIMEZONE_CHOICES)):
|
||||||
|
PRETTY_TIMEZONE_CHOICES[i] = PRETTY_TIMEZONE_CHOICES[i][1:]
|
||||||
1035
vrobbler/apps/profiles/migrations/0001_initial.py
Normal file
1035
vrobbler/apps/profiles/migrations/0001_initial.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,24 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-02-12 22:26
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import encrypted_field.fields
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('profiles', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='userprofile',
|
||||||
|
name='lastfm_password',
|
||||||
|
field=encrypted_field.fields.EncryptedField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='userprofile',
|
||||||
|
name='lastfm_username',
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
vrobbler/apps/profiles/migrations/__init__.py
Normal file
0
vrobbler/apps/profiles/migrations/__init__.py
Normal file
29
vrobbler/apps/profiles/models.py
Normal file
29
vrobbler/apps/profiles/models.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import pytz
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db import models
|
||||||
|
from django_extensions.db.models import TimeStampedModel
|
||||||
|
from profiles.constants import PRETTY_TIMEZONE_CHOICES
|
||||||
|
|
||||||
|
from encrypted_field import EncryptedField
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
BNULL = {"blank": True, "null": True}
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfile(TimeStampedModel):
|
||||||
|
user = models.OneToOneField(
|
||||||
|
User, on_delete=models.CASCADE, related_name="profile"
|
||||||
|
)
|
||||||
|
timezone = models.CharField(
|
||||||
|
max_length=255, choices=PRETTY_TIMEZONE_CHOICES, default=pytz.UTC
|
||||||
|
)
|
||||||
|
lastfm_username = models.CharField(max_length=255, **BNULL)
|
||||||
|
lastfm_password = EncryptedField(**BNULL)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"User profile for {self.user}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tzinfo(self):
|
||||||
|
return pytz.timezone(self.timezone)
|
||||||
13
vrobbler/apps/profiles/signals.py
Normal file
13
vrobbler/apps/profiles/signals.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db.models.base import post_save
|
||||||
|
from django.dispatch import receiver
|
||||||
|
|
||||||
|
from profiles.models import UserProfile
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=User)
|
||||||
|
def create_profile(sender, instance, created, **kwargs):
|
||||||
|
if created:
|
||||||
|
UserProfile.objects.create(user=instance)
|
||||||
33
vrobbler/apps/profiles/utils.py
Normal file
33
vrobbler/apps/profiles/utils.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import datetime
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
|
# need to translate to a non-naive timezone, even if timezone == settings.TIME_ZONE, so we can compare two dates
|
||||||
|
def to_user_timezone(date, profile):
|
||||||
|
timezone = profile.timezone if profile.timezone else settings.TIME_ZONE
|
||||||
|
return date.replace(tzinfo=pytz.timezone(settings.TIME_ZONE)).astimezone(
|
||||||
|
pytz.timezone(timezone)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def to_system_timezone(date, profile):
|
||||||
|
timezone = profile.timezone if profile.timezone else settings.TIME_ZONE
|
||||||
|
return date.replace(tzinfo=pytz.timezone(timezone)).astimezone(
|
||||||
|
pytz.timezone(settings.TIME_ZONE)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def now_user_timezone(profile):
|
||||||
|
timezone.activate(pytz.timezone(profile.timezone))
|
||||||
|
return timezone.localtime(timezone.now())
|
||||||
|
|
||||||
|
|
||||||
|
def now_system_timezone():
|
||||||
|
return (
|
||||||
|
datetime.datetime.now()
|
||||||
|
.replace(tzinfo=pytz.timezone(settings.TIME_ZONE))
|
||||||
|
.astimezone(pytz.timezone(settings.TIME_ZONE))
|
||||||
|
)
|
||||||
@ -1,6 +1,70 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from scrobbles.models import (
|
||||||
|
AudioScrobblerTSVImport,
|
||||||
|
ChartRecord,
|
||||||
|
KoReaderImport,
|
||||||
|
LastFmImport,
|
||||||
|
Scrobble,
|
||||||
|
)
|
||||||
|
|
||||||
from scrobbles.models import Scrobble
|
|
||||||
|
class ScrobbleInline(admin.TabularInline):
|
||||||
|
model = Scrobble
|
||||||
|
extra = 0
|
||||||
|
raw_id_fields = ('video', 'podcast_episode', 'track')
|
||||||
|
exclude = ('source_id', 'scrobble_log')
|
||||||
|
|
||||||
|
|
||||||
|
class ImportBaseAdmin(admin.ModelAdmin):
|
||||||
|
date_hierarchy = "created"
|
||||||
|
list_display = (
|
||||||
|
"uuid",
|
||||||
|
"process_count",
|
||||||
|
"processed_finished",
|
||||||
|
"processing_started",
|
||||||
|
)
|
||||||
|
ordering = ("-created",)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(AudioScrobblerTSVImport)
|
||||||
|
class AudioScrobblerTSVImportAdmin(ImportBaseAdmin):
|
||||||
|
""""""
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(LastFmImport)
|
||||||
|
class LastFmImportAdmin(ImportBaseAdmin):
|
||||||
|
""""""
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(KoReaderImport)
|
||||||
|
class KoReaderImportAdmin(ImportBaseAdmin):
|
||||||
|
""""""
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ChartRecord)
|
||||||
|
class ChartRecordAdmin(admin.ModelAdmin):
|
||||||
|
date_hierarchy = "created"
|
||||||
|
list_display = (
|
||||||
|
"user",
|
||||||
|
"rank",
|
||||||
|
"count",
|
||||||
|
"year",
|
||||||
|
"week",
|
||||||
|
"month",
|
||||||
|
"day",
|
||||||
|
"media_name",
|
||||||
|
)
|
||||||
|
ordering = ("-created",)
|
||||||
|
|
||||||
|
def media_name(self, obj):
|
||||||
|
if obj.video:
|
||||||
|
return obj.video
|
||||||
|
if obj.track:
|
||||||
|
return obj.track
|
||||||
|
if obj.podcast_episode:
|
||||||
|
return obj.podcast_episode
|
||||||
|
if obj.sport_event:
|
||||||
|
return obj.sport_event
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Scrobble)
|
@admin.register(Scrobble)
|
||||||
@ -10,26 +74,35 @@ class ScrobbleAdmin(admin.ModelAdmin):
|
|||||||
"timestamp",
|
"timestamp",
|
||||||
"media_name",
|
"media_name",
|
||||||
"media_type",
|
"media_type",
|
||||||
|
"playback_percent",
|
||||||
"source",
|
"source",
|
||||||
"playback_position",
|
|
||||||
"in_progress",
|
"in_progress",
|
||||||
"is_paused",
|
"is_paused",
|
||||||
|
"played_to_completion",
|
||||||
|
)
|
||||||
|
raw_id_fields = (
|
||||||
|
'video',
|
||||||
|
'podcast_episode',
|
||||||
|
'track',
|
||||||
|
'sport_event',
|
||||||
|
'book',
|
||||||
)
|
)
|
||||||
list_filter = ("is_paused", "in_progress", "source", "track__artist")
|
list_filter = ("is_paused", "in_progress", "source", "track__artist")
|
||||||
ordering = ("-timestamp",)
|
ordering = ("-timestamp",)
|
||||||
|
|
||||||
def media_name(self, obj):
|
def media_name(self, obj):
|
||||||
if obj.video:
|
return obj.media_obj
|
||||||
return obj.video
|
|
||||||
if obj.track:
|
|
||||||
return obj.track
|
|
||||||
if obj.podcast_episode:
|
|
||||||
return obj.podcast_episode
|
|
||||||
|
|
||||||
def media_type(self, obj):
|
def media_type(self, obj):
|
||||||
|
return obj.media_obj.__class__.__name__
|
||||||
if obj.video:
|
if obj.video:
|
||||||
return "Video"
|
return "Video"
|
||||||
if obj.track:
|
if obj.track:
|
||||||
return "Track"
|
return "Track"
|
||||||
if obj.podcast_episode:
|
if obj.podcast_episode:
|
||||||
return "Podcast"
|
return "Podcast"
|
||||||
|
if obj.sport_event:
|
||||||
|
return "Sport Event"
|
||||||
|
|
||||||
|
def playback_percent(self, obj):
|
||||||
|
return obj.percent_played
|
||||||
|
|||||||
0
vrobbler/apps/scrobbles/api/__init__.py
Normal file
0
vrobbler/apps/scrobbles/api/__init__.py
Normal file
33
vrobbler/apps/scrobbles/api/serializers.py
Normal file
33
vrobbler/apps/scrobbles/api/serializers.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
from scrobbles.models import (
|
||||||
|
AudioScrobblerTSVImport,
|
||||||
|
KoReaderImport,
|
||||||
|
LastFmImport,
|
||||||
|
Scrobble,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ScrobbleSerializer(serializers.HyperlinkedModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Scrobble
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
|
class KoReaderImportSerializer(serializers.HyperlinkedModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = KoReaderImport
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
|
class AudioScrobblerTSVImportSerializer(
|
||||||
|
serializers.HyperlinkedModelSerializer
|
||||||
|
):
|
||||||
|
class Meta:
|
||||||
|
model = AudioScrobblerTSVImport
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
|
class LastFmImportSerializer(serializers.HyperlinkedModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = LastFmImport
|
||||||
|
fields = "__all__"
|
||||||
49
vrobbler/apps/scrobbles/api/views.py
Normal file
49
vrobbler/apps/scrobbles/api/views.py
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
from rest_framework import permissions, viewsets
|
||||||
|
from scrobbles.api.serializers import (
|
||||||
|
AudioScrobblerTSVImportSerializer,
|
||||||
|
KoReaderImportSerializer,
|
||||||
|
LastFmImportSerializer,
|
||||||
|
ScrobbleSerializer,
|
||||||
|
)
|
||||||
|
from scrobbles.models import (
|
||||||
|
AudioScrobblerTSVImport,
|
||||||
|
KoReaderImport,
|
||||||
|
Scrobble,
|
||||||
|
LastFmImport,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ScrobbleViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Scrobble.objects.all().order_by('-timestamp')
|
||||||
|
serializer_class = ScrobbleSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return super().get_queryset().filter(user=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
|
class KoReaderImportViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = KoReaderImport.objects.all().order_by('-created')
|
||||||
|
serializer_class = KoReaderImportSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return super().get_queryset().filter(user=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
|
class AudioScrobblerTSVImportViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = AudioScrobblerTSVImport.objects.all().order_by('-created')
|
||||||
|
serializer_class = AudioScrobblerTSVImportSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return super().get_queryset().filter(user=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
|
class LastFmImportViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = LastFmImport.objects.all().order_by('-created')
|
||||||
|
serializer_class = LastFmImportSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return super().get_queryset().filter(user=self.request.user)
|
||||||
@ -1,3 +1,2 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
JELLYFIN_VIDEO_ITEM_TYPES = ["Episode", "Movie"]
|
JELLYFIN_VIDEO_ITEM_TYPES = ["Episode", "Movie"]
|
||||||
JELLYFIN_AUDIO_ITEM_TYPES = ["Audio"]
|
JELLYFIN_AUDIO_ITEM_TYPES = ["Audio"]
|
||||||
|
|||||||
17
vrobbler/apps/scrobbles/context_processors.py
Normal file
17
vrobbler/apps/scrobbles/context_processors.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import pytz
|
||||||
|
from django.utils import timezone
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
|
|
||||||
|
def now_playing(request):
|
||||||
|
user = request.user
|
||||||
|
now = timezone.now()
|
||||||
|
if not user.is_authenticated:
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
'now_playing_list': Scrobble.objects.filter(
|
||||||
|
in_progress=True,
|
||||||
|
is_paused=False,
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
}
|
||||||
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
|
||||||
25
vrobbler/apps/scrobbles/forms.py
Normal file
25
vrobbler/apps/scrobbles/forms.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
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):
|
||||||
|
item_id = forms.CharField(
|
||||||
|
label="",
|
||||||
|
widget=forms.TextInput(
|
||||||
|
attrs={
|
||||||
|
'class': "form-control form-control-dark w-100",
|
||||||
|
'placeholder': "Scrobble something (IMDB ID, String, TVDB ID ...)",
|
||||||
|
'aria-label': "Scrobble something",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
61
vrobbler/apps/scrobbles/imdb.py
Normal file
61
vrobbler/apps/scrobbles/imdb.py
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import logging
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from imdb import Cinemagoer
|
||||||
|
|
||||||
|
imdb_client = Cinemagoer()
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_video_from_imdb(imdb_id: str) -> dict:
|
||||||
|
|
||||||
|
if 'tt' not in imdb_id:
|
||||||
|
logger.warning(f"IMDB ID should begin with 'tt' {imdb_id}")
|
||||||
|
return
|
||||||
|
|
||||||
|
lookup_id = imdb_id.strip('tt')
|
||||||
|
media = imdb_client.get_movie(lookup_id)
|
||||||
|
|
||||||
|
run_time_seconds = 60 * 60
|
||||||
|
runtimes = media.get("runtimes")
|
||||||
|
if runtimes:
|
||||||
|
run_time_seconds = int(runtimes[0]) * 60
|
||||||
|
|
||||||
|
# Ticks otherwise known as miliseconds
|
||||||
|
run_time_ticks = run_time_seconds * 1000 * 1000
|
||||||
|
|
||||||
|
item_type = "Movie"
|
||||||
|
if media.get('series title'):
|
||||||
|
item_type = "Episode"
|
||||||
|
|
||||||
|
try:
|
||||||
|
plot = media.get('plot')[0]
|
||||||
|
except TypeError:
|
||||||
|
plot = ""
|
||||||
|
except IndexError:
|
||||||
|
plot = ""
|
||||||
|
|
||||||
|
logger.debug(f"Received data from IMDB: {media.__dict__}")
|
||||||
|
# Build a rough approximation of a Jellyfin data response
|
||||||
|
data_dict = {
|
||||||
|
"ItemType": item_type,
|
||||||
|
"Name": media.get('title'),
|
||||||
|
"Overview": plot,
|
||||||
|
"Tagline": media.get('tagline'),
|
||||||
|
"Year": media.get('year'),
|
||||||
|
"Provider_imdb": imdb_id,
|
||||||
|
"RunTime": run_time_seconds,
|
||||||
|
"RunTimeTicks": run_time_ticks,
|
||||||
|
"SeriesName": media.get('series title'),
|
||||||
|
"EpisodeNumber": media.get('episode'),
|
||||||
|
"SeasonNumber": media.get('season'),
|
||||||
|
"PlaybackPositionTicks": 1,
|
||||||
|
"PlaybackPosition": 1,
|
||||||
|
"UtcTimestamp": timezone.now().strftime('%Y-%m-%d %H:%M:%S.%f%z'),
|
||||||
|
"IsPaused": False,
|
||||||
|
"PlayedToCompletion": False,
|
||||||
|
}
|
||||||
|
logger.debug(f"Parsed data from IMDB data: {data_dict}")
|
||||||
|
|
||||||
|
return data_dict
|
||||||
124
vrobbler/apps/scrobbles/koreader.py
Normal file
124
vrobbler/apps/scrobbles/koreader.py
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
import sqlite3
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
from books.models import Author, Book
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class KoReaderBookColumn(Enum):
|
||||||
|
ID = 0
|
||||||
|
TITLE = 1
|
||||||
|
AUTHORS = 2
|
||||||
|
NOTES = 3
|
||||||
|
LAST_OPEN = 4
|
||||||
|
HIGHLIGHTS = 5
|
||||||
|
PAGES = 6
|
||||||
|
SERIES = 7
|
||||||
|
LANGUAGE = 8
|
||||||
|
MD5 = 9
|
||||||
|
TOTAL_READ_TIME = 10
|
||||||
|
TOTAL_READ_PAGES = 11
|
||||||
|
|
||||||
|
|
||||||
|
class KoReaderPageStatColumn(Enum):
|
||||||
|
ID_BOOK = 0
|
||||||
|
PAGE = 1
|
||||||
|
START_TIME = 2
|
||||||
|
DURATION = 3
|
||||||
|
TOTAL_PAGES = 4
|
||||||
|
|
||||||
|
|
||||||
|
def process_koreader_sqlite_file(sqlite_file_path, user_id):
|
||||||
|
"""Given a sqlite file from KoReader, open the book table, iterate
|
||||||
|
over rows creating scrobbles from each book found"""
|
||||||
|
# Create a SQL connection to our SQLite database
|
||||||
|
con = sqlite3.connect(sqlite_file_path)
|
||||||
|
cur = con.cursor()
|
||||||
|
|
||||||
|
# Return all results of query
|
||||||
|
book_table = cur.execute("SELECT * FROM book")
|
||||||
|
new_scrobbles = []
|
||||||
|
for book_row in book_table:
|
||||||
|
authors = book_row[KoReaderBookColumn.AUTHORS.value].split('\n')
|
||||||
|
author_list = []
|
||||||
|
for author_str in authors:
|
||||||
|
logger.debug(f"Looking up author {author_str}")
|
||||||
|
|
||||||
|
if author_str == "N/A":
|
||||||
|
continue
|
||||||
|
|
||||||
|
author, created = Author.objects.get_or_create(name=author_str)
|
||||||
|
if created:
|
||||||
|
author.fix_metadata()
|
||||||
|
author_list.append(author)
|
||||||
|
logger.debug(f"Found author {author}, created: {created}")
|
||||||
|
|
||||||
|
book, created = Book.objects.get_or_create(
|
||||||
|
koreader_md5=book_row[KoReaderBookColumn.MD5.value]
|
||||||
|
)
|
||||||
|
|
||||||
|
if created:
|
||||||
|
book.title = book_row[KoReaderBookColumn.TITLE.value]
|
||||||
|
book.pages = book_row[KoReaderBookColumn.PAGES.value]
|
||||||
|
book.koreader_id = int(book_row[KoReaderBookColumn.ID.value])
|
||||||
|
book.koreader_authors = book_row[KoReaderBookColumn.AUTHORS.value]
|
||||||
|
book.run_time_ticks = int(book_row[KoReaderBookColumn.PAGES.value])
|
||||||
|
book.save(
|
||||||
|
update_fields=[
|
||||||
|
"title",
|
||||||
|
"pages",
|
||||||
|
"koreader_id",
|
||||||
|
"koreader_authors",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
book.fix_metadata()
|
||||||
|
if author_list:
|
||||||
|
book.authors.add(*[a.id for a in author_list])
|
||||||
|
|
||||||
|
playback_position = int(
|
||||||
|
book_row[KoReaderBookColumn.TOTAL_READ_TIME.value]
|
||||||
|
)
|
||||||
|
playback_position_ticks = playback_position * 1000
|
||||||
|
pages_read = int(book_row[KoReaderBookColumn.TOTAL_READ_PAGES.value])
|
||||||
|
timestamp = datetime.utcfromtimestamp(
|
||||||
|
book_row[KoReaderBookColumn.LAST_OPEN.value]
|
||||||
|
).replace(tzinfo=pytz.utc)
|
||||||
|
|
||||||
|
new_scrobble = Scrobble(
|
||||||
|
book_id=book.id,
|
||||||
|
user_id=user_id,
|
||||||
|
source="KOReader",
|
||||||
|
timestamp=timestamp,
|
||||||
|
playback_position_ticks=playback_position_ticks,
|
||||||
|
playback_position=playback_position,
|
||||||
|
played_to_completion=True,
|
||||||
|
in_progress=False,
|
||||||
|
book_pages_read=pages_read,
|
||||||
|
)
|
||||||
|
|
||||||
|
existing = Scrobble.objects.filter(
|
||||||
|
timestamp=timestamp, book=book
|
||||||
|
).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)
|
||||||
|
|
||||||
|
# Be sure to close the connection
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
created = Scrobble.objects.bulk_create(new_scrobbles)
|
||||||
|
logger.info(
|
||||||
|
f"Created {len(created)} scrobbles",
|
||||||
|
extra={'created_scrobbles': created},
|
||||||
|
)
|
||||||
|
return created
|
||||||
146
vrobbler/apps/scrobbles/lastfm.py
Normal file
146
vrobbler/apps/scrobbles/lastfm.py
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import pylast
|
||||||
|
import pytz
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils import timezone
|
||||||
|
from music.utils import (
|
||||||
|
get_or_create_album,
|
||||||
|
get_or_create_artist,
|
||||||
|
get_or_create_track,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PYLAST_ERRORS = tuple(
|
||||||
|
getattr(pylast, exc_name)
|
||||||
|
for exc_name in (
|
||||||
|
"ScrobblingError",
|
||||||
|
"NetworkError",
|
||||||
|
"MalformedResponseError",
|
||||||
|
"WSError",
|
||||||
|
)
|
||||||
|
if hasattr(pylast, exc_name)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LastFM:
|
||||||
|
def __init__(self, user):
|
||||||
|
try:
|
||||||
|
self.client = pylast.LastFMNetwork(
|
||||||
|
api_key=getattr(settings, "LASTFM_API_KEY"),
|
||||||
|
api_secret=getattr(settings, "LASTFM_SECRET_KEY"),
|
||||||
|
username=user.profile.lastfm_username,
|
||||||
|
password_hash=pylast.md5(user.profile.lastfm_password),
|
||||||
|
)
|
||||||
|
self.user = self.client.get_user(user.profile.lastfm_username)
|
||||||
|
self.vrobbler_user = user
|
||||||
|
except PYLAST_ERRORS as e:
|
||||||
|
logger.error(f"Error during Last.fm setup: {e}")
|
||||||
|
|
||||||
|
def import_from_lastfm(self, last_processed=None):
|
||||||
|
"""Given a last processed time, import all scrobbles from LastFM since then"""
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
|
new_scrobbles = []
|
||||||
|
source = "Last.fm"
|
||||||
|
source_id = ""
|
||||||
|
lastfm_scrobbles = self.get_last_scrobbles(time_from=last_processed)
|
||||||
|
|
||||||
|
for lfm_scrobble in lastfm_scrobbles:
|
||||||
|
timestamp = lfm_scrobble.pop('timestamp')
|
||||||
|
|
||||||
|
artist = get_or_create_artist(lfm_scrobble.pop('artist'))
|
||||||
|
album = get_or_create_album(lfm_scrobble.pop('album'), artist)
|
||||||
|
|
||||||
|
lfm_scrobble['artist'] = artist
|
||||||
|
lfm_scrobble['album'] = album
|
||||||
|
track = get_or_create_track(**lfm_scrobble)
|
||||||
|
|
||||||
|
new_scrobble = Scrobble(
|
||||||
|
user=self.vrobbler_user,
|
||||||
|
timestamp=timestamp,
|
||||||
|
source=source,
|
||||||
|
source_id=source_id,
|
||||||
|
track=track,
|
||||||
|
played_to_completion=True,
|
||||||
|
in_progress=False,
|
||||||
|
)
|
||||||
|
# Vrobbler scrobbles on finish, LastFM scrobbles on start
|
||||||
|
seconds_eariler = timestamp - timedelta(seconds=20)
|
||||||
|
seconds_later = timestamp + timedelta(seconds=20)
|
||||||
|
existing = Scrobble.objects.filter(
|
||||||
|
created__gte=seconds_eariler,
|
||||||
|
created__lte=seconds_later,
|
||||||
|
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 get_last_scrobbles(self, time_from=None, time_to=None):
|
||||||
|
"""Given a user, Last.fm api key, and secret key, grab a list of scrobbled
|
||||||
|
tracks"""
|
||||||
|
lfm_params = {}
|
||||||
|
scrobbles = []
|
||||||
|
if time_from:
|
||||||
|
lfm_params["time_from"] = int(time_from.timestamp())
|
||||||
|
if time_to:
|
||||||
|
lfm_params["time_to"] = int(time_to.timestamp())
|
||||||
|
|
||||||
|
# if not time_from and not time_to:
|
||||||
|
lfm_params['limit'] = None
|
||||||
|
|
||||||
|
found_scrobbles = self.user.get_recent_tracks(**lfm_params)
|
||||||
|
# TOOD spin this out into a celery task over certain threshold of found scrobbles?
|
||||||
|
|
||||||
|
for scrobble in found_scrobbles:
|
||||||
|
run_time = None
|
||||||
|
run_time_ticks = None
|
||||||
|
mbid = None
|
||||||
|
artist = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
run_time_ticks = scrobble.track.get_duration()
|
||||||
|
run_time = int(run_time_ticks / 1000)
|
||||||
|
mbid = scrobble.track.get_mbid()
|
||||||
|
artist = scrobble.track.get_artist().name
|
||||||
|
except pylast.MalformedResponseError as e:
|
||||||
|
logger.warn(e)
|
||||||
|
except pylast.WSError as e:
|
||||||
|
logger.warn(
|
||||||
|
"LastFM barfed trying to get the track for {scrobble.track}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not artist:
|
||||||
|
logger.warn(f"Silly LastFM, no artist found for {scrobble}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
timestamp = datetime.utcfromtimestamp(
|
||||||
|
int(scrobble.timestamp)
|
||||||
|
).replace(tzinfo=pytz.utc)
|
||||||
|
|
||||||
|
logger.info(f"{artist},{scrobble.track.title},{timestamp}")
|
||||||
|
scrobbles.append(
|
||||||
|
{
|
||||||
|
"artist": artist,
|
||||||
|
"album": scrobble.album,
|
||||||
|
"title": scrobble.track.title,
|
||||||
|
"mbid": mbid,
|
||||||
|
"run_time": run_time,
|
||||||
|
"run_time_ticks": run_time_ticks,
|
||||||
|
"timestamp": timestamp,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return scrobbles
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-01-14 21:23
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('sports', '0002_rename_start_utc_sportevent_start'),
|
||||||
|
('scrobbles', '0007_scrobble_podcast_episode'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='scrobble',
|
||||||
|
name='sport_event',
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||||
|
to='sports.sportevent',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
35
vrobbler/apps/scrobbles/migrations/0009_scrobble_uuid.py
Normal file
35
vrobbler/apps/scrobbles/migrations/0009_scrobble_uuid.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-01-20 18:40
|
||||||
|
|
||||||
|
from uuid import uuid4
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def generate_uuids(apps, schema_editor):
|
||||||
|
"""Force uuid generation for old scrobbles"""
|
||||||
|
Scrobble = apps.get_model('scrobbles', 'Scrobble')
|
||||||
|
for scrobble in Scrobble.objects.all():
|
||||||
|
if not scrobble.uuid:
|
||||||
|
scrobble.uuid = uuid4()
|
||||||
|
scrobble.save(update_fields=['uuid'])
|
||||||
|
|
||||||
|
|
||||||
|
def reverse_generate_uuids(apps, schema_editor):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('scrobbles', '0008_scrobble_sport_event'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='scrobble',
|
||||||
|
name='uuid',
|
||||||
|
field=models.UUIDField(blank=True, editable=False, null=True),
|
||||||
|
),
|
||||||
|
migrations.RunPython(
|
||||||
|
code=generate_uuids, reverse_code=reverse_generate_uuids
|
||||||
|
),
|
||||||
|
]
|
||||||
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
61
vrobbler/apps/scrobbles/migrations/0018_lastfmimport.py
Normal file
61
vrobbler/apps/scrobbles/migrations/0018_lastfmimport.py
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-02-13 06:43
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django_extensions.db.fields
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
('scrobbles', '0017_audioscrobblertsvimport_user'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='LastFmImport',
|
||||||
|
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'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
('uuid', models.UUIDField(default=uuid.uuid4, editable=False)),
|
||||||
|
('processed_on', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('process_log', models.TextField(blank=True, null=True)),
|
||||||
|
('process_count', models.IntegerField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
'user',
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'get_latest_by': 'modified',
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-02-16 17:13
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('scrobbles', '0018_lastfmimport'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='lastfmimport',
|
||||||
|
old_name='processed_on',
|
||||||
|
new_name='processed_finished',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='lastfmimport',
|
||||||
|
name='processing_started',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,97 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-02-19 03:53
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django_extensions.db.fields
|
||||||
|
import scrobbles.models
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
(
|
||||||
|
'scrobbles',
|
||||||
|
'0019_rename_processed_on_lastfmimport_processed_finished_and_more',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='audioscrobblertsvimport',
|
||||||
|
options={},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='lastfmimport',
|
||||||
|
options={},
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='audioscrobblertsvimport',
|
||||||
|
old_name='processed_on',
|
||||||
|
new_name='processed_finished',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='audioscrobblertsvimport',
|
||||||
|
name='processing_started',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='KoReaderImport',
|
||||||
|
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'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
('uuid', models.UUIDField(default=uuid.uuid4, editable=False)),
|
||||||
|
(
|
||||||
|
'processing_started',
|
||||||
|
models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'processed_finished',
|
||||||
|
models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
('process_log', models.TextField(blank=True, null=True)),
|
||||||
|
('process_count', models.IntegerField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
'sqlite_file',
|
||||||
|
models.FileField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
upload_to=scrobbles.models.KoReaderImport.get_path,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'user',
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
25
vrobbler/apps/scrobbles/migrations/0021_scrobble_book.py
Normal file
25
vrobbler/apps/scrobbles/migrations/0021_scrobble_book.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-02-19 20:20
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('books', '0001_initial'),
|
||||||
|
('scrobbles', '0020_alter_audioscrobblertsvimport_options_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='scrobble',
|
||||||
|
name='book',
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||||
|
to='books.book',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-02-20 00:38
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('scrobbles', '0021_scrobble_book'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='scrobble',
|
||||||
|
name='book_pages_read',
|
||||||
|
field=models.IntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-02-25 00:19
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('scrobbles', '0022_scrobble_book_pages_read'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='audioscrobblertsvimport',
|
||||||
|
options={'verbose_name': 'AudioScrobbler TSV Import'},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='koreaderimport',
|
||||||
|
options={'verbose_name': 'KOReader Import'},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='lastfmimport',
|
||||||
|
options={'verbose_name': 'Last.FM Import'},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='chartrecord',
|
||||||
|
name='count',
|
||||||
|
field=models.IntegerField(default=0),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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,85 +1,446 @@
|
|||||||
|
import calendar
|
||||||
import logging
|
import logging
|
||||||
from datetime import timedelta
|
from uuid import uuid4
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from django.conf import settings
|
from books.models import Book
|
||||||
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.urls import reverse
|
||||||
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.lastfm import LastFM
|
||||||
from scrobbles.utils import check_scrobble_for_finish
|
from scrobbles.utils import check_scrobble_for_finish
|
||||||
from videos.models import Video
|
from sports.models import SportEvent
|
||||||
|
from videos.models import Series, Video
|
||||||
|
|
||||||
|
from vrobbler.apps.scrobbles.stats import build_charts
|
||||||
|
|
||||||
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}
|
||||||
VIDEO_BACKOFF = getattr(settings, 'VIDEO_BACKOFF_MINUTES')
|
|
||||||
TRACK_BACKOFF = getattr(settings, 'MUSIC_BACKOFF_SECONDS')
|
|
||||||
VIDEO_WAIT_PERIOD = getattr(settings, 'VIDEO_WAIT_PERIOD_DAYS')
|
class BaseFileImportMixin(TimeStampedModel):
|
||||||
TRACK_WAIT_PERIOD = getattr(settings, 'MUSIC_WAIT_PERIOD_MINUTES')
|
user = models.ForeignKey(User, on_delete=models.DO_NOTHING, **BNULL)
|
||||||
|
uuid = models.UUIDField(editable=False, default=uuid4)
|
||||||
|
processing_started = models.DateTimeField(**BNULL)
|
||||||
|
processed_finished = models.DateTimeField(**BNULL)
|
||||||
|
process_log = models.TextField(**BNULL)
|
||||||
|
process_count = models.IntegerField(**BNULL)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Scrobble import {self.id}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def human_start(self):
|
||||||
|
start = "Unknown"
|
||||||
|
if self.processing_started:
|
||||||
|
start = self.processing_started.strftime('%B %d, %Y at %H:%M')
|
||||||
|
return start
|
||||||
|
|
||||||
|
@property
|
||||||
|
def import_type(self) -> str:
|
||||||
|
class_name = self.__class__.__name__
|
||||||
|
if class_name == 'AudioscrobblerTSVImport':
|
||||||
|
return "Audioscrobbler"
|
||||||
|
if class_name == 'KoReaderImport':
|
||||||
|
return "KoReader"
|
||||||
|
if self.__class__.__name__ == 'LastFMImport':
|
||||||
|
return "LastFM"
|
||||||
|
return "Generic"
|
||||||
|
|
||||||
|
def process(self, force=False):
|
||||||
|
logger.warning("Process not implemented")
|
||||||
|
|
||||||
|
def undo(self, dryrun=False):
|
||||||
|
"""Accepts the log from a scrobble import and removes the scrobbles"""
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
|
if not self.process_log:
|
||||||
|
logger.warning("No lines in process log found to undo")
|
||||||
|
return
|
||||||
|
|
||||||
|
for line in self.process_log.split('\n'):
|
||||||
|
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()
|
||||||
|
self.processed_finished = None
|
||||||
|
self.processing_started = None
|
||||||
|
self.process_count = None
|
||||||
|
self.process_log = ""
|
||||||
|
self.save(
|
||||||
|
update_fields=[
|
||||||
|
"processed_finished",
|
||||||
|
"processing_started",
|
||||||
|
"process_log",
|
||||||
|
"process_count",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def mark_started(self):
|
||||||
|
self.processing_started = timezone.now()
|
||||||
|
self.save(update_fields=["processing_started"])
|
||||||
|
|
||||||
|
def mark_finished(self):
|
||||||
|
self.processed_finished = timezone.now()
|
||||||
|
self.save(update_fields=['processed_finished'])
|
||||||
|
|
||||||
|
def record_log(self, scrobbles):
|
||||||
|
self.process_log = ""
|
||||||
|
if not scrobbles:
|
||||||
|
self.process_count = 0
|
||||||
|
self.save(update_fields=["process_log", "process_count"])
|
||||||
|
return
|
||||||
|
|
||||||
|
for count, scrobble in enumerate(scrobbles):
|
||||||
|
scrobble_str = f"{scrobble.id}\t{scrobble.timestamp}\t{scrobble.media_obj.title}"
|
||||||
|
log_line = f"{scrobble_str}"
|
||||||
|
if count > 0:
|
||||||
|
log_line = "\n" + log_line
|
||||||
|
self.process_log += log_line
|
||||||
|
self.process_count = len(scrobbles)
|
||||||
|
self.save(update_fields=["process_log", "process_count"])
|
||||||
|
|
||||||
|
|
||||||
|
class KoReaderImport(BaseFileImportMixin):
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "KOReader Import"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"KoReader import on {self.human_start}"
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse(
|
||||||
|
'scrobbles:koreader-import-detail', kwargs={'slug': self.uuid}
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_path(instance, filename):
|
||||||
|
extension = filename.split('.')[-1]
|
||||||
|
uuid = instance.uuid
|
||||||
|
return f'koreader-uploads/{uuid}.{extension}'
|
||||||
|
|
||||||
|
sqlite_file = models.FileField(upload_to=get_path, **BNULL)
|
||||||
|
|
||||||
|
def process(self, force=False):
|
||||||
|
from scrobbles.koreader import process_koreader_sqlite_file
|
||||||
|
|
||||||
|
if self.processed_finished and not force:
|
||||||
|
logger.info(
|
||||||
|
f"{self} already processed on {self.processed_finished}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.mark_started()
|
||||||
|
scrobbles = process_koreader_sqlite_file(
|
||||||
|
self.sqlite_file.path, self.user.id
|
||||||
|
)
|
||||||
|
self.record_log(scrobbles)
|
||||||
|
self.mark_finished()
|
||||||
|
|
||||||
|
|
||||||
|
class AudioScrobblerTSVImport(BaseFileImportMixin):
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "AudioScrobbler TSV Import"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Audioscrobbler import on {self.human_start}"
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse(
|
||||||
|
'scrobbles:tsv-import-detail', kwargs={'slug': self.uuid}
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_path(instance, filename):
|
||||||
|
extension = filename.split('.')[-1]
|
||||||
|
uuid = instance.uuid
|
||||||
|
return f'audioscrobbler-uploads/{uuid}.{extension}'
|
||||||
|
|
||||||
|
tsv_file = models.FileField(upload_to=get_path, **BNULL)
|
||||||
|
|
||||||
|
def process(self, force=False):
|
||||||
|
from scrobbles.tsv import process_audioscrobbler_tsv_file
|
||||||
|
|
||||||
|
if self.processed_finished and not force:
|
||||||
|
logger.info(
|
||||||
|
f"{self} already processed on {self.processed_finished}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.mark_started()
|
||||||
|
|
||||||
|
tz = None
|
||||||
|
if self.user:
|
||||||
|
tz = self.user.profile.tzinfo
|
||||||
|
scrobbles = process_audioscrobbler_tsv_file(
|
||||||
|
self.tsv_file.path, self.user.id, user_tz=tz
|
||||||
|
)
|
||||||
|
self.record_log(scrobbles)
|
||||||
|
self.mark_finished()
|
||||||
|
|
||||||
|
|
||||||
|
class LastFmImport(BaseFileImportMixin):
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Last.FM Import"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"LastFM import on {self.human_start}"
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse(
|
||||||
|
'scrobbles:lastfm-import-detail', kwargs={'slug': self.uuid}
|
||||||
|
)
|
||||||
|
|
||||||
|
def process(self, import_all=False):
|
||||||
|
"""Import scrobbles found on LastFM"""
|
||||||
|
if self.processed_finished:
|
||||||
|
logger.info(
|
||||||
|
f"{self} already processed on {self.processed_finished}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
last_import = None
|
||||||
|
if not import_all:
|
||||||
|
try:
|
||||||
|
last_import = LastFmImport.objects.exclude(id=self.id).last()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not import_all and not last_import:
|
||||||
|
logger.warn(
|
||||||
|
"No previous import, to import all Last.fm scrobbles, pass import_all=True"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
lastfm = LastFM(self.user)
|
||||||
|
last_processed = None
|
||||||
|
if last_import:
|
||||||
|
last_processed = last_import.processed_finished
|
||||||
|
|
||||||
|
self.mark_started()
|
||||||
|
|
||||||
|
scrobbles = lastfm.import_from_lastfm(last_processed)
|
||||||
|
|
||||||
|
self.record_log(scrobbles)
|
||||||
|
self.mark_finished()
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
count = models.IntegerField(default=0)
|
||||||
|
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
|
||||||
|
if self.artist:
|
||||||
|
media_obj = self.artist
|
||||||
|
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):
|
||||||
|
title = f"#{self.rank} in {self.period}"
|
||||||
|
if self.day or self.week:
|
||||||
|
title = f"#{self.rank} on {self.period}"
|
||||||
|
return title
|
||||||
|
|
||||||
|
def link(self):
|
||||||
|
get_params = f"?date={self.year}"
|
||||||
|
if self.week:
|
||||||
|
get_params = get_params = get_params + f"-W{self.week}"
|
||||||
|
if self.month:
|
||||||
|
get_params = get_params = get_params + f"-{self.month}"
|
||||||
|
if self.day:
|
||||||
|
get_params = get_params = get_params + f"-{self.day}"
|
||||||
|
if self.artist:
|
||||||
|
get_params = get_params + "&media=Artist"
|
||||||
|
return reverse('scrobbles:charts-home') + get_params
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def build(cls, user, **kwargs):
|
||||||
|
build_charts(user=user, **kwargs)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def for_year(cls, user, year):
|
||||||
|
return cls.objects.filter(year=year, user=user)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def for_month(cls, user, year, month):
|
||||||
|
return cls.objects.filter(year=year, month=month, user=user)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def for_day(cls, user, year, day, month):
|
||||||
|
return cls.objects.filter(year=year, month=month, day=day, user=user)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def for_week(cls, user, year, week):
|
||||||
|
return cls.objects.filter(year=year, week=week, user=user)
|
||||||
|
|
||||||
|
|
||||||
class Scrobble(TimeStampedModel):
|
class Scrobble(TimeStampedModel):
|
||||||
|
"""A scrobble tracks played media items by a user."""
|
||||||
|
|
||||||
|
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)
|
||||||
podcast_episode = models.ForeignKey(
|
podcast_episode = models.ForeignKey(
|
||||||
Episode, on_delete=models.DO_NOTHING, **BNULL
|
Episode, on_delete=models.DO_NOTHING, **BNULL
|
||||||
)
|
)
|
||||||
|
sport_event = models.ForeignKey(
|
||||||
|
SportEvent, on_delete=models.DO_NOTHING, **BNULL
|
||||||
|
)
|
||||||
|
book = models.ForeignKey(Book, on_delete=models.DO_NOTHING, **BNULL)
|
||||||
user = models.ForeignKey(
|
user = models.ForeignKey(
|
||||||
User, blank=True, null=True, on_delete=models.DO_NOTHING
|
User, blank=True, null=True, on_delete=models.DO_NOTHING
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Time keeping
|
||||||
timestamp = models.DateTimeField(**BNULL)
|
timestamp = models.DateTimeField(**BNULL)
|
||||||
playback_position_ticks = models.PositiveBigIntegerField(**BNULL)
|
playback_position_ticks = models.PositiveBigIntegerField(**BNULL)
|
||||||
playback_position = models.CharField(max_length=8, **BNULL)
|
playback_position = models.CharField(max_length=8, **BNULL)
|
||||||
|
|
||||||
|
# Status indicators
|
||||||
is_paused = models.BooleanField(default=False)
|
is_paused = models.BooleanField(default=False)
|
||||||
played_to_completion = models.BooleanField(default=False)
|
played_to_completion = models.BooleanField(default=False)
|
||||||
|
in_progress = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
source = models.CharField(max_length=255, **BNULL)
|
source = models.CharField(max_length=255, **BNULL)
|
||||||
source_id = models.TextField(**BNULL)
|
source_id = models.TextField(**BNULL)
|
||||||
in_progress = models.BooleanField(default=True)
|
|
||||||
scrobble_log = models.TextField(**BNULL)
|
scrobble_log = models.TextField(**BNULL)
|
||||||
|
|
||||||
|
# Fields for keeping track of reads between scrobbles
|
||||||
|
book_pages_read = models.IntegerField(**BNULL)
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if not self.uuid:
|
||||||
|
self.uuid = uuid4()
|
||||||
|
|
||||||
|
return super(Scrobble, self).save(*args, **kwargs)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status(self) -> str:
|
||||||
|
if self.is_paused:
|
||||||
|
return 'paused'
|
||||||
|
if self.played_to_completion:
|
||||||
|
return 'finished'
|
||||||
|
if self.in_progress:
|
||||||
|
return 'in-progress'
|
||||||
|
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 (
|
if not self.media_obj:
|
||||||
self.playback_position_ticks
|
return 0
|
||||||
and self.media_obj.run_time_ticks
|
|
||||||
and source != 'Mopidy'
|
|
||||||
):
|
|
||||||
return int(
|
|
||||||
(self.playback_position_ticks / self.media_obj.run_time_ticks)
|
|
||||||
* 100
|
|
||||||
)
|
|
||||||
# If we don't have media_obj.run_time_ticks, let's guess from created time
|
|
||||||
now = timezone.now()
|
|
||||||
playback_duration = (now - self.created).seconds
|
|
||||||
if playback_duration and self.media_obj.run_time:
|
|
||||||
return int(
|
|
||||||
(playback_duration / int(self.media_obj.run_time)) * 100
|
|
||||||
)
|
|
||||||
|
|
||||||
return 0
|
if self.media_obj and not self.media_obj.run_time_ticks:
|
||||||
|
return 100
|
||||||
|
|
||||||
def is_stale(self, backoff, wait_period) -> bool:
|
if not self.playback_position_ticks and self.played_to_completion:
|
||||||
scrobble_is_stale = self.in_progress and self.modified > wait_period
|
return 100
|
||||||
|
|
||||||
# Check if found in progress scrobble is more than a day old
|
playback_ticks = self.playback_position_ticks
|
||||||
if scrobble_is_stale:
|
if not playback_ticks:
|
||||||
logger.info(
|
playback_ticks = (timezone.now() - self.timestamp).seconds * 1000
|
||||||
'Found a in-progress scrobble for this item more than a day old, creating a new scrobble'
|
|
||||||
)
|
|
||||||
delete_stale_scrobbles = getattr(
|
|
||||||
settings, "DELETE_STALE_SCROBBLES", True
|
|
||||||
)
|
|
||||||
|
|
||||||
if delete_stale_scrobbles:
|
percent = int((playback_ticks / self.media_obj.run_time_ticks) * 100)
|
||||||
logger.info(
|
if percent > 100:
|
||||||
'Deleting {scrobble} that has been in-progress too long'
|
percent = 100
|
||||||
)
|
return percent
|
||||||
self.delete()
|
|
||||||
|
|
||||||
return scrobble_is_stale
|
@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):
|
||||||
@ -90,165 +451,131 @@ class Scrobble(TimeStampedModel):
|
|||||||
media_obj = self.track
|
media_obj = self.track
|
||||||
if self.podcast_episode:
|
if self.podcast_episode:
|
||||||
media_obj = self.podcast_episode
|
media_obj = self.podcast_episode
|
||||||
|
if self.sport_event:
|
||||||
|
media_obj = self.sport_event
|
||||||
|
if self.book:
|
||||||
|
media_obj = self.book
|
||||||
return media_obj
|
return media_obj
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"Scrobble of {self.media_obj} {self.timestamp.year}-{self.timestamp.month}"
|
timestamp = self.timestamp.strftime('%Y-%m-%d')
|
||||||
|
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, jellyfin_data: dict
|
cls, media, user_id: int, scrobble_data: dict
|
||||||
) -> "Scrobble":
|
) -> "Scrobble":
|
||||||
jellyfin_data['video_id'] = video.id
|
|
||||||
logger.debug(
|
if media.__class__.__name__ == 'Track':
|
||||||
f"Creating or updating scrobble for video {video} with data {jellyfin_data}"
|
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
|
||||||
|
if media.__class__.__name__ == 'Book':
|
||||||
|
media_query = models.Q(book=media)
|
||||||
|
scrobble_data['book_id'] = media.id
|
||||||
|
|
||||||
scrobble = (
|
scrobble = (
|
||||||
cls.objects.filter(video=video, user_id=user_id)
|
cls.objects.filter(
|
||||||
|
media_query,
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
.order_by('-modified')
|
.order_by('-modified')
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
if scrobble and scrobble.can_be_updated:
|
||||||
|
logger.info(
|
||||||
|
f"Updating {scrobble.id}",
|
||||||
|
{"scrobble_data": scrobble_data, "media": media},
|
||||||
|
)
|
||||||
|
return scrobble.update(scrobble_data)
|
||||||
|
|
||||||
# Backoff is how long until we consider this a new scrobble
|
source = scrobble_data['source']
|
||||||
backoff = timezone.now() + timedelta(minutes=VIDEO_BACKOFF)
|
logger.info(
|
||||||
wait_period = timezone.now() + timedelta(days=VIDEO_WAIT_PERIOD)
|
f"Creating for {media.id} - {source}",
|
||||||
|
{"scrobble_data": scrobble_data, "media": media},
|
||||||
return cls.update_or_create(
|
|
||||||
scrobble, backoff, wait_period, jellyfin_data
|
|
||||||
)
|
)
|
||||||
|
# If creating a new scrobble, we don't need status
|
||||||
|
scrobble_data.pop('mopidy_status', None)
|
||||||
|
scrobble_data.pop('jellyfin_status', None)
|
||||||
|
return cls.create(scrobble_data)
|
||||||
|
|
||||||
@classmethod
|
def update(self, scrobble_data: dict) -> "Scrobble":
|
||||||
def create_or_update_for_track(
|
|
||||||
cls, track: "Track", user_id: int, scrobble_data: dict
|
|
||||||
) -> "Scrobble":
|
|
||||||
scrobble_data['track_id'] = track.id
|
|
||||||
scrobble = (
|
|
||||||
cls.objects.filter(track=track, user_id=user_id)
|
|
||||||
.order_by('-modified')
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
logger.debug(
|
|
||||||
f"Found existing scrobble for track {track}, updating",
|
|
||||||
{"scrobble_data": scrobble_data},
|
|
||||||
)
|
|
||||||
|
|
||||||
backoff = timezone.now() + timedelta(seconds=TRACK_BACKOFF)
|
|
||||||
wait_period = timezone.now() + timedelta(minutes=TRACK_WAIT_PERIOD)
|
|
||||||
|
|
||||||
return cls.update_or_create(
|
|
||||||
scrobble, backoff, wait_period, scrobble_data
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def create_or_update_for_podcast_episode(
|
|
||||||
cls, episode: "Episode", user_id: int, scrobble_data: dict
|
|
||||||
) -> "Scrobble":
|
|
||||||
scrobble_data['podcast_episode_id'] = episode.id
|
|
||||||
scrobble = (
|
|
||||||
cls.objects.filter(podcast_episode=episode, user_id=user_id)
|
|
||||||
.order_by('-modified')
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
logger.debug(
|
|
||||||
f"Found existing scrobble for podcast {episode}, updating",
|
|
||||||
{"scrobble_data": scrobble_data},
|
|
||||||
)
|
|
||||||
|
|
||||||
backoff = timezone.now() + timedelta(seconds=TRACK_BACKOFF)
|
|
||||||
wait_period = timezone.now() + timedelta(minutes=TRACK_WAIT_PERIOD)
|
|
||||||
|
|
||||||
return cls.update_or_create(
|
|
||||||
scrobble, backoff, wait_period, scrobble_data
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def update_or_create(
|
|
||||||
cls,
|
|
||||||
scrobble: Optional["Scrobble"],
|
|
||||||
backoff,
|
|
||||||
wait_period,
|
|
||||||
scrobble_data: dict,
|
|
||||||
) -> Optional["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
|
||||||
mopidy_status = scrobble_data.pop('status', None)
|
scrobble_status = scrobble_data.pop('mopidy_status', None)
|
||||||
scrobble_is_stale = False
|
if not scrobble_status:
|
||||||
|
scrobble_status = scrobble_data.pop('jellyfin_status', None)
|
||||||
|
|
||||||
if scrobble:
|
if self.percent_played < 100:
|
||||||
logger.debug(f"Updating scrobble ticks")
|
# Only worry about ticks if we haven't gotten to the end
|
||||||
scrobble.playback_position_ticks = scrobble_data.get(
|
self.update_ticks(scrobble_data)
|
||||||
"playback_position_ticks"
|
|
||||||
)
|
|
||||||
scrobble.save(update_fields=['playback_position_ticks'])
|
|
||||||
|
|
||||||
if mopidy_status == "stopped":
|
# On stop, stop progress and send it to the check for completion
|
||||||
logger.info(f"Mopidy sent a message to stop {scrobble}")
|
if scrobble_status == "stopped":
|
||||||
if not scrobble:
|
self.stop()
|
||||||
logger.warning(
|
# On pause, set is_paused and stop scrobbling
|
||||||
'Mopidy sent us a stopped message, without ever starting'
|
if scrobble_status == "paused":
|
||||||
)
|
self.pause()
|
||||||
return
|
if scrobble_status == "resumed":
|
||||||
|
self.resume()
|
||||||
|
|
||||||
# Mopidy finished a play, scrobble away
|
for key, value in scrobble_data.items():
|
||||||
scrobble.in_progress = False
|
setattr(self, key, value)
|
||||||
scrobble.played_to_completion = True
|
self.save()
|
||||||
scrobble.save(
|
return self
|
||||||
update_fields=['in_progress', 'played_to_completion']
|
|
||||||
)
|
|
||||||
return scrobble
|
|
||||||
|
|
||||||
if mopidy_status == "paused":
|
|
||||||
logger.info(f"Mopidy sent a message to pause {scrobble}")
|
|
||||||
if not scrobble:
|
|
||||||
logger.info("Message to pause while not started, ignoring")
|
|
||||||
return
|
|
||||||
if scrobble.is_paused:
|
|
||||||
logger.info("Message to pause while paused, ignoring")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Mopidy finished a play, scrobble away
|
|
||||||
scrobble.is_paused = True
|
|
||||||
scrobble.save(update_fields=["is_paused"])
|
|
||||||
scrobble = check_scrobble_for_finish(scrobble)
|
|
||||||
return scrobble
|
|
||||||
|
|
||||||
if mopidy_status == "resumed":
|
|
||||||
logger.info(f"Mopidy sent a message to resume {scrobble}")
|
|
||||||
if not scrobble:
|
|
||||||
logger.info("Message to resume while not started, ignoring")
|
|
||||||
return
|
|
||||||
if not scrobble.is_paused:
|
|
||||||
logger.info("Message to resume while not paused, resuming")
|
|
||||||
# Mopidy finished a play, scrobble away
|
|
||||||
scrobble.is_paused = False
|
|
||||||
scrobble.save(update_fields=["is_paused"])
|
|
||||||
return scrobble
|
|
||||||
|
|
||||||
if scrobble and not mopidy_status:
|
|
||||||
scrobble_is_finished = (
|
|
||||||
not scrobble.in_progress and scrobble.modified < backoff
|
|
||||||
)
|
|
||||||
if scrobble_is_finished:
|
|
||||||
logger.info(
|
|
||||||
'Found a very recent scrobble for this item, holding off scrobbling again'
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
scrobble_is_stale = scrobble.is_stale(backoff, wait_period)
|
|
||||||
|
|
||||||
if (not scrobble or scrobble_is_stale) or mopidy_status:
|
|
||||||
# If we default this to "" we can probably remove this
|
|
||||||
scrobble_data['scrobble_log'] = ""
|
|
||||||
scrobble = cls.objects.create(
|
|
||||||
**scrobble_data,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
for key, value in scrobble_data.items():
|
|
||||||
setattr(scrobble, key, value)
|
|
||||||
scrobble.save()
|
|
||||||
|
|
||||||
# If we hit our completion threshold, save it and get ready
|
|
||||||
# to scrobble again if we re-watch this.
|
|
||||||
scrobble = check_scrobble_for_finish(scrobble)
|
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(
|
||||||
|
cls,
|
||||||
|
scrobble_data: dict,
|
||||||
|
) -> "Scrobble":
|
||||||
|
scrobble_data['scrobble_log'] = ""
|
||||||
|
scrobble = cls.objects.create(
|
||||||
|
**scrobble_data,
|
||||||
|
)
|
||||||
return scrobble
|
return scrobble
|
||||||
|
|
||||||
|
def stop(self, force_finish=False) -> None:
|
||||||
|
if not self.in_progress:
|
||||||
|
return
|
||||||
|
self.in_progress = False
|
||||||
|
self.save(update_fields=['in_progress'])
|
||||||
|
logger.info(f"{self.id} - {self.source}")
|
||||||
|
check_scrobble_for_finish(self, force_finish)
|
||||||
|
|
||||||
|
def pause(self) -> None:
|
||||||
|
if self.is_paused:
|
||||||
|
logger.warning(f"{self.id} - already paused - {self.source}")
|
||||||
|
return
|
||||||
|
self.is_paused = True
|
||||||
|
self.save(update_fields=["is_paused"])
|
||||||
|
logger.info(f"{self.id} - pausing - {self.source}")
|
||||||
|
check_scrobble_for_finish(self)
|
||||||
|
|
||||||
|
def resume(self) -> None:
|
||||||
|
if self.is_paused or not self.in_progress:
|
||||||
|
self.is_paused = False
|
||||||
|
self.in_progress = True
|
||||||
|
logger.info(f"{self.id} - resuming - {self.source}")
|
||||||
|
return self.save(update_fields=["is_paused", "in_progress"])
|
||||||
|
|
||||||
|
def cancel(self) -> None:
|
||||||
|
check_scrobble_for_finish(self, force_finish=True)
|
||||||
|
self.delete()
|
||||||
|
|
||||||
|
def update_ticks(self, data) -> None:
|
||||||
|
self.playback_position_ticks = data.get("playback_position_ticks")
|
||||||
|
self.playback_position = data.get("playback_position")
|
||||||
|
logger.info(
|
||||||
|
f"{self.id} - {self.playback_position_ticks} - {self.source}"
|
||||||
|
)
|
||||||
|
self.save(
|
||||||
|
update_fields=['playback_position_ticks', 'playback_position']
|
||||||
|
)
|
||||||
|
|||||||
109
vrobbler/apps/scrobbles/musicbrainz.py
Normal file
109
vrobbler/apps/scrobbles/musicbrainz.py
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
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 {}
|
||||||
|
year = None
|
||||||
|
if top_result.get("date"):
|
||||||
|
year = parse(top_result["date"]).year
|
||||||
|
|
||||||
|
return {
|
||||||
|
"year": year,
|
||||||
|
"mb_id": top_result["id"],
|
||||||
|
"mb_group_id": top_result["release-group"]["id"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_artist_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
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_track_from_mb(
|
||||||
|
track_name: str, artist_mbid: str, album_mbid: str
|
||||||
|
) -> str:
|
||||||
|
musicbrainzngs.set_useragent('vrobbler', '0.3.0')
|
||||||
|
|
||||||
|
top_result = musicbrainzngs.search_recordings(
|
||||||
|
query=track_name, artist=artist_mbid, release=album_mbid
|
||||||
|
)['recording-list'][0]
|
||||||
|
score = int(top_result.get('ext:score'))
|
||||||
|
if score < 85:
|
||||||
|
logger.debug(
|
||||||
|
"Track lookup score below 85 threshold",
|
||||||
|
extra={"result": top_result},
|
||||||
|
)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
return top_result
|
||||||
@ -1,11 +1,20 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from dateutil.parser import parse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from music.constants import JELLYFIN_POST_KEYS
|
||||||
from music.models import Track
|
from music.models import Track
|
||||||
from podcasts.models import Episode
|
from podcasts.models import Episode
|
||||||
from scrobbles.models import Scrobble
|
from scrobbles.models import Scrobble
|
||||||
from scrobbles.utils import parse_mopidy_uri
|
from scrobbles.utils import convert_to_seconds, parse_mopidy_uri
|
||||||
|
from videos.models import Video
|
||||||
|
from sports.models import SportEvent
|
||||||
|
from vrobbler.apps.music.utils import (
|
||||||
|
get_or_create_album,
|
||||||
|
get_or_create_artist,
|
||||||
|
get_or_create_track,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -41,37 +50,35 @@ def mopidy_scrobble_podcast(
|
|||||||
"timestamp": timezone.now(),
|
"timestamp": timezone.now(),
|
||||||
"playback_position_ticks": data_dict.get("playback_time_ticks"),
|
"playback_position_ticks": data_dict.get("playback_time_ticks"),
|
||||||
"source": "Mopidy",
|
"source": "Mopidy",
|
||||||
"status": data_dict.get("status"),
|
"mopidy_status": data_dict.get("status"),
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
def mopidy_scrobble_track(
|
def mopidy_scrobble_track(
|
||||||
data_dict: dict, user_id: Optional[int]
|
data_dict: dict, user_id: Optional[int]
|
||||||
) -> Optional[Scrobble]:
|
) -> Optional[Scrobble]:
|
||||||
artist_dict = {
|
artist = get_or_create_artist(
|
||||||
"name": data_dict.get("artist", None),
|
data_dict.get("artist"),
|
||||||
"musicbrainz_id": data_dict.get("musicbrainz_artist_id", None),
|
mbid=data_dict.get("musicbrainz_artist_id", None),
|
||||||
}
|
)
|
||||||
|
album = get_or_create_album(
|
||||||
album_dict = {
|
data_dict.get("album"),
|
||||||
"name": data_dict.get("album"),
|
artist=artist,
|
||||||
"musicbrainz_id": data_dict.get("musicbrainz_album_id"),
|
mbid=data_dict.get("musicbrainz_album_id"),
|
||||||
}
|
)
|
||||||
|
track = get_or_create_track(
|
||||||
track_dict = {
|
title=data_dict.get("name"),
|
||||||
"title": data_dict.get("name"),
|
mbid=data_dict.get("musicbrainz_track_id"),
|
||||||
"run_time_ticks": data_dict.get("run_time_ticks"),
|
artist=artist,
|
||||||
"run_time": data_dict.get("run_time"),
|
album=album,
|
||||||
}
|
run_time_ticks=data_dict.get("run_time_ticks"),
|
||||||
|
run_time=data_dict.get("run_time"),
|
||||||
track = Track.find_or_create(artist_dict, album_dict, track_dict)
|
)
|
||||||
|
|
||||||
# Now we run off a scrobble
|
# Now we run off a scrobble
|
||||||
mopidy_data = {
|
mopidy_data = {
|
||||||
@ -79,15 +86,117 @@ def mopidy_scrobble_track(
|
|||||||
"timestamp": timezone.now(),
|
"timestamp": timezone.now(),
|
||||||
"playback_position_ticks": data_dict.get("playback_time_ticks"),
|
"playback_position_ticks": data_dict.get("playback_time_ticks"),
|
||||||
"source": "Mopidy",
|
"source": "Mopidy",
|
||||||
"status": data_dict.get("status"),
|
"mopidy_status": data_dict.get("status"),
|
||||||
}
|
}
|
||||||
|
|
||||||
scrobble = None
|
scrobble = Scrobble.create_or_update(track, user_id, mopidy_data)
|
||||||
if track:
|
|
||||||
# Jellyfin MB ids suck, so always overwrite with Mopidy if they're offering
|
|
||||||
track.musicbrainz_id = data_dict.get("musicbrainz_track_id")
|
|
||||||
track.save()
|
|
||||||
scrobble = Scrobble.create_or_update_for_track(
|
|
||||||
track, user_id, mopidy_data
|
|
||||||
)
|
|
||||||
return scrobble
|
return scrobble
|
||||||
|
|
||||||
|
|
||||||
|
def build_scrobble_dict(data_dict: dict, user_id: int) -> dict:
|
||||||
|
jellyfin_status = "resumed"
|
||||||
|
if data_dict.get("IsPaused"):
|
||||||
|
jellyfin_status = "paused"
|
||||||
|
elif data_dict.get("NotificationType") == 'PlaybackStop':
|
||||||
|
jellyfin_status = "stopped"
|
||||||
|
|
||||||
|
playback_ticks = data_dict.get("PlaybackPositionTicks", "")
|
||||||
|
if playback_ticks:
|
||||||
|
playback_ticks = playback_ticks // 10000
|
||||||
|
|
||||||
|
return {
|
||||||
|
"user_id": user_id,
|
||||||
|
"timestamp": parse(data_dict.get("UtcTimestamp")),
|
||||||
|
"playback_position_ticks": playback_ticks,
|
||||||
|
"playback_position": data_dict.get("PlaybackPosition", ""),
|
||||||
|
"source": data_dict.get("ClientName", "Vrobbler"),
|
||||||
|
"source_id": data_dict.get('MediaSourceId'),
|
||||||
|
"jellyfin_status": jellyfin_status,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def jellyfin_scrobble_track(
|
||||||
|
data_dict: dict, user_id: Optional[int]
|
||||||
|
) -> Optional[Scrobble]:
|
||||||
|
|
||||||
|
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 null_position_on_progress:
|
||||||
|
logger.error("No playback position tick from Jellyfin, aborting")
|
||||||
|
return
|
||||||
|
|
||||||
|
artist = get_or_create_artist(
|
||||||
|
data_dict.get(JELLYFIN_POST_KEYS["ARTIST_NAME"]),
|
||||||
|
mbid=data_dict.get(JELLYFIN_POST_KEYS["ARTIST_MB_ID"]),
|
||||||
|
)
|
||||||
|
album = get_or_create_album(
|
||||||
|
data_dict.get(JELLYFIN_POST_KEYS["ALBUM_NAME"]),
|
||||||
|
artist=artist,
|
||||||
|
mbid=data_dict.get(JELLYFIN_POST_KEYS['ALBUM_MB_ID']),
|
||||||
|
)
|
||||||
|
|
||||||
|
run_time_ticks = (
|
||||||
|
data_dict.get(JELLYFIN_POST_KEYS["RUN_TIME_TICKS"]) // 10000
|
||||||
|
)
|
||||||
|
run_time = convert_to_seconds(
|
||||||
|
data_dict.get(JELLYFIN_POST_KEYS["RUN_TIME"])
|
||||||
|
)
|
||||||
|
track = get_or_create_track(
|
||||||
|
title=data_dict.get("Name"),
|
||||||
|
artist=artist,
|
||||||
|
album=album,
|
||||||
|
run_time_ticks=run_time_ticks,
|
||||||
|
run_time=run_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
scrobble_dict = build_scrobble_dict(data_dict, user_id)
|
||||||
|
|
||||||
|
# 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]):
|
||||||
|
if not data_dict.get("Provider_imdb", None):
|
||||||
|
logger.error(
|
||||||
|
"No IMDB ID received. This is likely because all metadata is bad, not scrobbling"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
video = Video.find_or_create(data_dict)
|
||||||
|
|
||||||
|
scrobble_dict = build_scrobble_dict(data_dict, user_id)
|
||||||
|
|
||||||
|
return Scrobble.create_or_update(video, user_id, scrobble_dict)
|
||||||
|
|
||||||
|
|
||||||
|
def manual_scrobble_video(data_dict: dict, user_id: Optional[int]):
|
||||||
|
if not data_dict.get("Provider_imdb", None):
|
||||||
|
logger.error(
|
||||||
|
"No IMDB ID received. This is likely because all metadata is bad, not scrobbling"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
video = Video.find_or_create(data_dict)
|
||||||
|
|
||||||
|
scrobble_dict = build_scrobble_dict(data_dict, user_id)
|
||||||
|
|
||||||
|
return Scrobble.create_or_update(video, user_id, scrobble_dict)
|
||||||
|
|
||||||
|
|
||||||
|
def manual_scrobble_event(data_dict: dict, user_id: Optional[int]):
|
||||||
|
if not data_dict.get("Provider_thesportsdb", None):
|
||||||
|
logger.error(
|
||||||
|
"No TheSportsDB ID received. This is likely because all metadata is bad, not scrobbling"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
event = SportEvent.find_or_create(data_dict)
|
||||||
|
|
||||||
|
scrobble_dict = build_scrobble_dict(data_dict, user_id)
|
||||||
|
|
||||||
|
return Scrobble.create_or_update(event, user_id, scrobble_dict)
|
||||||
|
|||||||
@ -1,8 +0,0 @@
|
|||||||
from rest_framework import serializers
|
|
||||||
from scrobbles.models import Scrobble
|
|
||||||
|
|
||||||
|
|
||||||
class ScrobbleSerializer(serializers.ModelSerializer):
|
|
||||||
class Meta:
|
|
||||||
model = Scrobble
|
|
||||||
fields = "__all__"
|
|
||||||
12
vrobbler/apps/scrobbles/static/css/bootstrap.min.css
vendored
Normal file
12
vrobbler/apps/scrobbles/static/css/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
vrobbler/apps/scrobbles/static/images/apple-touch-icon.png
Normal file
BIN
vrobbler/apps/scrobbles/static/images/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
vrobbler/apps/scrobbles/static/images/favicon.ico
Normal file
BIN
vrobbler/apps/scrobbles/static/images/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
142
vrobbler/apps/scrobbles/stats.py
Normal file
142
vrobbler/apps/scrobbles/stats.py
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
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, ExpressionWrapper, OuterRef, Subquery
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
tz = pytz.utc
|
||||||
|
data_model = apps.get_model(app_label='music', model_name='Track')
|
||||||
|
if model_str == "Artist":
|
||||||
|
data_model = apps.get_model(app_label='music', model_name='Artist')
|
||||||
|
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'
|
||||||
|
)
|
||||||
|
|
||||||
|
if model_str == "Artist":
|
||||||
|
base_qs = data_model.objects.filter(
|
||||||
|
track__scrobble__user=user,
|
||||||
|
track__scrobble__played_to_completion=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
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 year and day and month:
|
||||||
|
logger.debug('Filtering by year, month and day')
|
||||||
|
start = datetime(year, month, day, 0, 0, tzinfo=tz)
|
||||||
|
end = datetime(year, month, day, 23, 59, tzinfo=tz)
|
||||||
|
elif year and week:
|
||||||
|
logger.debug('Filtering by year and week')
|
||||||
|
start, end = get_start_end_dates_by_week(year, week, tz)
|
||||||
|
elif month:
|
||||||
|
logger.debug('Filtering by month')
|
||||||
|
end_day = calendar.monthrange(year, month)[1]
|
||||||
|
start = datetime(year, month, 1, tzinfo=tz)
|
||||||
|
end = datetime(year, month, end_day, tzinfo=tz)
|
||||||
|
|
||||||
|
if model_str == "Artist":
|
||||||
|
scrobble_date_filter = Q(
|
||||||
|
track__scrobble__timestamp__gte=start,
|
||||||
|
track__scrobble__timestamp__lte=end,
|
||||||
|
)
|
||||||
|
qs = (
|
||||||
|
base_qs.filter(scrobble_date_filter)
|
||||||
|
.annotate(scrobble_count=Count("track__scrobble", distinct=True))
|
||||||
|
.order_by("-scrobble_count")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
scrobble_date_filter = Q(
|
||||||
|
scrobble__timestamp__gte=start, scrobble__timestamp__lte=end
|
||||||
|
)
|
||||||
|
qs = (
|
||||||
|
base_qs.filter(scrobble_date_filter)
|
||||||
|
.annotate(scrobble_count=Count("scrobble", distinct=True))
|
||||||
|
.order_by("-scrobble_count")
|
||||||
|
)
|
||||||
|
|
||||||
|
return qs
|
||||||
|
|
||||||
|
|
||||||
|
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]
|
||||||
|
chart_record['count'] = result.scrobble_count
|
||||||
|
if model_str == 'Track':
|
||||||
|
chart_record['track'] = result
|
||||||
|
if model_str == 'Video':
|
||||||
|
chart_record['video'] = result
|
||||||
|
if model_str == 'Artist':
|
||||||
|
chart_record['artist'] = result
|
||||||
|
chart_records.append(ChartRecord(**chart_record))
|
||||||
|
ChartRecord.objects.bulk_create(
|
||||||
|
chart_records, ignore_conflicts=True, batch_size=500
|
||||||
|
)
|
||||||
37
vrobbler/apps/scrobbles/tasks.py
Normal file
37
vrobbler/apps/scrobbles/tasks.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import logging
|
||||||
|
from celery import shared_task
|
||||||
|
|
||||||
|
from scrobbles.models import (
|
||||||
|
AudioScrobblerTSVImport,
|
||||||
|
KoReaderImport,
|
||||||
|
LastFmImport,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def process_lastfm_import(import_id):
|
||||||
|
lastfm_import = LastFmImport.objects.filter(id=import_id).first()
|
||||||
|
if not lastfm_import:
|
||||||
|
logger.warn(f"LastFmImport not found with id {import_id}")
|
||||||
|
|
||||||
|
lastfm_import.process()
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def process_tsv_import(import_id):
|
||||||
|
tsv_import = AudioScrobblerTSVImport.objects.filter(id=import_id).first()
|
||||||
|
if not tsv_import:
|
||||||
|
logger.warn(f"AudioScrobblerTSVImport not found with id {import_id}")
|
||||||
|
|
||||||
|
tsv_import.process()
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def process_koreader_import(import_id):
|
||||||
|
koreader_import = KoReaderImport.objects.filter(id=import_id).first()
|
||||||
|
if not koreader_import:
|
||||||
|
logger.warn(f"KOReaderImport not found with id {import_id}")
|
||||||
|
|
||||||
|
koreader_import.process()
|
||||||
0
vrobbler/apps/scrobbles/templatetags/__init__.py
Normal file
0
vrobbler/apps/scrobbles/templatetags/__init__.py
Normal file
10
vrobbler/apps/scrobbles/templatetags/urlreplace.py
Normal file
10
vrobbler/apps/scrobbles/templatetags/urlreplace.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
from django import template
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag(takes_context=True)
|
||||||
|
def urlreplace(context, **kwargs):
|
||||||
|
query = context['request'].GET.copy()
|
||||||
|
query.update(kwargs)
|
||||||
|
return query.urlencode()
|
||||||
75
vrobbler/apps/scrobbles/theaudiodb.py
Normal file
75
vrobbler/apps/scrobbles/theaudiodb.py
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
THEAUDIODB_API_KEY = getattr(settings, "THEAUDIODB_API_KEY")
|
||||||
|
ARTIST_SEARCH_URL = f"https://www.theaudiodb.com/api/v1/json/{THEAUDIODB_API_KEY}/search.php?s="
|
||||||
|
ALBUM_SEARCH_URL = f"https://www.theaudiodb.com/api/v1/json/{THEAUDIODB_API_KEY}/searchalbum.php?s="
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_artist_from_tadb(name: str) -> dict:
|
||||||
|
artist_info = {}
|
||||||
|
response = requests.get(ARTIST_SEARCH_URL + name)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.warn(f"Bad response from TADB: {response.status_code}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
if not response.content:
|
||||||
|
logger.warn(f"Bad content from TADB: {response.content}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
results = json.loads(response.content)
|
||||||
|
if results['artists']:
|
||||||
|
artist = results['artists'][0]
|
||||||
|
|
||||||
|
artist_info['biography'] = artist.get('strBiographyEN')
|
||||||
|
artist_info['genre'] = artist.get('strGenre')
|
||||||
|
artist_info['mood'] = artist.get('strMood')
|
||||||
|
artist_info['thumb_url'] = artist.get('strArtistThumb')
|
||||||
|
|
||||||
|
return artist_info
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_album_from_tadb(name: str, artist: str) -> dict:
|
||||||
|
album_info = {}
|
||||||
|
response = requests.get(''.join([ALBUM_SEARCH_URL, artist, "&a=", name]))
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.warn(f"Bad response from TADB: {response.status_code}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
if not response.content:
|
||||||
|
logger.warn(f"Bad content from TADB: {response.content}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
results = json.loads(response.content)
|
||||||
|
if results['album']:
|
||||||
|
album = results['album'][0]
|
||||||
|
|
||||||
|
album_info['theaudiodb_id'] = album.get('idAlbum')
|
||||||
|
album_info['theaudiodb_description'] = album.get('strDescriptionEN')
|
||||||
|
album_info['theaudiodb_genre'] = album.get('strGenre')
|
||||||
|
album_info['theaudiodb_style'] = album.get('strStyle')
|
||||||
|
album_info['theaudiodb_mood'] = album.get('strMood')
|
||||||
|
album_info['theaudiodb_speed'] = album.get('strSpeed')
|
||||||
|
album_info['theaudiodb_theme'] = album.get('strTheme')
|
||||||
|
album_info['theaudiodb_year_released'] = album.get('intYearReleased')
|
||||||
|
album_info['allmusic_id'] = album.get('strAllMusicID')
|
||||||
|
album_info['wikipedia_slug'] = album.get('strWikipediaID')
|
||||||
|
album_info['discogs_id'] = album.get('strDiscogsID')
|
||||||
|
album_info['wikidata_id'] = album.get('strWikidataID')
|
||||||
|
album_info['rateyourmusic_id'] = album.get('strRateYourMusicID')
|
||||||
|
|
||||||
|
if album.get('intScore'):
|
||||||
|
album_info['theaudiodb_score'] = float(album.get('intScore'))
|
||||||
|
if album.get('intScoreVotes'):
|
||||||
|
album_info['theaudiodb_score_votes'] = int(
|
||||||
|
album.get('intScoreVotes')
|
||||||
|
)
|
||||||
|
|
||||||
|
return album_info
|
||||||
52
vrobbler/apps/scrobbles/thesportsdb.py
Normal file
52
vrobbler/apps/scrobbles/thesportsdb.py
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from dateutil.parser import parse
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils import timezone
|
||||||
|
from pysportsdb import TheSportsDbClient
|
||||||
|
from sports.models import Sport
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
API_KEY = getattr(settings, "THESPORTSDB_API_KEY", "2")
|
||||||
|
client = TheSportsDbClient(api_key=API_KEY)
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_event_from_thesportsdb(event_id: str) -> dict:
|
||||||
|
|
||||||
|
event = client.lookup_event(event_id)['events'][0]
|
||||||
|
if not event or type(event) != dict:
|
||||||
|
return {}
|
||||||
|
league = {} # client.lookup_league(league_id=event.get('idLeague'))
|
||||||
|
event_type = "Game"
|
||||||
|
sport, _created = Sport.objects.get_or_create(
|
||||||
|
thesportsdb_id=event.get('strSport')
|
||||||
|
)
|
||||||
|
|
||||||
|
data_dict = {
|
||||||
|
"EventId": event_id,
|
||||||
|
"ItemType": sport.default_event_type,
|
||||||
|
"Name": event.get('strEvent'),
|
||||||
|
"AltName": event.get('strEventAlternate'),
|
||||||
|
"Start": parse(event.get('strTimestamp')),
|
||||||
|
"Provider_thesportsdb": event.get('idEvent'),
|
||||||
|
"RunTime": sport.default_event_run_time,
|
||||||
|
"RunTimeTicks": sport.default_event_run_time_ticks,
|
||||||
|
"Sport": event.get('strSport'),
|
||||||
|
"Season": event.get('strSeason'),
|
||||||
|
"LeagueId": event.get('idLeague'),
|
||||||
|
"LeagueName": event.get('strLeague'),
|
||||||
|
"HomeTeamId": event.get('idHomeTeam'),
|
||||||
|
"HomeTeamName": event.get('strHomeTeam'),
|
||||||
|
"AwayTeamId": event.get('idAwayTeam'),
|
||||||
|
"AwayTeamName": event.get('strAwayTeam'),
|
||||||
|
"RoundId": event.get('intRound'),
|
||||||
|
"PlaybackPositionTicks": None,
|
||||||
|
"PlaybackPosition": None,
|
||||||
|
"UtcTimestamp": timezone.now().strftime('%Y-%m-%d %H:%M:%S.%f%z'),
|
||||||
|
"IsPaused": False,
|
||||||
|
"PlayedToCompletion": False,
|
||||||
|
"Source": "Vrobbler",
|
||||||
|
}
|
||||||
|
|
||||||
|
return data_dict
|
||||||
80
vrobbler/apps/scrobbles/tsv.py
Normal file
80
vrobbler/apps/scrobbles/tsv.py
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import csv
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
from music.utils import (
|
||||||
|
get_or_create_album,
|
||||||
|
get_or_create_artist,
|
||||||
|
get_or_create_track,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def process_audioscrobbler_tsv_file(file_path, user_id, 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]:
|
||||||
|
if "Rockbox" in row[0]:
|
||||||
|
source = "Rockbox"
|
||||||
|
source_id += row[0] + "\n"
|
||||||
|
continue
|
||||||
|
if len(row) > 8:
|
||||||
|
logger.warning(
|
||||||
|
'Improper row length during Audioscrobbler import',
|
||||||
|
extra={'row': row},
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
artist = get_or_create_artist(row[0])
|
||||||
|
album = get_or_create_album(row[1], artist)
|
||||||
|
|
||||||
|
track = get_or_create_track(
|
||||||
|
title=row[2],
|
||||||
|
mbid=row[7],
|
||||||
|
artist=artist,
|
||||||
|
album=album,
|
||||||
|
run_time=row[4],
|
||||||
|
run_time_ticks=int(row[4]) * 1000,
|
||||||
|
)
|
||||||
|
|
||||||
|
timestamp = (
|
||||||
|
datetime.utcfromtimestamp(int(row[6]))
|
||||||
|
.replace(tzinfo=user_tz)
|
||||||
|
.astimezone(pytz.utc)
|
||||||
|
)
|
||||||
|
|
||||||
|
new_scrobble = Scrobble(
|
||||||
|
user_id=user_id,
|
||||||
|
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
|
||||||
@ -4,7 +4,67 @@ from scrobbles import views
|
|||||||
app_name = 'scrobbles'
|
app_name = 'scrobbles'
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', views.scrobble_endpoint, name='scrobble-list'),
|
path(
|
||||||
path('jellyfin/', views.jellyfin_websocket, name='jellyfin-websocket'),
|
'manual/imdb/',
|
||||||
path('mopidy/', views.mopidy_websocket, name='mopidy-websocket'),
|
views.ManualScrobbleView.as_view(),
|
||||||
|
name='imdb-manual-scrobble',
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
'manual/audioscrobbler/',
|
||||||
|
views.AudioScrobblerImportCreateView.as_view(),
|
||||||
|
name='audioscrobbler-file-upload',
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
'manual/koreader/',
|
||||||
|
views.KoReaderImportCreateView.as_view(),
|
||||||
|
name='koreader-file-upload',
|
||||||
|
),
|
||||||
|
path('finish/<slug:uuid>', views.scrobble_finish, name='finish'),
|
||||||
|
path('cancel/<slug:uuid>', views.scrobble_cancel, name='cancel'),
|
||||||
|
path(
|
||||||
|
'upload/',
|
||||||
|
views.AudioScrobblerImportCreateView.as_view(),
|
||||||
|
name='audioscrobbler-file-upload',
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
'lastfm-import/',
|
||||||
|
views.lastfm_import,
|
||||||
|
name='lastfm-import',
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
'webhook/jellyfin/',
|
||||||
|
views.jellyfin_webhook,
|
||||||
|
name='jellyfin-webhook',
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
'webhook/mopidy/',
|
||||||
|
views.mopidy_webhook,
|
||||||
|
name='mopidy-webhook',
|
||||||
|
),
|
||||||
|
path('export/', views.export, name='export'),
|
||||||
|
path(
|
||||||
|
'imports/',
|
||||||
|
views.ScrobbleImportListView.as_view(),
|
||||||
|
name='import-detail',
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
'imports/tsv/<slug:slug>/',
|
||||||
|
views.ScrobbleTSVImportDetailView.as_view(),
|
||||||
|
name='tsv-import-detail',
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
'imports/lastfm/<slug:slug>/',
|
||||||
|
views.ScrobbleLastFMImportDetailView.as_view(),
|
||||||
|
name='lastfm-import-detail',
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
'imports/koreader/<slug:slug>/',
|
||||||
|
views.ScrobbleKoReaderImportDetailView.as_view(),
|
||||||
|
name='koreader-import-detail',
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
'charts/',
|
||||||
|
views.ChartRecordView.as_view(),
|
||||||
|
name='charts-home',
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user