Compare commits

...

9 Commits
33 ... 34

49 changed files with 794 additions and 125 deletions

View File

@ -92,7 +92,7 @@ fetching and simple saving.
:LOGBOOK: :LOGBOOK:
CLOCK: [2025-07-09 Wed 09:55]--[2025-07-09 Wed 10:15] => 0:20 CLOCK: [2025-07-09 Wed 09:55]--[2025-07-09 Wed 10:15] => 0:20
:END: :END:
* Backlog [1/27] * Backlog [4/27]
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :vrobbler:personal:bug:music:scrobbles: ** 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: ** TODO [#C] Move to using more robust mopidy-webhooks pacakge form pypi :utility:improvement:
:PROPERTIES: :PROPERTIES:
@ -422,13 +422,50 @@ 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 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 [#B] Add PuzzleLogData class with with_people and completed :vrobbler:feature:puzzles:logdata:personal:project:
** TODO [#A] Add classmethod for metadata fetching to tracks :vrobbler:feature:music: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 koreader scrobble imports to use DST properly :vrobbler:personal:bug:books:imports:
- Note taken on [2025-09-25 Thu 10:37] \\
This may already be fixed ... need to check.
- Note taken on [2025-02-25 12:34] \\
The page data has the canonical date something was read in it, but it seems
to be an hour off. I traced this back to being off during DST, so we just need
the importer to be aware of whether a user is using DST or not and roll back
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:
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:
* Version 34.0 [4/4]
** DONE [#A] Use bgg-api for BoardGameGeek lookups :vrobbler:feature:boardgames:personal:project:
:PROPERTIES:
:ID: 738abb5a-c796-b16b-fe10-6e5639a0e10d
:END:
** DONE [#A] Add classmethod for metadata fetching to tracks :vrobbler:feature:music:personal:project:
:PROPERTIES: :PROPERTIES:
:ID: bc4b45e5-4c65-13c5-ab7b-1937d3fbf5c2 :ID: bc4b45e5-4c65-13c5-ab7b-1937d3fbf5c2
:END: :END:
** TODO [#A] Tasks from org-mode should properly update notes and leave them out of the body :vrobbler:bug:tasks: - Note taken on [2025-10-29 Wed 21:44]
** TODO [#A] Fix views for TV series where next episode is now None :vrobbler:bug:personal:videos:
Beyond a classmethod (which I think we have now), we need to update the flow of how we look up tracks.
It's a hot mess right now where Various Artists walks over the actual artist, and we often hit MB when we don't have to.
** DONE [#A] Fix views for TV series where next episode is now None :vrobbler:bug:personal:videos:
:PROPERTIES:
:ID: d7014ac4-cda6-0802-2cdf-8f66c6389fea
:END:
#+begin_src python #+begin_src python
ERROR django.request:241 log_response Internal Server Error: /series/c24100d1-da45-4abe-86bf-27cfce9b1f89/ ERROR django.request:241 log_response Internal Server Error: /series/c24100d1-da45-4abe-86bf-27cfce9b1f89/
@ -457,29 +494,17 @@ Traceback (most recent call last):
TypeError: can only concatenate str (not "NoneType") to str TypeError: can only concatenate str (not "NoneType") to str
#+end_src #+end_src
** TODO [#A] Fix koreader scrobble imports to use DST properly :vrobbler:personal:bug:books:imports: ** DONE [#A] Emacs tasks are duplicating rather than updating :vrobbler:bug:tasks:emacs:personal:project:
:PROPERTIES:
:ID: e93efc25-7ce9-8ef2-662e-0a19dd0b29c9
:END:
- Note taken on [2025-09-25 Thu 10:37] \\ - Note taken on [2025-10-29 Wed 16:38]
This may already be fixed ... need to check. Turns out I was misusing `orgmode` for the source of tasks when it shoulda been `Org-mode`
- Note taken on [2025-02-25 12:34] \\ A good lesson in using constants for things.
The page data has the canonical date something was read in it, but it seems
to be an hour off. I traced this back to being off during DST, so we just need
the importer to be aware of whether a user is using DST or not and roll back
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:
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:
* Version 33.0 [3/3] * Version 33.0 [3/3]
** DONE [#A] Fix bug where scrobble is_stale only uses seconds not total_seconds :vrobbler:bug:scrobbles:personal:project: ** DONE [#A] Fix bug where scrobble is_stale only uses seconds not total_seconds :vrobbler:bug:scrobbles:personal:project:
:PROPERTIES: :PROPERTIES:

File diff suppressed because one or more lines are too long

94
poetry.lock generated
View File

@ -361,6 +361,22 @@ python-dateutil = ">=2.8.2,<3.0.0"
requests = ">=2.28.2,<3.0.0" requests = ">=2.28.2,<3.0.0"
typing-extensions = ">=4.7.1,<5.0.0" typing-extensions = ">=4.7.1,<5.0.0"
[[package]]
name = "bgg-api"
version = "1.1.13"
description = "A Python API for boardgamegeek.com"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "bgg_api-1.1.13-py3-none-any.whl", hash = "sha256:6babe32ddb0ccbba7292b789770bd64ef523cab0d2cfd0a2c326cebce3e842e7"},
{file = "bgg_api-1.1.13.tar.gz", hash = "sha256:1e921b1d2818157418abb90d4ae7a50d8b071f1ade4ab47add1c8fdfa333e6dc"},
]
[package.dependencies]
requests = ">=2.31.0,<3.0.0"
requests-cache = ">=1.1.1,<2.0.0"
[[package]] [[package]]
name = "billiard" name = "billiard"
version = "4.2.1" version = "4.2.1"
@ -519,6 +535,33 @@ dev = ["CacheControl[filecache,redis]", "build", "cherrypy", "codespell[tomli]",
filecache = ["filelock (>=3.8.0)"] filecache = ["filelock (>=3.8.0)"]
redis = ["redis (>=2.10.5)"] redis = ["redis (>=2.10.5)"]
[[package]]
name = "cattrs"
version = "25.2.0"
description = "Composable complex class support for attrs and dataclasses."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "cattrs-25.2.0-py3-none-any.whl", hash = "sha256:539d7eedee7d2f0706e4e109182ad096d608ba84633c32c75ef3458f1d11e8f1"},
{file = "cattrs-25.2.0.tar.gz", hash = "sha256:f46c918e955db0177be6aa559068390f71988e877c603ae2e56c71827165cc06"},
]
[package.dependencies]
attrs = ">=24.3.0"
exceptiongroup = {version = ">=1.1.1", markers = "python_version < \"3.11\""}
typing-extensions = ">=4.12.2"
[package.extras]
bson = ["pymongo (>=4.4.0)"]
cbor2 = ["cbor2 (>=5.4.6)"]
msgpack = ["msgpack (>=1.0.5)"]
msgspec = ["msgspec (>=0.19.0) ; implementation_name == \"cpython\""]
orjson = ["orjson (>=3.10.7) ; implementation_name == \"cpython\""]
pyyaml = ["pyyaml (>=6.0)"]
tomlkit = ["tomlkit (>=0.11.8)"]
ujson = ["ujson (>=5.10.0)"]
[[package]] [[package]]
name = "celery" name = "celery"
version = "5.4.0" version = "5.4.0"
@ -4308,6 +4351,37 @@ urllib3 = ">=1.21.1,<3"
socks = ["PySocks (>=1.5.6,!=1.5.7)"] socks = ["PySocks (>=1.5.6,!=1.5.7)"]
use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
[[package]]
name = "requests-cache"
version = "1.2.1"
description = "A persistent cache for python requests"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "requests_cache-1.2.1-py3-none-any.whl", hash = "sha256:1285151cddf5331067baa82598afe2d47c7495a1334bfe7a7d329b43e9fd3603"},
{file = "requests_cache-1.2.1.tar.gz", hash = "sha256:68abc986fdc5b8d0911318fbb5f7c80eebcd4d01bfacc6685ecf8876052511d1"},
]
[package.dependencies]
attrs = ">=21.2"
cattrs = ">=22.2"
platformdirs = ">=2.5"
requests = ">=2.22"
url-normalize = ">=1.4"
urllib3 = ">=1.25.5"
[package.extras]
all = ["boto3 (>=1.15)", "botocore (>=1.18)", "itsdangerous (>=2.0)", "pymongo (>=3)", "pyyaml (>=6.0.1)", "redis (>=3)", "ujson (>=5.4)"]
bson = ["bson (>=0.5)"]
docs = ["furo (>=2023.3,<2024.0)", "linkify-it-py (>=2.0,<3.0)", "myst-parser (>=1.0,<2.0)", "sphinx (>=5.0.2,<6.0.0)", "sphinx-autodoc-typehints (>=1.19)", "sphinx-automodapi (>=0.14)", "sphinx-copybutton (>=0.5)", "sphinx-design (>=0.2)", "sphinx-notfound-page (>=0.8)", "sphinxcontrib-apidoc (>=0.3)", "sphinxext-opengraph (>=0.9)"]
dynamodb = ["boto3 (>=1.15)", "botocore (>=1.18)"]
json = ["ujson (>=5.4)"]
mongodb = ["pymongo (>=3)"]
redis = ["redis (>=3)"]
security = ["itsdangerous (>=2.0)"]
yaml = ["pyyaml (>=6.0.1)"]
[[package]] [[package]]
name = "requests-oauthlib" name = "requests-oauthlib"
version = "2.0.0" version = "2.0.0"
@ -5013,6 +5087,24 @@ tzdata = {version = "*", markers = "platform_system == \"Windows\""}
[package.extras] [package.extras]
devenv = ["check-manifest", "pytest (>=4.3)", "pytest-cov", "pytest-mock (>=3.3)", "zest.releaser"] devenv = ["check-manifest", "pytest (>=4.3)", "pytest-cov", "pytest-mock (>=3.3)", "zest.releaser"]
[[package]]
name = "url-normalize"
version = "2.2.1"
description = "URL normalization for Python"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "url_normalize-2.2.1-py3-none-any.whl", hash = "sha256:3deb687587dc91f7b25c9ae5162ffc0f057ae85d22b1e15cf5698311247f567b"},
{file = "url_normalize-2.2.1.tar.gz", hash = "sha256:74a540a3b6eba1d95bdc610c24f2c0141639f3ba903501e61a52a8730247ff37"},
]
[package.dependencies]
idna = ">=3.3"
[package.extras]
dev = ["mypy", "pre-commit", "pytest", "pytest-cov", "pytest-socket", "ruff"]
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "1.26.20" version = "1.26.20"
@ -5539,4 +5631,4 @@ cffi = ["cffi (>=1.11)"]
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = ">=3.9,<3.12" python-versions = ">=3.9,<3.12"
content-hash = "2e297ef6f8c524840a381ad793946c87b601d81afd569e882fe58120a5f84626" content-hash = "f89cff0d1019afe54e4df89a8debf50b79776c474e60d48fcae1e7c70daa3761"

View File

@ -58,6 +58,7 @@ tmdbv3api = "^1.9.0"
themoviedb = "^1.0.2" themoviedb = "^1.0.2"
feedparser = "^6.0.12" feedparser = "^6.0.12"
titlecase = "^2.4.1" titlecase = "^2.4.1"
bgg-api = "^1.1.13"
[tool.poetry.group.test] [tool.poetry.group.test]
optional = true optional = true

View File

@ -1,3 +1,4 @@
import pytest
from boardgames.bgg import ( from boardgames.bgg import (
take_first, take_first,
lookup_boardgame_id_from_bgg, lookup_boardgame_id_from_bgg,
@ -5,12 +6,14 @@ from boardgames.bgg import (
) )
@pytest.mark.skip(reason="Deprecated library")
def test_take_first(): def test_take_first():
assert take_first([]) == "" assert take_first([]) == ""
assert take_first(["a", "b"]) == "a" assert take_first(["a", "b"]) == "a"
@pytest.mark.skip(reason="Deprecated library")
def test_lookup_boardgame_id_from_bgg(): def test_lookup_boardgame_id_from_bgg():
bgg_id = lookup_boardgame_id_from_bgg("Cosmic Encounter") bgg_id = lookup_boardgame_id_from_bgg("Cosmic Encounter")
assert bgg_id == "15" assert bgg_id == "15"
@ -19,6 +22,7 @@ def test_lookup_boardgame_id_from_bgg():
assert bgg_id == None assert bgg_id == None
@pytest.mark.skip(reason="Deprecated library")
def test_lookup_boardgame_from_bgg(): def test_lookup_boardgame_from_bgg():
bgg_result = lookup_boardgame_from_bgg(15) bgg_result = lookup_boardgame_from_bgg(15)
assert bgg_result.get("bggeek_id") == 15 assert bgg_result.get("bggeek_id") == 15

View File

@ -34,7 +34,7 @@ def test_track():
Track.objects.create( Track.objects.create(
title="Emotion", title="Emotion",
artist=Artist.objects.create(name="Carly Rae Jepsen"), artist=Artist.objects.create(name="Carly Rae Jepsen"),
run_time_seconds=60, base_run_time_seconds=60,
) )

View File

@ -0,0 +1,26 @@
# Generated by Django 4.2.19 on 2025-10-30 01:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('beers', '0005_alter_beer_run_time_seconds'),
]
operations = [
migrations.RemoveField(
model_name='beer',
name='run_time_seconds',
),
migrations.RemoveField(
model_name='beer',
name='run_time_ticks',
),
migrations.AddField(
model_name='beer',
name='base_run_time_seconds',
field=models.IntegerField(blank=True, null=True),
),
]

View File

@ -0,0 +1,26 @@
# Generated by Django 4.2.19 on 2025-10-30 01:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('boardgames', '0010_boardgame_published_year'),
]
operations = [
migrations.RemoveField(
model_name='boardgame',
name='run_time_seconds',
),
migrations.RemoveField(
model_name='boardgame',
name='run_time_ticks',
),
migrations.AddField(
model_name='boardgame',
name='base_run_time_seconds',
field=models.IntegerField(blank=True, null=True),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.19 on 2025-11-03 04:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('boardgames', '0011_remove_boardgame_run_time_seconds_and_more'),
]
operations = [
migrations.AddField(
model_name='boardgame',
name='bgg_rank',
field=models.IntegerField(blank=True, null=True),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.19 on 2025-11-03 04:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('boardgames', '0012_boardgame_bgg_rank'),
]
operations = [
migrations.AddField(
model_name='boardgame',
name='publishers',
field=models.ManyToManyField(related_name='board_games', to='boardgames.boardgamepublisher'),
),
]

View File

@ -2,12 +2,12 @@ from functools import cached_property
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional, Any
from uuid import uuid4 from uuid import uuid4
from django import forms from django import forms
import requests import requests
from boardgames.bgg import lookup_boardgame_from_bgg from boardgames.sources.bgg import lookup_boardgame_from_bgg
from django.conf import settings from django.conf import settings
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.db import models from django.db import models
@ -191,6 +191,10 @@ class BoardGame(ScrobblableMixin):
publisher = models.ForeignKey( publisher = models.ForeignKey(
BoardGamePublisher, **BNULL, on_delete=models.DO_NOTHING BoardGamePublisher, **BNULL, on_delete=models.DO_NOTHING
) )
publishers = models.ManyToManyField(
BoardGamePublisher,
related_name="board_games",
)
designers = models.ManyToManyField( designers = models.ManyToManyField(
BoardGameDesigner, BoardGameDesigner,
related_name="board_games", related_name="board_games",
@ -224,6 +228,7 @@ class BoardGame(ScrobblableMixin):
options={"quality": 75}, options={"quality": 75},
) )
rating = models.FloatField(**BNULL) rating = models.FloatField(**BNULL)
bgg_rank = models.IntegerField(**BNULL)
max_players = models.PositiveSmallIntegerField(**BNULL) max_players = models.PositiveSmallIntegerField(**BNULL)
min_players = models.PositiveSmallIntegerField(**BNULL) min_players = models.PositiveSmallIntegerField(**BNULL)
published_date = models.DateField(**BNULL) published_date = models.DateField(**BNULL)
@ -301,29 +306,58 @@ class BoardGame(ScrobblableMixin):
# Go get cover image if the URL is present # Go get cover image if the URL is present
if cover_url and not self.cover: if cover_url and not self.cover:
headers = {"User-Agent": "Vrobbler 0.11.12"} self.save_image_from_url(cover_url)
r = requests.get(cover_url, headers=headers)
logger.debug(r.status_code) def save_image_from_url(self, url):
if r.status_code == 200: headers = {"User-Agent": "Vrobbler 0.11.12"}
fname = f"{self.title}_cover_{self.uuid}.jpg" r = requests.get(url, headers=headers)
self.cover.save(fname, ContentFile(r.content), save=True) if r.status_code == 200:
logger.debug("Loaded cover image from BGGeek") fname = f"{self.title}_cover_{self.uuid}.jpg"
self.cover.save(fname, ContentFile(r.content), save=True)
@classmethod @classmethod
def find_or_create( def find_or_create(
cls, lookup_id: str, data: Optional[dict] = {} cls, lookup_id: str, data: dict[str, Any] = {}
) -> Optional["BoardGame"]: ) -> "BoardGame":
"""Given a Lookup ID (either BGG or BGA ID), return a board game object""" """Given a Lookup ID (either BGG or BGA ID), return a board game object"""
boardgame = cls.objects.filter(bggeek_id=lookup_id).first() game = cls.objects.filter(bggeek_id=lookup_id).first()
if not data or not boardgame: if game:
data = lookup_boardgame_from_bgg(lookup_id) logger.info("Board game exists in database.", extra={"lookup_id": lookup_id, "data": data})
return game
if data and not boardgame: bgg_data = lookup_boardgame_from_bgg(data.get("name"))
boardgame, created = cls.objects.get_or_create(
title=data["title"], bggeek_id=lookup_id
)
if created:
boardgame.fix_metadata(data=data)
return boardgame mechanics = bgg_data.pop("mechanics", [])
designers = bgg_data.pop("designers", [])
categories = bgg_data.pop("categories", [])
publishers = bgg_data.pop("publishers", [])
cover_url = bgg_data.pop("cover_url")
game = cls.objects.create(
**bgg_data
)
game.save_image_from_url(cover_url)
game.cooperative = data.get("cooperative", False)
game.highest_wins = data.get("highestWins", True)
game.no_points = data.get("noPoints", False)
game.uses_teams = data.get("useTeams", False)
game.bgstats_id = data.get("uuid", None)
game.save()
if designers:
for designer_name in designers:
designer, created = BoardGameDesigner.objects.get_or_create(
name=designer_name
)
game.designers.add(designer.id)
if publishers:
for name in publishers:
publisher, _ = BoardGamePublisher.objects.get_or_create(
name=name
)
game.publishers.add(publisher)
return game

View File

@ -0,0 +1,29 @@
from typing import Any
from boardgamegeek import BGGClient
from django.conf import settings
def lookup_boardgame_from_bgg(title: str) -> dict[str, Any]:
game_dict = {"title": title}
bgg = BGGClient(access_token=settings.BGG_ACCESS_TOKEN)
game = bgg.game(title)
if game:
game_dict["description"] = game.description
game_dict["published_year"] = game.yearpublished
game_dict["cover_url"] = game.image
game_dict["min_players"] = game.minplayers
game_dict["max_players"] = game.maxplayers
game_dict["recommended_age"] = game.minage
game_dict["rating"] = game.rating_average
game_dict["bgg_rank"] = game.bgg_rank
game_dict["base_run_time_seconds"] = int(game.playingtime) * 60 if game.playingtime else None
game_dict["mechanics"] = game.mechanics
game_dict["categories"] = game.categories
game_dict["designers"] = game.designers
game_dict["publishers"] = game.publishers
return game_dict

View File

@ -18,9 +18,9 @@ def import_chess_games_for_user_id(user_id: int, commit: bool = False) -> dict:
for game_dict in games: for game_dict in games:
chess, created = BoardGame.objects.get_or_create(title="Chess") chess, created = BoardGame.objects.get_or_create(title="Chess")
if created: if created:
chess.run_time_seconds = 1800 chess.base_run_time_seconds = 1800
chess.bggeek_id = 171 chess.bggeek_id = 171
chess.save(update_fields=["run_time_seconds", "bggeek_id"]) chess.save(update_fields=["base_run_time_seconds", "bggeek_id"])
scrobble = Scrobble.objects.filter( scrobble = Scrobble.objects.filter(
user_id=user.id, user_id=user.id,
timestamp=game_dict.get("createdAt"), timestamp=game_dict.get("createdAt"),

View File

@ -114,7 +114,7 @@ def create_book_from_row(row: list):
"raw_row_data": clean_row, "raw_row_data": clean_row,
} }
}, },
run_time_seconds=run_time, base_run_time_seconds=run_time,
) )
# TODO Move these to async processes after importing # TODO Move these to async processes after importing
# book.fix_metadata() # book.fix_metadata()

