Compare commits

...

125 Commits
0.1.9 ... 0.8.1

Author SHA1 Message Date
6b7359707b Bump version to 0.8.1 2023-02-06 01:12:33 -05:00
e0295cbd56 Fix jellyfin edge case scrobbling mess
Finally get to resolve scrobbling music from Jellyfin. This may lead to
other issues, in fact now videos seem to sometimes create duplicate
scrobbles. But music can be scrobbled now from Jellyfin web or Finamp
successfully.
2023-02-06 00:22:10 -05:00
5271cfaea4 Create sport if it doesn't exist yet 2023-02-06 00:19:47 -05:00
0370b64351 Fix exporting only tracks by default 2023-02-06 00:19:07 -05:00
9ec31ba0f5 Remove noisy debug logging 2023-02-05 01:59:24 -05:00
a9de298057 Put import and export behind auth 2023-02-05 01:58:19 -05:00
9d303b1b94 Add exporting and importing scrobbles 2023-02-04 17:08:01 -05:00
4c434aeb7c Bump version to 0.8.0 2023-02-03 19:00:32 -05:00
64d9cac09c Update importing to include some logging 2023-02-03 18:59:48 -05:00
c21d6a96fe Fix unused imports in imdb module 2023-02-03 16:53:03 -05:00
e392477dc7 Add import of Audioscrobbler files
Here we add a model for holding Audioscrobbler imports and some code to
process the tab-separated files we get from Rockbox.
2023-02-03 16:45:31 -05:00
12087460f6 Irritating that poetry can't handle prod deps well 2023-01-31 10:44:39 -05:00
4b4fbf4777 Bump version to 0.7.5 2023-01-31 10:41:40 -05:00
ca57eabf87 Another attempt to fix the Jellyfin issue
This time, we simplify the progress updates, aggressively mark tracks as
100 played if they are marked played_to_completion, and implement a hack
for Jellyfin spamming us with progress updates less than a second apart.
2023-01-31 10:28:54 -05:00
6fc51d9296 Fix resurrecting past tracks
This may also be hack, but I think that if the playback position ticks
are automatically jumped to the run time ticks of the media object, it
should stop the resurrection of past scrobbles, because they will be
appropriately marked as being 100 played when the scrobble is finished.

The only weirdness here is that "in progress" scrobbles will suddenly
complete once the media type's threshold is met (90, 95 percent,
whatever). But it should be better than overwriting old scrobbles.
2023-01-30 23:18:42 -05:00
6e582e25e3 Bump version to 0.7.4 2023-01-30 18:32:31 -05:00
eed344ae46 Add the beginnings of charts
This commit adds a lot of files, but most of them have no impact on any
other code. The thrust here is to start creating chart pages showing
which tracks and artists were most played for various time periods. Lots
still not working, but we're getting there.
2023-01-30 18:29:18 -05:00
41570dc2f9 Fix duplicate Jellyfin music scrobbling bug
Solution is identical to what we were already doing with videos.  When
looking for existing scrobbles, don't filter by completion, but just
check if the scrobble was played to completion.  This does create
another irritating situation where old scrobbles from days, months or
even years ago that were not played to completion will be resurrected
and made current here. But that's way less annoying than having spam
scrobbles at the end of every track.
2023-01-30 18:25:27 -05:00
24c3f5b4d8 Bump version to 0.7.3 2023-01-29 14:41:05 -05:00
703dc3c181 Add a sample envrc file 2023-01-29 14:32:25 -05:00
93550c5734 Allow looking up artwork by release group
Historically we'd just fail if the specific MB release did not have
artwork, but this is silly. If the release itself does not have artwork,
we should also check the release group failing.
2023-01-29 14:31:31 -05:00
951fa225bb Add todos and get them updated 2023-01-29 14:31:12 -05:00
2e7470688d Update TODOs and fix tests 2023-01-24 16:44:11 -05:00
8ac938bd12 Should probably avoid bug fixing under hydrocodone 2023-01-24 15:25:19 -05:00
160f15a101 Add cover art to latest listening 2023-01-24 15:24:41 -05:00
b6e0607aab Fix jellyfin video scrobbles for real 2023-01-24 02:41:48 -05:00
bbbcfca04f Add a rudimentary todo doc 2023-01-23 12:01:26 -05:00
ace0d1d9fe Bump version to 0.7.2
- Add django-redis to deps
- Use django-redis to fix cachalot issue
2023-01-22 18:36:56 -05:00
b0fb62bdb9 Upgrade to django-redis for cachalot 2023-01-22 18:36:36 -05:00
7796ff5786 Bump version to 0.7.1 2023-01-22 18:34:23 -05:00
2285c5bfd6 Fix bug in jellyfin scrobbler 2023-01-22 18:34:02 -05:00
132d63bb5d Bump version to 0.7.0 2023-01-22 18:29:29 -05:00
49bf57dd58 Copmletely rejigger sports to accomodate tennis 2023-01-22 18:27:33 -05:00
506de848d7 Pull client name from Jellyfin if provided 2023-01-21 23:57:25 -05:00
d05256f249 Add authorization and per-user scrobbling
The webhook endpoints now require a token before it will accept a
scrobble. That auth then provides the user to assign the scrobble to.
2023-01-21 23:41:35 -05:00
646c7ab99c Add ability to cancel and finish manual scrobbles 2023-01-20 14:03:23 -05:00
7fc3705455 Fix a bug in IMDB lookups for episodes 2023-01-20 13:23:23 -05:00
cbe4abfb5f First tests for imdb module 2023-01-20 13:02:32 -05:00
13bdc201f0 Add more hacky tests 2023-01-20 00:09:45 -05:00
4f5ea7cd25 Fix aggregator tests to use users and time-machine 2023-01-20 00:01:34 -05:00
b3b3b28b92 Add tests for top_tracks 2023-01-19 23:52:01 -05:00
9ed3d034cf Fix shadowed test function 2023-01-19 21:52:23 -05:00
8e4a41a279 Update gitignore file 2023-01-19 20:49:16 -05:00
0cdde59de4 Add aggregator tests 2023-01-19 20:49:01 -05:00
65e713e43e Remove some files from coverage 2023-01-19 15:37:47 -05:00
0d95f8fee8 Fix bug in unique tracks on different albums 2023-01-19 15:20:26 -05:00
6712e38689 Update drone badge to use main branch 2023-01-19 14:53:06 -05:00
4ed5fde672 Update coverage map 2023-01-19 14:52:16 -05:00
1c5f721723 Fix coverage argument 2023-01-19 14:48:33 -05:00
39547c6e5c Add a test conf file 2023-01-19 14:47:54 -05:00
7447a97117 Add coverage run to drone 2023-01-19 14:44:51 -05:00
6aa933d13d Fix bad test imports 2023-01-19 14:41:02 -05:00
3bb73ae4be Add init files to tests 2023-01-19 14:34:10 -05:00
a57269b09a Run pytest with drone 2023-01-19 14:30:04 -05:00
68423488ff Start adding tests 2023-01-19 14:29:25 -05:00
e75c22d583 Add timezone support and an authenticated view 2023-01-18 00:39:12 -05:00
a0af0bce05 Add user profile with a timezone 2023-01-17 22:20:00 -05:00
fd984d7460 Replace weekly artists with monthly 2023-01-17 17:23:13 -05:00
065fc98a87 Fix bug in Jellyfin scrobbling
We need to have a default value when we pop off a dictionary, and we
need to clear both mopidy and jellyfin status before we look a scrobble
up.

We may also want simplify status so we don't have mopidy and jellyfin cruft.
2023-01-17 16:38:17 -05:00
6db5a00917 Fix caching issue with fixed 5 minute timeout 2023-01-17 13:33:21 -05:00
734aa6073b Add cachalot to help fix slow views 2023-01-17 13:09:43 -05:00
77362d3207 Fix scrobble spam from Jellyfin
The issue here was that we update a Jellyfin scrobble to be complete
when it hits a certain threshold of percentage played (90) and then we
stop finding that scrobble while the video finishes playing. This
happens over and over again, so once a video reaches 90 percent played
we get dozens more scrobbles for each update as the video finishes
playing.

This is a crude fix, for the spam, as we'll end up "resuming" videos
that are stopped at 95 percent. So we need some way to mark the scrobble
as complete.  I think forcing the percent to 100 on finish might work.
2023-01-17 11:41:48 -05:00
9d4db65b3c Remove whitenoise compression storage 2023-01-17 00:13:28 -05:00
e850d46539 Clean up gitignore a little 2023-01-17 00:11:14 -05:00
907ef802bc Bump version to 0.6.2
Releases

