Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c2ba8a48ac | |||
| 1530de3188 | |||
| 2d235c0577 | |||
| b0eb58953b | |||
| 7309181fed | |||
| 971fee5b4b | |||
| 920a9180c8 | |||
| d568a377f0 | |||
| 3851624dd7 | |||
| 8c865fe008 | |||
| 572dbf7a88 | |||
| 7addd50577 |
39
PROJECT.org
39
PROJECT.org
@ -92,7 +92,7 @@ fetching and simple saving.
|
||||
:LOGBOOK:
|
||||
CLOCK: [2025-07-09 Wed 09:55]--[2025-07-09 Wed 10:15] => 0:20
|
||||
:END:
|
||||
* Backlog [2/25]
|
||||
* Backlog [0/19]
|
||||
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :vrobbler:personal:bug:music:scrobbles:
|
||||
** TODO [#C] Move to using more robust mopidy-webhooks pacakge form pypi :utility:improvement:
|
||||
:PROPERTIES:
|
||||
@ -421,7 +421,12 @@ https://app.todoist.com/app/task/add-a-csv-endpoint-for-users-book-reads-that-li
|
||||
|
||||
As an example https://comicbookroundup.com/comic-books/reviews/humanoids-publishing/the-history-of-science-fiction
|
||||
** TODO [#B] Add PuzzleLogData class with with_people and completed :vrobbler:feature:puzzles:logdata:personal:project:
|
||||
** TODO [#A] Tasks from org-mode should properly update notes and leave them out of the body :vrobbler:bug:tasks:
|
||||
** TODO [#A] Fix raw text webpage title not truncating to 254 chars :vrobbler:personal:bug:webpages:
|
||||
|
||||
- Note taken on [2025-09-30 Tue 09:33]
|
||||
|
||||
This may have already been resolved ... need to just confirm it.
|
||||
** TODO [#A] Find page numbers for comic books from ComicVine :vrobbler:feature:books:personal:project:
|
||||
** TODO [#A] Fix koreader scrobble imports to use DST properly :vrobbler:personal:bug:books:imports:
|
||||
|
||||
- Note taken on [2025-09-25 Thu 10:37] \\
|
||||
@ -436,15 +441,29 @@ https://app.todoist.com/app/task/add-a-csv-endpoint-for-users-book-reads-that-li
|
||||
an hour for part of the year. Also, we'd need to adjust any old scrobbles that
|
||||
took place with DST off to roll them back by an hour.
|
||||
|
||||
** TODO [#A] Allow scrobbling from the Food list page's start links :vrobbler:bug:food:scrobbling:personal:project:
|
||||
* Version 37.0 [4/4]
|
||||
** DONE [#A] Tasks from org-mode should properly update notes and leave them out of the body :vrobbler:bug:tasks:
|
||||
:PROPERTIES:
|
||||
:ID: c8410001-dbb7-1536-bd89-9784189e058f
|
||||
:END:
|
||||
** DONE [#A] Allow scrobbling from the Food list page's start links :vrobbler:bug:food:scrobbling:personal:project:
|
||||
:PROPERTIES:
|
||||
:ID: 00f99f60-ac00-6cde-311d-c31f41a01353
|
||||
:END:
|
||||
https://life.lab.unbl.ink/scrobble/e39779c8-62a5-46a6-bdef-fb7662810dc6/start/
|
||||
** TODO [#A] Puzzles (and all longplays) should have a "Completed?" column on their detail page :vrobbler:bug:puzzles:personal:project:
|
||||
** TODO [#A] Fix raw text webpage title not truncating to 254 chars :vrobbler:personal:bug:webpages:
|
||||
|
||||
- Note taken on [2025-09-30 Tue 09:33]
|
||||
|
||||
This may have already been resolved ... need to just confirm it.
|
||||
** TODO [#A] Find page numbers for comic books from ComicVine :vrobbler:feature:books:personal:project:
|
||||
** DONE [#B] Food scrobbles should inherit calories from obj if missing :vrobbler:feature:food:personal:project:
|
||||
:PROPERTIES:
|
||||
:ID: 3322ff69-4252-db65-36b3-fae56c1b9327
|
||||
:END:
|
||||
** DONE [#A] Puzzles (and all longplays) should have a "Completed?" column on their detail page :vrobbler:bug:puzzles:personal:project:
|
||||
:PROPERTIES:
|
||||
:ID: e3e49a9a-67d2-8ad8-1114-6f05effee9b7
|
||||
:END:
|
||||
* Version 36.0 [1/1]
|
||||
** DONE [#A] Refactor how videos are scrobbled :vrobbler:vidoes:feature:personal:project:
|
||||
:PROPERTIES:
|
||||
:ID: 6034a11d-5376-994d-9a4b-e1640e258cfa
|
||||
:END:
|
||||
* Version 35.0 [3/3]
|
||||
** DONE [#B] Add youtube link in place of IMDB on video detail page :vrobbler:feature:videos:personal:project:
|
||||
:PROPERTIES:
|
||||
|
||||
81
poetry.lock
generated
81
poetry.lock
generated
@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "aiohappyeyeballs"
|
||||
@ -249,19 +249,19 @@ tests = ["mypy (>=1.14.0)", "pytest", "pytest-asyncio"]
|
||||
|
||||
[[package]]
|
||||
name = "asttokens"
|
||||
version = "3.0.0"
|
||||
version = "3.0.1"
|
||||
description = "Annotate AST trees with source code positions"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2"},
|
||||
{file = "asttokens-3.0.0.tar.gz", hash = "sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7"},
|
||||
{file = "asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a"},
|
||||
{file = "asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
astroid = ["astroid (>=2,<4)"]
|
||||
test = ["astroid (>=2,<4)", "pytest", "pytest-cov", "pytest-xdist"]
|
||||
astroid = ["astroid (>=2,<5)"]
|
||||
test = ["astroid (>=2,<5)", "pytest (<9.0)", "pytest-cov", "pytest-xdist"]
|
||||
|
||||
[[package]]
|
||||
name = "async-timeout"
|
||||
@ -322,14 +322,14 @@ testing = ["jaraco.test", "pytest (!=8.0.*)", "pytest (>=6,!=8.1.*)", "pytest-ch
|
||||
|
||||
[[package]]
|
||||
name = "bandit"
|
||||
version = "1.8.6"
|
||||
version = "1.9.1"
|
||||
description = "Security oriented static analyser for python code."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
python-versions = ">=3.10"
|
||||
groups = ["test"]
|
||||
files = [
|
||||
{file = "bandit-1.8.6-py3-none-any.whl", hash = "sha256:3348e934d736fcdb68b6aa4030487097e23a501adf3e7827b63658df464dddd0"},
|
||||
{file = "bandit-1.8.6.tar.gz", hash = "sha256:dbfe9c25fc6961c2078593de55fd19f2559f9e45b99f1272341f5b95dea4e56b"},
|
||||
{file = "bandit-1.9.1-py3-none-any.whl", hash = "sha256:0a1f34c04f067ee28985b7854edaa659c9299bd71e1b7e18236e46cccc79720b"},
|
||||
{file = "bandit-1.9.1.tar.gz", hash = "sha256:6dbafd1a51e276e065404f06980d624bad142344daeac3b085121fcfd117b7cf"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@ -405,14 +405,14 @@ requests-cache = ">=1.1.1,<2.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "billiard"
|
||||
version = "4.2.2"
|
||||
version = "4.2.3"
|
||||
description = "Python multiprocessing fork with improvements and bugfixes"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "billiard-4.2.2-py3-none-any.whl", hash = "sha256:4bc05dcf0d1cc6addef470723aac2a6232f3c7ed7475b0b580473a9145829457"},
|
||||
{file = "billiard-4.2.2.tar.gz", hash = "sha256:e815017a062b714958463e07ba15981d802dc53d41c5b69d28c5a7c238f8ecf3"},
|
||||
{file = "billiard-4.2.3-py3-none-any.whl", hash = "sha256:989e9b688e3abf153f307b68a1328dfacfb954e30a4f920005654e276c69236b"},
|
||||
{file = "billiard-4.2.3.tar.gz", hash = "sha256:96486f0885afc38219d02d5f0ccd5bec8226a414b834ab244008cbb0025b8dcb"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -470,18 +470,18 @@ css = ["tinycss2 (>=1.1.0,<1.5)"]
|
||||
|
||||
[[package]]
|
||||
name = "boto3"
|
||||
version = "1.40.73"
|
||||
version = "1.40.75"
|
||||
description = "The AWS SDK for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "boto3-1.40.73-py3-none-any.whl", hash = "sha256:85172e11e3b8d5a09504bc532b6589730ac68845410403ca3793d037b8a5d445"},
|
||||
{file = "boto3-1.40.73.tar.gz", hash = "sha256:3716703cb8b126607533853d7e2a85f0bb23b0b9d4805c69170abead33d725ef"},
|
||||
{file = "boto3-1.40.75-py3-none-any.whl", hash = "sha256:c246fb35d9978b285c5b827a20b81c9e77d52f99c9d175fbd91f14396432953f"},
|
||||
{file = "boto3-1.40.75.tar.gz", hash = "sha256:a5219a2f397f8616462d7908e696c281f120aa2d8458280ff24f7ddeb2108faf"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
botocore = ">=1.40.73,<1.41.0"
|
||||
botocore = ">=1.40.75,<1.41.0"
|
||||
jmespath = ">=0.7.1,<2.0.0"
|
||||
s3transfer = ">=0.14.0,<0.15.0"
|
||||
|
||||
@ -490,14 +490,14 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"]
|
||||
|
||||
[[package]]
|
||||
name = "botocore"
|
||||
version = "1.40.73"
|
||||
version = "1.40.75"
|
||||
description = "Low-level, data-driven core of boto 3."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "botocore-1.40.73-py3-none-any.whl", hash = "sha256:87524c5fe552ecceaea72f51163b37ab35eb82aaa6a64eb80489ade7340c1d23"},
|
||||
{file = "botocore-1.40.73.tar.gz", hash = "sha256:0650ceada268824282da9af8615f3e4cf2453be8bf85b820f9207eff958d56d0"},
|
||||
{file = "botocore-1.40.75-py3-none-any.whl", hash = "sha256:e822004688ca8035c518108e27d5b450d3ab0e0b3a73bcb8b87b80a8e5bd1910"},
|
||||
{file = "botocore-1.40.75.tar.gz", hash = "sha256:bf8b067209fee5a9738800d41852e113b8ebdb01bd7f1e8b4541d55ecdbdb8f3"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@ -506,7 +506,7 @@ python-dateutil = ">=2.1,<3.0.0"
|
||||
urllib3 = {version = ">=1.25.4,<2.2.0 || >2.2.0,<3", markers = "python_version >= \"3.10\""}
|
||||
|
||||
[package.extras]
|
||||
crt = ["awscrt (==0.27.6)"]
|
||||
crt = ["awscrt (==0.28.4)"]
|
||||
|
||||
[[package]]
|
||||
name = "build"
|
||||
@ -531,14 +531,14 @@ virtualenv = ["virtualenv (>=20.11) ; python_version < \"3.10\"", "virtualenv (>
|
||||
|
||||
[[package]]
|
||||
name = "cachecontrol"
|
||||
version = "0.14.3"
|
||||
version = "0.14.4"
|
||||
description = "httplib2 caching for requests"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "cachecontrol-0.14.3-py3-none-any.whl", hash = "sha256:b35e44a3113f17d2a31c1e6b27b9de6d4405f84ae51baa8c1d3cc5b633010cae"},
|
||||
{file = "cachecontrol-0.14.3.tar.gz", hash = "sha256:73e7efec4b06b20d9267b441c1f733664f989fb8688391b670ca812d70795d11"},
|
||||
{file = "cachecontrol-0.14.4-py3-none-any.whl", hash = "sha256:b7ac014ff72ee199b5f8af1de29d60239954f223e948196fa3d84adaffc71d2b"},
|
||||
{file = "cachecontrol-0.14.4.tar.gz", hash = "sha256:e6220afafa4c22a47dd0badb319f84475d79108100d04e26e8542ef7d3ab05a1"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@ -547,7 +547,7 @@ msgpack = ">=0.5.2,<2.0.0"
|
||||
requests = ">=2.16.0"
|
||||
|
||||
[package.extras]
|
||||
dev = ["CacheControl[filecache,redis]", "build", "cherrypy", "codespell[tomli]", "furo", "mypy", "pytest", "pytest-cov", "ruff", "sphinx", "sphinx-copybutton", "tox", "types-redis", "types-requests"]
|
||||
dev = ["cachecontrol[filecache,redis]", "cheroot (>=11.1.2)", "cherrypy", "codespell", "furo", "mypy", "pytest", "pytest-cov", "ruff", "sphinx", "sphinx-copybutton", "types-redis", "types-requests"]
|
||||
filecache = ["filelock (>=3.8.0)"]
|
||||
redis = ["redis (>=2.10.5)"]
|
||||
|
||||
@ -906,14 +906,14 @@ rapidfuzz = ">=3.0.0,<4.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.3.0"
|
||||
version = "8.3.1"
|
||||
description = "Composable command line interface toolkit"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main", "test"]
|
||||
files = [
|
||||
{file = "click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc"},
|
||||
{file = "click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4"},
|
||||
{file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"},
|
||||
{file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@ -2326,14 +2326,14 @@ typing-extensions = ">=4.5.0"
|
||||
|
||||
[[package]]
|
||||
name = "keyring"
|
||||
version = "25.6.0"
|
||||
version = "25.7.0"
|
||||
description = "Store and access your passwords safely."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "keyring-25.6.0-py3-none-any.whl", hash = "sha256:552a3f7af126ece7ed5c89753650eec89c7eaae8617d0aa4d9ad2b75111266bd"},
|
||||
{file = "keyring-25.6.0.tar.gz", hash = "sha256:0b39998aa941431eb3d9b0d4b2460bc773b9df6fed7621c2dfb291a7e0187a66"},
|
||||
{file = "keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f"},
|
||||
{file = "keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@ -2350,9 +2350,9 @@ check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"
|
||||
completion = ["shtab (>=1.1.0)"]
|
||||
cover = ["pytest-cov"]
|
||||
doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
|
||||
enabler = ["pytest-enabler (>=2.2)"]
|
||||
enabler = ["pytest-enabler (>=3.4)"]
|
||||
test = ["pyfakefs", "pytest (>=6,!=8.1.*)"]
|
||||
type = ["pygobject-stubs", "pytest-mypy", "shtab", "types-pywin32"]
|
||||
type = ["pygobject-stubs", "pytest-mypy (>=1.0.1)", "shtab", "types-pywin32"]
|
||||
|
||||
[[package]]
|
||||
name = "kombu"
|
||||
@ -4197,6 +4197,13 @@ optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["test"]
|
||||
files = [
|
||||
{file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"},
|
||||
{file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"},
|
||||
{file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"},
|
||||
{file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"},
|
||||
{file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"},
|
||||
{file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"},
|
||||
{file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"},
|
||||
{file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"},
|
||||
{file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"},
|
||||
{file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"},
|
||||
@ -5050,14 +5057,14 @@ test = ["argcomplete (>=3.0.3)", "mypy (>=1.7.0)", "pre-commit", "pytest (>=7.0,
|
||||
|
||||
[[package]]
|
||||
name = "trove-classifiers"
|
||||
version = "2025.9.11.17"
|
||||
version = "2025.11.14.15"
|
||||
description = "Canonical source for classifiers on PyPI (pypi.org)."
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "trove_classifiers-2025.9.11.17-py3-none-any.whl", hash = "sha256:5d392f2d244deb1866556457d6f3516792124a23d1c3a463a2e8668a5d1c15dd"},
|
||||
{file = "trove_classifiers-2025.9.11.17.tar.gz", hash = "sha256:931ca9841a5e9c9408bc2ae67b50d28acf85bef56219b56860876dd1f2d024dd"},
|
||||
{file = "trove_classifiers-2025.11.14.15-py3-none-any.whl", hash = "sha256:d1dac259c1e908939862e3331177931c6df0a37af2c1a8debcc603d9115fcdd9"},
|
||||
{file = "trove_classifiers-2025.11.14.15.tar.gz", hash = "sha256:6b60f49d40bbd895bc61d8dc414fc2f2286d70eb72ed23548db8cf94f62804ca"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
from videos.sources.imdb import lookup_video_from_imdb
|
||||
|
||||
|
||||
def test_lookup_imdb():
|
||||
def test_lookup_imdb_without_tt():
|
||||
metadata = lookup_video_from_imdb("8946378")
|
||||
print(metadata.__dict__)
|
||||
assert not metadata.imdb_id
|
||||
|
||||
def test_lookup_imdb_with_tt():
|
||||
metadata = lookup_video_from_imdb("tt8946378")
|
||||
assert metadata.title == "Knives Out"
|
||||
|
||||
@ -153,8 +153,8 @@ class Book(LongPlayScrobblableMixin):
|
||||
comicvine_id = models.CharField(max_length=255, **BNULL)
|
||||
readcomics_url = models.CharField(max_length=255, **BNULL)
|
||||
next_readcomics_url = models.CharField(max_length=255, **BNULL)
|
||||
issue_number = models.IntegerField(max_length=5, **BNULL)
|
||||
volume_number = models.IntegerField(max_length=5, **BNULL)
|
||||
issue_number = models.IntegerField(**BNULL)
|
||||
volume_number = models.IntegerField(**BNULL)
|
||||
# OpenLibrary
|
||||
openlibrary_id = models.CharField(max_length=255, **BNULL)
|
||||
cover = models.ImageField(upload_to="books/covers/", **BNULL)
|
||||
|
||||
@ -18,7 +18,6 @@ BNULL = {"blank": True, "null": True}
|
||||
class FoodLogData(BaseLogData, WithPeopleLogData):
|
||||
calories: Optional[int] = None
|
||||
meal: Optional[str] = None
|
||||
rating: Optional[str] = None
|
||||
|
||||
|
||||
class FoodCategory(TimeStampedModel):
|
||||
|
||||
@ -11,17 +11,15 @@ from django_extensions.db.models import TimeStampedModel
|
||||
from imagekit.models import ImageSpecField
|
||||
from imagekit.processors import ResizeToFit
|
||||
from puzzles.sources import ipdb
|
||||
from scrobbles.dataclasses import JSONDataclass
|
||||
from scrobbles.dataclasses import BaseLogData, WithPeopleLogData, LongPlayLogData
|
||||
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
||||
|
||||
BNULL = {"blank": True, "null": True}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PuzzleLogData(JSONDataclass):
|
||||
with_people: Optional[int] = None
|
||||
class PuzzleLogData(BaseLogData, WithPeopleLogData, LongPlayLogData):
|
||||
rating: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class PuzzleManufacturer(TimeStampedModel):
|
||||
|
||||
@ -13,7 +13,10 @@ User = get_user_model()
|
||||
|
||||
class ScrobbleLogDataEncoder(json.JSONEncoder):
|
||||
def default(self, o):
|
||||
return o.__dict__
|
||||
try:
|
||||
return o.__dict__
|
||||
except AttributeError:
|
||||
return {}
|
||||
|
||||
|
||||
class ScrobbleLogDataDecoder(json.JSONDecoder):
|
||||
|
||||
@ -77,11 +77,8 @@ def form_from_dataclass(dataclass):
|
||||
override_fields = {}
|
||||
for klass in dataclass.mro():
|
||||
if hasattr(klass, "override_fields"):
|
||||
print(klass, ": ", klass.override_fields())
|
||||
override_fields.update(klass.override_fields())
|
||||
print("overrides: ", override_fields)
|
||||
for f in fields(dataclass):
|
||||
print(f)
|
||||
if f.name in override_fields:
|
||||
form_fields[f.name] = override_fields[f.name]
|
||||
continue
|
||||
|
||||
@ -22,7 +22,7 @@ from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
from foods.models import Food
|
||||
from foods.models import Food, FoodLogData
|
||||
from imagekit.models import ImageSpecField
|
||||
from imagekit.processors import ResizeToFit
|
||||
from lifeevents.models import LifeEvent
|
||||
@ -1210,6 +1210,9 @@ class Scrobble(TimeStampedModel):
|
||||
"source": source,
|
||||
},
|
||||
)
|
||||
if mtype == cls.MediaType.FOOD and not scrobble_data.get("log", {}).get("calories", None):
|
||||
if media.calories:
|
||||
scrobble_data["log"] = FoodLogData(calories=media.calories)
|
||||
|
||||
scrobble = cls.create(scrobble_data)
|
||||
return scrobble
|
||||
|
||||
@ -123,9 +123,8 @@ def jellyfin_scrobble_media(
|
||||
/ 10000000
|
||||
)
|
||||
if media_type == Scrobble.MediaType.VIDEO:
|
||||
media_obj = Video.get_from_imdb_id(
|
||||
post_data.get("Provider_imdb", "").replace("tt", "")
|
||||
)
|
||||
imdb_id = post_data.get("Provider_imdb", "")
|
||||
media_obj = Video.find_or_create(imdb_id)
|
||||
else:
|
||||
media_obj = Track.find_or_create(
|
||||
title=post_data.get("Name", ""),
|
||||
@ -162,18 +161,14 @@ def jellyfin_scrobble_media(
|
||||
def web_scrobbler_scrobble_media(
|
||||
youtube_id: str, user_id: int, status: str = "started"
|
||||
) -> Optional[Scrobble]:
|
||||
video = Video.get_from_youtube_id(youtube_id)
|
||||
video = Video.find_or_create(youtube_id)
|
||||
return video.scrobble_for_user(user_id, status, source="Web Scrobbler")
|
||||
|
||||
|
||||
def manual_scrobble_video(
|
||||
video_id: str, user_id: int, source: str = "IMDb", action: Optional[str] = None
|
||||
):
|
||||
if "tt" in video_id:
|
||||
video = Video.get_from_imdb_id(video_id)
|
||||
|
||||
else:
|
||||
video = Video.get_from_youtube_id(video_id)
|
||||
video = Video.find_or_create(video_id)
|
||||
|
||||
# When manually scrobbling, try finding a source from the series
|
||||
if video.tv_series:
|
||||
|
||||
@ -949,10 +949,10 @@ class ScrobbleDetailView(DetailView):
|
||||
|
||||
log = self.object.log or {}
|
||||
initial_notes = log.get("notes", [])
|
||||
if isinstance(initial_notes, list) and isinstance(initial_notes[0], dict):
|
||||
if isinstance(initial_notes, list) and len(initial_notes) > 0 and isinstance(initial_notes[0], dict):
|
||||
notes_str = note_list_to_str(notes)
|
||||
else:
|
||||
notes_str = "\n".join(initial_notes)
|
||||
notes_str = "\n".join(initial_notes) if initial_notes else ""
|
||||
|
||||
notes_str_fixed = notes_str.encode("utf-8").decode(
|
||||
"unicode_escape"
|
||||
|
||||
@ -59,8 +59,11 @@ class VideoMetadata:
|
||||
|
||||
def as_dict_with_cover_and_genres(self) -> tuple:
|
||||
video_dict = vars(self)
|
||||
series_id = ""
|
||||
cover = None
|
||||
if "cover_url" in video_dict.keys():
|
||||
cover = video_dict.pop("cover_url", "")
|
||||
genres = video_dict.pop("genres", [])
|
||||
return video_dict, cover, genres
|
||||
if "tv_series_imdb_id" in video_dict.keys():
|
||||
series_id = video_dict.pop("tv_series_imdb_id")
|
||||
return video_dict, series_id, cover, genres
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
import logging
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
@ -27,7 +28,9 @@ from vrobbler.apps.scrobbles.dataclasses import BaseLogData, WithPeopleLogData
|
||||
|
||||
YOUTUBE_VIDEO_URL = "https://www.youtube.com/watch?v="
|
||||
YOUTUBE_CHANNEL_URL = "https://www.youtube.com/channel/"
|
||||
IMDB_VIDEO_URL = "https://www.imdb.com/title/tt"
|
||||
YOUTUBE_ID_PATTERN = re.compile(r'^[A-Za-z0-9_-]{11}$')
|
||||
|
||||
IMDB_VIDEO_URL = "https://www.imdb.com/title/"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
BNULL = {"blank": True, "null": True}
|
||||
@ -136,6 +139,13 @@ class Series(TimeStampedModel):
|
||||
url = self.cover_image_medium.url
|
||||
return url
|
||||
|
||||
def save_image_from_url(self, url: str, force_update: bool = False):
|
||||
if not self.cover_image or (force_update and url):
|
||||
r = requests.get(url)
|
||||
if r.status_code == 200:
|
||||
fname = f"{self.name}_{self.uuid}.jpg"
|
||||
self.cover_image.save(fname, ContentFile(r.content), save=True)
|
||||
|
||||
def scrobbles_for_user(self, user_id: int, include_playing=False):
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
@ -184,6 +194,32 @@ class Series(TimeStampedModel):
|
||||
if genres := imdb_dict.get("genres"):
|
||||
self.genre.add(*genres)
|
||||
|
||||
@classmethod
|
||||
def find_or_create(cls, imdb_id: str, overwrite: bool = True):
|
||||
series, created = cls.objects.get_or_create(imdb_id=imdb_id)
|
||||
|
||||
if not created and not overwrite:
|
||||
logger.info("Series not created and overwrite=False, returning")
|
||||
return series
|
||||
|
||||
vdict, _, cover, genres = lookup_video_from_imdb(
|
||||
imdb_id
|
||||
).as_dict_with_cover_and_genres()
|
||||
vdict.pop("video_type")
|
||||
|
||||
vdict["name"] = vdict.pop("title")
|
||||
for k, v in vdict.items():
|
||||
setattr(series, k, v)
|
||||
series.save()
|
||||
|
||||
if cover:
|
||||
series.save_image_from_url(cover)
|
||||
if genres:
|
||||
series.genre.add(*genres)
|
||||
|
||||
return series
|
||||
|
||||
|
||||
|
||||
class Video(ScrobblableMixin):
|
||||
COMPLETION_PERCENT = getattr(settings, "VIDEO_COMPLETION_PERCENT", 90)
|
||||
@ -304,7 +340,7 @@ class Video(ScrobblableMixin):
|
||||
if not created and not overwrite:
|
||||
return video
|
||||
|
||||
vdict, cover, genres = lookup_video_from_youtube(
|
||||
vdict, _, cover, genres = lookup_video_from_youtube(
|
||||
youtube_id
|
||||
).as_dict_with_cover_and_genres()
|
||||
if created or overwrite:
|
||||
@ -320,28 +356,38 @@ class Video(ScrobblableMixin):
|
||||
def get_from_imdb_id(
|
||||
cls, imdb_id: str, overwrite: bool = False
|
||||
) -> "Video":
|
||||
if "tt" in imdb_id:
|
||||
imdb_id = imdb_id[2:]
|
||||
video, created = cls.objects.get_or_create(imdb_id=imdb_id)
|
||||
if not created and not overwrite:
|
||||
return video
|
||||
|
||||
vdict, cover, genres = lookup_video_from_tmdb(
|
||||
vdict, series_id, cover, genres = lookup_video_from_imdb(
|
||||
imdb_id
|
||||
).as_dict_with_cover_and_genres()
|
||||
|
||||
if created or overwrite:
|
||||
for k, v in vdict.items():
|
||||
setattr(video, k, v)
|
||||
|
||||
if series_id:
|
||||
video.tv_series = Series.find_or_create(imdb_id=series_id)
|
||||
|
||||
video.save()
|
||||
|
||||
video.save_image_from_url(cover)
|
||||
video.genre.add(*genres)
|
||||
if cover:
|
||||
video.save_image_from_url(cover)
|
||||
if genres:
|
||||
video.genre.add(*genres)
|
||||
|
||||
return video
|
||||
|
||||
@classmethod
|
||||
def find_or_create(
|
||||
cls, data_dict: dict, post_keys: dict = JELLYFIN_POST_KEYS
|
||||
) -> Optional["Video"]:
|
||||
"""Thes smallest of wrappers around our actual get or create utility."""
|
||||
imdb_key = post_keys.get("IMDB_ID", "").replace("tt", "")
|
||||
return cls.get_from_imdb_id(data_dict.get(imdb_key))
|
||||
def find_or_create(cls, source_id: str, overwrite: bool = True) -> "Video":
|
||||
if "tt" in source_id:
|
||||
return cls.get_from_imdb_id(source_id, overwrite)
|
||||
if bool(YOUTUBE_ID_PATTERN.match(source_id)):
|
||||
return cls.get_from_youtube_id(source_id, overwrite)
|
||||
|
||||
#TODO scrobble but without a video obj?
|
||||
logger.warning("Video ID not recognized, not scrobbling")
|
||||
|
||||
return
|
||||
|
||||
@ -6,55 +6,17 @@ from videos.metadata import VideoMetadata, VideoType
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def lookup_video_from_imdb(
|
||||
name_or_id: str, kind: str = "movie"
|
||||
) -> VideoMetadata:
|
||||
from videos.models import Series
|
||||
|
||||
# Very few video titles start with tt, but IMDB IDs often come in with it
|
||||
if name_or_id.startswith("tt"):
|
||||
name_or_id = name_or_id[2:]
|
||||
|
||||
imdb_id = None
|
||||
|
||||
try:
|
||||
imdb_id = int(name_or_id)
|
||||
except ValueError:
|
||||
pass
|
||||
def lookup_video_from_imdb(imdb_id: str) -> VideoMetadata:
|
||||
if not imdb_id.startswith("tt"):
|
||||
logger.warning("This method requires an IMDB ID starting with 'tt'")
|
||||
return VideoMetadata()
|
||||
|
||||
video_metadata = VideoMetadata(imdb_id=imdb_id)
|
||||
imdb_result: dict = {}
|
||||
|
||||
imdb_result = imdb.get_title(name_or_id)
|
||||
imdb_result = imdb.get_title(imdb_id)
|
||||
logger.debug(f"Found result from IMDB: {imdb_result.title}")
|
||||
|
||||
if not imdb_result:
|
||||
imdb_result = {}
|
||||
imdb_results: list = imdb.search_movie(name_or_id)
|
||||
if len(imdb_results) > 1:
|
||||
for result in imdb_results:
|
||||
if result["kind"] == kind:
|
||||
imdb_client.update(
|
||||
result,
|
||||
info=[
|
||||
"plot",
|
||||
"synopsis",
|
||||
"taglines",
|
||||
"next_episode",
|
||||
"genres",
|
||||
],
|
||||
)
|
||||
imdb_result = result
|
||||
break
|
||||
|
||||
if len(imdb_results) == 1:
|
||||
imdb_result = imdb_results[0]
|
||||
|
||||
imdb_client.update(
|
||||
imdb_result,
|
||||
info=["plot", "synopsis", "taglines", "next_episode", "genres"],
|
||||
)
|
||||
|
||||
if not imdb_result:
|
||||
logger.info(
|
||||
f"[lookup_video_from_imdb] no video found on imdb",
|
||||
@ -62,39 +24,27 @@ def lookup_video_from_imdb(
|
||||
)
|
||||
return None
|
||||
|
||||
video_metadata.cover_url = imdb_result.get("cover url")
|
||||
if video_metadata.cover_url:
|
||||
video_metadata.cover_url = helpers.resizeImage(
|
||||
video_metadata.cover_url, width=800
|
||||
)
|
||||
video_metadata.imdb_id = imdb_id
|
||||
video_metadata.title = imdb_result.title
|
||||
video_metadata.base_run_time_seconds = (
|
||||
imdb_result.runtime * 60
|
||||
)
|
||||
video_metadata.year = imdb_result.year
|
||||
video_metadata.plot = imdb_result.plot.get("en-US", "")
|
||||
video_metadata.imdb_rating = imdb_result.rating
|
||||
video_metadata.genres = imdb_result.genres
|
||||
video_metadata.cover_url = imdb_result.primary_image
|
||||
|
||||
video_metadata.video_type = VideoType.MOVIE.value
|
||||
series_name = None
|
||||
if imdb_result.get("kind") == "episode":
|
||||
try:
|
||||
series_name = imdb_result.get("episode of", None).data.get(
|
||||
"title", None
|
||||
)
|
||||
except IndexError:
|
||||
series_name = None
|
||||
if series_name:
|
||||
series, _ = Series.objects.get_or_create(name=series_name)
|
||||
video_metadata.video_type = VideoType.TV_EPISODE.value
|
||||
video_metadata.tv_series_id = series.id
|
||||
if imdb_result.type_id == "tvEpisode":
|
||||
video_metadata.video_type = VideoType.TV_EPISODE.value
|
||||
|
||||
if imdb_result.get("runtimes"):
|
||||
video_metadata.base_run_time_seconds = (
|
||||
int(imdb_result.get("runtimes")[0]) * 60
|
||||
)
|
||||
series = imdb_result.series
|
||||
video_metadata.tv_series_imdb_id = series.imdb_id
|
||||
video_metadata.tv_series_title = series.title
|
||||
video_metadata.episode_number = imdb_result.episode
|
||||
video_metadata.season_number = imdb_result.season
|
||||
video_metadata.next_imdb_id = imdb_result.next_episode_id
|
||||
|
||||
video_metadata.imdb_id = name_or_id
|
||||
video_metadata.title = imdb_result.get("title", "")
|
||||
video_metadata.episode_number = imdb_result.get("episode", None)
|
||||
video_metadata.season_number = imdb_result.get("season", None)
|
||||
video_metadata.next_imdb_id = imdb_result.get("next episode", None)
|
||||
video_metadata.year = imdb_result.get("year", None)
|
||||
video_metadata.plot = imdb_result.get("plot outline", "")
|
||||
video_metadata.imdb_rating = imdb_result.get("rating", None)
|
||||
video_metadata.genres = imdb_result.get("genres", [])
|
||||
|
||||
return video_metadata
|
||||
|
||||
@ -1,51 +1,24 @@
|
||||
import logging
|
||||
|
||||
from scrobbles.utils import convert_to_seconds
|
||||
from videos.imdb import lookup_video_from_imdb
|
||||
from videos.models import Series, Video
|
||||
from videos.skatevideosite import lookup_video_from_skatevideosite
|
||||
from videos.models import Video
|
||||
from django.db import IntegrityError
|
||||
#from videos.skatevideosite import lookup_video_from_skatevideosite
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_or_create_video(data_dict: dict, post_keys: dict, force_update=False):
|
||||
name_or_id = data_dict.get(post_keys.get("IMDB_ID"), "") or data_dict.get(
|
||||
post_keys.get("VIDEO_TITLE"), ""
|
||||
)
|
||||
def clean_up_videos():
|
||||
videos = Video.objects.filter(imdb_id__isnull=False).exclude(imdb_id__icontains="tt")
|
||||
|
||||
video = Video.objects.filter(imdb_id=name_or_id).first()
|
||||
if video:
|
||||
return video
|
||||
for video in videos:
|
||||
logger.info(f"Fixing imdb_id for {video}")
|
||||
video.imdb_id = "tt" + video.imdb_id
|
||||
try:
|
||||
video.save(update_fields=["imdb_id"])
|
||||
except IntegrityError:
|
||||
new_video = Video.objects.filter(imdb_id="tt" + video.imdb_id).first()
|
||||
video.scrobble_set.all().update(video=new_video)
|
||||
video.delete()
|
||||
|
||||
imdb_metadata = lookup_video_from_imdb(name_or_id)
|
||||
# skatevideosite_metadata = lookup_video_from_skatevideosite(name_or_id)
|
||||
# youtube_metadata = {} # TODO lookup_video_from_youtube(name_or_id)
|
||||
|
||||
video_dict = imdb_metadata
|
||||
if not video_dict:
|
||||
logger.info(
|
||||
"No video found on imdb, skatevideosite or youtube, cannot scrobble",
|
||||
extra={"name_or_id": name_or_id},
|
||||
)
|
||||
return
|
||||
|
||||
video = Video.get_from_imdb_id(video_dict.get("imdb_id")
|
||||
|
||||
if not "overview" in video_dict.keys():
|
||||
video_dict["overview"] = data_dict.get(
|
||||
post_keys.get("OVERVIEW"), None
|
||||
)
|
||||
if not "tagline" in video_dict.keys():
|
||||
video_dict["tagline"] = data_dict.get(
|
||||
post_keys.get("TAGLINE"), None
|
||||
)
|
||||
if not "tmdb_id" in video_dict.keys():
|
||||
video_dict["tmdb_id"] = data_dict.get(
|
||||
post_keys.get("TMDB_ID"), None
|
||||
)
|
||||
|
||||
return video
|
||||
|
||||
|
||||
def get_or_create_video_from_skatevideosite(title: str, force_update: bool=True):
|
||||
return
|
||||
videos = Video.objects.filter(scrobble__isnull=True)
|
||||
videos.delete()
|
||||
|
||||
43
vrobbler/templates/_longplay_scrobblable_list.html
Normal file
43
vrobbler/templates/_longplay_scrobblable_list.html
Normal file
@ -0,0 +1,43 @@
|
||||
{% load urlreplace %}
|
||||
{% load naturalduration %}
|
||||
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Latest</th>
|
||||
<th scope="col">Title</th>
|
||||
<th scope="col">Scrobbles</th>
|
||||
<th scope="col">Complete</th>
|
||||
<th scope="col">Start</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for obj in object_list %}
|
||||
{% if obj.title %}
|
||||
<tr>
|
||||
<td><a href="{{obj.scrobble_set.last.get_absolute_url}}">{{obj.scrobble_set.last.local_timestamp}}
|
||||
<td><a href="{{obj.get_absolute_url}}">{{obj}}</a></td>
|
||||
{% if request.user.is_authenticated %}
|
||||
<td>{{obj.scrobble_count}}</td>
|
||||
<td>{% if obj.scrobble_set.last.logdata.long_play_complete == True %}Yes{% endif %}</td>
|
||||
<td><a type="button" class="btn btn-sm btn-primary" href="{{obj.start_url}}">Scrobble</a></td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p class="pagination">
|
||||
<span class="page-links">
|
||||
{% if page_obj.has_previous %}
|
||||
<a href="?{% urlreplace page=page_obj.previous_page_number %}">prev</a>
|
||||
{% endif %}
|
||||
<span class="page-current">
|
||||
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
|
||||
</span>
|
||||
{% if page_obj.has_next %}
|
||||
<a href="?{% urlreplace page=page_obj.next_page_number %}">next</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
</p>
|
||||
@ -16,7 +16,7 @@
|
||||
|
||||
<div class="col-md">
|
||||
<div class="table-responsive">
|
||||
{% include "_scrobblable_list.html" %}
|
||||
{% include "_longplay_scrobblable_list.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -16,7 +16,7 @@
|
||||
|
||||
<div class="col-md">
|
||||
<div class="table-responsive">
|
||||
{% include "_scrobblable_list.html" %}
|
||||
{% include "_longplay_scrobblable_list.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -16,7 +16,7 @@
|
||||
|
||||
<div class="col-md">
|
||||
<div class="table-responsive">
|
||||
{% include "_scrobblable_list.html" %}
|
||||
{% include "_longplay_scrobblable_list.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user