View File

@ -0,0 +1,39 @@
# Generated by Django 4.2.19 on 2025-10-30 01:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('books', '0031_book_next_readcomics_url'),
]
operations = [
migrations.RemoveField(
model_name='book',
name='run_time_seconds',
),
migrations.RemoveField(
model_name='book',
name='run_time_ticks',
),
migrations.RemoveField(
model_name='paper',
name='run_time_seconds',
),
migrations.RemoveField(
model_name='paper',
name='run_time_ticks',
),
migrations.AddField(
model_name='book',
name='base_run_time_seconds',
field=models.IntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='paper',
name='base_run_time_seconds',
field=models.IntegerField(blank=True, null=True),
),
]

View File

@ -402,7 +402,7 @@ class Book(LongPlayScrobblableMixin):
self.cover.save(fname, ContentFile(r.content), save=True) self.cover.save(fname, ContentFile(r.content), save=True)
if self.pages: if self.pages:
self.run_time_seconds = int(self.pages) * int( self.base_run_time_seconds = int(self.pages) * int(
self.AVG_PAGE_READING_SECONDS self.AVG_PAGE_READING_SECONDS
) )

View File

@ -67,9 +67,9 @@ def lookup_book_from_google(title: str) -> dict:
.replace("&edge=curl", "") .replace("&edge=curl", "")
) )
book_dict["run_time_seconds"] = 3600 book_dict["base_run_time_seconds"] = 3600
if book_dict.get("pages"): if book_dict.get("pages"):
book_dict["run_time_seconds"] = book_dict.get("pages", 10) * getattr( book_dict["base_run_time_seconds"] = book_dict.get("pages", 10) * getattr(
settings, "AVERAGE_PAGE_READING_SECONDS", 60 settings, "AVERAGE_PAGE_READING_SECONDS", 60
) )

