Compare commits
132 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 507b3aaaf2 | |||
| 879357473a | |||
| cc7d267494 | |||
| 685c99d023 | |||
| 69f596039d | |||
| cf55c9b464 | |||
| 8517212d0e | |||
| f435e60b80 | |||
| b51b189cd4 | |||
| 2a20d1212b | |||
| 83b6ba9cc3 | |||
| 4f0d5ad7f4 | |||
| 2b81b28bff | |||
| d0c88ce271 | |||
| f8c9df3b9a | |||
| 8b61dab1bc | |||
| 3655cd7934 | |||
| 602d1e0ddb | |||
| 457828e04d | |||
| 49889ae297 | |||
| 4d573bc934 | |||
| bdd0f19161 | |||
| cc0c573c51 | |||
| 28bd9ad504 | |||
| 657b194dc7 | |||
| 27ffd35826 | |||
| da64cb2b6f | |||
| 09e96a55d4 | |||
| e4027402ed | |||
| 4dc1599633 | |||
| 71a8a19491 | |||
| 1ec4333856 | |||
| f98fe4635c | |||
| c3b48099bf | |||
| 1476fe37ca | |||
| 842378e812 | |||
| 07ad6005c8 | |||
| 638be0b56a |
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:
|
||||
# Run tests against Python/Flask engine backend (with pytest)
|
||||
- name: django_tests
|
||||
- name: pytest with coverage
|
||||
image: python:3.10.4
|
||||
commands:
|
||||
# Install dependencies
|
||||
- cp vrobbler.conf.example vrobbler.conf
|
||||
- cp vrobbler.conf.test vrobbler.conf
|
||||
- pip install poetry
|
||||
- poetry install
|
||||
# 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:
|
||||
VROBBLER_DATABASE_URL: sqlite:///test.db
|
||||
volumes:
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@ -1,4 +1,5 @@
|
||||
db.sqlite3
|
||||
vrobbler.conf
|
||||
/media/
|
||||
/dist/
|
||||
media/
|
||||
dist/
|
||||
.coverage
|
||||
|
||||
@ -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.
|
||||
|
||||
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)"
|
||||
739
poetry.lock
generated
739
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "vrobbler"
|
||||
version = "0.1.6"
|
||||
version = "0.7.5"
|
||||
description = ""
|
||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||
|
||||
@ -27,24 +27,37 @@ django-markdownify = "^0.9.1"
|
||||
gunicorn = "^20.1.0"
|
||||
django-simple-history = "^3.1.1"
|
||||
whitenoise = "^6.3.0"
|
||||
musicbrainzngs = "^0.7.1"
|
||||
cinemagoer = "^2022.12.27"
|
||||
pysportsdb = "^0.1.0"
|
||||
django-cachalot = "^2.5.2"
|
||||
pytz = "^2022.7.1"
|
||||
django-redis = "^5.2.0"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
Werkzeug = "2.0.3"
|
||||
black = "^22.3"
|
||||
freezegun = "^1.2"
|
||||
coverage = "^7.0.5"
|
||||
mypy = "^0.961"
|
||||
pytest = "^7.1"
|
||||
pytest-black = "^0.3.12"
|
||||
pytest-cov = "^3.0"
|
||||
pytest-django = "^4.5.2"
|
||||
pytest-flake8 = "^1.1"
|
||||
pytest-isort = "^3.0"
|
||||
pytest-runner = "^6.0"
|
||||
pytest-selenium = "^2.0.1"
|
||||
time-machine = "^2.9.0"
|
||||
types-pytz = "^2022.1"
|
||||
types-requests = "^2.27"
|
||||
types-freezegun = "^1.1"
|
||||
bandit = "^1.7.4"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
minversion = "6.0"
|
||||
addopts = "-ra -q"
|
||||
testpaths = ["tests"]
|
||||
DJANGO_SETTINGS_MODULE='vrobbler.settings'
|
||||
|
||||
[tool.black]
|
||||
line-length = 79
|
||||
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
|
||||
107
tests/scrobbles_tests/test_aggregators.py
Normal file
107
tests/scrobbles_tests/test_aggregators.py
Normal file
@ -0,0 +1,107 @@
|
||||
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 (
|
||||
scrobble_counts,
|
||||
top_artists,
|
||||
top_tracks,
|
||||
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-websocket')
|
||||
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 = top_tracks(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 = top_tracks(user, filter='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 = top_tracks(user, filter='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 = top_tracks(user, filter='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 = top_artists(user, filter='week')
|
||||
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 = top_artists(user, filter='month')
|
||||
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 = top_artists(user, filter='year')
|
||||
assert tops[0].name == "Sublime"
|
||||
12
tests/scrobbles_tests/test_imdb.py
Normal file
12
tests/scrobbles_tests/test_imdb.py
Normal file
@ -0,0 +1,12 @@
|
||||
import pytest
|
||||
import imdb
|
||||
from mock import patch
|
||||
|
||||
from vrobbler.apps.scrobbles.imdb import lookup_video_from_imdb
|
||||
|
||||
|
||||
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-websocket')
|
||||
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-websocket')
|
||||
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-websocket')
|
||||
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-websocket')
|
||||
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-websocket')
|
||||
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"
|
||||
351
todos.org
Normal file
351
todos.org
Normal file
@ -0,0 +1,351 @@
|
||||
#+title: TODOs
|
||||
|
||||
A fun way to keep track of things in the project to fix or improve.
|
||||
|
||||
* DONE [#A] Fix fetching artwork without release group :bug:
|
||||
CLOSED: [2023-01-29 Sun 14:27]
|
||||
|
||||
When we get artwork from Musicbrianz, and it's not found, we should check for
|
||||
release groups as well. This will stop issues with missing artwork because of
|
||||
obscure MB release matches.
|
||||
|
||||
* DONE [#A] Fix Jellyfin music scrobbling N+1 past 90 completion perecnt :bug:
|
||||
CLOSED: [2023-01-30 Mon 18:31]
|
||||
:LOGBOOK:
|
||||
CLOCK: [2023-01-30 Mon 18:00]--[2023-01-30 Mon 18:31] => 0:31
|
||||
:END:
|
||||
|
||||
If we play music from Jellyfin and the track reaches 90% completion, the
|
||||
scrobbling goes crazy and starts creating new scrobbles with every update.
|
||||
|
||||
The cause is pretty simple, but the solution is hard. We want to mark a scrobble
|
||||
as complete for the following conditions:
|
||||
|
||||
- Play stopped and percent played beyond 90%
|
||||
- Play completely finished
|
||||
|
||||
But if we keep listening beyond 90, we should basically ignore updates (or just
|
||||
update the existing scrobble)
|
||||
|
||||
* TODO [#A] Add django-storage to store files on S3 :improvement:
|
||||
* TODO [#B] Adjust cancel/finish task to use javascript to submit :improvement:
|
||||
* TODO [#B] Implement a detail view for TV shows :improvement:
|
||||
* TODO [#B] Implement a detail view for Moviews :improvement:
|
||||
* TODO [#B] Allow scrobbling music without MB IDs by grabbing them before scrobble :improvement:
|
||||
|
||||
This would allow a few nice flows. One, you'd be able to record the play of an
|
||||
entire album by just dropping the muscibrainz_id in. This could be helpful for
|
||||
offline listening. It would also mean bad metadata from mopidy would not break
|
||||
scrobbling.
|
||||
* TODO [#C] Implement keeping track of week/month/year chart-toppers :improvement:
|
||||
:LOGBOOK:
|
||||
CLOCK: [2023-01-30 Mon 16:30]--[2023-01-30 Mon 18:00] => 1:30
|
||||
:END:
|
||||
|
||||
Maloja does this cool thing where artists and tracks get recorded as the top
|
||||
track of a given week, month or year. They get gold, silver or bronze stars for
|
||||
their place in the time period.
|
||||
|
||||
I could see this being implemented as a separate Chart table which gets
|
||||
populated at the end of a time period and has a start and end date that defines
|
||||
a period, along with a one, two, three instance.
|
||||
|
||||
Of course, it could also be a data model without a table, where it runs some fun
|
||||
calculations, stores it's values in Redis as a long-term lookup table and just
|
||||
has to re-populate when the server restarts.
|
||||
* TODO [#C] Move to using more robust mopidy-webhooks pacakge form pypi :improvement:
|
||||
** Example payloads from mopidy-webhooks
|
||||
*** Podcast playback ended
|
||||
#+begin_src json
|
||||
{
|
||||
"type": "event",
|
||||
"event": "track_playback_ended",
|
||||
"data": {
|
||||
"tl_track": {
|
||||
"__model__": "TlTrack",
|
||||
"tlid": 13,
|
||||
"track": {
|
||||
"__model__": "Track",
|
||||
"uri": "file:///var/lib/mopidy/media/podcasts/The%20Prince/2022-09-28-Wolf-warriors.mp3",
|
||||
"name": "Wolf warriors",
|
||||
"artists": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"name": "The Economist"
|
||||
}
|
||||
],
|
||||
"album": {
|
||||
"__model__": "Album",
|
||||
"name": "The Prince",
|
||||
"date": "2022"
|
||||
},
|
||||
"genre": "Blues",
|
||||
"date": "2022",
|
||||
"length": 2437778,
|
||||
"bitrate": 127988
|
||||
}
|
||||
},
|
||||
"time_position": 3290
|
||||
}
|
||||
}
|
||||
#+end_src
|
||||
*** Podcast playback state changes
|
||||
#+begin_src json
|
||||
{
|
||||
"type": "event",
|
||||
"event": "playback_state_changed",
|
||||
"data": {
|
||||
"old_state": "paused",
|
||||
"new_state": "playing"
|
||||
}
|
||||
}
|
||||
#+end_src
|
||||
|
||||
#+begin_src json
|
||||
{
|
||||
"type": "event",
|
||||
"event": "playback_state_changed",
|
||||
"data": {
|
||||
"old_state": "stopped",
|
||||
"new_state": "playing"
|
||||
}
|
||||
}
|
||||
#+end_src
|
||||
*** Podcast playback started
|
||||
#+begin_src json
|
||||
{
|
||||
"type": "event",
|
||||
"event": "track_playback_started",
|
||||
"data": {
|
||||
"tl_track": {
|
||||
"__model__": "TlTrack",
|
||||
"tlid": 13,
|
||||
"track": {
|
||||
"__model__": "Track",
|
||||
"uri": "file:///var/lib/mopidy/media/podcasts/The%20Prince/2022-09-28-Wolf-warriors.mp3",
|
||||
"name": "Wolf warriors",
|
||||
"artists": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"name": "The Economist"
|
||||
}
|
||||
],
|
||||
"album": {
|
||||
"__model__": "Album",
|
||||
"name": "The Prince",
|
||||
"date": "2022"
|
||||
},
|
||||
"genre": "Blues",
|
||||
"date": "2022",
|
||||
"length": 2437778,
|
||||
"bitrate": 127988
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#+end_src
|
||||
*** Podcast playback paused
|
||||
#+begin_src json
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"state": "paused",
|
||||
"current_track": {
|
||||
"__model__": "Track",
|
||||
"uri": "file:///var/lib/mopidy/media/podcasts/The%20Prince/2022-09-28-Wolf-warriors.mp3",
|
||||
"name": "Wolf warriors",
|
||||
"artists": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"name": "The Economist"
|
||||
}
|
||||
],
|
||||
"album": {
|
||||
"__model__": "Album",
|
||||
"name": "The Prince",
|
||||
"date": "2022"
|
||||
},
|
||||
"genre": "Blues",
|
||||
"date": "2022",
|
||||
"length": 2437778,
|
||||
"bitrate": 127988
|
||||
},
|
||||
"time_position": 2350
|
||||
}
|
||||
}
|
||||
|
||||
#+end_src
|
||||
*** Track playback started
|
||||
#+begin_src json
|
||||
{
|
||||
"type": "event",
|
||||
"event": "track_playback_started",
|
||||
"data": {
|
||||
"tl_track": {
|
||||
"__model__": "TlTrack",
|
||||
"tlid": 14,
|
||||
"track": {
|
||||
"__model__": "Track",
|
||||
"uri": "local:track:Various%20Artists%20-%202008%20-%20Twilight%20OST/01-muse-supermassive_black_hole.mp3",
|
||||
"name": "Supermassive Black Hole",
|
||||
"artists": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"uri": "local:artist:md5:250dd6551b66a58a6b4897aa697f200c",
|
||||
"name": "Muse",
|
||||
"musicbrainz_id": "9c9f1380-2516-4fc9-a3e6-f9f61941d090"
|
||||
}
|
||||
],
|
||||
"album": {
|
||||
"__model__": "Album",
|
||||
"uri": "local:album:md5:455343d54cdd89cb5a3b5ad537ea99d0",
|
||||
"name": "Twilight: Original Motion Picture Soundtrack",
|
||||
"artists": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"uri": "local:artist:md5:54e4db2d5624f80b0cc290346e696756",
|
||||
"name": "Various Artists",
|
||||
"musicbrainz_id": "89ad4ac3-39f7-470e-963a-56509c546377"
|
||||
}
|
||||
],
|
||||
"num_tracks": 12,
|
||||
"num_discs": 1,
|
||||
"date": "2008-11-04",
|
||||
"musicbrainz_id": "b4889eaf-d9f4-434c-a68d-69227b12b6a4"
|
||||
},
|
||||
"composers": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"uri": "local:artist:md5:4d49cbca0b347e0a89047bb019d2779d",
|
||||
"name": "Matt Bellamy"
|
||||
}
|
||||
],
|
||||
"genre": "Rock",
|
||||
"track_no": 1,
|
||||
"disc_no": 1,
|
||||
"date": "2008-11-04",
|
||||
"length": 211121,
|
||||
"musicbrainz_id": "ff1e3e1a-f6e8-4692-b426-355880383bb6",
|
||||
"last_modified": 1672712949510
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#+end_src
|
||||
*** Track playback in progress
|
||||
#+begin_src json
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"state": "playing",
|
||||
"current_track": {
|
||||
"__model__": "Track",
|
||||
"uri": "local:track:Various%20Artists%20-%202008%20-%20Twilight%20OST/01-muse-supermassive_black_hole.mp3",
|
||||
"name": "Supermassive Black Hole",
|
||||
"artists": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"uri": "local:artist:md5:250dd6551b66a58a6b4897aa697f200c",
|
||||
"name": "Muse",
|
||||
"musicbrainz_id": "9c9f1380-2516-4fc9-a3e6-f9f61941d090"
|
||||
}
|
||||
],
|
||||
"album": {
|
||||
"__model__": "Album",
|
||||
"uri": "local:album:md5:455343d54cdd89cb5a3b5ad537ea99d0",
|
||||
"name": "Twilight: Original Motion Picture Soundtrack",
|
||||
"artists": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"uri": "local:artist:md5:54e4db2d5624f80b0cc290346e696756",
|
||||
"name": "Various Artists",
|
||||
"musicbrainz_id": "89ad4ac3-39f7-470e-963a-56509c546377"
|
||||
}
|
||||
],
|
||||
"num_tracks": 12,
|
||||
"num_discs": 1,
|
||||
"date": "2008-11-04",
|
||||
"musicbrainz_id": "b4889eaf-d9f4-434c-a68d-69227b12b6a4"
|
||||
},
|
||||
"composers": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"uri": "local:artist:md5:4d49cbca0b347e0a89047bb019d2779d",
|
||||
"name": "Matt Bellamy"
|
||||
}
|
||||
],
|
||||
"genre": "Rock",
|
||||
"track_no": 1,
|
||||
"disc_no": 1,
|
||||
"date": "2008-11-04",
|
||||
"length": 211121,
|
||||
"musicbrainz_id": "ff1e3e1a-f6e8-4692-b426-355880383bb6",
|
||||
"last_modified": 1672712949510
|
||||
},
|
||||
"time_position": 17031
|
||||
}
|
||||
}
|
||||
#+end_src
|
||||
*** Track event playback paused
|
||||
#+begin_src json
|
||||
{
|
||||
"type": "event",
|
||||
"event": "track_playback_paused",
|
||||
"data": {
|
||||
"tl_track": {
|
||||
"__model__": "TlTrack",
|
||||
"tlid": 14,
|
||||
"track": {
|
||||
"__model__": "Track",
|
||||
"uri": "local:track:Various%20Artists%20-%202008%20-%20Twilight%20OST/01-muse-supermassive_black_hole.mp3",
|
||||
"name": "Supermassive Black Hole",
|
||||
"artists": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"uri": "local:artist:md5:250dd6551b66a58a6b4897aa697f200c",
|
||||
"name": "Muse",
|
||||
"musicbrainz_id": "9c9f1380-2516-4fc9-a3e6-f9f61941d090"
|
||||
}
|
||||
],
|
||||
"album": {
|
||||
"__model__": "Album",
|
||||
"uri": "local:album:md5:455343d54cdd89cb5a3b5ad537ea99d0",
|
||||
"name": "Twilight: Original Motion Picture Soundtrack",
|
||||
"artists": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"uri": "local:artist:md5:54e4db2d5624f80b0cc290346e696756",
|
||||
"name": "Various Artists",
|
||||
"musicbrainz_id": "89ad4ac3-39f7-470e-963a-56509c546377"
|
||||
}
|
||||
],
|
||||
"num_tracks": 12,
|
||||
"num_discs": 1,
|
||||
"date": "2008-11-04",
|
||||
"musicbrainz_id": "b4889eaf-d9f4-434c-a68d-69227b12b6a4"
|
||||
},
|
||||
"composers": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"uri": "local:artist:md5:4d49cbca0b347e0a89047bb019d2779d",
|
||||
"name": "Matt Bellamy"
|
||||
}
|
||||
],
|
||||
"genre": "Rock",
|
||||
"track_no": 1,
|
||||
"disc_no": 1,
|
||||
"date": "2008-11-04",
|
||||
"length": 211121,
|
||||
"musicbrainz_id": "ff1e3e1a-f6e8-4692-b426-355880383bb6",
|
||||
"last_modified": 1672712949510
|
||||
}
|
||||
},
|
||||
"time_position": 67578
|
||||
}
|
||||
}
|
||||
#+end_src
|
||||
* TODO [#C] Consider a purge command for duplicated and stuck in-progress scrobbles :improvement:
|
||||
* TODO Figure out how to add to web-scrobbler :imropvement:
|
||||
|
||||
An example:
|
||||
https://github.com/web-scrobbler/web-scrobbler/blob/master/src/core/background/scrobbler/maloja-scrobbler.js
|
||||
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
vrobbler/apps/music/__init__.py
Normal file
0
vrobbler/apps/music/__init__.py
Normal file
40
vrobbler/apps/music/admin.py
Normal file
40
vrobbler/apps/music/admin.py
Normal file
@ -0,0 +1,40 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from music.models import Artist, Album, Track
|
||||
|
||||
from scrobbles.admin import ScrobbleInline
|
||||
|
||||
|
||||
@admin.register(Album)
|
||||
class AlbumAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = ("name", "year", "musicbrainz_id")
|
||||
list_filter = ("year",)
|
||||
ordering = ("name",)
|
||||
filter_horizontal = [
|
||||
'artists',
|
||||
]
|
||||
|
||||
|
||||
@admin.register(Artist)
|
||||
class ArtistAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = ("name", "musicbrainz_id")
|
||||
ordering = ("name",)
|
||||
|
||||
|
||||
@admin.register(Track)
|
||||
class TrackAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = (
|
||||
"title",
|
||||
"album",
|
||||
"artist",
|
||||
"run_time",
|
||||
"musicbrainz_id",
|
||||
)
|
||||
list_filter = ("album", "artist")
|
||||
ordering = ("-created",)
|
||||
inlines = [
|
||||
ScrobbleInline,
|
||||
]
|
||||
151
vrobbler/apps/music/aggregators.py
Normal file
151
vrobbler/apps/music/aggregators.py
Normal file
@ -0,0 +1,151 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional
|
||||
|
||||
import pytz
|
||||
from django.db.models import Count, Q, Sum
|
||||
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()
|
||||
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)
|
||||
|
||||
|
||||
def scrobble_counts(user=None):
|
||||
|
||||
now = timezone.now()
|
||||
user_filter = Q()
|
||||
if user and user.is_authenticated:
|
||||
now = now_user_timezone(user.profile)
|
||||
user_filter = Q(user=user)
|
||||
|
||||
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)
|
||||
|
||||
finished_scrobbles_qs = Scrobble.objects.filter(
|
||||
user_filter, played_to_completion=True
|
||||
)
|
||||
data = {}
|
||||
data['today'] = finished_scrobbles_qs.filter(
|
||||
timestamp__gte=start_of_today
|
||||
).count()
|
||||
data['week'] = finished_scrobbles_qs.filter(
|
||||
timestamp__gte=starting_day_of_current_week
|
||||
).count()
|
||||
data['month'] = finished_scrobbles_qs.filter(
|
||||
timestamp__gte=starting_day_of_current_month
|
||||
).count()
|
||||
data['year'] = finished_scrobbles_qs.filter(
|
||||
timestamp__gte=starting_day_of_current_year
|
||||
).count()
|
||||
data['alltime'] = finished_scrobbles_qs.count()
|
||||
return data
|
||||
|
||||
|
||||
def week_of_scrobbles(user=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)
|
||||
|
||||
start_of_today = datetime.combine(
|
||||
now.date(), datetime.min.time(), now.tzinfo
|
||||
)
|
||||
|
||||
scrobble_day_dict = {}
|
||||
base_qs = Scrobble.objects.filter(user_filter, played_to_completion=True)
|
||||
|
||||
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):
|
||||
start = start_of_today - timedelta(days=day)
|
||||
end = datetime.combine(start, datetime.max.time(), now.tzinfo)
|
||||
day_of_week = start.strftime('%A')
|
||||
|
||||
scrobble_day_dict[day_of_week] = base_qs.filter(
|
||||
media_filter,
|
||||
timestamp__gte=start,
|
||||
timestamp__lte=end,
|
||||
played_to_completion=True,
|
||||
).count()
|
||||
|
||||
return scrobble_day_dict
|
||||
|
||||
|
||||
def top_tracks(
|
||||
user: "User", filter: str = "today", limit: int = 15
|
||||
) -> List["Track"]:
|
||||
|
||||
now = timezone.now()
|
||||
if user.is_authenticated:
|
||||
now = now_user_timezone(user.profile)
|
||||
|
||||
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)
|
||||
|
||||
time_filter = Q(scrobble__timestamp__gte=start_of_today)
|
||||
if filter == "week":
|
||||
time_filter = Q(scrobble__timestamp__gte=starting_day_of_current_week)
|
||||
if filter == "month":
|
||||
time_filter = Q(scrobble__timestamp__gte=starting_day_of_current_month)
|
||||
if filter == "year":
|
||||
time_filter = Q(scrobble__timestamp__gte=starting_day_of_current_year)
|
||||
|
||||
return (
|
||||
Track.objects.filter(time_filter)
|
||||
.annotate(num_scrobbles=Count("scrobble", distinct=True))
|
||||
.order_by("-num_scrobbles")[:limit]
|
||||
)
|
||||
|
||||
|
||||
def top_artists(
|
||||
user: "User", filter: str = "today", limit: int = 15
|
||||
) -> List["Artist"]:
|
||||
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 (
|
||||
Artist.objects.filter(time_filter)
|
||||
.annotate(num_scrobbles=Count("track__scrobble", distinct=True))
|
||||
.order_by("-num_scrobbles")[:limit]
|
||||
)
|
||||
|
||||
|
||||
def artist_scrobble_count(artist_id: int, filter: str = "today") -> int:
|
||||
return Scrobble.objects.filter(track__artist=artist_id).count()
|
||||
5
vrobbler/apps/music/apps.py
Normal file
5
vrobbler/apps/music/apps.py
Normal file
@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class MusicConfig(AppConfig):
|
||||
name = 'music'
|
||||
16
vrobbler/apps/music/constants.py
Normal file
16
vrobbler/apps/music/constants.py
Normal file
@ -0,0 +1,16 @@
|
||||
JELLYFIN_POST_KEYS = {
|
||||
'ITEM_TYPE': 'ItemType',
|
||||
'RUN_TIME_TICKS': 'RunTimeTicks',
|
||||
'RUN_TIME': 'RunTime',
|
||||
'TITLE': 'Name',
|
||||
'TIMESTAMP': 'UtcTimestamp',
|
||||
'YEAR': 'Year',
|
||||
'PLAYBACK_POSITION_TICKS': 'PlaybackPositionTicks',
|
||||
'PLAYBACK_POSITION': 'PlaybackPosition',
|
||||
'ARTIST_MB_ID': 'Provider_musicbrainzartist',
|
||||
'ALBUM_MB_ID': 'Provider_musicbrainzalbum',
|
||||
'RELEASEGROUP_MB_ID': 'Provider_musicbrainzreleasegroup',
|
||||
'TRACK_MB_ID': 'Provider_musicbrainztrack',
|
||||
'ALBUM_NAME': 'Album',
|
||||
'ARTIST_NAME': 'Artist',
|
||||
}
|
||||
8
vrobbler/apps/music/context_processors.py
Normal file
8
vrobbler/apps/music/context_processors.py
Normal file
@ -0,0 +1,8 @@
|
||||
from music.models import Artist, Album
|
||||
|
||||
|
||||
def music_lists(request):
|
||||
return {
|
||||
"artist_list": Artist.objects.all(),
|
||||
"album_list": Album.objects.all(),
|
||||
}
|
||||
156
vrobbler/apps/music/migrations/0001_initial.py
Normal file
156
vrobbler/apps/music/migrations/0001_initial.py
Normal file
@ -0,0 +1,156 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-07 19:37
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django_extensions.db.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Album',
|
||||
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)),
|
||||
('year', models.IntegerField()),
|
||||
(
|
||||
'musicbrainz_id',
|
||||
models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
(
|
||||
'musicbrainz_releasegroup_id',
|
||||
models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
(
|
||||
'musicbrainz_albumartist_id',
|
||||
models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'get_latest_by': 'modified',
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Artist',
|
||||
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)),
|
||||
(
|
||||
'musicbrainz_id',
|
||||
models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'get_latest_by': 'modified',
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Track',
|
||||
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'
|
||||
),
|
||||
),
|
||||
(
|
||||
'title',
|
||||
models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
(
|
||||
'musicbrainz_id',
|
||||
models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
(
|
||||
'run_time',
|
||||
models.CharField(blank=True, max_length=8, null=True),
|
||||
),
|
||||
(
|
||||
'run_time_ticks',
|
||||
models.PositiveBigIntegerField(blank=True, null=True),
|
||||
),
|
||||
(
|
||||
'album',
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to='music.album',
|
||||
),
|
||||
),
|
||||
(
|
||||
'artist',
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to='music.artist',
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'get_latest_by': 'modified',
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
18
vrobbler/apps/music/migrations/0002_alter_album_year.py
Normal file
18
vrobbler/apps/music/migrations/0002_alter_album_year.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-08 01:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('music', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='album',
|
||||
name='year',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,35 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-08 21:31
|
||||
|
||||
from django.db import migrations, models
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('music', '0002_alter_album_year'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='album',
|
||||
name='uuid',
|
||||
field=models.UUIDField(
|
||||
blank=True, default=uuid.uuid4, editable=False, null=True
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='artist',
|
||||
name='uuid',
|
||||
field=models.UUIDField(
|
||||
blank=True, default=uuid.uuid4, editable=False, null=True
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='track',
|
||||
name='uuid',
|
||||
field=models.UUIDField(
|
||||
blank=True, default=uuid.uuid4, editable=False, null=True
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,35 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-11 03:13
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('music', '0003_album_uuid_artist_uuid_track_uuid'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='artist',
|
||||
options={},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='album',
|
||||
name='musicbrainz_id',
|
||||
field=models.CharField(
|
||||
blank=True, max_length=255, null=True, unique=True
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='track',
|
||||
name='musicbrainz_id',
|
||||
field=models.CharField(
|
||||
blank=True, max_length=255, null=True, unique=True
|
||||
),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='artist',
|
||||
unique_together={('name', 'musicbrainz_id')},
|
||||
),
|
||||
]
|
||||
23
vrobbler/apps/music/migrations/0005_album_cover_image.py
Normal file
23
vrobbler/apps/music/migrations/0005_album_cover_image.py
Normal file
@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-12 04:22
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
(
|
||||
'music',
|
||||
'0004_alter_artist_options_alter_album_musicbrainz_id_and_more',
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='album',
|
||||
name='cover_image',
|
||||
field=models.ImageField(
|
||||
blank=True, null=True, upload_to='albums/'
|
||||
),
|
||||
),
|
||||
]
|
||||
20
vrobbler/apps/music/migrations/0006_album_artists.py
Normal file
20
vrobbler/apps/music/migrations/0006_album_artists.py
Normal file
@ -0,0 +1,20 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-12 05:04
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('music', '0005_album_cover_image'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='album',
|
||||
name='artists',
|
||||
field=models.ManyToManyField(
|
||||
blank=True, null=True, to='music.artist'
|
||||
),
|
||||
),
|
||||
]
|
||||
18
vrobbler/apps/music/migrations/0007_alter_album_artists.py
Normal file
18
vrobbler/apps/music/migrations/0007_alter_album_artists.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-12 17:17
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('music', '0006_album_artists'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='album',
|
||||
name='artists',
|
||||
field=models.ManyToManyField(to='music.artist'),
|
||||
),
|
||||
]
|
||||
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
vrobbler/apps/music/migrations/__init__.py
Normal file
0
vrobbler/apps/music/migrations/__init__.py
Normal file
210
vrobbler/apps/music/models.py
Normal file
210
vrobbler/apps/music/models.py
Normal file
@ -0,0 +1,210 @@
|
||||
import logging
|
||||
from typing import Dict, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
import musicbrainzngs
|
||||
from django.conf import settings
|
||||
from django.core.files.base import ContentFile
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
from scrobbles.mixins import ScrobblableMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
BNULL = {"blank": True, "null": True}
|
||||
|
||||
|
||||
class Artist(TimeStampedModel):
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
name = models.CharField(max_length=255)
|
||||
musicbrainz_id = models.CharField(max_length=255, **BNULL)
|
||||
|
||||
class Meta:
|
||||
unique_together = [['name', 'musicbrainz_id']]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def mb_link(self):
|
||||
return f"https://musicbrainz.org/artist/{self.musicbrainz_id}"
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('music:artist_detail', kwargs={'slug': self.uuid})
|
||||
|
||||
def scrobbles(self):
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
return Scrobble.objects.filter(
|
||||
track__in=self.track_set.all()
|
||||
).order_by('-timestamp')
|
||||
|
||||
@property
|
||||
def tracks(self):
|
||||
return (
|
||||
self.track_set.all()
|
||||
.annotate(scrobble_count=models.Count('scrobble'))
|
||||
.order_by('-scrobble_count')
|
||||
)
|
||||
|
||||
def charts(self):
|
||||
from scrobbles.models import ChartRecord
|
||||
|
||||
return ChartRecord.objects.filter(track__artist=self).order_by('-year')
|
||||
|
||||
|
||||
class Album(TimeStampedModel):
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
name = models.CharField(max_length=255)
|
||||
artists = models.ManyToManyField(Artist)
|
||||
year = models.IntegerField(**BNULL)
|
||||
musicbrainz_id = models.CharField(max_length=255, unique=True, **BNULL)
|
||||
musicbrainz_releasegroup_id = models.CharField(max_length=255, **BNULL)
|
||||
musicbrainz_albumartist_id = models.CharField(max_length=255, **BNULL)
|
||||
cover_image = models.ImageField(upload_to="albums/", **BNULL)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def primary_artist(self):
|
||||
return self.artists.first()
|
||||
|
||||
def fix_metadata(self):
|
||||
if not self.musicbrainz_albumartist_id or not self.year:
|
||||
musicbrainzngs.set_useragent('vrobbler', '0.3.0')
|
||||
mb_data = musicbrainzngs.get_release_by_id(
|
||||
self.musicbrainz_id, includes=['artists']
|
||||
)
|
||||
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
|
||||
|
||||
self.save(update_fields=['musicbrainz_albumartist_id', 'year'])
|
||||
|
||||
new_artist = Artist.objects.filter(
|
||||
musicbrainz_id=self.musicbrainz_albumartist_id
|
||||
).first()
|
||||
if self.musicbrainz_albumartist_id and new_artist:
|
||||
self.artists.add(new_artist)
|
||||
if not new_artist:
|
||||
for t in self.track_set.all():
|
||||
self.artists.add(t.artist)
|
||||
|
||||
def fetch_artwork(self, force=False):
|
||||
if not self.cover_image and not force:
|
||||
if self.musicbrainz_id:
|
||||
try:
|
||||
img_data = musicbrainzngs.get_image_front(
|
||||
self.musicbrainz_id
|
||||
)
|
||||
name = f"{self.name}_{self.uuid}.jpg"
|
||||
self.cover_image = ContentFile(img_data, name=name)
|
||||
logger.info(f'Setting image to {name}')
|
||||
except musicbrainzngs.ResponseError:
|
||||
logger.warning(
|
||||
f'No cover art found for {self.name} by release'
|
||||
)
|
||||
|
||||
if not self.cover_image and self.musicbrainz_releasegroup_id:
|
||||
try:
|
||||
img_data = musicbrainzngs.get_release_group_image_front(
|
||||
self.musicbrainz_releasegroup_id
|
||||
)
|
||||
name = f"{self.name}_{self.uuid}.jpg"
|
||||
self.cover_image = ContentFile(img_data, name=name)
|
||||
logger.info(f'Setting image to {name}')
|
||||
except musicbrainzngs.ResponseError:
|
||||
logger.warning(
|
||||
f'No cover art found for {self.name} by release group'
|
||||
)
|
||||
if not self.cover_image:
|
||||
logger.debug(
|
||||
f"No cover art found for release or release group for {self.name}, setting to default"
|
||||
)
|
||||
# TODO Get a placeholder image in here
|
||||
self.cover_image = 'default-image-replace-me'
|
||||
self.save()
|
||||
|
||||
@property
|
||||
def mb_link(self):
|
||||
return f"https://musicbrainz.org/release/{self.musicbrainz_id}"
|
||||
|
||||
|
||||
class Track(ScrobblableMixin):
|
||||
COMPLETION_PERCENT = getattr(settings, 'MUSIC_COMPLETION_PERCENT', 90)
|
||||
|
||||
class Opinion(models.IntegerChoices):
|
||||
DOWN = -1, 'Thumbs down'
|
||||
NEUTRAL = 0, 'No opinion'
|
||||
UP = 1, 'Thumbs up'
|
||||
|
||||
artist = models.ForeignKey(Artist, on_delete=models.DO_NOTHING)
|
||||
album = models.ForeignKey(Album, on_delete=models.DO_NOTHING, **BNULL)
|
||||
musicbrainz_id = models.CharField(max_length=255, **BNULL)
|
||||
|
||||
class Meta:
|
||||
unique_together = [['album', 'musicbrainz_id']]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.title} by {self.artist}"
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('music:track_detail', kwargs={'slug': self.uuid})
|
||||
|
||||
@property
|
||||
def mb_link(self):
|
||||
return f"https://musicbrainz.org/recording/{self.musicbrainz_id}"
|
||||
|
||||
@classmethod
|
||||
def find_or_create(
|
||||
cls, artist_dict: Dict, album_dict: Dict, track_dict: Dict
|
||||
) -> Optional["Track"]:
|
||||
"""Given a data dict from Jellyfin, does the heavy lifting of looking up
|
||||
the video and, if need, TV Series, creating both if they don't yet
|
||||
exist.
|
||||
|
||||
"""
|
||||
if not artist_dict.get('name') or not artist_dict.get(
|
||||
'musicbrainz_id'
|
||||
):
|
||||
logger.warning(
|
||||
f"No artist or artist musicbrainz ID found in message from source, not scrobbling"
|
||||
)
|
||||
return
|
||||
|
||||
artist, artist_created = Artist.objects.get_or_create(**artist_dict)
|
||||
if artist_created:
|
||||
logger.debug(f"Created new artist {artist}")
|
||||
else:
|
||||
logger.debug(f"Found album {artist}")
|
||||
|
||||
album, album_created = Album.objects.get_or_create(**album_dict)
|
||||
if album_created:
|
||||
logger.debug(f"Created new album {album}")
|
||||
else:
|
||||
logger.debug(f"Found album {album}")
|
||||
|
||||
album.fix_metadata()
|
||||
if not album.cover_image:
|
||||
album.fetch_artwork()
|
||||
|
||||
track_dict['album_id'] = getattr(album, "id", None)
|
||||
track_dict['artist_id'] = artist.id
|
||||
|
||||
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
|
||||
21
vrobbler/apps/music/urls.py
Normal file
21
vrobbler/apps/music/urls.py
Normal file
@ -0,0 +1,21 @@
|
||||
from django.urls import path
|
||||
from music import views
|
||||
|
||||
app_name = 'music'
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path('albums/', views.AlbumListView.as_view(), name='albums_list'),
|
||||
path("tracks/", views.TrackListView.as_view(), name='tracks_list'),
|
||||
path(
|
||||
'tracks/<slug:slug>/',
|
||||
views.TrackDetailView.as_view(),
|
||||
name='track_detail',
|
||||
),
|
||||
path('artists/', views.ArtistListView.as_view(), name='artist_list'),
|
||||
path(
|
||||
'artists/<slug:slug>/',
|
||||
views.ArtistDetailView.as_view(),
|
||||
name='artist_detail',
|
||||
),
|
||||
]
|
||||
33
vrobbler/apps/music/views.py
Normal file
33
vrobbler/apps/music/views.py
Normal file
@ -0,0 +1,33 @@
|
||||
from django.views import generic
|
||||
from music.models import Track, Artist, Album
|
||||
from scrobbles.stats import get_scrobble_count_qs
|
||||
|
||||
|
||||
class TrackListView(generic.ListView):
|
||||
model = Track
|
||||
|
||||
def get_queryset(self):
|
||||
return get_scrobble_count_qs(user=self.request.user).order_by(
|
||||
"-scrobble_count"
|
||||
)
|
||||
|
||||
|
||||
class TrackDetailView(generic.DetailView):
|
||||
model = Track
|
||||
slug_field = 'uuid'
|
||||
|
||||
|
||||
class ArtistListView(generic.ListView):
|
||||
model = Artist
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().order_by("name")
|
||||
|
||||
|
||||
class ArtistDetailView(generic.DetailView):
|
||||
model = Artist
|
||||
slug_field = 'uuid'
|
||||
|
||||
|
||||
class AlbumListView(generic.ListView):
|
||||
model = Album
|
||||
0
vrobbler/apps/podcasts/__init__.py
Normal file
0
vrobbler/apps/podcasts/__init__.py
Normal file
36
vrobbler/apps/podcasts/admin.py
Normal file
36
vrobbler/apps/podcasts/admin.py
Normal file
@ -0,0 +1,36 @@
|
||||
from django.contrib import admin
|
||||
from podcasts.models import Episode, Podcast, Producer
|
||||
from scrobbles.admin import ScrobbleInline
|
||||
|
||||
|
||||
@admin.register(Producer)
|
||||
class ProducerAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = ("name",)
|
||||
ordering = ("name",)
|
||||
|
||||
|
||||
@admin.register(Podcast)
|
||||
class PodcastAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = (
|
||||
"name",
|
||||
"producer",
|
||||
"active",
|
||||
)
|
||||
ordering = ("name",)
|
||||
|
||||
|
||||
@admin.register(Episode)
|
||||
class EpisodeAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = (
|
||||
"title",
|
||||
"podcast",
|
||||
"run_time",
|
||||
)
|
||||
list_filter = ("podcast",)
|
||||
ordering = ("-created",)
|
||||
inlines = [
|
||||
ScrobbleInline,
|
||||
]
|
||||
5
vrobbler/apps/podcasts/apps.py
Normal file
5
vrobbler/apps/podcasts/apps.py
Normal file
@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PodcastsConfig(AppConfig):
|
||||
name = 'podcasts'
|
||||
158
vrobbler/apps/podcasts/migrations/0001_initial.py
Normal file
158
vrobbler/apps/podcasts/migrations/0001_initial.py
Normal file
@ -0,0 +1,158 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-12 17:18
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django_extensions.db.fields
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Producer',
|
||||
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)),
|
||||
(
|
||||
'uuid',
|
||||
models.UUIDField(
|
||||
blank=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'get_latest_by': 'modified',
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Podcast',
|
||||
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)),
|
||||
(
|
||||
'uuid',
|
||||
models.UUIDField(
|
||||
blank=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
('active', models.BooleanField(default=True)),
|
||||
('url', models.URLField(blank=True, null=True)),
|
||||
(
|
||||
'producer',
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to='podcasts.producer',
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'get_latest_by': 'modified',
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Episode',
|
||||
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'
|
||||
),
|
||||
),
|
||||
('title', models.CharField(max_length=255)),
|
||||
(
|
||||
'uuid',
|
||||
models.UUIDField(
|
||||
blank=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
'mopidy_uri',
|
||||
models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
(
|
||||
'podcast',
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to='podcasts.producer',
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'get_latest_by': 'modified',
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,32 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-12 17:48
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('podcasts', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='episode',
|
||||
name='run_time',
|
||||
field=models.CharField(blank=True, max_length=8, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='episode',
|
||||
name='run_time_ticks',
|
||||
field=models.PositiveBigIntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='episode',
|
||||
name='podcast',
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to='podcasts.podcast',
|
||||
),
|
||||
),
|
||||
]
|
||||
18
vrobbler/apps/podcasts/migrations/0003_episode_pub_date.py
Normal file
18
vrobbler/apps/podcasts/migrations/0003_episode_pub_date.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-12 18:08
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('podcasts', '0002_episode_run_time_episode_run_time_ticks_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='episode',
|
||||
name='pub_date',
|
||||
field=models.DateField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
18
vrobbler/apps/podcasts/migrations/0004_episode_number.py
Normal file
18
vrobbler/apps/podcasts/migrations/0004_episode_number.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-12 18:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('podcasts', '0003_episode_pub_date'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='episode',
|
||||
name='number',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
0
vrobbler/apps/podcasts/migrations/__init__.py
Normal file
0
vrobbler/apps/podcasts/migrations/__init__.py
Normal file
88
vrobbler/apps/podcasts/models.py
Normal file
88
vrobbler/apps/podcasts/models.py
Normal file
@ -0,0 +1,88 @@
|
||||
import logging
|
||||
from typing import Dict, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
|
||||
from vrobbler.apps.scrobbles.mixins import ScrobblableMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
BNULL = {"blank": True, "null": True}
|
||||
|
||||
|
||||
class Producer(TimeStampedModel):
|
||||
name = models.CharField(max_length=255)
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name}"
|
||||
|
||||
|
||||
class Podcast(TimeStampedModel):
|
||||
name = models.CharField(max_length=255)
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
producer = models.ForeignKey(
|
||||
Producer, on_delete=models.DO_NOTHING, **BNULL
|
||||
)
|
||||
active = models.BooleanField(default=True)
|
||||
url = models.URLField(**BNULL)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name}"
|
||||
|
||||
|
||||
class Episode(ScrobblableMixin):
|
||||
COMPLETION_PERCENT = getattr(settings, 'PODCAST_COMPLETION_PERCENT', 90)
|
||||
|
||||
podcast = models.ForeignKey(Podcast, on_delete=models.DO_NOTHING)
|
||||
number = models.IntegerField(**BNULL)
|
||||
pub_date = models.DateField(**BNULL)
|
||||
mopidy_uri = models.CharField(max_length=255, **BNULL)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.title}"
|
||||
|
||||
@classmethod
|
||||
def find_or_create(
|
||||
cls, podcast_dict: Dict, producer_dict: Dict, episode_dict: Dict
|
||||
) -> Optional["Episode"]:
|
||||
"""Given a data dict from Mopidy, finds or creates a podcast and
|
||||
producer before saving the epsiode so it can be scrobbled.
|
||||
|
||||
"""
|
||||
if not podcast_dict.get('name'):
|
||||
logger.warning(f"No name from source for podcast, not scrobbling")
|
||||
return
|
||||
|
||||
producer = None
|
||||
if producer_dict.get('name'):
|
||||
producer, producer_created = Producer.objects.get_or_create(
|
||||
**producer_dict
|
||||
)
|
||||
if producer_created:
|
||||
logger.debug(f"Created new producer {producer}")
|
||||
else:
|
||||
logger.debug(f"Found producer {producer}")
|
||||
|
||||
if producer:
|
||||
podcast_dict["producer_id"] = producer.id
|
||||
podcast, podcast_created = Podcast.objects.get_or_create(
|
||||
**podcast_dict
|
||||
)
|
||||
if podcast_created:
|
||||
logger.debug(f"Created new podcast {podcast}")
|
||||
else:
|
||||
logger.debug(f"Found podcast {podcast}")
|
||||
|
||||
episode_dict['podcast_id'] = podcast.id
|
||||
|
||||
episode, created = cls.objects.get_or_create(**episode_dict)
|
||||
if created:
|
||||
logger.debug(f"Created new episode: {episode}")
|
||||
else:
|
||||
logger.debug(f"Found episode {episode}")
|
||||
|
||||
return episode
|
||||
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",)
|
||||
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
vrobbler/apps/profiles/migrations/__init__.py
Normal file
0
vrobbler/apps/profiles/migrations/__init__.py
Normal file
18
vrobbler/apps/profiles/models.py
Normal file
18
vrobbler/apps/profiles/models.py
Normal file
@ -0,0 +1,18 @@
|
||||
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
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class UserProfile(TimeStampedModel):
|
||||
user = models.OneToOneField(
|
||||
User, on_delete=models.CASCADE, related_name="profile"
|
||||
)
|
||||
timezone = models.CharField(
|
||||
max_length=255, choices=PRETTY_TIMEZONE_CHOICES
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f"User profile for {self.user}"
|
||||
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))
|
||||
)
|
||||
@ -3,17 +3,49 @@ from django.contrib import admin
|
||||
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')
|
||||
|
||||
|
||||
@admin.register(Scrobble)
|
||||
class ScrobbleAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "timestamp"
|
||||
list_display = (
|
||||
"video",
|
||||
"timestamp",
|
||||
"media_name",
|
||||
"media_type",
|
||||
"playback_percent",
|
||||
"source",
|
||||
"playback_position",
|
||||
"in_progress",
|
||||
"is_paused",
|
||||
"played_to_completion",
|
||||
)
|
||||
list_filter = ("video",)
|
||||
raw_id_fields = ('video', 'podcast_episode', 'track', 'sport_event')
|
||||
list_filter = ("is_paused", "in_progress", "source", "track__artist")
|
||||
ordering = ("-timestamp",)
|
||||
|
||||
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.site.register(Scrobble, ScrobbleAdmin)
|
||||
def media_type(self, obj):
|
||||
if obj.video:
|
||||
return "Video"
|
||||
if obj.track:
|
||||
return "Track"
|
||||
if obj.podcast_episode:
|
||||
return "Podcast"
|
||||
if obj.sport_event:
|
||||
return "Sport Event"
|
||||
|
||||
def playback_percent(self, obj):
|
||||
return obj.percent_played
|
||||
|
||||
2
vrobbler/apps/scrobbles/constants.py
Normal file
2
vrobbler/apps/scrobbles/constants.py
Normal file
@ -0,0 +1,2 @@
|
||||
JELLYFIN_VIDEO_ITEM_TYPES = ["Episode", "Movie"]
|
||||
JELLYFIN_AUDIO_ITEM_TYPES = ["Audio"]
|
||||
14
vrobbler/apps/scrobbles/forms.py
Normal file
14
vrobbler/apps/scrobbles/forms.py
Normal file
@ -0,0 +1,14 @@
|
||||
from django import forms
|
||||
|
||||
|
||||
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",
|
||||
}
|
||||
),
|
||||
)
|
||||
63
vrobbler/apps/scrobbles/imdb.py
Normal file
63
vrobbler/apps/scrobbles/imdb.py
Normal file
@ -0,0 +1,63 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
from django.utils import timezone
|
||||
|
||||
from imdb import Cinemagoer
|
||||
from videos.models import Video
|
||||
|
||||
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
|
||||
@ -0,0 +1,36 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-07 20:03
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('music', '0001_initial'),
|
||||
('videos', '0003_alter_video_run_time_ticks'),
|
||||
('scrobbles', '0005_alter_scrobble_playback_position_ticks'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='scrobble',
|
||||
name='track',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to='music.track',
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='scrobble',
|
||||
name='video',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to='videos.video',
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,25 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-12 17:48
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('podcasts', '0002_episode_run_time_episode_run_time_ticks_and_more'),
|
||||
('scrobbles', '0006_scrobble_track_alter_scrobble_video'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='scrobble',
|
||||
name='podcast_episode',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to='podcasts.episode',
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
17
vrobbler/apps/scrobbles/mixins.py
Normal file
17
vrobbler/apps/scrobbles/mixins.py
Normal file
@ -0,0 +1,17 @@
|
||||
from uuid import uuid4
|
||||
|
||||
from django.db import models
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
|
||||
BNULL = {"blank": True, "null": True}
|
||||
|
||||
|
||||
class ScrobblableMixin(TimeStampedModel):
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
title = models.CharField(max_length=255, **BNULL)
|
||||
run_time = models.CharField(max_length=8, **BNULL)
|
||||
run_time_ticks = models.PositiveBigIntegerField(**BNULL)
|
||||
# thumbs = models.IntegerField(default=Opinion.NEUTRAL, choices=Opinion.choices)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
@ -1,15 +1,110 @@
|
||||
import calendar
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
from music.models import Artist, Track
|
||||
from podcasts.models import Episode
|
||||
from scrobbles.utils import check_scrobble_for_finish
|
||||
from sports.models import SportEvent
|
||||
from videos.models import Series, Video
|
||||
from vrobbler.apps.profiles.utils import now_user_timezone
|
||||
|
||||
from videos.models import Video
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
User = get_user_model()
|
||||
BNULL = {"blank": True, "null": True}
|
||||
|
||||
|
||||
class ChartRecord(TimeStampedModel):
|
||||
"""Sort of like a materialized view for what we could dynamically generate,
|
||||
but would kill the DB as it gets larger. Collects time-based records
|
||||
generated by a cron-like archival job
|
||||
|
||||
1972 by Josh Rouse - #3 in 2023, January
|
||||
|
||||
"""
|
||||
|
||||
user = models.ForeignKey(User, on_delete=models.DO_NOTHING, **BNULL)
|
||||
rank = models.IntegerField()
|
||||
year = models.IntegerField(default=timezone.now().year)
|
||||
month = models.IntegerField(**BNULL)
|
||||
week = models.IntegerField(**BNULL)
|
||||
day = models.IntegerField(**BNULL)
|
||||
video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL)
|
||||
series = models.ForeignKey(Series, on_delete=models.DO_NOTHING, **BNULL)
|
||||
artist = models.ForeignKey(Artist, on_delete=models.DO_NOTHING, **BNULL)
|
||||
track = models.ForeignKey(Track, on_delete=models.DO_NOTHING, **BNULL)
|
||||
|
||||
@property
|
||||
def media_obj(self):
|
||||
media_obj = None
|
||||
if self.video:
|
||||
media_obj = self.video
|
||||
if self.track:
|
||||
media_obj = self.track
|
||||
return media_obj
|
||||
|
||||
@property
|
||||
def month_str(self) -> str:
|
||||
month_str = ""
|
||||
if self.month:
|
||||
month_str = calendar.month_name[self.month]
|
||||
return month_str
|
||||
|
||||
@property
|
||||
def day_str(self) -> str:
|
||||
day_str = ""
|
||||
if self.day:
|
||||
day_str = str(self.day)
|
||||
return day_str
|
||||
|
||||
@property
|
||||
def week_str(self) -> str:
|
||||
week_str = ""
|
||||
if self.week:
|
||||
week_str = str(self.week)
|
||||
return "Week " + week_str
|
||||
|
||||
@property
|
||||
def period(self) -> str:
|
||||
period = str(self.year)
|
||||
if self.month:
|
||||
period = " ".join([self.month_str, period])
|
||||
if self.week:
|
||||
period = " ".join([self.week_str, period])
|
||||
if self.day:
|
||||
period = " ".join([self.day_str, period])
|
||||
return period
|
||||
|
||||
@property
|
||||
def period_type(self) -> str:
|
||||
period = 'year'
|
||||
if self.month:
|
||||
period = 'month'
|
||||
if self.week:
|
||||
period = 'week'
|
||||
if self.day:
|
||||
period = 'day'
|
||||
return period
|
||||
|
||||
def __str__(self):
|
||||
return f"#{self.rank} in {self.period} - {self.media_obj}"
|
||||
|
||||
|
||||
class Scrobble(TimeStampedModel):
|
||||
video = models.ForeignKey(Video, on_delete=models.DO_NOTHING)
|
||||
uuid = models.UUIDField(editable=False, **BNULL)
|
||||
video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL)
|
||||
track = models.ForeignKey(Track, on_delete=models.DO_NOTHING, **BNULL)
|
||||
podcast_episode = models.ForeignKey(
|
||||
Episode, on_delete=models.DO_NOTHING, **BNULL
|
||||
)
|
||||
sport_event = models.ForeignKey(
|
||||
SportEvent, on_delete=models.DO_NOTHING, **BNULL
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
User, blank=True, null=True, on_delete=models.DO_NOTHING
|
||||
)
|
||||
@ -23,11 +118,256 @@ class Scrobble(TimeStampedModel):
|
||||
in_progress = models.BooleanField(default=True)
|
||||
scrobble_log = models.TextField(**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 percent_played(self) -> int:
|
||||
return int(
|
||||
(self.playback_position_ticks / self.video.run_time_ticks) * 100
|
||||
)
|
||||
if not self.media_obj.run_time_ticks:
|
||||
logger.warning(
|
||||
f"{self} has no run_time_ticks value, cannot show percent played"
|
||||
)
|
||||
return 100
|
||||
|
||||
playback_ticks = self.playback_position_ticks
|
||||
if not playback_ticks:
|
||||
playback_ticks = (timezone.now() - self.timestamp).seconds * 1000
|
||||
|
||||
if self.played_to_completion:
|
||||
return 100
|
||||
|
||||
percent = int((playback_ticks / self.media_obj.run_time_ticks) * 100)
|
||||
return percent
|
||||
|
||||
@property
|
||||
def media_obj(self):
|
||||
media_obj = None
|
||||
if self.video:
|
||||
media_obj = self.video
|
||||
if self.track:
|
||||
media_obj = self.track
|
||||
if self.podcast_episode:
|
||||
media_obj = self.podcast_episode
|
||||
if self.sport_event:
|
||||
media_obj = self.sport_event
|
||||
return media_obj
|
||||
|
||||
def __str__(self):
|
||||
return f"Scrobble of {self.video} {self.timestamp.year}-{self.timestamp.month}"
|
||||
timestamp = self.timestamp.strftime('%Y-%m-%d')
|
||||
return f"Scrobble of {self.media_obj} ({timestamp})"
|
||||
|
||||
@classmethod
|
||||
def create_or_update_for_video(
|
||||
cls, video: "Video", user_id: int, scrobble_data: dict
|
||||
) -> "Scrobble":
|
||||
scrobble_data['video_id'] = video.id
|
||||
|
||||
scrobble = (
|
||||
cls.objects.filter(
|
||||
video=video,
|
||||
user_id=user_id,
|
||||
)
|
||||
.order_by('-modified')
|
||||
.first()
|
||||
)
|
||||
if scrobble and scrobble.percent_played <= 100:
|
||||
logger.info(
|
||||
f"Found existing scrobble for video {video}, updating",
|
||||
{"scrobble_data": scrobble_data},
|
||||
)
|
||||
return cls.update(scrobble, scrobble_data)
|
||||
|
||||
logger.debug(
|
||||
f"No existing scrobble for video {video}, creating",
|
||||
{"scrobble_data": scrobble_data},
|
||||
)
|
||||
# If creating a new scrobble, we don't need status
|
||||
scrobble_data.pop('jellyfin_status')
|
||||
return cls.create(scrobble_data)
|
||||
|
||||
@classmethod
|
||||
def create_or_update_for_track(
|
||||
cls, track: "Track", user_id: int, scrobble_data: dict
|
||||
) -> "Scrobble":
|
||||
"""Look up any existing scrobbles for a track and compare
|
||||
the appropriate backoff time for music tracks to the setting
|
||||
so we can avoid duplicating scrobbles."""
|
||||
scrobble_data['track_id'] = track.id
|
||||
|
||||
scrobble = (
|
||||
cls.objects.filter(
|
||||
track=track,
|
||||
user_id=user_id,
|
||||
played_to_completion=False,
|
||||
)
|
||||
.order_by('-modified')
|
||||
.first()
|
||||
)
|
||||
if scrobble:
|
||||
logger.debug(
|
||||
f"Found existing scrobble for track {track}, updating",
|
||||
{"scrobble_data": scrobble_data},
|
||||
)
|
||||
return cls.update(scrobble, scrobble_data)
|
||||
|
||||
if 'jellyfin_status' in scrobble_data.keys():
|
||||
last_scrobble = Scrobble.objects.last()
|
||||
if (
|
||||
scrobble_data['timestamp'] - last_scrobble.timestamp
|
||||
).seconds <= 1:
|
||||
logger.warning('Jellyfin spammed us with duplicate updates')
|
||||
return last_scrobble
|
||||
|
||||
logger.debug(
|
||||
f"No existing scrobble for track {track}, creating",
|
||||
{"scrobble_data": scrobble_data},
|
||||
)
|
||||
# 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 create_or_update_for_podcast_episode(
|
||||
cls, episode: "Episode", user_id: int, scrobble_data: dict
|
||||
) -> "Scrobble":
|
||||
scrobble_data['podcast_episode_id'] = episode.id
|
||||
|
||||
scrobble = (
|
||||
cls.objects.filter(
|
||||
podcast_episode=episode,
|
||||
user_id=user_id,
|
||||
played_to_completion=False,
|
||||
)
|
||||
.order_by('-modified')
|
||||
.first()
|
||||
)
|
||||
if scrobble:
|
||||
logger.debug(
|
||||
f"Found existing scrobble for podcast {episode}, updating",
|
||||
{"scrobble_data": scrobble_data},
|
||||
)
|
||||
return cls.update(scrobble, scrobble_data)
|
||||
|
||||
logger.debug(
|
||||
f"No existing scrobble for podcast epsiode {episode}, creating",
|
||||
{"scrobble_data": scrobble_data},
|
||||
)
|
||||
# If creating a new scrobble, we don't need status
|
||||
scrobble_data.pop('mopidy_status')
|
||||
return cls.create(scrobble_data)
|
||||
|
||||
@classmethod
|
||||
def create_or_update_for_sport_event(
|
||||
cls, event: "SportEvent", user_id: int, scrobble_data: dict
|
||||
) -> "Scrobble":
|
||||
scrobble_data['sport_event_id'] = event.id
|
||||
scrobble = (
|
||||
cls.objects.filter(
|
||||
sport_event=event,
|
||||
user_id=user_id,
|
||||
played_to_completion=False,
|
||||
)
|
||||
.order_by('-modified')
|
||||
.first()
|
||||
)
|
||||
if scrobble:
|
||||
logger.debug(
|
||||
f"Found existing scrobble for sport event {event}, updating",
|
||||
{"scrobble_data": scrobble_data},
|
||||
)
|
||||
return cls.update(scrobble, scrobble_data)
|
||||
|
||||
logger.debug(
|
||||
f"No existing scrobble for sport event {event}, creating",
|
||||
{"scrobble_data": scrobble_data},
|
||||
)
|
||||
# If creating a new scrobble, we don't need status
|
||||
scrobble_data.pop('jellyfin_status')
|
||||
return cls.create(scrobble_data)
|
||||
|
||||
@classmethod
|
||||
def update(cls, scrobble: "Scrobble", scrobble_data: dict) -> "Scrobble":
|
||||
# Status is a field we get from Mopidy, which refuses to poll us
|
||||
scrobble_status = scrobble_data.pop('mopidy_status', None)
|
||||
if not scrobble_status:
|
||||
scrobble_status = scrobble_data.pop('jellyfin_status', None)
|
||||
|
||||
logger.debug(f"Scrobbling to {scrobble} with status {scrobble_status}")
|
||||
scrobble.update_ticks(scrobble_data)
|
||||
|
||||
# On stop, stop progress and send it to the check for completion
|
||||
if scrobble_status == "stopped":
|
||||
scrobble.stop()
|
||||
# On pause, set is_paused and stop scrobbling
|
||||
if scrobble_status == "paused":
|
||||
scrobble.pause()
|
||||
if scrobble_status == "resumed":
|
||||
scrobble.resume()
|
||||
|
||||
for key, value in scrobble_data.items():
|
||||
setattr(scrobble, key, value)
|
||||
scrobble.save()
|
||||
return scrobble
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
scrobble_data: dict,
|
||||
) -> "Scrobble":
|
||||
scrobble_data['scrobble_log'] = ""
|
||||
scrobble = cls.objects.create(
|
||||
**scrobble_data,
|
||||
)
|
||||
return scrobble
|
||||
|
||||
def stop(self, force_finish=False) -> None:
|
||||
if not self.in_progress:
|
||||
logger.warning("Scrobble already stopped")
|
||||
return
|
||||
self.in_progress = False
|
||||
self.save(update_fields=['in_progress'])
|
||||
check_scrobble_for_finish(self, force_finish)
|
||||
|
||||
def pause(self) -> None:
|
||||
print('Trying to pause it')
|
||||
if self.is_paused:
|
||||
logger.warning("Scrobble already paused")
|
||||
return
|
||||
self.is_paused = True
|
||||
self.save(update_fields=["is_paused"])
|
||||
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
|
||||
return self.save(update_fields=["is_paused", "in_progress"])
|
||||
logger.warning("Resume called but in progress or not paused")
|
||||
|
||||
def cancel(self) -> None:
|
||||
check_scrobble_for_finish(self, force_finish=True)
|
||||
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.debug(
|
||||
f"Updating scrobble ticks to {self.playback_position_ticks}"
|
||||
)
|
||||
self.save(
|
||||
update_fields=['playback_position_ticks', 'playback_position']
|
||||
)
|
||||
|
||||
203
vrobbler/apps/scrobbles/scrobblers.py
Normal file
203
vrobbler/apps/scrobbles/scrobblers.py
Normal file
@ -0,0 +1,203 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from dateutil.parser import parse
|
||||
from django.utils import timezone
|
||||
from music.constants import JELLYFIN_POST_KEYS
|
||||
from music.models import Track
|
||||
from podcasts.models import Episode
|
||||
from scrobbles.models import Scrobble
|
||||
from scrobbles.utils import convert_to_seconds, parse_mopidy_uri
|
||||
from videos.models import Video
|
||||
from sports.models import SportEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def mopidy_scrobble_podcast(
|
||||
data_dict: dict, user_id: Optional[int]
|
||||
) -> Scrobble:
|
||||
mopidy_uri = data_dict.get("mopidy_uri", "")
|
||||
parsed_data = parse_mopidy_uri(mopidy_uri)
|
||||
|
||||
producer_dict = {"name": data_dict.get("artist")}
|
||||
|
||||
podcast_name = data_dict.get("album")
|
||||
if not podcast_name:
|
||||
podcast_name = parsed_data.get("podcast_name")
|
||||
podcast_dict = {"name": podcast_name}
|
||||
|
||||
episode_name = parsed_data.get("episode_filename")
|
||||
episode_dict = {
|
||||
"title": episode_name,
|
||||
"run_time_ticks": data_dict.get("run_time_ticks"),
|
||||
"run_time": data_dict.get("run_time"),
|
||||
"number": parsed_data.get("episode_num"),
|
||||
"pub_date": parsed_data.get("pub_date"),
|
||||
"mopidy_uri": mopidy_uri,
|
||||
}
|
||||
|
||||
episode = Episode.find_or_create(podcast_dict, producer_dict, episode_dict)
|
||||
|
||||
# Now we run off a scrobble
|
||||
mopidy_data = {
|
||||
"user_id": user_id,
|
||||
"timestamp": timezone.now(),
|
||||
"playback_position_ticks": data_dict.get("playback_time_ticks"),
|
||||
"source": "Mopidy",
|
||||
"mopidy_status": data_dict.get("status"),
|
||||
}
|
||||
|
||||
scrobble = None
|
||||
if episode:
|
||||
scrobble = Scrobble.create_or_update_for_podcast_episode(
|
||||
episode, user_id, mopidy_data
|
||||
)
|
||||
return scrobble
|
||||
|
||||
|
||||
def mopidy_scrobble_track(
|
||||
data_dict: dict, user_id: Optional[int]
|
||||
) -> Optional[Scrobble]:
|
||||
artist_dict = {
|
||||
"name": data_dict.get("artist", None),
|
||||
"musicbrainz_id": data_dict.get("musicbrainz_artist_id", None),
|
||||
}
|
||||
|
||||
album_dict = {
|
||||
"name": data_dict.get("album"),
|
||||
"musicbrainz_id": data_dict.get("musicbrainz_album_id"),
|
||||
}
|
||||
|
||||
track_dict = {
|
||||
"title": data_dict.get("name"),
|
||||
"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
|
||||
mopidy_data = {
|
||||
"user_id": user_id,
|
||||
"timestamp": timezone.now(),
|
||||
"playback_position_ticks": data_dict.get("playback_time_ticks"),
|
||||
"source": "Mopidy",
|
||||
"mopidy_status": data_dict.get("status"),
|
||||
}
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
def create_jellyfin_scrobble_dict(data_dict: dict, user_id: int) -> dict:
|
||||
jellyfin_status = "resumed"
|
||||
if data_dict.get("IsPaused"):
|
||||
jellyfin_status = "paused"
|
||||
if data_dict.get("NotificationType") == 'PlaybackStop':
|
||||
jellyfin_status = "stopped"
|
||||
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"timestamp": parse(data_dict.get("UtcTimestamp")),
|
||||
"playback_position_ticks": data_dict.get("PlaybackPositionTicks", "")
|
||||
// 10000,
|
||||
"playback_position": data_dict.get("PlaybackPosition", ""),
|
||||
"source": data_dict.get("ClientName", "Vrobbler"),
|
||||
"source_id": data_dict.get('MediaSourceId'),
|
||||
"jellyfin_status": jellyfin_status,
|
||||
}
|
||||
|
||||
|
||||
def jellyfin_scrobble_track(
|
||||
data_dict: dict, user_id: Optional[int]
|
||||
) -> Optional[Scrobble]:
|
||||
|
||||
if not data_dict.get("Provider_musicbrainztrack", None):
|
||||
logger.error(
|
||||
"No MBrainz Track ID received. This is likely because all metadata is bad, not scrobbling"
|
||||
)
|
||||
return
|
||||
|
||||
artist_dict = {
|
||||
'name': data_dict.get(JELLYFIN_POST_KEYS["ARTIST_NAME"], None),
|
||||
'musicbrainz_id': data_dict.get(
|
||||
JELLYFIN_POST_KEYS["ARTIST_MB_ID"], None
|
||||
),
|
||||
}
|
||||
|
||||
album_dict = {
|
||||
"name": data_dict.get(JELLYFIN_POST_KEYS["ALBUM_NAME"], None),
|
||||
"musicbrainz_id": data_dict.get(JELLYFIN_POST_KEYS['ALBUM_MB_ID']),
|
||||
}
|
||||
|
||||
# Convert ticks from Jellyfin from microseconds to nanoseconds
|
||||
# Ain't nobody got time for nanoseconds
|
||||
track_dict = {
|
||||
"title": data_dict.get("Name", ""),
|
||||
"run_time_ticks": data_dict.get(
|
||||
JELLYFIN_POST_KEYS["RUN_TIME_TICKS"], None
|
||||
)
|
||||
// 10000,
|
||||
"run_time": convert_to_seconds(
|
||||
data_dict.get(JELLYFIN_POST_KEYS["RUN_TIME"], None)
|
||||
),
|
||||
}
|
||||
track = Track.find_or_create(artist_dict, album_dict, track_dict)
|
||||
|
||||
# Prefer Mopidy MD IDs to Jellyfin, so skip if we already have one
|
||||
if not track.musicbrainz_id:
|
||||
track.musicbrainz_id = data_dict.get(
|
||||
JELLYFIN_POST_KEYS["TRACK_MB_ID"], None
|
||||
)
|
||||
track.save()
|
||||
|
||||
scrobble_dict = create_jellyfin_scrobble_dict(data_dict, user_id)
|
||||
|
||||
return Scrobble.create_or_update_for_track(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 = create_jellyfin_scrobble_dict(data_dict, user_id)
|
||||
|
||||
return Scrobble.create_or_update_for_video(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 = create_jellyfin_scrobble_dict(data_dict, user_id)
|
||||
|
||||
return Scrobble.create_or_update_for_video(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 = create_jellyfin_scrobble_dict(data_dict, user_id)
|
||||
|
||||
return Scrobble.create_or_update_for_sport_event(
|
||||
event, user_id, scrobble_dict
|
||||
)
|
||||
115
vrobbler/apps/scrobbles/stats.py
Normal file
115
vrobbler/apps/scrobbles/stats.py
Normal file
@ -0,0 +1,115 @@
|
||||
import calendar
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
import pytz
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.db.models import Count, Q
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_start_end_dates_by_week(year, week, tz):
|
||||
d = datetime(year, 1, 1, tzinfo=tz)
|
||||
if d.weekday() <= 3:
|
||||
d = d - timedelta(d.weekday())
|
||||
else:
|
||||
d = d + timedelta(7 - d.weekday())
|
||||
dlt = timedelta(days=(week - 1) * 7)
|
||||
return d + dlt, d + dlt + timedelta(days=6)
|
||||
|
||||
|
||||
def get_scrobble_count_qs(
|
||||
year: Optional[int] = None,
|
||||
month: Optional[int] = None,
|
||||
week: Optional[int] = None,
|
||||
day: Optional[int] = None,
|
||||
user=None,
|
||||
model_str="Track",
|
||||
) -> dict[str, int]:
|
||||
|
||||
tz = settings.TIME_ZONE
|
||||
if user and user.is_authenticated:
|
||||
tz = pytz.timezone(user.profile.timezone)
|
||||
|
||||
data_model = apps.get_model(app_label='music', model_name='Track')
|
||||
if model_str == "Video":
|
||||
data_model = apps.get_model(app_label='videos', model_name='Video')
|
||||
if model_str == "SportEvent":
|
||||
data_model = apps.get_model(
|
||||
app_label='sports', model_name='SportEvent'
|
||||
)
|
||||
|
||||
base_qs = data_model.objects.filter(
|
||||
scrobble__user=user,
|
||||
scrobble__played_to_completion=True,
|
||||
)
|
||||
|
||||
# Returna all media items with scrobble count annotated
|
||||
if not year:
|
||||
return base_qs.annotate(scrobble_count=Count("scrobble")).order_by(
|
||||
"-scrobble_count"
|
||||
)
|
||||
|
||||
start = datetime(year, 1, 1, tzinfo=tz)
|
||||
end = datetime(year, 12, 31, tzinfo=tz)
|
||||
if month:
|
||||
end_day = calendar.monthrange(year, month)[1]
|
||||
start = datetime(year, month, 1, tzinfo=tz)
|
||||
end = datetime(year, month, end_day, tzinfo=tz)
|
||||
elif week:
|
||||
start, end = get_start_end_dates_by_week(year, week, tz)
|
||||
elif day and month:
|
||||
start = datetime(year, month, day, 0, 0, tzinfo=tz)
|
||||
end = datetime(year, month, day, 23, 59, tzinfo=tz)
|
||||
elif day and not month:
|
||||
logger.warning('Day provided with month, defaulting ot all-time')
|
||||
|
||||
date_filter = Q(
|
||||
scrobble__timestamp__gte=start, scrobble__timestamp__lte=end
|
||||
)
|
||||
|
||||
return (
|
||||
base_qs.annotate(
|
||||
scrobble_count=Count("scrobble", filter=Q(date_filter))
|
||||
)
|
||||
.filter(date_filter)
|
||||
.order_by("-scrobble_count")
|
||||
)
|
||||
|
||||
|
||||
def build_charts(
|
||||
year: Optional[int] = None,
|
||||
month: Optional[int] = None,
|
||||
week: Optional[int] = None,
|
||||
day: Optional[int] = None,
|
||||
user=None,
|
||||
model_str="Track",
|
||||
):
|
||||
ChartRecord = apps.get_model(
|
||||
app_label='scrobbles', model_name='ChartRecord'
|
||||
)
|
||||
results = get_scrobble_count_qs(year, month, week, day, user, model_str)
|
||||
unique_counts = list(set([result.scrobble_count for result in results]))
|
||||
unique_counts.sort(reverse=True)
|
||||
ranks = {}
|
||||
for rank, count in enumerate(unique_counts, start=1):
|
||||
ranks[count] = rank
|
||||
|
||||
chart_records = []
|
||||
for result in results:
|
||||
chart_record = {
|
||||
'year': year,
|
||||
'week': week,
|
||||
'month': month,
|
||||
'day': day,
|
||||
'user': user,
|
||||
}
|
||||
chart_record['rank'] = ranks[result.scrobble_count]
|
||||
if model_str == 'Track':
|
||||
chart_record['track'] = result
|
||||
chart_records.append(ChartRecord(**chart_record))
|
||||
ChartRecord.objects.bulk_create(chart_records, ignore_conflicts=True)
|
||||
50
vrobbler/apps/scrobbles/thesportsdb.py
Normal file
50
vrobbler/apps/scrobbles/thesportsdb.py
Normal file
@ -0,0 +1,50 @@
|
||||
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 = Sport.objects.filter(thesportsdb_id=event.get('strSport')).first()
|
||||
|
||||
logger.debug(event)
|
||||
data_dict = {
|
||||
"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
|
||||
@ -4,6 +4,9 @@ from scrobbles import views
|
||||
app_name = 'scrobbles'
|
||||
|
||||
urlpatterns = [
|
||||
path('', views.scrobble_endpoint, name='scrobble-list'),
|
||||
path('', views.scrobble_endpoint, name='api-list'),
|
||||
path('finish/<slug:uuid>', views.scrobble_finish, name='finish'),
|
||||
path('cancel/<slug:uuid>', views.scrobble_cancel, name='cancel'),
|
||||
path('jellyfin/', views.jellyfin_websocket, name='jellyfin-websocket'),
|
||||
path('mopidy/', views.mopidy_websocket, name='mopidy-websocket'),
|
||||
]
|
||||
|
||||
87
vrobbler/apps/scrobbles/utils.py
Normal file
87
vrobbler/apps/scrobbles/utils.py
Normal file
@ -0,0 +1,87 @@
|
||||
import logging
|
||||
from urllib.parse import unquote
|
||||
|
||||
from dateutil.parser import ParserError, parse
|
||||
from django.conf import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def convert_to_seconds(run_time: str) -> int:
|
||||
"""Jellyfin sends run time as 00:00:00 string. We want the run time to
|
||||
actually be in seconds so we'll convert it"
|
||||
|
||||
This is actually deprecated, as we now convert to seconds before saving.
|
||||
But for older videos, we'll leave this here.
|
||||
"""
|
||||
if ":" in str(run_time):
|
||||
run_time_list = run_time.split(":")
|
||||
hours = int(run_time_list[0])
|
||||
minutes = int(run_time_list[1])
|
||||
seconds = int(run_time_list[2])
|
||||
run_time = (((hours * 60) + minutes) * 60) + seconds
|
||||
return int(run_time)
|
||||
|
||||
|
||||
def parse_mopidy_uri(uri: str) -> dict:
|
||||
logger.debug(f"Parsing URI: {uri}")
|
||||
parsed_uri = uri.split('/')
|
||||
|
||||
episode_str = unquote(parsed_uri.pop(-1).strip(".mp3"))
|
||||
podcast_str = unquote(parsed_uri.pop(-1))
|
||||
possible_date_str = episode_str[0:10]
|
||||
|
||||
try:
|
||||
pub_date = parse(possible_date_str)
|
||||
except ParserError:
|
||||
pub_date = ""
|
||||
logger.debug(f"Found pub date {pub_date} from Mopidy URI")
|
||||
|
||||
try:
|
||||
if pub_date:
|
||||
episode_num = int(episode_str.split('-')[3])
|
||||
else:
|
||||
episode_num = int(episode_str.split('-')[0])
|
||||
except IndexError:
|
||||
episode_num = None
|
||||
except ValueError:
|
||||
episode_num = None
|
||||
logger.debug(f"Found episode num {episode_num} from Mopidy URI")
|
||||
|
||||
if pub_date:
|
||||
episode_str = episode_str.strip(episode_str[:11])
|
||||
|
||||
if type(episode_num) is int:
|
||||
episode_num_gap = len(str(episode_num)) + 1
|
||||
episode_str = episode_str.strip(episode_str[:episode_num_gap])
|
||||
|
||||
episode_str = episode_str.replace('-', ' ')
|
||||
logger.debug(f"Found episode name {episode_str} from Mopidy URI")
|
||||
|
||||
return {
|
||||
'episode_filename': episode_str,
|
||||
'episode_num': episode_num,
|
||||
'podcast_name': podcast_str,
|
||||
'pub_date': pub_date,
|
||||
}
|
||||
|
||||
|
||||
def check_scrobble_for_finish(
|
||||
scrobble: "Scrobble", force_finish=False
|
||||
) -> None:
|
||||
completion_percent = scrobble.media_obj.COMPLETION_PERCENT
|
||||
|
||||
if scrobble.percent_played >= completion_percent or force_finish:
|
||||
logger.debug(f"Completion percent {completion_percent} met, finishing")
|
||||
|
||||
scrobble.in_progress = False
|
||||
scrobble.is_paused = False
|
||||
scrobble.played_to_completion = True
|
||||
scrobble.save(
|
||||
update_fields=["in_progress", "is_paused", "played_to_completion"]
|
||||
)
|
||||
|
||||
if scrobble.percent_played % 5 == 0:
|
||||
if getattr(settings, "KEEP_DETAILED_SCROBBLE_LOGS", False):
|
||||
scrobble.scrobble_log += f"\n{str(scrobble.timestamp)} - {scrobble.playback_position} - {str(scrobble.playback_position_ticks)} - {str(scrobble.percent_played)}%"
|
||||
scrobble.save(update_fields=['scrobble_log'])
|
||||
@ -1,56 +1,124 @@
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from dateutil.parser import parse
|
||||
import pytz
|
||||
from django.conf import settings
|
||||
from django.db.models.fields import timezone
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.generic import FormView
|
||||
from django.views.generic.list import ListView
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import api_view
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
from scrobbles.constants import (
|
||||
JELLYFIN_AUDIO_ITEM_TYPES,
|
||||
JELLYFIN_VIDEO_ITEM_TYPES,
|
||||
)
|
||||
from scrobbles.forms import ScrobbleForm
|
||||
from scrobbles.imdb import lookup_video_from_imdb
|
||||
from scrobbles.models import Scrobble
|
||||
from scrobbles.scrobblers import (
|
||||
jellyfin_scrobble_track,
|
||||
jellyfin_scrobble_video,
|
||||
manual_scrobble_event,
|
||||
manual_scrobble_video,
|
||||
mopidy_scrobble_podcast,
|
||||
mopidy_scrobble_track,
|
||||
)
|
||||
from scrobbles.serializers import ScrobbleSerializer
|
||||
from videos.models import Series, Video
|
||||
from vrobbler.settings import DELETE_STALE_SCROBBLES
|
||||
from django.utils import timezone
|
||||
from scrobbles.thesportsdb import lookup_event_from_thesportsdb
|
||||
|
||||
from vrobbler.apps.music.aggregators import (
|
||||
scrobble_counts,
|
||||
top_artists,
|
||||
top_tracks,
|
||||
week_of_scrobbles,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TRUTHY_VALUES = [
|
||||
'true',
|
||||
'1',
|
||||
't',
|
||||
'y',
|
||||
'yes',
|
||||
'yeah',
|
||||
'yup',
|
||||
'certainly',
|
||||
'uh-huh',
|
||||
]
|
||||
|
||||
|
||||
class RecentScrobbleList(ListView):
|
||||
model = Scrobble
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
data = super().get_context_data(**kwargs)
|
||||
user = self.request.user
|
||||
now = timezone.now()
|
||||
last_ten_minutes = timezone.now() - timedelta(minutes=10)
|
||||
# Find scrobbles from the last 10 minutes
|
||||
data['now_playing_list'] = Scrobble.objects.filter(
|
||||
in_progress=True,
|
||||
timestamp__gte=last_ten_minutes,
|
||||
timestamp__lte=now,
|
||||
)
|
||||
if user.is_authenticated:
|
||||
if user.profile:
|
||||
timezone.activate(pytz.timezone(user.profile.timezone))
|
||||
now = timezone.localtime(timezone.now())
|
||||
data['now_playing_list'] = Scrobble.objects.filter(
|
||||
in_progress=True,
|
||||
is_paused=False,
|
||||
timestamp__lte=now,
|
||||
user=user,
|
||||
)
|
||||
|
||||
completed_for_user = Scrobble.objects.filter(
|
||||
played_to_completion=True, user=user
|
||||
)
|
||||
data['video_scrobble_list'] = completed_for_user.filter(
|
||||
video__isnull=False
|
||||
).order_by('-timestamp')[:15]
|
||||
|
||||
data['podcast_scrobble_list'] = completed_for_user.filter(
|
||||
podcast_episode__isnull=False
|
||||
).order_by('-timestamp')[:15]
|
||||
|
||||
data['sport_scrobble_list'] = completed_for_user.filter(
|
||||
sport_event__isnull=False
|
||||
).order_by('-timestamp')[:15]
|
||||
|
||||
# data['top_daily_tracks'] = top_tracks()
|
||||
data['top_weekly_tracks'] = top_tracks(user, filter='week')
|
||||
data['top_monthly_tracks'] = top_tracks(user, filter='month')
|
||||
|
||||
# data['top_daily_artists'] = top_artists()
|
||||
data['top_weekly_artists'] = top_artists(user, filter='week')
|
||||
data['top_monthly_artists'] = top_artists(user, filter='month')
|
||||
|
||||
data["weekly_data"] = week_of_scrobbles(user=user)
|
||||
|
||||
data['counts'] = scrobble_counts(user)
|
||||
data['imdb_form'] = ScrobbleForm
|
||||
return data
|
||||
|
||||
def get_queryset(self):
|
||||
return Scrobble.objects.filter(in_progress=False).order_by(
|
||||
'-timestamp'
|
||||
)
|
||||
return Scrobble.objects.filter(
|
||||
track__isnull=False, in_progress=False
|
||||
).order_by('-timestamp')[:15]
|
||||
|
||||
|
||||
class ManualScrobbleView(FormView):
|
||||
form_class = ScrobbleForm
|
||||
template_name = 'scrobbles/manual_form.html'
|
||||
|
||||
def form_valid(self, form):
|
||||
|
||||
item_id = form.cleaned_data.get('item_id')
|
||||
data_dict = None
|
||||
if 'tt' in item_id:
|
||||
data_dict = lookup_video_from_imdb(
|
||||
form.cleaned_data.get('item_id')
|
||||
)
|
||||
if data_dict:
|
||||
manual_scrobble_video(data_dict, self.request.user.id)
|
||||
|
||||
if not data_dict:
|
||||
logger.debug(f"Looking for sport event with ID {item_id}")
|
||||
data_dict = lookup_event_from_thesportsdb(
|
||||
form.cleaned_data.get('item_id')
|
||||
)
|
||||
if data_dict:
|
||||
manual_scrobble_event(data_dict, self.request.user.id)
|
||||
|
||||
return HttpResponseRedirect(reverse("home"))
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@ -63,125 +131,87 @@ def scrobble_endpoint(request):
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@permission_classes([IsAuthenticated])
|
||||
@api_view(['POST'])
|
||||
def jellyfin_websocket(request):
|
||||
data_dict = request.data
|
||||
media_type = data_dict["ItemType"]
|
||||
imdb_id = data_dict.get("Provider_imdb", None)
|
||||
if not imdb_id:
|
||||
logger.error(
|
||||
"No IMDB ID received. This is likely because all metadata is bad, not scrobbling"
|
||||
)
|
||||
|
||||
# For making things easier to build new input processors
|
||||
if getattr(settings, "DUMP_REQUEST_DATA", False):
|
||||
json_data = json.dumps(data_dict, indent=4)
|
||||
logger.debug(f"{json_data}")
|
||||
|
||||
scrobble = None
|
||||
media_type = data_dict.get("ItemType", "")
|
||||
|
||||
if media_type in JELLYFIN_AUDIO_ITEM_TYPES:
|
||||
scrobble = jellyfin_scrobble_track(data_dict, request.user.id)
|
||||
|
||||
if media_type in JELLYFIN_VIDEO_ITEM_TYPES:
|
||||
scrobble = jellyfin_scrobble_video(data_dict, request.user.id)
|
||||
|
||||
if not scrobble:
|
||||
return Response({}, status=status.HTTP_400_BAD_REQUEST)
|
||||
# Check if it's a TV Episode
|
||||
video_dict = {
|
||||
"title": data_dict.get("Name", ""),
|
||||
"imdb_id": imdb_id,
|
||||
"video_type": Video.VideoType.MOVIE,
|
||||
"year": data_dict.get("Year", ""),
|
||||
}
|
||||
if media_type == 'Episode':
|
||||
series_name = data_dict["SeriesName"]
|
||||
series, series_created = Series.objects.get_or_create(name=series_name)
|
||||
|
||||
video_dict['video_type'] = Video.VideoType.TV_EPISODE
|
||||
video_dict["tv_series_id"] = series.id
|
||||
video_dict["episode_number"] = data_dict.get("EpisodeNumber", "")
|
||||
video_dict["season_number"] = data_dict.get("SeasonNumber", "")
|
||||
video_dict["tvdb_id"] = data_dict.get("Provider_tvdb", None)
|
||||
video_dict["tvrage_id"] = data_dict.get("Provider_tvrage", None)
|
||||
return Response({'scrobble_id': scrobble.id}, status=status.HTTP_200_OK)
|
||||
|
||||
video, video_created = Video.objects.get_or_create(**video_dict)
|
||||
|
||||
if video_created:
|
||||
video.overview = data_dict["Overview"]
|
||||
video.tagline = data_dict["Tagline"]
|
||||
video.run_time_ticks = data_dict["RunTimeTicks"]
|
||||
video.run_time = data_dict["RunTime"]
|
||||
video.save()
|
||||
@csrf_exempt
|
||||
@permission_classes([IsAuthenticated])
|
||||
@api_view(['POST'])
|
||||
def mopidy_websocket(request):
|
||||
try:
|
||||
data_dict = json.loads(request.data)
|
||||
except TypeError:
|
||||
logger.warning('Received Mopidy data as dict, rather than a string')
|
||||
data_dict = request.data
|
||||
|
||||
# Now we run off a scrobble
|
||||
timestamp = parse(data_dict["UtcTimestamp"])
|
||||
scrobble_dict = {
|
||||
'video_id': video.id,
|
||||
'user_id': request.user.id,
|
||||
'in_progress': True,
|
||||
}
|
||||
# For making things easier to build new input processors
|
||||
if getattr(settings, "DUMP_REQUEST_DATA", False):
|
||||
json_data = json.dumps(data_dict, indent=4)
|
||||
logger.debug(f"{json_data}")
|
||||
|
||||
existing_scrobble = (
|
||||
Scrobble.objects.filter(video=video, user_id=request.user.id)
|
||||
.order_by('-modified')
|
||||
.first()
|
||||
)
|
||||
|
||||
minutes_from_now = timezone.now() + timedelta(minutes=15)
|
||||
a_day_from_now = timezone.now() + timedelta(days=1)
|
||||
|
||||
existing_finished_scrobble = (
|
||||
existing_scrobble
|
||||
and not existing_scrobble.in_progress
|
||||
and existing_scrobble.modified < minutes_from_now
|
||||
)
|
||||
existing_in_progress_scrobble = (
|
||||
existing_scrobble
|
||||
and existing_scrobble.in_progress
|
||||
and existing_scrobble.modified > a_day_from_now
|
||||
)
|
||||
delete_stale_scrobbles = getattr(settings, "DELETE_STALE_SCROBBLES", True)
|
||||
|
||||
if existing_finished_scrobble:
|
||||
logger.info(
|
||||
'Found a scrobble for this video less than 15 minutes ago, holding off scrobbling again'
|
||||
)
|
||||
return Response(video_dict, status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
# Check if found in progress scrobble is more than a day old
|
||||
if existing_in_progress_scrobble:
|
||||
logger.info(
|
||||
'Found a scrobble for this video more than a day old, creating a new scrobble'
|
||||
)
|
||||
scrobble = existing_in_progress_scrobble
|
||||
scrobble_created = False
|
||||
if 'podcast' in data_dict.get('mopidy_uri'):
|
||||
scrobble = mopidy_scrobble_podcast(data_dict, request.user.id)
|
||||
else:
|
||||
if existing_in_progress_scrobble and delete_stale_scrobbles:
|
||||
existing_in_progress_scrobble.delete()
|
||||
scrobble, scrobble_created = Scrobble.objects.get_or_create(
|
||||
**scrobble_dict
|
||||
)
|
||||
scrobble = mopidy_scrobble_track(data_dict, request.user.id)
|
||||
|
||||
if scrobble_created:
|
||||
# If we newly created this, capture the client we're watching from
|
||||
scrobble.source = data_dict['ClientName']
|
||||
scrobble.source_id = data_dict['MediaSourceId']
|
||||
scrobble.scrobble_log = ""
|
||||
if not scrobble:
|
||||
return Response({}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Update a found scrobble with new position and timestamp
|
||||
scrobble.playback_position_ticks = data_dict["PlaybackPositionTicks"]
|
||||
scrobble.playback_position = data_dict["PlaybackPosition"]
|
||||
scrobble.timestamp = parse(data_dict["UtcTimestamp"])
|
||||
scrobble.is_paused = data_dict["IsPaused"] in TRUTHY_VALUES
|
||||
scrobble.save(
|
||||
update_fields=[
|
||||
'playback_position_ticks',
|
||||
'playback_position',
|
||||
'timestamp',
|
||||
'is_paused',
|
||||
]
|
||||
return Response({'scrobble_id': scrobble.id}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@permission_classes([IsAuthenticated])
|
||||
@api_view(['GET'])
|
||||
def scrobble_finish(request, uuid):
|
||||
user = request.user
|
||||
if not user.is_authenticated:
|
||||
return Response({}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
scrobble = Scrobble.objects.filter(user=user, uuid=uuid).first()
|
||||
if not scrobble:
|
||||
return Response({}, status=status.HTTP_404_NOT_FOUND)
|
||||
scrobble.stop(force_finish=True)
|
||||
return Response(
|
||||
{'id': scrobble.id, 'status': scrobble.status},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
# If we hit our completion threshold, save it and get ready
|
||||
# to scrobble again if we re-watch this.
|
||||
if scrobble.percent_played >= getattr(
|
||||
settings, "PERCENT_FOR_COMPLETION", 95
|
||||
):
|
||||
scrobble.in_progress = False
|
||||
scrobble.playback_position_ticks = video.run_time_ticks
|
||||
scrobble.save()
|
||||
|
||||
if scrobble.percent_played % 5 == 0:
|
||||
if getattr(settings, "KEEP_DETAILED_SCROBBLE_LOGS", False):
|
||||
scrobble.scrobble_log += f"\n{str(scrobble.timestamp)} - {scrobble.playback_position} - {str(scrobble.playback_position_ticks)} - {str(scrobble.percent_played)}%"
|
||||
scrobble.save(update_fields=['scrobble_log'])
|
||||
logger.debug(f"You are {scrobble.percent_played}% through {video}")
|
||||
@csrf_exempt
|
||||
@permission_classes([IsAuthenticated])
|
||||
@api_view(['GET'])
|
||||
def scrobble_cancel(request, uuid):
|
||||
user = request.user
|
||||
if not user.is_authenticated:
|
||||
return Response({}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
return Response(video_dict, status=status.HTTP_201_CREATED)
|
||||
scrobble = Scrobble.objects.filter(user=user, uuid=uuid).first()
|
||||
if not scrobble:
|
||||
return Response({}, status=status.HTTP_404_NOT_FOUND)
|
||||
scrobble.cancel()
|
||||
return Response(
|
||||
{'id': scrobble.id, 'status': 'cancelled'}, status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
0
vrobbler/apps/sports/__init__.py
Normal file
0
vrobbler/apps/sports/__init__.py
Normal file
77
vrobbler/apps/sports/admin.py
Normal file
77
vrobbler/apps/sports/admin.py
Normal file
@ -0,0 +1,77 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from sports.models import (
|
||||
League,
|
||||
Player,
|
||||
Round,
|
||||
Season,
|
||||
Sport,
|
||||
SportEvent,
|
||||
Team,
|
||||
)
|
||||
|
||||
from scrobbles.admin import ScrobbleInline
|
||||
|
||||
|
||||
@admin.register(Sport)
|
||||
class SportAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
ordering = ("name",)
|
||||
|
||||
|
||||
@admin.register(League)
|
||||
class LeagueAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = ("name", "abbreviation_str")
|
||||
ordering = ("name",)
|
||||
|
||||
|
||||
@admin.register(Player)
|
||||
class PlayerAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = ("name", "league", "team")
|
||||
ordering = ("name",)
|
||||
|
||||
|
||||
@admin.register(Season)
|
||||
class SeasonAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = ("name", "league")
|
||||
ordering = ("name",)
|
||||
|
||||
|
||||
@admin.register(Round)
|
||||
class RoundAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = ("name", "season")
|
||||
ordering = ("name",)
|
||||
|
||||
|
||||
@admin.register(Team)
|
||||
class TeamAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = ("name", "league")
|
||||
ordering = ("name",)
|
||||
|
||||
|
||||
@admin.register(SportEvent)
|
||||
class SportEventAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = (
|
||||
"title",
|
||||
"event_type",
|
||||
"start",
|
||||
"comp_str",
|
||||
"round",
|
||||
)
|
||||
list_filter = ("round__season", "home_team", "away_team")
|
||||
ordering = ("-created",)
|
||||
inlines = [
|
||||
ScrobbleInline,
|
||||
]
|
||||
|
||||
def comp_str(self, obj):
|
||||
if obj.home_team:
|
||||
return f'{obj.away_team} @ {obj.home_team}'
|
||||
if obj.player_one:
|
||||
return f'{obj.player_one} v {obj.player_two}'
|
||||
5
vrobbler/apps/sports/apps.py
Normal file
5
vrobbler/apps/sports/apps.py
Normal file
@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class SportsConfig(AppConfig):
|
||||
name = "sports"
|
||||
214
vrobbler/apps/sports/migrations/0001_initial.py
Normal file
214
vrobbler/apps/sports/migrations/0001_initial.py
Normal file
@ -0,0 +1,214 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-14 21:14
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django_extensions.db.fields
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='League',
|
||||
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)),
|
||||
(
|
||||
'uuid',
|
||||
models.UUIDField(
|
||||
blank=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
'logo',
|
||||
models.ImageField(
|
||||
blank=True, null=True, upload_to='sports/league-logos/'
|
||||
),
|
||||
),
|
||||
(
|
||||
'abbreviation_str',
|
||||
models.CharField(blank=True, max_length=10, null=True),
|
||||
),
|
||||
('thesportsdb_id', models.IntegerField(blank=True, null=True)),
|
||||
],
|
||||
options={
|
||||
'get_latest_by': 'modified',
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Team',
|
||||
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)),
|
||||
(
|
||||
'uuid',
|
||||
models.UUIDField(
|
||||
blank=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
('thesportsdb_id', models.IntegerField(blank=True, null=True)),
|
||||
(
|
||||
'league',
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to='sports.league',
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'get_latest_by': 'modified',
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SportEvent',
|
||||
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,
|
||||
),
|
||||
),
|
||||
(
|
||||
'title',
|
||||
models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
(
|
||||
'run_time',
|
||||
models.CharField(blank=True, max_length=8, null=True),
|
||||
),
|
||||
(
|
||||
'run_time_ticks',
|
||||
models.PositiveBigIntegerField(blank=True, null=True),
|
||||
),
|
||||
(
|
||||
'event_type',
|
||||
models.CharField(
|
||||
choices=[
|
||||
('UK', 'Unknown'),
|
||||
('GA', 'Game'),
|
||||
('MA', 'Match'),
|
||||
('ME', 'Meet'),
|
||||
],
|
||||
default='UK',
|
||||
max_length=2,
|
||||
),
|
||||
),
|
||||
('start_utc', models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
'season',
|
||||
models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
(
|
||||
'away_team',
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name='away_event_set',
|
||||
to='sports.team',
|
||||
),
|
||||
),
|
||||
(
|
||||
'home_team',
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name='home_event_set',
|
||||
to='sports.team',
|
||||
),
|
||||
),
|
||||
(
|
||||
'league',
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to='sports.league',
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-14 21:17
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('sports', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='sportevent',
|
||||
old_name='start_utc',
|
||||
new_name='start',
|
||||
),
|
||||
]
|
||||
71
vrobbler/apps/sports/migrations/0003_sport_league_sport.py
Normal file
71
vrobbler/apps/sports/migrations/0003_sport_league_sport.py
Normal file
@ -0,0 +1,71 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-21 04:21
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django_extensions.db.fields
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('sports', '0002_rename_start_utc_sportevent_start'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Sport',
|
||||
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)),
|
||||
(
|
||||
'uuid',
|
||||
models.UUIDField(
|
||||
blank=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
('thesportsdb_id', models.IntegerField(blank=True, null=True)),
|
||||
(
|
||||
'default_event_run_time',
|
||||
models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'get_latest_by': 'modified',
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='league',
|
||||
name='sport',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to='sports.sport',
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,254 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-22 20:20
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django_extensions.db.fields
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('sports', '0003_sport_league_sport'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='league',
|
||||
options={},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='sport',
|
||||
options={},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='team',
|
||||
options={},
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='sportevent',
|
||||
name='league',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='league',
|
||||
name='thesportsdb_id',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='sport',
|
||||
name='thesportsdb_id',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='team',
|
||||
name='thesportsdb_id',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Season',
|
||||
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)),
|
||||
(
|
||||
'uuid',
|
||||
models.UUIDField(
|
||||
blank=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
'thesportsdb_id',
|
||||
models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
(
|
||||
'league',
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to='sports.league',
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Round',
|
||||
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)),
|
||||
(
|
||||
'uuid',
|
||||
models.UUIDField(
|
||||
blank=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
'thesportsdb_id',
|
||||
models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
(
|
||||
'season',
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to='sports.season',
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Player',
|
||||
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)),
|
||||
(
|
||||
'uuid',
|
||||
models.UUIDField(
|
||||
blank=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
'thesportsdb_id',
|
||||
models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
(
|
||||
'league',
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to='sports.league',
|
||||
),
|
||||
),
|
||||
(
|
||||
'team',
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to='sports.team',
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sportevent',
|
||||
name='player_one',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name='player_one_set',
|
||||
to='sports.player',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sportevent',
|
||||
name='player_two',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name='player_two_set',
|
||||
to='sports.player',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sportevent',
|
||||
name='round',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to='sports.round',
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='sportevent',
|
||||
name='season',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to='sports.season',
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,17 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-22 20:34
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('sports', '0004_alter_league_options_alter_sport_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='sportevent',
|
||||
name='season',
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,22 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-22 21:48
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('sports', '0005_remove_sportevent_season'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='sportevent',
|
||||
name='event_type',
|
||||
field=models.CharField(
|
||||
choices=[('UK', 'Unknown'), ('GA', 'Game'), ('MA', 'Match')],
|
||||
default='UK',
|
||||
max_length=2,
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,22 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-22 22:11
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('sports', '0006_alter_sportevent_event_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='sport',
|
||||
name='default_event_type',
|
||||
field=models.CharField(
|
||||
choices=[('UK', 'Unknown'), ('GA', 'Game'), ('MA', 'Match')],
|
||||
default='UK',
|
||||
max_length=2,
|
||||
),
|
||||
),
|
||||
]
|
||||
0
vrobbler/apps/sports/migrations/__init__.py
Normal file
0
vrobbler/apps/sports/migrations/__init__.py
Normal file
223
vrobbler/apps/sports/models.py
Normal file
223
vrobbler/apps/sports/models.py
Normal file
@ -0,0 +1,223 @@
|
||||
import logging
|
||||
from typing import Dict
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
from sqlalchemy import update
|
||||
from scrobbles.mixins import ScrobblableMixin
|
||||
from vrobbler.apps.sports.utils import (
|
||||
get_players_from_event,
|
||||
get_round_name_from_event,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
BNULL = {"blank": True, "null": True}
|
||||
|
||||
|
||||
class SportEventType(models.TextChoices):
|
||||
UNKNOWN = 'UK', _('Unknown')
|
||||
GAME = 'GA', _('Game')
|
||||
MATCH = 'MA', _('Match')
|
||||
|
||||
|
||||
class TheSportsDbMixin(TimeStampedModel):
|
||||
name = models.CharField(max_length=255)
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
thesportsdb_id = models.CharField(max_length=255, **BNULL)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Sport(TheSportsDbMixin):
|
||||
default_event_run_time = models.IntegerField(**BNULL)
|
||||
default_event_type = models.CharField(
|
||||
max_length=2,
|
||||
choices=SportEventType.choices,
|
||||
default=SportEventType.UNKNOWN,
|
||||
)
|
||||
|
||||
# TODO Add these to the default run_time for Football
|
||||
# run_time_seconds = 11700
|
||||
# run_time_ticks = run_time_seconds * 1000
|
||||
@property
|
||||
def default_event_run_time_ticks(self):
|
||||
return self.default_event_run_time * 1000
|
||||
|
||||
|
||||
class League(TheSportsDbMixin):
|
||||
logo = models.ImageField(upload_to="sports/league-logos/", **BNULL)
|
||||
abbreviation_str = models.CharField(max_length=10, **BNULL)
|
||||
sport = models.ForeignKey(Sport, on_delete=models.DO_NOTHING, **BNULL)
|
||||
|
||||
@property
|
||||
def abbreviation(self):
|
||||
return self.abbreviation_str
|
||||
|
||||
|
||||
class Season(TheSportsDbMixin):
|
||||
league = models.ForeignKey(League, on_delete=models.DO_NOTHING, **BNULL)
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.name} season of {self.league}'
|
||||
|
||||
|
||||
class Team(TheSportsDbMixin):
|
||||
league = models.ForeignKey(League, on_delete=models.DO_NOTHING, **BNULL)
|
||||
|
||||
|
||||
class Player(TheSportsDbMixin):
|
||||
league = models.ForeignKey(League, on_delete=models.DO_NOTHING, **BNULL)
|
||||
team = models.ForeignKey(Team, on_delete=models.DO_NOTHING, **BNULL)
|
||||
|
||||
|
||||
class Round(TheSportsDbMixin):
|
||||
season = models.ForeignKey(Season, on_delete=models.DO_NOTHING, **BNULL)
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.name} of {self.season}'
|
||||
|
||||
|
||||
class SportEvent(ScrobblableMixin):
|
||||
COMPLETION_PERCENT = getattr(settings, 'SPORT_COMPLETION_PERCENT', 90)
|
||||
|
||||
event_type = models.CharField(
|
||||
max_length=2,
|
||||
choices=SportEventType.choices,
|
||||
default=SportEventType.UNKNOWN,
|
||||
)
|
||||
round = models.ForeignKey(Round, on_delete=models.DO_NOTHING, **BNULL)
|
||||
start = models.DateTimeField(**BNULL)
|
||||
home_team = models.ForeignKey(
|
||||
Team,
|
||||
on_delete=models.DO_NOTHING,
|
||||
related_name='home_event_set',
|
||||
**BNULL,
|
||||
)
|
||||
away_team = models.ForeignKey(
|
||||
Team,
|
||||
on_delete=models.DO_NOTHING,
|
||||
related_name='away_event_set',
|
||||
**BNULL,
|
||||
)
|
||||
player_one = models.ForeignKey(
|
||||
Player,
|
||||
on_delete=models.DO_NOTHING,
|
||||
related_name='player_one_set',
|
||||
**BNULL,
|
||||
)
|
||||
player_two = models.ForeignKey(
|
||||
Player,
|
||||
on_delete=models.DO_NOTHING,
|
||||
related_name='player_two_set',
|
||||
**BNULL,
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.start.date()} - {self.round} - {self.home_team} v {self.away_team}"
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("sports:event_detail", kwargs={'slug': self.uuid})
|
||||
|
||||
@classmethod
|
||||
def find_or_create(cls, data_dict: Dict) -> "Event":
|
||||
"""Given a data dict from Jellyfin, does the heavy lifting of looking up
|
||||
the video and, if need, TV Series, creating both if they don't yet
|
||||
exist.
|
||||
|
||||
"""
|
||||
# Find or create our Sport
|
||||
sid = data_dict.get("Sport")
|
||||
sport, s_created = Sport.objects.get_or_create(thesportsdb_id=sid)
|
||||
if s_created:
|
||||
sport.name = sid
|
||||
sport.save(update_fields=['name'])
|
||||
|
||||
# Find or create our League
|
||||
lid = data_dict.get("LeagueId")
|
||||
league, l_created = League.objects.get_or_create(
|
||||
thesportsdb_id=lid, sport=sport
|
||||
)
|
||||
if l_created:
|
||||
league.sport = sport
|
||||
league.name = data_dict.get("LeagueName", "")
|
||||
league.save(update_fields=['sport', 'name'])
|
||||
|
||||
# Find or create our Season
|
||||
seid = data_dict.get('Season')
|
||||
season, se_created = Season.objects.get_or_create(
|
||||
thesportsdb_id=seid, league=league
|
||||
)
|
||||
if se_created:
|
||||
season.name = seid
|
||||
season.save(update_fields=['name'])
|
||||
|
||||
# Find or create our Round
|
||||
rid = data_dict.get('RoundId')
|
||||
round, r_created = Round.objects.get_or_create(
|
||||
thesportsdb_id=rid, season=season
|
||||
)
|
||||
if r_created:
|
||||
round.season = season
|
||||
round.save(update_fields=['season'])
|
||||
|
||||
# Set some special data for Tennis
|
||||
player_one = None
|
||||
player_two = None
|
||||
if data_dict.get('Sport') == 'Tennis':
|
||||
event_name = data_dict.get('Name', '')
|
||||
if not round.name:
|
||||
round.name = get_round_name_from_event(event_name)
|
||||
round.save(update_fields=['name'])
|
||||
|
||||
players_list = get_players_from_event(event_name)
|
||||
player_one = Player.objects.filter(
|
||||
name__icontains=players_list[0]
|
||||
).first()
|
||||
if not player_one:
|
||||
player_one = Player.objects.create(name=players_list[0])
|
||||
player_two = Player.objects.filter(
|
||||
name__icontains=players_list[1]
|
||||
).first()
|
||||
if not player_two:
|
||||
player_two = Player.objects.create(name=players_list[1])
|
||||
|
||||
home_team = None
|
||||
away_team = None
|
||||
if data_dict.get("HomeTeamName"):
|
||||
home_team_dict = {
|
||||
"name": data_dict.get("HomeTeamName", ""),
|
||||
"thesportsdb_id": data_dict.get("HomeTeamId", ""),
|
||||
"league": league,
|
||||
}
|
||||
home_team, _created = Team.objects.get_or_create(**home_team_dict)
|
||||
|
||||
away_team_dict = {
|
||||
"name": data_dict.get("AwayTeamName", ""),
|
||||
"thesportsdb_id": data_dict.get("AwayTeamId", ""),
|
||||
"league": league,
|
||||
}
|
||||
away_team, _created = Team.objects.get_or_create(**away_team_dict)
|
||||
|
||||
event_dict = {
|
||||
"title": data_dict.get("Name"),
|
||||
"event_type": sport.default_event_type,
|
||||
"home_team": home_team,
|
||||
"away_team": away_team,
|
||||
"player_one": player_one,
|
||||
"player_two": player_two,
|
||||
"start": data_dict['Start'],
|
||||
"round": round,
|
||||
"run_time_ticks": data_dict.get("RunTimeTicks"),
|
||||
"run_time": data_dict.get("RunTime", ""),
|
||||
}
|
||||
event, _created = cls.objects.get_or_create(**event_dict)
|
||||
|
||||
return event
|
||||
11
vrobbler/apps/sports/utils.py
Normal file
11
vrobbler/apps/sports/utils.py
Normal file
@ -0,0 +1,11 @@
|
||||
def get_round_name_from_event(event: str) -> str:
|
||||
return ' '.join(event.split(' ')[:2])
|
||||
|
||||
|
||||
def get_players_from_event(event: str) -> list[str]:
|
||||
players = []
|
||||
event_name = get_round_name_from_event(event)
|
||||
players_list = event.split(event_name)[1:][0].split('vs')
|
||||
players.append(players_list[0].strip())
|
||||
players.append(players_list[1].strip())
|
||||
return players
|
||||
@ -1,16 +1,20 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from scrobbles.models import Scrobble
|
||||
from videos.models import Series, Video
|
||||
from scrobbles.admin import ScrobbleInline
|
||||
|
||||
|
||||
@admin.register(Series)
|
||||
class SeriesAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = ("name", "tagline")
|
||||
ordering = ("-created",)
|
||||
|
||||
|
||||
@admin.register(Video)
|
||||
class VideoAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
raw_id_fields = ('tv_series',)
|
||||
list_display = (
|
||||
"title",
|
||||
"video_type",
|
||||
@ -22,7 +26,6 @@ class VideoAdmin(admin.ModelAdmin):
|
||||
)
|
||||
list_filter = ("year", "tv_series", "video_type")
|
||||
ordering = ("-created",)
|
||||
|
||||
|
||||
admin.site.register(Series, SeriesAdmin)
|
||||
admin.site.register(Video, VideoAdmin)
|
||||
inlines = [
|
||||
ScrobbleInline,
|
||||
]
|
||||
|
||||
8
vrobbler/apps/videos/context_processors.py
Normal file
8
vrobbler/apps/videos/context_processors.py
Normal file
@ -0,0 +1,8 @@
|
||||
from videos.models import Video, Series
|
||||
|
||||
|
||||
def video_lists(request):
|
||||
return {
|
||||
"movie_list": Video.objects.filter(video_type=Video.VideoType.MOVIE),
|
||||
"series_list": Series.objects.all(),
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-08 21:31
|
||||
|
||||
from django.db import migrations, models
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('videos', '0003_alter_video_run_time_ticks'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='series',
|
||||
name='uuid',
|
||||
field=models.UUIDField(
|
||||
blank=True, default=uuid.uuid4, editable=False, null=True
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='video',
|
||||
name='uuid',
|
||||
field=models.UUIDField(
|
||||
blank=True, default=uuid.uuid4, editable=False, null=True
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,21 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-11 03:23
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('videos', '0004_series_uuid_video_uuid'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='video',
|
||||
options={},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='video',
|
||||
unique_together={('title', 'imdb_id')},
|
||||
),
|
||||
]
|
||||
18
vrobbler/apps/videos/migrations/0006_alter_video_year.py
Normal file
18
vrobbler/apps/videos/migrations/0006_alter_video_year.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-13 01:47
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('videos', '0005_alter_video_options_alter_video_unique_together'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='video',
|
||||
name='year',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -1,40 +1,53 @@
|
||||
from django.db import models
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
import logging
|
||||
from typing import Dict
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
from scrobbles.utils import convert_to_seconds
|
||||
from scrobbles.mixins import ScrobblableMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
BNULL = {"blank": True, "null": True}
|
||||
|
||||
|
||||
class Series(TimeStampedModel):
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
name = models.CharField(max_length=255)
|
||||
overview = models.TextField(**BNULL)
|
||||
tagline = models.TextField(**BNULL)
|
||||
# tvdb_id = models.CharField(max_length=20, **BNULL)
|
||||
# imdb_id = models.CharField(max_length=20, **BNULL)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def imdb_link(self):
|
||||
return f"https://www.imdb.com/title/{self.imdb_id}"
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = "series"
|
||||
|
||||
|
||||
class Video(TimeStampedModel):
|
||||
class Video(ScrobblableMixin):
|
||||
COMPLETION_PERCENT = getattr(settings, 'VIDEO_COMPLETION_PERCENT', 90)
|
||||
|
||||
class VideoType(models.TextChoices):
|
||||
UNKNOWN = 'U', _('Unknown')
|
||||
TV_EPISODE = 'E', _('TV Episode')
|
||||
MOVIE = 'M', _('Movie')
|
||||
|
||||
# General fields
|
||||
video_type = models.CharField(
|
||||
max_length=1,
|
||||
choices=VideoType.choices,
|
||||
default=VideoType.UNKNOWN,
|
||||
)
|
||||
title = models.CharField(max_length=255, **BNULL)
|
||||
overview = models.TextField(**BNULL)
|
||||
tagline = models.TextField(**BNULL)
|
||||
run_time = models.CharField(max_length=8, **BNULL)
|
||||
run_time_ticks = models.PositiveBigIntegerField(**BNULL)
|
||||
year = models.IntegerField()
|
||||
year = models.IntegerField(**BNULL)
|
||||
|
||||
# TV show specific fields
|
||||
tv_series = models.ForeignKey(Series, on_delete=models.DO_NOTHING, **BNULL)
|
||||
@ -44,9 +57,73 @@ class Video(TimeStampedModel):
|
||||
imdb_id = models.CharField(max_length=20, **BNULL)
|
||||
tvrage_id = models.CharField(max_length=20, **BNULL)
|
||||
|
||||
# Metadata fields from TMDB
|
||||
class Meta:
|
||||
unique_together = [['title', 'imdb_id']]
|
||||
|
||||
def __str__(self):
|
||||
if self.video_type == self.VideoType.TV_EPISODE:
|
||||
return f"{self.tv_series} - Season {self.season_number}, Episode {self.episode_number}"
|
||||
return self.title
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("videos:video_detail", kwargs={'slug': self.uuid})
|
||||
|
||||
@property
|
||||
def imdb_link(self):
|
||||
return f"https://www.imdb.com/title/{self.imdb_id}"
|
||||
|
||||
@classmethod
|
||||
def find_or_create(cls, data_dict: Dict) -> "Video":
|
||||
"""Given a data dict from Jellyfin, does the heavy lifting of looking up
|
||||
the video and, if need, TV Series, creating both if they don't yet
|
||||
exist.
|
||||
|
||||
"""
|
||||
video_dict = {
|
||||
"title": data_dict.get("Name", ""),
|
||||
"imdb_id": data_dict.get("Provider_imdb", None),
|
||||
"video_type": Video.VideoType.MOVIE,
|
||||
}
|
||||
|
||||
series = None
|
||||
if data_dict.get("ItemType", "") == "Episode":
|
||||
series_name = data_dict.get("SeriesName", "")
|
||||
series, series_created = Series.objects.get_or_create(
|
||||
name=series_name
|
||||
)
|
||||
if series_created:
|
||||
logger.debug(f"Created new series {series}")
|
||||
else:
|
||||
logger.debug(f"Found series {series}")
|
||||
video_dict['video_type'] = Video.VideoType.TV_EPISODE
|
||||
|
||||
video, created = cls.objects.get_or_create(**video_dict)
|
||||
|
||||
run_time_ticks = data_dict.get("RunTimeTicks", None)
|
||||
if run_time_ticks:
|
||||
run_time_ticks = run_time_ticks // 10000
|
||||
|
||||
video_extra_dict = {
|
||||
"year": data_dict.get("Year", ""),
|
||||
"overview": data_dict.get("Overview", None),
|
||||
"tagline": data_dict.get("Tagline", None),
|
||||
"run_time_ticks": run_time_ticks,
|
||||
"run_time": convert_to_seconds(data_dict.get("RunTime", "")),
|
||||
"tvdb_id": data_dict.get("Provider_tvdb", None),
|
||||
"tvrage_id": data_dict.get("Provider_tvrage", None),
|
||||
"episode_number": data_dict.get("EpisodeNumber", None),
|
||||
"season_number": data_dict.get("SeasonNumber", None),
|
||||
}
|
||||
|
||||
if series:
|
||||
video_extra_dict["tv_series_id"] = series.id
|
||||
|
||||
if not video.run_time_ticks:
|
||||
logger.debug(f"Created new video: {video}")
|
||||
for key, value in video_extra_dict.items():
|
||||
setattr(video, key, value)
|
||||
video.save()
|
||||
else:
|
||||
logger.debug(f"Found video {video}")
|
||||
|
||||
return video
|
||||
|
||||
21
vrobbler/apps/videos/urls.py
Normal file
21
vrobbler/apps/videos/urls.py
Normal file
@ -0,0 +1,21 @@
|
||||
from django.urls import path
|
||||
from videos import views
|
||||
|
||||
app_name = 'videos'
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
# path('', views.scrobble_endpoint, name='scrobble-list'),
|
||||
path("movies/", views.MovieListView.as_view(), name='movie_list'),
|
||||
path('series/', views.SeriesListView.as_view(), name='series_list'),
|
||||
path(
|
||||
'series/<slug:slug>/',
|
||||
views.SeriesDetailView.as_view(),
|
||||
name='series_detail',
|
||||
),
|
||||
path(
|
||||
'video/<slug:slug>/',
|
||||
views.VideoDetailView.as_view(),
|
||||
name='video_detail',
|
||||
),
|
||||
]
|
||||
25
vrobbler/apps/videos/views.py
Normal file
25
vrobbler/apps/videos/views.py
Normal file
@ -0,0 +1,25 @@
|
||||
from django.views import generic
|
||||
from videos.models import Series, Video
|
||||
|
||||
# class VideoIndexView():
|
||||
|
||||
|
||||
class MovieListView(generic.ListView):
|
||||
model = Video
|
||||
|
||||
def get_queryset(self):
|
||||
return Video.objects.filter(video_type=Video.VideoType.MOVIE)
|
||||
|
||||
|
||||
class SeriesListView(generic.ListView):
|
||||
model = Series
|
||||
|
||||
|
||||
class SeriesDetailView(generic.DetailView):
|
||||
model = Series
|
||||
slug_field = 'uuid'
|
||||
|
||||
|
||||
class VideoDetailView(generic.DetailView):
|
||||
model = Video
|
||||
slug_field = 'uuid'
|
||||
@ -6,15 +6,18 @@ from os import environ as env
|
||||
|
||||
if not 'DJANGO_SETTINGS_MODULE' in env:
|
||||
from vrobbler import settings
|
||||
|
||||
env.setdefault('DJANGO_SETTINGS_MODULE', settings.__name__)
|
||||
|
||||
|
||||
import django
|
||||
|
||||
django.setup()
|
||||
|
||||
# this line must be after django.setup() for logging configure
|
||||
logger = logging.getLogger('vrobbler')
|
||||
|
||||
|
||||
def main():
|
||||
# to get configured settings
|
||||
from django.conf import settings
|
||||
|
||||
@ -37,26 +37,33 @@ KEEP_DETAILED_SCROBBLE_LOGS = os.getenv(
|
||||
"VROBBLER_KEEP_DETAILED_SCROBBLE_LOGS", False
|
||||
)
|
||||
|
||||
|
||||
# Should we cull old in-progress scrobbles that are beyond the wait period for resuming?
|
||||
DELETE_STALE_SCROBBLES = os.getenv("VROBBLER_DELETE_STALE_SCROBBLES", True)
|
||||
|
||||
# Used to dump data coming from srobbling sources, helpful for building new inputs
|
||||
DUMP_REQUEST_DATA = os.getenv("VROBBLER_DUMP_REQUEST_DATA", False)
|
||||
|
||||
|
||||
THESPORTSDB_API_KEY = os.getenv("VROBBLER_THESPORTSDB_API_KEY", "2")
|
||||
THESPORTSDB_BASE_URL = os.getenv(
|
||||
"VROBBLER_THESPORTSDB_BASE_URL", "https://www.thesportsdb.com/api/v1/json/"
|
||||
)
|
||||
TMDB_API_KEY = os.getenv("VROBBLER_TMDB_API_KEY", "")
|
||||
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
|
||||
TIME_ZONE = os.getenv("VROBBLER_TIME_ZONE", "US/Eastern")
|
||||
|
||||
ALLOWED_HOSTS = ["*"]
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
os.getenv("VROBBLER_TRUSTED_ORIGINS", "http://localhost:8000")
|
||||
]
|
||||
X_FRAME_OPTIONS = "SAMEORIGIN"
|
||||
|
||||
CACHALOT_TIMEOUT = os.getenv("VROBBLER_CACHALOT_TIMEOUT", 3600)
|
||||
REDIS_URL = os.getenv("VROBBLER_REDIS_URL", None)
|
||||
|
||||
CELERY_TASK_ALWAYS_EAGER = os.getenv("VROBBLER_SKIP_CELERY", False)
|
||||
CELERY_BROKER_URL = REDIS_URL if REDIS_URL else "memory://localhost/"
|
||||
CELERY_RESULT_BACKEND = "django-db"
|
||||
CELERY_TIMEZONE = os.getenv("VROBBLER_TIME_ZONE", "EST")
|
||||
CELERY_TASK_TRACK_STARTED = True
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
@ -65,14 +72,22 @@ INSTALLED_APPS = [
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"django.contrib.sites",
|
||||
"django.contrib.humanize",
|
||||
"django_filters",
|
||||
"django_extensions",
|
||||
'rest_framework.authtoken',
|
||||
"cachalot",
|
||||
"profiles",
|
||||
"scrobbles",
|
||||
"videos",
|
||||
"music",
|
||||
"podcasts",
|
||||
"sports",
|
||||
"mathfilters",
|
||||
"rest_framework",
|
||||
"allauth",
|
||||
"allauth.account",
|
||||
"allauth.socialaccount",
|
||||
"django_celery_results",
|
||||
]
|
||||
|
||||
@ -103,6 +118,8 @@ TEMPLATES = [
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
"videos.context_processors.video_lists",
|
||||
"music.context_processors.music_lists",
|
||||
],
|
||||
},
|
||||
},
|
||||
@ -129,9 +146,7 @@ CACHES = {
|
||||
}
|
||||
}
|
||||
if REDIS_URL:
|
||||
CACHES["default"][
|
||||
"BACKEND"
|
||||
] = "django.core.cache.backends.redis.RedisCache"
|
||||
CACHES["default"]["BACKEND"] = "django_redis.cache.RedisCache"
|
||||
CACHES["default"]["LOCATION"] = REDIS_URL
|
||||
|
||||
SESSION_ENGINE = "django.contrib.sessions.backends.cached_db"
|
||||
@ -186,8 +201,6 @@ TIME_ZONE = os.getenv("VROBBLER_TIME_ZONE", "EST")
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_L10N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
|
||||
@ -198,10 +211,6 @@ STATIC_URL = "static/"
|
||||
STATIC_ROOT = os.getenv(
|
||||
"VROBBLER_STATIC_ROOT", os.path.join(PROJECT_ROOT, "static")
|
||||
)
|
||||
if not DEBUG:
|
||||
STATICFILES_STORAGE = (
|
||||
"whitenoise.storage.CompressedManifestStaticFilesStorage"
|
||||
)
|
||||
|
||||
MEDIA_URL = "/media/"
|
||||
MEDIA_ROOT = os.getenv(
|
||||
@ -256,17 +265,17 @@ LOGGING = {
|
||||
"class": "logging.NullHandler",
|
||||
"level": LOG_LEVEL,
|
||||
},
|
||||
"file": {
|
||||
"class": "logging.handlers.RotatingFileHandler",
|
||||
"filename": "".join([LOG_FILE_PATH, "vrobbler.log"]),
|
||||
"formatter": LOG_TYPE,
|
||||
"level": LOG_LEVEL,
|
||||
'sql': {
|
||||
'class': 'logging.handlers.RotatingFileHandler',
|
||||
'filename': ''.join([LOG_FILE_PATH, 'vrobbler_sql.', LOG_TYPE]),
|
||||
'formatter': LOG_TYPE,
|
||||
'level': LOG_LEVEL,
|
||||
},
|
||||
"requests_file": {
|
||||
"class": "logging.handlers.RotatingFileHandler",
|
||||
"filename": "".join([LOG_FILE_PATH, "vrobbler_requests.log"]),
|
||||
"formatter": LOG_TYPE,
|
||||
"level": LOG_LEVEL,
|
||||
'file': {
|
||||
'class': 'logging.handlers.RotatingFileHandler',
|
||||
'filename': ''.join([LOG_FILE_PATH, 'vrobbler.', LOG_TYPE]),
|
||||
'formatter': LOG_TYPE,
|
||||
'level': LOG_LEVEL,
|
||||
},
|
||||
},
|
||||
"loggers": {
|
||||
@ -276,13 +285,15 @@ LOGGING = {
|
||||
"propagate": True,
|
||||
},
|
||||
"django.db.backends": {"handlers": ["null"]},
|
||||
"django.server": {"handlers": ["null"]},
|
||||
"vrobbler": {
|
||||
"handlers": ["console", "file"],
|
||||
"handlers": ["file"],
|
||||
"propagate": True,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if DEBUG:
|
||||
# We clear out a db with lots of games all the time in dev
|
||||
DATA_UPLOAD_MAX_NUMBER_FIELDS = 3000
|
||||
LOG_TO_CONSOLE = os.getenv("VROBBLER_LOG_TO_CONSOLE", False)
|
||||
if LOG_TO_CONSOLE:
|
||||
LOGGING['loggers']['django']['handlers'] = ["console"]
|
||||
LOGGING['loggers']['vrobbler']['handlers'] = ["console"]
|
||||
|
||||
BIN
vrobbler/static/images/apple-touch-icon.png
Normal file
BIN
vrobbler/static/images/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
vrobbler/static/images/favicon.ico
Normal file
BIN
vrobbler/static/images/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
@ -1,17 +1,19 @@
|
||||
{% load static %}
|
||||
{% load humanize %}
|
||||
<!doctype html>
|
||||
<html class="no-js" lang="">
|
||||
<head>
|
||||
<title>{% block page_title %}Welcome{% endblock %} | Vrobbler » For video scrobbling</title>
|
||||
<title>{% block page_title %}Scrobble all the things{% endblock %} @ Vrobbler</title>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
||||
<meta name="description" content="">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.0.0/dist/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">
|
||||
<link rel="shortcut icon" type="image/png" href="{% static 'images/favicon.ico' %}"/>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
|
||||
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.12.9/dist/umd/popper.min.js" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM" crossorigin="anonymous"></script>
|
||||
<script src="https://code.jquery.com/jquery-3.3.1.js" integrity="sha256-2Kok7MbOyxpgUVvAk/HJ2jigOSYS2auK4Pfzbm7uH60=" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.0.0/dist/js/bootstrap.min.js" integrity="sha384-JZR6Spejh4U02d8jOt6vLEHfe/JQGiRRSQQxSfFWpi1MquVdAyjUar5+76PVCmYl" crossorigin="anonymous"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/clipboard.js/2.0.10/clipboard.min.js"></script>
|
||||
|
||||
<style type="text/css">
|
||||
dl {
|
||||
display: flex;
|
||||
@ -48,76 +50,276 @@
|
||||
border-radius: 3px;
|
||||
transition: width 500ms ease-in-out;
|
||||
}
|
||||
.bd-placeholder-img {
|
||||
font-size: 1.125rem;
|
||||
text-anchor: middle;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.bd-placeholder-img-lg {
|
||||
font-size: 3.5rem;
|
||||
}
|
||||
}
|
||||
body {
|
||||
font-size: .875rem;
|
||||
}
|
||||
|
||||
.feather {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
|
||||
/*
|
||||
* Sidebar
|
||||
*/
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
/* rtl:raw:
|
||||
right: 0;
|
||||
*/
|
||||
bottom: 0;
|
||||
/* rtl:remove */
|
||||
left: 0;
|
||||
z-index: 100; /* Behind the navbar */
|
||||
padding: 48px 0 0; /* Height of navbar */
|
||||
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1);
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.sidebar {
|
||||
top: 5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-sticky {
|
||||
position: relative;
|
||||
top: 0;
|
||||
height: calc(100vh - 48px);
|
||||
padding-top: .5rem;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto; /* Scrollable contents if viewport is shorter than content. */
|
||||
}
|
||||
|
||||
.sidebar .nav-link {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.sidebar .nav-link .feather {
|
||||
margin-right: 4px;
|
||||
color: #727272;
|
||||
}
|
||||
|
||||
.sidebar .nav-link.active {
|
||||
color: #2470dc;
|
||||
}
|
||||
|
||||
.sidebar .nav-link:hover .feather,
|
||||
.sidebar .nav-link.active .feather {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.sidebar-heading {
|
||||
font-size: .75rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/*
|
||||
* Navbar
|
||||
*/
|
||||
|
||||
.navbar-brand {
|
||||
padding-top: .75rem;
|
||||
padding-bottom: .75rem;
|
||||
font-size: 1rem;
|
||||
background-color: rgba(0, 0, 0, .25);
|
||||
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .25);
|
||||
}
|
||||
|
||||
.navbar .navbar-toggler {
|
||||
top: .25rem;
|
||||
right: 1rem;
|
||||
}
|
||||
|
||||
.navbar .form-control {
|
||||
padding: .75rem 1rem;
|
||||
border-width: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.form-control-dark {
|
||||
color: #fff;
|
||||
background-color: rgba(255, 255, 255, .1);
|
||||
border-color: rgba(255, 255, 255, .1);
|
||||
}
|
||||
|
||||
.form-control-dark:focus {
|
||||
border-color: transparent;
|
||||
box-shadow: 0 0 0 3px rgba(255, 255, 255, .25);
|
||||
}
|
||||
#scrobble-form { width: 100% }
|
||||
</style>
|
||||
{% block head_extra %}{% endblock %}
|
||||
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
<!-- Place favicon.ico in the root directory -->
|
||||
|
||||
<script>
|
||||
function checkUpdate(){
|
||||
$.get('/library/update/status/', function(data) {
|
||||
$('#library-update-status').html("");
|
||||
console.log('Checking for task');
|
||||
setTimeout(checkUpdate,5000);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<link rel="apple-touch-icon" href="{% static 'images/apple-touch-icon.png' %}">
|
||||
</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]-->
|
||||
<div class="container">
|
||||
<nav class="navbar navbar-expand-lg navbar-light bg-light">
|
||||
<a class="navbar-brand" href="#">Vrobbler</a>
|
||||
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<header class="navbar navbar-dark sticky-top bg-dark flex-md-nowrap p-0 shadow">
|
||||
<a class="navbar-brand col-md-3 col-lg-2 me-0 px-3" href="#">Vrobbler</a>
|
||||
<button class="navbar-toggler position-absolute d-md-none collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
||||
<ul class="navbar-nav mr-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'home' %}">Recent<span class="sr-only"></span></a>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">Movies</a>
|
||||
<div class="dropdown-menu" aria-labelledby="navbarDropdown">
|
||||
<a class="dropdown-item" href="{ url "games:gamesystem_list" %}">All</a>
|
||||
{% for system in game_systems %}
|
||||
<a class="dropdown-item" href="{{system.get_absolute_url}}">{{system.name}} ({{system.game_set.count}})</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">Shows</a>
|
||||
<div class="dropdown-menu" aria-labelledby="navbarDropdown">
|
||||
<a class="dropdown-item" href="{ url "games:gamecollection_list" %}">All</a>
|
||||
{% for collection in game_collections %}
|
||||
<a class="dropdown-item" href="{{collection.get_absolute_url}}">{{collection.name}} ({{collection.games.count}})</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% if request.user.is_authenticated %}
|
||||
<a class="nav-link" href="{% url 'account_logout' %}">Logout<span class="sr-only"></span></a>
|
||||
{% else %}
|
||||
<a class="nav-link" href="{% url 'account_login' %}">Login<span class="sr-only"></span></a>
|
||||
{% if user.is_authenticated %}
|
||||
<form id="scrobble-form" action="{% url 'imdb-manual-scrobble' %}" method="post">
|
||||
{% csrf_token %}
|
||||
{{ imdb_form }}
|
||||
</form>
|
||||
{% endif %}
|
||||
</nav>
|
||||
<div class="navbar-nav">
|
||||
<div class="nav-item text-nowrap">
|
||||
{% if user.is_authenticated %}
|
||||
<a class="nav-link px-3" href="{% url "account_logout" %}">Sign out</a>
|
||||
{% else %}
|
||||
<a class="nav-link px-3" href="{% url "account_login" %}">Sign in</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<h1>{% block title %}{% endblock %}</h1>
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<nav id="sidebarMenu" class="col-md-3 col-lg-2 d-md-block bg-light sidebar collapse">
|
||||
<div class="position-sticky pt-3">
|
||||
{% if now_playing_list and user.is_authenticated %}
|
||||
<ul style="padding-right:10px;">
|
||||
<b>Now playing</b>
|
||||
{% for scrobble in now_playing_list %}
|
||||
<div>
|
||||
{{scrobble.media_obj.title}}<br/>
|
||||
{% if scrobble.track %}<em>{{scrobble.track.artist}}</em><br/>{% endif %}
|
||||
{% if scrobble.podcast_episode%}<em>{{scrobble.podcast_episode.podcast}}</em><br/>{% endif %}
|
||||
{% if scrobble.video.tv_series %}<em>{{scrobble.video.tv_series }}</em><br/>{% endif %}
|
||||
{% if scrobble.sport_event %}<em>{{scrobble.sport_event.round.season.league}}</em><br/>{% endif %}
|
||||
<small>{{scrobble.timestamp|naturaltime}}<br/>
|
||||
from {{scrobble.source}}</small>
|
||||
<div class="progress-bar" style="margin-right:5px;">
|
||||
<span class="progress-bar-fill" style="width: {{scrobble.percent_played}}%;"></span>
|
||||
</div>
|
||||
<a href="{% url "scrobbles:cancel" scrobble.uuid %}">Cancel</a>
|
||||
<a href="{% url "scrobbles:finish" scrobble.uuid %}">Finish</a>
|
||||
</div>
|
||||
<hr/>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" aria-current="page" href="/">
|
||||
<span data-feather="music"></span>
|
||||
Dashboard
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" aria-current="page" href="/tracks/">
|
||||
<span data-feather="music"></span>
|
||||
Tracks
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/artists/">
|
||||
<span data-feather="user"></span>
|
||||
Artists
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/movies/">
|
||||
<span data-feather="film"></span>
|
||||
Movies
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/series/">
|
||||
<span data-feather="tv"></span>
|
||||
Series
|
||||
</a>
|
||||
</li>
|
||||
{% if user.is_authenticated %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/">
|
||||
<span data-feather="key"></span>
|
||||
Admin
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% block extra_nav %}
|
||||
{% endblock %}
|
||||
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div>
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
{% block extra_js %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/feather-icons@4.28.0/dist/feather.min.js" integrity="sha384-uO3SXW5IuS1ZpFPKugNNWqTZRRglnUJK6UAZ/gxOX80nxEkN9NcGZTftn6RzhGWE" crossorigin="anonymous"></script><script src="https://cdn.jsdelivr.net/npm/chart.js@2.9.4/dist/Chart.min.js" integrity="sha384-zNy6FEbO50N+Cg5wap8IKA4M/ZnLJgzc6w2NqACZaK0u0FXfOWRRJOnQtpZun8ha" crossorigin="anonymous"></script>
|
||||
<script><!-- comment ------------------------------------------------->
|
||||
/* globals Chart:false, feather:false */
|
||||
(function () {
|
||||
'use strict'
|
||||
|
||||
feather.replace({ 'aria-hidden': 'true' })
|
||||
|
||||
// Graphs
|
||||
var ctx = document.getElementById('myChart')
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
var myChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [
|
||||
{% for day in weekly_data.keys %}
|
||||
"{{day}}"{% if not forloop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
],
|
||||
datasets: [{
|
||||
data: [
|
||||
{% for count in weekly_data.values %}
|
||||
{{count}}{% if not forloop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
],
|
||||
lineTension: 0,
|
||||
backgroundColor: 'transparent',
|
||||
borderColor: '#007bf0',
|
||||
borderWidth: 4,
|
||||
pointBackgroundColor: '#007bff'
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
scales: {
|
||||
yAxes: [{
|
||||
ticks: {
|
||||
beginAtZero: false
|
||||
}
|
||||
}]
|
||||
},
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
})
|
||||
})()
|
||||
|
||||
</script>
|
||||
{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
18
vrobbler/templates/base_detail.html
Normal file
18
vrobbler/templates/base_detail.html
Normal file
@ -0,0 +1,18 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<main class="col-md-4 ms-sm-auto col-lg-10 px-md-4">
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
||||
<h1 class="h2">{% block title %}{% endblock %} </h1>
|
||||
<div class="btn-toolbar mb-2 mb-md-0">
|
||||
<div class="btn-group me-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary">Export</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
{% block details %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
18
vrobbler/templates/base_list.html
Normal file
18
vrobbler/templates/base_list.html
Normal file
@ -0,0 +1,18 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<main class="col-md-4 ms-sm-auto col-lg-10 px-md-4">
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
||||
<h1 class="h2">{% block title %}{% endblock %} </h1>
|
||||
<div class="btn-toolbar mb-2 mb-md-0">
|
||||
<div class="btn-group me-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary">Export</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
{% block lists %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
11
vrobbler/templates/music/album_list.html
Normal file
11
vrobbler/templates/music/album_list.html
Normal file
@ -0,0 +1,11 @@
|
||||
{% extends "base_list.html" %}
|
||||
|
||||
{% block title %}Albums{% endblock %}
|
||||
|
||||
{% block lists %}
|
||||
{% for album in object_list %}
|
||||
<dl style="width: 130px; float: left; margin-right:10px;">
|
||||
<dd><img src="{{album.cover_image.url}}" width=120 height=120 /></dd>
|
||||
</dl>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user