Compare commits

...

55 Commits

Author SHA1 Message Date
2e98850494 Bump version to 0.10.0 2023-02-20 02:06:53 -05:00
5d315b4834 Fix LastFM and add UI for KoReader 2023-02-20 02:06:17 -05:00
6ef8238442 Add book scrobbling 2023-02-19 22:19:01 -05:00
f4a444354d Fix lastfm import errors 2023-02-19 22:18:11 -05:00
0db5bbe36c Fix users not being added to tsv and lastfm imports 2023-02-17 14:05:06 -05:00
69b6364f88 Update todos and add Procfile/honcho 2023-02-17 13:57:50 -05:00
966aeefbdd Turns out I shoulda tested this more 2023-02-17 13:25:56 -05:00
d944fdd0c0 Bump version to 0.9.3 2023-02-16 23:58:27 -05:00
e345631e27 Spin TSV imports off to tasks 2023-02-16 23:58:06 -05:00
59d0108fe5 Bump version to 0.9.2 2023-02-16 23:47:48 -05:00
8d67b672f9 Oops, fix the source thing properly 2023-02-16 23:47:12 -05:00
376650f937 Bump version to 0.9.1 2023-02-16 23:42:45 -05:00
485fbd63a3 Fix a few issues with TSV imports 2023-02-16 23:42:25 -05:00
d3f059caab Bump version to 0.9.0 2023-02-16 22:32:31 -05:00
bb9936af65 Clean up lastfm importing 2023-02-16 22:31:46 -05:00
9568726bf3 Switch log statement to info in lastfm 2023-02-16 02:44:57 -05:00
4ae70ef1f1 Fix celery task running 2023-02-16 02:38:30 -05:00
21df4e0a77 Add task to sync with last.fm 2023-02-16 02:27:39 -05:00
cc82504262 Fix scrobbling tracks with featured artists 2023-02-16 02:13:52 -05:00
c7b84b27b2 Fix bug in duplicate lastfm scrobbles 2023-02-15 01:41:10 -05:00
20528b576b Fix lastfm importing 2023-02-15 01:33:12 -05:00
817ad3f67f Remove django-cachalot, more problems than solutions 2023-02-15 01:32:33 -05:00
b47ca53c5d Fix source for import from Rockbox 2023-02-15 01:31:27 -05:00
7a7c1caecc Add Last.fm importing 2023-02-14 01:48:53 -05:00
87f068dccd Update tsv to use utility functions 2023-02-13 18:31:57 -05:00
31907ed1b2 Fix appending count to TSV log 2023-02-12 17:03:30 -05:00
36d7950859 Fix bug in scrobbling duplicate tracks from TSV 2023-02-12 16:54:45 -05:00
0e4501cad3 Bump version to 0.8.6 2023-02-08 20:01:09 -05:00
71e4ff28c8 Add musicbrainz utilities 2023-02-08 20:00:46 -05:00
9f272df99c Fix fetching release group and cover images 2023-02-08 20:00:09 -05:00
8ba8ceefb8 Bump version to 0.8.5 2023-02-07 01:30:38 -05:00
9590cd0f60 Fix fetching artwork when importing tsv 2023-02-07 01:30:04 -05:00
5e7c8ff137 Bump version to 0.8.4 2023-02-07 00:52:11 -05:00
fae59849f8 Add mb lookups to TSV imports 2023-02-07 00:51:43 -05:00
837e1280bd Bump version to 0.8.3 2023-02-06 23:30:14 -05:00
8f9c825903 Fix user timezones in scrobbler files 2023-02-06 23:28:57 -05:00
541073aae3 Bump version to 0.8.2 2023-02-06 19:32:15 -05:00
b63ec6b15f Fix bug in export when artist does not exist 2023-02-06 19:31:25 -05:00
117157e3ae Fix audioscrobbler import bug
Issue was not having a user so we couldn't set a timezone. All fixed now
2023-02-06 19:30:58 -05:00
0c10e78d5e Fix bug in unified scrobbler for podcasts 2023-02-06 17:54:48 -05:00
6b7359707b Bump version to 0.8.1 2023-02-06 01:12:33 -05:00
e0295cbd56 Fix jellyfin edge case scrobbling mess
Finally get to resolve scrobbling music from Jellyfin. This may lead to
other issues, in fact now videos seem to sometimes create duplicate
scrobbles. But music can be scrobbled now from Jellyfin web or Finamp
successfully.
2023-02-06 00:22:10 -05:00
5271cfaea4 Create sport if it doesn't exist yet 2023-02-06 00:19:47 -05:00
0370b64351 Fix exporting only tracks by default 2023-02-06 00:19:07 -05:00
9ec31ba0f5 Remove noisy debug logging 2023-02-05 01:59:24 -05:00
a9de298057 Put import and export behind auth 2023-02-05 01:58:19 -05:00
9d303b1b94 Add exporting and importing scrobbles 2023-02-04 17:08:01 -05:00
4c434aeb7c Bump version to 0.8.0 2023-02-03 19:00:32 -05:00
64d9cac09c Update importing to include some logging 2023-02-03 18:59:48 -05:00
c21d6a96fe Fix unused imports in imdb module 2023-02-03 16:53:03 -05:00
e392477dc7 Add import of Audioscrobbler files
Here we add a model for holding Audioscrobbler imports and some code to
process the tab-separated files we get from Rockbox.
2023-02-03 16:45:31 -05:00
12087460f6 Irritating that poetry can't handle prod deps well 2023-01-31 10:44:39 -05:00
4b4fbf4777 Bump version to 0.7.5 2023-01-31 10:41:40 -05:00
ca57eabf87 Another attempt to fix the Jellyfin issue
This time, we simplify the progress updates, aggressively mark tracks as
100 played if they are marked played_to_completion, and implement a hack
for Jellyfin spamming us with progress updates less than a second apart.
2023-01-31 10:28:54 -05:00
6fc51d9296 Fix resurrecting past tracks
This may also be hack, but I think that if the playback position ticks
are automatically jumped to the run time ticks of the media object, it
should stop the resurrection of past scrobbles, because they will be
appropriately marked as being 100 played when the scrobble is finished.

The only weirdness here is that "in progress" scrobbles will suddenly
complete once the media type's threshold is met (90, 95 percent,
whatever). But it should be better than overwriting old scrobbles.
2023-01-30 23:18:42 -05:00
51 changed files with 2594 additions and 494 deletions

2
Procfile Normal file
View File

@ -0,0 +1,2 @@
web: python manage.py runserver 0.0.0.0:8014
worker: celery -A vrobbler worker -l DEBUG

200
poetry.lock generated
View File