View File

@ -67,7 +67,7 @@ def lookup_paper_from_semantic(title: str) -> dict:
paper_dict["openaccess_pdf_url"] = result.get("openAccessPdf", {}).get( paper_dict["openaccess_pdf_url"] = result.get("openAccessPdf", {}).get(
"url" "url"
) )
paper_dict["run_time_seconds"] = paper_dict.get("pages", 10) * getattr( paper_dict["base_run_time_seconds"] = paper_dict.get("pages", 10) * getattr(
settings, "AVERAGE_PAGE_READING_SECONDS", 60 settings, "AVERAGE_PAGE_READING_SECONDS", 60
) )
paper_dict["author_dicts"] = result.get("authors") paper_dict["author_dicts"] = result.get("authors")

View File

@ -0,0 +1,26 @@
# Generated by Django 4.2.19 on 2025-10-30 01:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bricksets', '0002_alter_brickset_run_time_seconds'),
]
operations = [
migrations.RemoveField(
model_name='brickset',
name='run_time_seconds',
),
migrations.RemoveField(
model_name='brickset',
name='run_time_ticks',
),
migrations.AddField(
model_name='brickset',
name='base_run_time_seconds',
field=models.IntegerField(blank=True, null=True),
),
]

View File

@ -0,0 +1,26 @@
# Generated by Django 4.2.19 on 2025-10-30 01:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('foods', '0003_food_calories'),
]
operations = [
migrations.RemoveField(
model_name='food',
name='run_time_seconds',
),
migrations.RemoveField(
model_name='food',
name='run_time_ticks',
),
migrations.AddField(
model_name='food',
name='base_run_time_seconds',
field=models.IntegerField(blank=True, null=True),
),
]

