Compare commits

...

54 Commits
29 ... 37

Author SHA1 Message Date
c2ba8a48ac [release] Update project log for 37 2025-11-17 22:07:23 -05:00
1530de3188 [scrobbles] Remove debug printing 2025-11-17 21:45:38 -05:00
2d235c0577 [scrobbles] Fix missing note error 2025-11-17 21:45:17 -05:00
b0eb58953b [food] Add calories if they're missing 2025-11-17 21:45:08 -05:00
7309181fed [scrobbles] Fix dataclass dict converstion error 2025-11-17 21:44:50 -05:00
971fee5b4b [food] No need to rate food 2025-11-17 21:44:39 -05:00
920a9180c8 [books] Remove unused max_length parameter 2025-11-17 21:44:19 -05:00
d568a377f0 [templates] Add longplay complete to templates 2025-11-17 20:36:50 -05:00
3851624dd7 [tests] Fix IMDB test and bump reqs 2025-11-17 20:09:34 -05:00
8c865fe008 [release] Release version 36 2025-11-17 18:15:21 -05:00
572dbf7a88 [videos] Clean up utilities 2025-11-17 18:13:40 -05:00
7addd50577 [videos] Refactor lookup to use new library 2025-11-17 17:56:15 -05:00
cd5dc25642 [release] Update project file for release 35 2025-11-17 16:35:09 -05:00
9c2355978e [videos] Fix lookup for ids for videos 2025-11-17 16:34:38 -05:00
4b9b785e50 [videos] Add youtube link to detail page 2025-11-17 16:30:40 -05:00
050b2b9d77 [videos] Fix imdb lookups with new library 2025-11-17 16:25:46 -05:00
d12cca304f [api] Missed a few api endpoints 2025-11-17 16:11:31 -05:00
8603bbd5cb [api] Add many missing API endpoints 2025-11-17 16:00:10 -05:00
749e74a54c [scrobbles] Fix missed run_time_seconds cleanup 2025-11-05 09:58:48 -05:00
7b3692ef7b [books] Think I had DST logic messed up 2025-11-05 09:58:48 -05:00
c49f6a1740 [scrobbles] Clean up various sources 2025-11-03 08:49:53 -05:00
1d813e4643 [food] Fix error in calorie aggregation 2025-11-03 00:15:09 -05:00
5e0a429d81 [project] Cut version 34 2025-11-02 23:52:48 -05:00
d928d266b9 [videos] Add video to play again media 2025-11-02 23:52:26 -05:00
b4dbbb4211 [boardgames] Deprecate failing tests 2025-11-02 23:45:34 -05:00
dcb5260cfc [boardgames] Tighten up boardgame lookups 2025-11-02 23:43:19 -05:00
a8747dfe77 Update all the places we need base_run_time_seconds now 2025-11-02 21:18:52 -05:00
a474b5df48 [scrobbles] Refactor run time sec to be blank by default 2025-10-29 21:54:18 -04:00
082979bea6 [logs] Fix class name for scrobble_for_user 2025-10-29 19:56:55 -04:00
1275186d86 [videos] Fix next episode error 2025-10-29 19:45:50 -04:00
cd60ac6387 [tasks] Fix emacs not updating or completing 2025-10-29 17:12:48 -04:00
bdfbd3e5c0 [project] Update task list with version 33 2025-10-28 14:57:07 -04:00
dff63f325f [scrobbles] Fix calorie aggregation bug 2025-10-28 14:56:30 -04:00
2b634e3b7e [scrobbles] Fix look up of old scrobbles by total seconds 2025-10-28 14:41:52 -04:00
723d739405 [books] Clean up resume URLs 2025-10-28 14:41:16 -04:00
e62a07af37 [boardgames] Add auth to BGG API call 2025-10-28 14:38:36 -04:00
f86c3b2935 [project] Bump version to 32 2025-10-22 14:20:03 -04:00
050add8543 [books] Add utility urls to model and scrobbles 2025-10-22 14:18:01 -04:00
8faf0296a6 [project] Finish book resume link task 2025-10-22 12:18:40 -04:00
f209f3b107 [books] Set restart and resume urls on comic book scrobbles 2025-10-22 12:18:08 -04:00
b233b60ae0 [books] Add bookmark_url to logdata 2025-10-22 01:00:25 -04:00
e1d4a7c5a4 [books] Fix looking up comic by original title 2025-10-20 22:47:32 -04:00
59e8339e94 [releases] Fix comic books scrobbling, mostly 2025-10-20 17:17:18 -04:00
9277db97e5 [books] Fix comic scrobbles overrwriting one another 2025-10-20 17:15:54 -04:00
e755dc6641 Fix bug where title not found 2025-10-20 17:02:52 -04:00
782f5c15d6 [books] Calc stats and dont die when title not found 2025-10-20 17:02:34 -04:00
2f4fae7d02 [books] Short circut google lookup if it fails 2025-10-20 16:12:01 -04:00
4b7c5aa58d [books] Fix bad lookups for creating books 2025-10-20 16:11:20 -04:00
d4f82f2d6f [releases] Adding comic reading 2025-10-20 15:51:07 -04:00
106d25c20f [webpages] Redirect back to the page 2025-10-20 15:46:28 -04:00
d77caa2783 [scrobblers] Allow stopping reading comics 2025-10-20 15:46:10 -04:00
b5bfad73ef [books] Allow comic scrobbling to update per page 2025-10-20 15:41:02 -04:00
274b2704ed [scrobbles] Clean up type in logs 2025-10-20 14:55:27 -04:00
80fcb6c002 [books] Clean up google searches 2025-10-20 14:54:53 -04:00
105 changed files with 4604 additions and 2891 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 [3/27] * Backlog [0/19]
** 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:
@ -414,7 +414,6 @@ Could be as simple as a JSON form on the scrobble detail page (do I have have on
** TODO [#B] Add webdav syncing to retroarch imports :vrobbler:videogames:webdav:feature:project:personal: ** TODO [#B] Add webdav syncing to retroarch imports :vrobbler:videogames:webdav:feature:project:personal:
** TODO [#B] Add CSV endpoint for book scrobbles that LibraryThing can ingest :personal:project:books:feature:export: ** TODO [#B] Add CSV endpoint for book scrobbles that LibraryThing can ingest :personal:project:books:feature:export:
https://app.todoist.com/app/task/add-a-csv-endpoint-for-users-book-reads-that-library-thing-can-ingest-6X7QPMRp265xMXqg#comment-6X7QrXq6gJjMP4hg https://app.todoist.com/app/task/add-a-csv-endpoint-for-users-book-reads-that-library-thing-can-ingest-6X7QPMRp265xMXqg#comment-6X7QrXq6gJjMP4hg
** TODO [#B] Add youtube link in place of IMDB on video detail page :vrobbler:feature:videos:personal:project:
** TODO [#B] Fix PuzzleLogData has no attribute form :vrobbler:puzzles:personal:project:logdata: ** TODO [#B] Fix PuzzleLogData has no attribute form :vrobbler:puzzles:personal:project:logdata:
** TODO [#B] Scrape ComicBookRoundUp ratings for comic book metadata :vrobbler:books:feature:comicbook:personal:project: ** TODO [#B] Scrape ComicBookRoundUp ratings for comic book metadata :vrobbler:books:feature:comicbook:personal:project:
@ -422,13 +421,82 @@ 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] 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] \\
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.
* 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/
** 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:
:ID: 84064bd6-2258-a4de-f048-b131db9465c9
:END:
** DONE [#B] Add missing API lookups to resolve broken scrobbles endpoint :vrobbler:feature:api:scrobbles:personal:project:
:PROPERTIES:
:ID: 0f668a54-f587-3b17-353e-3a56969d3a82
:END:
** DONE [#A] IMDB lookups are not working :vrobbler:bug:videos:personal:project:
:PROPERTIES:
:ID: d1ba1ca1-509b-13a9-1307-b2dc94a2eafe
:END:
* 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,28 +525,67 @@ 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 * Version 33.0 [3/3]
to be an hour off. I traced this back to being off during DST, so we just need ** DONE [#A] Fix bug where scrobble is_stale only uses seconds not total_seconds :vrobbler:bug:scrobbles:personal:project:
the importer to be aware of whether a user is using DST or not and roll back :PROPERTIES:
an hour for part of the year. Also, we'd need to adjust any old scrobbles that :ID: 7f6070ac-4f67-011d-ebd5-f3dc47da46ed
took place with DST off to roll them back by an hour. :END:
** DONE [#B] Fix duplicatged Read next issue for Comic books :vrobbler:bug:books:personal:project:
:PROPERTIES:
:ID: 97943040-1f03-b0b7-b0aa-123a783e4f7b
:END:
** DONE [#A] Add API authentication to BGG calls :vrobbler:bug:boardgames:personal:project:
:PROPERTIES:
:ID: 4955cc34-0882-50db-92f7-f36a95bf57a4
:END:
<2025-10-28 Tue>
* Version 32.0 [2/2]
** DONE [#B] Save path to reading source on book scrobbles and show it on the detail page :vrobbler:feature:books:personal:project:
:PROPERTIES:
:ID: f1ef3945-e6e4-66c1-b72e-3cede7a0f84a
:END:
** DONE [#B] Move comic resume URL to next page and check if it exists :vrobbler:feature:books:personal:project:
:PROPERTIES:
:ID: 9fe09567-11a3-7083-53c7-07458a9591d0
:END:
* Version 31.0 [3/3]
** DONE [#A] Stop comic book webpage scrobbles from overwriting old scrobbles :vrobbler:personal:bug:books:scrobbling:
:PROPERTIES:
:ID: 4b2ec068-a281-a88b-c31d-6248d6eb0aa0
:END:
** DONE [#A] Add page calculation to manually scrobbled books :vrobbler:personal:feature:books:scrobbling:
:PROPERTIES:
:ID: b2e313b3-5c35-57e7-8933-627535baf34b
:END:
** DONE [#A] Fix bug in scrobbling comics where google fails :vrobbler:personal:bug:books:scrobbling:
:PROPERTIES:
:ID: 9a870c05-6d20-0803-d35d-c03fbe1d0ee1
:END:
* Version 30.0 [3/3]
** DONE [#A] Fix readcomicsonline browsing to update pages :vrobbler:books:feature:comicbook:personal:project:scrobbling:
:PROPERTIES:
:ID: 981b215a-6473-5fc7-d4cc-51b3eddec4c3
:END:
** DONE [#B] Redirect webpages back to the original page when starting or stopping :vrobbler:project:webpages:bug:
:PROPERTIES:
:ID: 6183d03a-452b-51d5-cceb-5bfeada947aa
:END:
** TODO [#A] Allow scrobbling from the Food list page's start links :vrobbler:bug:food:scrobbling:personal:project: ** DONE [#B] Fix ComicVine as source for comic book metadata :vrobbler:books:feature:comicbook:personal:project:scrobbling:
https://life.lab.unbl.ink/scrobble/e39779c8-62a5-46a6-bdef-fb7662810dc6/start/ :PROPERTIES:
** TODO [#A] Puzzles (and all longplays) should have a "Completed?" column on their detail page :vrobbler:bug:puzzles:personal:project: :ID: d22cec3f-117f-f203-33a5-efbefa8a5cee
** TODO [#A] Fix raw text webpage title not truncating to 254 chars :vrobbler:personal:bug:webpages: :END:
- Note taken on [2025-09-30 Tue 09:33]
This may have already been resolved ... need to just confirm it.
* Version 29.0 [1/1] * Version 29.0 [1/1]
** DONE HOTFIX podcast lookups, final ** DONE HOTFIX podcast lookups, final
* Version 28.0 [1/1] * Version 28.0 [1/1]

File diff suppressed because one or more lines are too long

5268
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,7 @@ description = ""
authors = ["Colin Powell <colin@unbl.ink>"] authors = ["Colin Powell <colin@unbl.ink>"]
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = ">=3.9,<3.12" python = ">=3.11,<3.14"
Django = "^4.0.3" Django = "^4.0.3"
django-extensions = "^3.1.5" django-extensions = "^3.1.5"
python-dateutil = "^2.8.2" python-dateutil = "^2.8.2"
@ -16,8 +16,8 @@ httpx = "<=0.27.2"
djangorestframework = "^3.13.1" djangorestframework = "^3.13.1"
Markdown = "^3.3.6" Markdown = "^3.3.6"
django-filter = "^21.1" django-filter = "^21.1"
Pillow = "^9.0.1" Pillow = "^10.0.0"
psycopg2 = "^2.9.3" psycopg2 = "2.9.10"
dj-database-url = "^0.5.0" dj-database-url = "^0.5.0"
django-mathfilters = "^1.0.0" django-mathfilters = "^1.0.0"
django-allauth = "^0.50.0" django-allauth = "^0.50.0"
@ -28,7 +28,7 @@ django-markdownify = "^0.9.1"
gunicorn = "^20.1.0" gunicorn = "^20.1.0"
django-simple-history = "^3.1.1" django-simple-history = "^3.1.1"
musicbrainzngs = "^0.7.1" musicbrainzngs = "^0.7.1"
cinemagoer = "^2022.12.27" cinemagoerng = {git = "https://github.com/cinemagoer/cinemagoerng"}
pysportsdb = "^0.1.0" pysportsdb = "^0.1.0"
pytz = "^2022.7.1" pytz = "^2022.7.1"
django-redis = "^5.2.0" django-redis = "^5.2.0"
@ -41,11 +41,11 @@ beautifulsoup4 = "^4.11.2"
django-storages = "^1.13.2" django-storages = "^1.13.2"
stream-sqlite = "^0.0.41" stream-sqlite = "^0.0.41"
ipython = "^8.14.0" ipython = "^8.14.0"
pendulum = "^2.1.2" pendulum = "^3"
trafilatura = "^1.6.3" trafilatura = "^1.6.3"
django-imagekit = "^5.0.0" django-imagekit = "^5.0.0"
thefuzz = "^0.22.1" thefuzz = "^0.22.1"
dataclass-wizard = "0.22.0" dataclass-wizard = "^0.35.0"
webdavclient3 = "^3.14.6" webdavclient3 = "^3.14.6"
boto3 = "^1.35.37" boto3 = "^1.35.37"
urllib3 = "<2" urllib3 = "<2"
@ -57,6 +57,8 @@ orgparse = "^0.4.20250520"
tmdbv3api = "^1.9.0" tmdbv3api = "^1.9.0"
themoviedb = "^1.0.2" themoviedb = "^1.0.2"
feedparser = "^6.0.12" feedparser = "^6.0.12"
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

@ -1,6 +1,10 @@
from videos.sources.imdb import lookup_video_from_imdb from videos.sources.imdb import lookup_video_from_imdb
def test_lookup_imdb_without_tt():
def test_lookup_imdb():
metadata = lookup_video_from_imdb("8946378") 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" assert metadata.title == "Knives Out"

View File

View File

@ -0,0 +1,18 @@
from rest_framework import serializers
from beers.models import Beer, BeerProducer, BeerStyle
class BeerSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Beer
fields = "__all__"
class BeerProducerSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = BeerProducer
fields = "__all__"
class BeerStyleSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = BeerStyle
fields = "__all__"

View File

@ -0,0 +1,19 @@
from rest_framework import permissions, viewsets
from beers.api import serializers
from beers import models
class BeerViewSet(viewsets.ModelViewSet):
queryset = models.Beer.objects.all().order_by("-created")
serializer_class = serializers.BeerSerializer
permission_classes = [permissions.IsAuthenticated]
class BeerProducerViewSet(viewsets.ModelViewSet):
queryset = models.BeerProducer.objects.all().order_by("-created")
serializer_class = serializers.BeerProducerSerializer
permission_classes = [permissions.IsAuthenticated]
class BeerStyleViewSet(viewsets.ModelViewSet):
queryset = models.BeerStyle.objects.all().order_by("-created")
serializer_class = serializers.BeerStyleSerializer
permission_classes = [permissions.IsAuthenticated]

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,22 @@
from boardgames import models
from rest_framework import serializers
class BoardGameDesignerSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = models.BoardGameDesigner
fields = "__all__"
class BoardGamePublisherSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = models.BoardGamePublisher
fields = "__all__"
class BoardGameLocationSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = models.BoardGameLocation
fields = "__all__"
class BoardGameSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = models.BoardGame
fields = "__all__"

View File

@ -0,0 +1,28 @@
from rest_framework import permissions, viewsets
from boardgames.api import serializers
from boardgames import models
class BoardGameDesignerViewSet(viewsets.ModelViewSet):
queryset = models.BoardGameDesigner.objects.all().order_by("-created")
serializer_class = serializers.BoardGameDesignerSerializer
permission_classes = [permissions.IsAuthenticated]
class BoardGamePublisherViewSet(viewsets.ModelViewSet):
queryset = models.BoardGamePublisher.objects.all().order_by("-created")
serializer_class = serializers.BoardGamePublisherSerializer
permission_classes = [permissions.IsAuthenticated]
class BoardGameLocationViewSet(viewsets.ModelViewSet):
queryset = models.BoardGameLocation.objects.all().order_by("-created")
serializer_class = serializers.BoardGameLocationSerializer
permission_classes = [permissions.IsAuthenticated]
class BoardGameViewSet(viewsets.ModelViewSet):
queryset = models.BoardGame.objects.all().order_by("-created")
serializer_class = serializers.BoardGameSerializer
permission_classes = [permissions.IsAuthenticated]

View File

@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Optional
import requests import requests
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.conf import settings
User = get_user_model() User = get_user_model()
if TYPE_CHECKING: if TYPE_CHECKING:
@ -17,6 +18,8 @@ SEARCH_ID_URL = (
"https://boardgamegeek.com/xmlapi/search?search={query}&exact=1" "https://boardgamegeek.com/xmlapi/search?search={query}&exact=1"
) )
GAME_ID_URL = "https://boardgamegeek.com/xmlapi/boardgame/{id}" GAME_ID_URL = "https://boardgamegeek.com/xmlapi/boardgame/{id}"
BGG_ACCESS_TOKEN = getattr(settings, "BGG_ACCESS_TOKEN", "")
BASE_HEADERS = {"User-Agent": "Vrobbler 31.0", "Authorization": f"Bearer {BGG_ACCESS_TOKEN}"}
def take_first(thing: Optional[list]) -> str: def take_first(thing: Optional[list]) -> str:
@ -37,10 +40,9 @@ def take_first(thing: Optional[list]) -> str:
def lookup_boardgame_id_from_bgg(title: str) -> Optional[int]: def lookup_boardgame_id_from_bgg(title: str) -> Optional[int]:
soup = None soup = None
headers = {"User-Agent": "Vrobbler 0.11.12"}
game_id = None game_id = None
url = SEARCH_ID_URL.format(query=title) url = SEARCH_ID_URL.format(query=title)
r = requests.get(url, headers=headers) r = requests.get(url, headers=BASE_HEADERS)
if r.status_code == 200: if r.status_code == 200:
soup = BeautifulSoup(r.text, "xml") soup = BeautifulSoup(r.text, "xml")
@ -57,7 +59,6 @@ def lookup_boardgame_id_from_bgg(title: str) -> Optional[int]:
def lookup_boardgame_from_bgg(lookup_id: str) -> dict: def lookup_boardgame_from_bgg(lookup_id: str) -> dict:
soup = None soup = None
game_dict = {} game_dict = {}
headers = {"User-Agent": "Vrobbler 0.11.12"}
title = "" title = ""
bgg_id = None bgg_id = None
@ -73,7 +74,7 @@ def lookup_boardgame_from_bgg(lookup_id: str) -> dict:
bgg_id = lookup_boardgame_id_from_bgg(title) bgg_id = lookup_boardgame_id_from_bgg(title)
url = GAME_ID_URL.format(id=bgg_id) url = GAME_ID_URL.format(id=bgg_id)
r = requests.get(url, headers=headers) r = requests.get(url, headers=BASE_HEADERS)
if r.status_code == 200: if r.status_code == 200:
soup = BeautifulSoup(r.text, "xml") soup = BeautifulSoup(r.text, "xml")
@ -109,7 +110,8 @@ def push_scrobble_to_bgg(scrobble: "Scrobble", user: User) -> Optional[bool]:
login_payload = { login_payload = {
"credentials": {"username": bgg_username, "password": bgg_password} "credentials": {"username": bgg_username, "password": bgg_password}
} }
headers = {"content-type": "application/json"} headers = BASE_HEADERS
headers["content-type"] = "application/json"
# TODO Look up past plays for scrobble.media_obj.bggeek_id, and make sure we haven't scrobbled this before # TODO Look up past plays for scrobble.media_obj.bggeek_id, and make sure we haven't scrobbled this before

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

@ -21,7 +21,8 @@ class BookAdmin(admin.ModelAdmin):
date_hierarchy = "created" date_hierarchy = "created"
list_display = ( list_display = (
"title", "title",
"subtitle", "author",
"issue_or_volume",
"isbn_13", "isbn_13",
"first_publish_year", "first_publish_year",
"pages", "pages",
@ -32,6 +33,9 @@ class BookAdmin(admin.ModelAdmin):
ScrobbleInline, ScrobbleInline,
] ]
def issue_or_volume(self, obj):
return obj.issue_number or obj.volume_number
@admin.register(Paper) @admin.register(Paper)
class BookAdmin(admin.ModelAdmin): class BookAdmin(admin.ModelAdmin):

View File

@ -1,19 +1,16 @@
from rest_framework import permissions, viewsets from rest_framework import permissions, viewsets
from books.api.serializers import ( from books.api import serializers
AuthorSerializer, from books import models
BookSerializer,
)
from books.models import Author, Book
class AuthorViewSet(viewsets.ModelViewSet): class AuthorViewSet(viewsets.ModelViewSet):
queryset = Author.objects.all().order_by("-created") queryset = models.Author.objects.all().order_by("-created")
serializer_class = AuthorSerializer serializer_class = serializers.AuthorSerializer
permission_classes = [permissions.IsAuthenticated] permission_classes = [permissions.IsAuthenticated]
class BookViewSet(viewsets.ModelViewSet): class BookViewSet(viewsets.ModelViewSet):
queryset = Book.objects.all().order_by("-created") queryset = models.Book.objects.all().order_by("-created")
serializer_class = BookSerializer serializer_class = serializers.BookSerializer
permission_classes = [permissions.IsAuthenticated] permission_classes = [permissions.IsAuthenticated]

View File

@ -5,3 +5,5 @@ BOOKS_TITLES_TO_IGNORE = [
"zb2rhkSwygt9vjkAEBj7tP5KVgFqejJqsJ2W3bYsrgiiKK8XL", "zb2rhkSwygt9vjkAEBj7tP5KVgFqejJqsJ2W3bYsrgiiKK8XL",
"zb2rhchGpo7P27mofV9hYjT63d9ZaQnbQ6LSfzmkvsYzvARif", "zb2rhchGpo7P27mofV9hYjT63d9ZaQnbQ6LSfzmkvsYzvARif",
] ]
READCOMICSONLINE_URL = "https://readcomicsonline.ru"

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()
@ -286,11 +286,11 @@ def build_scrobbles_from_book_map(
) )
# Adjust for Daylight Saving Time # Adjust for Daylight Saving Time
if timestamp.dst() == timedelta( #if timestamp.dst() == timedelta(
0 # 0
) or stop_timestamp.dst() == timedelta(0): #) or stop_timestamp.dst() == timedelta(0):
timestamp = timestamp - timedelta(hours=1) # timestamp = timestamp - timedelta(hours=1)
stop_timestamp = stop_timestamp - timedelta(hours=1) # stop_timestamp = stop_timestamp - timedelta(hours=1)
scrobble = Scrobble.objects.filter( scrobble = Scrobble.objects.filter(
timestamp=timestamp, timestamp=timestamp,

View File

@ -0,0 +1,33 @@
# Generated by Django 4.2.19 on 2025-10-20 18:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('books', '0028_delete_page'),
]
operations = [
migrations.AddField(
model_name='book',
name='comicvine_id',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='book',
name='issue_number',
field=models.IntegerField(blank=True, max_length=5, null=True),
),
migrations.AddField(
model_name='book',
name='original_title',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='book',
name='volume_number',
field=models.IntegerField(blank=True, max_length=5, null=True),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.19 on 2025-10-22 16:29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('books', '0029_book_comicvine_id_book_issue_number_and_more'),
]
operations = [
migrations.AddField(
model_name='book',
name='readcomics_url',
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.19 on 2025-10-22 17:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('books', '0030_book_readcomics_url'),
]
operations = [
migrations.AddField(
model_name='book',
name='next_readcomics_url',
field=models.CharField(blank=True, max_length=255, null=True),
),
]

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

@ -1,15 +1,19 @@
import logging
from collections import OrderedDict from collections import OrderedDict
from dataclasses import dataclass from dataclasses import dataclass
import logging
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from uuid import uuid4 from uuid import uuid4
import requests import requests
from books.constants import READCOMICSONLINE_URL
from books.openlibrary import ( from books.openlibrary import (
lookup_author_from_openlibrary, lookup_author_from_openlibrary,
lookup_book_from_openlibrary, lookup_book_from_openlibrary,
) )
from books.sources.google import lookup_book_from_google
from books.sources.semantic import lookup_paper_from_semantic
from books.utils import get_comic_issue_url
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
@ -18,27 +22,25 @@ from django.urls import reverse
from django_extensions.db.models import TimeStampedModel from django_extensions.db.models import TimeStampedModel
from imagekit.models import ImageSpecField from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit from imagekit.processors import ResizeToFit
from scrobbles.dataclasses import BaseLogData, LongPlayLogData
from scrobbles.mixins import ( from scrobbles.mixins import (
LongPlayScrobblableMixin, LongPlayScrobblableMixin,
ObjectWithGenres, ObjectWithGenres,
ScrobblableConstants, ScrobblableConstants,
) )
from scrobbles.utils import get_scrobbles_for_media from scrobbles.utils import get_scrobbles_for_media, next_url_if_exists
from taggit.managers import TaggableManager from taggit.managers import TaggableManager
from thefuzz import fuzz from thefuzz import fuzz
from vrobbler.apps.books.comicvine import (
ComicVineClient,
lookup_comic_from_comicvine,
)
from vrobbler.apps.books.locg import ( from vrobbler.apps.books.locg import (
lookup_comic_by_locg_slug, lookup_comic_by_locg_slug,
lookup_comic_from_locg, lookup_comic_from_locg,
lookup_comic_writer_by_locg_slug, lookup_comic_writer_by_locg_slug,
) )
from books.sources.google import lookup_book_from_google from vrobbler.apps.books.sources.comicvine import (
from books.sources.semantic import lookup_paper_from_semantic ComicVineClient,
from scrobbles.dataclasses import BaseLogData, LongPlayLogData lookup_comic_from_comicvine,
)
COMICVINE_API_KEY = getattr(settings, "COMICVINE_API_KEY", "") COMICVINE_API_KEY = getattr(settings, "COMICVINE_API_KEY", "")
@ -62,6 +64,7 @@ class BookLogData(BaseLogData, LongPlayLogData):
pages_read: Optional[int] = None pages_read: Optional[int] = None
page_start: Optional[int] = None page_start: Optional[int] = None
page_end: Optional[int] = None page_end: Optional[int] = None
resume_url: Optional[str] = None
_excluded_fields = {"koreader_hash", "page_data"} _excluded_fields = {"koreader_hash", "page_data"}
@ -135,6 +138,7 @@ class Book(LongPlayScrobblableMixin):
) )
title = models.CharField(max_length=255) title = models.CharField(max_length=255)
original_title = models.CharField(max_length=255, **BNULL)
authors = models.ManyToManyField(Author, blank=True) authors = models.ManyToManyField(Author, blank=True)
koreader_data_by_hash = models.JSONField(**BNULL) koreader_data_by_hash = models.JSONField(**BNULL)
isbn_13 = models.CharField(max_length=255, **BNULL) isbn_13 = models.CharField(max_length=255, **BNULL)
@ -145,6 +149,13 @@ class Book(LongPlayScrobblableMixin):
publish_date = models.DateField(**BNULL) publish_date = models.DateField(**BNULL)
publisher = models.CharField(max_length=255, **BNULL) publisher = models.CharField(max_length=255, **BNULL)
first_sentence = models.TextField(**BNULL) first_sentence = models.TextField(**BNULL)
# ComicVine
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(**BNULL)
volume_number = models.IntegerField(**BNULL)
# OpenLibrary
openlibrary_id = models.CharField(max_length=255, **BNULL) openlibrary_id = models.CharField(max_length=255, **BNULL)
cover = models.ImageField(upload_to="books/covers/", **BNULL) cover = models.ImageField(upload_to="books/covers/", **BNULL)
cover_small = ImageSpecField( cover_small = ImageSpecField(
@ -163,7 +174,11 @@ class Book(LongPlayScrobblableMixin):
genre = TaggableManager(through=ObjectWithGenres) genre = TaggableManager(through=ObjectWithGenres)
def __str__(self): def __str__(self) -> str:
if self.issue_number and "Issue" not in str(self.title):
return f"{self.title} - Issue {self.issue_number}"
if self.volume_number and "Volume" not in str(self.title):
return f"{self.title} - Volume {self.volume_number}"
return f"{self.title}" return f"{self.title}"
@property @property
@ -188,9 +203,45 @@ class Book(LongPlayScrobblableMixin):
def get_absolute_url(self): def get_absolute_url(self):
return reverse("books:book_detail", kwargs={"slug": self.uuid}) return reverse("books:book_detail", kwargs={"slug": self.uuid})
@classmethod
def get_from_comicvine(cls, title: str, overwrite: bool = False, force_new: bool =False) -> "Book":
book, created = cls.objects.get_or_create(title=title)
if not created:
return book
book_dict = lookup_comic_from_comicvine(title)
if created or overwrite:
author_list = []
author_dicts = book_dict.pop("author_dicts")
if author_dicts:
for author_dict in author_dicts:
if author_dict.get("authorId"):
author, a_created = Author.objects.get_or_create(
semantic_id=author_dict.get("authorId")
)
author_list.append(author)
if a_created:
author.name = author_dict.get("name")
author.save()
# TODO enrich author?
...
for k, v in book_dict.items():
setattr(book, k, v)
book.save()
if author_list:
book.authors.add(*author_list)
genres = book_dict.pop("genres", [])
if genres:
book.genre.add(*genres)
return book
@classmethod @classmethod
def find_or_create( def find_or_create(
cls, title: str, enrich: bool = False, commit: bool = True cls, title: str, url: str = "", enrich: bool = False, commit: bool = True
): ):
"""Given a title, get a Book instance. """Given a title, get a Book instance.
@ -201,7 +252,7 @@ class Book(LongPlayScrobblableMixin):
like to batch create, use commit=False and you'll get an unsaved but enriched like to batch create, use commit=False and you'll get an unsaved but enriched
instance back which you can then save at your convenience.""" instance back which you can then save at your convenience."""
# TODO use either a Google Books id identifier or author name like for tracks # TODO use either a Google Books id identifier or author name like for tracks
book, created = cls.objects.get_or_create(title=title) book, created = cls.objects.get_or_create(original_title=title)
if not created: if not created:
logger.info( logger.info(
"Found exact match for book by title", extra={"title": title} "Found exact match for book by title", extra={"title": title}
@ -214,15 +265,22 @@ class Book(LongPlayScrobblableMixin):
) )
return book return book
book_dict = lookup_book_from_google(title) book_dict = None
if READCOMICSONLINE_URL in url:
book_dict = lookup_comic_from_comicvine(title)
book_dict["readcomics_url"] = get_comic_issue_url(url)
book_dict["next_readcomics_url"] = next_url_if_exists(book_dict["readcomics_url"])
if not book_dict:
book_dict = lookup_book_from_google(title)
if not book_dict:
logger.warning("No book found in any source, using data as is", extra={"title": title})
author_list = [] author_list = []
authors = book_dict.pop("authors") authors = book_dict.pop("authors", [])
cover_url = book_dict.pop("cover_url") cover_url = book_dict.pop("cover_url", "")
try: genres = book_dict.pop("generes", [])
genres = book_dict.pop("generes")
except:
genres = []
if authors: if authors:
for author_str in authors: for author_str in authors:
@ -248,7 +306,7 @@ class Book(LongPlayScrobblableMixin):
return book return book
def save_image_from_url(self, url: str, force_update: bool = False): def save_image_from_url(self, url: str, force_update: bool = False):
if not self.cover or (force_update and url): if url and (not self.cover or force_update):
r = requests.get(url) r = requests.get(url)
if r.status_code == 200: if r.status_code == 200:
fname = f"{self.title}_{self.uuid}.jpg" fname = f"{self.title}_{self.uuid}.jpg"
@ -344,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

@ -3,7 +3,6 @@ ComicVine API Information & Documentation:
https://comicvine.gamespot.com/api/ https://comicvine.gamespot.com/api/
https://comicvine.gamespot.com/api/documentation https://comicvine.gamespot.com/api/documentation
""" """
import json
import logging import logging
from django.conf import settings from django.conf import settings
@ -200,34 +199,72 @@ class ComicVineClient(object):
def lookup_comic_from_comicvine(title: str) -> dict: def lookup_comic_from_comicvine(title: str) -> dict:
original_title = title
issue_number = None
volume_nubmer = None
resource_type = "issue"
if "Issue " in title:
resource_type = "issue"
issue_number = title.split("Issue ")[1]
volume_number = None
if "Volume " in title:
resource_type = "volume"
volume_number = title.split("Volume ")[1]
api_key = getattr(settings, "COMICVINE_API_KEY", "") api_key = getattr(settings, "COMICVINE_API_KEY", "")
if not api_key: if not api_key:
logger.warn("No ComicVine API key configured, not looking anything up") logger.warning("No ComicVine API key configured, not looking anything up")
return {} return {}
client = ComicVineClient( client = ComicVineClient(
api_key=getattr(settings, "COMICVINE_API_KEY", None) api_key=getattr(settings, "COMICVINE_API_KEY", None)
) )
result = [
r
for r in client.search(title).get("results")
if r.get("resource_type") == "volume"
][0]
if "volume" not in result.keys(): raw_results = client.search(title).get("results")
logger.warn("No result found on ComicVine", extra={"title": title}) results = [
r
for r in raw_results
if r.get("resource_type") == resource_type
]
if not results:
logger.warning("No comic found on ComicVine")
return {} return {}
title = " ".join([result.get("volume").get("name"), result.get("name)")]) found_result = None
for result in results:
if result.get("issue_number") == str(issue_number):
found_result = result
break
if result.get("volume_number") == str(volume_number):
found_result = result
break
if not found_result:
found_result = results[0]
logger.info("ComicVine results", extra={"results": results})
if not found_result:
logger.warning("No matches found on ComicVine")
return {}
title = found_result.get("name")
if found_result.get("volume"):
title = found_result.get("volume").get("name")
data_dict = { data_dict = {
"title": title, "title": title,
"cover_url": result.get("image").get("original_url"), "original_title": original_title,
"comicvine_data": { "issue_number": found_result.get("issue_number"),
"id": result.get("id"), "volume_number": found_result.get("volume_number"),
"site_detail_url": result.get("site_detail_url"), "cover_url": found_result.get("image").get("original_url"),
"description": result.get("description"), "comicvine_id": found_result.get("id"),
"image": result.get("image").get("original_url"), "comicvine_data": found_result,
}, "summary": found_result.get("description"),
"publish_date": found_result.get("cover_date"),
"first_publish_year": found_result.get("cover_date", "")[:4]
} }
return data_dict return data_dict

View File

@ -29,6 +29,9 @@ def lookup_book_from_google(title: str) -> dict:
google_result = ( google_result = (
json.loads(response.content).get("items", [{}])[0].get("volumeInfo") json.loads(response.content).get("items", [{}])[0].get("volumeInfo")
) )
if not google_result:
return {}
publish_date = pendulum.parse(google_result.get("publishedDate")) publish_date = pendulum.parse(google_result.get("publishedDate"))
isbn_13 = "" isbn_13 = ""
@ -59,13 +62,15 @@ def lookup_book_from_google(title: str) -> dict:
book_dict["genres"] = google_result.get("categories") book_dict["genres"] = google_result.get("categories")
book_dict["cover_url"] = ( book_dict["cover_url"] = (
google_result.get("imageLinks", {}) google_result.get("imageLinks", {})
.get("thumbnail") .get("thumbnail", "")
.replace("zoom=1", "zoom=15") .replace("zoom=1", "zoom=15")
.replace("&edge=curl", "") .replace("&edge=curl", "")
) )
book_dict["run_time_seconds"] = book_dict.get("pages", 10) * getattr( book_dict["base_run_time_seconds"] = 3600
settings, "AVERAGE_PAGE_READING_SECONDS", 60 if book_dict.get("pages"):
) book_dict["base_run_time_seconds"] = book_dict.get("pages", 10) * getattr(
settings, "AVERAGE_PAGE_READING_SECONDS", 60
)
return book_dict return book_dict

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,59 @@
import re
from urllib.parse import urlparse, urlunparse
from titlecase import titlecase
def parse_readcomicsonline_uri(uri: str) -> tuple:
try:
path = uri.split("comic/")[1]
except IndexError:
return "", "", ""
parts = path.split('/')
title = ""
volume = 1
page = 1
if len(parts) == 2:
title = titlecase(parts[0].replace("-", " "))
volume = parts[1]
if len(parts) == 3:
title = titlecase(parts[0].replace("-", " "))
volume = parts[1]
page = parts[2]
return title, volume, page
def get_comic_issue_url(url: str) -> str:
parsed = urlparse(url)
parts = [p for p in parsed.path.strip('/').split('/') if p]
# Find the index of "comic"
try:
comic_index = parts.index("comic")
except ValueError:
raise ValueError("URL does not contain '/comic/' segment")
# Extract title (next part after 'comic')
if len(parts) <= comic_index + 1:
raise ValueError("No comic title found after '/comic/'")
title = parts[comic_index + 1]
# Look for the first numeric segment after the title
number = None
for segment in parts[comic_index + 2:]:
if segment.isdigit():
number = segment
break
# Build normalized path
new_parts = ["comic", title]
if number:
new_parts.append(number)
normalized_path = "/" + "/".join(new_parts)
# Rebuild full URL (same scheme and host)
simplified_url = urlunparse(parsed._replace(path=normalized_path, query='', fragment=''))
return simplified_url

View File

View File

@ -0,0 +1,8 @@
from rest_framework import serializers
from bricksets.models import BrickSet
class BrickSetSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = BrickSet
fields = "__all__"

View File

@ -0,0 +1,9 @@
from rest_framework import permissions, viewsets
from bricksets.api.serializers import BrickSetSerializer
from bricksets.models import BrickSet
class BrickSetViewSet(viewsets.ModelViewSet):
queryset = BrickSet.objects.all().order_by("-created")
serializer_class = BrickSetSerializer
permission_classes = [permissions.IsAuthenticated]

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

View File

@ -0,0 +1,14 @@
from rest_framework import serializers
from foods import models
class FoodCategorySerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = models.FoodCategory
fields = "__all__"
class FoodSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = models.Food
fields = "__all__"

View File

@ -0,0 +1,21 @@
from rest_framework import permissions, viewsets
from foods.api.serializers import (
FoodSerializer,
FoodCategorySerializer,
)
from foods.models import (
FoodCategory,
Food,
)
class FoodCategoryViewSet(viewsets.ModelViewSet):
queryset = FoodCategory.objects.all().order_by("-created")
serializer_class = FoodCategorySerializer
permission_classes = [permissions.IsAuthenticated]
class FoodViewSet(viewsets.ModelViewSet):
queryset = Food.objects.all().order_by("-created")
serializer_class = FoodSerializer
permission_classes = [permissions.IsAuthenticated]

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

@ -18,7 +18,6 @@ BNULL = {"blank": True, "null": True}
class FoodLogData(BaseLogData, WithPeopleLogData): class FoodLogData(BaseLogData, WithPeopleLogData):
calories: Optional[int] = None calories: Optional[int] = None
meal: Optional[str] = None meal: Optional[str] = None
rating: Optional[str] = None
class FoodCategory(TimeStampedModel): class FoodCategory(TimeStampedModel):

View File

@ -0,0 +1,8 @@
from lifeevents.models import LifeEvent
from rest_framework import serializers
class LifeEventSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = LifeEvent
fields = "__all__"

View File

@ -0,0 +1,10 @@
from rest_framework import permissions, viewsets
from lifeevents.api import serializers
from lifeevents import models
class LifeEventViewSet(viewsets.ModelViewSet):
queryset = models.LifeEvent.objects.all().order_by("-created")
serializer_class = serializers.LifeEventSerializer
permission_classes = [permissions.IsAuthenticated]

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,8 @@
from locations import models
from rest_framework import serializers
class GeoLocationSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = models.GeoLocation
fields = "__all__"

View File

@ -0,0 +1,9 @@
from rest_framework import permissions, viewsets
from locations.api import serializers
from locations import models
class GeoLocationViewSet(viewsets.ModelViewSet):
queryset = models.GeoLocation.objects.all().order_by("-created")
serializer_class = serializers.GeoLocationSerializer
permission_classes = [permissions.IsAuthenticated]

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

View File

View File

@ -0,0 +1,8 @@
from rest_framework import serializers
from moods.models import Mood
class MoodSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Mood
fields = "__all__"

View File

@ -0,0 +1,9 @@
from rest_framework import permissions, viewsets
from moods.api.serializers import MoodSerializer
from moods.models import Mood
class MoodViewSet(viewsets.ModelViewSet):
queryset = Mood.objects.all().order_by("-created")
serializer_class = MoodSerializer
permission_classes = [permissions.IsAuthenticated]

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,17 @@
from podcasts import models
from rest_framework import serializers
class ProducerSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = models.Producer
fields = "__all__"
class PodcastEpisodeSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = models.PodcastEpisode
fields = "__all__"
class PodcastSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = models.Podcast
fields = "__all__"

View File

@ -0,0 +1,20 @@
from rest_framework import permissions, viewsets
from podcasts.api import serializers
from podcasts import models
class ProducerViewSet(viewsets.ModelViewSet):
queryset = models.Producer.objects.all().order_by("-created")
serializer_class = serializers.ProducerSerializer
permission_classes = [permissions.IsAuthenticated]
class PodcastViewSet(viewsets.ModelViewSet):
queryset = models.Podcast.objects.all().order_by("-created")
serializer_class = serializers.PodcastSerializer
permission_classes = [permissions.IsAuthenticated]
class PodcastEpisodeViewSet(viewsets.ModelViewSet):
queryset = models.PodcastEpisode.objects.all().order_by("-created")
serializer_class = serializers.PodcastEpisodeSerializer
permission_classes = [permissions.IsAuthenticated]

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,12 @@
from puzzles import models
from rest_framework import serializers
class PuzzleManufacturerSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = models.PuzzleManufacturer
fields = "__all__"
class PuzzleSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = models.Puzzle
fields = "__all__"

View File

@ -0,0 +1,15 @@
from rest_framework import permissions, viewsets
from puzzles.api import serializers
from puzzles import models
class PuzzleManufacturerViewSet(viewsets.ModelViewSet):
queryset = models.PuzzleManufacturer.objects.all().order_by("-created")
serializer_class = serializers.PuzzleManufacturerSerializer
permission_classes = [permissions.IsAuthenticated]
class PuzzleViewSet(viewsets.ModelViewSet):
queryset = models.Puzzle.objects.all().order_by("-created")
serializer_class = serializers.PuzzleSerializer
permission_classes = [permissions.IsAuthenticated]

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

@ -11,17 +11,15 @@ from django_extensions.db.models import TimeStampedModel
from imagekit.models import ImageSpecField from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit from imagekit.processors import ResizeToFit
from puzzles.sources import ipdb from puzzles.sources import ipdb
from scrobbles.dataclasses import JSONDataclass from scrobbles.dataclasses import BaseLogData, WithPeopleLogData, LongPlayLogData
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
BNULL = {"blank": True, "null": True} BNULL = {"blank": True, "null": True}
@dataclass @dataclass
class PuzzleLogData(JSONDataclass): class PuzzleLogData(BaseLogData, WithPeopleLogData, LongPlayLogData):
with_people: Optional[int] = None
rating: Optional[str] = None rating: Optional[str] = None
notes: Optional[str] = None
class PuzzleManufacturer(TimeStampedModel): class PuzzleManufacturer(TimeStampedModel):

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

@ -13,7 +13,10 @@ User = get_user_model()
class ScrobbleLogDataEncoder(json.JSONEncoder): class ScrobbleLogDataEncoder(json.JSONEncoder):
def default(self, o): def default(self, o):
return o.__dict__ try:
return o.__dict__
except AttributeError:
return {}
class ScrobbleLogDataDecoder(json.JSONDecoder): class ScrobbleLogDataDecoder(json.JSONDecoder):

View File

@ -77,11 +77,8 @@ def form_from_dataclass(dataclass):
override_fields = {} override_fields = {}
for klass in dataclass.mro(): for klass in dataclass.mro():
if hasattr(klass, "override_fields"): if hasattr(klass, "override_fields"):
print(klass, ": ", klass.override_fields())
override_fields.update(klass.override_fields()) override_fields.update(klass.override_fields())
print("overrides: ", override_fields)
for f in fields(dataclass): for f in fields(dataclass):
print(f)
if f.name in override_fields: if f.name in override_fields:
form_fields[f.name] = override_fields[f.name] form_fields[f.name] = override_fields[f.name]
continue continue

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

@ -12,7 +12,7 @@ import pytz
from beers.models import Beer from beers.models import Beer
from boardgames.models import BoardGame from boardgames.models import BoardGame
from books.koreader import process_koreader_sqlite_file from books.koreader import process_koreader_sqlite_file
from books.models import Book, Paper from books.models import Book, Paper, BookPageLogData, BookLogData
from bricksets.models import BrickSet from bricksets.models import BrickSet
from dataclass_wizard.errors import ParseError from dataclass_wizard.errors import ParseError
from django.conf import settings from django.conf import settings
@ -22,7 +22,7 @@ from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django_extensions.db.models import TimeStampedModel 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.models import ImageSpecField
from imagekit.processors import ResizeToFit from imagekit.processors import ResizeToFit
from lifeevents.models import LifeEvent from lifeevents.models import LifeEvent
@ -775,7 +775,7 @@ class Scrobble(TimeStampedModel):
and user.profile.redirect_to_webpage and user.profile.redirect_to_webpage
): ):
logger.info(f"Redirecting to {self.media_obj.url}") logger.info(f"Redirecting to {self.media_obj.url}")
redirect_url = self.media_obj.get_read_url() redirect_url = self.media_obj.url
if ( if (
self.media_type == self.MediaType.VIDEO self.media_type == self.MediaType.VIDEO
@ -822,7 +822,7 @@ class Scrobble(TimeStampedModel):
""" """
is_stale = False is_stale = False
now = timezone.now() now = timezone.now()
seconds_since_last_update = (now - self.modified).seconds seconds_since_last_update = (now - self.modified).total_seconds()
if seconds_since_last_update >= self.media_obj.SECONDS_TO_STALE: if seconds_since_last_update >= self.media_obj.SECONDS_TO_STALE:
is_stale = True is_stale = True
return is_stale return is_stale
@ -991,7 +991,7 @@ class Scrobble(TimeStampedModel):
@property @property
def can_be_updated(self) -> bool: def can_be_updated(self) -> bool:
if self.media_obj.__class__.__name__ in LONG_PLAY_MEDIA.values(): if self.media_obj.__class__.__name__ in LONG_PLAY_MEDIA.values() and self.source != "readcomicsonline.ru":
logger.info( logger.info(
"[scrobbling] cannot be updated, long play media", "[scrobbling] cannot be updated, long play media",
extra={ extra={
@ -1131,6 +1131,8 @@ class Scrobble(TimeStampedModel):
media_query = models.Q(**{key: media}) media_query = models.Q(**{key: media})
scrobble_data[key + "_id"] = media.id scrobble_data[key + "_id"] = media.id
skip_in_progress_check = kwargs.get("skip_in_progress_check", False) skip_in_progress_check = kwargs.get("skip_in_progress_check", False)
read_log_page = kwargs.get("read_log_page", None)
# Find our last scrobble of this media item (track, video, etc) # Find our last scrobble of this media item (track, video, etc)
scrobble = ( scrobble = (
@ -1154,7 +1156,7 @@ class Scrobble(TimeStampedModel):
) )
return scrobble return scrobble
if not skip_in_progress_check: if not skip_in_progress_check or read_log_page:
logger.info( logger.info(
f"[create_or_update] check for existing scrobble to update ", f"[create_or_update] check for existing scrobble to update ",
extra={ extra={
@ -1170,15 +1172,35 @@ class Scrobble(TimeStampedModel):
# If it's marked as stopped, send it through our update mechanism, which will complete it # If it's marked as stopped, send it through our update mechanism, which will complete it
if scrobble and ( if scrobble and (
scrobble.can_be_updated scrobble.can_be_updated
or (read_log_page and scrobble.can_be_updated)
or scrobble_data["playback_status"] == "stopped" or scrobble_data["playback_status"] == "stopped"
): ):
if "log" in scrobble_data.keys() and scrobble.log: if read_log_page:
page_list = scrobble.log.get("page_data", [])
if page_list:
for page in page_list:
if not page.get("end_ts", None):
page["end_ts"] = int(timezone.now().timestamp())
page["duration"] = page["end_ts"] - page.get("start_ts")
page_list.append(
BookPageLogData(
page_number=read_log_page,
start_ts=int(timezone.now().timestamp())
)
)
scrobble.log["page_data"] = page_list
scrobble.save(update_fields=["log"])
elif "log" in scrobble_data.keys() and scrobble.log:
scrobble_data["log"] = scrobble.log | scrobble_data["log"] scrobble_data["log"] = scrobble.log | scrobble_data["log"]
return scrobble.update(scrobble_data) return scrobble.update(scrobble_data)
# Discard status before creating # Discard status before creating
scrobble_data.pop("playback_status") scrobble_data.pop("playback_status")
if read_log_page:
scrobble_data["log"] = BookLogData(page_data=[BookPageLogData(page_number=read_log_page, start_ts=int(timezone.now().timestamp()))])
logger.info( logger.info(
f"[scrobbling] creating new scrobble", f"[scrobbling] creating new scrobble",
extra={ extra={
@ -1188,6 +1210,9 @@ class Scrobble(TimeStampedModel):
"source": source, "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) scrobble = cls.create(scrobble_data)
return scrobble return scrobble
@ -1371,6 +1396,9 @@ class Scrobble(TimeStampedModel):
if class_name in LONG_PLAY_MEDIA.values(): if class_name in LONG_PLAY_MEDIA.values():
self.finish_long_play() self.finish_long_play()
if class_name == "Book":
self.calculate_reading_stats()
logger.info( logger.info(
f"[scrobbling] stopped", f"[scrobbling] stopped",
extra={ extra={
@ -1466,3 +1494,40 @@ class Scrobble(TimeStampedModel):
beyond_completion = False beyond_completion = False
return beyond_completion return beyond_completion
def calculate_reading_stats(self, commit=True):
# --- Sort safely by numeric page_number ---
def safe_page_number(entry):
try:
return int(getattr("page_number", entry), 0)
except (ValueError, TypeError):
return float("inf") # push invalid entries to the end
page_data = self.log.get("page_data")
if not page_data:
logger.warning("No page data found to calculate")
return
if isinstance(page_data, dict):
logger.warning("Page data is dict, migrate koreader data")
return
page_data.sort(key=safe_page_number)
# --- Extract valid numeric page numbers ---
valid_pages = []
for page in page_data:
try:
valid_pages.append(int(page["page_number"]))
except (ValueError, TypeError):
continue
# --- Compute stats ---
if valid_pages:
self.log["page_start"] = min(valid_pages)
self.log["page_end"] = max(valid_pages)
self.log["pages_read"] = len(set(valid_pages))
if commit:
self.save(update_fields=["log"])

View File

@ -7,7 +7,9 @@ import pendulum
import pytz import pytz
from beers.models import Beer from beers.models import Beer
from boardgames.models import BoardGame, BoardGameDesigner, BoardGameLocation from boardgames.models import BoardGame, BoardGameDesigner, BoardGameLocation
from books.models import Book from books.constants import READCOMICSONLINE_URL
from books.models import Book, BookLogData, BookPageLogData
from books.utils import parse_readcomicsonline_uri
from bricksets.models import BrickSet from bricksets.models import BrickSet
from dateutil.parser import parse from dateutil.parser import parse
from django.utils import timezone from django.utils import timezone
@ -27,7 +29,12 @@ from scrobbles.constants import (
) )
from scrobbles.models import Scrobble from scrobbles.models import Scrobble
from scrobbles.notifications import ScrobbleNtfyNotification from scrobbles.notifications import ScrobbleNtfyNotification
from scrobbles.utils import convert_to_seconds, extract_domain from scrobbles.utils import (
convert_to_seconds,
extract_domain,
remove_last_part,
next_url_if_exists,
)
from sports.models import SportEvent from sports.models import SportEvent
from sports.thesportsdb import lookup_event_from_thesportsdb from sports.thesportsdb import lookup_event_from_thesportsdb
from tasks.models import Task from tasks.models import Task
@ -116,9 +123,8 @@ def jellyfin_scrobble_media(
/ 10000000 / 10000000
) )
if media_type == Scrobble.MediaType.VIDEO: if media_type == Scrobble.MediaType.VIDEO:
media_obj = Video.get_from_imdb_id( imdb_id = post_data.get("Provider_imdb", "")
post_data.get("Provider_imdb", "").replace("tt", "") media_obj = Video.find_or_create(imdb_id)
)
else: else:
media_obj = Track.find_or_create( media_obj = Track.find_or_create(
title=post_data.get("Name", ""), title=post_data.get("Name", ""),
@ -155,21 +161,16 @@ def jellyfin_scrobble_media(
def web_scrobbler_scrobble_media( def web_scrobbler_scrobble_media(
youtube_id: str, user_id: int, status: str = "started" youtube_id: str, user_id: int, status: str = "started"
) -> Optional[Scrobble]: ) -> 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") return video.scrobble_for_user(user_id, status, source="Web Scrobbler")
def manual_scrobble_video( def manual_scrobble_video(
video_id: str, user_id: int, action: Optional[str] = None video_id: str, user_id: int, source: str = "IMDb", action: Optional[str] = None
): ):
if "tt" in video_id: video = Video.find_or_create(video_id)
video = Video.get_from_imdb_id(video_id)
else:
video = Video.get_from_youtube_id(video_id)
# When manually scrobbling, try finding a source from the series # When manually scrobbling, try finding a source from the series
source = "Vrobbler"
if video.tv_series: if video.tv_series:
source = video.tv_series.preferred_source source = video.tv_series.preferred_source
scrobble_dict = { scrobble_dict = {
@ -198,7 +199,7 @@ def manual_scrobble_video(
def manual_scrobble_event( def manual_scrobble_event(
thesportsdb_id: str, user_id: int, action: Optional[str] = None thesportsdb_id: str, user_id: int, source: str = "TheSportsDB", action: Optional[str] = None
): ):
data_dict = lookup_event_from_thesportsdb(thesportsdb_id) data_dict = lookup_event_from_thesportsdb(thesportsdb_id)
@ -213,7 +214,7 @@ def manual_scrobble_event(
def manual_scrobble_video_game( def manual_scrobble_video_game(
hltb_id: str, user_id: int, action: Optional[str] = None hltb_id: str, user_id: int, source: str = "HLTB", action: Optional[str] = None
): ):
game = VideoGame.objects.filter(hltb_id=hltb_id).first() game = VideoGame.objects.filter(hltb_id=hltb_id).first()
if not game: if not game:
@ -235,7 +236,7 @@ def manual_scrobble_video_game(
"user_id": user_id, "user_id": user_id,
"timestamp": timezone.now(), "timestamp": timezone.now(),
"playback_position_seconds": 0, "playback_position_seconds": 0,
"source": "Vrobbler", "source": source,
"long_play_complete": False, "long_play_complete": False,
} }
@ -253,15 +254,44 @@ def manual_scrobble_video_game(
def manual_scrobble_book( def manual_scrobble_book(
title: str, user_id: int, action: Optional[str] = None title: str, user_id: int, source: str = "Google Books", action: Optional[str] = None
): ):
book = Book.find_or_create(title) log = {}
page = None
url = ""
if READCOMICSONLINE_URL in title:
url = title
title, volume, page = parse_readcomicsonline_uri(title)
if not title:
logger.info(
"[scrobblers] manual book scrobble request failed",
extra={
"title": title,
"user_id": user_id,
"media_type": Scrobble.MediaType.BOOK,
},
)
return
title = f"{title} - Issue {volume}"
if not page:
page = 1
logger.info("[scrobblers] Book page included in scrobble, should update!")
source = READCOMICSONLINE_URL.replace("https://", "")
# TODO: Check for scrobble of this book already and if so, update the page count
book = Book.find_or_create(title, url=url, enrich=True)
scrobble_dict = { scrobble_dict = {
"user_id": user_id, "user_id": user_id,
"timestamp": timezone.now(), "timestamp": timezone.now(),
"playback_position_seconds": 0, "playback_position_seconds": 0,
"source": "Vrobbler", "source": source,
"long_play_complete": False, "long_play_complete": False,
} }
@ -275,11 +305,23 @@ def manual_scrobble_book(
}, },
) )
return Scrobble.create_or_update(book, user_id, scrobble_dict)
scrobble = Scrobble.create_or_update(book, user_id, scrobble_dict, read_log_page=page)
if action == "stop":
if url:
if isinstance(scrobble.log, "BookLogData"):
scrobble.log.resume_url = next_url_if_exists(url)
else:
scrobble.log["resume_url"] = next_url_if_exists(url)
scrobble.save(update_fields=["log"])
scrobble.stop(force_finish=True)
return scrobble
def manual_scrobble_board_game( def manual_scrobble_board_game(
bggeek_id: str, user_id: int, action: Optional[str] = None bggeek_id: str, user_id: int, source: str = "BGG", action: Optional[str] = None
) -> Scrobble | None: ) -> Scrobble | None:
boardgame = BoardGame.find_or_create(bggeek_id) boardgame = BoardGame.find_or_create(bggeek_id)
@ -291,7 +333,7 @@ def manual_scrobble_board_game(
"user_id": user_id, "user_id": user_id,
"timestamp": timezone.now(), "timestamp": timezone.now(),
"playback_position_seconds": 0, "playback_position_seconds": 0,
"source": "Vrobbler", "source": source,
} }
logger.info( logger.info(
"[vrobbler-scrobble] board game scrobble request received", "[vrobbler-scrobble] board game scrobble request received",
@ -306,29 +348,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]:
@ -358,11 +377,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", []):
@ -504,7 +523,7 @@ def email_scrobble_board_game(
def manual_scrobble_from_url( def manual_scrobble_from_url(
url: str, user_id: int, action: Optional[str] = None url: str, user_id: int, source: str = "Vrobbler", action: Optional[str] = None
) -> Scrobble: ) -> Scrobble:
"""We have scrobblable media URLs, and then any other webpages that """We have scrobblable media URLs, and then any other webpages that
we want to scrobble as a media type in and of itself. This checks whether we want to scrobble as a media type in and of itself. This checks whether
@ -532,11 +551,13 @@ def manual_scrobble_from_url(
if content_key == "-i" and "v=" in url: if content_key == "-i" and "v=" in url:
item_id = url.split("v=")[1].split("&")[0] item_id = url.split("v=")[1].split("&")[0]
elif content_key == "-c" and "comics" in url:
item_id = url
elif content_key == "-i" and "title/tt" in url: elif content_key == "-i" and "title/tt" in url:
item_id = "tt" + str(item_id) item_id = "tt" + str(item_id)
scrobble_fn = MANUAL_SCROBBLE_FNS[content_key] scrobble_fn = MANUAL_SCROBBLE_FNS[content_key]
return eval(scrobble_fn)(item_id, user_id, action=action) return eval(scrobble_fn)(item_id, user_id, source=source, action=action)
def todoist_scrobble_task_finish( def todoist_scrobble_task_finish(
@ -745,7 +766,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()
@ -816,9 +836,10 @@ def emacs_scrobble_task(
return scrobble return scrobble
def manual_scrobble_task(url: str, user_id: int, action: Optional[str] = None): def manual_scrobble_task(url: str, user_id: int, source: str = "Vrobbler", action: Optional[str] = None):
source_id = re.findall(r"\d+", url)[0] source_id = re.findall(r"\d+", url)[0]
description = ""
if "todoist" in url: if "todoist" in url:
source = "Todoist" source = "Todoist"
title = "Generic Todoist task" title = "Generic Todoist task"
@ -847,7 +868,7 @@ def manual_scrobble_task(url: str, user_id: int, action: Optional[str] = None):
def manual_scrobble_webpage( def manual_scrobble_webpage(
url: str, user_id: int, action: Optional[str] = None url: str, user_id: int, source: str = "Bookmarklet", action: Optional[str] = None
): ):
webpage = WebPage.find_or_create({"url": url}) webpage = WebPage.find_or_create({"url": url})
@ -855,7 +876,7 @@ def manual_scrobble_webpage(
"user_id": user_id, "user_id": user_id,
"timestamp": timezone.now(), "timestamp": timezone.now(),
"playback_position_seconds": 0, "playback_position_seconds": 0,
"source": "Vrobbler", "source": source,
} }
logger.info( logger.info(
"[vrobbler-scrobble] webpage scrobble request received", "[vrobbler-scrobble] webpage scrobble request received",
@ -974,7 +995,7 @@ def web_scrobbler_scrobble_video_or_song(
def manual_scrobble_beer( def manual_scrobble_beer(
untappd_id: str, user_id: int, action: Optional[str] = None untappd_id: str, user_id: int, source: str = "Untappd", action: Optional[str] = None
): ):
beer = Beer.find_or_create(untappd_id) beer = Beer.find_or_create(untappd_id)
@ -986,7 +1007,7 @@ def manual_scrobble_beer(
"user_id": user_id, "user_id": user_id,
"timestamp": timezone.now(), "timestamp": timezone.now(),
"playback_position_seconds": 0, "playback_position_seconds": 0,
"source": "Vrobbler", "source": source,
} }
logger.info( logger.info(
"[vrobbler-scrobble] beer scrobble request received", "[vrobbler-scrobble] beer scrobble request received",
@ -1003,7 +1024,7 @@ def manual_scrobble_beer(
def manual_scrobble_puzzle( def manual_scrobble_puzzle(
ipdb_id: str, user_id: int, action: Optional[str] = None ipdb_id: str, user_id: int, source: str = "IPDb", action: Optional[str] = None
): ):
puzzle = Puzzle.find_or_create(ipdb_id) puzzle = Puzzle.find_or_create(ipdb_id)
@ -1015,7 +1036,7 @@ def manual_scrobble_puzzle(
"user_id": user_id, "user_id": user_id,
"timestamp": timezone.now(), "timestamp": timezone.now(),
"playback_position_seconds": 0, "playback_position_seconds": 0,
"source": "Vrobbler", "source": source,
} }
logger.info( logger.info(
"[vrobbler-scrobble] puzzle scrobble request received", "[vrobbler-scrobble] puzzle scrobble request received",
@ -1032,7 +1053,7 @@ def manual_scrobble_puzzle(
def manual_scrobble_brickset( def manual_scrobble_brickset(
brickset_id: str, user_id: int, action: Optional[str] = None brickset_id: str, user_id: int, source: str = "BrickSet", action: Optional[str] = None
): ):
brickset = BrickSet.find_or_create(brickset_id) brickset = BrickSet.find_or_create(brickset_id)
@ -1044,7 +1065,7 @@ def manual_scrobble_brickset(
"user_id": user_id, "user_id": user_id,
"timestamp": timezone.now(), "timestamp": timezone.now(),
"playback_position_seconds": 0, "playback_position_seconds": 0,
"source": "Vrobbler", "source": source,
"log": {"serial_scrobble_id": ""}, "log": {"serial_scrobble_id": ""},
} }
logger.info( logger.info(

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -1,5 +1,6 @@
import hashlib import hashlib
import logging import logging
import requests
import re import re
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING, Optional
@ -393,10 +394,14 @@ def get_daily_calories_for_user_by_day(user_id: int, date: date| str) -> int:
if isinstance(date, str): if isinstance(date, str):
date = pendulum.parse(date) date = pendulum.parse(date)
qs = base_scrobble_qs(user_id).filter(day=date) try:
agg = qs.aggregate(total_calories=models.Sum("calories_int")) qs = base_scrobble_qs(user_id).filter(day=date)
agg = qs.aggregate(total_calories=models.Sum("calories_int"))
except AttributeError as e:
logger.warning(f"Can't generate calorie total: {e}")
agg = {}
return agg["total_calories"] or 0 return agg.get("total_calories") or 0
def get_daily_calorie_dict_for_user(user_id: int) -> dict[date, int]: def get_daily_calorie_dict_for_user(user_id: int) -> dict[date, int]:
"""Return {day: total_calories} for all days with scrobbles, in one query.""" """Return {day: total_calories} for all days with scrobbles, in one query."""
@ -408,3 +413,41 @@ def get_daily_calorie_dict_for_user(user_id: int) -> dict[date, int]:
) )
return {entry["day"]: entry["total_calories"] for entry in qs} return {entry["day"]: entry["total_calories"] for entry in qs}
def remove_last_part(url: str) -> str:
url = url.rstrip('/')
if '/' not in url:
return url
return url.rsplit('/', 1)[0]
def next_url_if_exists(url: str) -> str:
# Normalize (remove trailing slash)
url = url.rstrip('/')
# Find last number in the URL path
match = re.search(r'(\d+)(?:/?$)', url)
if not match:
logger.info("No numeric segment found in the URL", extra={"url": url})
return ""
number = int(match.group(1))
new_number = number + 1
# Replace only the last occurrence of that number
new_url = re.sub(rf'{number}(?:/?$)', f'{new_number}/', url + '/', 1)
# Check if the new URL exists
try:
resp = requests.head(new_url, allow_redirects=True, timeout=5)
if resp.status_code == 200:
return new_url
else:
# Fallback: some sites may not support HEAD well — try GET
resp = requests.get(new_url, timeout=5)
if resp.status_code == 200:
return new_url
except requests.RequestException:
pass
# If it doesnt exist
return ""

View File

@ -127,8 +127,9 @@ class RecentScrobbleList(ListView):
if user.is_authenticated: if user.is_authenticated:
if scrobble_url := self.request.GET.get("scrobble_url", ""): if scrobble_url := self.request.GET.get("scrobble_url", ""):
action = self.request.GET.get("action", "") action = self.request.GET.get("action", "")
source = self.request.GET.get("source", "Bookmarklet")
scrobble = manual_scrobble_from_url( scrobble = manual_scrobble_from_url(
scrobble_url, self.request.user.id, action scrobble_url, self.request.user.id, source, action
) )
return HttpResponseRedirect(scrobble.redirect_url(user.id)) return HttpResponseRedirect(scrobble.redirect_url(user.id))
return super().get(*args, **kwargs) return super().get(*args, **kwargs)
@ -604,8 +605,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
@ -625,9 +625,9 @@ def scrobble_start(request, uuid):
if ( if (
user.profile.redirect_to_webpage user.profile.redirect_to_webpage
and media_obj.__class__.__name__ == Scrobble.MediaType.WEBPAGE and (media_obj.__class__.__name__ == Scrobble.MediaType.WEBPAGE or media_obj.__class__.__name__ == Scrobble.MediaType.BOOK)
): ):
logger.info(f"Redirecting to {media_obj} detail apge") logger.info(f"Redirecting to {media_obj} detail page")
return HttpResponseRedirect(media_obj.url) return HttpResponseRedirect(media_obj.url)
return HttpResponseRedirect(success_url) return HttpResponseRedirect(success_url)
@ -949,12 +949,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 len(initial_notes) > 0 and isinstance(initial_notes[0], dict):
notes_str = "\n".join(initial_notes) notes_str = note_list_to_str(notes)
notes_str_fixed = notes_str.encode("utf-8").decode( else:
"unicode_escape" notes_str = "\n".join(initial_notes) if initial_notes else ""
)
log["notes"] = notes_str_fixed notes_str_fixed = notes_str.encode("utf-8").decode(
"unicode_escape"
)
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

@ -244,7 +244,7 @@ class SportEvent(ScrobblableMixin):
"player_two": player_two, "player_two": player_two,
"start": data_dict.get("Start"), "start": data_dict.get("Start"),
"round": round, "round": round,
"run_time_seconds": data_dict.get("RunTime"), "base_run_time_seconds": data_dict.get("RunTime"),
} }
event, _created = cls.objects.get_or_create(**event_dict) event, _created = cls.objects.get_or_create(**event_dict)

View File

@ -0,0 +1,8 @@
from tasks import models
from rest_framework import serializers
class TaskSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = models.Task
fields = "__all__"

View File

@ -0,0 +1,10 @@
from rest_framework import permissions, viewsets
from tasks.api import serializers
from tasks import models
class TaskViewSet(viewsets.ModelViewSet):
queryset = models.Task.objects.all().order_by("-created")
serializer_class = serializers.TaskSerializer
permission_classes = [permissions.IsAuthenticated]

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,8 @@
from trails import models
from rest_framework import serializers
class TrailSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = models.Trail
fields = "__all__"

View File

@ -0,0 +1,11 @@
from rest_framework import permissions, viewsets
from trails.api import serializers
from trails import models
class TrailViewSet(viewsets.ModelViewSet):
queryset = models.Trail.objects.all().order_by("-created")
serializer_class = serializers.TrailSerializer
permission_classes = [permissions.IsAuthenticated]

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,16 +51,19 @@ 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)
series_id = ""
cover = None cover = None
if "cover_url" in video_dict.keys(): if "cover_url" in video_dict.keys():
cover = video_dict.pop("cover_url", "") cover = video_dict.pop("cover_url", "")
genres = video_dict.pop("genres", []) 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

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

@ -1,4 +1,5 @@
from dataclasses import dataclass from dataclasses import dataclass
import re
import logging import logging
from typing import Optional from typing import Optional
from uuid import uuid4 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_VIDEO_URL = "https://www.youtube.com/watch?v="
YOUTUBE_CHANNEL_URL = "https://www.youtube.com/channel/" 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__) logger = logging.getLogger(__name__)
BNULL = {"blank": True, "null": True} BNULL = {"blank": True, "null": True}
@ -136,6 +139,13 @@ class Series(TimeStampedModel):
url = self.cover_image_medium.url url = self.cover_image_medium.url
return 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): def scrobbles_for_user(self, user_id: int, include_playing=False):
from scrobbles.models import Scrobble from scrobbles.models import Scrobble
@ -184,6 +194,32 @@ class Series(TimeStampedModel):
if genres := imdb_dict.get("genres"): if genres := imdb_dict.get("genres"):
self.genre.add(*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): class Video(ScrobblableMixin):
COMPLETION_PERCENT = getattr(settings, "VIDEO_COMPLETION_PERCENT", 90) COMPLETION_PERCENT = getattr(settings, "VIDEO_COMPLETION_PERCENT", 90)
@ -304,7 +340,7 @@ class Video(ScrobblableMixin):
if not created and not overwrite: if not created and not overwrite:
return video return video
vdict, cover, genres = lookup_video_from_youtube( vdict, _, cover, genres = lookup_video_from_youtube(
youtube_id youtube_id
).as_dict_with_cover_and_genres() ).as_dict_with_cover_and_genres()
if created or overwrite: if created or overwrite:
@ -320,28 +356,38 @@ class Video(ScrobblableMixin):
def get_from_imdb_id( def get_from_imdb_id(
cls, imdb_id: str, overwrite: bool = False cls, imdb_id: str, overwrite: bool = False
) -> "Video": ) -> "Video":
if "tt" in imdb_id:
imdb_id = imdb_id[2:]
video, created = cls.objects.get_or_create(imdb_id=imdb_id) video, created = cls.objects.get_or_create(imdb_id=imdb_id)
if not created and not overwrite: if not created and not overwrite:
return video return video
vdict, cover, genres = lookup_video_from_tmdb( vdict, series_id, cover, genres = lookup_video_from_imdb(
imdb_id imdb_id
).as_dict_with_cover_and_genres() ).as_dict_with_cover_and_genres()
if created or overwrite: if created or overwrite:
for k, v in vdict.items(): for k, v in vdict.items():
setattr(video, k, v) setattr(video, k, v)
if series_id:
video.tv_series = Series.find_or_create(imdb_id=series_id)
video.save() video.save()
video.save_image_from_url(cover) if cover:
video.genre.add(*genres) video.save_image_from_url(cover)
if genres:
video.genre.add(*genres)
return video return video
@classmethod @classmethod
def find_or_create( def find_or_create(cls, source_id: str, overwrite: bool = True) -> "Video":
cls, data_dict: dict, post_keys: dict = JELLYFIN_POST_KEYS if "tt" in source_id:
) -> Optional["Video"]: return cls.get_from_imdb_id(source_id, overwrite)
"""Thes smallest of wrappers around our actual get or create utility.""" if bool(YOUTUBE_ID_PATTERN.match(source_id)):
imdb_key = post_keys.get("IMDB_ID", "").replace("tt", "") return cls.get_from_youtube_id(source_id, overwrite)
return cls.get_from_imdb_id(data_dict.get(imdb_key))
#TODO scrobble but without a video obj?
logger.warning("Video ID not recognized, not scrobbling")
return

View File

@ -1,60 +1,21 @@
import logging import logging
from imdb import Cinemagoer, helpers from cinemagoerng import web as imdb
from videos.metadata import VideoMetadata, VideoType from videos.metadata import VideoMetadata, VideoType
imdb_client = Cinemagoer()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def lookup_video_from_imdb( def lookup_video_from_imdb(imdb_id: str) -> VideoMetadata:
name_or_id: str, kind: str = "movie" if not imdb_id.startswith("tt"):
) -> VideoMetadata: logger.warning("This method requires an IMDB ID starting with 'tt'")
from videos.models import Series return VideoMetadata()
# 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
video_metadata = VideoMetadata(imdb_id=imdb_id) video_metadata = VideoMetadata(imdb_id=imdb_id)
imdb_result: dict = {} imdb_result: dict = {}
imdb_result = imdb_client.get_movie(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_client.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: if not imdb_result:
logger.info( logger.info(
@ -63,39 +24,27 @@ def lookup_video_from_imdb(
) )
return None return None
video_metadata.cover_url = imdb_result.get("cover url") video_metadata.imdb_id = imdb_id
if video_metadata.cover_url: video_metadata.title = imdb_result.title
video_metadata.cover_url = helpers.resizeImage( video_metadata.base_run_time_seconds = (
video_metadata.cover_url, width=800 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 video_metadata.video_type = VideoType.MOVIE.value
series_name = None if imdb_result.type_id == "tvEpisode":
if imdb_result.get("kind") == "episode": video_metadata.video_type = VideoType.TV_EPISODE.value
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.get("runtimes"): series = imdb_result.series
video_metadata.run_time_seconds = ( video_metadata.tv_series_imdb_id = series.imdb_id
int(imdb_result.get("runtimes")[0]) * 60 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 return video_metadata

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

@ -1,51 +1,24 @@
import logging import logging
from scrobbles.utils import convert_to_seconds from videos.models import Video
from videos.imdb import lookup_video_from_imdb from django.db import IntegrityError
from videos.models import Series, Video #from videos.skatevideosite import lookup_video_from_skatevideosite
from videos.skatevideosite import lookup_video_from_skatevideosite
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def get_or_create_video(data_dict: dict, post_keys: dict, force_update=False): def clean_up_videos():
name_or_id = data_dict.get(post_keys.get("IMDB_ID"), "") or data_dict.get( videos = Video.objects.filter(imdb_id__isnull=False).exclude(imdb_id__icontains="tt")
post_keys.get("VIDEO_TITLE"), ""
)
video = Video.objects.filter(imdb_id=name_or_id).first() for video in videos:
if video: logger.info(f"Fixing imdb_id for {video}")
return 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) videos = Video.objects.filter(scrobble__isnull=True)
# skatevideosite_metadata = lookup_video_from_skatevideosite(name_or_id) videos.delete()
# 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

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,14 @@
from webpages.models import Domain, WebPage
from rest_framework import serializers
class DomainSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Domain
fields = "__all__"
class WebPageSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = WebPage
fields = "__all__"

View File

@ -0,0 +1,19 @@
from rest_framework import permissions, viewsets
from webpages.api.serializers import (
DomainSerializer,
WebPageSerializer,
)
from webpages.models import Domain, WebPage
class DomainViewSet(viewsets.ModelViewSet):
queryset = Domain.objects.all().order_by("-created")
serializer_class = DomainSerializer
permission_classes = [permissions.IsAuthenticated]
class WebPageViewSet(viewsets.ModelViewSet):
queryset = WebPage.objects.all().order_by("-created")
serializer_class = WebPageSerializer
permission_classes = [permissions.IsAuthenticated]

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

View File

@ -68,6 +68,7 @@ LASTFM_SECRET_KEY = os.getenv("VROBBLER_LASTFM_SECRET_KEY")
IGDB_CLIENT_ID = os.getenv("VROBBLER_IGDB_CLIENT_ID") IGDB_CLIENT_ID = os.getenv("VROBBLER_IGDB_CLIENT_ID")
IGDB_CLIENT_SECRET = os.getenv("VROBBLER_IGDB_CLIENT_SECRET") IGDB_CLIENT_SECRET = os.getenv("VROBBLER_IGDB_CLIENT_SECRET")
COMICVINE_API_KEY = os.getenv("VROBBLER_COMICVINE_API_KEY") COMICVINE_API_KEY = os.getenv("VROBBLER_COMICVINE_API_KEY")
BGG_ACCESS_TOKEN = os.getenv("VROBBLER_BGG_ACCESS_TOKEN", "")
GEOLOC_ACCURACY = os.getenv("VROBBLER_GEOLOC_ACCURACY", 3) GEOLOC_ACCURACY = os.getenv("VROBBLER_GEOLOC_ACCURACY", 3)
GEOLOC_PROXIMITY = os.getenv("VROBBLER_GEOLOC_PROXIMITY", "0.0001") GEOLOC_PROXIMITY = os.getenv("VROBBLER_GEOLOC_PROXIMITY", "0.0001")
POINTS_FOR_MOVEMENT_HISTORY = os.getenv( POINTS_FOR_MOVEMENT_HISTORY = os.getenv(

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

@ -26,7 +26,20 @@
</div> </div>
</div> </div>
<div class="row"> <div class="row">
{% if object.readcomics_url %}
<p><a href="{{object.readcomics_url}}">Read again</a></p>
{% endif %}
{% if object.next_readcomics_url %}
<p><a href="{{object.next_readcomics_url}}">Read next issue</a></p>
{% endif %}
<p>{{scrobbles.count}} scrobbles</p> <p>{{scrobbles.count}} scrobbles</p>
{% for s in scrobbles %}
{% if forloop.first %}
<p><a href="{{s.logdata.resume_url}}">Resume reading</a></p>
{% endif %}
{% endfor %}
</div> </div>
<div class="row"> <div class="row">
<div class="col-md"> <div class="col-md">

Some files were not shown because too many files have changed in this diff Show More