Compare commits

..

29 Commits
27 ... 33

Author SHA1 Message Date
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
188e899357 [podcasts] Fix podcast data 2025-10-14 11:55:22 -04:00
30b005fa46 [scrobbles] Stop any scrobble when stop is called 2025-10-14 11:51:33 -04:00
72f739ee5a [podcasts] Try to clean up lookups 2025-10-14 11:45:47 -04:00
56ee14512d [podcasts] Actually test new lookup method 2025-10-14 11:30:57 -04:00
22 changed files with 593 additions and 119 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 [1/27]
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :vrobbler:personal:bug:music:scrobbles: ** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :vrobbler:personal:bug:music:scrobbles:
** TODO [#C] Move to using more robust mopidy-webhooks pacakge form pypi :utility:improvement: ** TODO [#C] Move to using more robust mopidy-webhooks pacakge form pypi :utility:improvement:
:PROPERTIES: :PROPERTIES:
@ -479,6 +479,61 @@ https://life.lab.unbl.ink/scrobble/e39779c8-62a5-46a6-bdef-fb7662810dc6/start/
- Note taken on [2025-09-30 Tue 09:33] - Note taken on [2025-09-30 Tue 09:33]
This may have already been resolved ... need to just confirm it. This may have already been resolved ... need to just confirm it.
** TODO [#A] Find page numbers for comic books from ComicVine :vrobbler:feature:books:personal:project:
* Version 33.0 [3/3]
** DONE [#A] Fix bug where scrobble is_stale only uses seconds not total_seconds :vrobbler:bug:scrobbles:personal:project:
:PROPERTIES:
:ID: 7f6070ac-4f67-011d-ebd5-f3dc47da46ed
: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:
** DONE [#B] Fix ComicVine as source for comic book metadata :vrobbler:books:feature:comicbook:personal:project:scrobbling:
:PROPERTIES:
:ID: d22cec3f-117f-f203-33a5-efbefa8a5cee
:END:
* Version 29.0 [1/1]
** DONE HOTFIX podcast lookups, final
* Version 28.0 [1/1]
** 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:

16
poetry.lock generated
View File

@ -4756,6 +4756,20 @@ webencodings = ">=0.4"
doc = ["sphinx", "sphinx_rtd_theme"] doc = ["sphinx", "sphinx_rtd_theme"]
test = ["pytest", "ruff"] test = ["pytest", "ruff"]
[[package]]
name = "titlecase"
version = "2.4.1"
description = "Python Port of John Gruber's titlecase.pl"
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "titlecase-2.4.1.tar.gz", hash = "sha256:7d83a277ccbbda11a2944e78a63e5ccaf3d32f828c594312e4862f9a07f635f5"},
]
[package.extras]
regex = ["regex (>=2020.4.4)"]
[[package]] [[package]]
name = "tld" name = "tld"
version = "0.13" version = "0.13"
@ -5525,4 +5539,4 @@ cffi = ["cffi (>=1.11)"]
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = ">=3.9,<3.12" python-versions = ">=3.9,<3.12"
content-hash = "cd3b566597e09aa444f9af30f95f94f922bf3dca71fbd05c887fb10cbc11d7bf" content-hash = "2e297ef6f8c524840a381ad793946c87b601d81afd569e882fe58120a5f84626"

View File

@ -57,6 +57,7 @@ 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"
[tool.poetry.group.test] [tool.poetry.group.test]
optional = true optional = true

View File

@ -115,6 +115,12 @@ def mopidy_podcast_request_data():
mopidy_uri=mopidy_uri, artist="NPR", album="Up First" mopidy_uri=mopidy_uri, artist="NPR", album="Up First"
).request_json ).request_json
@pytest.fixture
def mopidy_podcast_https_request_data():
mopidy_uri = "podcast+https://feeds.npr.org/510318/podcast.xml#85b9c4c4-ae09-43d9-8853-31ccf43f68e6"
return MopidyRequest(
mopidy_uri=mopidy_uri, artist="NPR", album="Up First"
).request_json
class JellyfinTrackRequest: class JellyfinTrackRequest:
name = "Emotion" name = "Emotion"

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

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

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

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

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

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["run_time_seconds"] = 3600
settings, "AVERAGE_PAGE_READING_SECONDS", 60 if book_dict.get("pages"):
) book_dict["run_time_seconds"] = book_dict.get("pages", 10) * getattr(
settings, "AVERAGE_PAGE_READING_SECONDS", 60
)
return book_dict return book_dict

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