View File

@ -0,0 +1,26 @@
# Generated by Django 4.2.19 on 2025-10-30 01:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('lifeevents', '0002_alter_lifeevent_run_time_seconds'),
]
operations = [
migrations.RemoveField(
model_name='lifeevent',
name='run_time_seconds',
),
migrations.RemoveField(
model_name='lifeevent',
name='run_time_ticks',
),
migrations.AddField(
model_name='lifeevent',
name='base_run_time_seconds',
field=models.IntegerField(blank=True, null=True),
),
]

View File

@ -0,0 +1,26 @@
# Generated by Django 4.2.19 on 2025-10-30 01:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('locations', '0007_alter_geolocation_run_time_seconds'),
]
operations = [
migrations.RemoveField(
model_name='geolocation',
name='run_time_seconds',
),
migrations.RemoveField(
model_name='geolocation',
name='run_time_ticks',
),
migrations.AddField(
model_name='geolocation',
name='base_run_time_seconds',
field=models.IntegerField(blank=True, null=True),
),
]

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,26 @@
# Generated by Django 4.2.19 on 2025-10-30 01:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('moods', '0003_alter_mood_run_time_seconds'),
]
operations = [
migrations.RemoveField(
model_name='mood',
name='run_time_seconds',
),
migrations.RemoveField(
model_name='mood',
name='run_time_ticks',
),
migrations.AddField(
model_name='mood',
name='base_run_time_seconds',
field=models.IntegerField(blank=True, null=True),
),
]

