Compare commits

..

44 Commits
28 ... 35

Author SHA1 Message Date
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
c6f3c90006 [releases] Catching up project with actual releases 2025-10-14 12:29:45 -04:00
387dee7d37 [podcasts] Hotfix looking up podcast data from feed URLS 2025-10-14 12:28:17 -04:00
94 changed files with 4427 additions and 2739 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 [2/25]
** 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,63 @@ https://app.todoist.com/app/task/add-a-csv-endpoint-for-users-book-reads-that-li
As an example https://comicbookroundup.com/comic-books/reviews/humanoids-publishing/the-history-of-science-fiction As an example https://comicbookroundup.com/comic-books/reviews/humanoids-publishing/the-history-of-science-fiction
** TODO [#B] Add PuzzleLogData class with with_people and completed :vrobbler:feature:puzzles:logdata:personal:project: ** TODO [#B] Add PuzzleLogData class with with_people and completed :vrobbler:feature:puzzles:logdata:personal:project:
** TODO [#A] Add classmethod for metadata fetching to tracks :vrobbler:feature:music:personal:project: ** TODO [#A] Tasks from org-mode should properly update notes and leave them out of the body :vrobbler:bug:tasks:
** TODO [#A] Fix koreader scrobble imports to use DST properly :vrobbler:personal:bug:books:imports:
- Note taken on [2025-09-25 Thu 10:37] \\
This may already be fixed ... need to check.
- Note taken on [2025-02-25 12:34] \\
The page data has the canonical date something was read in it, but it seems
to be an hour off. I traced this back to being off during DST, so we just need
the importer to be aware of whether a user is using DST or not and roll back
an hour for part of the year. Also, we'd need to adjust any old scrobbles that
took place with DST off to roll them back by an hour.
** TODO [#A] Allow scrobbling from the Food list page's start links :vrobbler:bug:food:scrobbling:personal:project:
https://life.lab.unbl.ink/scrobble/e39779c8-62a5-46a6-bdef-fb7662810dc6/start/
** TODO [#A] Puzzles (and all longplays) should have a "Completed?" column on their detail page :vrobbler:bug:puzzles:personal:project:
** TODO [#A] Fix raw text webpage title not truncating to 254 chars :vrobbler:personal:bug:webpages:
- Note taken on [2025-09-30 Tue 09:33]
This may have already been resolved ... need to just confirm it.
** TODO [#A] Find page numbers for comic books from ComicVine :vrobbler:feature:books:personal:project:
* Version 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 +506,71 @@ 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:
* Version 29.0 [1/1]
- Note taken on [2025-09-30 Tue 09:33] ** DONE HOTFIX podcast lookups, final
* Version 28.0 [1/1]
This may have already been resolved ... need to just confirm it. ** DONE HOTFIX podcast lookups
* Version 27.0 [3/3] * Version 27.0 [3/3]
** DONE [#A] Fix bug where podcast scrobbling creates duplicate Podcast :project:vrobbler:scrobbling:podcasts:bug:personal: ** DONE [#A] Fix bug where podcast scrobbling creates duplicate Podcast :project:vrobbler:scrobbling:podcasts:bug:personal:
:PROPERTIES: :PROPERTIES:

File diff suppressed because one or more lines are too long

5237
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

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(max_length=5, **BNULL)
volume_number = models.IntegerField(max_length=5, **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

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

@ -4,6 +4,7 @@ from typing import Any
from urllib.parse import unquote from urllib.parse import unquote
import feedparser import feedparser
import requests
from dateutil.parser import ParserError, parse from dateutil.parser import ParserError, parse
from podcasts.models import PodcastEpisode from podcasts.models import PodcastEpisode
@ -27,31 +28,41 @@ def fetch_metadata_from_rss(uri: str) -> dict[str, Any]:
log_context = {"mopidy_uri": uri, "media_type": "Podcast"} log_context = {"mopidy_uri": uri, "media_type": "Podcast"}
podcast_data: dict[str, Any] = {} podcast_data: dict[str, Any] = {}
rss_url = uri.split("#")[0].split("podcast+")[1]
target_guid = uri.split("#")[1]
log_context["rss_url"] = rss_url
log_context["target_guid"] = target_guid
try: try:
feed = feedparser.parse(uri.split("#")[0]) resp = requests.get(rss_url, timeout=10)
target_guid = uri.split("#")[1] feed = feedparser.parse(resp.text)
except IndexError: except IndexError:
logger.warning("Tried to parse uri as RSS feed, but no target found", extra=log_context) logger.warning("Tried to parse uri as RSS feed, but no target found", extra=log_context)
return podcast_data return podcast_data
podcast_publisher = feed.feed.get("itunes_publisher") podcast_publisher = getattr(feed.feed, "itunes_publisher", "")
podcast_owner = feed.feed.itunes_owner.get("name") if isinstance(feed.feed.itunes_owner, dict) else feed.feed.itunes_owner try:
podcast_other = feed.feed.get("managingeditor") or feed.feed.get("copyright") podcast_owner = feed.feed.itunes_owner.get("name") if isinstance(feed.feed.itunes_owner, dict) else feed.feed.itunes_owner
podcast_other = feed.feed.get("managingeditor") or feed.feed.get("copyright")
except AttributeError:
podcast_owner = None
podcast_other = None
podcast_data = { podcast_data = {
"podcast_name": feed.feed.get("title", "Unknown Podcast"), "podcast_name": getattr(feed.feed, "title", ""),
# "podcast_description": feed.feed.get("description", ""), # "podcast_description": getattr(feed.feed, "description", ""),
# "podcast_link": feed.feed.get("link", ""), # "podcast_link": getattr(feed.feed, "link", ""),
"podcast_producer": podcast_publisher or podcast_owner or podcast_other "podcast_producer": podcast_publisher or podcast_owner or podcast_other
} }
for entry in feed.entries: for entry in feed.entries:
if target_guid in target_guid: if entry.get("guid") == target_guid:
logger.info("🎧 Episode found in RSS feed", extra=log_context) logger.info("🎧 Episode found in RSS feed", extra=log_context)
podcast_data["title"] = entry.title podcast_data["title"] = entry.title
podcast_data["episode_num"] = entry.guid podcast_data["episode_num"] = int(entry.get("itunes_episode", 0))
podcast_data["pub_date"] = 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
@ -63,7 +74,7 @@ def parse_mopidy_uri(uri: str) -> dict[str, Any]:
podcast_data: dict[str, Any] = {} podcast_data: dict[str, Any] = {}
logger.debug(f"Parsing URI: {uri}") logger.debug(f"Parsing URI: {uri}")
if "https://" in uri: if "podcast+https" in uri:
return fetch_metadata_from_rss(uri) return fetch_metadata_from_rss(uri)

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

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

View File

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

View File

@ -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
@ -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={
@ -1371,6 +1393,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 +1491,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
@ -160,7 +167,7 @@ def web_scrobbler_scrobble_media(
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: if "tt" in video_id:
video = Video.get_from_imdb_id(video_id) video = Video.get_from_imdb_id(video_id)
@ -169,7 +176,6 @@ def manual_scrobble_video(
video = Video.get_from_youtube_id(video_id) 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 +204,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 +219,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 +241,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 +259,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 +310,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 +338,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 +353,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 +382,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 +528,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 +556,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 +771,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 +841,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 +873,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 +881,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 +1000,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 +1012,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 +1029,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 +1041,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 +1058,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 +1070,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 isinstance(initial_notes[0], dict):
notes_str = note_list_to_str(notes)
else:
notes_str = "\n".join(initial_notes) notes_str = "\n".join(initial_notes)
notes_str_fixed = notes_str.encode("utf-8").decode(
"unicode_escape" notes_str_fixed = notes_str.encode("utf-8").decode(
) "unicode_escape"
log["notes"] = notes_str_fixed )
log["notes"] = notes_str_fixed
return FormClass(initial=log) return FormClass(initial=log)

View File

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

View File

@ -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,11 +51,11 @@ class VideoMetadata:
self, self,
imdb_id: Optional[str] = "", imdb_id: Optional[str] = "",
youtube_id: Optional[str] = "", youtube_id: Optional[str] = "",
run_time_seconds: int = 900, base_run_time_seconds: int = 900,
): ):
self.imdb_id = imdb_id self.imdb_id = imdb_id
self.youtube_id = youtube_id self.youtube_id = youtube_id
self.run_time_seconds = run_time_seconds self.base_run_time_seconds = base_run_time_seconds
def as_dict_with_cover_and_genres(self) -> tuple: def as_dict_with_cover_and_genres(self) -> tuple:
video_dict = vars(self) video_dict = vars(self)

View File

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

View File

@ -1,10 +1,8 @@
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__)
@ -27,11 +25,12 @@ def lookup_video_from_imdb(
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(name_or_id)
logger.debug(f"Found result from IMDB: {imdb_result.title}")
if not imdb_result: if not imdb_result:
imdb_result = {} imdb_result = {}
imdb_results: list = imdb_client.search_movie(name_or_id) imdb_results: list = imdb.search_movie(name_or_id)
if len(imdb_results) > 1: if len(imdb_results) > 1:
for result in imdb_results: for result in imdb_results:
if result["kind"] == kind: if result["kind"] == kind:
@ -84,7 +83,7 @@ def lookup_video_from_imdb(
video_metadata.tv_series_id = series.id video_metadata.tv_series_id = series.id
if imdb_result.get("runtimes"): if imdb_result.get("runtimes"):
video_metadata.run_time_seconds = ( video_metadata.base_run_time_seconds = (
int(imdb_result.get("runtimes")[0]) * 60 int(imdb_result.get("runtimes")[0]) * 60
) )

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@ -66,9 +66,16 @@ dd {
{% if object.overview %}<p><em>{{object.overview}}</em></p>{% endif %} {% if object.overview %}<p><em>{{object.overview}}</em></p>{% endif %}
{% if object.plot%}<p>{{object.plot|safe|linebreaks|truncatewords:160}}</p>{% endif %} {% if object.plot%}<p>{{object.plot|safe|linebreaks|truncatewords:160}}</p>{% endif %}
<hr /> <hr />
{% if object.imdb_id %}
<p style="float:right;"> <p style="float:right;">
<a href="{{object.imdb_link}}"><img src="{% static "images/imdb_logo.png" %}" width=35></a> <a href="{{object.imdb_link}}"><img src="{% static "images/imdb_logo.png" %}" width=35></a>
</p> </p>
{% endif %}
{% if object.youtube_id %}
<p style="float:right;">
<a href="{{object.youtube_link}}"><img src="{% static "images/youtube_logo.png" %}" width=35></a>
</p>
{% endif %}
</div> </div>
<div class="deets"> <div class="deets">

View File

@ -4,21 +4,35 @@ from oauth2_provider import urls as oauth2_urls
from rest_framework import routers from rest_framework import routers
import vrobbler.apps.scrobbles.views as scrobbles_views import vrobbler.apps.scrobbles.views as scrobbles_views
from vrobbler.apps.boardgames import urls as boardgame_urls from vrobbler.apps.boardgames import urls as boardgame_urls
from vrobbler.apps.boardgames.api.views import BoardGameViewSet, BoardGameDesignerViewSet, BoardGamePublisherViewSet, BoardGameLocationViewSet
from vrobbler.apps.books import urls as book_urls from vrobbler.apps.books import urls as book_urls
from vrobbler.apps.books.api.views import AuthorViewSet, BookViewSet from vrobbler.apps.books.api.views import AuthorViewSet, BookViewSet
from vrobbler.apps.bricksets import urls as bricksets_urls
from vrobbler.apps.lifeevents import urls as lifeevents_urls from vrobbler.apps.lifeevents import urls as lifeevents_urls
from vrobbler.apps.lifeevents.api.views import LifeEventViewSet
from vrobbler.apps.locations import urls as locations_urls from vrobbler.apps.locations import urls as locations_urls
from vrobbler.apps.locations.api.views import GeoLocationViewSet
from vrobbler.apps.moods import urls as moods_urls from vrobbler.apps.moods import urls as moods_urls
from vrobbler.apps.moods.api.views import MoodViewSet
from vrobbler.apps.foods import urls as foods_urls
from vrobbler.apps.foods.api.views import FoodViewSet, FoodCategoryViewSet
from vrobbler.apps.music import urls as music_urls from vrobbler.apps.music import urls as music_urls
from vrobbler.apps.music.api.views import ( from vrobbler.apps.music.api.views import (
AlbumViewSet, AlbumViewSet,
ArtistViewSet, ArtistViewSet,
TrackViewSet, TrackViewSet,
) )
from vrobbler.apps.podcasts import urls as podcast_urls from vrobbler.apps.podcasts import urls as podcast_urls
from vrobbler.apps.profiles.api.views import UserProfileViewSet, UserViewSet from vrobbler.apps.podcasts.api.views import ProducerViewSet, PodcastViewSet, PodcastEpisodeViewSet
from vrobbler.apps.scrobbles import urls as scrobble_urls from vrobbler.apps.scrobbles import urls as scrobble_urls
from vrobbler.apps.scrobbles.api.views import ( from vrobbler.apps.scrobbles.api.views import (
AudioScrobblerTSVImportViewSet, AudioScrobblerTSVImportViewSet,
@ -26,6 +40,7 @@ from vrobbler.apps.scrobbles.api.views import (
LastFmImportViewSet, LastFmImportViewSet,
ScrobbleViewSet, ScrobbleViewSet,
) )
from vrobbler.apps.sports import urls as sports_urls from vrobbler.apps.sports import urls as sports_urls
from vrobbler.apps.sports.api.views import ( from vrobbler.apps.sports.api.views import (
LeagueViewSet, LeagueViewSet,
@ -36,15 +51,29 @@ from vrobbler.apps.sports.api.views import (
TeamViewSet, TeamViewSet,
) )
from vrobbler.apps.tasks import urls as tasks_urls from vrobbler.apps.tasks import urls as tasks_urls
from vrobbler.apps.tasks.api.views import TaskViewSet
from vrobbler.apps.profiles import urls as profiles_urls from vrobbler.apps.profiles import urls as profiles_urls
from vrobbler.apps.profiles.api.views import UserProfileViewSet, UserViewSet
from vrobbler.apps.trails import urls as trails_urls from vrobbler.apps.trails import urls as trails_urls
from vrobbler.apps.trails.api.views import TrailViewSet
from vrobbler.apps.beers import urls as beers_urls from vrobbler.apps.beers import urls as beers_urls
from vrobbler.apps.foods import urls as foods_urls from vrobbler.apps.beers.api.views import BeerViewSet, BeerProducerViewSet, BeerStyleViewSet
from vrobbler.apps.puzzles import urls as puzzles_urls from vrobbler.apps.puzzles import urls as puzzles_urls
from vrobbler.apps.puzzles.api.views import PuzzleViewSet, PuzzleManufacturerViewSet
from vrobbler.apps.videogames import urls as videogame_urls from vrobbler.apps.videogames import urls as videogame_urls
from vrobbler.apps.videos import urls as video_urls from vrobbler.apps.videos import urls as video_urls
from vrobbler.apps.videos.api.views import SeriesViewSet, VideoViewSet from vrobbler.apps.videos.api.views import SeriesViewSet, VideoViewSet
from vrobbler.apps.bricksets import urls as bricksets_urls
from vrobbler.apps.bricksets.api.views import BrickSetViewSet
from vrobbler.apps.webpages import urls as webpages_urls from vrobbler.apps.webpages import urls as webpages_urls
from vrobbler.apps.webpages.api.views import DomainViewSet, WebPageViewSet
# from vrobbler.apps.modern_ui import urls as modern_ui_urls # from vrobbler.apps.modern_ui import urls as modern_ui_urls
@ -68,6 +97,26 @@ router.register(r"sport-events", SportEventViewSet)
router.register(r"teams", TeamViewSet) router.register(r"teams", TeamViewSet)
router.register(r"users", UserViewSet) router.register(r"users", UserViewSet)
router.register(r"profiles", UserProfileViewSet) router.register(r"profiles", UserProfileViewSet)
router.register(r"domains", DomainViewSet)
router.register(r"webpages", WebPageViewSet)
router.register(r"foods", FoodViewSet)
router.register(r"moods", MoodViewSet)
router.register(r"tasks", TaskViewSet)
router.register(r"locations", GeoLocationViewSet)
router.register(r"beers", BeerViewSet)
router.register(r"beer-producers", BeerProducerViewSet)
router.register(r"beer-styles", BeerStyleViewSet)
router.register(r"boardgames", BoardGameViewSet)
router.register(r"boardgame-designers", BoardGameDesignerViewSet)
router.register(r"boardgame-publishers", BoardGamePublisherViewSet)
router.register(r"boardgame-locations", BoardGameLocationViewSet)
router.register(r"podcast-producers", ProducerViewSet)
router.register(r"podcast-episodes", PodcastEpisodeViewSet)
router.register(r"podcasts", PodcastViewSet)
router.register(r"puzzle-manufacturers", PuzzleManufacturerViewSet)
router.register(r"puzzles", PuzzleViewSet)
router.register(r"bricksets", BrickSetViewSet)
router.register(r"lifeevents", LifeEventViewSet)
urlpatterns = [ urlpatterns = [
path("api/v1/", include(router.urls)), path("api/v1/", include(router.urls)),