@ -143,13 +143,13 @@ class PodcastEpisode(ScrobblableMixin):
def find_or_create( def find_or_create(
cls, cls,
title: str, title: str,
podcast_name: str,
podcast_description: str,
pub_date: str, pub_date: str,
number: int = 0, episode_num: int = 0,
mopidy_uri: str = "",
producer_name: str = "",
run_time_seconds: int = 1800, run_time_seconds: int = 1800,
mopidy_uri: str = "",
podcast_name: str = "",
podcast_producer: str = "",
podcast_description: str = "",
enrich: bool = True, enrich: bool = True,
) -> "PodcastEpisode": ) -> "PodcastEpisode":
"""Given a data dict from Mopidy, finds or creates a podcast and """Given a data dict from Mopidy, finds or creates a podcast and
@ -158,8 +158,8 @@ class PodcastEpisode(ScrobblableMixin):
""" """
log_context={"mopidy_uri": mopidy_uri, "media_type": "Podcast"} log_context={"mopidy_uri": mopidy_uri, "media_type": "Podcast"}
producer = None producer = None
if producer_name: if podcast_producer:
producer = Producer.find_or_create(producer_name) producer = Producer.find_or_create(podcast_producer)
podcast, created = Podcast.objects.get_or_create(name=podcast_name, defaults={"description": podcast_description}) podcast, created = Podcast.objects.get_or_create(name=podcast_name, defaults={"description": podcast_description})
log_context["podcast_id"] = podcast.id log_context["podcast_id"] = podcast.id
@ -175,7 +175,7 @@ class PodcastEpisode(ScrobblableMixin):
podcast=podcast, podcast=podcast,
defaults={ defaults={
"run_time_seconds": run_time_seconds, "run_time_seconds": run_time_seconds,
"number": number, "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,33 +28,43 @@ 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["episode_name"] = 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["episode_pub_date"] = entry.get("published", None) podcast_data["pub_date"] = parse(entry.get("published", None))
podcast_data["episode_description"] = entry.get("description", None) podcast_data["run_time_seconds"] = parse_duration(entry.get("itunes_duration", None))
podcast_data["episode_url"] = entry.enclosures[0].href if entry.get("enclosures") else None # podcast_data["description"] = entry.get("description", None)
podcast_data["episode_runtime_seconds"] = parse_duration(entry.get("itunes_duration", None)) # podcast_data["episode_url"] = entry.enclosures[0].href if entry.get("enclosures") else None
return podcast_data return podcast_data
else: else:
logger.info("Episode not found in RSS feed.") logger.info("Episode not found in RSS feed.")
@ -63,14 +74,14 @@ 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)
parsed_uri = os.path.splitext(unquote(uri))[0].split("/") parsed_uri = os.path.splitext(unquote(uri))[0].split("/")
podcast_data = { podcast_data = {
"episode_filename": parsed_uri[-1], "title": parsed_uri[-1],
"episode_num": None, "episode_num": None,
"podcast_name": parsed_uri[-2].strip(), "podcast_name": parsed_uri[-2].strip(),
"pub_date": None, "pub_date": None,
@ -105,30 +116,14 @@ def parse_mopidy_uri(uri: str) -> dict[str, Any]:
if podcast_data["episode_num"]: if podcast_data["episode_num"]:
gap_to_strip += episode_num_pad gap_to_strip += episode_num_pad
podcast_data["episode_name"] = episode_str[gap_to_strip:].replace("-", " ").strip() podcast_data["title"] = episode_str[gap_to_strip:].replace("-", " ").strip()
return podcast_data return podcast_data
def get_or_create_podcast(post_data: dict) -> PodcastEpisode: def get_or_create_podcast(post_data: dict) -> PodcastEpisode:
logger.info("Looking up podcast", extra={"post_data": post_data, "media_type": "Podcast"}) logger.info("Looking up podcast", extra={"post_data": post_data, "media_type": "Podcast"})
mopidy_uri = post_data.get("mopidy_uri", "") mopidy_uri = post_data.get("mopidy_uri", "")
parsed_data = parse_mopidy_uri(mopidy_uri) parsed_data = parse_mopidy_uri(mopidy_uri)
producer_name = parsed_data.get("podcast_producer", post_data.get("artist", ""))
podcast_name = parsed_data.get("podcast_name", post_data.get("album", ""))
episode_name = parsed_data.get("episode_title", parsed_data.get("episode_filename", ""))
run_time_seconds = parsed_data.get("episode_runtime_seconds", post_data.get("run_time", 2700))
episode_dict = { return PodcastEpisode.find_or_create(**parsed_data)
"title": episode_name,
"podcast_name": podcast_name,
"podcast_description": parsed_data.get("podcast_description"),
"pub_date": parsed_data.get("pub_date"),
"number": parsed_data.get("episode_num"),
"mopidy_uri": mopidy_uri,
"producer_name": producer_name,
"run_time_seconds": run_time_seconds,
}
return PodcastEpisode.find_or_create(**episode_dict)

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
@ -56,18 +63,11 @@ def mopidy_scrobble_media(post_data: dict, user_id: int) -> Scrobble:
if media_type == Scrobble.MediaType.PODCAST_EPISODE: if media_type == Scrobble.MediaType.PODCAST_EPISODE:
parsed_data = parse_mopidy_uri(post_data.get("mopidy_uri", "")) parsed_data = parse_mopidy_uri(post_data.get("mopidy_uri", ""))
podcast_name = post_data.get( if not parsed_data:
"album", parsed_data.get("podcast_name", "") logger.warning("Tried to scrobble podcast but no uri found", extra={"post_data": post_data})
) return Scrobble()
media_obj = PodcastEpisode.find_or_create( media_obj = PodcastEpisode.find_or_create(**parsed_data)
title=parsed_data.get("episode_filename", ""),
podcast_name=podcast_name,
producer_name=post_data.get("artist", ""),
number=parsed_data.get("episode_num", ""),
pub_date=parsed_data.get("pub_date", ""),
mopidy_uri=post_data.get("mopidy_uri", ""),
)
else: else:
media_obj = Track.find_or_create( media_obj = Track.find_or_create(
title=post_data.get("name", ""), title=post_data.get("name", ""),
@ -262,13 +262,43 @@ 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, action: Optional[str] = None
): ):
book = Book.find_or_create(title) log = {}
source = "Vrobbler"
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,
} }
@ -282,7 +312,19 @@ 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(
@ -539,6 +581,8 @@ 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)
@ -875,8 +919,13 @@ def manual_scrobble_webpage(
) )
scrobble = Scrobble.create_or_update(webpage, user_id, scrobble_dict) scrobble = Scrobble.create_or_update(webpage, user_id, scrobble_dict)
# possibly async this?
scrobble.push_to_archivebox() if action == "stop":
scrobble.stop(force_finish=True)
else:
# possibly async this?
scrobble.push_to_archivebox()
return scrobble return scrobble

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,7 +394,10 @@ 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:
qs = base_scrobble_qs(user_id).filter(day=date)
except AttibuteError as e:
logger.warning(f"Can't generate calorie total: {e}")
agg = qs.aggregate(total_calories=models.Sum("calories_int")) agg = qs.aggregate(total_calories=models.Sum("calories_int"))
return agg["total_calories"] or 0 return agg["total_calories"] or 0
@ -408,3 +412,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

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

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