View File

@ -0,0 +1,26 @@
# Generated by Django 4.2.19 on 2025-10-30 01:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('music', '0028_alter_track_albums'),
]
operations = [
migrations.RemoveField(
model_name='track',
name='run_time_seconds',
),
migrations.RemoveField(
model_name='track',
name='run_time_ticks',
),
migrations.AddField(
model_name='track',
name='base_run_time_seconds',
field=models.IntegerField(blank=True, null=True),
),
]

View File

@ -677,7 +677,7 @@ class Track(ScrobblableMixin):
lookup_keys = {"title": title, "artist": artist} lookup_keys = {"title": title, "artist": artist}
if run_time_seconds: if run_time_seconds:
lookup_keys["run_time_seconds"] = run_time_seconds lookup_keys["base_run_time_seconds"] = run_time_seconds
logger.info(f"Looking up track using: {lookup_keys}") logger.info(f"Looking up track using: {lookup_keys}")
track = cls.objects.filter(**lookup_keys).first() track = cls.objects.filter(**lookup_keys).first()
if track: if track:
@ -699,7 +699,7 @@ class Track(ScrobblableMixin):
if album: if album:
track.albums.add(album) track.albums.add(album)
if enrich or not track.run_time_seconds: if enrich or not track.base_run_time_seconds:
logger.info( logger.info(
f"Enriching track {track}", f"Enriching track {track}",
extra={ extra={
@ -715,7 +715,7 @@ class Track(ScrobblableMixin):
except Exception: except Exception:
print("No musicbrainz result found, cannot enrich") print("No musicbrainz result found, cannot enrich")
return track return track
track.run_time_seconds = run_time_seconds or int(length / 1000) track.base_run_time_seconds = run_time_seconds or int(length / 1000)
track.musicbrainz_id = mbid track.musicbrainz_id = mbid
if commit: if commit:
track.save() track.save()

View File

@ -0,0 +1,26 @@
# Generated by Django 4.2.19 on 2025-10-30 01:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('podcasts', '0017_podcast_podcastindex_id'),
]
operations = [
migrations.RemoveField(
model_name='podcastepisode',
name='run_time_seconds',
),
migrations.RemoveField(
model_name='podcastepisode',
name='run_time_ticks',
),
migrations.AddField(
model_name='podcastepisode',
name='base_run_time_seconds',
field=models.IntegerField(blank=True, null=True),
),
]

View File

@ -145,7 +145,7 @@ class PodcastEpisode(ScrobblableMixin):
title: str, title: str,
pub_date: str, pub_date: str,
episode_num: int = 0, episode_num: int = 0,
run_time_seconds: int = 1800, base_run_time_seconds: int = 2400,
mopidy_uri: str = "", mopidy_uri: str = "",
podcast_name: str = "", podcast_name: str = "",
podcast_producer: str = "", podcast_producer: str = "",
@ -174,7 +174,7 @@ class PodcastEpisode(ScrobblableMixin):
title=title, title=title,
podcast=podcast, podcast=podcast,
defaults={ defaults={
"run_time_seconds": run_time_seconds, "base_run_time_seconds": base_run_time_seconds,
"number": episode_num, "number": episode_num,
"pub_date": pub_date, "pub_date": pub_date,
"mopidy_uri": mopidy_uri, "mopidy_uri": mopidy_uri,

View File

@ -62,7 +62,7 @@ def fetch_metadata_from_rss(uri: str) -> dict[str, Any]:
podcast_data["title"] = entry.title podcast_data["title"] = entry.title
podcast_data["episode_num"] = int(entry.get("itunes_episode", 0)) podcast_data["episode_num"] = int(entry.get("itunes_episode", 0))
podcast_data["pub_date"] = parse(entry.get("published", None)) podcast_data["pub_date"] = parse(entry.get("published", None))
podcast_data["run_time_seconds"] = parse_duration(entry.get("itunes_duration", None)) podcast_data["base_run_time_seconds"] = parse_duration(entry.get("itunes_duration", None))
# podcast_data["description"] = entry.get("description", None) # podcast_data["description"] = entry.get("description", None)
# podcast_data["episode_url"] = entry.enclosures[0].href if entry.get("enclosures") else None # podcast_data["episode_url"] = entry.enclosures[0].href if entry.get("enclosures") else None
return podcast_data return podcast_data

View File

@ -0,0 +1,26 @@
# Generated by Django 4.2.19 on 2025-10-30 01:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('puzzles', '0003_rename_igdb_id_puzzle_ipdb_id_and_more'),
]
operations = [
migrations.RemoveField(
model_name='puzzle',
name='run_time_seconds',
),
migrations.RemoveField(
model_name='puzzle',
name='run_time_ticks',
),
migrations.AddField(
model_name='puzzle',
name='base_run_time_seconds',
field=models.IntegerField(blank=True, null=True),
),
]

View File

@ -20,6 +20,7 @@ PLAY_AGAIN_MEDIA = {
"beers": "Beer", "beers": "Beer",
"foods": "Food", "foods": "Food",
"locations": "GeoLocation", "locations": "GeoLocation",
"videos": "Video",
} }
MEDIA_END_PADDING_SECONDS = { MEDIA_END_PADDING_SECONDS = {

View File

@ -57,14 +57,20 @@ class ScrobblableMixin(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL) uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
title = models.CharField(max_length=255, **BNULL) title = models.CharField(max_length=255, **BNULL)
run_time_seconds = models.IntegerField(default=900) base_run_time_seconds = models.IntegerField(**BNULL)
run_time_ticks = models.PositiveBigIntegerField(**BNULL)
genre = TaggableManager(through=ObjectWithGenres, blank=True) genre = TaggableManager(through=ObjectWithGenres, blank=True)
class Meta: class Meta:
abstract = True abstract = True
@property
def run_time_seconds(self) -> int:
run_time = 900
if self.base_run_time_seconds:
run_time = self.base_run_time_seconds
return run_time
@classmethod @classmethod
def is_long_play_media(cls) -> bool: def is_long_play_media(cls) -> bool:
return False return False
@ -93,7 +99,7 @@ class ScrobblableMixin(TimeStampedModel):
"[scrobble_for_user] called", "[scrobble_for_user] called",
extra={ extra={
"id": self.id, "id": self.id,
"media_type": self.__class__, "media_type": self.__class__.__name__,
"user_id": user_id, "user_id": user_id,
"scrobble_data": scrobble_data, "scrobble_data": scrobble_data,
}, },

View File

@ -355,29 +355,6 @@ def manual_scrobble_board_game(
return Scrobble.create_or_update(boardgame, user_id, scrobble_dict) return Scrobble.create_or_update(boardgame, user_id, scrobble_dict)
def find_and_enrich_board_game_data(game_dict: dict) -> BoardGame | None:
"""TODO Move this to a utility somewhere"""
game = BoardGame.find_or_create(game_dict.get("bggId"))
if game:
game.cooperative = game_dict.get("cooperative", False)
game.highest_wins = game_dict.get("highestWins", True)
game.no_points = game_dict.get("noPoints", False)
game.uses_teams = game_dict.get("useTeams", False)
game.bgstats_id = game_dict.get("uuid", None)
if not game.rating:
game.rating = game_dict.get("rating") / 10
game.save()
if game_dict.get("designers"):
for designer_name in game_dict.get("designers", "").split(", "):
designer, created = BoardGameDesigner.objects.get_or_create(
name=designer_name
)
game.designers.add(designer.id)
return game
def email_scrobble_board_game( def email_scrobble_board_game(
bgstat_data: dict[str, Any], user_id: int bgstat_data: dict[str, Any], user_id: int
) -> list[Scrobble]: ) -> list[Scrobble]:
@ -407,11 +384,11 @@ def email_scrobble_board_game(
log_data = {} log_data = {}
for game in game_list: for game in game_list:
logger.info(f"Finding and enriching {game.get('name')}") logger.info(f"Finding and enriching {game.get('name')}")
enriched_game = find_and_enrich_board_game_data(game) game_obj = BoardGame.find_or_create(game.get("bggId"), data=game)
if game.get("isBaseGame"): if game.get("isBaseGame"):
base_games[game.get("id")] = enriched_game base_games[game.get("id")] = game_obj
if game.get("isExpansion"): if game.get("isExpansion"):
expansions[game.get("id")] = enriched_game expansions[game.get("id")] = game_obj
locations = {} locations = {}
for location_dict in bgstat_data.get("locations", []): for location_dict in bgstat_data.get("locations", []):
@ -796,7 +773,6 @@ def emacs_scrobble_task(
user_id=user_id, user_id=user_id,
in_progress=True, in_progress=True,
log__orgmode_id=orgmode_id, log__orgmode_id=orgmode_id,
log__source="orgmode",
task=task, task=task,
).last() ).last()

View File

@ -604,8 +604,7 @@ def scrobble_start(request, uuid):
"[scrobble_start] media object not found", "[scrobble_start] media object not found",
extra={"uuid": uuid, "user_id": user.id}, extra={"uuid": uuid, "user_id": user.id},
) )
# TODO Log that we couldn't find a media obj to scrobble raise Exception("No media object provided to scrobble")
return
scrobble = None scrobble = None
user_id = request.user.id user_id = request.user.id
@ -949,12 +948,15 @@ class ScrobbleDetailView(DetailView):
log = self.object.log or {} log = self.object.log or {}
initial_notes = log.get("notes", []) initial_notes = log.get("notes", [])
if isinstance(initial_notes, list): if isinstance(initial_notes, list) 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)
notes_str_fixed = notes_str.encode("utf-8").decode(
"unicode_escape" notes_str_fixed = notes_str.encode("utf-8").decode(
) "unicode_escape"
log["notes"] = notes_str_fixed )
log["notes"] = notes_str_fixed
return FormClass(initial=log) return FormClass(initial=log)

View File

@ -0,0 +1,26 @@
# Generated by Django 4.2.19 on 2025-10-30 01:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('sports', '0015_alter_sportevent_run_time_seconds'),
]
operations = [
migrations.RemoveField(
model_name='sportevent',
name='run_time_seconds',
),
migrations.RemoveField(
model_name='sportevent',
name='run_time_ticks',
),
migrations.AddField(
model_name='sportevent',
name='base_run_time_seconds',
field=models.IntegerField(blank=True, null=True),
),
]

View File

@ -0,0 +1,26 @@
# Generated by Django 4.2.19 on 2025-10-30 01:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tasks', '0004_alter_task_run_time_seconds'),
]
operations = [
migrations.RemoveField(
model_name='task',
name='run_time_seconds',
),
migrations.RemoveField(
model_name='task',
name='run_time_ticks',
),
migrations.AddField(
model_name='task',
name='base_run_time_seconds',
field=models.IntegerField(blank=True, null=True),
),
]

View File

@ -39,15 +39,22 @@ class TaskLogData(BaseLogData):
def notes_as_str(self) -> str: def notes_as_str(self) -> str:
"""Return formatted notes with line breaks and no keys""" """Return formatted notes with line breaks and no keys"""
note_block = "" labels_str = ""
if isinstance(self.notes, list): if self.labels:
note_block = "</br>".join(self.notes) labels_str = ", ".join(self.labels)
# DEPRECATED ... we don't store notes in dicts anymore lines = []
if isinstance(self.notes, dict): if self.notes:
for id, content in self.notes.items(): for note in self.notes:
note_block += content + "</br>" if isinstance(note, dict):
return note_block timestamp, note_text = next(iter(note.items()))
# Flatten newlines and clean whitespace
note_text = " ".join(note_text.strip().split())
lines.append(f"{timestamp}: {note_text} [{labels_str}]")
if isinstance(note, str):
lines.append(note)
return "\n".join(lines)
class Task(LongPlayScrobblableMixin): class Task(LongPlayScrobblableMixin):
@ -82,14 +89,14 @@ class Task(LongPlayScrobblableMixin):
def subtitle_for_user(self, user_id): def subtitle_for_user(self, user_id):
scrobble = self.scrobbles(user_id).first() scrobble = self.scrobbles(user_id).first()
return scrobble.logdata.title or "" return scrobble.logdata.title or scrobble.log.get("title")
@classmethod @classmethod
def find_or_create(cls, title: str) -> "Task": def find_or_create(cls, title: str) -> "Task":
task, created = cls.objects.get_or_create(title=title) task, created = cls.objects.get_or_create(title=title)
if created: if created:
task.run_time_seconds = 1800 task.base_run_time_seconds = 1800
task.save(update_fields=["run_time_seconds"]) task.save(update_fields=["base_run_time_seconds"])
return task return task

View File

@ -0,0 +1,26 @@
# Generated by Django 4.2.19 on 2025-10-30 01:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('trails', '0005_trail_alltrails_id_trail_gaiagps_id'),
]
operations = [
migrations.RemoveField(
model_name='trail',
name='run_time_seconds',
),
migrations.RemoveField(
model_name='trail',
name='run_time_ticks',
),
migrations.AddField(
model_name='trail',
name='base_run_time_seconds',
field=models.IntegerField(blank=True, null=True),
),
]

