Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 363a132df2 | |||
| c484905d11 | |||
| 0378dfe6eb | |||
| c39443e35b | |||
| bb3dfdf7ba | |||
| fdfb8a635e | |||
| 290e6dc8d9 | |||
| 499546503c | |||
| bd3a381346 | |||
| e206a7fbf3 | |||
| 6313da9868 | |||
| f7c69a6763 | |||
| ab88fcb9a7 | |||
| 1d868d3075 | |||
| 5b07c70ca2 | |||
| 9607fb2d1e | |||
| e6cf126f5c | |||
| eeee6eea4e | |||
| 1f26931215 | |||
| 610ec63cbd | |||
| 72fded4097 | |||
| d5eea53a01 | |||
| a49eb31276 | |||
| c1e1160db3 | |||
| 0e17831724 | |||
| 045fad8552 | |||
| a09c6d6b92 | |||
| 3f8b29f5ee | |||
| 507b3aaaf2 | |||
| 879357473a | |||
| cc7d267494 | |||
| 685c99d023 | |||
| 69f596039d | |||
| cf55c9b464 | |||
| 8517212d0e | |||
| f435e60b80 | |||
| b51b189cd4 | |||
| 2a20d1212b | |||
| 83b6ba9cc3 |
@ -1,4 +1,7 @@
|
||||
#+title: Readme
|
||||
Vrobbler
|
||||
========
|
||||
|
||||
[](https://ci.unbl.ink/secstate/vrobbler)
|
||||
|
||||
Vrobbler is a pretty simple Django-powered web app for scrobbling video plays from you favorite Jellyfin installation.
|
||||
|
||||
324
poetry.lock
generated
324
poetry.lock
generated
@ -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"},
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "vrobbler"
|
||||
version = "0.2.2"
|
||||
version = "0.6.0"
|
||||
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"
|
||||
|
||||
@ -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>
|
||||
@ -2,6 +2,8 @@ from django.contrib import admin
|
||||
|
||||
from music.models import Artist, Album, Track
|
||||
|
||||
from scrobbles.admin import ScrobbleInline
|
||||
|
||||
|
||||
@admin.register(Album)
|
||||
class AlbumAdmin(admin.ModelAdmin):
|
||||
@ -9,6 +11,9 @@ class AlbumAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "year", "musicbrainz_id")
|
||||
list_filter = ("year",)
|
||||
ordering = ("name",)
|
||||
filter_horizontal = [
|
||||
'artists',
|
||||
]
|
||||
|
||||
|
||||
@admin.register(Artist)
|
||||
@ -30,3 +35,6 @@ class TrackAdmin(admin.ModelAdmin):
|
||||
)
|
||||
list_filter = ("album", "artist")
|
||||
ordering = ("-created",)
|
||||
inlines = [
|
||||
ScrobbleInline,
|
||||
]
|
||||
|
||||
@ -18,7 +18,7 @@ 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
|
||||
@ -53,7 +53,7 @@ def week_of_scrobbles(media: str = 'tracks') -> dict[str, int]:
|
||||
.filter(
|
||||
timestamp__gte=start,
|
||||
timestamp__lte=end,
|
||||
in_progress=False,
|
||||
played_to_completion=True,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
23
vrobbler/apps/music/migrations/0005_album_cover_image.py
Normal file
23
vrobbler/apps/music/migrations/0005_album_cover_image.py
Normal file
@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-12 04:22
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
(
|
||||
'music',
|
||||
'0004_alter_artist_options_alter_album_musicbrainz_id_and_more',
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='album',
|
||||
name='cover_image',
|
||||
field=models.ImageField(
|
||||
blank=True, null=True, upload_to='albums/'
|
||||
),
|
||||
),
|
||||
]
|
||||
20
vrobbler/apps/music/migrations/0006_album_artists.py
Normal file
20
vrobbler/apps/music/migrations/0006_album_artists.py
Normal file
@ -0,0 +1,20 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-12 05:04
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('music', '0005_album_cover_image'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='album',
|
||||
name='artists',
|
||||
field=models.ManyToManyField(
|
||||
blank=True, null=True, to='music.artist'
|
||||
),
|
||||
),
|
||||
]
|
||||
18
vrobbler/apps/music/migrations/0007_alter_album_artists.py
Normal file
18
vrobbler/apps/music/migrations/0007_alter_album_artists.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-12 17:17
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('music', '0006_album_artists'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='album',
|
||||
name='artists',
|
||||
field=models.ManyToManyField(to='music.artist'),
|
||||
),
|
||||
]
|
||||
17
vrobbler/apps/music/migrations/0008_alter_track_options.py
Normal file
17
vrobbler/apps/music/migrations/0008_alter_track_options.py
Normal file
@ -0,0 +1,17 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-13 01:47
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('music', '0007_alter_album_artists'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='track',
|
||||
options={},
|
||||
),
|
||||
]
|
||||
@ -2,39 +2,25 @@ import logging
|
||||
from typing import Dict, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
import musicbrainzngs
|
||||
from django.apps.config import cached_property
|
||||
|
||||
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, unique=True, **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']]
|
||||
unique_together = [['name', 'musicbrainz_id']]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@ -44,20 +30,77 @@ 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):
|
||||
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, unique=True, **BNULL)
|
||||
run_time = models.CharField(max_length=8, **BNULL)
|
||||
run_time_ticks = models.PositiveBigIntegerField(**BNULL)
|
||||
# thumbs = models.IntegerField(default=Opinion.NEUTRAL, choices=Opinion.choices)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.title} by {self.artist}"
|
||||
@ -83,12 +126,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}")
|
||||
|
||||
@ -98,6 +142,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
|
||||
|
||||
|
||||
0
vrobbler/apps/podcasts/__init__.py
Normal file
0
vrobbler/apps/podcasts/__init__.py
Normal file
36
vrobbler/apps/podcasts/admin.py
Normal file
36
vrobbler/apps/podcasts/admin.py
Normal file
@ -0,0 +1,36 @@
|
||||
from django.contrib import admin
|
||||
from podcasts.models import Episode, Podcast, Producer
|
||||
from scrobbles.admin import ScrobbleInline
|
||||
|
||||
|
||||
@admin.register(Producer)
|
||||
class ProducerAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = ("name",)
|
||||
ordering = ("name",)
|
||||
|
||||
|
||||
@admin.register(Podcast)
|
||||
class PodcastAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = (
|
||||
"name",
|
||||
"producer",
|
||||
"active",
|
||||
)
|
||||
ordering = ("name",)
|
||||
|
||||
|
||||
@admin.register(Episode)
|
||||
class EpisodeAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = (
|
||||
"title",
|
||||
"podcast",
|
||||
"run_time",
|
||||
)
|
||||
list_filter = ("podcast",)
|
||||
ordering = ("-created",)
|
||||
inlines = [
|
||||
ScrobbleInline,
|
||||
]
|
||||
5
vrobbler/apps/podcasts/apps.py
Normal file
5
vrobbler/apps/podcasts/apps.py
Normal file
@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PodcastsConfig(AppConfig):
|
||||
name = 'podcasts'
|
||||
158
vrobbler/apps/podcasts/migrations/0001_initial.py
Normal file
158
vrobbler/apps/podcasts/migrations/0001_initial.py
Normal file
@ -0,0 +1,158 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-12 17:18
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django_extensions.db.fields
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Producer',
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name='ID',
|
||||
),
|
||||
),
|
||||
(
|
||||
'created',
|
||||
django_extensions.db.fields.CreationDateTimeField(
|
||||
auto_now_add=True, verbose_name='created'
|
||||
),
|
||||
),
|
||||
(
|
||||
'modified',
|
||||
django_extensions.db.fields.ModificationDateTimeField(
|
||||
auto_now=True, verbose_name='modified'
|
||||
),
|
||||
),
|
||||
('name', models.CharField(max_length=255)),
|
||||
(
|
||||
'uuid',
|
||||
models.UUIDField(
|
||||
blank=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'get_latest_by': 'modified',
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Podcast',
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name='ID',
|
||||
),
|
||||
),
|
||||
(
|
||||
'created',
|
||||
django_extensions.db.fields.CreationDateTimeField(
|
||||
auto_now_add=True, verbose_name='created'
|
||||
),
|
||||
),
|
||||
(
|
||||
'modified',
|
||||
django_extensions.db.fields.ModificationDateTimeField(
|
||||
auto_now=True, verbose_name='modified'
|
||||
),
|
||||
),
|
||||
('name', models.CharField(max_length=255)),
|
||||
(
|
||||
'uuid',
|
||||
models.UUIDField(
|
||||
blank=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
('active', models.BooleanField(default=True)),
|
||||
('url', models.URLField(blank=True, null=True)),
|
||||
(
|
||||
'producer',
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to='podcasts.producer',
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'get_latest_by': 'modified',
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Episode',
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name='ID',
|
||||
),
|
||||
),
|
||||
(
|
||||
'created',
|
||||
django_extensions.db.fields.CreationDateTimeField(
|
||||
auto_now_add=True, verbose_name='created'
|
||||
),
|
||||
),
|
||||
(
|
||||
'modified',
|
||||
django_extensions.db.fields.ModificationDateTimeField(
|
||||
auto_now=True, verbose_name='modified'
|
||||
),
|
||||
),
|
||||
('title', models.CharField(max_length=255)),
|
||||
(
|
||||
'uuid',
|
||||
models.UUIDField(
|
||||
blank=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
'mopidy_uri',
|
||||
models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
(
|
||||
'podcast',
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to='podcasts.producer',
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'get_latest_by': 'modified',
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,32 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-12 17:48
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('podcasts', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='episode',
|
||||
name='run_time',
|
||||
field=models.CharField(blank=True, max_length=8, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='episode',
|
||||
name='run_time_ticks',
|
||||
field=models.PositiveBigIntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='episode',
|
||||
name='podcast',
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to='podcasts.podcast',
|
||||
),
|
||||
),
|
||||
]
|
||||
18
vrobbler/apps/podcasts/migrations/0003_episode_pub_date.py
Normal file
18
vrobbler/apps/podcasts/migrations/0003_episode_pub_date.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-12 18:08
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('podcasts', '0002_episode_run_time_episode_run_time_ticks_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='episode',
|
||||
name='pub_date',
|
||||
field=models.DateField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
18
vrobbler/apps/podcasts/migrations/0004_episode_number.py
Normal file
18
vrobbler/apps/podcasts/migrations/0004_episode_number.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-12 18:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('podcasts', '0003_episode_pub_date'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='episode',
|
||||
name='number',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,22 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-13 01:47
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('podcasts', '0004_episode_number'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='episode',
|
||||
options={},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='episode',
|
||||
name='title',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
]
|
||||
0
vrobbler/apps/podcasts/migrations/__init__.py
Normal file
0
vrobbler/apps/podcasts/migrations/__init__.py
Normal file
85
vrobbler/apps/podcasts/models.py
Normal file
85
vrobbler/apps/podcasts/models.py
Normal file
@ -0,0 +1,85 @@
|
||||
import logging
|
||||
from typing import Dict, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
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):
|
||||
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
|
||||
@ -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", "track__artist")
|
||||
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
|
||||
|
||||
14
vrobbler/apps/scrobbles/forms.py
Normal file
14
vrobbler/apps/scrobbles/forms.py
Normal file
@ -0,0 +1,14 @@
|
||||
from django import forms
|
||||
|
||||
|
||||
class ScrobbleForm(forms.Form):
|
||||
item_id = forms.CharField(
|
||||
label="",
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
'class': "form-control form-control-dark w-100",
|
||||
'placeholder': "Scrobble something (IMDB ID, String, TVDB ID ...)",
|
||||
'aria-label': "Scrobble something",
|
||||
}
|
||||
),
|
||||
)
|
||||
57
vrobbler/apps/scrobbles/imdb.py
Normal file
57
vrobbler/apps/scrobbles/imdb.py
Normal file
@ -0,0 +1,57 @@
|
||||
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 = int(media.get('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
|
||||
@ -0,0 +1,25 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-12 17:48
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('podcasts', '0002_episode_run_time_episode_run_time_ticks_and_more'),
|
||||
('scrobbles', '0006_scrobble_track_alter_scrobble_video'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='scrobble',
|
||||
name='podcast_episode',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to='podcasts.episode',
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,25 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-14 21:23
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('sports', '0002_rename_start_utc_sportevent_start'),
|
||||
('scrobbles', '0007_scrobble_podcast_episode'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='scrobble',
|
||||
name='sport_event',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to='sports.sportevent',
|
||||
),
|
||||
),
|
||||
]
|
||||
17
vrobbler/apps/scrobbles/mixins.py
Normal file
17
vrobbler/apps/scrobbles/mixins.py
Normal file
@ -0,0 +1,17 @@
|
||||
from uuid import uuid4
|
||||
|
||||
from django.db import models
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
|
||||
BNULL = {"blank": True, "null": True}
|
||||
|
||||
|
||||
class ScrobblableMixin(TimeStampedModel):
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
title = models.CharField(max_length=255, **BNULL)
|
||||
run_time = models.CharField(max_length=8, **BNULL)
|
||||
run_time_ticks = models.PositiveBigIntegerField(**BNULL)
|
||||
# thumbs = models.IntegerField(default=Opinion.NEUTRAL, choices=Opinion.choices)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
@ -8,7 +8,10 @@ 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()
|
||||
@ -22,6 +25,12 @@ 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,67 +46,49 @@ 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 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)
|
||||
playback_ticks = None
|
||||
percent_played = 100
|
||||
|
||||
return 0
|
||||
if not self.media_obj.run_time_ticks:
|
||||
logger.warning(
|
||||
f"{self} has no run_time_ticks value, cannot show percent played"
|
||||
)
|
||||
return percent_played
|
||||
|
||||
playback_ticks = self.playback_position_ticks
|
||||
if not playback_ticks:
|
||||
logger.info(
|
||||
"No playback_position_ticks, estimating based on creation time"
|
||||
)
|
||||
playback_ticks = (timezone.now() - self.timestamp).seconds * 1000
|
||||
|
||||
percent = int((playback_ticks / self.media_obj.run_time_ticks) * 100)
|
||||
|
||||
if percent > 100:
|
||||
percent = 100
|
||||
return percent
|
||||
|
||||
@property
|
||||
def media_run_time_ticks(self) -> int:
|
||||
def 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}"
|
||||
)
|
||||
return f"Scrobble of {self.media_obj} {self.timestamp.year}-{self.timestamp.month}"
|
||||
|
||||
@classmethod
|
||||
def create_or_update_for_video(
|
||||
cls, video: "Video", user_id: int, jellyfin_data: dict
|
||||
) -> "Scrobble":
|
||||
jellyfin_data['video_id'] = video.id
|
||||
logger.debug(
|
||||
f"Creating or updating scrobble for video {video} with data {jellyfin_data}"
|
||||
)
|
||||
scrobble = (
|
||||
cls.objects.filter(video=video, user_id=user_id)
|
||||
.order_by('-modified')
|
||||
@ -122,8 +113,31 @@ class Scrobble(TimeStampedModel):
|
||||
.order_by('-modified')
|
||||
.first()
|
||||
)
|
||||
if scrobble:
|
||||
logger.debug(
|
||||
f"Found existing scrobble for track {track}, updating",
|
||||
{"scrobble_data": scrobble_data},
|
||||
)
|
||||
|
||||
backoff = timezone.now() + timedelta(seconds=TRACK_BACKOFF)
|
||||
wait_period = timezone.now() + timedelta(minutes=TRACK_WAIT_PERIOD)
|
||||
|
||||
return cls.update_or_create(
|
||||
scrobble, backoff, wait_period, scrobble_data
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def create_or_update_for_podcast_episode(
|
||||
cls, episode: "Episode", user_id: int, scrobble_data: dict
|
||||
) -> "Scrobble":
|
||||
scrobble_data['podcast_episode_id'] = episode.id
|
||||
scrobble = (
|
||||
cls.objects.filter(podcast_episode=episode, user_id=user_id)
|
||||
.order_by('-modified')
|
||||
.first()
|
||||
)
|
||||
logger.debug(
|
||||
f"Found existing scrobble for track {track}, updating",
|
||||
f"Found existing scrobble for podcast {episode}, updating",
|
||||
{"scrobble_data": scrobble_data},
|
||||
)
|
||||
|
||||
@ -134,6 +148,25 @@ class Scrobble(TimeStampedModel):
|
||||
scrobble, backoff, wait_period, scrobble_data
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def create_or_update_for_sport_event(
|
||||
cls, event: "SportEvent", user_id: int, jellyfin_data: dict
|
||||
) -> "Scrobble":
|
||||
jellyfin_data['sport_event_id'] = event.id
|
||||
scrobble = (
|
||||
cls.objects.filter(sport_event=event, user_id=user_id)
|
||||
.order_by('-modified')
|
||||
.first()
|
||||
)
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def update_or_create(
|
||||
cls,
|
||||
@ -144,57 +177,86 @@ class Scrobble(TimeStampedModel):
|
||||
) -> Optional["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
|
||||
|
||||
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
|
||||
|
||||
# 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
|
||||
scrobble_status = scrobble_data.pop('mopidy_status', None)
|
||||
if not scrobble_status:
|
||||
scrobble_status = scrobble_data.pop('jellyfin_status', None)
|
||||
if not scrobble_status:
|
||||
logger.warning(
|
||||
f"No status update found in message, not scrobbling"
|
||||
)
|
||||
if scrobble_is_finished:
|
||||
logger.info(
|
||||
'Found a very recent scrobble for this item, holding off scrobbling again'
|
||||
)
|
||||
return
|
||||
return
|
||||
|
||||
scrobble_is_stale = scrobble.is_stale(backoff, wait_period)
|
||||
if scrobble:
|
||||
logger.debug(
|
||||
f"Scrobbling to {scrobble} with status {scrobble_status}"
|
||||
)
|
||||
scrobble.update_ticks(scrobble_data)
|
||||
|
||||
if (not scrobble or scrobble_is_stale) or mopidy_status:
|
||||
# On stop, stop progress and send it to the check for completion
|
||||
if scrobble_status == "stopped":
|
||||
return scrobble.stop()
|
||||
|
||||
# On pause, set is_paused and stop scrobbling
|
||||
if scrobble_status == "paused":
|
||||
return scrobble.pause()
|
||||
|
||||
if scrobble_status == "resumed":
|
||||
return scrobble.resume()
|
||||
|
||||
for key, value in scrobble_data.items():
|
||||
setattr(scrobble, key, value)
|
||||
scrobble.save()
|
||||
|
||||
# We're not changing the scrobble, but we don't want to walk over an existing one
|
||||
# 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
|
||||
check_scrobble_for_finish(scrobble)
|
||||
else:
|
||||
logger.debug(
|
||||
f"Creating new scrobble with status {scrobble_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'])
|
||||
|
||||
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']
|
||||
)
|
||||
|
||||
216
vrobbler/apps/scrobbles/scrobblers.py
Normal file
216
vrobbler/apps/scrobbles/scrobblers.py
Normal file
@ -0,0 +1,216 @@
|
||||
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"),
|
||||
}
|
||||
|
||||
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, 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
|
||||
)
|
||||
44
vrobbler/apps/scrobbles/thesportsdb.py
Normal file
44
vrobbler/apps/scrobbles/thesportsdb.py
Normal 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
|
||||
@ -1,7 +1,89 @@
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
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 = getattr(settings, "MUSIC_COMPLETION_PERCENT", 95)
|
||||
if scrobble.video:
|
||||
completion_percent = getattr(settings, "VIDEO_COMPLETION_PERCENT", 90)
|
||||
if scrobble.podcast_episode:
|
||||
completion_percent = getattr(
|
||||
settings, "PODCAST_COMPLETION_PERCENT", 25
|
||||
)
|
||||
if scrobble.percent_played >= completion_percent:
|
||||
scrobble.in_progress = False
|
||||
scrobble.is_paused = False
|
||||
scrobble.played_to_completion = True
|
||||
scrobble.save(
|
||||
update_fields=["in_progress", "is_paused", "played_to_completion"]
|
||||
)
|
||||
|
||||
if scrobble.percent_played % 5 == 0:
|
||||
if getattr(settings, "KEEP_DETAILED_SCROBBLE_LOGS", False):
|
||||
scrobble.scrobble_log += f"\n{str(scrobble.timestamp)} - {scrobble.playback_position} - {str(scrobble.playback_position_ticks)} - {str(scrobble.percent_played)}%"
|
||||
scrobble.save(update_fields=['scrobble_log'])
|
||||
|
||||
@ -1,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,10 +16,20 @@ 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 scrobbles.thesportsdb import lookup_event_from_thesportsdb
|
||||
|
||||
from vrobbler.apps.music.aggregators import (
|
||||
scrobble_counts,
|
||||
top_artists,
|
||||
@ -49,17 +58,20 @@ 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]
|
||||
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')
|
||||
@ -70,12 +82,39 @@ class RecentScrobbleList(ListView):
|
||||
|
||||
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]
|
||||
).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
|
||||
@ -97,79 +136,14 @@ 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)
|
||||
@ -187,41 +161,12 @@ 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)
|
||||
|
||||
0
vrobbler/apps/sports/__init__.py
Normal file
0
vrobbler/apps/sports/__init__.py
Normal file
37
vrobbler/apps/sports/admin.py
Normal file
37
vrobbler/apps/sports/admin.py
Normal 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,
|
||||
]
|
||||
5
vrobbler/apps/sports/apps.py
Normal file
5
vrobbler/apps/sports/apps.py
Normal file
@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class SportsConfig(AppConfig):
|
||||
name = "sports"
|
||||
214
vrobbler/apps/sports/migrations/0001_initial.py
Normal file
214
vrobbler/apps/sports/migrations/0001_initial.py
Normal file
@ -0,0 +1,214 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-14 21:14
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django_extensions.db.fields
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='League',
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name='ID',
|
||||
),
|
||||
),
|
||||
(
|
||||
'created',
|
||||
django_extensions.db.fields.CreationDateTimeField(
|
||||
auto_now_add=True, verbose_name='created'
|
||||
),
|
||||
),
|
||||
(
|
||||
'modified',
|
||||
django_extensions.db.fields.ModificationDateTimeField(
|
||||
auto_now=True, verbose_name='modified'
|
||||
),
|
||||
),
|
||||
('name', models.CharField(max_length=255)),
|
||||
(
|
||||
'uuid',
|
||||
models.UUIDField(
|
||||
blank=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
'logo',
|
||||
models.ImageField(
|
||||
blank=True, null=True, upload_to='sports/league-logos/'
|
||||
),
|
||||
),
|
||||
(
|
||||
'abbreviation_str',
|
||||
models.CharField(blank=True, max_length=10, null=True),
|
||||
),
|
||||
('thesportsdb_id', models.IntegerField(blank=True, null=True)),
|
||||
],
|
||||
options={
|
||||
'get_latest_by': 'modified',
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Team',
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name='ID',
|
||||
),
|
||||
),
|
||||
(
|
||||
'created',
|
||||
django_extensions.db.fields.CreationDateTimeField(
|
||||
auto_now_add=True, verbose_name='created'
|
||||
),
|
||||
),
|
||||
(
|
||||
'modified',
|
||||
django_extensions.db.fields.ModificationDateTimeField(
|
||||
auto_now=True, verbose_name='modified'
|
||||
),
|
||||
),
|
||||
('name', models.CharField(max_length=255)),
|
||||
(
|
||||
'uuid',
|
||||
models.UUIDField(
|
||||
blank=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
('thesportsdb_id', models.IntegerField(blank=True, null=True)),
|
||||
(
|
||||
'league',
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to='sports.league',
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'get_latest_by': 'modified',
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SportEvent',
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name='ID',
|
||||
),
|
||||
),
|
||||
(
|
||||
'created',
|
||||
django_extensions.db.fields.CreationDateTimeField(
|
||||
auto_now_add=True, verbose_name='created'
|
||||
),
|
||||
),
|
||||
(
|
||||
'modified',
|
||||
django_extensions.db.fields.ModificationDateTimeField(
|
||||
auto_now=True, verbose_name='modified'
|
||||
),
|
||||
),
|
||||
(
|
||||
'uuid',
|
||||
models.UUIDField(
|
||||
blank=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
'title',
|
||||
models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
(
|
||||
'run_time',
|
||||
models.CharField(blank=True, max_length=8, null=True),
|
||||
),
|
||||
(
|
||||
'run_time_ticks',
|
||||
models.PositiveBigIntegerField(blank=True, null=True),
|
||||
),
|
||||
(
|
||||
'event_type',
|
||||
models.CharField(
|
||||
choices=[
|
||||
('UK', 'Unknown'),
|
||||
('GA', 'Game'),
|
||||
('MA', 'Match'),
|
||||
('ME', 'Meet'),
|
||||
],
|
||||
default='UK',
|
||||
max_length=2,
|
||||
),
|
||||
),
|
||||
('start_utc', models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
'season',
|
||||
models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
(
|
||||
'away_team',
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name='away_event_set',
|
||||
to='sports.team',
|
||||
),
|
||||
),
|
||||
(
|
||||
'home_team',
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name='home_event_set',
|
||||
to='sports.team',
|
||||
),
|
||||
),
|
||||
(
|
||||
'league',
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to='sports.league',
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-14 21:17
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('sports', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='sportevent',
|
||||
old_name='start_utc',
|
||||
new_name='start',
|
||||
),
|
||||
]
|
||||
0
vrobbler/apps/sports/migrations/__init__.py
Normal file
0
vrobbler/apps/sports/migrations/__init__.py
Normal file
113
vrobbler/apps/sports/models.py
Normal file
113
vrobbler/apps/sports/models.py
Normal file
@ -0,0 +1,113 @@
|
||||
import logging
|
||||
from typing import Dict
|
||||
from uuid import uuid4
|
||||
|
||||
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):
|
||||
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
|
||||
@ -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,
|
||||
]
|
||||
|
||||
18
vrobbler/apps/videos/migrations/0006_alter_video_year.py
Normal file
18
vrobbler/apps/videos/migrations/0006_alter_video_year.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-13 01:47
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('videos', '0005_alter_video_options_alter_video_unique_together'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='video',
|
||||
name='year',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -7,6 +7,7 @@ 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 +31,12 @@ class Series(TimeStampedModel):
|
||||
verbose_name_plural = "series"
|
||||
|
||||
|
||||
class Video(TimeStampedModel):
|
||||
class Video(ScrobblableMixin):
|
||||
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 +44,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)
|
||||
@ -84,13 +80,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(
|
||||
@ -101,16 +93,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}")
|
||||
|
||||
|
||||
@ -37,6 +37,11 @@ KEEP_DETAILED_SCROBBLE_LOGS = os.getenv(
|
||||
"VROBBLER_KEEP_DETAILED_SCROBBLE_LOGS", False
|
||||
)
|
||||
|
||||
PODCAST_COMPLETION_PERCENT = os.getenv(
|
||||
"VROBBLER_PODCAST_COMPLETION_PERCENT", 25
|
||||
)
|
||||
MUSIC_COMPLETION_PERCENT = os.getenv("VROBBLER_MUSIC_COMPLETION_PERCENT", 90)
|
||||
|
||||
# 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)
|
||||
|
||||
@ -44,7 +49,7 @@ DELETE_STALE_SCROBBLES = os.getenv("VROBBLER_DELETE_STALE_SCROBBLES", True)
|
||||
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)
|
||||
MUSIC_BACKOFF_SECONDS = os.getenv("VROBBLER_VIDEO_BACKOFF_SECONDS", 1)
|
||||
|
||||
# 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
|
||||
@ -53,10 +58,16 @@ MUSIC_BACKOFF_SECONDS = os.getenv("VROBBLER_VIDEO_BACKOFF_SECONDS", 5)
|
||||
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 +97,8 @@ INSTALLED_APPS = [
|
||||
"scrobbles",
|
||||
"videos",
|
||||
"music",
|
||||
"podcasts",
|
||||
"sports",
|
||||
"rest_framework",
|
||||
"allauth",
|
||||
"allauth.account",
|
||||
@ -295,6 +308,7 @@ LOGGING = {
|
||||
"propagate": True,
|
||||
},
|
||||
"django.db.backends": {"handlers": ["null"]},
|
||||
"django.server": {"handlers": ["null"]},
|
||||
"vrobbler": {
|
||||
"handlers": ["console", "file"],
|
||||
"propagate": True,
|
||||
|
||||
@ -161,7 +161,7 @@
|
||||
border-color: transparent;
|
||||
box-shadow: 0 0 0 3px rgba(255, 255, 255, .25);
|
||||
}
|
||||
|
||||
#scrobble-form { width: 100% }
|
||||
</style>
|
||||
{% block head_extra %}{% endblock %}
|
||||
|
||||
@ -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>
|
||||
|
||||
13
vrobbler/templates/scrobbles/manual_form.html
Normal file
13
vrobbler/templates/scrobbles/manual_form.html
Normal file
@ -0,0 +1,13 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<main class="col-md-4 ms-sm-auto col-lg-10 px-md-4">
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
||||
<h1 class="h2">Manual scrobble</h1>
|
||||
<form action="#" method="post">
|
||||
{% csrf_token %}
|
||||
{{ form }}
|
||||
<input type="submit" value="Submit">
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
@ -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,109 +19,211 @@
|
||||
</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">
|
||||
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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-lg">
|
||||
<h2>Top tracks this week</h2>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">#</th>
|
||||
<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 track in top_weekly_tracks %}
|
||||
<tr>
|
||||
<td>{{track.num_scrobbles}}</td>
|
||||
<td>{{track.title}}</td>
|
||||
<td>{{track.artist.name}}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-lg">
|
||||
<h2>Latest listened</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>
|
||||
|
||||
<div class="col-lg">
|
||||
<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">Source</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for scrobble in video_scrobble_list %}
|
||||
<tr>
|
||||
<td>{{scrobble.timestamp|naturaltime}}</td>
|
||||
<td>{% if scrobble.video.tv_series %}E{{scrobble.video.season_number}}S{{scrobble.video.season_number}} -{% endif %} {{scrobble.video.title}}</td>
|
||||
<td>{% if scrobble.video.tv_series %}{{scrobble.video.tv_series}}{% endif %}</td>
|
||||
<td>{{scrobble.source}}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<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>
|
||||
|
||||
@ -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 %}
|
||||
|
||||
22
vrobbler/templates/videos/series_list.html
Normal file
22
vrobbler/templates/videos/series_list.html
Normal 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 %}
|
||||
@ -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:
|
||||
|
||||
Reference in New Issue
Block a user