* Fix for double scrobbling
* Fix error messages
* Fix bug in IMDB lookup when runtime is missing
* Fix filter order with annotations for top tracks and artists
* Fix error in completion percentage issues
2023-01-17 00:05:54 -05:00
d700b581a1 Fix filter order with annotations
We were getting all artists of all time, not just for the time period
2023-01-17 00:02:20 -05:00
7605c672f6 Fix str rep for scrobbles 2023-01-16 23:54:19 -05:00
8d1df806d7 Fix IMDB fetch when runtimes are not present 2023-01-16 23:46:04 -05:00
0f562b7c58 Fix resume bug, stop trying to avoid resuming
Turns out trying to not resume in-progress Scrobbles is super painful.
Maybe I'll come up with a better idea later, but for now, I'd rather
just resurrect old paused scrobbles of past tracks, rather than
completely mess up all other aspects of scrobbling.
2023-01-16 23:44:49 -05:00
fe53b68714 Fix silly error message 2023-01-16 20:40:02 -05:00
7e2915850f Fix double scrobbling for real 2023-01-16 20:39:27 -05:00
90687a6b43 Fix complicated completion percentage 2023-01-16 19:36:10 -05:00
07cfb03eb6 Fix really irritating double scrobble bug 2023-01-16 19:34:40 -05:00
58be8d26a0 Bump version to 0.6.1 2023-01-16 17:53:14 -05:00
0fede269b1 Fix checking for completion percentages 2023-01-16 17:52:07 -05:00
6cdcf4ff6f Fix Mopidy resuming messing things up 2023-01-16 16:12:01 -05:00
0634b94368 Fix how we calcualte resuming a scrobble 2023-01-16 13:43:14 -05:00
0ab7c563cf Fix favicon static files and logging 2023-01-15 19:06:54 -05:00
363a132df2 Bump version to 0.6.0 2023-01-15 02:42:14 -05:00
c484905d11 Add manual scrobbling by TheSportsDB ID 2023-01-15 02:41:36 -05:00
0378dfe6eb Add drone badge 2023-01-15 01:45:35 -05:00
c39443e35b Bump versoin to 0.5.1 2023-01-15 01:43:36 -05:00
bb3dfdf7ba Fix small debug log error 2023-01-15 01:42:46 -05:00
fdfb8a635e Add monthly tracks to homepage 2023-01-15 01:40:41 -05:00
290e6dc8d9 Fixing flow for mopidy scrobble 2023-01-15 01:20:18 -05:00
499546503c Fix README to use markdown 2023-01-14 16:46:46 -05:00
bd3a381346 Add sports as a preliminary scobble type 2023-01-14 16:42:48 -05:00
e206a7fbf3 Fix display of TV episode and season on homepage 2023-01-14 10:37:38 -05:00
6313da9868 Work towards unifiying manual scrobbling 2023-01-13 17:56:44 -05:00
f7c69a6763 Bump version to 0.5.0 2023-01-13 17:15:33 -05:00
ab88fcb9a7 Fix lookup bug in track year 2023-01-13 17:15:13 -05:00
1d868d3075 Add manual scrobble to main page 2023-01-13 17:12:50 -05:00
5b07c70ca2 Add scrobble inlines to admin classes 2023-01-13 17:06:29 -05:00
9607fb2d1e Make way for more manual scrobbling options ...
Next stop, sports games!
2023-01-13 16:52:45 -05:00
e6cf126f5c Add rudimentary manual scrobbling 2023-01-13 16:47:06 -05:00
eeee6eea4e Fix seconds calculator 2023-01-13 14:26:25 -05:00
1f26931215 Fix Now playing widget 2023-01-13 00:14:50 -05:00
610ec63cbd Fix bug in non-series video not scrobbling
THe season and episode numbers need to be None, not an empty string.
2023-01-13 00:01:34 -05:00
72fded4097 Bump version to 0.4.3 2023-01-12 23:51:58 -05:00
d5eea53a01 Actually fix bug in loading extra video meta 2023-01-12 23:51:10 -05:00
a49eb31276 Add podcasts to template list 2023-01-12 23:50:57 -05:00
c1e1160db3 Bump version to 0.4.2 2023-01-12 23:39:11 -05:00
0e17831724 Fix bug in load extra video info 2023-01-12 23:38:34 -05:00
045fad8552 Remove errant base template 2023-01-12 22:59:29 -05:00
a09c6d6b92 Bump version to 0.4.1 2023-01-12 21:34:17 -05:00
3f8b29f5ee Dramatically simplify the scrobblig code 2023-01-12 21:33:45 -05:00
507b3aaaf2 Bump version to 0.4.0 2023-01-12 16:11:50 -05:00
879357473a Add hack to fix Mopidy progress 2023-01-12 16:07:53 -05:00
cc7d267494 Update repeated attributes for scrobblable models 2023-01-12 16:05:47 -05:00
685c99d023 Fix proper scrobbling of podcasts 2023-01-12 16:05:29 -05:00
69f596039d Add better property for multiple media types
This adds a fun helper method on Scrobble instances to get whatever the
type should be based on media_obj
2023-01-12 15:42:05 -05:00
cf55c9b464 Fix parsing of podcast episode titles 2023-01-12 14:12:10 -05:00
8517212d0e Add podcasts as new media type 2023-01-12 13:56:56 -05:00
f435e60b80 Add very rudimentary fetching of art and metadata from MB 2023-01-12 11:10:25 -05:00
b51b189cd4 Fix sidebar to be accurate 2023-01-11 11:58:56 -05:00
2a20d1212b Bump version to 0.3.0 2023-01-11 11:56:12 -05:00
83b6ba9cc3 Add tabs and clean up main page 2023-01-11 11:55:44 -05:00
4f0d5ad7f4 Bump version to 0.2.2 2023-01-10 23:48:21 -05:00
2b81b28bff Fix bug with duplicate Jellyfin scrobbles 2023-01-10 23:47:26 -05:00
d0c88ce271 Bump version to 0.2.1 2023-01-10 16:14:33 -05:00
f8c9df3b9a Fix artist aggregation so it works 2023-01-10 16:13:52 -05:00
8b61dab1bc Fix aggregator and blacken things up 2023-01-10 15:21:12 -05:00
3655cd7934 Bump version to 0.2.0 2023-01-10 14:41:56 -05:00
602d1e0ddb Add better frontend, the first of many! 2023-01-10 14:40:59 -05:00
457828e04d Fix silly bug in classmethods for Scrobbles 2023-01-10 14:40:09 -05:00
107 changed files with 6936 additions and 701 deletions

7
.coveragerc Normal file
View File