View File

@ -0,0 +1,26 @@
# Generated by Django 4.2.19 on 2025-10-30 01:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('videogames', '0012_alter_videogame_run_time_seconds'),
]
operations = [
migrations.RemoveField(
model_name='videogame',
name='run_time_seconds',
),
migrations.RemoveField(
model_name='videogame',
name='run_time_ticks',
),
migrations.AddField(
model_name='videogame',
name='base_run_time_seconds',
field=models.IntegerField(blank=True, null=True),
),
]

View File

@ -205,7 +205,7 @@ class VideoGame(LongPlayScrobblableMixin):
@property @property
def seconds_for_completion(self) -> int: def seconds_for_completion(self) -> int:
completion_time = self.run_time_ticks completion_time = self.run_time_seconds
if not completion_time: if not completion_time:
# Default to 10 hours, why not # Default to 10 hours, why not
completion_time = 10 * 60 * 60 completion_time = 10 * 60 * 60
@ -237,9 +237,9 @@ class VideoGame(LongPlayScrobblableMixin):
if self.igdb_id: if self.igdb_id:
load_game_data_from_igdb(self.id, self.igdb_id) load_game_data_from_igdb(self.id, self.igdb_id)
if (not self.run_time_ticks or force_update) and self.main_story_time: if force_update and self.main_story_time:
self.run_time_seconds = self.main_story_time self.base_run_time_seconds = self.main_story_time
self.save(update_fields=["run_time_seconds"]) self.save(update_fields=["base_run_time_seconds"])
@classmethod @classmethod
def find_or_create(cls, data_dict: dict) -> "Game": def find_or_create(cls, data_dict: dict) -> "Game":

View File

@ -21,7 +21,7 @@ class VideoType(Enum):
class VideoMetadata: class VideoMetadata:
title: str title: str
video_type: VideoType = VideoType.UNKNOWN video_type: VideoType = VideoType.UNKNOWN
run_time_seconds: int = ( base_run_time_seconds: int = (
60 # Silly default, but things break if this is 0 or null 60 # Silly default, but things break if this is 0 or null
) )
imdb_id: Optional[str] imdb_id: Optional[str]
@ -51,11 +51,11 @@ class VideoMetadata:
self, self,
imdb_id: Optional[str] = "", imdb_id: Optional[str] = "",
youtube_id: Optional[str] = "", youtube_id: Optional[str] = "",
run_time_seconds: int = 900, base_run_time_seconds: int = 900,
): ):
self.imdb_id = imdb_id self.imdb_id = imdb_id
self.youtube_id = youtube_id self.youtube_id = youtube_id
self.run_time_seconds = run_time_seconds self.base_run_time_seconds = base_run_time_seconds
def as_dict_with_cover_and_genres(self) -> tuple: def as_dict_with_cover_and_genres(self) -> tuple:
video_dict = vars(self) video_dict = vars(self)

View File

@ -0,0 +1,26 @@
# Generated by Django 4.2.19 on 2025-10-30 01:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('videos', '0023_video_tmdb_rating'),
]
operations = [
migrations.RemoveField(
model_name='video',
name='run_time_seconds',
),
migrations.RemoveField(
model_name='video',
name='run_time_ticks',
),
migrations.AddField(
model_name='video',
name='base_run_time_seconds',
field=models.IntegerField(blank=True, null=True),
),
]

