Compare commits

..

12 Commits
35 ... 37

19 changed files with 245 additions and 205 deletions

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@ -16,7 +16,7 @@
<div class="col-md">
<div class="table-responsive">
{% include "_scrobblable_list.html" %}
{% include "_longplay_scrobblable_list.html" %}
</div>
</div>
</div>

View File

@ -16,7 +16,7 @@
<div class="col-md">
<div class="table-responsive">
{% include "_scrobblable_list.html" %}
{% include "_longplay_scrobblable_list.html" %}
</div>
</div>
</div>

View File

@ -16,7 +16,7 @@
<div class="col-md">
<div class="table-responsive">
{% include "_scrobblable_list.html" %}
{% include "_longplay_scrobblable_list.html" %}
</div>
</div>
</div>