@ -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,62 @@ 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 = "honcho"
version = "1.1.0"
description = "Honcho: a Python clone of Foreman. For managing Procfile-based applications."
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
colorama = {version = "*", markers = "sys_platform == \"win32\""}
[package.extras]
export = ["jinja2 (>=2.7,<3)"]
[[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 +923,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 +956,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 +1270,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 +1331,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 +1612,17 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools"
[metadata]
lock-version = "1.1"
python-versions = "^3.8"
content-hash = "0e23dbecb64cbef4dfe51bdf47e0f6b1357aab1d34342fef5341eaead2c26f1e"
content-hash = "d57d0a79f04c3288d12d8a9fb3579e03fa514d1a130b11c28812150feeb66a06"
[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 +1968,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 +2093,18 @@ h11 = [
{file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"},
{file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"},
]
honcho = [
{file = "honcho-1.1.0-py2.py3-none-any.whl", hash = "sha256:a4d6e3a88a7b51b66351ecfc6e9d79d8f4b87351db9ad7e923f5632cc498122f"},
{file = "honcho-1.1.0.tar.gz", hash = "sha256:c5eca0bded4bef6697a23aec0422fd4f6508ea3581979a3485fc4b89357eb2a9"},
]
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 +2380,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 +2423,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 +2557,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"},

View File

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

View File

@ -9,7 +9,7 @@ When we get artwork from Musicbrianz, and it's not found, we should check for
release groups as well. This will stop issues with missing artwork because of
obscure MB release matches.
* DONE [#A] Fix Jellyfin music scrobbling N+1 past 90 completion perecnt :bug:
* DONE [#A] Fix Jellyfin music scrobbling N+1 past 90 completion percent :bug:
CLOSED: [2023-01-30 Mon 18:31]
:LOGBOOK:
CLOCK: [2023-01-30 Mon 18:00]--[2023-01-30 Mon 18:31] => 0:31
@ -26,17 +26,47 @@ as complete for the following conditions:
But if we keep listening beyond 90, we should basically ignore updates (or just
update the existing scrobble)
* DONE [#A] Add support for Audioscrobbler tab-separated file uploads :improvement:
CLOSED: [2023-02-03 Fri 16:52]
* TODO [#A] Add django-storage to store files on S3 :improvement:
* TODO [#B] Adjust cancel/finish task to use javascript to submit :improvement:
* TODO [#B] Implement a detail view for TV shows :improvement:
* TODO [#B] Implement a detail view for Moviews :improvement:
* TODO [#B] Allow scrobbling music without MB IDs by grabbing them before scrobble :improvement:
An example of the format:
#+begin_src csv
,
#AUDIOSCROBBLER/1.1
#TZ/UNKNOWN
#CLIENT/Rockbox sansaclipplus $Revision$
75 Dollar Bill I Was Real I Was Real 4 1015 S 1740494944 64ff5f53-d187-4512-827e-7606c69e66ff
75 Dollar Bill I Was Real I Was Real 4 1015 S 1740494990 64ff5f53-d187-4512-827e-7606c69e66ff
311 311 Down 1 173 S 1740495003 00476c23-fd9e-464b-9b27-a62d69f3d4f4
311 311 Down 1 173 L 1740495049 00476c23-fd9e-464b-9b27-a62d69f3d4f4
311 311 Down 1 173 L 1740495113 00476c23-fd9e-464b-9b27-a62d69f3d4f4
311 311 Random 2 187 S 1740495190 530c09f3-46fe-4d90-b11f-7b63bcb4b373
311 311 Random 2 187 L 1740495194 530c09f3-46fe-4d90-b11f-7b63bcb4b373
311 311 Jackolanterns Weather 3 204 L 1740495382 cc3b2dec-5d99-47ea-8930-20bf258be4ea
311 311 All Mixed Up 4 182 L 1740495586 980a78b5-5bdd-4f50-9e3a-e13261e2817b
311 311 Hive 5 179 L 1740495768 18f6dc98-d3a2-4f81-b967-97359d14c68c
311 311 Guns (Are for Pussies) 6 137 L 1740495948 5e97ed9f-c8cc-4282-9cbe-f8e17aee5128
311 311 Misdirected Hostility 7 179 S 1740496085 61ff2c1a-fc9c-44c3-8da1-5e50a44245af
,
#+end_src
* DONE [#B] Allow scrobbling music without MB IDs by grabbing them before scrobble :improvement:
CLOSED: [2023-02-17 Fri 00:10]
This would allow a few nice flows. One, you'd be able to record the play of an
entire album by just dropping the muscibrainz_id in. This could be helpful for
offline listening. It would also mean bad metadata from mopidy would not break
scrobbling.
* DONE When updating musicbrainz IDs, clear and run fetch artwrok :improvement:
CLOSED: [2023-02-17 Fri 00:11]
* TODO [#A] Add ability to manually scrobble albums or tracks from MB :improvement:
Given a UUID from musicbrainz, we should be able to scrobble an album or
individual track.
* TODO [#A] Add django-storage to store files on S3 :improvement:
* TODO [#B] Adjust cancel/finish task to use javascript to submit :improvement:
* TODO [#B] Implement a detail view for TV shows :improvement:
* TODO [#B] Implement a detail view for Moviews :improvement:
* TODO [#C] Implement keeping track of week/month/year chart-toppers :improvement:
:LOGBOOK:
CLOCK: [2023-01-30 Mon 16:30]--[2023-01-30 Mon 18:00] => 1:30
@ -345,7 +375,8 @@ has to re-populate when the server restarts.
}
#+end_src
* TODO [#C] Consider a purge command for duplicated and stuck in-progress scrobbles :improvement:
* TODO Figure out how to add to web-scrobbler :imropvement:
* TODO [#C] Figure out how to add to web-scrobbler :imropvement:
An example:
https://github.com/web-scrobbler/web-scrobbler/blob/master/src/core/background/scrobbler/maloja-scrobbler.js

View File

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

View File

@ -0,0 +1,25 @@
from django.contrib import admin
from books.models import Author, Book
from scrobbles.admin import ScrobbleInline
@admin.register(Author)
class AlbumAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = ("name", "openlibrary_id")
ordering = ("name",)
@admin.register(Book)
class ArtistAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"title",
"isbn",
"first_publish_year",
"pages",
"openlibrary_id",
)
ordering = ("title",)

View File

@ -0,0 +1,128 @@
# Generated by Django 4.1.5 on 2023-02-19 20:17
from django.db import migrations, models
import django_extensions.db.fields
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name='Author',
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)),
(
'openlibrary_id',
models.CharField(blank=True, max_length=255, null=True),
),
],
options={
'get_latest_by': 'modified',
'abstract': False,
},
),
migrations.CreateModel(
name='Book',
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,
),
),
(
'run_time',
models.CharField(blank=True, max_length=8, null=True),
),
(
'run_time_ticks',
models.PositiveBigIntegerField(blank=True, null=True),
),
('title', models.CharField(max_length=255)),
(
'openlibrary_id',
models.CharField(blank=True, max_length=255, null=True),
),
(
'goodreads_id',
models.CharField(blank=True, max_length=255, null=True),
),
('koreader_id', models.IntegerField(blank=True, null=True)),
(
'koreader_authors',
models.CharField(blank=True, max_length=255, null=True),
),
(
'koreader_md5',
models.CharField(blank=True, max_length=255, null=True),
),
(
'isbn',
models.CharField(blank=True, max_length=255, null=True),
),
('pages', models.IntegerField(blank=True, null=True)),
(
'language',
models.CharField(blank=True, max_length=4, null=True),
),
(
'first_publish_year',
models.IntegerField(blank=True, null=True),
),
('authors', models.ManyToManyField(to='books.author')),
],
options={
'abstract': False,
},
),
]

View File

@ -0,0 +1,68 @@
import logging
from typing import Dict
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import models
from django.urls import reverse
from django_extensions.db.models import TimeStampedModel
from scrobbles.mixins import ScrobblableMixin
from vrobbler.apps.books.utils import lookup_book_from_openlibrary
logger = logging.getLogger(__name__)
User = get_user_model()
BNULL = {"blank": True, "null": True}
class Author(TimeStampedModel):
name = models.CharField(max_length=255)
openlibrary_id = models.CharField(max_length=255, **BNULL)
def __str__(self):
return f"{self.name}"
def fix_metadata(self):
logger.warn("Not implemented yet")
class Book(ScrobblableMixin):
COMPLETION_PERCENT = getattr(settings, 'BOOK_COMPLETION_PERCENT', 95)
title = models.CharField(max_length=255)
authors = models.ManyToManyField(Author)
openlibrary_id = models.CharField(max_length=255, **BNULL)
goodreads_id = models.CharField(max_length=255, **BNULL)
koreader_id = models.IntegerField(**BNULL)
koreader_authors = models.CharField(max_length=255, **BNULL)
koreader_md5 = models.CharField(max_length=255, **BNULL)
isbn = models.CharField(max_length=255, **BNULL)
pages = models.IntegerField(**BNULL)
language = models.CharField(max_length=4, **BNULL)
first_publish_year = models.IntegerField(**BNULL)
def __str__(self):
return f"{self.title} by {self.author}"
def fix_metadata(self):
if not self.openlibrary_id:
book_meta = lookup_book_from_openlibrary(self.title, self.author)
self.openlibrary_id = book_meta.get("openlibrary_id")
self.isbn = book_meta.get("isbn")
self.goodreads_id = book_meta.get("goodreads_id")
self.first_pubilsh_year = book_meta.get("first_publish_year")
self.save()
@property
def author(self):
return self.authors.first()
def get_absolute_url(self):
return reverse("books:book_detail", kwargs={'slug': self.uuid})
@property
def pages_for_completion(self) -> int:
if not self.pages:
logger.warn(f"{self} has no pages, no completion percentage")
return 0
return int(self.pages * (self.COMPLETION_PERCENT / 100))

View File

@ -0,0 +1,47 @@
import json
from typing import Optional
import requests
import logging
logger = logging.getLogger(__name__)
SEARCH_URL = "https://openlibrary.org/search.json?title={title}"
ISBN_URL = "https://openlibrary.org/isbn/{isbn}.json"
def get_first(key: str, result: dict) -> str:
obj = ""
if obj_list := result.get(key):
obj = obj_list[0]
return obj
def lookup_book_from_openlibrary(title: str, author: str = None) -> dict:
search_url = SEARCH_URL.format(title=title)
response = requests.get(search_url)
if response.status_code != 200:
logger.warn(f"Bad response from OL: {response.status_code}")
return {}
results = json.loads(response.content)
if len(results.get('docs')) == 0:
logger.warn(f"No results found from OL for {title}")
return {}
top = results.get('docs')[0]
if author and author not in top['author_name']:
logger.warn(
f"Lookup for {title} found top result with mismatched author"
)
return {
"title": top.get("title"),
"isbn": top.get("isbn")[0],
"openlibrary_id": top.get("cover_edition_key"),
"author_name": get_first("author_name", top),
"author_openlibrary_id": get_first("author_key", top),
"goodreads_id": get_first("id_goodreads", top),
"first_publish_year": top.get("first_publish_year"),
}

View File

@ -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
@ -183,16 +203,7 @@ class Track(ScrobblableMixin):
return
artist, artist_created = Artist.objects.get_or_create(**artist_dict)
if artist_created:
logger.debug(f"Created new artist {artist}")
else:
logger.debug(f"Found album {artist}")
album, album_created = Album.objects.get_or_create(**album_dict)
if album_created:
logger.debug(f"Created new album {album}")
else:
logger.debug(f"Found album {album}")
album.fix_metadata()
if not album.cover_image:
@ -202,9 +213,5 @@ class Track(ScrobblableMixin):
track_dict['artist_id'] = artist.id
track, created = cls.objects.get_or_create(**track_dict)
if created:
logger.debug(f"Created new track: {track}")
else:
logger.debug(f"Found track {track}")
return track

View File

@ -0,0 +1,107 @@
import logging
import re
from scrobbles.musicbrainz import (
lookup_album_dict_from_mb,
lookup_artist_from_mb,
)
logger = logging.getLogger(__name__)
from music.models import Album, Artist, 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

View File

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

View File

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

View File

@ -1,9 +1,14 @@
import pytz
from django.contrib.auth import get_user_model
from django.db import models
from django_extensions.db.models import TimeStampedModel
from profiles.constants import PRETTY_TIMEZONE_CHOICES
from encrypted_field import EncryptedField
User = get_user_model()
BNULL = {"blank": True, "null": True}
class UserProfile(TimeStampedModel):
@ -11,8 +16,14 @@ class UserProfile(TimeStampedModel):
User, on_delete=models.CASCADE, related_name="profile"
)
timezone = models.CharField(
max_length=255, choices=PRETTY_TIMEZONE_CHOICES
max_length=255, choices=PRETTY_TIMEZONE_CHOICES, default=pytz.UTC
)
lastfm_username = models.CharField(max_length=255, **BNULL)
lastfm_password = EncryptedField(**BNULL)
def __str__(self):
return f"User profile for {self.user}"
@property
def tzinfo(self):
return pytz.timezone(self.timezone)

View File

@ -1,6 +1,11 @@
from django.contrib import admin
from scrobbles.models import Scrobble
from scrobbles.models import (
AudioScrobblerTSVImport,
ChartRecord,
KoReaderImport,
LastFmImport,
Scrobble,
)
class ScrobbleInline(admin.TabularInline):
@ -10,6 +15,57 @@ class ScrobbleInline(admin.TabularInline):
exclude = ('source_id', 'scrobble_log')
class ImportBaseAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"uuid",
"process_count",
"processed_finished",
"processing_started",
)
ordering = ("-created",)
@admin.register(AudioScrobblerTSVImport)
class AudioScrobblerTSVImportAdmin(ImportBaseAdmin):
""""""
@admin.register(LastFmImport)
class LastFmImportAdmin(ImportBaseAdmin):
""""""
@admin.register(KoReaderImport)
class KoReaderImportAdmin(ImportBaseAdmin):
""""""
@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"
@ -23,21 +79,21 @@ class ScrobbleAdmin(admin.ModelAdmin):
"is_paused",
"played_to_completion",
)
raw_id_fields = ('video', 'podcast_episode', 'track', 'sport_event')
raw_id_fields = (
'video',
'podcast_episode',
'track',
'sport_event',
'book',
)
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
return obj.media_obj
def media_type(self, obj):
return obj.media_obj.__class__.__name__
if obj.video:
return "Video"
if obj.track:

View File

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

View File

@ -1,6 +1,17 @@
from django import forms
class ExportScrobbleForm(forms.Form):
"""Provide options for downloading scrobbles"""
EXPORT_TYPES = (
('as', 'Audioscrobbler'),
('csv', 'CSV'),
('html', 'HTML'),
)
export_type = forms.ChoiceField(choices=EXPORT_TYPES)
class ScrobbleForm(forms.Form):
item_id = forms.CharField(
label="",

View File

@ -1,9 +1,7 @@
import logging
from typing import Optional
from django.utils import timezone
from imdb import Cinemagoer
from videos.models import Video
imdb_client = Cinemagoer()

View File

@ -0,0 +1,124 @@
import logging
from datetime import datetime
import sqlite3
from enum import Enum
import pytz
from books.models import Author, Book
from scrobbles.models import Scrobble
from django.utils import timezone
logger = logging.getLogger(__name__)
class KoReaderBookColumn(Enum):
ID = 0
TITLE = 1
AUTHORS = 2
NOTES = 3
LAST_OPEN = 4
HIGHLIGHTS = 5
PAGES = 6
SERIES = 7
LANGUAGE = 8
MD5 = 9
TOTAL_READ_TIME = 10
TOTAL_READ_PAGES = 11
class KoReaderPageStatColumn(Enum):
ID_BOOK = 0
PAGE = 1
START_TIME = 2
DURATION = 3
TOTAL_PAGES = 4
def process_koreader_sqlite_file(sqlite_file_path, user_id):
"""Given a sqlite file from KoReader, open the book table, iterate
over rows creating scrobbles from each book found"""
# Create a SQL connection to our SQLite database
con = sqlite3.connect(sqlite_file_path)
cur = con.cursor()
# Return all results of query
book_table = cur.execute("SELECT * FROM book")
new_scrobbles = []
for book_row in book_table:
authors = book_row[KoReaderBookColumn.AUTHORS.value].split('\n')
author_list = []
for author_str in authors:
logger.debug(f"Looking up author {author_str}")
if author_str == "N/A":
continue
author, created = Author.objects.get_or_create(name=author_str)
if created:
author.fix_metadata()
author_list.append(author)
logger.debug(f"Found author {author}, created: {created}")
book, created = Book.objects.get_or_create(
koreader_md5=book_row[KoReaderBookColumn.MD5.value]
)
if created:
book.title = book_row[KoReaderBookColumn.TITLE.value]
book.pages = book_row[KoReaderBookColumn.PAGES.value]
book.koreader_id = int(book_row[KoReaderBookColumn.ID.value])
book.koreader_authors = book_row[KoReaderBookColumn.AUTHORS.value]
book.run_time_ticks = int(book_row[KoReaderBookColumn.PAGES.value])
book.save(
update_fields=[
"title",
"pages",
"koreader_id",
"koreader_authors",
]
)
book.fix_metadata()
if author_list:
book.authors.add(*[a.id for a in author_list])
playback_position = int(
book_row[KoReaderBookColumn.TOTAL_READ_TIME.value]
)
playback_position_ticks = playback_position * 1000
pages_read = int(book_row[KoReaderBookColumn.TOTAL_READ_PAGES.value])
timestamp = datetime.utcfromtimestamp(
book_row[KoReaderBookColumn.LAST_OPEN.value]
).replace(tzinfo=pytz.utc)
new_scrobble = Scrobble(
book_id=book.id,
user_id=user_id,
source="KOReader",
timestamp=timestamp,
playback_position_ticks=playback_position_ticks,
playback_position=playback_position,
played_to_completion=True,
in_progress=False,
book_pages_read=pages_read,
)
existing = Scrobble.objects.filter(
timestamp=timestamp, book=book
).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)
# Be sure to close the connection
con.close()
created = Scrobble.objects.bulk_create(new_scrobbles)
logger.info(
f"Created {len(created)} scrobbles",
extra={'created_scrobbles': created},
)
return created

View File

@ -0,0 +1,146 @@
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 = ""
lastfm_scrobbles = self.get_last_scrobbles(time_from=last_processed)
for lfm_scrobble in lastfm_scrobbles:
timestamp = lfm_scrobble.pop('timestamp')
artist = get_or_create_artist(lfm_scrobble.pop('artist'))
album = get_or_create_album(lfm_scrobble.pop('album'), artist)
lfm_scrobble['artist'] = artist
lfm_scrobble['album'] = album
track = get_or_create_track(**lfm_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
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 artist:
logger.warn(f"Silly LastFM, no artist found for {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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,26 @@
# Generated by Django 4.1.5 on 2023-02-07 00:07
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('scrobbles', '0016_audioscrobblertsvimport_process_count'),
]
operations = [
migrations.AddField(
model_name='audioscrobblertsvimport',
name='user',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to=settings.AUTH_USER_MODEL,
),
),
]

View File

@ -0,0 +1,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,
},
),
]

View File

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

View File

@ -0,0 +1,97 @@
# Generated by Django 4.1.5 on 2023-02-19 03:53
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django_extensions.db.fields
import scrobbles.models
import uuid
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
(
'scrobbles',
'0019_rename_processed_on_lastfmimport_processed_finished_and_more',
),
]
operations = [
migrations.AlterModelOptions(
name='audioscrobblertsvimport',
options={},
),
migrations.AlterModelOptions(
name='lastfmimport',
options={},
),
migrations.RenameField(
model_name='audioscrobblertsvimport',
old_name='processed_on',
new_name='processed_finished',
),
migrations.AddField(
model_name='audioscrobblertsvimport',
name='processing_started',
field=models.DateTimeField(blank=True, null=True),
),
migrations.CreateModel(
name='KoReaderImport',
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)),
(
'processing_started',
models.DateTimeField(blank=True, null=True),
),
(
'processed_finished',
models.DateTimeField(blank=True, null=True),
),
('process_log', models.TextField(blank=True, null=True)),
('process_count', models.IntegerField(blank=True, null=True)),
(
'sqlite_file',
models.FileField(
blank=True,
null=True,
upload_to=scrobbles.models.KoReaderImport.get_path,
),
),
(
'user',
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
'abstract': False,
},
),
]

View File

@ -0,0 +1,25 @@
# Generated by Django 4.1.5 on 2023-02-19 20:20
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('books', '0001_initial'),
('scrobbles', '0020_alter_audioscrobblertsvimport_options_and_more'),
]
operations = [
migrations.AddField(
model_name='scrobble',
name='book',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to='books.book',
),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.1.5 on 2023-02-20 00:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('scrobbles', '0021_scrobble_book'),
]
operations = [
migrations.AddField(
model_name='scrobble',
name='book_pages_read',
field=models.IntegerField(blank=True, null=True),
),
]

View File

@ -7,6 +7,8 @@ BNULL = {"blank": True, "null": True}
class ScrobblableMixin(TimeStampedModel):
SECONDS_TO_STALE = 1600
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
title = models.CharField(max_length=255, **BNULL)
run_time = models.CharField(max_length=8, **BNULL)

View File

@ -1,6 +1,5 @@
import calendar
import logging
from datetime import timedelta
from uuid import uuid4
from django.contrib.auth import get_user_model
@ -8,7 +7,10 @@ from django.db import models
from django.utils import timezone
from django_extensions.db.models import TimeStampedModel
from music.models import Artist, Track
from books.models import Book
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
@ -18,6 +20,178 @@ User = get_user_model()
BNULL = {"blank": True, "null": True}
class BaseFileImportMixin(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)
class Meta:
abstract = True
def __str__(self):
return f"Scrobble import {self.id}"
def process(self, force=False):
logger.warning("Process not implemented")
def undo(self, dryrun=False):
"""Accepts the log from a scrobble import and removes the scrobbles"""
from scrobbles.models import Scrobble
if not self.process_log:
logger.warning("No lines in process log found to undo")
return
for line in self.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()
self.processed_finished = None
self.processing_started = None
self.process_count = None
self.process_log = ""
self.save(
update_fields=[
"processed_finished",
"processing_started",
"process_log",
"process_count",
]
)
def mark_started(self):
self.processing_started = timezone.now()
self.save(update_fields=["processing_started"])
def mark_finished(self):
self.processed_finished = timezone.now()
self.save(update_fields=['processed_finished'])
def record_log(self, scrobbles):
self.process_log = ""
if not scrobbles:
self.process_count = 0
self.save(update_fields=["process_log", "process_count"])
return
for count, scrobble in enumerate(scrobbles):
scrobble_str = f"{scrobble.id}\t{scrobble.timestamp}\t{scrobble.media_obj.title}"
log_line = f"{scrobble_str}"
if count > 0:
log_line = "\n" + log_line
self.process_log += log_line
self.process_count = len(scrobbles)
self.save(update_fields=["process_log", "process_count"])
class KoReaderImport(BaseFileImportMixin):
class Meta:
verbose_name = "KOReader Import"
def get_path(instance, filename):
extension = filename.split('.')[-1]
uuid = instance.uuid
return f'koreader-uploads/{uuid}.{extension}'
sqlite_file = models.FileField(upload_to=get_path, **BNULL)
def process(self, force=False):
from scrobbles.koreader import process_koreader_sqlite_file
if self.processed_finished and not force:
logger.info(
f"{self} already processed on {self.processed_finished}"
)
return
self.mark_started()
scrobbles = process_koreader_sqlite_file(
self.sqlite_file.path, self.user.id
)
self.record_log(scrobbles)
self.mark_finished()
class AudioScrobblerTSVImport(BaseFileImportMixin):
class Meta:
verbose_name = "AudioScrobbler TSV Import"
def get_path(instance, filename):
extension = filename.split('.')[-1]
uuid = instance.uuid
return f'audioscrobbler-uploads/{uuid}.{extension}'
tsv_file = models.FileField(upload_to=get_path, **BNULL)
def process(self, force=False):
from scrobbles.tsv import process_audioscrobbler_tsv_file
if self.processed_finished and not force:
logger.info(
f"{self} already processed on {self.processed_finished}"
)
return
self.mark_started()
tz = None
if self.user:
tz = self.user.profile.tzinfo
scrobbles = process_audioscrobbler_tsv_file(
self.tsv_file.path, self.user.id, user_tz=tz
)
self.record_log(scrobbles)
self.mark_finished()
class LastFmImport(BaseFileImportMixin):
class Meta:
verbose_name = "Last.FM Import"
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.mark_started()
scrobbles = lastfm.import_from_lastfm(last_processed)
self.record_log(scrobbles)
self.mark_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
@ -95,6 +269,8 @@ class ChartRecord(TimeStampedModel):
class Scrobble(TimeStampedModel):
"""A scrobble tracks played media items by a user."""
uuid = models.UUIDField(editable=False, **BNULL)
video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL)
track = models.ForeignKey(Track, on_delete=models.DO_NOTHING, **BNULL)
@ -104,19 +280,29 @@ class Scrobble(TimeStampedModel):
sport_event = models.ForeignKey(
SportEvent, on_delete=models.DO_NOTHING, **BNULL
)
book = models.ForeignKey(Book, on_delete=models.DO_NOTHING, **BNULL)
user = models.ForeignKey(
User, blank=True, null=True, on_delete=models.DO_NOTHING
)
# Time keeping
timestamp = models.DateTimeField(**BNULL)
playback_position_ticks = models.PositiveBigIntegerField(**BNULL)
playback_position = models.CharField(max_length=8, **BNULL)
# Status indicators
is_paused = models.BooleanField(default=False)
played_to_completion = models.BooleanField(default=False)
in_progress = models.BooleanField(default=True)
# Metadata
source = models.CharField(max_length=255, **BNULL)
source_id = models.TextField(**BNULL)
in_progress = models.BooleanField(default=True)
scrobble_log = models.TextField(**BNULL)
# Fields for keeping track of reads between scrobbles
book_pages_read = models.IntegerField(**BNULL)
def save(self, *args, **kwargs):
if not self.uuid:
self.uuid = uuid4()
@ -133,24 +319,47 @@ class Scrobble(TimeStampedModel):
return 'in-progress'
return 'zombie'
@property
def is_stale(self) -> bool:
"""Mark scrobble as stale if it's been more than an hour since it was updated"""
is_stale = False
now = timezone.now()
seconds_since_last_update = (now - self.modified).seconds
if seconds_since_last_update >= self.media_obj.SECONDS_TO_STALE:
is_stale = True
return is_stale
@property
def percent_played(self) -> int:
if not self.media_obj.run_time_ticks:
logger.warning(
f"{self} has no run_time_ticks value, cannot show percent played"
)
if not self.media_obj:
return 0
if self.media_obj and not self.media_obj.run_time_ticks:
return 100
if not self.playback_position_ticks and self.played_to_completion:
return 100
playback_ticks = self.playback_position_ticks
if not playback_ticks:
playback_ticks = (timezone.now() - self.timestamp).seconds * 1000
if self.played_to_completion:
playback_ticks = self.media_obj.run_time_ticks
percent = int((playback_ticks / self.media_obj.run_time_ticks) * 100)
if percent > 100:
percent = 100
return percent
@property
def can_be_updated(self) -> bool:
updatable = True
if self.percent_played > 100:
logger.info(f"No - 100% played - {self.id} - {self.source}")
updatable = False
if self.is_stale:
logger.info(f"No - stale - {self.id} - {self.source}")
updatable = False
return updatable
@property
def media_obj(self):
media_obj = None
@ -162,6 +371,8 @@ class Scrobble(TimeStampedModel):
media_obj = self.podcast_episode
if self.sport_event:
media_obj = self.sport_event
if self.book:
media_obj = self.book
return media_obj
def __str__(self):
@ -169,150 +380,74 @@ class Scrobble(TimeStampedModel):
return f"Scrobble of {self.media_obj} ({timestamp})"
@classmethod
def create_or_update_for_video(
cls, video: "Video", user_id: int, scrobble_data: dict
def create_or_update(
cls, media, user_id: int, scrobble_data: dict
) -> "Scrobble":
scrobble_data['video_id'] = video.id
if media.__class__.__name__ == 'Track':
media_query = models.Q(track=media)
scrobble_data['track_id'] = media.id
if media.__class__.__name__ == 'Video':
media_query = models.Q(video=media)
scrobble_data['video_id'] = media.id
if media.__class__.__name__ == 'Episode':
media_query = models.Q(podcast_episode=media)
scrobble_data['podcast_episode_id'] = media.id
if media.__class__.__name__ == 'SportEvent':
media_query = models.Q(sport_event=media)
scrobble_data['sport_event_id'] = media.id
if media.__class__.__name__ == 'Book':
media_query = models.Q(book=media)
scrobble_data['book_id'] = media.id
scrobble = (
cls.objects.filter(
video=video,
media_query,
user_id=user_id,
)
.order_by('-modified')
.first()
)
if scrobble and scrobble.percent_played <= 100:
if scrobble and scrobble.can_be_updated:
logger.info(
f"Found existing scrobble for video {video}, updating",
{"scrobble_data": scrobble_data},
f"Updating {scrobble.id}",
{"scrobble_data": scrobble_data, "media": media},
)
return cls.update(scrobble, scrobble_data)
return scrobble.update(scrobble_data)
logger.debug(
f"No existing scrobble for video {video}, creating",
{"scrobble_data": scrobble_data},
)
# If creating a new scrobble, we don't need status
scrobble_data.pop('jellyfin_status')
return cls.create(scrobble_data)
@classmethod
def create_or_update_for_track(
cls, track: "Track", user_id: int, scrobble_data: dict
) -> "Scrobble":
"""Look up any existing scrobbles for a track and compare
the appropriate backoff time for music tracks to the setting
so we can avoid duplicating scrobbles."""
scrobble_data['track_id'] = track.id
scrobble = (
cls.objects.filter(
track=track,
user_id=user_id,
)
.order_by('-modified')
.first()
)
if scrobble and scrobble.percent_played <= 100:
logger.debug(
f"Found existing scrobble for track {track}, updating",
{"scrobble_data": scrobble_data},
)
return cls.update(scrobble, scrobble_data)
logger.debug(
f"No existing scrobble for track {track}, creating",
{"scrobble_data": scrobble_data},
source = scrobble_data['source']
logger.info(
f"Creating for {media.id} - {source}",
{"scrobble_data": scrobble_data, "media": media},
)
# If creating a new scrobble, we don't need status
scrobble_data.pop('mopidy_status', None)
scrobble_data.pop('jellyfin_status', None)
return cls.create(scrobble_data)
@classmethod
def create_or_update_for_podcast_episode(
cls, episode: "Episode", user_id: int, scrobble_data: dict
) -> "Scrobble":
scrobble_data['podcast_episode_id'] = episode.id
scrobble = (
cls.objects.filter(
podcast_episode=episode,
user_id=user_id,
played_to_completion=False,
)
.order_by('-modified')
.first()
)
if scrobble:
logger.debug(
f"Found existing scrobble for podcast {episode}, updating",
{"scrobble_data": scrobble_data},
)
return cls.update(scrobble, scrobble_data)
logger.debug(
f"No existing scrobble for podcast epsiode {episode}, creating",
{"scrobble_data": scrobble_data},
)
# If creating a new scrobble, we don't need status
scrobble_data.pop('mopidy_status')
return cls.create(scrobble_data)
@classmethod
def create_or_update_for_sport_event(
cls, event: "SportEvent", user_id: int, scrobble_data: dict
) -> "Scrobble":
scrobble_data['sport_event_id'] = event.id
scrobble = (
cls.objects.filter(
sport_event=event,
user_id=user_id,
played_to_completion=False,
)
.order_by('-modified')
.first()
)
if scrobble:
logger.debug(
f"Found existing scrobble for sport event {event}, updating",
{"scrobble_data": scrobble_data},
)
return cls.update(scrobble, scrobble_data)
logger.debug(
f"No existing scrobble for sport event {event}, creating",
{"scrobble_data": scrobble_data},
)
# If creating a new scrobble, we don't need status
scrobble_data.pop('jellyfin_status')
return cls.create(scrobble_data)
@classmethod
def update(cls, scrobble: "Scrobble", scrobble_data: dict) -> "Scrobble":
def update(self, scrobble_data: dict) -> "Scrobble":
# Status is a field we get from Mopidy, which refuses to poll us
scrobble_status = scrobble_data.pop('mopidy_status', None)
if not scrobble_status:
scrobble_status = scrobble_data.pop('jellyfin_status', None)
logger.debug(f"Scrobbling to {scrobble} with status {scrobble_status}")
scrobble.update_ticks(scrobble_data)
if self.percent_played < 100:
# Only worry about ticks if we haven't gotten to the end
self.update_ticks(scrobble_data)
# On stop, stop progress and send it to the check for completion
if scrobble_status == "stopped":
scrobble.stop()
self.stop()
# On pause, set is_paused and stop scrobbling
if scrobble_status == "paused":
scrobble.pause()
self.pause()
if scrobble_status == "resumed":
scrobble.resume()
self.resume()
for key, value in scrobble_data.items():
setattr(scrobble, key, value)
scrobble.save()
check_scrobble_for_finish(scrobble)
return scrobble
setattr(self, key, value)
self.save()
return self
@classmethod
def create(
@ -327,24 +462,26 @@ class Scrobble(TimeStampedModel):
def stop(self, force_finish=False) -> None:
if not self.in_progress:
logger.warning("Scrobble already stopped")
return
self.in_progress = False
self.save(update_fields=['in_progress'])
logger.info(f"{self.id} - {self.source}")
check_scrobble_for_finish(self, force_finish)
def pause(self) -> None:
if self.is_paused:
logger.warning("Scrobble already paused")
logger.warning(f"{self.id} - already paused - {self.source}")
return
self.is_paused = True
self.save(update_fields=["is_paused"])
logger.info(f"{self.id} - pausing - {self.source}")
check_scrobble_for_finish(self)
def resume(self) -> None:
if self.is_paused or not self.in_progress:
self.is_paused = False
self.in_progress = True
logger.info(f"{self.id} - resuming - {self.source}")
return self.save(update_fields=["is_paused", "in_progress"])
def cancel(self) -> None:
@ -354,8 +491,8 @@ class Scrobble(TimeStampedModel):
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}"
logger.info(
f"{self.id} - {self.playback_position_ticks} - {self.source}"
)
self.save(
update_fields=['playback_position_ticks', 'playback_position']

View File

@ -0,0 +1,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

View File

@ -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__)
@ -50,32 +55,30 @@ def mopidy_scrobble_podcast(
scrobble = None
if episode:
scrobble = Scrobble.create_or_update_for_podcast_episode(
episode, user_id, mopidy_data
)
scrobble = Scrobble.create_or_update(episode, user_id, mopidy_data)
return scrobble
def mopidy_scrobble_track(
data_dict: dict, user_id: Optional[int]
) -> Optional[Scrobble]:
artist_dict = {
"name": data_dict.get("artist", None),
"musicbrainz_id": data_dict.get("musicbrainz_artist_id", None),
}
album_dict = {
"name": data_dict.get("album"),
"musicbrainz_id": data_dict.get("musicbrainz_album_id"),
}
track_dict = {
"title": data_dict.get("name"),
"run_time_ticks": data_dict.get("run_time_ticks"),
"run_time": data_dict.get("run_time"),
}
track = Track.find_or_create(artist_dict, album_dict, track_dict)
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 = {
@ -90,35 +93,27 @@ def mopidy_scrobble_track(
track.musicbrainz_id = data_dict.get("musicbrainz_track_id")
track.save()
scrobble = Scrobble.create_or_update_for_track(track, user_id, mopidy_data)
scrobble = Scrobble.create_or_update(track, user_id, mopidy_data)
return scrobble
def create_jellyfin_scrobble_dict(data_dict: dict, user_id: int) -> dict:
def build_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"):
elif data_dict.get("NotificationType") == 'PlaybackStop':
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)
playback_ticks = data_dict.get("PlaybackPositionTicks", "")
if playback_ticks:
playback_ticks = playback_ticks // 10000
return {
"user_id": user_id,
"timestamp": parse(data_dict.get("UtcTimestamp")),
"playback_position_ticks": playback_position_ticks,
"playback_position": playback_position,
"playback_position_ticks": playback_ticks,
"playback_position": data_dict.get("PlaybackPosition", ""),
"source": data_dict.get("ClientName", "Vrobbler"),
"source_id": data_dict.get('MediaSourceId'),
"jellyfin_status": jellyfin_status,
@ -128,48 +123,49 @@ def create_jellyfin_scrobble_dict(data_dict: dict, user_id: int) -> dict:
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"
)
null_position_on_progress = (
data_dict.get("PlaybackPosition") == "00:00:00"
and data_dict.get("NotificationType") == "PlaybackProgress"
)
# Jellyfin has some race conditions with it's webhooks, these hacks fix some of them
if not data_dict.get("PlaybackPositionTicks") or null_position_on_progress:
logger.error("No playback position tick from Jellyfin, aborting")
return
artist_dict = {
'name': data_dict.get(JELLYFIN_POST_KEYS["ARTIST_NAME"], None),
'musicbrainz_id': data_dict.get(
JELLYFIN_POST_KEYS["ARTIST_MB_ID"], None
),
}
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']),
}
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,
)
# 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)
scrobble_dict = build_scrobble_dict(data_dict, user_id)
# 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()
# A hack to make Jellyfin work more like Mopidy for music tracks
scrobble_dict["playback_position_ticks"] = 0
scrobble_dict["playback_position"] = ""
scrobble_dict = create_jellyfin_scrobble_dict(data_dict, user_id)
return Scrobble.create_or_update_for_track(track, user_id, scrobble_dict)
return Scrobble.create_or_update(track, user_id, scrobble_dict)
def jellyfin_scrobble_video(data_dict: dict, user_id: Optional[int]):
@ -180,9 +176,9 @@ def jellyfin_scrobble_video(data_dict: dict, user_id: Optional[int]):
return
video = Video.find_or_create(data_dict)
scrobble_dict = create_jellyfin_scrobble_dict(data_dict, user_id)
scrobble_dict = build_scrobble_dict(data_dict, user_id)
return Scrobble.create_or_update_for_video(video, user_id, scrobble_dict)
return Scrobble.create_or_update(video, user_id, scrobble_dict)
def manual_scrobble_video(data_dict: dict, user_id: Optional[int]):
@ -193,9 +189,9 @@ def manual_scrobble_video(data_dict: dict, user_id: Optional[int]):
return
video = Video.find_or_create(data_dict)
scrobble_dict = create_jellyfin_scrobble_dict(data_dict, user_id)
scrobble_dict = build_scrobble_dict(data_dict, user_id)
return Scrobble.create_or_update_for_video(video, user_id, scrobble_dict)
return Scrobble.create_or_update(video, user_id, scrobble_dict)
def manual_scrobble_event(data_dict: dict, user_id: Optional[int]):
@ -206,8 +202,6 @@ def manual_scrobble_event(data_dict: dict, user_id: Optional[int]):
return
event = SportEvent.find_or_create(data_dict)
scrobble_dict = create_jellyfin_scrobble_dict(data_dict, user_id)
scrobble_dict = build_scrobble_dict(data_dict, user_id)
return Scrobble.create_or_update_for_sport_event(
event, user_id, scrobble_dict
)
return Scrobble.create_or_update(event, user_id, scrobble_dict)

View File

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

View File

@ -0,0 +1,37 @@
import logging
from celery import shared_task
from scrobbles.models import (
AudioScrobblerTSVImport,
KoReaderImport,
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()
@shared_task
def process_tsv_import(import_id):
tsv_import = AudioScrobblerTSVImport.objects.filter(id=import_id).first()
if not tsv_import:
logger.warn(f"AudioScrobblerTSVImport not found with id {import_id}")
tsv_import.process()
@shared_task
def process_koreader_import(import_id):
koreader_import = KoReaderImport.objects.filter(id=import_id).first()
if not koreader_import:
logger.warn(f"KOReaderImport not found with id {import_id}")
koreader_import.process()

View File

@ -19,9 +19,10 @@ def lookup_event_from_thesportsdb(event_id: str) -> dict:
return {}
league = {} # client.lookup_league(league_id=event.get('idLeague'))
event_type = "Game"
sport = Sport.objects.filter(thesportsdb_id=event.get('strSport')).first()
sport, _created = Sport.objects.get_or_create(
thesportsdb_id=event.get('strSport')
)
logger.debug(event)
data_dict = {
"ItemType": sport.default_event_type,
"Name": event.get('strEvent'),

View File

@ -0,0 +1,80 @@
import csv
import logging
from datetime import datetime
import pytz
from scrobbles.models import Scrobble
from music.utils import (
get_or_create_album,
get_or_create_artist,
get_or_create_track,
)
logger = logging.getLogger(__name__)
def process_audioscrobbler_tsv_file(file_path, user_id, user_tz=None):
"""Takes a path to a file of TSV data and imports it as past scrobbles"""
new_scrobbles = []
if not user_tz:
user_tz = pytz.utc
with open(file_path) as infile:
source = 'Audioscrobbler File'
rows = csv.reader(infile, delimiter="\t")
source_id = ""
for row_num, row in enumerate(rows):
if row_num in [0, 1, 2]:
if "Rockbox" in row[0]:
source = "Rockbox"
source_id += row[0] + "\n"
continue
if len(row) > 8:
logger.warning(
'Improper row length during Audioscrobbler import',
extra={'row': row},
)
continue
artist = get_or_create_artist(row[0])
album = get_or_create_album(row[1], artist)
track = get_or_create_track(
title=row[2],
mbid=row[7],
artist=artist,
album=album,
run_time=row[4],
run_time_ticks=int(row[4]) * 1000,
)
timestamp = (
datetime.utcfromtimestamp(int(row[6]))
.replace(tzinfo=user_tz)
.astimezone(pytz.utc)
)
new_scrobble = Scrobble(
user_id=user_id,
timestamp=timestamp,
source=source,
source_id=source_id,
track=track,
played_to_completion=True,
in_progress=False,
)
existing = Scrobble.objects.filter(
timestamp=timestamp, track=track
).first()
if existing:
logger.debug(f"Skipping existing scrobble {new_scrobble}")
continue
logger.debug(f"Queued scrobble {new_scrobble} for creation")
new_scrobbles.append(new_scrobble)
created = Scrobble.objects.bulk_create(new_scrobbles)
logger.info(
f"Created {len(created)} scrobbles",
extra={'created_scrobbles': created},
)
return created

View File

@ -7,6 +7,13 @@ urlpatterns = [
path('', views.scrobble_endpoint, name='api-list'),
path('finish/<slug:uuid>', views.scrobble_finish, name='finish'),
path('cancel/<slug:uuid>', views.scrobble_cancel, name='cancel'),
path(
'upload/',
views.AudioScrobblerImportCreateView.as_view(),
name='audioscrobbler-file-upload',
),
path('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'),
]

View File

@ -67,18 +67,36 @@ def parse_mopidy_uri(uri: str) -> dict:
def check_scrobble_for_finish(
scrobble: "Scrobble", force_finish=False
scrobble: "Scrobble", force_to_100=False, force_finish=False
) -> None:
completion_percent = scrobble.media_obj.COMPLETION_PERCENT
if scrobble.percent_played >= completion_percent or force_finish:
logger.debug(f"Completion percent {completion_percent} met, finishing")
logger.info(f"{scrobble.id} {completion_percent} met, finishing")
if (
scrobble.playback_position_ticks
and scrobble.media_obj.run_time_ticks
and force_to_100
):
scrobble.playback_position_ticks = (
scrobble.media_obj.run_time_ticks
)
logger.info(
f"{scrobble.playback_position_ticks} set to {scrobble.media_obj.run_time_ticks}"
)
scrobble.in_progress = False
scrobble.is_paused = False
scrobble.played_to_completion = True
scrobble.save(
update_fields=["in_progress", "is_paused", "played_to_completion"]
update_fields=[
"in_progress",
"is_paused",
"played_to_completion",
'playback_position_ticks',
]
)
if scrobble.percent_played % 5 == 0:

View File

@ -1,26 +1,46 @@
import json
import logging
from datetime import datetime
import pytz
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models.fields import timezone
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.http import FileResponse, HttpResponseRedirect, JsonResponse
from django.urls import reverse, reverse_lazy
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import FormView
from django.views.generic.edit import CreateView
from django.views.generic.list import ListView
from music.aggregators import (
scrobble_counts,
top_artists,
top_tracks,
week_of_scrobbles,
)
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.decorators import (
api_view,
parser_classes,
permission_classes,
)
from rest_framework.parsers import MultiPartParser
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from scrobbles.constants import (
JELLYFIN_AUDIO_ITEM_TYPES,
JELLYFIN_VIDEO_ITEM_TYPES,
)
from scrobbles.forms import ScrobbleForm
from scrobbles.export import export_scrobbles
from scrobbles.forms import ExportScrobbleForm, ScrobbleForm
from scrobbles.imdb import lookup_video_from_imdb
from scrobbles.models import Scrobble
from scrobbles.models import (
AudioScrobblerTSVImport,
KoReaderImport,
LastFmImport,
Scrobble,
)
from scrobbles.scrobblers import (
jellyfin_scrobble_track,
jellyfin_scrobble_video,
@ -29,15 +49,16 @@ from scrobbles.scrobblers import (
mopidy_scrobble_podcast,
mopidy_scrobble_track,
)
from scrobbles.serializers import ScrobbleSerializer
from scrobbles.thesportsdb import lookup_event_from_thesportsdb
from vrobbler.apps.music.aggregators import (
scrobble_counts,
top_artists,
top_tracks,
week_of_scrobbles,
from scrobbles.serializers import (
AudioScrobblerTSVImportSerializer,
ScrobbleSerializer,
)
from scrobbles.tasks import (
process_koreader_import,
process_lastfm_import,
process_tsv_import,
)
from scrobbles.thesportsdb import lookup_event_from_thesportsdb
logger = logging.getLogger(__name__)
@ -87,6 +108,7 @@ class RecentScrobbleList(ListView):
data['counts'] = scrobble_counts(user)
data['imdb_form'] = ScrobbleForm
data['export_form'] = ExportScrobbleForm
return data
def get_queryset(self):
@ -118,7 +140,79 @@ class ManualScrobbleView(FormView):
if data_dict:
manual_scrobble_event(data_dict, self.request.user.id)
return HttpResponseRedirect(reverse("home"))
return HttpResponseRedirect(reverse("vrobbler-home"))
class JsonableResponseMixin:
"""
Mixin to add JSON support to a form.
Must be used with an object-based FormView (e.g. CreateView)
"""
def form_invalid(self, form):
response = super().form_invalid(form)
if self.request.accepts('text/html'):
return response
else:
return JsonResponse(form.errors, status=400)
def form_valid(self, form):
# We make sure to call the parent's form_valid() method because
# it might do some processing (in the case of CreateView, it will
# call form.save() for example).
response = super().form_valid(form)
if self.request.accepts('text/html'):
return response
else:
data = {
'pk': self.object.pk,
}
return JsonResponse(data)
class AudioScrobblerImportCreateView(
LoginRequiredMixin, JsonableResponseMixin, CreateView
):
model = AudioScrobblerTSVImport
fields = ['tsv_file']
template_name = 'scrobbles/upload_form.html'
success_url = reverse_lazy('vrobbler-home')
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.user = self.request.user
self.object.save()
process_tsv_import.delay(self.object.id)
return HttpResponseRedirect(self.get_success_url())
class KoReaderImportCreateView(
LoginRequiredMixin, JsonableResponseMixin, CreateView
):
model = KoReaderImport
fields = ['sqlite_file']
template_name = 'scrobbles/upload_form.html'
success_url = reverse_lazy('vrobbler-home')
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.user = self.request.user
self.object.save()
process_koreader_import.delay(self.object.id)
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
@ -136,6 +230,12 @@ def scrobble_endpoint(request):
def jellyfin_websocket(request):
data_dict = request.data
if (
data_dict['NotificationType'] == 'PlaybackProgress'
and data_dict['ItemType'] == 'Audio'
):
return Response({}, status=status.HTTP_304_NOT_MODIFIED)
# For making things easier to build new input processors
if getattr(settings, "DUMP_REQUEST_DATA", False):
json_data = json.dumps(data_dict, indent=4)
@ -182,6 +282,29 @@ def mopidy_websocket(request):
return Response({'scrobble_id': scrobble.id}, status=status.HTTP_200_OK)
@csrf_exempt
@permission_classes([IsAuthenticated])
@api_view(['POST'])
@parser_classes([MultiPartParser])
def import_audioscrobbler_file(request):
"""Takes a TSV file in the Audioscrobbler format, saves it and processes the
scrobbles.
"""
scrobbles_created = []
# tsv_file = request.FILES[0]
file_serializer = AudioScrobblerTSVImportSerializer(data=request.data)
if file_serializer.is_valid():
import_file = file_serializer.save()
return Response(
{'scrobble_ids': scrobbles_created}, status=status.HTTP_200_OK
)
else:
return Response(
file_serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
@csrf_exempt
@permission_classes([IsAuthenticated])
@api_view(['GET'])
@ -215,3 +338,23 @@ def scrobble_cancel(request, uuid):
return Response(
{'id': scrobble.id, 'status': 'cancelled'}, status=status.HTTP_200_OK
)
@permission_classes([IsAuthenticated])
@api_view(['GET'])
def export(request):
format = request.GET.get('export_type', 'csv')
start = request.GET.get('start')
end = request.GET.get('end')
logger.debug(f"Exporting all scrobbles in format {format}")
temp_file, extension = export_scrobbles(
start_date=start, end_date=end, format=format
)
now = datetime.now()
filename = f"vrobbler-export-{str(now)}.{extension}"
response = FileResponse(open(temp_file, 'rb'))
response["Content-Disposition"] = f'attachment; filename="{filename}"'
return response

View File

@ -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}
@ -49,7 +45,12 @@ class Sport(TheSportsDbMixin):
# run_time_ticks = run_time_seconds * 1000
@property
def default_event_run_time_ticks(self):
return self.default_event_run_time * 1000
default_run_time = getattr(
settings, 'DEFAULT_EVENT_RUNTIME_SECONDS', 14400
)
if self.default_event_run_time:
default_run_time = self.default_event_run_time
return default_run_time * 1000
class League(TheSportsDbMixin):

View File

@ -34,6 +34,7 @@ class Series(TimeStampedModel):
class Video(ScrobblableMixin):
COMPLETION_PERCENT = getattr(settings, 'VIDEO_COMPLETION_PERCENT', 90)
SECONDS_TO_STALE = getattr(settings, 'VIDEO_SECONDS_TO_STALE', 14400)
class VideoType(models.TextChoices):
UNKNOWN = 'U', _('Unknown')
@ -91,10 +92,6 @@ class Video(ScrobblableMixin):
series, series_created = Series.objects.get_or_create(
name=series_name
)
if series_created:
logger.debug(f"Created new series {series}")
else:
logger.debug(f"Found series {series}")
video_dict['video_type'] = Video.VideoType.TV_EPISODE
video, created = cls.objects.get_or_create(**video_dict)
@ -119,11 +116,8 @@ class Video(ScrobblableMixin):
video_extra_dict["tv_series_id"] = series.id
if not video.run_time_ticks:
logger.debug(f"Created new video: {video}")
for key, value in video_extra_dict.items():
setattr(video, key, value)
video.save()
else:
logger.debug(f"Found video {video}")
return video

13
vrobbler/celery.py Normal file
View 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}")

View File

@ -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,14 +89,15 @@ INSTALLED_APPS = [
"django.contrib.humanize",
"django_filters",
"django_extensions",
'rest_framework.authtoken',
"cachalot",
"rest_framework.authtoken",
"encrypted_field",
"profiles",
"scrobbles",
"videos",
"music",
"podcasts",
"sports",
"books",
"mathfilters",
"rest_framework",
"allauth",
@ -286,9 +301,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,
},
},
}

View File

@ -271,7 +271,6 @@
{% endblock %}
</div>
</div>
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/feather-icons@4.28.0/dist/feather.min.js" integrity="sha384-uO3SXW5IuS1ZpFPKugNNWqTZRRglnUJK6UAZ/gxOX80nxEkN9NcGZTftn6RzhGWE" crossorigin="anonymous"></script><script src="https://cdn.jsdelivr.net/npm/chart.js@2.9.4/dist/Chart.min.js" integrity="sha384-zNy6FEbO50N+Cg5wap8IKA4M/ZnLJgzc6w2NqACZaK0u0FXfOWRRJOnQtpZun8ha" crossorigin="anonymous"></script>
<script><!-- comment ------------------------------------------------->
/* globals Chart:false, feather:false */
@ -320,6 +319,7 @@
})()
</script>
{% block extra_js %}
{% endblock %}
</body>
</html>

View File

@ -4,22 +4,33 @@
{% 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">Share</button>
<button type="button" class="btn btn-sm btn-outline-secondary">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-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>
<div class="dropdown-menu" aria-labelledby="graphDateButton">
<a class="dropdown-item" href="#">Action</a>
<a class="dropdown-item" href="#">Another action</a>
<a class="dropdown-item" href="#">Something else here</a>
<div class="dropdown-menu" data-bs-toggle="#graphDataChange" aria-labelledby="graphDateButton">
<a class="dropdown-item" href="#">This month</a>
<a class="dropdown-item" href="#">This year</a>
</div>
</div>
</div>
@ -31,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>
@ -77,195 +98,282 @@
<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>
<td><img src="{{scrobble.track.album.cover_image.url}}" width=50 height=50 style="border:1px solid black;" /></td>
<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>
{% else %}
{% endif %}
</div>
</main>
<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">&times;</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>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">Import</button>
</div>
</form>
<form action="{% url 'koreader-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">KOReader sqlite file:</label>
<input type="file" name="sqlite_file" class="form-control" id="id_sqlite_file">
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">Import</button>
</div>
</form>
</div>
</div>
</div>
<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">&times;</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>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">Export</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
$('#importModal').on('shown.bs.modal', function () { $('#importInput').trigger('focus') });
$('#exportModal').on('shown.bs.modal', function () { $('#exportInput').trigger('focus') });
</script>
{% endblock %}

View File

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

View File

@ -19,9 +19,21 @@ urlpatterns = [
scrobbles_views.ManualScrobbleView.as_view(),
name='imdb-manual-scrobble',
),
path(
'manual/audioscrobbler/',
scrobbles_views.AudioScrobblerImportCreateView.as_view(),
name='audioscrobbler-file-upload',
),
path(
'manual/koreader/',
scrobbles_views.KoReaderImportCreateView.as_view(),
name='koreader-file-upload',
),
path("", include(music_urls, namespace="music")),
path("", include(video_urls, namespace="videos")),
path("", scrobbles_views.RecentScrobbleList.as_view(), name="home"),
path(
"", scrobbles_views.RecentScrobbleList.as_view(), name="vrobbler-home"
),
]
if settings.DEBUG: