Compare commits

...

58 Commits
0.2.0 ... 0.6.2

Author SHA1 Message Date
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
56 changed files with 2560 additions and 550 deletions

View File

@ -1,4 +1,7 @@
#+title: Readme
Vrobbler
========
[![Build Status](https://ci.unbl.ink/api/badges/secstate/vrobbler/status.svg)](https://ci.unbl.ink/secstate/vrobbler)
Vrobbler is a pretty simple Django-powered web app for scrobbling video plays from you favorite Jellyfin installation.

324
poetry.lock generated
View File

@ -209,6 +209,22 @@ python-versions = ">=3.6.0"
[package.extras]
unicode-backport = ["unicodedata2"]
[[package]]
name = "cinemagoer"
version = "2022.12.27"
description = "Python package to access the IMDb's database"
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
lxml = "*"
SQLAlchemy = "*"
[package.extras]
dev = ["flake8", "flake8-isort", "pytest", "pytest-cov", "tox"]
doc = ["sphinx", "sphinx-rtd-theme"]
[[package]]
name = "click"
version = "8.1.3"
@ -504,6 +520,18 @@ python-versions = ">=3.7"
[package.dependencies]
gitdb = ">=4.0.1,<5"
[[package]]
name = "greenlet"
version = "2.0.1"
description = "Lightweight in-process concurrent programming"
category = "main"
optional = false
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*"
[package.extras]
docs = ["Sphinx", "docutils (<0.18)"]
test = ["faulthandler", "objgraph", "psutil"]
[[package]]
name = "gunicorn"
version = "20.1.0"
@ -603,6 +631,20 @@ sqs = ["boto3 (>=1.9.12)", "pycurl (>=7.44.1,<7.45.0)", "urllib3 (>=1.26.7)"]
yaml = ["PyYAML (>=3.10)"]
zookeeper = ["kazoo (>=1.3.1)"]
[[package]]
name = "lxml"
version = "4.9.2"
description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API."
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, != 3.4.*"
[package.extras]
cssselect = ["cssselect (>=0.7)"]
html5 = ["html5lib"]
htmlsoup = ["BeautifulSoup4"]
source = ["Cython (>=0.29.7)"]
[[package]]
name = "markdown"
version = "3.4.1"
@ -625,6 +667,27 @@ category = "dev"
optional = false
python-versions = ">=3.6"
[[package]]
name = "mock"
version = "5.0.1"
description = "Rolling backport of unittest.mock for all Pythons"
category = "main"
optional = false
python-versions = ">=3.6"
[package.extras]
build = ["blurb", "twine", "wheel"]
docs = ["sphinx"]
test = ["pytest", "pytest-cov"]
[[package]]
name = "musicbrainzngs"
version = "0.7.1"
description = "Python bindings for the MusicBrainz NGS and the Cover Art Archive webservices"
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[[package]]
name = "mypy"
version = "0.961"
@ -811,6 +874,18 @@ category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[[package]]
name = "pysportsdb"
version = "0.1.0"
description = "An simple Python interface to thesportsdb.com's API"
category = "main"
optional = false
python-versions = ">=3.7,<4.0"
[package.dependencies]
mock = ">=5.0.1,<6.0.0"
requests = ">=2.28.2,<3.0.0"
[[package]]
name = "pytest"
version = "7.2.0"
@ -1045,7 +1120,7 @@ ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)"
[[package]]
name = "requests"
version = "2.28.1"
version = "2.28.2"
description = "Python HTTP for Humans."
category = "main"
optional = false
@ -1053,7 +1128,7 @@ python-versions = ">=3.7, <4"
[package.dependencies]
certifi = ">=2017.4.17"
charset-normalizer = ">=2,<3"
charset-normalizer = ">=2,<4"
idna = ">=2.5,<4"
urllib3 = ">=1.21.1,<1.27"
@ -1135,6 +1210,38 @@ category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "sqlalchemy"
version = "1.4.46"
description = "Database Abstraction Library"
category = "main"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7"
[package.dependencies]
greenlet = {version = "!=0.4.17", markers = "python_version >= \"3\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")"}
[package.extras]
aiomysql = ["aiomysql", "greenlet (!=0.4.17)"]
aiosqlite = ["aiosqlite", "greenlet (!=0.4.17)", "typing_extensions (!=3.10.0.1)"]
asyncio = ["greenlet (!=0.4.17)"]
asyncmy = ["asyncmy (>=0.2.3,!=0.2.4)", "greenlet (!=0.4.17)"]
mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2)"]
mssql = ["pyodbc"]
mssql-pymssql = ["pymssql"]
mssql-pyodbc = ["pyodbc"]
mypy = ["mypy (>=0.910)", "sqlalchemy2-stubs"]
mysql = ["mysqlclient (>=1.4.0)", "mysqlclient (>=1.4.0,<2)"]
mysql-connector = ["mysql-connector-python"]
oracle = ["cx_oracle (>=7)", "cx_oracle (>=7,<8)"]
postgresql = ["psycopg2 (>=2.7)"]
postgresql-asyncpg = ["asyncpg", "greenlet (!=0.4.17)"]
postgresql-pg8000 = ["pg8000 (>=1.16.6,!=1.29.0)"]
postgresql-psycopg2binary = ["psycopg2-binary"]
postgresql-psycopg2cffi = ["psycopg2cffi"]
pymysql = ["pymysql", "pymysql (<1)"]
sqlcipher = ["sqlcipher3_binary"]
[[package]]
name = "sqlparse"
version = "0.4.3"
@ -1369,7 +1476,7 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools"
[metadata]
lock-version = "1.1"
python-versions = "^3.8"
content-hash = "6105971e3adba942edffa16bd54f5822cdcabcd1e55dfecfc67410cf486a1a71"
content-hash = "39b4ea9b1b67a317d159760bff3def91ead580997052759a3f64521044e0b5ae"
[metadata.files]
amqp = [
@ -1514,6 +1621,10 @@ charset-normalizer = [
{file = "charset-normalizer-2.1.1.tar.gz", hash = "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845"},
{file = "charset_normalizer-2.1.1-py3-none-any.whl", hash = "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f"},
]
cinemagoer = [
{file = "cinemagoer-2022.12.27-py3-none-any.whl", hash = "sha256:9e295e2ed49fb93f2983d9b5e991e052ec48502a14bd8c53bf1ac88f95b67e6e"},
{file = "cinemagoer-2022.12.27.tar.gz", hash = "sha256:b94abfe948a3bfa92b04d09ae5fb41586fee60bb3fe692f20ce34bbc1a318d8a"},
]
click = [
{file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"},
{file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"},
@ -1683,6 +1794,68 @@ gitpython = [
{file = "GitPython-3.1.30-py3-none-any.whl", hash = "sha256:cd455b0000615c60e286208ba540271af9fe531fa6a87cc590a7298785ab2882"},
{file = "GitPython-3.1.30.tar.gz", hash = "sha256:769c2d83e13f5d938b7688479da374c4e3d49f71549aaf462b646db9602ea6f8"},
]
greenlet = [
{file = "greenlet-2.0.1-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:9ed358312e63bf683b9ef22c8e442ef6c5c02973f0c2a939ec1d7b50c974015c"},
{file = "greenlet-2.0.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:4f09b0010e55bec3239278f642a8a506b91034f03a4fb28289a7d448a67f1515"},
{file = "greenlet-2.0.1-cp27-cp27m-win32.whl", hash = "sha256:1407fe45246632d0ffb7a3f4a520ba4e6051fc2cbd61ba1f806900c27f47706a"},
{file = "greenlet-2.0.1-cp27-cp27m-win_amd64.whl", hash = "sha256:3001d00eba6bbf084ae60ec7f4bb8ed375748f53aeaefaf2a37d9f0370558524"},
{file = "greenlet-2.0.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:d566b82e92ff2e09dd6342df7e0eb4ff6275a3f08db284888dcd98134dbd4243"},
{file = "greenlet-2.0.1-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:0722c9be0797f544a3ed212569ca3fe3d9d1a1b13942d10dd6f0e8601e484d26"},
{file = "greenlet-2.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d37990425b4687ade27810e3b1a1c37825d242ebc275066cfee8cb6b8829ccd"},
{file = "greenlet-2.0.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be35822f35f99dcc48152c9839d0171a06186f2d71ef76dc57fa556cc9bf6b45"},
{file = "greenlet-2.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c140e7eb5ce47249668056edf3b7e9900c6a2e22fb0eaf0513f18a1b2c14e1da"},
{file = "greenlet-2.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d21681f09e297a5adaa73060737e3aa1279a13ecdcfcc6ef66c292cb25125b2d"},
{file = "greenlet-2.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fb412b7db83fe56847df9c47b6fe3f13911b06339c2aa02dcc09dce8bbf582cd"},
{file = "greenlet-2.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:c6a08799e9e88052221adca55741bf106ec7ea0710bca635c208b751f0d5b617"},
{file = "greenlet-2.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9e112e03d37987d7b90c1e98ba5e1b59e1645226d78d73282f45b326f7bddcb9"},
{file = "greenlet-2.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:56961cfca7da2fdd178f95ca407fa330c64f33289e1804b592a77d5593d9bd94"},
{file = "greenlet-2.0.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:13ba6e8e326e2116c954074c994da14954982ba2795aebb881c07ac5d093a58a"},
{file = "greenlet-2.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bf633a50cc93ed17e494015897361010fc08700d92676c87931d3ea464123ce"},
{file = "greenlet-2.0.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:9f2c221eecb7ead00b8e3ddb913c67f75cba078fd1d326053225a3f59d850d72"},
{file = "greenlet-2.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:13ebf93c343dd8bd010cd98e617cb4c1c1f352a0cf2524c82d3814154116aa82"},
{file = "greenlet-2.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:6f61d71bbc9b4a3de768371b210d906726535d6ca43506737682caa754b956cd"},
{file = "greenlet-2.0.1-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:2d0bac0385d2b43a7bd1d651621a4e0f1380abc63d6fb1012213a401cbd5bf8f"},
{file = "greenlet-2.0.1-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:f6327b6907b4cb72f650a5b7b1be23a2aab395017aa6f1adb13069d66360eb3f"},
{file = "greenlet-2.0.1-cp35-cp35m-win32.whl", hash = "sha256:81b0ea3715bf6a848d6f7149d25bf018fd24554a4be01fcbbe3fdc78e890b955"},
{file = "greenlet-2.0.1-cp35-cp35m-win_amd64.whl", hash = "sha256:38255a3f1e8942573b067510f9611fc9e38196077b0c8eb7a8c795e105f9ce77"},
{file = "greenlet-2.0.1-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:04957dc96669be041e0c260964cfef4c77287f07c40452e61abe19d647505581"},
{file = "greenlet-2.0.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:4aeaebcd91d9fee9aa768c1b39cb12214b30bf36d2b7370505a9f2165fedd8d9"},
{file = "greenlet-2.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:974a39bdb8c90a85982cdb78a103a32e0b1be986d411303064b28a80611f6e51"},
{file = "greenlet-2.0.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dca09dedf1bd8684767bc736cc20c97c29bc0c04c413e3276e0962cd7aeb148"},
{file = "greenlet-2.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4c0757db9bd08470ff8277791795e70d0bf035a011a528ee9a5ce9454b6cba2"},
{file = "greenlet-2.0.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:5067920de254f1a2dee8d3d9d7e4e03718e8fd2d2d9db962c8c9fa781ae82a39"},
{file = "greenlet-2.0.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:5a8e05057fab2a365c81abc696cb753da7549d20266e8511eb6c9d9f72fe3e92"},
{file = "greenlet-2.0.1-cp36-cp36m-win32.whl", hash = "sha256:3d75b8d013086b08e801fbbb896f7d5c9e6ccd44f13a9241d2bf7c0df9eda928"},
{file = "greenlet-2.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:097e3dae69321e9100202fc62977f687454cd0ea147d0fd5a766e57450c569fd"},
{file = "greenlet-2.0.1-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:cb242fc2cda5a307a7698c93173d3627a2a90d00507bccf5bc228851e8304963"},
{file = "greenlet-2.0.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:72b00a8e7c25dcea5946692a2485b1a0c0661ed93ecfedfa9b6687bd89a24ef5"},
{file = "greenlet-2.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5b0ff9878333823226d270417f24f4d06f235cb3e54d1103b71ea537a6a86ce"},
{file = "greenlet-2.0.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be9e0fb2ada7e5124f5282d6381903183ecc73ea019568d6d63d33f25b2a9000"},
{file = "greenlet-2.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b493db84d124805865adc587532ebad30efa68f79ad68f11b336e0a51ec86c2"},
{file = "greenlet-2.0.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:0459d94f73265744fee4c2d5ec44c6f34aa8a31017e6e9de770f7bcf29710be9"},
{file = "greenlet-2.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:a20d33124935d27b80e6fdacbd34205732660e0a1d35d8b10b3328179a2b51a1"},
{file = "greenlet-2.0.1-cp37-cp37m-win32.whl", hash = "sha256:ea688d11707d30e212e0110a1aac7f7f3f542a259235d396f88be68b649e47d1"},
{file = "greenlet-2.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:afe07421c969e259e9403c3bb658968702bc3b78ec0b6fde3ae1e73440529c23"},
{file = "greenlet-2.0.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:cd4ccc364cf75d1422e66e247e52a93da6a9b73cefa8cad696f3cbbb75af179d"},
{file = "greenlet-2.0.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:4c8b1c43e75c42a6cafcc71defa9e01ead39ae80bd733a2608b297412beede68"},
{file = "greenlet-2.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:659f167f419a4609bc0516fb18ea69ed39dbb25594934bd2dd4d0401660e8a1e"},
{file = "greenlet-2.0.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:356e4519d4dfa766d50ecc498544b44c0249b6de66426041d7f8b751de4d6b48"},
{file = "greenlet-2.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:811e1d37d60b47cb8126e0a929b58c046251f28117cb16fcd371eed61f66b764"},
{file = "greenlet-2.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:d38ffd0e81ba8ef347d2be0772e899c289b59ff150ebbbbe05dc61b1246eb4e0"},
{file = "greenlet-2.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:0109af1138afbfb8ae647e31a2b1ab030f58b21dd8528c27beaeb0093b7938a9"},
{file = "greenlet-2.0.1-cp38-cp38-win32.whl", hash = "sha256:88c8d517e78acdf7df8a2134a3c4b964415b575d2840a2746ddb1cc6175f8608"},
{file = "greenlet-2.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:d6ee1aa7ab36475035eb48c01efae87d37936a8173fc4d7b10bb02c2d75dd8f6"},
{file = "greenlet-2.0.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:b1992ba9d4780d9af9726bbcef6a1db12d9ab1ccc35e5773685a24b7fb2758eb"},
{file = "greenlet-2.0.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:b5e83e4de81dcc9425598d9469a624826a0b1211380ac444c7c791d4a2137c19"},
{file = "greenlet-2.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:505138d4fa69462447a562a7c2ef723c6025ba12ac04478bc1ce2fcc279a2db5"},
{file = "greenlet-2.0.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cce1e90dd302f45716a7715517c6aa0468af0bf38e814ad4eab58e88fc09f7f7"},
{file = "greenlet-2.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e9744c657d896c7b580455e739899e492a4a452e2dd4d2b3e459f6b244a638d"},
{file = "greenlet-2.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:662e8f7cad915ba75d8017b3e601afc01ef20deeeabf281bd00369de196d7726"},
{file = "greenlet-2.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:41b825d65f31e394b523c84db84f9383a2f7eefc13d987f308f4663794d2687e"},
{file = "greenlet-2.0.1-cp39-cp39-win32.whl", hash = "sha256:db38f80540083ea33bdab614a9d28bcec4b54daa5aff1668d7827a9fc769ae0a"},
{file = "greenlet-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:b23d2a46d53210b498e5b701a1913697671988f4bf8e10f935433f6e7c332fb6"},
{file = "greenlet-2.0.1.tar.gz", hash = "sha256:42e602564460da0e8ee67cb6d7236363ee5e131aa15943b6670e44e5c2ed0f67"},
]
gunicorn = [
{file = "gunicorn-20.1.0-py3-none-any.whl", hash = "sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e"},
{file = "gunicorn-20.1.0.tar.gz", hash = "sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8"},
@ -1711,6 +1884,85 @@ kombu = [
{file = "kombu-5.2.4-py3-none-any.whl", hash = "sha256:8b213b24293d3417bcf0d2f5537b7f756079e3ea232a8386dcc89a59fd2361a4"},
{file = "kombu-5.2.4.tar.gz", hash = "sha256:37cee3ee725f94ea8bb173eaab7c1760203ea53bbebae226328600f9d2799610"},
]
lxml = [
{file = "lxml-4.9.2-cp27-cp27m-macosx_10_15_x86_64.whl", hash = "sha256:76cf573e5a365e790396a5cc2b909812633409306c6531a6877c59061e42c4f2"},
{file = "lxml-4.9.2-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b1f42b6921d0e81b1bcb5e395bc091a70f41c4d4e55ba99c6da2b31626c44892"},
{file = "lxml-4.9.2-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:9f102706d0ca011de571de32c3247c6476b55bb6bc65a20f682f000b07a4852a"},
{file = "lxml-4.9.2-cp27-cp27m-win32.whl", hash = "sha256:8d0b4612b66ff5d62d03bcaa043bb018f74dfea51184e53f067e6fdcba4bd8de"},
{file = "lxml-4.9.2-cp27-cp27m-win_amd64.whl", hash = "sha256:4c8f293f14abc8fd3e8e01c5bd86e6ed0b6ef71936ded5bf10fe7a5efefbaca3"},
{file = "lxml-4.9.2-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2899456259589aa38bfb018c364d6ae7b53c5c22d8e27d0ec7609c2a1ff78b50"},
{file = "lxml-4.9.2-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6749649eecd6a9871cae297bffa4ee76f90b4504a2a2ab528d9ebe912b101975"},
{file = "lxml-4.9.2-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:a08cff61517ee26cb56f1e949cca38caabe9ea9fbb4b1e10a805dc39844b7d5c"},
{file = "lxml-4.9.2-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:85cabf64adec449132e55616e7ca3e1000ab449d1d0f9d7f83146ed5bdcb6d8a"},
{file = "lxml-4.9.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:8340225bd5e7a701c0fa98284c849c9b9fc9238abf53a0ebd90900f25d39a4e4"},
{file = "lxml-4.9.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:1ab8f1f932e8f82355e75dda5413a57612c6ea448069d4fb2e217e9a4bed13d4"},
{file = "lxml-4.9.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:699a9af7dffaf67deeae27b2112aa06b41c370d5e7633e0ee0aea2e0b6c211f7"},
{file = "lxml-4.9.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b9cc34af337a97d470040f99ba4282f6e6bac88407d021688a5d585e44a23184"},
{file = "lxml-4.9.2-cp310-cp310-win32.whl", hash = "sha256:d02a5399126a53492415d4906ab0ad0375a5456cc05c3fc0fc4ca11771745cda"},
{file = "lxml-4.9.2-cp310-cp310-win_amd64.whl", hash = "sha256:a38486985ca49cfa574a507e7a2215c0c780fd1778bb6290c21193b7211702ab"},
{file = "lxml-4.9.2-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:c83203addf554215463b59f6399835201999b5e48019dc17f182ed5ad87205c9"},
{file = "lxml-4.9.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:2a87fa548561d2f4643c99cd13131acb607ddabb70682dcf1dff5f71f781a4bf"},
{file = "lxml-4.9.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:d6b430a9938a5a5d85fc107d852262ddcd48602c120e3dbb02137c83d212b380"},
{file = "lxml-4.9.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:3efea981d956a6f7173b4659849f55081867cf897e719f57383698af6f618a92"},
{file = "lxml-4.9.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:df0623dcf9668ad0445e0558a21211d4e9a149ea8f5666917c8eeec515f0a6d1"},
{file = "lxml-4.9.2-cp311-cp311-win32.whl", hash = "sha256:da248f93f0418a9e9d94b0080d7ebc407a9a5e6d0b57bb30db9b5cc28de1ad33"},
{file = "lxml-4.9.2-cp311-cp311-win_amd64.whl", hash = "sha256:3818b8e2c4b5148567e1b09ce739006acfaa44ce3156f8cbbc11062994b8e8dd"},
{file = "lxml-4.9.2-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ca989b91cf3a3ba28930a9fc1e9aeafc2a395448641df1f387a2d394638943b0"},
{file = "lxml-4.9.2-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:822068f85e12a6e292803e112ab876bc03ed1f03dddb80154c395f891ca6b31e"},
{file = "lxml-4.9.2-cp35-cp35m-win32.whl", hash = "sha256:be7292c55101e22f2a3d4d8913944cbea71eea90792bf914add27454a13905df"},
{file = "lxml-4.9.2-cp35-cp35m-win_amd64.whl", hash = "sha256:998c7c41910666d2976928c38ea96a70d1aa43be6fe502f21a651e17483a43c5"},
{file = "lxml-4.9.2-cp36-cp36m-macosx_10_15_x86_64.whl", hash = "sha256:b26a29f0b7fc6f0897f043ca366142d2b609dc60756ee6e4e90b5f762c6adc53"},
{file = "lxml-4.9.2-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:ab323679b8b3030000f2be63e22cdeea5b47ee0abd2d6a1dc0c8103ddaa56cd7"},
{file = "lxml-4.9.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:689bb688a1db722485e4610a503e3e9210dcc20c520b45ac8f7533c837be76fe"},
{file = "lxml-4.9.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:f49e52d174375a7def9915c9f06ec4e569d235ad428f70751765f48d5926678c"},
{file = "lxml-4.9.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:36c3c175d34652a35475a73762b545f4527aec044910a651d2bf50de9c3352b1"},
{file = "lxml-4.9.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a35f8b7fa99f90dd2f5dc5a9fa12332642f087a7641289ca6c40d6e1a2637d8e"},
{file = "lxml-4.9.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:58bfa3aa19ca4c0f28c5dde0ff56c520fbac6f0daf4fac66ed4c8d2fb7f22e74"},
{file = "lxml-4.9.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:bc718cd47b765e790eecb74d044cc8d37d58562f6c314ee9484df26276d36a38"},
{file = "lxml-4.9.2-cp36-cp36m-win32.whl", hash = "sha256:d5bf6545cd27aaa8a13033ce56354ed9e25ab0e4ac3b5392b763d8d04b08e0c5"},
{file = "lxml-4.9.2-cp36-cp36m-win_amd64.whl", hash = "sha256:3ab9fa9d6dc2a7f29d7affdf3edebf6ece6fb28a6d80b14c3b2fb9d39b9322c3"},
{file = "lxml-4.9.2-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:05ca3f6abf5cf78fe053da9b1166e062ade3fa5d4f92b4ed688127ea7d7b1d03"},
{file = "lxml-4.9.2-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:a5da296eb617d18e497bcf0a5c528f5d3b18dadb3619fbdadf4ed2356ef8d941"},
{file = "lxml-4.9.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:04876580c050a8c5341d706dd464ff04fd597095cc8c023252566a8826505726"},
{file = "lxml-4.9.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:c9ec3eaf616d67db0764b3bb983962b4f385a1f08304fd30c7283954e6a7869b"},
{file = "lxml-4.9.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2a29ba94d065945944016b6b74e538bdb1751a1db6ffb80c9d3c2e40d6fa9894"},
{file = "lxml-4.9.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a82d05da00a58b8e4c0008edbc8a4b6ec5a4bc1e2ee0fb6ed157cf634ed7fa45"},
{file = "lxml-4.9.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:223f4232855ade399bd409331e6ca70fb5578efef22cf4069a6090acc0f53c0e"},
{file = "lxml-4.9.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d17bc7c2ccf49c478c5bdd447594e82692c74222698cfc9b5daae7ae7e90743b"},
{file = "lxml-4.9.2-cp37-cp37m-win32.whl", hash = "sha256:b64d891da92e232c36976c80ed7ebb383e3f148489796d8d31a5b6a677825efe"},
{file = "lxml-4.9.2-cp37-cp37m-win_amd64.whl", hash = "sha256:a0a336d6d3e8b234a3aae3c674873d8f0e720b76bc1d9416866c41cd9500ffb9"},
{file = "lxml-4.9.2-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:da4dd7c9c50c059aba52b3524f84d7de956f7fef88f0bafcf4ad7dde94a064e8"},
{file = "lxml-4.9.2-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:821b7f59b99551c69c85a6039c65b75f5683bdc63270fec660f75da67469ca24"},
{file = "lxml-4.9.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:e5168986b90a8d1f2f9dc1b841467c74221bd752537b99761a93d2d981e04889"},
{file = "lxml-4.9.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:8e20cb5a47247e383cf4ff523205060991021233ebd6f924bca927fcf25cf86f"},
{file = "lxml-4.9.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:13598ecfbd2e86ea7ae45ec28a2a54fb87ee9b9fdb0f6d343297d8e548392c03"},
{file = "lxml-4.9.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:880bbbcbe2fca64e2f4d8e04db47bcdf504936fa2b33933efd945e1b429bea8c"},
{file = "lxml-4.9.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:7d2278d59425777cfcb19735018d897ca8303abe67cc735f9f97177ceff8027f"},
{file = "lxml-4.9.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5344a43228767f53a9df6e5b253f8cdca7dfc7b7aeae52551958192f56d98457"},
{file = "lxml-4.9.2-cp38-cp38-win32.whl", hash = "sha256:925073b2fe14ab9b87e73f9a5fde6ce6392da430f3004d8b72cc86f746f5163b"},
{file = "lxml-4.9.2-cp38-cp38-win_amd64.whl", hash = "sha256:9b22c5c66f67ae00c0199f6055705bc3eb3fcb08d03d2ec4059a2b1b25ed48d7"},
{file = "lxml-4.9.2-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:5f50a1c177e2fa3ee0667a5ab79fdc6b23086bc8b589d90b93b4bd17eb0e64d1"},
{file = "lxml-4.9.2-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:090c6543d3696cbe15b4ac6e175e576bcc3f1ccfbba970061b7300b0c15a2140"},
{file = "lxml-4.9.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:63da2ccc0857c311d764e7d3d90f429c252e83b52d1f8f1d1fe55be26827d1f4"},
{file = "lxml-4.9.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:5b4545b8a40478183ac06c073e81a5ce4cf01bf1734962577cf2bb569a5b3bbf"},
{file = "lxml-4.9.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2e430cd2824f05f2d4f687701144556646bae8f249fd60aa1e4c768ba7018947"},
{file = "lxml-4.9.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6804daeb7ef69e7b36f76caddb85cccd63d0c56dedb47555d2fc969e2af6a1a5"},
{file = "lxml-4.9.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:a6e441a86553c310258aca15d1c05903aaf4965b23f3bc2d55f200804e005ee5"},
{file = "lxml-4.9.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ca34efc80a29351897e18888c71c6aca4a359247c87e0b1c7ada14f0ab0c0fb2"},
{file = "lxml-4.9.2-cp39-cp39-win32.whl", hash = "sha256:6b418afe5df18233fc6b6093deb82a32895b6bb0b1155c2cdb05203f583053f1"},
{file = "lxml-4.9.2-cp39-cp39-win_amd64.whl", hash = "sha256:f1496ea22ca2c830cbcbd473de8f114a320da308438ae65abad6bab7867fe38f"},
{file = "lxml-4.9.2-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:b264171e3143d842ded311b7dccd46ff9ef34247129ff5bf5066123c55c2431c"},
{file = "lxml-4.9.2-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0dc313ef231edf866912e9d8f5a042ddab56c752619e92dfd3a2c277e6a7299a"},
{file = "lxml-4.9.2-pp38-pypy38_pp73-macosx_10_15_x86_64.whl", hash = "sha256:16efd54337136e8cd72fb9485c368d91d77a47ee2d42b057564aae201257d419"},
{file = "lxml-4.9.2-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:0f2b1e0d79180f344ff9f321327b005ca043a50ece8713de61d1cb383fb8ac05"},
{file = "lxml-4.9.2-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:7b770ed79542ed52c519119473898198761d78beb24b107acf3ad65deae61f1f"},
{file = "lxml-4.9.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:efa29c2fe6b4fdd32e8ef81c1528506895eca86e1d8c4657fda04c9b3786ddf9"},
{file = "lxml-4.9.2-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7e91ee82f4199af8c43d8158024cbdff3d931df350252288f0d4ce656df7f3b5"},
{file = "lxml-4.9.2-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:b23e19989c355ca854276178a0463951a653309fb8e57ce674497f2d9f208746"},
{file = "lxml-4.9.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:01d36c05f4afb8f7c20fd9ed5badca32a2029b93b1750f571ccc0b142531caf7"},
{file = "lxml-4.9.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7b515674acfdcadb0eb5d00d8a709868173acece5cb0be3dd165950cbfdf5409"},
{file = "lxml-4.9.2.tar.gz", hash = "sha256:2455cfaeb7ac70338b3257f41e21f0724f4b5b0c0e7702da67ee6c3640835b67"},
]
markdown = [
{file = "Markdown-3.4.1-py3-none-any.whl", hash = "sha256:08fb8465cffd03d10b9dd34a5c3fea908e20391a2a90b88d66362cb05beed186"},
{file = "Markdown-3.4.1.tar.gz", hash = "sha256:3b809086bb6efad416156e00a0da66fe47618a5d6918dd688f53f40c8e4cfeff"},
@ -1719,6 +1971,14 @@ mccabe = [
{file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"},
{file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"},
]
mock = [
{file = "mock-5.0.1-py3-none-any.whl", hash = "sha256:c41cfb1e99ba5d341fbcc5308836e7d7c9786d302f995b2c271ce2144dece9eb"},
{file = "mock-5.0.1.tar.gz", hash = "sha256:e3ea505c03babf7977fd21674a69ad328053d414f05e6433c30d8fa14a534a6b"},
]
musicbrainzngs = [
{file = "musicbrainzngs-0.7.1-py2.py3-none-any.whl", hash = "sha256:e841a8f975104c0a72290b09f59326050194081a5ae62ee512f41915090e1a10"},
{file = "musicbrainzngs-0.7.1.tar.gz", hash = "sha256:ab1c0100fd0b305852e65f2ed4113c6de12e68afd55186987b8ed97e0f98e627"},
]
mypy = [
{file = "mypy-0.961-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:697540876638ce349b01b6786bc6094ccdaba88af446a9abb967293ce6eaa2b0"},
{file = "mypy-0.961-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b117650592e1782819829605a193360a08aa99f1fc23d1d71e1a75a142dc7e15"},
@ -1769,6 +2029,13 @@ pbr = [
{file = "pbr-5.11.0.tar.gz", hash = "sha256:b97bc6695b2aff02144133c2e7399d5885223d42b7912ffaec2ca3898e673bfe"},
]
pillow = [
{file = "Pillow-9.4.0-1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1b4b4e9dda4f4e4c4e6896f93e84a8f0bcca3b059de9ddf67dac3c334b1195e1"},
{file = "Pillow-9.4.0-1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:fb5c1ad6bad98c57482236a21bf985ab0ef42bd51f7ad4e4538e89a997624e12"},
{file = "Pillow-9.4.0-1-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:f0caf4a5dcf610d96c3bd32932bfac8aee61c96e60481c2a0ea58da435e25acd"},
{file = "Pillow-9.4.0-1-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:3f4cc516e0b264c8d4ccd6b6cbc69a07c6d582d8337df79be1e15a5056b258c9"},
{file = "Pillow-9.4.0-1-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:b8c2f6eb0df979ee99433d8b3f6d193d9590f735cf12274c108bd954e30ca858"},
{file = "Pillow-9.4.0-1-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:b70756ec9417c34e097f987b4d8c510975216ad26ba6e57ccb53bc758f490dab"},
{file = "Pillow-9.4.0-1-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:43521ce2c4b865d385e78579a082b6ad1166ebed2b1a2293c3be1d68dd7ca3b9"},
{file = "Pillow-9.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:2968c58feca624bb6c8502f9564dd187d0e1389964898f5e9e1fbc8533169157"},
{file = "Pillow-9.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c5c1362c14aee73f50143d74389b2c158707b4abce2cb055b7ad37ce60738d47"},
{file = "Pillow-9.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd752c5ff1b4a870b7661234694f24b1d2b9076b8bf337321a814c612665f343"},
@ -1885,6 +2152,10 @@ pysocks = [
{file = "PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5"},
{file = "PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0"},
]
pysportsdb = [
{file = "pysportsdb-0.1.0-py3-none-any.whl", hash = "sha256:9e3a6654e1270a176cdb4bfcec123240bb9061f80db5bf421e5422b547efe7aa"},
{file = "pysportsdb-0.1.0.tar.gz", hash = "sha256:d495ec5d1c416af8192be127ece500d4d2fd6224bb9a001044b823ac158d22b5"},
]
pytest = [
{file = "pytest-7.2.0-py3-none-any.whl", hash = "sha256:892f933d339f068883b6fd5a459f03d85bfcb355e4981e146d2c7616c21fef71"},
{file = "pytest-7.2.0.tar.gz", hash = "sha256:c4014eb40e10f11f355ad4e3c2fb2c6c6d1919c73f3b5a433de4708202cade59"},
@ -1995,8 +2266,8 @@ redis = [
{file = "redis-4.4.0.tar.gz", hash = "sha256:7b8c87d19c45d3f1271b124858d2a5c13160c4e74d4835e28273400fa34d5228"},
]
requests = [
{file = "requests-2.28.1-py3-none-any.whl", hash = "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"},
{file = "requests-2.28.1.tar.gz", hash = "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983"},
{file = "requests-2.28.2-py3-none-any.whl", hash = "sha256:64299f4909223da747622c030b781c0d7811e359c37124b4bd368fb8c6518baa"},
{file = "requests-2.28.2.tar.gz", hash = "sha256:98b1b2782e3c6c4904938b84c0eb932721069dfdb9134313beff7c83c2df24bf"},
]
requests-oauthlib = [
{file = "requests-oauthlib-1.3.1.tar.gz", hash = "sha256:75beac4a47881eeb94d5ea5d6ad31ef88856affe2332b9aafb52c6452ccf0d7a"},
@ -2026,6 +2297,49 @@ sortedcontainers = [
{file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"},
{file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"},
]
sqlalchemy = [
{file = "SQLAlchemy-1.4.46-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:7001f16a9a8e06488c3c7154827c48455d1c1507d7228d43e781afbc8ceccf6d"},
{file = "SQLAlchemy-1.4.46-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:c7a46639ba058d320c9f53a81db38119a74b8a7a1884df44d09fbe807d028aaf"},
{file = "SQLAlchemy-1.4.46-cp27-cp27m-win32.whl", hash = "sha256:c04144a24103135ea0315d459431ac196fe96f55d3213bfd6d39d0247775c854"},
{file = "SQLAlchemy-1.4.46-cp27-cp27m-win_amd64.whl", hash = "sha256:7b81b1030c42b003fc10ddd17825571603117f848814a344d305262d370e7c34"},
{file = "SQLAlchemy-1.4.46-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:939f9a018d2ad04036746e15d119c0428b1e557470361aa798e6e7d7f5875be0"},
{file = "SQLAlchemy-1.4.46-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:b7f4b6aa6e87991ec7ce0e769689a977776db6704947e562102431474799a857"},
{file = "SQLAlchemy-1.4.46-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dbf17ac9a61e7a3f1c7ca47237aac93cabd7f08ad92ac5b96d6f8dea4287fc1"},
{file = "SQLAlchemy-1.4.46-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:7f8267682eb41a0584cf66d8a697fef64b53281d01c93a503e1344197f2e01fe"},
{file = "SQLAlchemy-1.4.46-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64cb0ad8a190bc22d2112001cfecdec45baffdf41871de777239da6a28ed74b6"},
{file = "SQLAlchemy-1.4.46-cp310-cp310-win32.whl", hash = "sha256:5f752676fc126edc1c4af0ec2e4d2adca48ddfae5de46bb40adbd3f903eb2120"},
{file = "SQLAlchemy-1.4.46-cp310-cp310-win_amd64.whl", hash = "sha256:31de1e2c45e67a5ec1ecca6ec26aefc299dd5151e355eb5199cd9516b57340be"},
{file = "SQLAlchemy-1.4.46-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d68e1762997bfebf9e5cf2a9fd0bcf9ca2fdd8136ce7b24bbd3bbfa4328f3e4a"},
{file = "SQLAlchemy-1.4.46-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d112b0f3c1bc5ff70554a97344625ef621c1bfe02a73c5d97cac91f8cd7a41e"},
{file = "SQLAlchemy-1.4.46-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:69fac0a7054d86b997af12dc23f581cf0b25fb1c7d1fed43257dee3af32d3d6d"},
{file = "SQLAlchemy-1.4.46-cp311-cp311-win32.whl", hash = "sha256:887865924c3d6e9a473dc82b70977395301533b3030d0f020c38fd9eba5419f2"},
{file = "SQLAlchemy-1.4.46-cp311-cp311-win_amd64.whl", hash = "sha256:984ee13543a346324319a1fb72b698e521506f6f22dc37d7752a329e9cd00a32"},
{file = "SQLAlchemy-1.4.46-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:9167d4227b56591a4cc5524f1b79ccd7ea994f36e4c648ab42ca995d28ebbb96"},
{file = "SQLAlchemy-1.4.46-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d61e9ecc849d8d44d7f80894ecff4abe347136e9d926560b818f6243409f3c86"},
{file = "SQLAlchemy-1.4.46-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3ec187acf85984263299a3f15c34a6c0671f83565d86d10f43ace49881a82718"},
{file = "SQLAlchemy-1.4.46-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9883f5fae4fd8e3f875adc2add69f8b945625811689a6c65866a35ee9c0aea23"},
{file = "SQLAlchemy-1.4.46-cp36-cp36m-win32.whl", hash = "sha256:535377e9b10aff5a045e3d9ada8a62d02058b422c0504ebdcf07930599890eb0"},
{file = "SQLAlchemy-1.4.46-cp36-cp36m-win_amd64.whl", hash = "sha256:18cafdb27834fa03569d29f571df7115812a0e59fd6a3a03ccb0d33678ec8420"},
{file = "SQLAlchemy-1.4.46-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:a1ad90c97029cc3ab4ffd57443a20fac21d2ec3c89532b084b073b3feb5abff3"},
{file = "SQLAlchemy-1.4.46-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4847f4b1d822754e35707db913396a29d874ee77b9c3c3ef3f04d5a9a6209618"},
{file = "SQLAlchemy-1.4.46-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c5a99282848b6cae0056b85da17392a26b2d39178394fc25700bcf967e06e97a"},
{file = "SQLAlchemy-1.4.46-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4b1cc7835b39835c75cf7c20c926b42e97d074147c902a9ebb7cf2c840dc4e2"},
{file = "SQLAlchemy-1.4.46-cp37-cp37m-win32.whl", hash = "sha256:c522e496f9b9b70296a7675272ec21937ccfc15da664b74b9f58d98a641ce1b6"},
{file = "SQLAlchemy-1.4.46-cp37-cp37m-win_amd64.whl", hash = "sha256:ae067ab639fa499f67ded52f5bc8e084f045d10b5ac7bb928ae4ca2b6c0429a5"},
{file = "SQLAlchemy-1.4.46-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:e3c1808008124850115a3f7e793a975cfa5c8a26ceeeb9ff9cbb4485cac556df"},
{file = "SQLAlchemy-1.4.46-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4d164df3d83d204c69f840da30b292ac7dc54285096c6171245b8d7807185aa"},
{file = "SQLAlchemy-1.4.46-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b33ffbdbbf5446cf36cd4cc530c9d9905d3c2fe56ed09e25c22c850cdb9fac92"},
{file = "SQLAlchemy-1.4.46-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d94682732d1a0def5672471ba42a29ff5e21bb0aae0afa00bb10796fc1e28dd"},
{file = "SQLAlchemy-1.4.46-cp38-cp38-win32.whl", hash = "sha256:f8cb80fe8d14307e4124f6fad64dfd87ab749c9d275f82b8b4ec84c84ecebdbe"},
{file = "SQLAlchemy-1.4.46-cp38-cp38-win_amd64.whl", hash = "sha256:07e48cbcdda6b8bc7a59d6728bd3f5f574ffe03f2c9fb384239f3789c2d95c2e"},
{file = "SQLAlchemy-1.4.46-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:1b1e5e96e2789d89f023d080bee432e2fef64d95857969e70d3cadec80bd26f0"},
{file = "SQLAlchemy-1.4.46-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3714e5b33226131ac0da60d18995a102a17dddd42368b7bdd206737297823ad"},
{file = "SQLAlchemy-1.4.46-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:955162ad1a931fe416eded6bb144ba891ccbf9b2e49dc7ded39274dd9c5affc5"},
{file = "SQLAlchemy-1.4.46-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6e4cb5c63f705c9d546a054c60d326cbde7421421e2d2565ce3e2eee4e1a01f"},
{file = "SQLAlchemy-1.4.46-cp39-cp39-win32.whl", hash = "sha256:51e1ba2884c6a2b8e19109dc08c71c49530006c1084156ecadfaadf5f9b8b053"},
{file = "SQLAlchemy-1.4.46-cp39-cp39-win_amd64.whl", hash = "sha256:315676344e3558f1f80d02535f410e80ea4e8fddba31ec78fe390eff5fb8f466"},
{file = "SQLAlchemy-1.4.46.tar.gz", hash = "sha256:6913b8247d8a292ef8315162a51931e2b40ce91681f1b6f18f697045200c4a30"},
]
sqlparse = [
{file = "sqlparse-0.4.3-py3-none-any.whl", hash = "sha256:0323c0ec29cd52bceabc1b4d9d579e311f3e4961b98d174201d5622a23b85e34"},
{file = "sqlparse-0.4.3.tar.gz", hash = "sha256:69ca804846bb114d2ec380e4360a8a340db83f0ccf3afceeb1404df028f57268"},

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "vrobbler"
version = "0.2.0"
version = "0.6.2"
description = ""
authors = ["Colin Powell <colin@unbl.ink>"]
@ -27,6 +27,9 @@ 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"
[tool.poetry.dev-dependencies]
Werkzeug = "2.0.3"

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

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

@ -10,38 +10,58 @@ from datetime import datetime, timedelta
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_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():
finished_scrobbles_qs = Scrobble.objects.filter(in_progress=False)
finished_scrobbles_qs = Scrobble.objects.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['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(media: str='tracks') -> dict[str, int]:
scrobble_day_dict= {}
media_filter = Q(track__isnull=True)
for day in range(1,8):
def week_of_scrobbles(media: str = 'tracks') -> dict[str, int]:
scrobble_day_dict = {}
media_filter = Q(track__isnull=False)
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')
if media == 'movies':
media_filter = Q(video__videotype=Video.VideoType.MOVIE)
if media == 'series':
media_filter = Q(video__videotype=Video.VideoType.MOVIE)
scrobble_day_dict[day_of_week] = Scrobble.objects.filter(media_filter).filter(timestamp__lte=START_OF_TODAY, timestamp__gt=end, in_progress=False).count()
media_filter = Q(video__videotype=Video.VideoType.TV_EPISODE)
scrobble_day_dict[day_of_week] = (
Scrobble.objects.filter(media_filter)
.filter(
timestamp__gte=start,
timestamp__lte=end,
played_to_completion=True,
)
.count()
)
return scrobble_day_dict
def top_tracks(filter: str="today", limit: int=15) -> List["Track"]:
def top_tracks(filter: str = "today", limit: int = 15) -> List["Track"]:
time_filter = Q(scrobble__timestamp__gte=START_OF_TODAY)
if filter == "week":
time_filter = Q(scrobble__timestamp__gte=STARTING_DAY_OF_CURRENT_WEEK)
@ -50,21 +70,34 @@ def top_tracks(filter: str="today", limit: int=15) -> List["Track"]:
if filter == "year":
time_filter = Q(scrobble__timestamp__gte=STARTING_DAY_OF_CURRENT_YEAR)
return Track.objects.annotate(num_scrobbles=Count("scrobble", distinct=True)).filter(time_filter).order_by("-num_scrobbles")[:limit]
return (
Track.objects.filter(time_filter)
.annotate(num_scrobbles=Count("scrobble", distinct=True))
.order_by("-num_scrobbles")[:limit]
)
def top_artists(filter: str="today", limit: int=15) -> List["Artist"]:
def top_artists(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)
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)
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)
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]
)
return Artist.objects.annotate(num_scrobbles=Sum("track__scrobble", distinct=True)).filter(time_filter).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()
)
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

@ -2,37 +2,27 @@ import logging
from typing import Dict, Optional
from uuid import uuid4
import musicbrainzngs
from django.apps.config import cached_property
from django.conf import settings
from django.core.files.base import ContentFile
from django.db import models
from django.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
@ -41,20 +31,79 @@ class Artist(TimeStampedModel):
return f"https://musicbrainz.org/artist/{self.musicbrainz_id}"
class Track(TimeStampedModel):
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):
if not self.cover_image:
try:
img_data = musicbrainzngs.get_image_front(self.musicbrainz_id)
name = f"{self.name}_{self.uuid}.jpg"
self.cover_image = ContentFile(img_data, name=name)
except musicbrainzngs.ResponseError:
logger.warning(f'No cover art found for {self.name}')
self.cover_image = 'default-image-replace-me'
self.save()
@property
def mb_link(self):
return f"https://musicbrainz.org/release/{self.musicbrainz_id}"
class Track(ScrobblableMixin):
COMPLETION_PERCENT = getattr(settings, 'MUSIC_COMPLETION_PERCENT', 90)
class Opinion(models.IntegerChoices):
DOWN = -1, 'Thumbs down'
NEUTRAL = 0, 'No opinion'
UP = 1, 'Thumbs up'
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)
musicbrainz_id = models.CharField(max_length=255, unique=True, **BNULL)
def __str__(self):
return f"{self.title} by {self.artist}"
@ -80,12 +129,13 @@ 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}")
logger.debug(f"Created new artist {artist}")
else:
logger.debug(f"Found album {artist}")
@ -95,6 +145,10 @@ class Track(TimeStampedModel):
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

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

@ -3,18 +3,49 @@ from django.contrib import admin
from scrobbles.models import Scrobble
class ScrobbleInline(admin.TabularInline):
model = Scrobble
extra = 0
raw_id_fields = ('video', 'podcast_episode', 'track')
exclude = ('source_id', 'scrobble_log')
@admin.register(Scrobble)
class ScrobbleAdmin(admin.ModelAdmin):
date_hierarchy = "timestamp"
list_display = (
"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,14 @@
from django import forms
class ScrobbleForm(forms.Form):
item_id = forms.CharField(
label="",
widget=forms.TextInput(
attrs={
'class': "form-control form-control-dark w-100",
'placeholder': "Scrobble something (IMDB ID, String, TVDB ID ...)",
'aria-label': "Scrobble something",
}
),
)

View File

@ -0,0 +1,61 @@
import logging
from typing import Optional
from django.utils import timezone
import imdb
from videos.models import Video
imdb_client = imdb.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 = Video.VideoType.MOVIE
if media.get('series title'):
item_type = Video.VideoType.TV_EPISODE
try:
plot = media.get('plot')[0]
except TypeError:
plot = ""
except IndexError:
plot = ""
# 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,
}
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,17 @@
from uuid import uuid4
from django.db import models
from django_extensions.db.models import TimeStampedModel
BNULL = {"blank": True, "null": True}
class ScrobblableMixin(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
title = models.CharField(max_length=255, **BNULL)
run_time = models.CharField(max_length=8, **BNULL)
run_time_ticks = models.PositiveBigIntegerField(**BNULL)
# thumbs = models.IntegerField(default=Opinion.NEUTRAL, choices=Opinion.choices)
class Meta:
abstract = True

View File

@ -1,27 +1,30 @@
import logging
from datetime import timedelta
from typing import Optional
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 podcasts.models import Episode
from scrobbles.utils import check_scrobble_for_finish
from videos.models import Video
from sports.models import SportEvent
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 Scrobble(TimeStampedModel):
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
)
@ -37,164 +40,225 @@ class Scrobble(TimeStampedModel):
@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 not self.media_obj.run_time_ticks:
logger.warning(
f"{self} has no run_time_ticks value, cannot show percent played"
)
# 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)
return 100
return 0
playback_ticks = self.playback_position_ticks
if not playback_ticks:
playback_ticks = (timezone.now() - self.timestamp).seconds * 1000
if self.played_to_completion:
playback_ticks = self.media_obj.run_time_ticks
percent = int((playback_ticks / self.media_obj.run_time_ticks) * 100)
return percent
@property
def media_run_time_ticks(self) -> int:
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
cls, video: "Video", 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}"
)
scrobble_data['video_id'] = video.id
scrobble = (
cls.objects.filter(video=video, user_id=user_id)
cls.objects.filter(
video=video,
user_id=user_id,
played_to_completion=False,
)
.order_by('-modified')
.first()
)
if scrobble:
logger.info(
f"Found existing scrobble for video {video}, updating",
{"scrobble_data": scrobble_data},
)
return cls.update(scrobble, 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
logger.debug(
f"No existing scrobble for video {video}, creating",
{"scrobble_data": scrobble_data},
)
# If creating a new scrobble, we don't need status
scrobble_data.pop('jellyfin_status')
return cls.create(scrobble_data)
@classmethod
def create_or_update_for_track(
cls, track: "Track", user_id: int, scrobble_data: dict
) -> "Scrobble":
"""Look up any existing scrobbles for a track and compare
the appropriate backoff time for music tracks to the setting
so we can avoid duplicating scrobbles."""
scrobble_data['track_id'] = track.id
scrobble = (
cls.objects.filter(track=track, user_id=user_id)
cls.objects.filter(
track=track,
user_id=user_id,
played_to_completion=False,
)
.order_by('-modified')
.first()
)
if scrobble:
logger.debug(
f"Found existing scrobble for track {track}, updating",
{"scrobble_data": scrobble_data},
)
return cls.update(scrobble, scrobble_data)
logger.debug(
f"Found existing scrobble for track {track}, updating",
f"No existing scrobble for track {track}, creating",
{"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
)
# If creating a new scrobble, we don't need status
scrobble_data.pop('mopidy_status')
return cls.create(scrobble_data)
@classmethod
def update_or_create(
cls,
scrobble: Optional["Scrobble"],
backoff,
wait_period,
scrobble_data: dict,
) -> Optional["Scrobble"]:
def create_or_update_for_podcast_episode(
cls, episode: "Episode", user_id: int, scrobble_data: dict
) -> "Scrobble":
scrobble_data['podcast_episode_id'] = episode.id
scrobble = (
cls.objects.filter(
podcast_episode=episode,
user_id=user_id,
played_to_completion=False,
)
.order_by('-modified')
.first()
)
if scrobble:
logger.debug(
f"Found existing scrobble for podcast {episode}, updating",
{"scrobble_data": scrobble_data},
)
return cls.update(scrobble, scrobble_data)
logger.debug(
f"No existing scrobble for podcast epsiode {episode}, creating",
{"scrobble_data": scrobble_data},
)
# If creating a new scrobble, we don't need status
scrobble_data.pop('mopidy_status')
return cls.create(scrobble_data)
@classmethod
def create_or_update_for_sport_event(
cls, event: "SportEvent", user_id: int, scrobble_data: dict
) -> "Scrobble":
scrobble_data['sport_event_id'] = event.id
scrobble = (
cls.objects.filter(
sport_event=event,
user_id=user_id,
played_to_completion=False,
)
.order_by('-modified')
.first()
)
if scrobble:
logger.debug(
f"Found existing scrobble for sport event {event}, updating",
{"scrobble_data": scrobble_data},
)
return cls.update(scrobble, scrobble_data)
logger.debug(
f"No existing scrobble for sport event {event}, creating",
{"scrobble_data": scrobble_data},
)
# If creating a new scrobble, we don't need status
scrobble_data.pop('jellyfin_status')
return cls.create(scrobble_data)
@classmethod
def update(cls, scrobble: "Scrobble", scrobble_data: dict) -> "Scrobble":
# Status is a field we get from Mopidy, which refuses to poll us
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
logger.debug(f"Scrobbling to {scrobble} with status {scrobble_status}")
scrobble.update_ticks(scrobble_data)
# Mopidy finished a play, scrobble away
scrobble.in_progress = False
scrobble.save(update_fields=['in_progress'])
return scrobble
if scrobble and not mopidy_status:
scrobble_is_finished = (
not scrobble.in_progress and scrobble.modified < backoff
)
if scrobble_is_finished:
logger.info(
'Found a very recent scrobble for this item, holding off scrobbling again'
)
return
scrobble_is_stale = scrobble.is_stale(backoff, wait_period)
if (not scrobble or scrobble_is_stale) or mopidy_status:
# If we default this to "" we can probably remove this
scrobble_data['scrobble_log'] = ""
scrobble = cls.objects.create(
**scrobble_data,
)
else:
for key, value in scrobble_data.items():
setattr(scrobble, key, value)
scrobble.save()
# If we hit our completion threshold, save it and get ready
# to scrobble again if we re-watch this.
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'])
# On stop, stop progress and send it to the check for completion
if scrobble_status == "stopped":
scrobble.stop()
# On pause, set is_paused and stop scrobbling
if scrobble_status == "paused":
scrobble.pause()
if scrobble_status == "resumed":
scrobble.resume()
for key, value in scrobble_data.items():
setattr(scrobble, key, value)
scrobble.save()
check_scrobble_for_finish(scrobble)
return scrobble
@classmethod
def create(
cls,
scrobble_data: dict,
) -> "Scrobble":
scrobble_data['scrobble_log'] = ""
scrobble = cls.objects.create(
**scrobble_data,
)
return scrobble
def stop(self) -> None:
if not self.in_progress:
logger.warning("Scrobble already stopped")
return
self.in_progress = False
self.save(update_fields=['in_progress'])
check_scrobble_for_finish(self)
def pause(self) -> None:
if self.is_paused:
logger.warning("Scrobble already paused")
return
self.is_paused = True
self.save(update_fields=["is_paused"])
check_scrobble_for_finish(self)
def resume(self) -> None:
if self.is_paused or not self.in_progress:
self.is_paused = False
self.in_progress = True
return self.save(update_fields=["is_paused", "in_progress"])
def update_ticks(self, data) -> None:
self.playback_position_ticks = data.get("playback_position_ticks")
self.playback_position = data.get("playback_position")
logger.debug(
f"Updating scrobble ticks to {self.playback_position_ticks}"
)
self.save(
update_fields=['playback_position_ticks', 'playback_position']
)

View File

@ -0,0 +1,213 @@
import logging
from typing import Optional
from dateutil.parser import parse
from django.utils import timezone
from music.constants import JELLYFIN_POST_KEYS
from music.models import Track
from podcasts.models import Episode
from scrobbles.models import Scrobble
from scrobbles.utils import convert_to_seconds, parse_mopidy_uri
from videos.models import Video
from sports.models import SportEvent
logger = logging.getLogger(__name__)
def mopidy_scrobble_podcast(
data_dict: dict, user_id: Optional[int]
) -> Scrobble:
mopidy_uri = data_dict.get("mopidy_uri", "")
parsed_data = parse_mopidy_uri(mopidy_uri)
producer_dict = {"name": data_dict.get("artist")}
podcast_name = data_dict.get("album")
if not podcast_name:
podcast_name = parsed_data.get("podcast_name")
podcast_dict = {"name": podcast_name}
episode_name = parsed_data.get("episode_filename")
episode_dict = {
"title": episode_name,
"run_time_ticks": data_dict.get("run_time_ticks"),
"run_time": data_dict.get("run_time"),
"number": parsed_data.get("episode_num"),
"pub_date": parsed_data.get("pub_date"),
"mopidy_uri": mopidy_uri,
}
episode = Episode.find_or_create(podcast_dict, producer_dict, episode_dict)
# Now we run off a scrobble
mopidy_data = {
"user_id": user_id,
"timestamp": timezone.now(),
"playback_position_ticks": data_dict.get("playback_time_ticks"),
"source": "Mopidy",
"mopidy_status": data_dict.get("status"),
}
scrobble = None
if episode:
scrobble = Scrobble.create_or_update_for_podcast_episode(
episode, user_id, mopidy_data
)
return scrobble
def mopidy_scrobble_track(
data_dict: dict, user_id: Optional[int]
) -> Optional[Scrobble]:
artist_dict = {
"name": data_dict.get("artist", None),
"musicbrainz_id": data_dict.get("musicbrainz_artist_id", None),
}
album_dict = {
"name": data_dict.get("album"),
"musicbrainz_id": data_dict.get("musicbrainz_album_id"),
}
track_dict = {
"title": data_dict.get("name"),
"run_time_ticks": data_dict.get("run_time_ticks"),
"run_time": data_dict.get("run_time"),
}
track = Track.find_or_create(artist_dict, album_dict, track_dict)
# Now we run off a scrobble
mopidy_data = {
"user_id": user_id,
"timestamp": timezone.now(),
"playback_position_ticks": data_dict.get("playback_time_ticks"),
"source": "Mopidy",
"mopidy_status": data_dict.get("status"),
}
# Jellyfin MB ids suck, so always overwrite with Mopidy if they're offering
track.musicbrainz_id = data_dict.get("musicbrainz_track_id")
track.save()
scrobble = Scrobble.create_or_update_for_track(track, user_id, mopidy_data)
return scrobble
def create_jellyfin_scrobble_dict(data_dict: dict, user_id: int) -> dict:
jellyfin_status = "resumed"
if data_dict.get("IsPaused"):
jellyfin_status = "paused"
if data_dict.get("PlayedToCompletion"):
jellyfin_status = "stopped"
playback_position_ticks = None
if data_dict.get("PlaybackPositionTicks"):
playback_position_ticks = (
data_dict.get("PlaybackPositionTicks") // 10000
)
if playback_position_ticks <= 0:
playback_position_ticks = None
playback_position = data_dict.get("PlaybackPosition")
if playback_position:
playback_position = convert_to_seconds(playback_position)
return {
"user_id": user_id,
"timestamp": parse(data_dict.get("UtcTimestamp")),
"playback_position_ticks": playback_position_ticks,
"playback_position": playback_position,
"source": "Jellyfin",
"source_id": data_dict.get('MediaSourceId'),
"jellyfin_status": jellyfin_status,
}
def jellyfin_scrobble_track(
data_dict: dict, user_id: Optional[int]
) -> Optional[Scrobble]:
if not data_dict.get("Provider_musicbrainztrack", None):
logger.error(
"No MBrainz Track ID received. This is likely because all metadata is bad, not scrobbling"
)
return
artist_dict = {
'name': data_dict.get(JELLYFIN_POST_KEYS["ARTIST_NAME"], None),
'musicbrainz_id': data_dict.get(
JELLYFIN_POST_KEYS["ARTIST_MB_ID"], None
),
}
album_dict = {
"name": data_dict.get(JELLYFIN_POST_KEYS["ALBUM_NAME"], None),
"musicbrainz_id": data_dict.get(JELLYFIN_POST_KEYS['ALBUM_MB_ID']),
}
# Convert ticks from Jellyfin from microseconds to nanoseconds
# Ain't nobody got time for nanoseconds
track_dict = {
"title": data_dict.get("Name", ""),
"run_time_ticks": data_dict.get(
JELLYFIN_POST_KEYS["RUN_TIME_TICKS"], None
)
// 10000,
"run_time": convert_to_seconds(
data_dict.get(JELLYFIN_POST_KEYS["RUN_TIME"], None)
),
}
track = Track.find_or_create(artist_dict, album_dict, track_dict)
# Prefer Mopidy MD IDs to Jellyfin, so skip if we already have one
if not track.musicbrainz_id:
track.musicbrainz_id = data_dict.get(
JELLYFIN_POST_KEYS["TRACK_MB_ID"], None
)
track.save()
scrobble_dict = create_jellyfin_scrobble_dict(data_dict, user_id)
return Scrobble.create_or_update_for_track(track, user_id, scrobble_dict)
def jellyfin_scrobble_video(data_dict: dict, user_id: Optional[int]):
if not data_dict.get("Provider_imdb", None):
logger.error(
"No IMDB ID received. This is likely because all metadata is bad, not scrobbling"
)
return
video = Video.find_or_create(data_dict)
scrobble_dict = create_jellyfin_scrobble_dict(data_dict, user_id)
return Scrobble.create_or_update_for_video(video, user_id, scrobble_dict)
def manual_scrobble_video(data_dict: dict, user_id: Optional[int]):
if not data_dict.get("Provider_imdb", None):
logger.error(
"No IMDB ID received. This is likely because all metadata is bad, not scrobbling"
)
return
video = Video.find_or_create(data_dict)
scrobble_dict = create_jellyfin_scrobble_dict(data_dict, user_id)
return Scrobble.create_or_update_for_video(video, user_id, scrobble_dict)
def manual_scrobble_event(data_dict: dict, user_id: Optional[int]):
if not data_dict.get("Provider_thesportsdb", None):
logger.error(
"No TheSportsDB ID received. This is likely because all metadata is bad, not scrobbling"
)
return
event = SportEvent.find_or_create(data_dict)
scrobble_dict = create_jellyfin_scrobble_dict(data_dict, user_id)
return Scrobble.create_or_update_for_sport_event(
event, user_id, scrobble_dict
)

View File

@ -0,0 +1,44 @@
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 SportEvent
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]
league = {} # client.lookup_league(league_id=event.get('idLeague'))
event_type = SportEvent.Type.GAME
run_time_seconds = 11700
run_time_ticks = run_time_seconds * 1000
data_dict = {
"ItemType": event_type,
"Name": event.get('strEventAlternate'),
"Start": parse(event.get('strTimestamp')),
"Provider_thesportsdb": event.get('idEvent'),
"RunTime": run_time_seconds,
"RunTimeTicks": run_time_ticks,
"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,
}
return data_dict

View File

@ -1,7 +1,85 @@
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") -> None:
completion_percent = scrobble.media_obj.COMPLETION_PERCENT
if scrobble.percent_played >= completion_percent:
logger.debug(f"Completion percent {completion_percent} met, finishing")
scrobble.in_progress = False
scrobble.is_paused = False
scrobble.played_to_completion = True
scrobble.save(
update_fields=["in_progress", "is_paused", "played_to_completion"]
)
if scrobble.percent_played % 5 == 0:
if getattr(settings, "KEEP_DETAILED_SCROBBLE_LOGS", False):
scrobble.scrobble_log += f"\n{str(scrobble.timestamp)} - {scrobble.playback_position} - {str(scrobble.playback_position_ticks)} - {str(scrobble.percent_played)}%"
scrobble.save(update_fields=['scrobble_log'])

View File

@ -1,15 +1,14 @@
import json
import logging
from datetime import datetime, timedelta
from dateutil.parser import parse
from django.conf import settings
from django.db.models.fields import timezone
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import FormView
from django.views.generic.list import ListView
from 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.response import Response
@ -17,26 +16,29 @@ from scrobbles.constants import (
JELLYFIN_AUDIO_ITEM_TYPES,
JELLYFIN_VIDEO_ITEM_TYPES,
)
from scrobbles.forms import ScrobbleForm
from scrobbles.imdb import lookup_video_from_imdb
from scrobbles.models import Scrobble
from scrobbles.scrobblers import (
jellyfin_scrobble_track,
jellyfin_scrobble_video,
manual_scrobble_event,
manual_scrobble_video,
mopidy_scrobble_podcast,
mopidy_scrobble_track,
)
from scrobbles.serializers import ScrobbleSerializer
from scrobbles.utils import convert_to_seconds
from videos.models import Video
from vrobbler.apps.music.aggregators import scrobble_counts, top_tracks, week_of_scrobbles
from scrobbles.thesportsdb import lookup_event_from_thesportsdb
from vrobbler.apps.music.aggregators import (
scrobble_counts,
top_artists,
top_tracks,
week_of_scrobbles,
)
logger = logging.getLogger(__name__)
TRUTHY_VALUES = [
'true',
'1',
't',
'y',
'yes',
'yeah',
'yup',
'certainly',
'uh-huh',
]
class RecentScrobbleList(ListView):
model = Scrobble
@ -44,26 +46,63 @@ class RecentScrobbleList(ListView):
def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs)
now = timezone.now()
last_eight_minutes = timezone.now() - timedelta(minutes=8)
# Find scrobbles from the last 10 minutes
data['now_playing_list'] = Scrobble.objects.filter(
in_progress=True,
is_paused=False,
timestamp__gte=last_eight_minutes,
timestamp__lte=now,
)
data['video_scrobble_list'] = Scrobble.objects.filter(video__isnull=False, in_progress=False).order_by('-timestamp')[:10]
data['video_scrobble_list'] = Scrobble.objects.filter(
video__isnull=False, played_to_completion=True
).order_by('-timestamp')[:15]
data['podcast_scrobble_list'] = Scrobble.objects.filter(
podcast_episode__isnull=False, played_to_completion=True
).order_by('-timestamp')[:15]
data['sport_scrobble_list'] = Scrobble.objects.filter(
sport_event__isnull=False, played_to_completion=True
).order_by('-timestamp')[:15]
data['top_daily_tracks'] = top_tracks()
data['top_weekly_tracks'] = top_tracks(filter='week')
data['top_monthly_tracks'] = top_tracks(filter='month')
data['top_daily_artists'] = top_artists()
data['top_weekly_artists'] = top_artists(filter='week')
data['top_monthly_artists'] = top_artists(filter='month')
data["weekly_data"] = week_of_scrobbles()
data['counts'] = scrobble_counts()
data['imdb_form'] = ScrobbleForm
return data
def get_queryset(self):
return Scrobble.objects.filter(track__isnull=False, in_progress=False).order_by(
'-timestamp'
)[:25]
return Scrobble.objects.filter(
track__isnull=False, in_progress=False
).order_by('-timestamp')[:15]
class ManualScrobbleView(FormView):
form_class = ScrobbleForm
template_name = 'scrobbles/manual_form.html'
def form_valid(self, form):
item_id = form.cleaned_data.get('item_id')
data_dict = None
if 'tt' in item_id:
data_dict = lookup_video_from_imdb(
form.cleaned_data.get('item_id')
)
if data_dict:
manual_scrobble_video(data_dict, self.request.user.id)
if not data_dict:
logger.debug(f"Looking for sport event with ID {item_id}")
data_dict = lookup_event_from_thesportsdb(
form.cleaned_data.get('item_id')
)
if data_dict:
manual_scrobble_event(data_dict, self.request.user.id)
return HttpResponseRedirect(reverse("home"))
@csrf_exempt
@ -85,86 +124,19 @@ def jellyfin_websocket(request):
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
@ -175,45 +147,14 @@ def mopidy_websocket(request):
# 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_201_CREATED
)
return Response({'scrobble_id': scrobble.id}, status=status.HTTP_200_OK)

View File

View File

@ -0,0 +1,37 @@
from django.contrib import admin
from sports.models import League, SportEvent, Team
from scrobbles.admin import ScrobbleInline
@admin.register(League)
class LeagueAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = ("name", "abbreviation_str")
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",
"home_team",
"away_team",
"season",
)
list_filter = ("season", "home_team", "away_team")
ordering = ("-created",)
inlines = [
ScrobbleInline,
]

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,116 @@
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.mixins import ScrobblableMixin
logger = logging.getLogger(__name__)
BNULL = {"blank": True, "null": True}
class League(TimeStampedModel):
name = models.CharField(max_length=255)
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
logo = models.ImageField(upload_to="sports/league-logos/", **BNULL)
abbreviation_str = models.CharField(max_length=10, **BNULL)
thesportsdb_id = models.IntegerField(**BNULL)
def __str__(self):
return self.name
@property
def abbreviation(self):
return self.abbreviation_str
class Team(TimeStampedModel):
name = models.CharField(max_length=255)
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
league = models.ForeignKey(League, on_delete=models.DO_NOTHING, **BNULL)
thesportsdb_id = models.IntegerField(**BNULL)
def __str__(self):
return self.name
class SportEvent(ScrobblableMixin):
COMPLETION_PERCENT = getattr(settings, 'SPORT_COMPLETION_PERCENT', 90)
class Type(models.TextChoices):
UNKNOWN = 'UK', _('Unknown')
GAME = 'GA', _('Game')
MATCH = 'MA', _('Match')
MEET = 'ME', _('Meet')
event_type = models.CharField(
max_length=2,
choices=Type.choices,
default=Type.UNKNOWN,
)
league = models.ForeignKey(League, 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,
)
season = models.CharField(max_length=255, **BNULL)
def __str__(self):
return f"{self.start.date()} - {self.league.abbreviation} - {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.
"""
league_dict = {
"abbreviation_str": data_dict.get("LeagueName", ""),
"thesportsdb_id": data_dict.get("LeagueId", ""),
}
league, _created = League.objects.get_or_create(**league_dict)
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": data_dict.get("ItemType"),
"home_team": home_team,
"away_team": away_team,
"start": data_dict['Start'],
"league": league,
"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

@ -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,14 @@ class Series(TimeStampedModel):
verbose_name_plural = "series"
class Video(TimeStampedModel):
class Video(ScrobblableMixin):
COMPLETION_PERCENT = getattr(settings, 'VIDEO_COMPLETION_PERCENT', 90)
class VideoType(models.TextChoices):
UNKNOWN = 'U', _('Unknown')
TV_EPISODE = 'E', _('TV Episode')
MOVIE = 'M', _('Movie')
# General fields
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 +47,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 +57,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,13 +83,9 @@ 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(
@ -100,15 +96,33 @@ class Video(TimeStampedModel):
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:
run_time_ticks = data_dict.get("RunTimeTicks", None)
if run_time_ticks:
run_time_ticks = run_time_ticks // 10000
video_extra_dict = {
"year": data_dict.get("Year", ""),
"overview": data_dict.get("Overview", None),
"tagline": data_dict.get("Tagline", None),
"run_time_ticks": run_time_ticks,
"run_time": convert_to_seconds(data_dict.get("RunTime", "")),
"tvdb_id": data_dict.get("Provider_tvdb", None),
"tvrage_id": data_dict.get("Provider_tvrage", None),
"episode_number": data_dict.get("EpisodeNumber", None),
"season_number": data_dict.get("SeasonNumber", None),
}
if series:
video_extra_dict["tv_series_id"] = series.id
if not video.run_time_ticks:
logger.debug(f"Created new video: {video}")
for key, value in video_extra_dict.items():
setattr(video, key, value)
video.save()
else:
logger.debug(f"Found video {video}")

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,26 +37,24 @@ 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", "EST")
ALLOWED_HOSTS = ["*"]
CSRF_TRUSTED_ORIGINS = [
os.getenv("VROBBLER_TRUSTED_ORIGINS", "http://localhost:8000")
@ -86,6 +84,8 @@ INSTALLED_APPS = [
"scrobbles",
"videos",
"music",
"podcasts",
"sports",
"rest_framework",
"allauth",
"allauth.account",
@ -275,17 +275,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": {
@ -295,13 +295,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

@ -8,6 +8,7 @@
<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="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/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>
@ -161,12 +162,11 @@
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 -->
<link rel="apple-touch-icon" href="{% static 'images/apple-touch-icon.png' %}">
</head>
<body>
<header class="navbar navbar-dark sticky-top bg-dark flex-md-nowrap p-0 shadow">
@ -174,7 +174,11 @@
<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>
<input class="form-control form-control-dark w-100" type="text" placeholder="Search" aria-label="Search">
<form id="scrobble-form" action="{% url 'imdb-manual-scrobble' %}" method="post">
{% csrf_token %}
{{ imdb_form }}
</form>
<div class="navbar-nav">
<div class="nav-item text-nowrap">
{% if user.is_authenticated %}
@ -194,27 +198,18 @@
<ul style="padding-right:10px;">
<b>Now playing</b>
{% for scrobble in now_playing_list %}
{% if scrobble.video %}
<div>
{{scrobble.video.title}}<br/>
<small>{{scrobble.created|naturaltime}}<br/>
from {{scrobble.source}}</small>
<div class="progress-bar">
<span class="progress-bar-fill" style="width: {{scrobble.percent_played}}%;"></span>
</div>
</div>
{% endif %}
{% if scrobble.track %}
<div>
{{scrobble.track.title}}<br/>
<em>{{scrobble.track.artist}}</em><br/>
<small>{{scrobble.created|naturaltime}}<br/>
{{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.league.abbreviation}}</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>
</div>
{% endif %}
<hr/>
{% endfor %}
</ul>
@ -223,25 +218,31 @@
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="#">
<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="#">
<a class="nav-link" href="/artists/">
<span data-feather="user"></span>
Artists
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">
<a class="nav-link" href="/movies/">
<span data-feather="film"></span>
Movies
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">
<a class="nav-link" href="/series/">
<span data-feather="tv"></span>
Series
</a>
@ -280,35 +281,35 @@
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: '#007bff',
borderWidth: 4,
pointBackgroundColor: '#007bff'
}]
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
}
scales: {
yAxes: [{
ticks: {
beginAtZero: false
}
}]
},
legend: {
display: false
}
}
})
})()

View File

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block content %}
<main class="col-md-4 ms-sm-auto col-lg-10 px-md-4">
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">Manual scrobble</h1>
<form action="#" method="post">
{% csrf_token %}
{{ form }}
<input type="submit" value="Submit">
</form>
</div>
</main>
{% endblock %}

View File

@ -3,10 +3,11 @@
{% block content %}
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4">
<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">Dashboard</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">Share</button>
<button type="button" class="btn btn-sm btn-outline-secondary">Export</button>
@ -18,82 +19,213 @@
</div>
</div>
<canvas class="my-4 w-100" id="myChart" width="900" height="380"></canvas>
<canvas class="my-4 w-100" id="myChart" width="900" height="220"></canvas>
<div class="container">
<h2>Top this week</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">Track</th>
<th scope="col">Artist</th>
<th scope="col">Album</th>
</tr>
</thead>
<tbody>
{% for track in top_weekly_tracks %}
<tr>
<td>{{track.num_scrobbles}}</td>
<td>{{track.title}}</td>
<td>{{track.artist.name}}</td>
<td>{{track.album.name}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="row">
<p>Today <b>{{counts.today}}</b> | This Week <b>{{counts.week}}</b> | This Month <b>{{counts.month}}</b> | This Year <b>{{counts.year}}</b> | All Time <b>{{counts.alltime}}</b></p>
</div>
<div class="row">
<div class="col-md">
<ul class="nav nav-tabs" id="myTab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="home-tab" data-bs-toggle="tab" data-bs-target="#artists-week" type="button" role="tab" aria-controls="home" aria-selected="true">Weekly Artists</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="artist-month-tab" data-bs-toggle="tab" data-bs-target="#artists-month" type="button" role="tab" aria-controls="home" aria-selected="true">Monthly Artists</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#tracks-week" type="button" role="tab" aria-controls="profile" aria-selected="false">Weekly Tracks</button>
</li>
</ul>
<h2>Latest scrobbles</h2>
<p>Today <b>{{counts.today}}</b> | This Week <b>{{counts.week}}</b> | This Month <b>{{counts.month}}</b> | This Year <b>{{counts.year}}</b> | All Time <b>{{counts.alltime}}</b></p>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Time</th>
<th scope="col">Track</th>
<th scope="col">Artist</th>
<th scope="col">Source</th>
</tr>
</thead>
<tbody>
{% for scrobble in object_list %}
<tr>
<td>{{scrobble.timestamp|naturaltime}}</td>
<td>{{scrobble.track.title}}</td>
<td>{{scrobble.track.artist.name}}</td>
<td>{{scrobble.source}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="tab-content" id="myTabContent">
<div class="tab-pane fade show active" id="artists-week" role="tabpanel" aria-labelledby="artists-week-tab">
<h2>Top artists this week</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">Artist</th>
</tr>
</thead>
<tbody>
{% for artist in top_weekly_artists %}
<tr>
<td>{{artist.num_scrobbles}}</td>
<td>{{artist.name}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<h2>Latest watched</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Time</th>
<th scope="col">Title</th>
<th scope="col">Series</th>
<th scope="col">Season & Episode</th>
<th scope="col">Source</th>
</tr>
</thead>
<tbody>
{% for scrobble in video_scrobble_list %}
<tr>
<td>{{scrobble.timestamp|naturaltime}}</td>
<td>{{scrobble.video.title}}</td>
<td>{% if scrobble.video.tv_series %}{{scrobble.video.tv_series}}{% endif %}</td>
<td>{% if scrobble.video.tv_series %}{{scrobble.video.season_number}}, {{scrobble.video.episode_number}}{% endif %}</td>
<td>{{scrobble.source}}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="tab-pane fade show" id="tracks-week" role="tabpanel" aria-labelledby="tracks-week-tab">
<h2>Top tracks this week</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">Track</th>
<th scope="col">Artist</th>
</tr>
</thead>
<tbody>
{% for track in top_weekly_tracks %}
<tr>
<td>{{track.num_scrobbles}}</td>
<td>{{track.title}}</td>
<td>{{track.artist.name}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="tab-pane fade show " id="artists-month" role="tabpanel" aria-labelledby="artists-month-tab">
<h2>Top artists this month</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">Artist</th>
</tr>
</thead>
<tbody>
{% for artist in top_monthly_artists %}
<tr>
<td>{{artist.num_scrobbles}}</td>
<td>{{artist.name}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-md">
<ul class="nav nav-tabs" id="myTab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="home-tab" data-bs-toggle="tab" data-bs-target="#latest-listened" type="button" role="tab" aria-controls="home" aria-selected="true">Tracks</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#latest-watched" type="button" role="tab" aria-controls="profile" aria-selected="false">Videos</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#latest-podcasted" type="button" role="tab" aria-controls="profile" aria-selected="false">Podcasts</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#latest-sports" type="button" role="tab" aria-controls="profile" aria-selected="false">Sports</button>
</li>
</ul>
<div class="tab-content" id="myTabContent2">
<div class="tab-pane fade show active" id="latest-listened" role="tabpanel" aria-labelledby="latest-listened-tab">
<h2>Latest listened</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Time</th>
<th scope="col">Track</th>
<th scope="col">Artist</th>
</tr>
</thead>
<tbody>
{% for scrobble in object_list %}
<tr>
<td>{{scrobble.timestamp|naturaltime}}</td>
<td>{{scrobble.track.title}}</td>
<td>{{scrobble.track.artist.name}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="tab-pane fade show" id="latest-watched" role="tabpanel" aria-labelledby="latest-watched-tab">
<h2>Latest watched</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Time</th>
<th scope="col">Title</th>
<th scope="col">Series</th>
</tr>
</thead>
<tbody>
{% for scrobble in video_scrobble_list %}
<tr>
<td>{{scrobble.timestamp|naturaltime}}</td>
<td>{% if scrobble.video.tv_series %}S{{scrobble.video.season_number}}E{{scrobble.video.episode_number}} -{% endif %} {{scrobble.video.title}}</td>
<td>{% if scrobble.video.tv_series %}{{scrobble.video.tv_series}}{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="tab-pane fade show" id="latest-sports" role="tabpanel" aria-labelledby="latest-sports-tab">
<h2>Latest Sports</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">Title</th>
<th scope="col">League</th>
</tr>
</thead>
<tbody>
{% for scrobble in sport_scrobble_list %}
<tr>
<td>{{scrobble.timestamp|naturaltime}}</td>
<td>{{scrobble.sport_event.title}}</td>
<td>{{scrobble.sport_event.league.abbreviation}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="tab-pane fade show" id="latest-podcasted" role="tabpanel" aria-labelledby="latest-podcasted-tab">
<h2>Latest Podcasted</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">Title</th>
<th scope="col">Podcast</th>
</tr>
</thead>
<tbody>
{% for scrobble in podcast_scrobble_list %}
<tr>
<td>{{scrobble.timestamp|naturaltime}}</td>
<td>{{scrobble.podcast_episode.title}}</td>
<td>{{scrobble.podcast_episode.podcast}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</main>
{% endblock %}

View File

@ -1,12 +1,28 @@
{% 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">Movies</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">Share</button>
<button type="button" class="btn btn-sm btn-outline-secondary">Export</button>
</div>
<button type="button" class="btn btn-sm btn-outline-secondary dropdown-toggle">
<span data-feather="calendar"></span>
This week
</button>
</div>
</div>
<h2>Movies</h2>
<div class="container">
<ul>
{% for movie in object_list %}
<li>{{movie}}</li>
{% endfor %}
</ul>
</div>
</main>
{% endblock %}

View File

@ -0,0 +1,22 @@
{% 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">Series</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">
<ul>
{% for movie in object_list %}
<li>{{movie}}</li>
{% endfor %}
</ul>
</div>
</main>
{% endblock %}

View File

@ -3,7 +3,7 @@ from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include, path
from rest_framework import routers
from scrobbles.views import RecentScrobbleList
import scrobbles.views as scrobbles_views
from videos import urls as video_urls
from scrobbles import urls as scrobble_urls
@ -15,8 +15,13 @@ urlpatterns = [
# path("movies/", include(movies, namespace="movies")),
# path("shows/", include(shows, namespace="shows")),
path("api/v1/scrobbles/", include(scrobble_urls, namespace="scrobbles")),
path(
'manual/imdb/',
scrobbles_views.ManualScrobbleView.as_view(),
name='imdb-manual-scrobble',
),
path("", include(video_urls, namespace="videos")),
path("", RecentScrobbleList.as_view(), name="home"),
path("", scrobbles_views.RecentScrobbleList.as_view(), name="home"),
]
if settings.DEBUG: