Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d3f059caab | |||
| bb9936af65 | |||
| 9568726bf3 | |||
| 4ae70ef1f1 | |||
| 21df4e0a77 | |||
| cc82504262 | |||
| c7b84b27b2 | |||
| 20528b576b | |||
| 817ad3f67f | |||
| b47ca53c5d | |||
| 7a7c1caecc | |||
| 87f068dccd | |||
| 31907ed1b2 | |||
| 36d7950859 | |||
| 0e4501cad3 | |||
| 71e4ff28c8 | |||
| 9f272df99c | |||
| 8ba8ceefb8 | |||
| 9590cd0f60 |
182
poetry.lock
generated
182
poetry.lock
generated
@ -9,6 +9,23 @@ python-versions = ">=3.6"
|
||||
[package.dependencies]
|
||||
vine = ">=5.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "3.6.2"
|
||||
description = "High level compatibility layer for multiple asynchronous event loop implementations"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6.2"
|
||||
|
||||
[package.dependencies]
|
||||
idna = ">=2.8"
|
||||
sniffio = ">=1.1"
|
||||
|
||||
[package.extras]
|
||||
doc = ["packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"]
|
||||
test = ["contextlib2", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (<0.15)", "uvloop (>=0.15)"]
|
||||
trio = ["trio (>=0.16,<0.22)"]
|
||||
|
||||
[[package]]
|
||||
name = "asgiref"
|
||||
version = "3.6.0"
|
||||
@ -375,17 +392,6 @@ python3-openid = ">=3.0.8"
|
||||
requests = "*"
|
||||
requests-oauthlib = ">=0.3.0"
|
||||
|
||||
[[package]]
|
||||
name = "django-cachalot"
|
||||
version = "2.5.2"
|
||||
description = "Caches your Django ORM queries and automatically invalidates them."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[package.dependencies]
|
||||
Django = ">=2.2,<4.2"
|
||||
|
||||
[[package]]
|
||||
name = "django-celery-results"
|
||||
version = "2.4.0"
|
||||
@ -397,6 +403,18 @@ python-versions = "*"
|
||||
[package.dependencies]
|
||||
celery = ">=5.2.3,<6.0"
|
||||
|
||||
[[package]]
|
||||
name = "django-encrypted-field"
|
||||
version = "1.0.5"
|
||||
description = "This is a Django Model Field class that can be encrypted using ChaCha20 poly 1305, and other algorithms."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[package.dependencies]
|
||||
Django = ">=4.0"
|
||||
pycryptodomex = ">=3.12.0"
|
||||
|
||||
[[package]]
|
||||
name = "django-extensions"
|
||||
version = "3.2.1"
|
||||
@ -565,10 +583,48 @@ tornado = ["tornado (>=0.2)"]
|
||||
name = "h11"
|
||||
version = "0.14.0"
|
||||
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
|
||||
category = "dev"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "0.16.3"
|
||||
description = "A minimal low-level HTTP client."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[package.dependencies]
|
||||
anyio = ">=3.0,<5.0"
|
||||
certifi = "*"
|
||||
h11 = ">=0.13,<0.15"
|
||||
sniffio = ">=1.0.0,<2.0.0"
|
||||
|
||||
[package.extras]
|
||||
http2 = ["h2 (>=3,<5)"]
|
||||
socks = ["socksio (>=1.0.0,<2.0.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.23.3"
|
||||
description = "The next generation HTTP client."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[package.dependencies]
|
||||
certifi = "*"
|
||||
httpcore = ">=0.15.0,<0.17.0"
|
||||
rfc3986 = {version = ">=1.3,<2", extras = ["idna2008"]}
|
||||
sniffio = "*"
|
||||
|
||||
[package.extras]
|
||||
brotli = ["brotli", "brotlicffi"]
|
||||
cli = ["click (>=8.0.0,<9.0.0)", "pygments (>=2.0.0,<3.0.0)", "rich (>=10,<13)"]
|
||||
http2 = ["h2 (>=3,<5)"]
|
||||
socks = ["socksio (>=1.0.0,<2.0.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.4"
|
||||
@ -853,6 +909,14 @@ category = "main"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||
|
||||
[[package]]
|
||||
name = "pycryptodomex"
|
||||
version = "3.17"
|
||||
description = "Cryptographic library for Python"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
|
||||
[[package]]
|
||||
name = "pyflakes"
|
||||
version = "2.5.0"
|
||||
@ -878,6 +942,20 @@ dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pyte
|
||||
docs = ["sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"]
|
||||
tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "pylast"
|
||||
version = "5.1.0"
|
||||
description = "A Python interface to Last.fm and Libre.fm"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[package.dependencies]
|
||||
httpx = "*"
|
||||
|
||||
[package.extras]
|
||||
tests = ["flaky", "pytest", "pytest-cov", "pytest-random-order", "pyyaml"]
|
||||
|
||||
[[package]]
|
||||
name = "pysocks"
|
||||
version = "1.7.1"
|
||||
@ -1178,6 +1256,20 @@ requests = ">=2.0.0"
|
||||
[package.extras]
|
||||
rsa = ["oauthlib[signedtoken] (>=3.0.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "rfc3986"
|
||||
version = "1.5.0"
|
||||
description = "Validating URI References per RFC 3986"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[package.dependencies]
|
||||
idna = {version = "*", optional = true, markers = "extra == \"idna2008\""}
|
||||
|
||||
[package.extras]
|
||||
idna2008 = ["idna"]
|
||||
|
||||
[[package]]
|
||||
name = "selenium"
|
||||
version = "4.7.2"
|
||||
@ -1225,7 +1317,7 @@ python-versions = ">=3.6"
|
||||
name = "sniffio"
|
||||
version = "1.3.0"
|
||||
description = "Sniff out which async library your code is running under"
|
||||
category = "dev"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
|
||||
@ -1506,13 +1598,17 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools"
|
||||
[metadata]
|
||||
lock-version = "1.1"
|
||||
python-versions = "^3.8"
|
||||
content-hash = "0e23dbecb64cbef4dfe51bdf47e0f6b1357aab1d34342fef5341eaead2c26f1e"
|
||||
content-hash = "4b71b291b00a768d7d3c253a02faf70249d1f10ba85fcb88fc5c80fecb412332"
|
||||
|
||||
[metadata.files]
|
||||
amqp = [
|
||||
{file = "amqp-5.1.1-py3-none-any.whl", hash = "sha256:6f0956d2c23d8fa6e7691934d8c3930eadb44972cbbd1a7ae3a520f735d43359"},
|
||||
{file = "amqp-5.1.1.tar.gz", hash = "sha256:2c1b13fecc0893e946c65cbd5f36427861cffa4ea2201d8f6fca22e2a373b5e2"},
|
||||
]
|
||||
anyio = [
|
||||
{file = "anyio-3.6.2-py3-none-any.whl", hash = "sha256:fbbe32bd270d2a2ef3ed1c5d45041250284e31fc0a4df4a5a6071842051a51e3"},
|
||||
{file = "anyio-3.6.2.tar.gz", hash = "sha256:25ea0d673ae30af41a0c442f81cf3b38c7e79fdc7b60335a4c14e05eb0947421"},
|
||||
]
|
||||
asgiref = [
|
||||
{file = "asgiref-3.6.0-py3-none-any.whl", hash = "sha256:71e68008da809b957b7ee4b43dbccff33d1b23519fb8344e33f049897077afac"},
|
||||
{file = "asgiref-3.6.0.tar.gz", hash = "sha256:9567dfe7bd8d3c8c892227827c41cce860b368104c3431da67a0c5a65a949506"},
|
||||
@ -1858,14 +1954,13 @@ django = [
|
||||
django-allauth = [
|
||||
{file = "django-allauth-0.50.0.tar.gz", hash = "sha256:ee3a174e249771caeb1d037e64b2704dd3c56cfec44f2058fae2214b224d35e8"},
|
||||
]
|
||||
django-cachalot = [
|
||||
{file = "django-cachalot-2.5.2.tar.gz", hash = "sha256:bf0420cb8fa5cb4ceebd373a069cf91bb7afb995850cc35e91ccdadfa6cc41b1"},
|
||||
{file = "django_cachalot-2.5.2-py3-none-any.whl", hash = "sha256:7db091320ae352e9ad88c22336cff8d64fb928571287f1fae5299a187fc1a17e"},
|
||||
]
|
||||
django-celery-results = [
|
||||
{file = "django_celery_results-2.4.0-py3-none-any.whl", hash = "sha256:be91307c02fbbf0dda21993c3001c60edb74595444ccd6ad696552fe3689e85b"},
|
||||
{file = "django_celery_results-2.4.0.tar.gz", hash = "sha256:75aa51970db5691cbf242c6a0ff50c8cdf419e265cd0e9b772335d06436c4b99"},
|
||||
]
|
||||
django-encrypted-field = [
|
||||
{file = "django-encrypted-field-1.0.5.tar.gz", hash = "sha256:e5dbd6d7d1397feb46930b216d7f0806624ebf518bd3fc510b74efb78ee78b6e"},
|
||||
]
|
||||
django-extensions = [
|
||||
{file = "django-extensions-3.2.1.tar.gz", hash = "sha256:2a4f4d757be2563cd1ff7cfdf2e57468f5f931cc88b23cf82ca75717aae504a4"},
|
||||
{file = "django_extensions-3.2.1-py3-none-any.whl", hash = "sha256:421464be390289513f86cb5e18eb43e5dc1de8b4c27ba9faa3b91261b0d67e09"},
|
||||
@ -1984,6 +2079,14 @@ h11 = [
|
||||
{file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"},
|
||||
{file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"},
|
||||
]
|
||||
httpcore = [
|
||||
{file = "httpcore-0.16.3-py3-none-any.whl", hash = "sha256:da1fb708784a938aa084bde4feb8317056c55037247c787bd7e19eb2c2949dc0"},
|
||||
{file = "httpcore-0.16.3.tar.gz", hash = "sha256:c5d6f04e2fc530f39e0c077e6a30caa53f1451096120f1f38b954afd0b17c0cb"},
|
||||
]
|
||||
httpx = [
|
||||
{file = "httpx-0.23.3-py3-none-any.whl", hash = "sha256:a211fcce9b1254ea24f0cd6af9869b3d29aba40154e947d2a07bb499b3e310d6"},
|
||||
{file = "httpx-0.23.3.tar.gz", hash = "sha256:9818458eb565bb54898ccb9b8b251a28785dd4a55afbc23d0eb410754fe7d0f9"},
|
||||
]
|
||||
idna = [
|
||||
{file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"},
|
||||
{file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"},
|
||||
@ -2259,6 +2362,41 @@ pycparser = [
|
||||
{file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"},
|
||||
{file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"},
|
||||
]
|
||||
pycryptodomex = [
|
||||
{file = "pycryptodomex-3.17-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:12056c38e49d972f9c553a3d598425f8a1c1d35b2e4330f89d5ff1ffb70de041"},
|
||||
{file = "pycryptodomex-3.17-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:ab33c2d9f275e05e235dbca1063753b5346af4a5cac34a51fa0da0d4edfb21d7"},
|
||||
{file = "pycryptodomex-3.17-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:caa937ff29d07a665dfcfd7a84f0d4207b2ebf483362fa9054041d67fdfacc20"},
|
||||
{file = "pycryptodomex-3.17-cp27-cp27m-manylinux2014_aarch64.whl", hash = "sha256:db23d7341e21b273d2440ec6faf6c8b1ca95c8894da612e165be0b89a8688340"},
|
||||
{file = "pycryptodomex-3.17-cp27-cp27m-musllinux_1_1_aarch64.whl", hash = "sha256:f854c8476512cebe6a8681cc4789e4fcff6019c17baa0fd72b459155dc605ab4"},
|
||||
{file = "pycryptodomex-3.17-cp27-cp27m-win32.whl", hash = "sha256:a57e3257bacd719769110f1f70dd901c5b6955e9596ad403af11a3e6e7e3311c"},
|
||||
{file = "pycryptodomex-3.17-cp27-cp27m-win_amd64.whl", hash = "sha256:d38ab9e53b1c09608ba2d9b8b888f1e75d6f66e2787e437adb1fecbffec6b112"},
|
||||
{file = "pycryptodomex-3.17-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:3c2516b42437ae6c7a29ef3ddc73c8d4714e7b6df995b76be4695bbe4b3b5cd2"},
|
||||
{file = "pycryptodomex-3.17-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:5c23482860302d0d9883404eaaa54b0615eefa5274f70529703e2c43cc571827"},
|
||||
{file = "pycryptodomex-3.17-cp27-cp27mu-manylinux2014_aarch64.whl", hash = "sha256:7a8dc3ee7a99aae202a4db52de5a08aa4d01831eb403c4d21da04ec2f79810db"},
|
||||
{file = "pycryptodomex-3.17-cp27-cp27mu-musllinux_1_1_aarch64.whl", hash = "sha256:7cc28dd33f1f3662d6da28ead4f9891035f63f49d30267d3b41194c8778997c8"},
|
||||
{file = "pycryptodomex-3.17-cp35-abi3-macosx_10_9_universal2.whl", hash = "sha256:2d4d395f109faba34067a08de36304e846c791808524614c731431ee048fe70a"},
|
||||
{file = "pycryptodomex-3.17-cp35-abi3-macosx_10_9_x86_64.whl", hash = "sha256:55eed98b4150a744920597c81b3965b632038781bab8a08a12ea1d004213c600"},
|
||||
{file = "pycryptodomex-3.17-cp35-abi3-manylinux2014_aarch64.whl", hash = "sha256:7fa0b52df90343fafe319257b31d909be1d2e8852277fb0376ba89d26d2921db"},
|
||||
{file = "pycryptodomex-3.17-cp35-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78f0ddd4adc64baa39b416f3637aaf99f45acb0bcdc16706f0cc7ebfc6f10109"},
|
||||
{file = "pycryptodomex-3.17-cp35-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a4fa037078e92c7cc49f6789a8bac3de06856740bb2038d05f2d9a2e4b165d59"},
|
||||
{file = "pycryptodomex-3.17-cp35-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:88b0d5bb87eaf2a31e8a759302b89cf30c97f2f8ca7d83b8c9208abe8acb447a"},
|
||||
{file = "pycryptodomex-3.17-cp35-abi3-musllinux_1_1_i686.whl", hash = "sha256:6feedf4b0e36b395329b4186a805f60f900129cdf0170e120ecabbfcb763995d"},
|
||||
{file = "pycryptodomex-3.17-cp35-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:7a6651a07f67c28b6e978d63aa3a3fccea0feefed9a8453af3f7421a758461b7"},
|
||||
{file = "pycryptodomex-3.17-cp35-abi3-win32.whl", hash = "sha256:32e764322e902bbfac49ca1446604d2839381bbbdd5a57920c9daaf2e0b778df"},
|
||||
{file = "pycryptodomex-3.17-cp35-abi3-win_amd64.whl", hash = "sha256:4b51e826f0a04d832eda0790bbd0665d9bfe73e5a4d8ea93b6a9b38beeebe935"},
|
||||
{file = "pycryptodomex-3.17-pp27-pypy_73-macosx_10_9_x86_64.whl", hash = "sha256:d4cf0128da167562c49b0e034f09e9cedd733997354f2314837c2fa461c87bb1"},
|
||||
{file = "pycryptodomex-3.17-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:c92537b596bd5bffb82f8964cabb9fef1bca8a28a9e0a69ffd3ec92a4a7ad41b"},
|
||||
{file = "pycryptodomex-3.17-pp27-pypy_73-win32.whl", hash = "sha256:599bb4ae4bbd614ca05f49bd4e672b7a250b80b13ae1238f05fd0f09d87ed80a"},
|
||||
{file = "pycryptodomex-3.17-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4c4674f4b040321055c596aac926d12f7f6859dfe98cd12f4d9453b43ab6adc8"},
|
||||
{file = "pycryptodomex-3.17-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67a3648025e4ddb72d43addab764336ba2e670c8377dba5dd752e42285440d31"},
|
||||
{file = "pycryptodomex-3.17-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40e8a11f578bd0851b02719c862d55d3ee18d906c8b68a9c09f8c564d6bb5b92"},
|
||||
{file = "pycryptodomex-3.17-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:23d83b610bd97704f0cd3acc48d99b76a15c8c1540d8665c94d514a49905bad7"},
|
||||
{file = "pycryptodomex-3.17-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:fd29d35ac80755e5c0a99d96b44fb9abbd7e871849581ea6a4cb826d24267537"},
|
||||
{file = "pycryptodomex-3.17-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64b876d57cb894b31056ad8dd6a6ae1099b117ae07a3d39707221133490e5715"},
|
||||
{file = "pycryptodomex-3.17-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee8bf4fdcad7d66beb744957db8717afc12d176e3fd9c5d106835133881a049b"},
|
||||
{file = "pycryptodomex-3.17-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:c84689c73358dfc23f9fdcff2cb9e7856e65e2ce3b5ed8ff630d4c9bdeb1867b"},
|
||||
{file = "pycryptodomex-3.17.tar.gz", hash = "sha256:0af93aad8d62e810247beedef0261c148790c52f3cd33643791cc6396dd217c1"},
|
||||
]
|
||||
pyflakes = [
|
||||
{file = "pyflakes-2.5.0-py2.py3-none-any.whl", hash = "sha256:4579f67d887f804e67edb544428f264b7b24f435b263c4614f384135cea553d2"},
|
||||
{file = "pyflakes-2.5.0.tar.gz", hash = "sha256:491feb020dca48ccc562a8c0cbe8df07ee13078df59813b83959cbdada312ea3"},
|
||||
@ -2267,6 +2405,10 @@ pyjwt = [
|
||||
{file = "PyJWT-2.6.0-py3-none-any.whl", hash = "sha256:d83c3d892a77bbb74d3e1a2cfa90afaadb60945205d1095d9221f04466f64c14"},
|
||||
{file = "PyJWT-2.6.0.tar.gz", hash = "sha256:69285c7e31fc44f68a1feb309e948e0df53259d579295e6cfe2b1792329f05fd"},
|
||||
]
|
||||
pylast = [
|
||||
{file = "pylast-5.1.0-py3-none-any.whl", hash = "sha256:73cc7429a57e4965b3769254b1cb9625cdd910d3ac26cb0a1dd57145cdc498c0"},
|
||||
{file = "pylast-5.1.0.tar.gz", hash = "sha256:89300fdcdf423d7be0606bdc44da27c3b48c4d73aa1d4cb12672cc006c979bdc"},
|
||||
]
|
||||
pysocks = [
|
||||
{file = "PySocks-1.7.1-py27-none-any.whl", hash = "sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299"},
|
||||
{file = "PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5"},
|
||||
@ -2397,6 +2539,10 @@ requests-oauthlib = [
|
||||
{file = "requests-oauthlib-1.3.1.tar.gz", hash = "sha256:75beac4a47881eeb94d5ea5d6ad31ef88856affe2332b9aafb52c6452ccf0d7a"},
|
||||
{file = "requests_oauthlib-1.3.1-py2.py3-none-any.whl", hash = "sha256:2577c501a2fb8d05a304c09d090d6e47c306fef15809d102b327cf8364bddab5"},
|
||||
]
|
||||
rfc3986 = [
|
||||
{file = "rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"},
|
||||
{file = "rfc3986-1.5.0.tar.gz", hash = "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835"},
|
||||
]
|
||||
selenium = [
|
||||
{file = "selenium-4.7.2-py3-none-any.whl", hash = "sha256:06a1c7d9f313130b21c3218ddd8852070d0e7419afdd31f96160cd576555a5ce"},
|
||||
{file = "selenium-4.7.2.tar.gz", hash = "sha256:3aefa14a28a42e520550c1cd0f29cf1d566328186ea63aa9a3e01fb265b5894d"},
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "vrobbler"
|
||||
version = "0.8.4"
|
||||
version = "0.9.0"
|
||||
description = ""
|
||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||
|
||||
@ -30,9 +30,11 @@ whitenoise = "^6.3.0"
|
||||
musicbrainzngs = "^0.7.1"
|
||||
cinemagoer = "^2022.12.27"
|
||||
pysportsdb = "^0.1.0"
|
||||
django-cachalot = "^2.5.2"
|
||||
pytz = "^2022.7.1"
|
||||
django-redis = "^5.2.0"
|
||||
pylast = "^5.1.0"
|
||||
django-encrypted-field = "^1.0.5"
|
||||
celery = "^5.2.7"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
Werkzeug = "2.0.3"
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
# This will make sure the app is always imported when
|
||||
# Django starts so that shared_task will use this app.
|
||||
from .celery import app as celery_app
|
||||
|
||||
__all__ = ('celery_app',)
|
||||
|
||||
@ -72,11 +72,19 @@ class Album(TimeStampedModel):
|
||||
return self.artists.first()
|
||||
|
||||
def fix_metadata(self):
|
||||
if not self.musicbrainz_albumartist_id or not self.year:
|
||||
if (
|
||||
not self.musicbrainz_albumartist_id
|
||||
or not self.year
|
||||
or not self.musicbrainz_releasegroup_id
|
||||
):
|
||||
musicbrainzngs.set_useragent('vrobbler', '0.3.0')
|
||||
mb_data = musicbrainzngs.get_release_by_id(
|
||||
self.musicbrainz_id, includes=['artists']
|
||||
self.musicbrainz_id, includes=['artists', 'release-groups']
|
||||
)
|
||||
if not self.musicbrainz_releasegroup_id:
|
||||
self.musicbrainz_releasegroup_id = mb_data['release'][
|
||||
'release-group'
|
||||
]['id']
|
||||
if not self.musicbrainz_albumartist_id:
|
||||
self.musicbrainz_albumartist_id = mb_data['release'][
|
||||
'artist-credit'
|
||||
@ -89,7 +97,13 @@ class Album(TimeStampedModel):
|
||||
except IndexError:
|
||||
pass
|
||||
|
||||
self.save(update_fields=['musicbrainz_albumartist_id', 'year'])
|
||||
self.save(
|
||||
update_fields=[
|
||||
'musicbrainz_albumartist_id',
|
||||
'musicbrainz_releasegroup_id',
|
||||
'year',
|
||||
]
|
||||
)
|
||||
|
||||
new_artist = Artist.objects.filter(
|
||||
musicbrainz_id=self.musicbrainz_albumartist_id
|
||||
@ -99,6 +113,11 @@ class Album(TimeStampedModel):
|
||||
if not new_artist:
|
||||
for t in self.track_set.all():
|
||||
self.artists.add(t.artist)
|
||||
if (
|
||||
not self.cover_image
|
||||
or self.cover_image == 'default-image-replace-me'
|
||||
):
|
||||
self.fetch_artwork()
|
||||
|
||||
def fetch_artwork(self, force=False):
|
||||
if not self.cover_image and not force:
|
||||
@ -115,7 +134,10 @@ class Album(TimeStampedModel):
|
||||
f'No cover art found for {self.name} by release'
|
||||
)
|
||||
|
||||
if not self.cover_image and self.musicbrainz_releasegroup_id:
|
||||
if (
|
||||
not self.cover_image
|
||||
or self.cover_image == "default-image-replace-me"
|
||||
) and self.musicbrainz_releasegroup_id:
|
||||
try:
|
||||
img_data = musicbrainzngs.get_release_group_image_front(
|
||||
self.musicbrainz_releasegroup_id
|
||||
@ -131,8 +153,6 @@ class Album(TimeStampedModel):
|
||||
logger.debug(
|
||||
f"No cover art found for release or release group for {self.name}, setting to default"
|
||||
)
|
||||
# TODO Get a placeholder image in here
|
||||
self.cover_image = 'default-image-replace-me'
|
||||
self.save()
|
||||
|
||||
@property
|
||||
|
||||
106
vrobbler/apps/music/utils.py
Normal file
106
vrobbler/apps/music/utils.py
Normal file
@ -0,0 +1,106 @@
|
||||
import re
|
||||
import logging
|
||||
|
||||
from scrobbles.musicbrainz import (
|
||||
lookup_album_dict_from_mb,
|
||||
lookup_artist_from_mb,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
from music.models import Artist, Album, Track
|
||||
|
||||
|
||||
def get_or_create_artist(name: str, mbid: str = None) -> Artist:
|
||||
artist = None
|
||||
logger.debug(f'Got artist {name} and mbid: {mbid}')
|
||||
|
||||
if 'feat.' in name.lower():
|
||||
name = re.split("feat.", name, flags=re.IGNORECASE)[0].strip()
|
||||
if 'featuring' in name.lower():
|
||||
name = re.split("featuring", name, flags=re.IGNORECASE)[0].strip()
|
||||
artist_dict = lookup_artist_from_mb(name)
|
||||
mbid = mbid or artist_dict['id']
|
||||
|
||||
logger.debug(f'Looking up artist {name} and mbid: {mbid}')
|
||||
artist, created = Artist.objects.get_or_create(
|
||||
name=name, musicbrainz_id=mbid
|
||||
)
|
||||
|
||||
logger.debug(f"Cleaning artist {name} with {artist_dict['name']}")
|
||||
# Clean up bad names in our DB with MB names
|
||||
if artist.name != artist_dict['name']:
|
||||
artist.name = artist_dict["name"]
|
||||
artist.save(update_fields=["name"])
|
||||
|
||||
return artist
|
||||
|
||||
|
||||
def get_or_create_album(name: str, artist: Artist, mbid: str = None) -> Album:
|
||||
album = None
|
||||
album_created = False
|
||||
albums = Album.objects.filter(name__iexact=name)
|
||||
if albums.count() == 1:
|
||||
album = albums.first()
|
||||
else:
|
||||
for potential_album in albums:
|
||||
if artist in album.artist_set.all():
|
||||
album = potential_album
|
||||
if not album:
|
||||
album_created = True
|
||||
album = Album.objects.create(name=name, musicbrainz_id=mbid)
|
||||
album.save()
|
||||
album.artists.add(artist)
|
||||
|
||||
if album_created or not mbid:
|
||||
album_dict = lookup_album_dict_from_mb(
|
||||
album.name, artist_name=artist.name
|
||||
)
|
||||
album.year = album_dict["year"]
|
||||
album.musicbrainz_id = album_dict["mb_id"]
|
||||
album.musicbrainz_releasegroup_id = album_dict["mb_group_id"]
|
||||
album.musicbrainz_albumartist_id = artist.musicbrainz_id
|
||||
album.save(
|
||||
update_fields=[
|
||||
"year",
|
||||
"musicbrainz_id",
|
||||
"musicbrainz_releasegroup_id",
|
||||
"musicbrainz_albumartist_id",
|
||||
]
|
||||
)
|
||||
album.artists.add(artist)
|
||||
album.fetch_artwork()
|
||||
return album
|
||||
|
||||
|
||||
def get_or_create_track(
|
||||
title: str,
|
||||
mbid: str,
|
||||
artist: Artist,
|
||||
album: Album,
|
||||
run_time=None,
|
||||
run_time_ticks=None,
|
||||
) -> Track:
|
||||
track = None
|
||||
if mbid:
|
||||
track = Track.objects.filter(
|
||||
musicbrainz_id=mbid,
|
||||
).first()
|
||||
if not track:
|
||||
track = Track.objects.filter(
|
||||
title=title, artist=artist, album=album
|
||||
).first()
|
||||
|
||||
# TODO Can we look up mbid for tracks?
|
||||
if not track:
|
||||
track = Track.objects.create(
|
||||
title=title,
|
||||
artist=artist,
|
||||
album=album,
|
||||
musicbrainz_id=mbid,
|
||||
run_time=run_time,
|
||||
run_time_ticks=run_time_ticks,
|
||||
)
|
||||
|
||||
return track
|
||||
@ -7,7 +7,7 @@ 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
|
||||
from scrobbles.mixins import ScrobblableMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
BNULL = {"blank": True, "null": True}
|
||||
|
||||
@ -0,0 +1,24 @@
|
||||
# Generated by Django 4.1.5 on 2023-02-12 22:26
|
||||
|
||||
from django.db import migrations, models
|
||||
import encrypted_field.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('profiles', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userprofile',
|
||||
name='lastfm_password',
|
||||
field=encrypted_field.fields.EncryptedField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='userprofile',
|
||||
name='lastfm_username',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
]
|
||||
@ -5,7 +5,10 @@ from django.db import models
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
from profiles.constants import PRETTY_TIMEZONE_CHOICES
|
||||
|
||||
from encrypted_field import EncryptedField
|
||||
|
||||
User = get_user_model()
|
||||
BNULL = {"blank": True, "null": True}
|
||||
|
||||
|
||||
class UserProfile(TimeStampedModel):
|
||||
@ -15,6 +18,8 @@ class UserProfile(TimeStampedModel):
|
||||
timezone = models.CharField(
|
||||
max_length=255, choices=PRETTY_TIMEZONE_CHOICES
|
||||
)
|
||||
lastfm_username = models.CharField(max_length=255, **BNULL)
|
||||
lastfm_password = EncryptedField(**BNULL)
|
||||
|
||||
def __str__(self):
|
||||
return f"User profile for {self.user}"
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from scrobbles.models import AudioScrobblerTSVImport, Scrobble
|
||||
from scrobbles.models import (
|
||||
AudioScrobblerTSVImport,
|
||||
ChartRecord,
|
||||
LastFmImport,
|
||||
Scrobble,
|
||||
)
|
||||
|
||||
|
||||
class ScrobbleInline(admin.TabularInline):
|
||||
@ -17,6 +21,43 @@ class AudioScrobblerTSVImportAdmin(admin.ModelAdmin):
|
||||
ordering = ("-created",)
|
||||
|
||||
|
||||
@admin.register(LastFmImport)
|
||||
class LastFmImportAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = (
|
||||
"uuid",
|
||||
"process_count",
|
||||
"processed_finished",
|
||||
"processing_started",
|
||||
)
|
||||
ordering = ("-created",)
|
||||
|
||||
|
||||
@admin.register(ChartRecord)
|
||||
class ChartRecordAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = (
|
||||
"user",
|
||||
"rank",
|
||||
"year",
|
||||
"week",
|
||||
"month",
|
||||
"day",
|
||||
"media_name",
|
||||
)
|
||||
ordering = ("-created",)
|
||||
|
||||
def media_name(self, obj):
|
||||
if obj.video:
|
||||
return obj.video
|
||||
if obj.track:
|
||||
return obj.track
|
||||
if obj.podcast_episode:
|
||||
return obj.podcast_episode
|
||||
if obj.sport_event:
|
||||
return obj.sport_event
|
||||
|
||||
|
||||
@admin.register(Scrobble)
|
||||
class ScrobbleAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "timestamp"
|
||||
|
||||
167
vrobbler/apps/scrobbles/lastfm.py
Normal file
167
vrobbler/apps/scrobbles/lastfm.py
Normal file
@ -0,0 +1,167 @@
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pylast
|
||||
import pytz
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from music.utils import (
|
||||
get_or_create_album,
|
||||
get_or_create_artist,
|
||||
get_or_create_track,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PYLAST_ERRORS = tuple(
|
||||
getattr(pylast, exc_name)
|
||||
for exc_name in (
|
||||
"ScrobblingError",
|
||||
"NetworkError",
|
||||
"MalformedResponseError",
|
||||
"WSError",
|
||||
)
|
||||
if hasattr(pylast, exc_name)
|
||||
)
|
||||
|
||||
|
||||
class LastFM:
|
||||
def __init__(self, user):
|
||||
try:
|
||||
self.client = pylast.LastFMNetwork(
|
||||
api_key=getattr(settings, "LASTFM_API_KEY"),
|
||||
api_secret=getattr(settings, "LASTFM_SECRET_KEY"),
|
||||
username=user.profile.lastfm_username,
|
||||
password_hash=pylast.md5(user.profile.lastfm_password),
|
||||
)
|
||||
self.user = self.client.get_user(user.profile.lastfm_username)
|
||||
self.vrobbler_user = user
|
||||
except PYLAST_ERRORS as e:
|
||||
logger.error(f"Error during Last.fm setup: {e}")
|
||||
|
||||
def import_from_lastfm(self, last_processed=None):
|
||||
"""Given a last processed time, import all scrobbles from LastFM since then"""
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
new_scrobbles = []
|
||||
source = "Last.fm"
|
||||
source_id = ""
|
||||
latest_scrobbles = self.get_last_scrobbles(time_from=last_processed)
|
||||
|
||||
for scrobble in latest_scrobbles:
|
||||
timestamp = scrobble.pop('timestamp')
|
||||
|
||||
artist = get_or_create_artist(scrobble.pop('artist'))
|
||||
album = get_or_create_album(scrobble.pop('album'), artist)
|
||||
|
||||
scrobble['artist'] = artist
|
||||
scrobble['album'] = album
|
||||
track = get_or_create_track(**scrobble)
|
||||
|
||||
new_scrobble = Scrobble(
|
||||
user=self.vrobbler_user,
|
||||
timestamp=timestamp,
|
||||
source=source,
|
||||
source_id=source_id,
|
||||
track=track,
|
||||
played_to_completion=True,
|
||||
in_progress=False,
|
||||
)
|
||||
# Vrobbler scrobbles on finish, LastFM scrobbles on start
|
||||
seconds_eariler = timestamp - timedelta(seconds=20)
|
||||
seconds_later = timestamp + timedelta(seconds=20)
|
||||
existing = Scrobble.objects.filter(
|
||||
created__gte=seconds_eariler,
|
||||
created__lte=seconds_later,
|
||||
track=track,
|
||||
).first()
|
||||
if existing:
|
||||
logger.debug(f"Skipping existing scrobble {new_scrobble}")
|
||||
continue
|
||||
logger.debug(f"Queued scrobble {new_scrobble} for creation")
|
||||
new_scrobbles.append(new_scrobble)
|
||||
|
||||
created = Scrobble.objects.bulk_create(new_scrobbles)
|
||||
logger.info(
|
||||
f"Created {len(created)} scrobbles",
|
||||
extra={'created_scrobbles': created},
|
||||
)
|
||||
return created
|
||||
|
||||
@staticmethod
|
||||
def undo_lastfm_import(process_log, dryrun=True):
|
||||
"""Given a newline separated list of scrobbles, delete them"""
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
if not process_log:
|
||||
logger.warning("No lines in process log found to undo")
|
||||
return
|
||||
|
||||
for line in process_log.split('\n'):
|
||||
scrobble_id = line.split("\t")[0]
|
||||
scrobble = Scrobble.objects.filter(id=scrobble_id).first()
|
||||
if not scrobble:
|
||||
logger.warning(
|
||||
f"Could not find scrobble {scrobble_id} to undo"
|
||||
)
|
||||
continue
|
||||
logger.info(f"Removing scrobble {scrobble_id}")
|
||||
if not dryrun:
|
||||
scrobble.delete()
|
||||
|
||||
def get_last_scrobbles(self, time_from=None, time_to=None):
|
||||
"""Given a user, Last.fm api key, and secret key, grab a list of scrobbled
|
||||
tracks"""
|
||||
lfm_params = {}
|
||||
scrobbles = []
|
||||
if time_from:
|
||||
lfm_params["time_from"] = int(time_from.timestamp())
|
||||
if time_to:
|
||||
lfm_params["time_to"] = int(time_to.timestamp())
|
||||
|
||||
# if not time_from and not time_to:
|
||||
lfm_params['limit'] = None
|
||||
|
||||
found_scrobbles = self.user.get_recent_tracks(**lfm_params)
|
||||
# TOOD spin this out into a celery task over certain threshold of found scrobbles?
|
||||
|
||||
for scrobble in found_scrobbles:
|
||||
run_time = None
|
||||
run_time_ticks = None
|
||||
mbid = None
|
||||
artist = None
|
||||
|
||||
try:
|
||||
run_time_ticks = scrobble.track.get_duration()
|
||||
run_time = int(run_time_ticks / 1000)
|
||||
mbid = scrobble.track.get_mbid()
|
||||
artist = scrobble.track.get_artist().name
|
||||
except pylast.MalformedResponseError as e:
|
||||
logger.warn(e)
|
||||
except pylast.WSError as e:
|
||||
logger.warn(
|
||||
"LastFM barfed trying to get the track for {scrobble.track}"
|
||||
)
|
||||
|
||||
if not mbid or not artist:
|
||||
logger.warn(f"Silly LastFM, bad data, bailing on {scrobble}")
|
||||
continue
|
||||
|
||||
timestamp = datetime.utcfromtimestamp(
|
||||
int(scrobble.timestamp)
|
||||
).replace(tzinfo=pytz.utc)
|
||||
|
||||
logger.info(f"{artist},{scrobble.track.title},{timestamp}")
|
||||
scrobbles.append(
|
||||
{
|
||||
"artist": artist,
|
||||
"album": scrobble.album,
|
||||
"title": scrobble.track.title,
|
||||
"mbid": mbid,
|
||||
"run_time": run_time,
|
||||
"run_time_ticks": run_time_ticks,
|
||||
"timestamp": timestamp,
|
||||
}
|
||||
)
|
||||
return scrobbles
|
||||
61
vrobbler/apps/scrobbles/migrations/0018_lastfmimport.py
Normal file
61
vrobbler/apps/scrobbles/migrations/0018_lastfmimport.py
Normal file
@ -0,0 +1,61 @@
|
||||
# Generated by Django 4.1.5 on 2023-02-13 06:43
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django_extensions.db.fields
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('scrobbles', '0017_audioscrobblertsvimport_user'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='LastFmImport',
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name='ID',
|
||||
),
|
||||
),
|
||||
(
|
||||
'created',
|
||||
django_extensions.db.fields.CreationDateTimeField(
|
||||
auto_now_add=True, verbose_name='created'
|
||||
),
|
||||
),
|
||||
(
|
||||
'modified',
|
||||
django_extensions.db.fields.ModificationDateTimeField(
|
||||
auto_now=True, verbose_name='modified'
|
||||
),
|
||||
),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, editable=False)),
|
||||
('processed_on', models.DateTimeField(blank=True, null=True)),
|
||||
('process_log', models.TextField(blank=True, null=True)),
|
||||
('process_count', models.IntegerField(blank=True, null=True)),
|
||||
(
|
||||
'user',
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'get_latest_by': 'modified',
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.1.5 on 2023-02-16 17:13
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('scrobbles', '0018_lastfmimport'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='lastfmimport',
|
||||
old_name='processed_on',
|
||||
new_name='processed_finished',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='lastfmimport',
|
||||
name='processing_started',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -8,10 +8,11 @@ from django.utils import timezone
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
from music.models import Artist, Track
|
||||
from podcasts.models import Episode
|
||||
from profiles.utils import now_user_timezone
|
||||
from scrobbles.lastfm import LastFM
|
||||
from scrobbles.utils import check_scrobble_for_finish
|
||||
from sports.models import SportEvent
|
||||
from videos.models import Series, Video
|
||||
from vrobbler.apps.profiles.utils import now_user_timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
User = get_user_model()
|
||||
@ -56,10 +57,12 @@ class AudioScrobblerTSVImport(TimeStampedModel):
|
||||
self.tsv_file.path, user_tz=tz
|
||||
)
|
||||
if scrobbles:
|
||||
self.process_log = f"Created {len(scrobbles)} scrobbles"
|
||||
for scrobble in scrobbles:
|
||||
for count, scrobble in enumerate(scrobbles):
|
||||
scrobble_str = f"{scrobble.id}\t{scrobble.timestamp}\t{scrobble.track.title}"
|
||||
self.process_log += f"\n{scrobble_str}"
|
||||
log_line = f"{scrobble_str}"
|
||||
if count > 0:
|
||||
log_line = "\n" + log_line
|
||||
self.process_log += log_line
|
||||
self.process_count = len(scrobbles)
|
||||
else:
|
||||
self.process_log = f"Created no new scrobbles"
|
||||
@ -76,6 +79,75 @@ class AudioScrobblerTSVImport(TimeStampedModel):
|
||||
undo_audioscrobbler_tsv_import(self.process_log, dryrun)
|
||||
|
||||
|
||||
class LastFmImport(TimeStampedModel):
|
||||
user = models.ForeignKey(User, on_delete=models.DO_NOTHING, **BNULL)
|
||||
uuid = models.UUIDField(editable=False, default=uuid4)
|
||||
processing_started = models.DateTimeField(**BNULL)
|
||||
processed_finished = models.DateTimeField(**BNULL)
|
||||
process_log = models.TextField(**BNULL)
|
||||
process_count = models.IntegerField(**BNULL)
|
||||
|
||||
def __str__(self):
|
||||
return f"LastFM Import: {self.uuid}"
|
||||
|
||||
def process(self, import_all=False):
|
||||
"""Import scrobbles found on LastFM"""
|
||||
if self.processed_finished:
|
||||
logger.info(
|
||||
f"{self} already processed on {self.processed_finished}"
|
||||
)
|
||||
return
|
||||
|
||||
last_import = None
|
||||
if not import_all:
|
||||
try:
|
||||
last_import = LastFmImport.objects.exclude(id=self.id).last()
|
||||
except:
|
||||
pass
|
||||
|
||||
if not import_all and not last_import:
|
||||
logger.warn(
|
||||
"No previous import, to import all Last.fm scrobbles, pass import_all=True"
|
||||
)
|
||||
return
|
||||
|
||||
lastfm = LastFM(self.user)
|
||||
last_processed = None
|
||||
if last_import:
|
||||
last_processed = last_import.processed_finished
|
||||
|
||||
self.processing_started = timezone.now()
|
||||
self.save(update_fields=['processing_started'])
|
||||
|
||||
scrobbles = lastfm.import_from_lastfm(last_processed)
|
||||
self.process_log = ""
|
||||
if scrobbles:
|
||||
for count, scrobble in enumerate(scrobbles):
|
||||
scrobble_str = f"{scrobble.id}\t{scrobble.timestamp}\t{scrobble.track.title}"
|
||||
log_line = f"{scrobble_str}"
|
||||
if count > 0:
|
||||
log_line = "\n" + log_line
|
||||
self.process_log += log_line
|
||||
self.process_count = len(scrobbles)
|
||||
else:
|
||||
self.process_count = 0
|
||||
|
||||
self.processed_finished = timezone.now()
|
||||
self.save(
|
||||
update_fields=[
|
||||
'processed_finished',
|
||||
'process_count',
|
||||
'process_log',
|
||||
]
|
||||
)
|
||||
|
||||
def undo(self, dryrun=False):
|
||||
"""Undo import of scrobbles from LastFM"""
|
||||
LastFM.undo_lastfm_import(self.process_log, dryrun)
|
||||
self.processed_finished = None
|
||||
self.save(update_fields=['processed_finished'])
|
||||
|
||||
|
||||
class ChartRecord(TimeStampedModel):
|
||||
"""Sort of like a materialized view for what we could dynamically generate,
|
||||
but would kill the DB as it gets larger. Collects time-based records
|
||||
|
||||
107
vrobbler/apps/scrobbles/musicbrainz.py
Normal file
107
vrobbler/apps/scrobbles/musicbrainz.py
Normal file
@ -0,0 +1,107 @@
|
||||
import logging
|
||||
|
||||
import musicbrainzngs
|
||||
from dateutil.parser import parse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def lookup_album_from_mb(musicbrainz_id: str) -> dict:
|
||||
release_dict = {}
|
||||
|
||||
musicbrainzngs.set_useragent('vrobbler', '0.3.0')
|
||||
release_data = musicbrainzngs.get_release_by_id(
|
||||
musicbrainz_id,
|
||||
includes=['artists', 'release-groups', 'recordings'],
|
||||
).get('release')
|
||||
|
||||
if not release_data:
|
||||
return release_dict
|
||||
|
||||
primary_artist = release_data.get('artist-credit')[0]
|
||||
release_dict = {
|
||||
'artist': {
|
||||
'name': primary_artist.get('name'),
|
||||
'musicbrainz_id': primary_artist.get('id'),
|
||||
},
|
||||
'album': {
|
||||
'name': release_data.get('title'),
|
||||
'musicbrainz_id': musicbrainz_id,
|
||||
'musicbrainz_releasegroup_id': release_data.get(
|
||||
'release-group'
|
||||
).get('id'),
|
||||
'musicbrainz_albumaritist_id': primary_artist.get('id'),
|
||||
'year': release_data.get('year')[0:4],
|
||||
},
|
||||
}
|
||||
|
||||
release_dict['tracks'] = []
|
||||
for track in release_data.get('medium-list')[0]['track-list']:
|
||||
recording = track['recording']
|
||||
release_dict['tracks'].append(
|
||||
{
|
||||
'title': recording['title'],
|
||||
'musicbrainz_id': recording['id'],
|
||||
'run_time_ticks': track['length'],
|
||||
}
|
||||
)
|
||||
|
||||
return release_dict
|
||||
|
||||
|
||||
def lookup_album_dict_from_mb(release_name: str, artist_name: str) -> dict:
|
||||
musicbrainzngs.set_useragent('vrobbler', '0.3.0')
|
||||
|
||||
top_result = musicbrainzngs.search_releases(
|
||||
release_name, artist=artist_name
|
||||
)['release-list'][0]
|
||||
score = int(top_result.get('ext:score'))
|
||||
if score < 85:
|
||||
logger.debug(
|
||||
"Album lookup score below 85 threshold",
|
||||
extra={"result": top_result},
|
||||
)
|
||||
return {}
|
||||
year = None
|
||||
if top_result.get("date"):
|
||||
year = parse(top_result["date"]).year
|
||||
|
||||
return {
|
||||
"year": year,
|
||||
"mb_id": top_result["id"],
|
||||
"mb_group_id": top_result["release-group"]["id"],
|
||||
}
|
||||
|
||||
|
||||
def lookup_artist_from_mb(artist_name: str) -> str:
|
||||
musicbrainzngs.set_useragent('vrobbler', '0.3.0')
|
||||
|
||||
top_result = musicbrainzngs.search_artists(artist=artist_name)[
|
||||
'artist-list'
|
||||
][0]
|
||||
score = int(top_result.get('ext:score'))
|
||||
if score < 85:
|
||||
logger.debug(
|
||||
"Artist lookup score below 85 threshold",
|
||||
extra={"result": top_result},
|
||||
)
|
||||
return ""
|
||||
|
||||
return top_result
|
||||
|
||||
|
||||
def lookup_track_from_mb(artist_name: str) -> str:
|
||||
musicbrainzngs.set_useragent('vrobbler', '0.3.0')
|
||||
|
||||
top_result = musicbrainzngs.search_recordings(artist=artist_name)[
|
||||
'artist-list'
|
||||
][0]
|
||||
score = int(top_result.get('ext:score'))
|
||||
if score < 85:
|
||||
logger.debug(
|
||||
"Artist lookup score below 85 threshold",
|
||||
extra={"result": top_result},
|
||||
)
|
||||
return ""
|
||||
|
||||
return top_result
|
||||
@ -10,6 +10,11 @@ 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
|
||||
from vrobbler.apps.music.utils import (
|
||||
get_or_create_album,
|
||||
get_or_create_artist,
|
||||
get_or_create_track,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -57,23 +62,23 @@ def mopidy_scrobble_podcast(
|
||||
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)
|
||||
artist = get_or_create_artist(
|
||||
data_dict.get("artist"),
|
||||
mbid=data_dict.get("musicbrainz_artist_id", None),
|
||||
)
|
||||
album = get_or_create_album(
|
||||
data_dict.get("album"),
|
||||
artist=artist,
|
||||
mbid=data_dict.get("musicbrainz_album_id"),
|
||||
)
|
||||
track = get_or_create_track(
|
||||
title=data_dict.get("name"),
|
||||
mbid=data_dict.get("musicbrainz_track_id"),
|
||||
artist=artist,
|
||||
album=album,
|
||||
run_time_ticks=data_dict.get("run_time_ticks"),
|
||||
run_time=data_dict.get("run_time"),
|
||||
)
|
||||
|
||||
# Now we run off a scrobble
|
||||
mopidy_data = {
|
||||
@ -119,13 +124,6 @@ def jellyfin_scrobble_track(
|
||||
data_dict: dict, user_id: Optional[int]
|
||||
) -> Optional[Scrobble]:
|
||||
|
||||
if not data_dict.get("Provider_musicbrainztrack", None):
|
||||
# TODO we should be able to look up tracks via MB rather than error out
|
||||
logger.error(
|
||||
"No MBrainz Track ID received. This is likely because all metadata is bad, not scrobbling"
|
||||
)
|
||||
return
|
||||
|
||||
null_position_on_progress = (
|
||||
data_dict.get("PlaybackPosition") == "00:00:00"
|
||||
and data_dict.get("NotificationType") == "PlaybackProgress"
|
||||
@ -136,38 +134,30 @@ def jellyfin_scrobble_track(
|
||||
logger.error("No playback position tick from Jellyfin, aborting")
|
||||
return
|
||||
|
||||
artist_dict = {
|
||||
'name': data_dict.get(JELLYFIN_POST_KEYS["ARTIST_NAME"], None),
|
||||
'musicbrainz_id': data_dict.get(
|
||||
JELLYFIN_POST_KEYS["ARTIST_MB_ID"], None
|
||||
),
|
||||
}
|
||||
artist = get_or_create_artist(
|
||||
data_dict.get(JELLYFIN_POST_KEYS["ARTIST_NAME"]),
|
||||
mbid=data_dict.get(JELLYFIN_POST_KEYS["ARTIST_MB_ID"]),
|
||||
)
|
||||
album = get_or_create_album(
|
||||
data_dict.get(JELLYFIN_POST_KEYS["ALBUM_NAME"]),
|
||||
artist=artist,
|
||||
mbid=data_dict.get(JELLYFIN_POST_KEYS['ALBUM_MB_ID']),
|
||||
)
|
||||
|
||||
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()
|
||||
run_time_ticks = (
|
||||
data_dict.get(JELLYFIN_POST_KEYS["RUN_TIME_TICKS"]) // 10000
|
||||
)
|
||||
run_time = convert_to_seconds(
|
||||
data_dict.get(JELLYFIN_POST_KEYS["RUN_TIME"])
|
||||
)
|
||||
track = get_or_create_track(
|
||||
title=data_dict.get("Name"),
|
||||
mbid=data_dict.get(JELLYFIN_POST_KEYS["TRACK_MB_ID"]),
|
||||
artist=artist,
|
||||
album=album,
|
||||
run_time_ticks=run_time_ticks,
|
||||
run_time=run_time,
|
||||
)
|
||||
|
||||
scrobble_dict = build_scrobble_dict(data_dict, user_id)
|
||||
|
||||
|
||||
15
vrobbler/apps/scrobbles/tasks.py
Normal file
15
vrobbler/apps/scrobbles/tasks.py
Normal file
@ -0,0 +1,15 @@
|
||||
import logging
|
||||
from celery import shared_task
|
||||
|
||||
from scrobbles.models import LastFmImport
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task
|
||||
def process_lastfm_import(import_id):
|
||||
lastfm_import = LastFmImport.objects.filter(id=import_id).first()
|
||||
if not lastfm_import:
|
||||
logger.warn(f"LastFmImport not found with id {import_id}")
|
||||
|
||||
lastfm_import.process()
|
||||
@ -3,12 +3,11 @@ import logging
|
||||
from datetime import datetime
|
||||
|
||||
import pytz
|
||||
from music.models import Album, Artist, Track
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
from vrobbler.apps.scrobbles.musicbrainz import (
|
||||
lookup_album_dict_from_mb,
|
||||
lookup_artist_id_from_mb,
|
||||
from music.utils import (
|
||||
get_or_create_album,
|
||||
get_or_create_artist,
|
||||
get_or_create_track,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -27,6 +26,8 @@ def process_audioscrobbler_tsv_file(file_path, user_tz=None):
|
||||
source_id = ""
|
||||
for row_num, row in enumerate(rows):
|
||||
if row_num in [0, 1, 2]:
|
||||
if "Rockbox" in row[0]:
|
||||
source = "Rockbox"
|
||||
source_id += row[0] + "\n"
|
||||
continue
|
||||
if len(row) > 8:
|
||||
@ -35,56 +36,18 @@ def process_audioscrobbler_tsv_file(file_path, user_tz=None):
|
||||
extra={'row': row},
|
||||
)
|
||||
continue
|
||||
artist, artist_created = Artist.objects.get_or_create(name=row[0])
|
||||
if artist_created:
|
||||
artist.musicbrainz_id = lookup_artist_id_from_mb(artist.name)
|
||||
artist.save(update_fields=["musicbrainz_id"])
|
||||
artist = get_or_create_artist(row[0])
|
||||
album = get_or_create_album(row[1])
|
||||
|
||||
album = None
|
||||
album_created = False
|
||||
albums = Album.objects.filter(name=row[1])
|
||||
if albums.count() == 1:
|
||||
album = albums.first()
|
||||
else:
|
||||
for potential_album in albums:
|
||||
if artist in album.artist_set.all():
|
||||
album = potential_album
|
||||
if not album:
|
||||
album_created = True
|
||||
album = Album.objects.create(name=row[1])
|
||||
album.save()
|
||||
album.artists.add(artist)
|
||||
|
||||
if album_created:
|
||||
album_dict = lookup_album_dict_from_mb(
|
||||
album.name, artist_name=artist.name
|
||||
)
|
||||
album.year = album_dict["year"]
|
||||
album.musicbrainz_id = album_dict["mb_id"]
|
||||
album.musicbrainz_releasegroup_id = album_dict["mb_group_id"]
|
||||
album.musicbrainz_albumartist_id = artist.musicbrainz_id
|
||||
album.save(
|
||||
update_fields=[
|
||||
"year",
|
||||
"musicbrainz_id",
|
||||
"musicbrainz_releasegroup_id",
|
||||
"musicbrainz_albumartist_id",
|
||||
]
|
||||
)
|
||||
album.artists.add(artist)
|
||||
|
||||
track, track_created = Track.objects.get_or_create(
|
||||
track = get_or_create_track(
|
||||
title=row[2],
|
||||
mbid=row[7],
|
||||
artist=artist,
|
||||
album=album,
|
||||
run_time=row[4],
|
||||
run_time_ticks=row[4] * 1000,
|
||||
)
|
||||
|
||||
if track_created:
|
||||
track.musicbrainz_id = row[7]
|
||||
track.run_time = int(row[4])
|
||||
track.run_time_ticks = int(row[4]) * 1000
|
||||
track.save()
|
||||
|
||||
timestamp = (
|
||||
datetime.utcfromtimestamp(int(row[6]))
|
||||
.replace(tzinfo=user_tz)
|
||||
@ -123,9 +86,7 @@ def undo_audioscrobbler_tsv_import(process_log, dryrun=True):
|
||||
logger.warning("No lines in process log found to undo")
|
||||
return
|
||||
|
||||
for line_num, line in enumerate(process_log.split('\n')):
|
||||
if line_num == 0:
|
||||
continue
|
||||
for line in process_log.split('\n'):
|
||||
scrobble_id = line.split("\t")[0]
|
||||
scrobble = Scrobble.objects.filter(id=scrobble_id).first()
|
||||
if not scrobble:
|
||||
|
||||
@ -12,6 +12,7 @@ urlpatterns = [
|
||||
views.AudioScrobblerImportCreateView.as_view(),
|
||||
name='audioscrobbler-file-upload',
|
||||
),
|
||||
path('lastfm-import/', views.lastfm_import, name='lastfm-import'),
|
||||
path('jellyfin/', views.jellyfin_websocket, name='jellyfin-websocket'),
|
||||
path('mopidy/', views.mopidy_websocket, name='mopidy-websocket'),
|
||||
path('export/', views.export, name='export'),
|
||||
|
||||
@ -13,6 +13,12 @@ from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.generic import FormView
|
||||
from django.views.generic.edit import CreateView
|
||||
from django.views.generic.list import ListView
|
||||
from music.aggregators import (
|
||||
scrobble_counts,
|
||||
top_artists,
|
||||
top_tracks,
|
||||
week_of_scrobbles,
|
||||
)
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import (
|
||||
api_view,
|
||||
@ -26,9 +32,10 @@ from scrobbles.constants import (
|
||||
JELLYFIN_AUDIO_ITEM_TYPES,
|
||||
JELLYFIN_VIDEO_ITEM_TYPES,
|
||||
)
|
||||
from scrobbles.export import export_scrobbles
|
||||
from scrobbles.forms import ExportScrobbleForm, ScrobbleForm
|
||||
from scrobbles.imdb import lookup_video_from_imdb
|
||||
from scrobbles.models import AudioScrobblerTSVImport, Scrobble
|
||||
from scrobbles.models import AudioScrobblerTSVImport, LastFmImport, Scrobble
|
||||
from scrobbles.scrobblers import (
|
||||
jellyfin_scrobble_track,
|
||||
jellyfin_scrobble_video,
|
||||
@ -41,16 +48,9 @@ from scrobbles.serializers import (
|
||||
AudioScrobblerTSVImportSerializer,
|
||||
ScrobbleSerializer,
|
||||
)
|
||||
from scrobbles.tasks import process_lastfm_import
|
||||
from scrobbles.thesportsdb import lookup_event_from_thesportsdb
|
||||
|
||||
from vrobbler.apps.music.aggregators import (
|
||||
scrobble_counts,
|
||||
top_artists,
|
||||
top_tracks,
|
||||
week_of_scrobbles,
|
||||
)
|
||||
from vrobbler.apps.scrobbles.export import export_scrobbles
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ -176,6 +176,19 @@ class AudioScrobblerImportCreateView(
|
||||
return HttpResponseRedirect(self.get_success_url())
|
||||
|
||||
|
||||
@permission_classes([IsAuthenticated])
|
||||
@api_view(['GET'])
|
||||
def lastfm_import(request):
|
||||
lfm_import, created = LastFmImport.objects.get_or_create(
|
||||
user=request.user, processed_finished__isnull=True
|
||||
)
|
||||
|
||||
process_lastfm_import.delay(lfm_import.id)
|
||||
|
||||
success_url = reverse_lazy('vrobbler-home')
|
||||
return HttpResponseRedirect(success_url)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@api_view(['GET'])
|
||||
def scrobble_endpoint(request):
|
||||
|
||||
@ -7,12 +7,8 @@ from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
from sqlalchemy import update
|
||||
from scrobbles.mixins import ScrobblableMixin
|
||||
from vrobbler.apps.sports.utils import (
|
||||
get_players_from_event,
|
||||
get_round_name_from_event,
|
||||
)
|
||||
from sports.utils import get_players_from_event, get_round_name_from_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
BNULL = {"blank": True, "null": True}
|
||||
|
||||
13
vrobbler/celery.py
Normal file
13
vrobbler/celery.py
Normal file
@ -0,0 +1,13 @@
|
||||
import os
|
||||
|
||||
from celery import Celery
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "vrobbler.settings")
|
||||
app = Celery("vrobbler")
|
||||
app.config_from_object("django.conf:settings", namespace="CELERY")
|
||||
app.autodiscover_tasks()
|
||||
|
||||
|
||||
@app.task(bind=True)
|
||||
def debug_task(self):
|
||||
print(f"Request: {self.request!r}")
|
||||
@ -37,6 +37,12 @@ KEEP_DETAILED_SCROBBLE_LOGS = os.getenv(
|
||||
"VROBBLER_KEEP_DETAILED_SCROBBLE_LOGS", False
|
||||
)
|
||||
|
||||
# Key must be 16, 24 or 32 bytes long and will be converted to a byte stream
|
||||
ENCRYPTED_FIELD_KEY = os.getenv(
|
||||
"VROBBLER_ENCRYPTED_FIELD_KEY", "12345678901234567890123456789012"
|
||||
)
|
||||
|
||||
DJANGO_ENCRYPTED_FIELD_KEY = bytes(ENCRYPTED_FIELD_KEY, "utf-8")
|
||||
|
||||
# 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)
|
||||
@ -51,6 +57,9 @@ THESPORTSDB_BASE_URL = os.getenv(
|
||||
)
|
||||
TMDB_API_KEY = os.getenv("VROBBLER_TMDB_API_KEY", "")
|
||||
|
||||
LASTFM_API_KEY = os.getenv("VROBBLER_LASTFM_API_KEY")
|
||||
LASTFM_SECRET_KEY = os.getenv("VROBBLER_LASTFM_SECRET_KEY")
|
||||
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
|
||||
TIME_ZONE = os.getenv("VROBBLER_TIME_ZONE", "US/Eastern")
|
||||
@ -61,9 +70,14 @@ CSRF_TRUSTED_ORIGINS = [
|
||||
]
|
||||
X_FRAME_OPTIONS = "SAMEORIGIN"
|
||||
|
||||
CACHALOT_TIMEOUT = os.getenv("VROBBLER_CACHALOT_TIMEOUT", 3600)
|
||||
REDIS_URL = os.getenv("VROBBLER_REDIS_URL", None)
|
||||
|
||||
CELERY_TASK_ALWAYS_EAGER = os.getenv("VROBBLER_SKIP_CELERY", False)
|
||||
CELERY_BROKER_URL = REDIS_URL if REDIS_URL else "memory://localhost/"
|
||||
CELERY_RESULT_BACKEND = "django-db"
|
||||
CELERY_TIMEZONE = os.getenv("VROBBLER_TIME_ZONE", "US/Eastern")
|
||||
CELERY_TASK_TRACK_STARTED = True
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
@ -75,8 +89,8 @@ INSTALLED_APPS = [
|
||||
"django.contrib.humanize",
|
||||
"django_filters",
|
||||
"django_extensions",
|
||||
'rest_framework.authtoken',
|
||||
"cachalot",
|
||||
"rest_framework.authtoken",
|
||||
"encrypted_field",
|
||||
"profiles",
|
||||
"scrobbles",
|
||||
"videos",
|
||||
@ -286,9 +300,12 @@ LOGGING = {
|
||||
},
|
||||
"django.db.backends": {"handlers": ["null"]},
|
||||
"django.server": {"handlers": ["null"]},
|
||||
"pylast": {"handlers": ["null"], "propagate": False},
|
||||
"musicbrainzngs": {"handlers": ["null"], "propagate": False},
|
||||
"httpx": {"handlers": ["null"], "propagate": False},
|
||||
"vrobbler": {
|
||||
"handlers": ["file"],
|
||||
"propagate": True,
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@ -4,17 +4,27 @@
|
||||
|
||||
{% 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">
|
||||
<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">
|
||||
{% if user.is_authenticated %}
|
||||
<div class="btn-group me-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#importModal">Import</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#exportModal">Export</button>
|
||||
{% if user.profile.lastfm_username %}
|
||||
|
||||
<form action="{% url 'scrobbles:lastfm-import' %}" method="get">
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary">Last.fm Sync</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal"
|
||||
data-bs-target="#importModal">Import</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal"
|
||||
data-bs-target="#exportModal">Export</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="dropdown">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary dropdown-toggle" id="graphDateButton" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary dropdown-toggle" id="graphDateButton"
|
||||
data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<span data-feather="calendar"></span>
|
||||
This week
|
||||
</button>
|
||||
@ -32,44 +42,54 @@
|
||||
|
||||
{% if user.is_authenticated %}
|
||||
<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>
|
||||
<p>Today <b>{{counts.today}}</b> | This Week <b>{{counts.week}}</b> | This Month <b>{{counts.month}}</b> |
|
||||
This Year <b>{{counts.year}}</b> | All Time <b>{{counts.alltime}}</b></p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md">
|
||||
<ul class="nav nav-tabs" id="myTab" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="home-tab" data-bs-toggle="tab" data-bs-target="#artists-week" type="button" role="tab" aria-controls="home" aria-selected="true">Weekly Artists</button>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#tracks-month" type="button" role="tab" aria-controls="profile" aria-selected="false">Monthly Tracks</button>
|
||||
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#tracks-month"
|
||||
type="button" role="tab" aria-controls="profile" aria-selected="false">Monthly
|
||||
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">
|
||||
<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>
|
||||
<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>
|
||||
@ -78,216 +98,231 @@
|
||||
<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>
|
||||
<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="tracks-month" role="tabpanel" aria-labelledby="tracks-month-tab">
|
||||
<div class="tab-pane fade show" id="tracks-month" role="tabpanel"
|
||||
aria-labelledby="tracks-month-tab">
|
||||
<h2>Top tracks this month</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_monthly_tracks %}
|
||||
<tr>
|
||||
<td>{{track.num_scrobbles}}</td>
|
||||
<td>{{track.title}}</td>
|
||||
<td>{{track.artist.name}}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">#</th>
|
||||
<th scope="col">Track</th>
|
||||
<th scope="col">Artist</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for track in top_monthly_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">
|
||||
<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>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">#</th>
|
||||
<th scope="col">Artist</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for artist in top_monthly_artists %}
|
||||
<tr>
|
||||
<td>{{artist.num_scrobbles}}</td>
|
||||
<td>{{artist.name}}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md">
|
||||
<ul class="nav nav-tabs" id="myTab" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="home-tab" data-bs-toggle="tab" data-bs-target="#latest-listened" type="button" role="tab" aria-controls="home" aria-selected="true">Tracks</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#latest-watched" type="button" role="tab" aria-controls="profile" aria-selected="false">Videos</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#latest-podcasted" type="button" role="tab" aria-controls="profile" aria-selected="false">Podcasts</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#latest-sports" type="button" role="tab" aria-controls="profile" aria-selected="false">Sports</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="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">Album</th>
|
||||
<th scope="col">Track</th>
|
||||
<th scope="col">Artist</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for scrobble in object_list %}
|
||||
<tr>
|
||||
<td>{{scrobble.timestamp|naturaltime}}</td>
|
||||
{% if scrobble.track.album.cover_image %}
|
||||
<td><img src="{{scrobble.track.album.cover_image.url}}" width=50 height=50 style="border:1px solid black;" /></td>
|
||||
{% else %}
|
||||
<td>{{scrobble.track.album.name}}</td>
|
||||
{% endif %}
|
||||
<td>{{scrobble.track.title}}</td>
|
||||
<td>{{scrobble.track.artist.name}}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<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">Album</th>
|
||||
<th scope="col">Track</th>
|
||||
<th scope="col">Artist</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for scrobble in object_list %}
|
||||
<tr>
|
||||
<td>{{scrobble.timestamp|naturaltime}}</td>
|
||||
{% if scrobble.track.album.cover_image %}
|
||||
<td><img src="{{scrobble.track.album.cover_image.url}}" width=50 height=50
|
||||
style="border:1px solid black;" /></td>
|
||||
{% else %}
|
||||
<td>{{scrobble.track.album.name}}</td>
|
||||
{% endif %}
|
||||
<td>{{scrobble.track.title}}</td>
|
||||
<td>{{scrobble.track.artist.name}}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade show" id="latest-watched" role="tabpanel" aria-labelledby="latest-watched-tab">
|
||||
<h2>Latest watched</h2>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Time</th>
|
||||
<th scope="col">Title</th>
|
||||
<th scope="col">Series</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for scrobble in video_scrobble_list %}
|
||||
<tr>
|
||||
<td>{{scrobble.timestamp|naturaltime}}</td>
|
||||
<td>{% if scrobble.video.tv_series %}S{{scrobble.video.season_number}}E{{scrobble.video.episode_number}} -{% endif %} {{scrobble.video.title}}</td>
|
||||
<td>{% if scrobble.video.tv_series %}{{scrobble.video.tv_series}}{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div 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>
|
||||
|
||||
<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 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>
|
||||
|
||||
<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 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>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div class="modal fade" id="importModal" tabindex="-1" role="dialog" aria-labelledby="importModalLabel" aria-hidden="true">
|
||||
<div class="modal fade" id="importModal" tabindex="-1" role="dialog" aria-labelledby="importModalLabel"
|
||||
aria-hidden="true">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="importModalLabel">Import scrobbles</h5>
|
||||
<button type="button" class="close" data-bs-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<form action="{% url 'audioscrobbler-file-upload' %}" method="post" enctype="multipart/form-data">
|
||||
<div class="modal-body">
|
||||
{% csrf_token %}
|
||||
<div class="form-group">
|
||||
<label for="tsv_file" class="col-form-label">Audioscrobbler TSV file:</label>
|
||||
<input type="file" name="tsv_file" class="form-control" id="id_tsv_file">
|
||||
</div>
|
||||
{% csrf_token %}
|
||||
<div class="form-group">
|
||||
<label for="tsv_file" class="col-form-label">Audioscrobbler TSV file:</label>
|
||||
<input type="file" name="tsv_file" class="form-control" id="id_tsv_file">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" class="btn btn-primary">Import</button>
|
||||
@ -298,21 +333,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="exportModal" tabindex="-1" role="dialog" aria-labelledby="exportModalLabel" aria-hidden="true">
|
||||
<div class="modal fade" id="exportModal" tabindex="-1" role="dialog" aria-labelledby="exportModalLabel"
|
||||
aria-hidden="true">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="exportModalLabel">Export scrobbles</h5>
|
||||
<button type="button" class="close" data-bs-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<form action="{% url 'scrobbles:export' %}" method="get">
|
||||
<div class="modal-body">
|
||||
{% csrf_token %}
|
||||
<div class="form-group">
|
||||
{{export_form.as_div}}
|
||||
</div>
|
||||
{% csrf_token %}
|
||||
<div class="form-group">
|
||||
{{export_form.as_div}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" class="btn btn-primary">Export</button>
|
||||
@ -326,7 +362,7 @@
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
$('#importModal').on('shown.bs.modal', function () { $('#importInput').trigger('focus') });
|
||||
$('#exportModal').on('shown.bs.modal', function () { $('#exportInput').trigger('focus') });
|
||||
$('#importModal').on('shown.bs.modal', function () { $('#importInput').trigger('focus') });
|
||||
$('#exportModal').on('shown.bs.modal', function () { $('#exportInput').trigger('focus') });
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user