@ -0,0 +1,7 @@
[run]
omit=
vrobbler/wsgi.py
vrobbler/asgi.py
vrobbler/cli.py
*admin.py
migrations/*

View File

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

@ -1,4 +1,5 @@
db.sqlite3
vrobbler.conf
/media/
/dist/
media/
dist/
.coverage

View File

@ -1,4 +1,7 @@
#+title: Readme
Vrobbler
========
[![Build Status](https://ci.unbl.ink/api/badges/secstate/vrobbler/status.svg?ref=refs/heads/main)](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
View File

@ -0,0 +1,4 @@
export ENV_PATH=$(poetry env info --path)
source "${ENV_PATH}/bin/activate"
export PYPI_PASSWORD="$(pass personal/apikey/pypi)"

739
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "vrobbler"
version = "0.1.9"
version = "0.8.1"
description = ""
authors = ["Colin Powell <colin@unbl.ink>"]
@ -16,7 +16,7 @@ djangorestframework = "^3.13.1"
Markdown = "^3.3.6"
django-filter = "^21.1"
Pillow = "^9.0.1"
psycopg2 = {version = "^2.9.3", extras = ["production"]}
psycopg2 = "^2.9.3"
dj-database-url = "^0.5.0"
django-mathfilters = "^1.0.0"
django-allauth = "^0.50.0"
@ -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

View File

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

View File

View File

View 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

View 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"

View 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"

View 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"

380
todos.org Normal file
View File

@ -0,0 +1,380 @@
#+title: TODOs
A fun way to keep track of things in the project to fix or improve.
* DONE [#A] Fix fetching artwork without release group :bug:
CLOSED: [2023-01-29 Sun 14:27]
When we get artwork from Musicbrianz, and it's not found, we should check for
release groups as well. This will stop issues with missing artwork because of
obscure MB release matches.
* DONE [#A] Fix Jellyfin music scrobbling N+1 past 90 completion percent :bug:
CLOSED: [2023-01-30 Mon 18:31]
:LOGBOOK:
CLOCK: [2023-01-30 Mon 18:00]--[2023-01-30 Mon 18:31] => 0:31
:END:
If we play music from Jellyfin and the track reaches 90% completion, the
scrobbling goes crazy and starts creating new scrobbles with every update.
The cause is pretty simple, but the solution is hard. We want to mark a scrobble
as complete for the following conditions:
- Play stopped and percent played beyond 90%
- Play completely finished
But if we keep listening beyond 90, we should basically ignore updates (or just
update the existing scrobble)
* DONE [#A] Add support for Audioscrobbler tab-separated file uploads :improvement:
CLOSED: [2023-02-03 Fri 16:52]
An example of the format:
#+begin_src csv
,
#AUDIOSCROBBLER/1.1
#TZ/UNKNOWN
#CLIENT/Rockbox sansaclipplus $Revision$
75 Dollar Bill I Was Real I Was Real 4 1015 S 1740494944 64ff5f53-d187-4512-827e-7606c69e66ff
75 Dollar Bill I Was Real I Was Real 4 1015 S 1740494990 64ff5f53-d187-4512-827e-7606c69e66ff
311 311 Down 1 173 S 1740495003 00476c23-fd9e-464b-9b27-a62d69f3d4f4
311 311 Down 1 173 L 1740495049 00476c23-fd9e-464b-9b27-a62d69f3d4f4
311 311 Down 1 173 L 1740495113 00476c23-fd9e-464b-9b27-a62d69f3d4f4
311 311 Random 2 187 S 1740495190 530c09f3-46fe-4d90-b11f-7b63bcb4b373
311 311 Random 2 187 L 1740495194 530c09f3-46fe-4d90-b11f-7b63bcb4b373
311 311 Jackolanterns Weather 3 204 L 1740495382 cc3b2dec-5d99-47ea-8930-20bf258be4ea
311 311 All Mixed Up 4 182 L 1740495586 980a78b5-5bdd-4f50-9e3a-e13261e2817b
311 311 Hive 5 179 L 1740495768 18f6dc98-d3a2-4f81-b967-97359d14c68c
311 311 Guns (Are for Pussies) 6 137 L 1740495948 5e97ed9f-c8cc-4282-9cbe-f8e17aee5128
311 311 Misdirected Hostility 7 179 S 1740496085 61ff2c1a-fc9c-44c3-8da1-5e50a44245af
,
#+end_src
* TODO [#A] Add ability to manually scrobble albums or tracks from MB :improvement:
Given a UUID from musicbrainz, we should be able to scrobble an album or
individual track.
* TODO [#A] Add django-storage to store files on S3 :improvement:
* TODO [#B] Adjust cancel/finish task to use javascript to submit :improvement:
* TODO [#B] Implement a detail view for TV shows :improvement:
* TODO [#B] Implement a detail view for Moviews :improvement:
* TODO [#B] Allow scrobbling music without MB IDs by grabbing them before scrobble :improvement:
This would allow a few nice flows. One, you'd be able to record the play of an
entire album by just dropping the muscibrainz_id in. This could be helpful for
offline listening. It would also mean bad metadata from mopidy would not break
scrobbling.
* TODO [#C] Implement keeping track of week/month/year chart-toppers :improvement:
:LOGBOOK:
CLOCK: [2023-01-30 Mon 16:30]--[2023-01-30 Mon 18:00] => 1:30
:END:
Maloja does this cool thing where artists and tracks get recorded as the top
track of a given week, month or year. They get gold, silver or bronze stars for
their place in the time period.
I could see this being implemented as a separate Chart table which gets
populated at the end of a time period and has a start and end date that defines
a period, along with a one, two, three instance.
Of course, it could also be a data model without a table, where it runs some fun
calculations, stores it's values in Redis as a long-term lookup table and just
has to re-populate when the server restarts.
* TODO [#C] Move to using more robust mopidy-webhooks pacakge form pypi :improvement:
** Example payloads from mopidy-webhooks
*** Podcast playback ended
#+begin_src json
{
"type": "event",
"event": "track_playback_ended",
"data": {
"tl_track": {
"__model__": "TlTrack",
"tlid": 13,
"track": {
"__model__": "Track",
"uri": "file:///var/lib/mopidy/media/podcasts/The%20Prince/2022-09-28-Wolf-warriors.mp3",
"name": "Wolf warriors",
"artists": [
{
"__model__": "Artist",
"name": "The Economist"
}
],
"album": {
"__model__": "Album",
"name": "The Prince",
"date": "2022"
},
"genre": "Blues",
"date": "2022",
"length": 2437778,
"bitrate": 127988
}
},
"time_position": 3290
}
}
#+end_src
*** Podcast playback state changes
#+begin_src json
{
"type": "event",
"event": "playback_state_changed",
"data": {
"old_state": "paused",
"new_state": "playing"
}
}
#+end_src
#+begin_src json
{
"type": "event",
"event": "playback_state_changed",
"data": {
"old_state": "stopped",
"new_state": "playing"
}
}
#+end_src
*** Podcast playback started
#+begin_src json
{
"type": "event",
"event": "track_playback_started",
"data": {
"tl_track": {
"__model__": "TlTrack",
"tlid": 13,
"track": {
"__model__": "Track",
"uri": "file:///var/lib/mopidy/media/podcasts/The%20Prince/2022-09-28-Wolf-warriors.mp3",
"name": "Wolf warriors",
"artists": [
{
"__model__": "Artist",
"name": "The Economist"
}
],
"album": {
"__model__": "Album",
"name": "The Prince",
"date": "2022"
},
"genre": "Blues",
"date": "2022",
"length": 2437778,
"bitrate": 127988
}
}
}
}
#+end_src
*** Podcast playback paused
#+begin_src json
{
"type": "status",
"data": {
"state": "paused",
"current_track": {
"__model__": "Track",
"uri": "file:///var/lib/mopidy/media/podcasts/The%20Prince/2022-09-28-Wolf-warriors.mp3",
"name": "Wolf warriors",
"artists": [
{
"__model__": "Artist",
"name": "The Economist"
}
],
"album": {
"__model__": "Album",
"name": "The Prince",
"date": "2022"
},
"genre": "Blues",
"date": "2022",
"length": 2437778,
"bitrate": 127988
},
"time_position": 2350
}
}
#+end_src
*** Track playback started
#+begin_src json
{
"type": "event",
"event": "track_playback_started",
"data": {
"tl_track": {
"__model__": "TlTrack",
"tlid": 14,
"track": {
"__model__": "Track",
"uri": "local:track:Various%20Artists%20-%202008%20-%20Twilight%20OST/01-muse-supermassive_black_hole.mp3",
"name": "Supermassive Black Hole",
"artists": [
{
"__model__": "Artist",
"uri": "local:artist:md5:250dd6551b66a58a6b4897aa697f200c",
"name": "Muse",
"musicbrainz_id": "9c9f1380-2516-4fc9-a3e6-f9f61941d090"
}
],
"album": {
"__model__": "Album",
"uri": "local:album:md5:455343d54cdd89cb5a3b5ad537ea99d0",
"name": "Twilight: Original Motion Picture Soundtrack",
"artists": [
{
"__model__": "Artist",
"uri": "local:artist:md5:54e4db2d5624f80b0cc290346e696756",
"name": "Various Artists",
"musicbrainz_id": "89ad4ac3-39f7-470e-963a-56509c546377"
}
],
"num_tracks": 12,
"num_discs": 1,
"date": "2008-11-04",
"musicbrainz_id": "b4889eaf-d9f4-434c-a68d-69227b12b6a4"
},
"composers": [
{
"__model__": "Artist",
"uri": "local:artist:md5:4d49cbca0b347e0a89047bb019d2779d",
"name": "Matt Bellamy"
}
],
"genre": "Rock",
"track_no": 1,
"disc_no": 1,
"date": "2008-11-04",
"length": 211121,
"musicbrainz_id": "ff1e3e1a-f6e8-4692-b426-355880383bb6",
"last_modified": 1672712949510
}
}
}
}
#+end_src
*** Track playback in progress
#+begin_src json
{
"type": "status",
"data": {
"state": "playing",
"current_track": {
"__model__": "Track",
"uri": "local:track:Various%20Artists%20-%202008%20-%20Twilight%20OST/01-muse-supermassive_black_hole.mp3",
"name": "Supermassive Black Hole",
"artists": [
{
"__model__": "Artist",
"uri": "local:artist:md5:250dd6551b66a58a6b4897aa697f200c",
"name": "Muse",
"musicbrainz_id": "9c9f1380-2516-4fc9-a3e6-f9f61941d090"
}
],
"album": {
"__model__": "Album",
"uri": "local:album:md5:455343d54cdd89cb5a3b5ad537ea99d0",
"name": "Twilight: Original Motion Picture Soundtrack",
"artists": [
{
"__model__": "Artist",
"uri": "local:artist:md5:54e4db2d5624f80b0cc290346e696756",
"name": "Various Artists",
"musicbrainz_id": "89ad4ac3-39f7-470e-963a-56509c546377"
}
],
"num_tracks": 12,
"num_discs": 1,
"date": "2008-11-04",
"musicbrainz_id": "b4889eaf-d9f4-434c-a68d-69227b12b6a4"
},
"composers": [
{
"__model__": "Artist",
"uri": "local:artist:md5:4d49cbca0b347e0a89047bb019d2779d",
"name": "Matt Bellamy"
}
],
"genre": "Rock",
"track_no": 1,
"disc_no": 1,
"date": "2008-11-04",
"length": 211121,
"musicbrainz_id": "ff1e3e1a-f6e8-4692-b426-355880383bb6",
"last_modified": 1672712949510
},
"time_position": 17031
}
}
#+end_src
*** Track event playback paused
#+begin_src json
{
"type": "event",
"event": "track_playback_paused",
"data": {
"tl_track": {
"__model__": "TlTrack",
"tlid": 14,
"track": {
"__model__": "Track",
"uri": "local:track:Various%20Artists%20-%202008%20-%20Twilight%20OST/01-muse-supermassive_black_hole.mp3",
"name": "Supermassive Black Hole",
"artists": [
{
"__model__": "Artist",
"uri": "local:artist:md5:250dd6551b66a58a6b4897aa697f200c",
"name": "Muse",
"musicbrainz_id": "9c9f1380-2516-4fc9-a3e6-f9f61941d090"
}
],
"album": {
"__model__": "Album",
"uri": "local:album:md5:455343d54cdd89cb5a3b5ad537ea99d0",
"name": "Twilight: Original Motion Picture Soundtrack",
"artists": [
{
"__model__": "Artist",
"uri": "local:artist:md5:54e4db2d5624f80b0cc290346e696756",
"name": "Various Artists",
"musicbrainz_id": "89ad4ac3-39f7-470e-963a-56509c546377"
}
],
"num_tracks": 12,
"num_discs": 1,
"date": "2008-11-04",
"musicbrainz_id": "b4889eaf-d9f4-434c-a68d-69227b12b6a4"
},
"composers": [
{
"__model__": "Artist",
"uri": "local:artist:md5:4d49cbca0b347e0a89047bb019d2779d",
"name": "Matt Bellamy"
}
],
"genre": "Rock",
"track_no": 1,
"disc_no": 1,
"date": "2008-11-04",
"length": 211121,
"musicbrainz_id": "ff1e3e1a-f6e8-4692-b426-355880383bb6",
"last_modified": 1672712949510
}
},
"time_position": 67578
}
}
#+end_src
* TODO [#C] Consider a purge command for duplicated and stuck in-progress scrobbles :improvement:
* TODO Figure out how to add to web-scrobbler :imropvement:
An example:
https://github.com/web-scrobbler/web-scrobbler/blob/master/src/core/background/scrobbler/maloja-scrobbler.js
* TODO When updating musicbrainz IDs, clear and run fetch artwrok :improvement:

8
vrobbler.conf.test Normal file
View 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

View File

@ -2,12 +2,19 @@ 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):
@ -15,6 +22,7 @@ class ArtistAdmin(admin.ModelAdmin):
list_display = ("name", "musicbrainz_id")
ordering = ("name",)
@admin.register(Track)
class TrackAdmin(admin.ModelAdmin):
date_hierarchy = "created"
@ -27,3 +35,6 @@ class TrackAdmin(admin.ModelAdmin):
)
list_filter = ("album", "artist")
ordering = ("-created",)
inlines = [
ScrobbleInline,
]

View 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()

View File

@ -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')},
),
]

View File

@ -1,25 +0,0 @@
# Generated by Django 4.1.5 on 2023-01-09 16:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('music', '0003_album_uuid_artist_uuid_track_uuid'),
]
operations = [
migrations.AddField(
model_name='track',
name='thumbs',
field=models.IntegerField(
choices=[
(-1, 'Thumbs down'),
(0, 'No opinion'),
(1, 'Thumbs up'),
],
default=0,
),
),
]

View 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/'
),
),
]

View 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'
),
),
]

View 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'),
),
]

View 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={},
),
]

View File

@ -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')},
),
]

View File

@ -2,37 +2,27 @@ import logging
from typing import Dict, Optional
from uuid import uuid4
from django.apps.config import cached_property
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 Album(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
name = models.CharField(max_length=255)
year = models.IntegerField(**BNULL)
musicbrainz_id = models.CharField(max_length=255, **BNULL)
musicbrainz_releasegroup_id = models.CharField(max_length=255, **BNULL)
musicbrainz_albumartist_id = models.CharField(max_length=255, **BNULL)
def __str__(self):
return self.name
@property
def mb_link(self):
return f"https://musicbrainz.org/release/{self.musicbrainz_id}"
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
@ -40,33 +30,141 @@ class Artist(TimeStampedModel):
def mb_link(self):
return f"https://musicbrainz.org/artist/{self.musicbrainz_id}"
def get_absolute_url(self):
return reverse('music:artist_detail', kwargs={'slug': self.uuid})
def scrobbles(self):
from scrobbles.models import Scrobble
return Scrobble.objects.filter(
track__in=self.track_set.all()
).order_by('-timestamp')
@property
def tracks(self):
return (
self.track_set.all()
.annotate(scrobble_count=models.Count('scrobble'))
.order_by('-scrobble_count')
)
def charts(self):
from scrobbles.models import ChartRecord
return ChartRecord.objects.filter(track__artist=self).order_by('-year')
class Album(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
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 Track(TimeStampedModel):
class Opinion(models.IntegerChoices):
DOWN = -1, 'Thumbs down'
NEUTRAL = 0, 'No opinion'
UP = 1, 'Thumbs up'
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
title = models.CharField(max_length=255, **BNULL)
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)
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:
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}"
@cached_property
def scrobble_count(self):
return self.scrobble_set.filter(in_progress=False).count()
@classmethod
def find_or_create(
cls, artist_dict: Dict, album_dict: Dict, track_dict: Dict
@ -80,28 +178,20 @@ class Track(TimeStampedModel):
'musicbrainz_id'
):
logger.warning(
f"No artist or artist musicbrainz ID found in message from Jellyfin, not scrobbling"
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 album {artist}")
else:
logger.debug(f"Found album {artist}")
artist, artist_created = Artist.objects.get_or_create(**artist_dict)
album, album_created = Album.objects.get_or_create(**album_dict)
if album_created:
logger.debug(f"Created new album {album}")
else:
logger.debug(f"Found album {album}")
album.fix_metadata()
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

View 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',
),
]

View 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

View File

View 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,
]

View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class PodcastsConfig(AppConfig):
name = 'podcasts'

View 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,
},
),
]

View File

@ -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',
),
),
]

View 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),
),
]

View 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),
),
]

View File

@ -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),
),
]

View 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

View File

View 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",)

View 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:]

File diff suppressed because it is too large Load Diff

View 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}"

View 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)

View 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))
)

View File

@ -1,20 +1,58 @@
from django.contrib import admin
from scrobbles.models import Scrobble
from scrobbles.models import AudioScrobblerTSVImport, Scrobble
class ScrobbleInline(admin.TabularInline):
model = Scrobble
extra = 0
raw_id_fields = ('video', 'podcast_episode', 'track')
exclude = ('source_id', 'scrobble_log')
@admin.register(AudioScrobblerTSVImport)
class AudioScrobblerTSVImportAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = ("uuid", "created", "process_count", "tsv_file")
ordering = ("-created",)
@admin.register(Scrobble)
class ScrobbleAdmin(admin.ModelAdmin):
date_hierarchy = "timestamp"
list_display = (
"timestamp",
"video",
"track",
"media_name",
"media_type",
"playback_percent",
"source",
"playback_position",
"in_progress",
"is_paused",
"played_to_completion",
)
list_filter = ("in_progress", "source")
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

View File

@ -1,3 +1,2 @@
#!/usr/bin/env python3
JELLYFIN_VIDEO_ITEM_TYPES = ["Episode", "Movie"]
JELLYFIN_AUDIO_ITEM_TYPES = ["Audio"]

View File

@ -0,0 +1,66 @@
import csv
import tempfile
from scrobbles.models import Scrobble
from django.db.models import Q
def export_scrobbles(start_date=None, end_date=None, format="AS"):
start_query = Q()
end_query = Q()
if start_date:
start_query = Q(timestamp__gte=start_date)
if start_date:
end_query = Q(timestamp__lte=end_date)
scrobble_qs = Scrobble.objects.filter(
start_query, end_query, track__isnull=False
)
headers = []
extension = 'tsv'
delimiter = '\t'
if format == "as":
headers = [
['#AUDIOSCROBBLER/1.1'],
['#TZ/UTC'],
['#CLIENT/Vrobbler 1.0.0'],
]
if format == "csv":
delimiter = ','
extension = 'csv'
headers = [
[
"artists",
"album",
"title",
"track_number",
"run_time",
"rating",
"timestamp",
"musicbrainz_id",
]
]
with tempfile.NamedTemporaryFile(mode='w', delete=False) as outfile:
writer = csv.writer(outfile, delimiter=delimiter)
for row in headers:
writer.writerow(row)
for scrobble in scrobble_qs:
track = scrobble.track
track_number = 0 # TODO Add track number
track_rating = "S" # TODO implement ratings?
row = [
track.album.primary_artist.name,
track.album.name,
track.title,
track_number,
track.run_time,
track_rating,
scrobble.timestamp.strftime('%s'),
track.musicbrainz_id,
]
writer.writerow(row)
return outfile.name, extension

View File

@ -0,0 +1,25 @@
from django import forms
class ExportScrobbleForm(forms.Form):
"""Provide options for downloading scrobbles"""
EXPORT_TYPES = (
('as', 'Audioscrobbler'),
('csv', 'CSV'),
('html', 'HTML'),
)
export_type = forms.ChoiceField(choices=EXPORT_TYPES)
class ScrobbleForm(forms.Form):
item_id = forms.CharField(
label="",
widget=forms.TextInput(
attrs={
'class': "form-control form-control-dark w-100",
'placeholder': "Scrobble something (IMDB ID, String, TVDB ID ...)",
'aria-label': "Scrobble something",
}
),
)

View File

@ -0,0 +1,61 @@
import logging
from django.utils import timezone
from imdb import Cinemagoer
imdb_client = Cinemagoer()
logger = logging.getLogger(__name__)
def lookup_video_from_imdb(imdb_id: str) -> dict:
if 'tt' not in imdb_id:
logger.warning(f"IMDB ID should begin with 'tt' {imdb_id}")
return
lookup_id = imdb_id.strip('tt')
media = imdb_client.get_movie(lookup_id)
run_time_seconds = 60 * 60
runtimes = media.get("runtimes")
if runtimes:
run_time_seconds = int(runtimes[0]) * 60
# Ticks otherwise known as miliseconds
run_time_ticks = run_time_seconds * 1000 * 1000
item_type = "Movie"
if media.get('series title'):
item_type = "Episode"
try:
plot = media.get('plot')[0]
except TypeError:
plot = ""
except IndexError:
plot = ""
logger.debug(f"Received data from IMDB: {media.__dict__}")
# Build a rough approximation of a Jellyfin data response
data_dict = {
"ItemType": item_type,
"Name": media.get('title'),
"Overview": plot,
"Tagline": media.get('tagline'),
"Year": media.get('year'),
"Provider_imdb": imdb_id,
"RunTime": run_time_seconds,
"RunTimeTicks": run_time_ticks,
"SeriesName": media.get('series title'),
"EpisodeNumber": media.get('episode'),
"SeasonNumber": media.get('season'),
"PlaybackPositionTicks": 1,
"PlaybackPosition": 1,
"UtcTimestamp": timezone.now().strftime('%Y-%m-%d %H:%M:%S.%f%z'),
"IsPaused": False,
"PlayedToCompletion": False,
}
logger.debug(f"Parsed data from IMDB data: {data_dict}")
return data_dict

View File

@ -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',
),
),
]

View File

@ -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',
),
),
]

View 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
),
]

View 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,
},
),
]

View 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,
),
),
]

View File

@ -0,0 +1,52 @@
# Generated by Django 4.1.5 on 2023-02-03 19:50
from django.db import migrations, models
import django_extensions.db.fields
class Migration(migrations.Migration):
dependencies = [
('scrobbles', '0011_chartrecord_user'),
]
operations = [
migrations.CreateModel(
name='AudioScrobblerTSVImport',
fields=[
(
'id',
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name='ID',
),
),
(
'created',
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name='created'
),
),
(
'modified',
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name='modified'
),
),
(
'tsv_file',
models.FileField(
blank=True,
null=True,
upload_to='audioscrobbler-uploads/%Y/%m-%d/',
),
),
],
options={
'get_latest_by': 'modified',
'abstract': False,
},
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.1.5 on 2023-02-03 20:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('scrobbles', '0012_audioscrobblertsvimport'),
]
operations = [
migrations.AddField(
model_name='audioscrobblertsvimport',
name='processed_on',
field=models.DateTimeField(blank=True, null=True),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.1.5 on 2023-02-03 22:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('scrobbles', '0013_audioscrobblertsvimport_processed_on'),
]
operations = [
migrations.AddField(
model_name='audioscrobblertsvimport',
name='process_log',
field=models.TextField(blank=True, null=True),
),
]

View File

@ -0,0 +1,29 @@
# Generated by Django 4.1.5 on 2023-02-03 23:36
from django.db import migrations, models
import scrobbles.models
import uuid
class Migration(migrations.Migration):
dependencies = [
('scrobbles', '0014_audioscrobblertsvimport_process_log'),
]
operations = [
migrations.AddField(
model_name='audioscrobblertsvimport',
name='uuid',
field=models.UUIDField(default=uuid.uuid4, editable=False),
),
migrations.AlterField(
model_name='audioscrobblertsvimport',
name='tsv_file',
field=models.FileField(
blank=True,
null=True,
upload_to=scrobbles.models.AudioScrobblerTSVImport.get_path,
),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.1.5 on 2023-02-03 23:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('scrobbles', '0015_audioscrobblertsvimport_uuid_and_more'),
]
operations = [
migrations.AddField(
model_name='audioscrobblertsvimport',
name='process_count',
field=models.IntegerField(blank=True, null=True),
),
]

View File

@ -0,0 +1,19 @@
from uuid import uuid4
from django.db import models
from django_extensions.db.models import TimeStampedModel
BNULL = {"blank": True, "null": True}
class ScrobblableMixin(TimeStampedModel):
SECONDS_TO_STALE = 1600
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

View File

@ -1,27 +1,163 @@
import calendar
import logging
from datetime import timedelta
from typing import Optional
from uuid import uuid4
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import models
from django.utils import timezone
from django_extensions.db.models import TimeStampedModel
from music.models import Track
from videos.models import Video
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
logger = logging.getLogger(__name__)
User = get_user_model()
BNULL = {"blank": True, "null": True}
VIDEO_BACKOFF = getattr(settings, 'VIDEO_BACKOFF_MINUTES')
TRACK_BACKOFF = getattr(settings, 'MUSIC_BACKOFF_SECONDS')
VIDEO_WAIT_PERIOD = getattr(settings, 'VIDEO_WAIT_PERIOD_DAYS')
TRACK_WAIT_PERIOD = getattr(settings, 'MUSIC_WAIT_PERIOD_MINUTES')
class AudioScrobblerTSVImport(TimeStampedModel):
def get_path(instance, filename):
extension = filename.split('.')[-1]
uuid = instance.uuid
return f'audioscrobbler-uploads/{uuid}.{extension}'
uuid = models.UUIDField(editable=False, default=uuid4)
tsv_file = models.FileField(upload_to=get_path, **BNULL)
processed_on = models.DateTimeField(**BNULL)
process_log = models.TextField(**BNULL)
process_count = models.IntegerField(**BNULL)
def __str__(self):
if self.tsv_file:
return f"Audioscrobbler TSV upload: {self.tsv_file.path}"
return f"Audioscrobbler TSV upload {self.id}"
def save(self, **kwargs):
"""On save, attempt to import the TSV file"""
super().save(**kwargs)
self.process()
return
def process(self, force=False):
from scrobbles.tsv import process_audioscrobbler_tsv_file
if self.processed_on and not force:
logger.info(f"{self} already processed on {self.processed_on}")
return
scrobbles = process_audioscrobbler_tsv_file(self.tsv_file.path)
if scrobbles:
self.process_log = f"Created {len(scrobbles)} scrobbles"
for scrobble in scrobbles:
scrobble_str = f"{scrobble.id}\t{scrobble.timestamp}\t{scrobble.track.title}"
self.process_log += f"\n{scrobble_str}"
self.process_count = len(scrobbles)
else:
self.process_log = f"Created no new scrobbles"
self.process_count = 0
self.processed_on = timezone.now()
self.save(
update_fields=['processed_on', 'process_count', 'process_log']
)
def undo(self, dryrun=True):
from scrobbles.tsv import undo_audioscrobbler_tsv_import
undo_audioscrobbler_tsv_import(self.process_log, dryrun)
class ChartRecord(TimeStampedModel):
"""Sort of like a materialized view for what we could dynamically generate,
but would kill the DB as it gets larger. Collects time-based records
generated by a cron-like archival job
1972 by Josh Rouse - #3 in 2023, January
"""
user = models.ForeignKey(User, on_delete=models.DO_NOTHING, **BNULL)
rank = models.IntegerField()
year = models.IntegerField(default=timezone.now().year)
month = models.IntegerField(**BNULL)
week = models.IntegerField(**BNULL)
day = models.IntegerField(**BNULL)
video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL)
series = models.ForeignKey(Series, on_delete=models.DO_NOTHING, **BNULL)
artist = models.ForeignKey(Artist, on_delete=models.DO_NOTHING, **BNULL)
track = models.ForeignKey(Track, on_delete=models.DO_NOTHING, **BNULL)
@property
def media_obj(self):
media_obj = None
if self.video:
media_obj = self.video
if self.track:
media_obj = self.track
return media_obj
@property
def month_str(self) -> str:
month_str = ""
if self.month:
month_str = calendar.month_name[self.month]
return month_str
@property
def day_str(self) -> str:
day_str = ""
if self.day:
day_str = str(self.day)
return day_str
@property
def week_str(self) -> str:
week_str = ""
if self.week:
week_str = str(self.week)
return "Week " + week_str
@property
def period(self) -> str:
period = str(self.year)
if self.month:
period = " ".join([self.month_str, period])
if self.week:
period = " ".join([self.week_str, period])
if self.day:
period = " ".join([self.day_str, period])
return period
@property
def period_type(self) -> str:
period = 'year'
if self.month:
period = 'month'
if self.week:
period = 'week'
if self.day:
period = 'day'
return period
def __str__(self):
return f"#{self.rank} in {self.period} - {self.media_obj}"
class Scrobble(TimeStampedModel):
"""A scrobble tracks played media items by a user."""
uuid = models.UUIDField(editable=False, **BNULL)
video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL)
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
)
@ -35,166 +171,189 @@ 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 is_stale(self) -> bool:
"""Mark scrobble as stale if it's been more than an hour since it was updated"""
is_stale = False
now = timezone.now()
seconds_since_last_update = (now - self.modified).seconds
if seconds_since_last_update >= self.media_obj.SECONDS_TO_STALE:
is_stale = True
return is_stale
@property
def percent_played(self) -> int:
if self.playback_position_ticks and self.media_run_time_ticks:
return int(
(self.playback_position_ticks / self.media_run_time_ticks)
* 100
)
# If we don't have media_run_time_ticks, let's guess from created time
now = timezone.now()
playback_duration = (now - self.created).seconds
if playback_duration and self.track.run_time:
return int((playback_duration / int(self.track.run_time)) * 100)
if not self.media_obj.run_time_ticks:
return 100
return 0
if not self.playback_position_ticks and self.played_to_completion:
return 100
playback_ticks = self.playback_position_ticks
if not playback_ticks:
playback_ticks = (timezone.now() - self.timestamp).seconds * 1000
percent = int((playback_ticks / self.media_obj.run_time_ticks) * 100)
if percent > 100:
percent = 100
return percent
@property
def media_run_time_ticks(self) -> int:
def can_be_updated(self) -> bool:
updatable = True
if self.percent_played > 100:
logger.info(f"No - 100% played - {self.id} - {self.source}")
updatable = False
if self.is_stale:
logger.info(f"No - stale - {self.id} - {self.source}")
updatable = False
return updatable
@property
def media_obj(self):
media_obj = None
if self.video:
return self.video.run_time_ticks
media_obj = self.video
if self.track:
return self.track.run_time_ticks
# this is hacky, but want to avoid divide by zero
return 1
def is_stale(self, backoff, wait_period) -> bool:
scrobble_is_stale = self.in_progress and self.modified > wait_period
# Check if found in progress scrobble is more than a day old
if scrobble_is_stale:
logger.info(
'Found a in-progress scrobble for this item more than a day old, creating a new scrobble'
)
delete_stale_scrobbles = getattr(
settings, "DELETE_STALE_SCROBBLES", True
)
if delete_stale_scrobbles:
logger.info(
'Deleting {scrobble} that has been in-progress too long'
)
self.delete()
return scrobble_is_stale
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):
media = None
if self.video:
media = self.video
if self.track:
media = self.track
return (
f"Scrobble of {media} {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, jellyfin_data: dict
def create_or_update(
cls, media, user_id: int, scrobble_data: dict
) -> "Scrobble":
jellyfin_data['video_id'] = video.id
logger.debug(
f"Creating or updating scrobble for video {video} with data {jellyfin_data}"
)
if media.__class__.__name__ == 'Track':
media_query = models.Q(track=media)
scrobble_data['track_id'] = media.id
if media.__class__.__name__ == 'Video':
media_query = models.Q(video=media)
scrobble_data['video_id'] = media.id
if media.__class__.__name__ == 'Episode':
media_query = models.Q(podcast_episode=media)
scrobble_data['podcast_id'] = media.id
if media.__class__.__name__ == 'SportEvent':
media_query = models.Q(sport_event=media)
scrobble_data['sport_event_id'] = media.id
scrobble = (
Scrobble.objects.filter(video=video, user_id=user_id)
cls.objects.filter(
media_query,
user_id=user_id,
)
.order_by('-modified')
.first()
)
if scrobble and scrobble.can_be_updated:
logger.info(
f"Updating {scrobble.id}",
{"scrobble_data": scrobble_data, "media": media},
)
return scrobble.update(scrobble_data)
# Backoff is how long until we consider this a new scrobble
backoff = timezone.now() + timedelta(minutes=VIDEO_BACKOFF)
wait_period = timezone.now() + timedelta(days=VIDEO_WAIT_PERIOD)
return cls.update_or_create(
scrobble, backoff, wait_period, jellyfin_data
source = scrobble_data['source']
logger.info(
f"Creating for {media.id} - {source}",
{"scrobble_data": scrobble_data, "media": media},
)
# If creating a new scrobble, we don't need status
scrobble_data.pop('mopidy_status', None)
scrobble_data.pop('jellyfin_status', None)
return cls.create(scrobble_data)
@classmethod
def create_or_update_for_track(
cls, track: "Track", user_id: int, scrobble_data: dict
) -> "Scrobble":
scrobble_data['track_id'] = track.id
scrobble = (
Scrobble.objects.filter(track=track, user_id=user_id)
.order_by('-modified')
.first()
)
logger.debug(
f"Found existing scrobble for track {track}, updating",
{"scrobble_data": scrobble_data},
)
backoff = timezone.now() + timedelta(seconds=TRACK_BACKOFF)
wait_period = timezone.now() + timedelta(minutes=TRACK_WAIT_PERIOD)
return cls.update_or_create(
scrobble, backoff, wait_period, scrobble_data
)
@classmethod
def update_or_create(
cls,
scrobble: Optional["Scrobble"],
backoff,
wait_period,
scrobble_data: dict,
) -> Optional["Scrobble"]:
def update(self, scrobble_data: dict) -> "Scrobble":
# Status is a field we get from Mopidy, which refuses to poll us
mopidy_status = scrobble_data.pop('status', None)
scrobble_is_stale = False
scrobble_status = scrobble_data.pop('mopidy_status', None)
if not scrobble_status:
scrobble_status = scrobble_data.pop('jellyfin_status', None)
if mopidy_status == "stopped":
logger.info(f"Mopidy sent a message to stop {scrobble}")
if not scrobble:
logger.warning(
'Mopidy sent us a stopped message, without ever starting'
)
return
if self.percent_played < 100:
# Only worry about ticks if we haven't gotten to the end
self.update_ticks(scrobble_data)
# Mopidy finished a play, scrobble away
scrobble.in_progress = False
scrobble.save(update_fields=['in_progress'])
return scrobble
# On stop, stop progress and send it to the check for completion
if scrobble_status == "stopped":
self.stop()
# On pause, set is_paused and stop scrobbling
if scrobble_status == "paused":
self.pause()
if scrobble_status == "resumed":
self.resume()
if scrobble and not mopidy_status:
scrobble_is_finished = (
not scrobble.in_progress and scrobble.modified < backoff
)
if scrobble_is_finished:
logger.info(
'Found a very recent scrobble for this item, holding off scrobbling again'
)
return
scrobble_is_stale = scrobble.is_stale(backoff, wait_period)
if (not scrobble or scrobble_is_stale) or mopidy_status:
# If we default this to "" we can probably remove this
scrobble_data['scrobble_log'] = ""
scrobble = cls.objects.create(
**scrobble_data,
)
else:
for key, value in scrobble_data.items():
setattr(scrobble, key, value)
scrobble.save()
# If we hit our completion threshold, save it and get ready
# to scrobble again if we re-watch this.
if scrobble.percent_played >= getattr(
settings, "PERCENT_FOR_COMPLETION", 95
):
scrobble.in_progress = False
scrobble.playback_position_ticks = scrobble.media_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'])
for key, value in scrobble_data.items():
setattr(self, key, value)
self.save()
return self
@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:
return
self.in_progress = False
self.save(update_fields=['in_progress'])
logger.info(f"{self.id} - {self.source}")
check_scrobble_for_finish(self, force_finish)
def pause(self) -> None:
if self.is_paused:
logger.warning(f"{self.id} - already paused - {self.source}")
return
self.is_paused = True
self.save(update_fields=["is_paused"])
logger.info(f"{self.id} - pausing - {self.source}")
check_scrobble_for_finish(self)
def resume(self) -> None:
if self.is_paused or not self.in_progress:
self.is_paused = False
self.in_progress = True
logger.info(f"{self.id} - resuming - {self.source}")
return self.save(update_fields=["is_paused", "in_progress"])
def cancel(self) -> None:
check_scrobble_for_finish(self, force_finish=True)
self.delete()
def update_ticks(self, data) -> None:
self.playback_position_ticks = data.get("playback_position_ticks")
self.playback_position = data.get("playback_position")
logger.info(
f"{self.id} - {self.playback_position_ticks} - {self.source}"
)
self.save(
update_fields=['playback_position_ticks', 'playback_position']
)

View File

@ -0,0 +1,217 @@
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(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(track, user_id, mopidy_data)
return scrobble
def build_scrobble_dict(data_dict: dict, user_id: int) -> dict:
jellyfin_status = "resumed"
if data_dict.get("IsPaused"):
jellyfin_status = "paused"
elif data_dict.get("NotificationType") == 'PlaybackStop':
jellyfin_status = "stopped"
playback_ticks = data_dict.get("PlaybackPositionTicks", "")
if playback_ticks:
playback_ticks = playback_ticks // 10000
return {
"user_id": user_id,
"timestamp": parse(data_dict.get("UtcTimestamp")),
"playback_position_ticks": playback_ticks,
"playback_position": data_dict.get("PlaybackPosition", ""),
"source": data_dict.get("ClientName", "Vrobbler"),
"source_id": data_dict.get('MediaSourceId'),
"jellyfin_status": jellyfin_status,
}
def jellyfin_scrobble_track(
data_dict: dict, user_id: Optional[int]
) -> Optional[Scrobble]:
if not data_dict.get("Provider_musicbrainztrack", None):
# TODO we should be able to look up tracks via MB rather than error out
logger.error(
"No MBrainz Track ID received. This is likely because all metadata is bad, not scrobbling"
)
return
null_position_on_progress = (
data_dict.get("PlaybackPosition") == "00:00:00"
and data_dict.get("NotificationType") == "PlaybackProgress"
)
# Jellyfin has some race conditions with it's webhooks, these hacks fix some of them
if not data_dict.get("PlaybackPositionTicks") or null_position_on_progress:
logger.error("No playback position tick from Jellyfin, aborting")
return
artist_dict = {
'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 = build_scrobble_dict(data_dict, user_id)
# A hack to make Jellyfin work more like Mopidy for music tracks
scrobble_dict["playback_position_ticks"] = 0
scrobble_dict["playback_position"] = ""
return Scrobble.create_or_update(track, user_id, scrobble_dict)
def jellyfin_scrobble_video(data_dict: dict, user_id: Optional[int]):
if not data_dict.get("Provider_imdb", None):
logger.error(
"No IMDB ID received. This is likely because all metadata is bad, not scrobbling"
)
return
video = Video.find_or_create(data_dict)
scrobble_dict = build_scrobble_dict(data_dict, user_id)
return Scrobble.create_or_update(video, user_id, scrobble_dict)
def manual_scrobble_video(data_dict: dict, user_id: Optional[int]):
if not data_dict.get("Provider_imdb", None):
logger.error(
"No IMDB ID received. This is likely because all metadata is bad, not scrobbling"
)
return
video = Video.find_or_create(data_dict)
scrobble_dict = build_scrobble_dict(data_dict, user_id)
return Scrobble.create_or_update(video, user_id, scrobble_dict)
def manual_scrobble_event(data_dict: dict, user_id: Optional[int]):
if not data_dict.get("Provider_thesportsdb", None):
logger.error(
"No TheSportsDB ID received. This is likely because all metadata is bad, not scrobbling"
)
return
event = SportEvent.find_or_create(data_dict)
scrobble_dict = build_scrobble_dict(data_dict, user_id)
return Scrobble.create_or_update(event, user_id, scrobble_dict)

View File

@ -1,5 +1,11 @@
from rest_framework import serializers
from scrobbles.models import Scrobble
from scrobbles.models import Scrobble, AudioScrobblerTSVImport
class AudioScrobblerTSVImportSerializer(serializers.ModelSerializer):
class Meta:
model = AudioScrobblerTSVImport
fields = ('tsv_file',)
class ScrobbleSerializer(serializers.ModelSerializer):

View 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)

View File

@ -0,0 +1,51 @@
import logging
from dateutil.parser import parse
from django.conf import settings
from django.utils import timezone
from pysportsdb import TheSportsDbClient
from sports.models import Sport
logger = logging.getLogger(__name__)
API_KEY = getattr(settings, "THESPORTSDB_API_KEY", "2")
client = TheSportsDbClient(api_key=API_KEY)
def lookup_event_from_thesportsdb(event_id: str) -> dict:
event = client.lookup_event(event_id)['events'][0]
if not event or type(event) != dict:
return {}
league = {} # client.lookup_league(league_id=event.get('idLeague'))
event_type = "Game"
sport, _created = Sport.objects.get_or_create(
thesportsdb_id=event.get('strSport')
)
data_dict = {
"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

View File

@ -0,0 +1,118 @@
import csv
import logging
from datetime import datetime
import pytz
from music.models import Album, Artist, Track
from scrobbles.models import Scrobble
logger = logging.getLogger(__name__)
def process_audioscrobbler_tsv_file(file_path):
"""Takes a path to a file of TSV data and imports it as past scrobbles"""
new_scrobbles = []
with open(file_path) as infile:
source = 'Audioscrobbler File'
rows = csv.reader(infile, delimiter="\t")
source_id = ""
for row_num, row in enumerate(rows):
if row_num in [0, 1, 2]:
source_id += row[0] + "\n"
continue
if len(row) > 8:
logger.warning(
'Improper row length during Audioscrobbler import',
extra={'row': row},
)
continue
artist, artist_created = Artist.objects.get_or_create(name=row[0])
if artist_created:
logger.debug(f"Created artist {artist}")
else:
logger.debug(f"Found artist {artist}")
album = None
album_created = False
albums = Album.objects.filter(name=row[1])
if albums.count() == 1:
album = albums.first()
else:
for potential_album in albums:
if artist in album.artist_set.all():
album = potential_album
if not album:
album_created = True
album = Album.objects.create(name=row[1])
album.save()
album.artists.add(artist)
if album_created:
logger.debug(f"Created album {album}")
else:
logger.debug(f"Found album {album}")
track, track_created = Track.objects.get_or_create(
title=row[2],
artist=artist,
album=album,
)
if track_created:
logger.debug(f"Created track {track}")
else:
logger.debug(f"Found track {track}")
if track_created:
track.musicbrainz_id = row[7]
track.save()
timestamp = datetime.utcfromtimestamp(int(row[6])).replace(
tzinfo=pytz.utc
)
source = 'Audioscrobbler File'
new_scrobble = Scrobble(
timestamp=timestamp,
source=source,
source_id=source_id,
track=track,
played_to_completion=True,
in_progress=False,
)
existing = Scrobble.objects.filter(
timestamp=timestamp, track=track
).first()
if existing:
logger.debug(f"Skipping existing scrobble {new_scrobble}")
continue
logger.debug(f"Queued scrobble {new_scrobble} for creation")
new_scrobbles.append(new_scrobble)
created = Scrobble.objects.bulk_create(new_scrobbles)
logger.info(
f"Created {len(created)} scrobbles",
extra={'created_scrobbles': created},
)
return created
def undo_audioscrobbler_tsv_import(process_log, dryrun=True):
"""Accepts the log from a TSV import and removes the scrobbles"""
if not process_log:
logger.warning("No lines in process log found to undo")
return
for line_num, line in enumerate(process_log.split('\n')):
if line_num == 0:
continue
scrobble_id = line.split("\t")[0]
scrobble = Scrobble.objects.filter(id=scrobble_id).first()
if not scrobble:
logger.warning(f"Could not find scrobble {scrobble_id} to undo")
continue
logger.info(f"Removing scrobble {scrobble_id}")
if not dryrun:
scrobble.delete()

View File

@ -4,7 +4,15 @@ 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(
'upload/',
views.AudioScrobblerImportCreateView.as_view(),
name='audioscrobbler-file-upload',
),
path('jellyfin/', views.jellyfin_websocket, name='jellyfin-websocket'),
path('mopidy/', views.mopidy_websocket, name='mopidy-websocket'),
path('export/', views.export, name='export'),
]

View File

@ -1,7 +1,105 @@
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"""
if ":" in run_time:
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(":")
run_time = (int(run_time_list[1]) * 60) + int(run_time_list[2])
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_to_100=False, force_finish=False
) -> None:
completion_percent = scrobble.media_obj.COMPLETION_PERCENT
if scrobble.percent_played >= completion_percent or force_finish:
logger.info(f"{scrobble.id} {completion_percent} met, finishing")
if (
scrobble.playback_position_ticks
and scrobble.media_obj.run_time_ticks
and force_to_100
):
scrobble.playback_position_ticks = (
scrobble.media_obj.run_time_ticks
)
logger.info(
f"{scrobble.playback_position_ticks} set to {scrobble.media_obj.run_time_ticks}"
)
scrobble.in_progress = False
scrobble.is_paused = False
scrobble.played_to_completion = True
scrobble.save(
update_fields=[
"in_progress",
"is_paused",
"played_to_completion",
'playback_position_ticks',
]
)
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'])

View File

@ -1,61 +1,170 @@
import json
import logging
from datetime import datetime, timedelta
from datetime import datetime
from dateutil.parser import parse
import pytz
from django.conf import settings
from django.db.models.fields import timezone
from django.http import FileResponse, HttpResponseRedirect, JsonResponse
from django.urls import reverse, reverse_lazy
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import FormView
from django.views.generic.edit import CreateView
from django.views.generic.list import ListView
from music.constants import JELLYFIN_POST_KEYS as KEYS
from music.models import Track
from rest_framework import status
from rest_framework.decorators import api_view
from rest_framework.decorators import (
api_view,
parser_classes,
permission_classes,
)
from rest_framework.parsers import MultiPartParser
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from scrobbles.constants import (
JELLYFIN_AUDIO_ITEM_TYPES,
JELLYFIN_VIDEO_ITEM_TYPES,
)
from scrobbles.models import Scrobble
from scrobbles.serializers import ScrobbleSerializer
from scrobbles.utils import convert_to_seconds
from videos.models import Video
from scrobbles.forms import ExportScrobbleForm, ScrobbleForm
from scrobbles.imdb import lookup_video_from_imdb
from scrobbles.models import AudioScrobblerTSVImport, 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 (
AudioScrobblerTSVImportSerializer,
ScrobbleSerializer,
)
from scrobbles.thesportsdb import lookup_event_from_thesportsdb
from vrobbler.apps.music.aggregators import (
scrobble_counts,
top_artists,
top_tracks,
week_of_scrobbles,
)
from vrobbler.apps.scrobbles.export import export_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_three_minutes = timezone.now() - timedelta(minutes=3)
# Find scrobbles from the last 10 minutes
data['now_playing_list'] = Scrobble.objects.filter(
in_progress=True,
timestamp__gte=last_three_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
data['export_form'] = ExportScrobbleForm
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("vrobbler-home"))
class JsonableResponseMixin:
"""
Mixin to add JSON support to a form.
Must be used with an object-based FormView (e.g. CreateView)
"""
def form_invalid(self, form):
response = super().form_invalid(form)
if self.request.accepts('text/html'):
return response
else:
return JsonResponse(form.errors, status=400)
def form_valid(self, form):
# We make sure to call the parent's form_valid() method because
# it might do some processing (in the case of CreateView, it will
# call form.save() for example).
response = super().form_valid(form)
if self.request.accepts('text/html'):
return response
else:
data = {
'pk': self.object.pk,
}
return JsonResponse(data)
class AudioScrobblerImportCreateView(JsonableResponseMixin, CreateView):
model = AudioScrobblerTSVImport
fields = ['tsv_file']
template_name = 'scrobbles/upload_form.html'
success_url = reverse_lazy('vrobbler-home')
@csrf_exempt
@ -68,144 +177,136 @@ def scrobble_endpoint(request):
@csrf_exempt
@permission_classes([IsAuthenticated])
@api_view(['POST'])
def jellyfin_websocket(request):
data_dict = request.data
if (
data_dict['NotificationType'] == 'PlaybackProgress'
and data_dict['ItemType'] == 'Audio'
):
return Response({}, status=status.HTTP_304_NOT_MODIFIED)
# For making things easier to build new input processors
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", "")
track = None
video = None
if media_type in JELLYFIN_AUDIO_ITEM_TYPES:
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 Response({}, status=status.HTTP_400_BAD_REQUEST)
artist_dict = {
'name': data_dict.get(KEYS["ARTIST_NAME"], None),
'musicbrainz_id': data_dict.get(KEYS["ARTIST_MB_ID"], None),
}
album_dict = {
"name": data_dict.get(KEYS["ALBUM_NAME"], None),
"year": data_dict.get(KEYS["YEAR"], ""),
"musicbrainz_id": data_dict.get(KEYS['ALBUM_MB_ID']),
"musicbrainz_releasegroup_id": data_dict.get(
KEYS["RELEASEGROUP_MB_ID"]
),
"musicbrainz_albumartist_id": data_dict.get(KEYS["ARTIST_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(KEYS["RUN_TIME_TICKS"], None)
// 10000,
"run_time": convert_to_seconds(
data_dict.get(KEYS["RUN_TIME"], None)
),
}
track = Track.find_or_create(artist_dict, album_dict, track_dict)
scrobble = jellyfin_scrobble_track(data_dict, request.user.id)
if media_type in JELLYFIN_VIDEO_ITEM_TYPES:
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 Response({}, status=status.HTTP_400_BAD_REQUEST)
video = Video.find_or_create(data_dict)
# Now we run off a scrobble
jellyfin_data = {
"user_id": request.user.id,
"timestamp": parse(data_dict.get("UtcTimestamp")),
"playback_position_ticks": data_dict.get("PlaybackPositionTicks")
// 10000,
"playback_position": convert_to_seconds(
data_dict.get("PlaybackPosition")
),
"source": "Jellyfin",
"source_id": data_dict.get('MediaSourceId'),
"is_paused": data_dict.get("IsPaused") in TRUTHY_VALUES,
}
scrobble = None
if video:
scrobble = Scrobble.create_or_update_for_video(
video, request.user.id, jellyfin_data
)
if track:
# Prefer Mopidy MD IDs to Jellyfin, so skip if we already have one
if not track.musicbrainz_id:
track.musicbrainz_id = data_dict.get(KEYS["TRACK_MB_ID"], None)
track.save()
scrobble = Scrobble.create_or_update_for_track(
track, request.user.id, jellyfin_data
)
scrobble = jellyfin_scrobble_video(data_dict, request.user.id)
if not scrobble:
return Response({}, status=status.HTTP_400_BAD_REQUEST)
return Response(
{'scrobble_id': scrobble.id}, status=status.HTTP_201_CREATED
)
return Response({'scrobble_id': scrobble.id}, status=status.HTTP_200_OK)
@csrf_exempt
@permission_classes([IsAuthenticated])
@api_view(['POST'])
def mopidy_websocket(request):
data_dict = json.loads(request.data)
try:
data_dict = json.loads(request.data)
except TypeError:
logger.warning('Received Mopidy data as dict, rather than a string')
data_dict = request.data
# 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}")
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": request.user.id,
"timestamp": timezone.now(),
"source": "Mopidy",
"status": data_dict.get("status"),
}
scrobble = None
if track:
# Jellyfin MB ids suck, so always overwrite with Mopidy if they're offering
track.musicbrainz_id = data_dict.get("musicbrainz_track_id")
track.save()
scrobble = Scrobble.create_or_update_for_track(
track, request.user.id, mopidy_data
)
if 'podcast' in data_dict.get('mopidy_uri'):
scrobble = mopidy_scrobble_podcast(data_dict, request.user.id)
else:
scrobble = mopidy_scrobble_track(data_dict, request.user.id)
if not scrobble:
return Response({}, status=status.HTTP_400_BAD_REQUEST)
return Response({'scrobble_id': scrobble.id}, status=status.HTTP_200_OK)
@csrf_exempt
@permission_classes([IsAuthenticated])
@api_view(['POST'])
@parser_classes([MultiPartParser])
def import_audioscrobbler_file(request):
"""Takes a TSV file in the Audioscrobbler format, saves it and processes the
scrobbles.
"""
scrobbles_created = []
# tsv_file = request.FILES[0]
file_serializer = AudioScrobblerTSVImportSerializer(data=request.data)
if file_serializer.is_valid():
import_file = file_serializer.save()
return Response(
{'scrobble_ids': scrobbles_created}, status=status.HTTP_200_OK
)
else:
return Response(
file_serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
@csrf_exempt
@permission_classes([IsAuthenticated])
@api_view(['GET'])
def scrobble_finish(request, uuid):
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(
{'scrobble_id': scrobble.id}, status=status.HTTP_201_CREATED
{'id': scrobble.id, 'status': scrobble.status},
status=status.HTTP_200_OK,
)
@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)
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
)
@permission_classes([IsAuthenticated])
@api_view(['GET'])
def export(request):
format = request.GET.get('export_type', 'csv')
start = request.GET.get('start')
end = request.GET.get('end')
logger.debug(f"Exporting all scrobbles in format {format}")
temp_file, extension = export_scrobbles(
start_date=start, end_date=end, format=format
)
now = datetime.now()
filename = f"vrobbler-export-{str(now)}.{extension}"
response = FileResponse(open(temp_file, 'rb'))
response["Content-Disposition"] = f'attachment; filename="{filename}"'
return response

View File

View 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}'

View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class SportsConfig(AppConfig):
name = "sports"

View 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,
},
),
]

View File

@ -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',
),
]

View 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',
),
),
]

View File

@ -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',
),
),
]

View File

@ -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',
),
]

View File

@ -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,
),
),
]

View File

@ -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,
),
),
]

View File

@ -0,0 +1,228 @@
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):
default_run_time = getattr(
settings, 'DEFAULT_EVENT_RUNTIME_SECONDS', 14400
)
if self.default_event_run_time:
default_run_time = self.default_event_run_time
return default_run_time * 1000
class League(TheSportsDbMixin):
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

View 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

View File

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

View File

@ -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')},
),
]

View 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),
),
]

View File

@ -2,11 +2,13 @@ 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}
@ -30,15 +32,15 @@ class Series(TimeStampedModel):
verbose_name_plural = "series"
class Video(TimeStampedModel):
class Video(ScrobblableMixin):
COMPLETION_PERCENT = getattr(settings, 'VIDEO_COMPLETION_PERCENT', 90)
SECONDS_TO_STALE = getattr(settings, 'VIDEO_SECONDS_TO_STALE', 14400)
class VideoType(models.TextChoices):
UNKNOWN = 'U', _('Unknown')
TV_EPISODE = 'E', _('TV Episode')
MOVIE = 'M', _('Movie')
# General fields
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
title = models.CharField(max_length=255, **BNULL)
video_type = models.CharField(
max_length=1,
choices=VideoType.choices,
@ -46,9 +48,7 @@ class Video(TimeStampedModel):
)
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)
@ -58,7 +58,8 @@ 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:
@ -83,33 +84,40 @@ class Video(TimeStampedModel):
"title": data_dict.get("Name", ""),
"imdb_id": data_dict.get("Provider_imdb", None),
"video_type": Video.VideoType.MOVIE,
"year": data_dict.get("Year", ""),
"overview": data_dict.get("Overview", None),
"tagline": data_dict.get("Tagline", None),
"run_time_ticks": data_dict.get("RunTimeTicks", 0) // 10000,
"run_time": convert_to_seconds(data_dict.get("RunTime", "")),
}
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_dict["tv_series_id"] = series.id
video_dict["tvdb_id"] = data_dict.get("Provider_tvdb", None)
video_dict["tvrage_id"] = data_dict.get("Provider_tvrage", None)
video_dict["episode_number"] = data_dict.get("EpisodeNumber", "")
video_dict["season_number"] = data_dict.get("SeasonNumber", "")
video, created = cls.objects.get_or_create(**video_dict)
if created:
logger.debug(f"Created new video: {video}")
else:
logger.debug(f"Found video {video}")
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:
for key, value in video_extra_dict.items():
setattr(video, key, value)
video.save()
return video

View File

@ -1,7 +1,7 @@
from django.urls import path
from videos import views
app_name = 'scrobbles'
app_name = 'videos'
urlpatterns = [

View File

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

View File

@ -37,40 +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)
VIDEO_BACKOFF_MINUTES = os.getenv("VROBBLER_VIDEO_BACKOFF_MINUTES", 15)
MUSIC_BACKOFF_SECONDS = os.getenv("VROBBLER_VIDEO_BACKOFF_SECONDS", 5)
# If you stop waching or listening to a track, how long should we wait before we
# give up on the old scrobble and start a new one? This could also be considered
# a "continue in progress scrobble" time period. So if you pause the media and
# start again, should it be a new scrobble.
VIDEO_WAIT_PERIOD_DAYS = os.getenv("VROBBLER_VIDEO_WAIT_PERIOD_DAYS", 1)
MUSIC_WAIT_PERIOD_MINUTES = os.getenv("VROBBLER_VIDEO_BACKOFF_MINUTES", 1)
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",
@ -83,12 +76,18 @@ INSTALLED_APPS = [
"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",
]
@ -147,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"
@ -162,8 +159,8 @@ AUTHENTICATION_BACKENDS = [
REST_FRAMEWORK = {
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.AllowAny",),
'DEFAULT_AUTHENTICATION_CLASSES': [
#'rest_framework.authentication.BasicAuthentication',
#'rest_framework.authentication.TokenAuthentication',
'rest_framework.authentication.BasicAuthentication',
'rest_framework.authentication.TokenAuthentication',
'rest_framework.authentication.SessionAuthentication',
],
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
@ -204,8 +201,6 @@ TIME_ZONE = os.getenv("VROBBLER_TIME_ZONE", "EST")
USE_I18N = True
USE_L10N = True
USE_TZ = True
@ -216,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(
@ -274,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": {
@ -294,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"]

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -1,4 +1,5 @@
{% load static %}
{% load humanize %}
<!doctype html>
<html class="no-js" lang="">
<head>
@ -7,11 +8,12 @@
<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 "videos:movie_list" %}">All</a>
{% for movie in movie_list %}
<a class="dropdown-item" href="{{movie.get_absolute_url}}">{{movie.title}}</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 series in series_list %}
<a class="dropdown-item" href="{{series.get_absolute_url}}">{{series.name}}</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>
<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>
{% block extra_js %}
{% endblock %}
</body>
</html>

View 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 %}

View 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 %}

View 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 %}

View File

@ -0,0 +1,72 @@
{% extends "base_detail.html" %}
{% load mathfilters %}
{% block title %}{{object.name}}{% endblock %}
{% block details %}
<div class="row">
{% for album in artist.album_set.all %}
{% if album.cover_image %}
<p style="width:150px; float:left;"><img src="{{album.cover_image.url}}" width=150 height=150 /></p>
{% endif %}
{% endfor %}
</div>
<div class="row">
<p>{{artist.scrobbles.count}} scrobbles</p>
<div class="col-md">
<h3>Top tracks</h3>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Rank</th>
<th scope="col">Track</th>
<th scope="col">Count</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{% for track in object.tracks %}
<tr>
<td>{{rank}}#1</td>
<td>{{track.title}}</td>
<td>{{track.scrobble_count}}</td>
<td>
<div class="progress-bar" style="margin-right:5px;">
<span class="progress-bar-fill" style="width: {{track.scrobble_count|mul:10}}%;"></span>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="row">
<div class="col-md">
<h3>Last scrobbles</h3>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">Track</th>
<th scope="col">Album</th>
</tr>
</thead>
<tbody>
{% for scrobble in object.scrobbles %}
<tr>
<td>{{scrobble.timestamp}}</td>
<td>{{scrobble.track.title}}</td>
<td>{{scrobble.track.album.name}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,30 @@
{% extends "base_list.html" %}
{% block title %}Artists{% endblock %}
{% block lists %}
<div class="row">
<div class="col-md">
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Artist</th>
<th scope="col">Scrobbles</th>
<th scope="col">All time</th>
</tr>
</thead>
<tbody>
{% for artist in object_list %}
<tr>
<td><a href="{{artist.get_absolute_url}}">{{artist}}</a></td>
<td>{{artist.scrobbles.count}}</td>
<td></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,13 @@
{% extends "base_detail.html" %}
{% block title %}{{object.title}}{% endblock %}
{% block details %}
<h2>Last scrobbles</h2>
{% for scrobble in object.scrobble_set.all %}
<ul>
<li>{{scrobble.timestamp|date:"d M Y h:m"}} - <img src="{{object.album.cover_image.url}}" width=25 height=25 /> - {{object}}</li>
</ul>
{% endfor %}
{% endblock %}

Some files were not shown because too many files have changed in this diff Show More