View File

@ -84,7 +84,7 @@ def lookup_video_from_imdb(
video_metadata.tv_series_id = series.id video_metadata.tv_series_id = series.id
if imdb_result.get("runtimes"): if imdb_result.get("runtimes"):
video_metadata.run_time_seconds = ( video_metadata.base_run_time_seconds = (
int(imdb_result.get("runtimes")[0]) * 60 int(imdb_result.get("runtimes")[0]) * 60
) )

View File

@ -108,7 +108,7 @@ def lookup_video_from_skatevideosite(title: str) -> Optional[dict]:
.replace("(", "") .replace("(", "")
.replace(")", "") .replace(")", "")
) )
run_time_seconds = ( base_run_time_seconds = (
int( int(
detail_soup.find("div", class_="p-1") detail_soup.find("div", class_="p-1")
.contents[-1] .contents[-1]
@ -123,6 +123,6 @@ def lookup_video_from_skatevideosite(title: str) -> Optional[dict]:
"title": str(result.find("img").get("alt").replace(" cover", "")), "title": str(result.find("img").get("alt").replace(" cover", "")),
"video_type": "S", "video_type": "S",
"year": year, "year": year,
"run_time_seconds": run_time_seconds, "base_run_time_seconds": run_time_seconds,
"cover_url": str(result.find("img").get("src")), "cover_url": str(result.find("img").get("src")),
} }

View File

@ -72,7 +72,7 @@ def lookup_video_from_tmdb(
return video_metadata return video_metadata
video_metadata.tmdb_id = media.id video_metadata.tmdb_id = media.id
video_metadata.run_time_seconds = media.runtime * 60 video_metadata.base_run_time_seconds = media.runtime * 60
video_metadata.plot = media.overview video_metadata.plot = media.overview
video_metadata.overview = media.overview video_metadata.overview = media.overview
video_metadata.tmdb_rating = media.vote_average video_metadata.tmdb_rating = media.vote_average

View File

@ -65,7 +65,7 @@ def lookup_video_from_youtube(youtube_id: str) -> VideoMetadata:
video_metadata.channel_id = channel.id video_metadata.channel_id = channel.id
video_metadata.title = yt_metadata.get("title", "") video_metadata.title = yt_metadata.get("title", "")
video_metadata.run_time_seconds = duration video_metadata.base_run_time_seconds = duration
video_metadata.video_type = VideoType.YOUTUBE.value video_metadata.video_type = VideoType.YOUTUBE.value
video_metadata.youtube_id = youtube_id video_metadata.youtube_id = youtube_id
video_metadata.cover_url = ( video_metadata.cover_url = (

View File

@ -27,10 +27,11 @@ class SeriesDetailView(LoginRequiredMixin, generic.DetailView):
context_data["scrobbles"] = self.object.scrobbles_for_user(user_id) context_data["scrobbles"] = self.object.scrobbles_for_user(user_id)
next_episode_id = self.object.last_scrobbled_episode( next_episode_id = self.object.last_scrobbled_episode(
user_id user_id
).next_imdb_id ).next_imdb_id or ""
if self.object.is_episode_playing(user_id): if self.object.is_episode_playing(user_id):
next_episode_id = "" next_episode_id = ""
context_data["next_episode_id"] = "tt" + next_episode_id if next_episode_id:
context_data["next_episode_id"] = "tt" + next_episode_id
return context_data return context_data

View File

@ -0,0 +1,26 @@
# Generated by Django 4.2.19 on 2025-10-30 01:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('webpages', '0005_alter_webpage_run_time_seconds'),
]
operations = [
migrations.RemoveField(
model_name='webpage',
name='run_time_seconds',
),
migrations.RemoveField(
model_name='webpage',
name='run_time_ticks',
),
migrations.AddField(
model_name='webpage',
name='base_run_time_seconds',
field=models.IntegerField(blank=True, null=True),
),
]

View File

@ -220,8 +220,8 @@ class WebPage(ScrobblableMixin):
if not self.domain or force: if not self.domain or force:
self._update_domain_from_url() self._update_domain_from_url()
if not self.run_time_seconds or force: if not self.base_run_time_seconds or force:
self.run_time_seconds = self.estimated_time_to_read_in_seconds self.base_run_time_seconds = self.estimated_time_to_read_in_seconds
if save: if save:
self.save() self.save()