Compare commits

...

67 Commits
24 ... 34

Author SHA1 Message Date
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
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
8c947d35dd [release] Bump version to 27.0 2025-10-14 11:18:05 -04:00
61bab1f734 [podcasts] Clean up lookup and creation 2025-10-14 11:17:20 -04:00
42ce6df9bd [templates] Small fix to obj title missing 2025-10-14 10:58:54 -04:00
cbd46df4bc [scrobbles] Add Food and Geolocation to long play & manual comics 2025-10-14 10:58:31 -04:00
e7203cdb9b [podcasts] Add parsing of RSS feed urls 2025-10-14 10:55:09 -04:00
7246adfeb6 [project] Update and reorganize todos 2025-09-30 09:36:03 -04:00
a5606951c5 [templates] Fix missing Beer placeholder 2025-09-23 00:43:49 -04:00
0b4537b7ed [food] Add calories per day 2025-09-16 15:28:28 -04:00
6306390f82 [release] 26 2025-09-11 18:58:48 -04:00
350d3ceb14 [templates] Move moods around 2025-09-11 18:56:34 -04:00
a1ff82bfec [templates] Cleaning up templates and datalog forms 2025-09-11 18:55:45 -04:00
92c0c668b3 [locations] Add locations to dashboard 2025-09-11 18:29:28 -04:00
3b77feda45 [templates] Add links to scrobbles
Ultimately these should probably be templated
2025-09-11 18:03:35 -04:00
45c402f8c1 [music] Fix bug in generating log data form 2025-09-11 17:57:17 -04:00
90a1398438 [foods] Adjust fields on food data log 2025-09-11 10:44:48 -04:00
c7a81802ac [release] 25.0 2025-09-11 09:45:02 -04:00
a9a8678ac0 [project] Update toods 2025-09-11 09:43:23 -04:00
cbf0583871 [foods] Add calories to food model 2025-09-11 09:41:22 -04:00
5cac1fe109 [templates] Fix food templates and such 2025-09-11 09:41:12 -04:00
6782ed312d [templates] Add food to homepage 2025-09-11 09:33:34 -04:00
fda505ea4e [scrobbles] Fix calc of elapsed time 2025-09-11 09:33:29 -04:00
8db111f66f [food] Fix lookup for food object 2025-09-11 09:27:32 -04:00
ee1cae496a [music] Back and forth ... let us not use album name 2025-09-11 09:08:14 -04:00
9403c68184 [videos] Fix small bug in views 2025-09-11 09:06:07 -04:00
96030f4a99 [boardgames] Fix expansion checking 2025-09-11 09:05:50 -04:00
a8c3925af4 [project] Check off another task 2025-08-20 11:40:18 -04:00
a2f507a976 [videos] Wire up generic video list view 2025-08-20 11:39:27 -04:00
7a7edc6e47 [templates] Fix some bugs and clean up list views 2025-08-20 11:27:10 -04:00
af6c39fb85 [templates] Clean up task titles 2025-08-19 12:37:26 -04:00
98 changed files with 1986 additions and 446 deletions

View File

@ -1,6 +1,20 @@
#+title: Vrobbler Project
* Overview
Vrobbler began humbly enough as a way to use Jellyfin's webhook to keep track of
the shows and movies I was watching. More specifically, I broke my ankle a few
days after Christmas in 2022 and spent the next four months very slowly
recovering after surgical repair. So once I had the webhook working, and
scrobbling videos, it was only a matter of time till I expaned it to mopidy to
replicate LastFM. Then I added board games, books via KoReader, sports events,
podcasts ... it just keeps going. Vrobbler is now a sort of Frankenstein's
monster of scrobbling an entire life.
I am still unconvinced I can keep this going, but being able to scrobble org
tasks, Todoist tasks, web pages I've read and trails I've hiked has turned out
to be sometimes cathartic and sometimes functional as I try to remember when I
did a thing.
* Features
** Beer
*** Triggers
@ -70,7 +84,6 @@ fetching and simple saving.
**** Bookmarklet
*** Metadata sources
**** Scraper
* Release history
* Chores
** DONE Document various vrobbler features :chore:personal:project:vrobbler:documentation:
:PROPERTIES:
@ -79,71 +92,8 @@ fetching and simple saving.
:LOGBOOK:
CLOCK: [2025-07-09 Wed 09:55]--[2025-07-09 Wed 10:15] => 0:20
:END:
* Backlog [2/22]
** TODO [#A] Add classmethod for metadata fetching to tracks :vrobbler:feature:music:personal:project:
:PROPERTIES:
:ID: bc4b45e5-4c65-13c5-ab7b-1937d3fbf5c2
:END:
:LOGBOOK:
CLOCK: [2025-07-09 Wed 10:15]
:END:
** TODO Add importer class for IMAP imports :vrobbler:feature:imap:importers:project:personal:
** TODO Add youtube link in place of IMDB on video detail page :vrobbler:feature:videos: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 small bug in views for TV series where next episode is now None :vrobbler:bug:personal:videos:
#+begin_src python
ERROR django.request:241 log_response Internal Server Error: /series/c24100d1-da45-4abe-86bf-27cfce9b1f89/
Traceback (most recent call last):
File "/usr/local/lib/python3.11/site-packages/django/core/handlers/exception.py", line 55, in inner
response = get_response(request)
^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/django/core/handlers/base.py", line 197, in _get_response
response = wrapped_callback(request, *callback_args, **callback_kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/django/views/generic/base.py", line 104, in view
return self.dispatch(request, *args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/django/contrib/auth/mixins.py", line 73, in dispatch
return super().dispatch(request, *args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/django/views/generic/base.py", line 143, in dispatch
return handler(request, *args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/django/views/generic/detail.py", line 109, in get
context = self.get_context_data(object=self.object)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/videos/views.py", line 33, in get_context_data
context_data["next_episode_id"] = "tt" + next_episode_id
~~~~~^~~~~~~~~~~~~~~~~
TypeError: can only concatenate str (not "NoneType") to str
#+end_src
** TODO [#A] Send periodic check notifications for board games and beer that ask if you're still scrobbling :vrobbler:personal:project:beers:boardgames:notifications:feature:
** TODO [#A] Fix raw text webpage title not truncating to 254 chars :vrobbler:personal:bug:webpages:
** TODO [#A] Fix koreader scrobble imports to use DST properly :vrobbler:personal:bug:books:imports:
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] Create small utility to clean up tracks scrobbled with wonky playback times :vrobbler:personal:bug:music:scrobbles:
** TODO [#B] Add AllTrails as a source for Trail data :vrobbler:trails:feature:personal:project:
Pretty clear, I would love to make trails more useful. Historically I wasn't
hiking a lot, which made the source for this a bit silly. But it's clear that
AllTrails is the best source, though having TrailForks is nice to.
** TODO [#B] Add `garmin_activity_id` to the TrailMetadataLog class :vrobbler:trails:feature:personal:project:
Would be nice to have some loose connection to the actual event in my Garmin profile.
** TODO [#B] Explore a way to add metadata editing to scrobbles after saving :vrobbler:spike:scrobbling:personal:project:
Could be as simple as a JSON form on the scrobble detail page (do I have have one of those yet?).
** TODO [#B] Explore a good way to show notes and descriptions from scrobbles to users :personal:project:scrobbling:vrobbler:spike:
** 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:
https://app.todoist.com/app/task/add-a-csv-endpoint-for-users-book-reads-that-library-thing-can-ingest-6X7QPMRp265xMXqg#comment-6X7QrXq6gJjMP4hg
** TODO [#C] Fix bug where podcast scrobbling creates duplicate Podcast :project:vrobbler:scrobbling:podcasts:bug:personal:
Rather than pick up an existing Podcast using the podcast title in the mopidy
file name, Vrobbler creates a new podcast with no enriched data. Not a big deal
for my use as the volume of podcasts I listen to makes manual fixes easy. But
it's annoying.
* Backlog [4/27]
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :vrobbler:personal:bug:music:scrobbles:
** TODO [#C] Move to using more robust mopidy-webhooks pacakge form pypi :utility:improvement:
:PROPERTIES:
:ID: ab31fdc3-359c-1b1d-6b9d-546b476021ba
@ -442,6 +392,239 @@ it's annoying.
** TODO [#C] Allow users to see tasks on calendar view :vrobbler:personal:project:templates:feature:
https://codepen.io/oliviale/pen/QYqybo
** TODO [#C] Come up with a possible flow using WebDAV and super-productivity for tasks :personal:feature:project:vrobbler:tasks:
** TODO [#B] Add importer class for IMAP imports :vrobbler:feature:imap:importers:project:personal:
** TODO [#B] Clean up follow up notifications for board games and beer that ask if you're still scrobbling :vrobbler:personal:project:beers:boardgames:notifications:feature:
- Note taken on [2025-09-30 Tue 09:32]
I added this feature in a very rough way, but now we should add "Action"
headers so that we can either Finish or Cancel the associated scrobble:
https://docs.ntfy.sh/publish/#send-http-request
** TODO [#B] Add AllTrails as a source for Trail data :vrobbler:trails:feature:personal:project:
Pretty clear, I would love to make trails more useful. Historically I wasn't
hiking a lot, which made the source for this a bit silly. But it's clear that
AllTrails is the best source, though having TrailForks is nice to.
** TODO [#B] Add `garmin_activity_id` to the TrailMetadataLog class :vrobbler:trails:feature:personal:project:
Would be nice to have some loose connection to the actual event in my Garmin profile.
** TODO [#B] Explore a way to add metadata editing to scrobbles after saving :vrobbler:spike:scrobbling:personal:project:
Could be as simple as a JSON form on the scrobble detail page (do I have have one of those yet?).
** TODO [#B] Explore a good way to show notes and descriptions from scrobbles to users :personal:project:scrobbling:vrobbler:spike:
** 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:
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] Scrape ComicBookRoundUp ratings for comic book metadata :vrobbler:books:feature:comicbook:personal:project:
- Note taken on [2025-09-25 Thu 10:51]
As an example https://comicbookroundup.com/comic-books/reviews/humanoids-publishing/the-history-of-science-fiction
** TODO [#B] Add PuzzleLogData class with with_people and completed :vrobbler:feature:puzzles:logdata:personal:project:
** TODO [#A] Tasks from org-mode should properly update notes and leave them out of the body :vrobbler:bug:tasks:
** TODO [#A] Fix koreader scrobble imports to use DST properly :vrobbler:personal:bug:books:imports:
- Note taken on [2025-09-25 Thu 10:37] \\
This may already be fixed ... need to check.
- Note taken on [2025-02-25 12:34] \\
The page data has the canonical date something was read in it, but it seems
to be an hour off. I traced this back to being off during DST, so we just need
the importer to be aware of whether a user is using DST or not and roll back
an hour for part of the year. Also, we'd need to adjust any old scrobbles that
took place with DST off to roll them back by an hour.
** TODO [#A] Allow scrobbling from the Food list page's start links :vrobbler:bug:food:scrobbling:personal:project:
https://life.lab.unbl.ink/scrobble/e39779c8-62a5-46a6-bdef-fb7662810dc6/start/
** TODO [#A] Puzzles (and all longplays) should have a "Completed?" column on their detail page :vrobbler:bug:puzzles:personal:project:
** TODO [#A] Fix raw text webpage title not truncating to 254 chars :vrobbler:personal:bug:webpages:
- Note taken on [2025-09-30 Tue 09:33]
This may have already been resolved ... need to just confirm it.
** TODO [#A] Find page numbers for comic books from ComicVine :vrobbler:feature:books:personal:project:
* Version 34.0 [4/4]
** DONE [#A] Use bgg-api for BoardGameGeek lookups :vrobbler:feature:boardgames:personal:project:
:PROPERTIES:
:ID: 738abb5a-c796-b16b-fe10-6e5639a0e10d
:END:
** DONE [#A] Add classmethod for metadata fetching to tracks :vrobbler:feature:music:personal:project:
:PROPERTIES:
:ID: bc4b45e5-4c65-13c5-ab7b-1937d3fbf5c2
:END:
- Note taken on [2025-10-29 Wed 21:44]
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
ERROR django.request:241 log_response Internal Server Error: /series/c24100d1-da45-4abe-86bf-27cfce9b1f89/
Traceback (most recent call last):
File "/usr/local/lib/python3.11/site-packages/django/core/handlers/exception.py", line 55, in inner
response = get_response(request)
^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/django/core/handlers/base.py", line 197, in _get_response
response = wrapped_callback(request, *callback_args, **callback_kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/django/views/generic/base.py", line 104, in view
return self.dispatch(request, *args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/django/contrib/auth/mixins.py", line 73, in dispatch
return super().dispatch(request, *args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/django/views/generic/base.py", line 143, in dispatch
return handler(request, *args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/django/views/generic/detail.py", line 109, in get
context = self.get_context_data(object=self.object)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/videos/views.py", line 33, in get_context_data
context_data["next_episode_id"] = "tt" + next_episode_id
~~~~~^~~~~~~~~~~~~~~~~
TypeError: can only concatenate str (not "NoneType") to str
#+end_src
** 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-10-29 Wed 16:38]
Turns out I was misusing `orgmode` for the source of tasks when it shoulda been `Org-mode`
A good lesson in using constants for things.
* 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]
** DONE [#A] Fix bug where podcast scrobbling creates duplicate Podcast :project:vrobbler:scrobbling:podcasts:bug:personal:
:PROPERTIES:
:ID: 7377ef6c-5fa7-9e4e-9080-f9810a76118c
:END:
Rather than pick up an existing Podcast using the podcast title in the mopidy
file name, Vrobbler creates a new podcast with no enriched data. Not a big deal
for my use as the volume of podcasts I listen to makes manual fixes easy. But
it's annoying.
** DONE [#A] Allow reading comic books from readcomicsoline.ru :vrobbler:books:feature:comicbook:personal:project:scrobbling:
:PROPERTIES:
:ID: 7c7e9ecc-b675-68c3-764f-ef771ce5d88f
:END:
- Note taken on [2025-09-25 Thu 10:52]
Things to consider are whether we scrobble the issue on one page, send it to
archivebox? (yes), and how best to enrich the data
** DONE [#A] Add RSS feed lookups to podcasts :vrobbler:personal:feature:podcasts:
:PROPERTIES:
:ID: d60645b0-7578-97c1-0278-05bd9de4269c
:END:
- Note taken on [2025-10-14 Tue 10:08]
Turns out the Podcast plugin for mopidy does a pretty good job of showing the
latest file without having to scroll the bottom using only Muse to not parse
the podcast title name. BUT, now we're getting urls like this:
https://nsf.libsyn.com/rss#77e01251-cb20-4609-b577-d48e985d2e7b
This is great, because there's more context there, but it has to read out of
the RSS feed. We should add a check in the podcast util to sniff out the file
referenced in the # in that url and populate the info from there. This should
actually be much more reliable than the current state of the podcast lookup
which depends on the file to be name properly.
* Version 26.0 [3/3]
** DONE Clean up templates for scrobble details :vrobbler:personal:bug:templates:
:PROPERTIES:
:ID: 43dc1e02-c110-5b49-0ac7-4c4f7656d1aa
:END:
** DONE Add named locations visited to dashboard :vrobbler:personal:feature:locations:templates:
:PROPERTIES:
:ID: ebc365a1-cef4-f75d-569f-c24b072ef5a4
:END:
** DONE Add moods to dashboard :vrobbler:moods:feature:templates:personal:
:PROPERTIES:
:ID: c03a38ce-b337-f4fa-adba-aee08d4329f5
:END:
* Version 25.0 [3/3]
** DONE Add basic food templates and fix urls :food:vrobbler:personal:project:bug:urls:
:PROPERTIES:
:ID: 3de3459e-8e7e-abba-e068-b919a819d3e3
:END:
** DONE [#C] Fix how elapsed time is calculated :vrobbler:personal:project:scrobbles:bug:
:PROPERTIES:
:ID: cff58fc4-06ac-8016-4eae-130b51e3c9b7
:END:
** DONE Fix templates for videos and dashboard links :personal:feature:project:vrobbler:templates:
:PROPERTIES:
:ID: 7debfbaf-cdd8-f49b-57ff-804bfe7c9236
:END:
* Version 24.0 [2/2]
** DONE Clean up logdata for various media :personal:feature:project:vrobbler:logdata:
:PROPERTIES:

File diff suppressed because one or more lines are too long

134
poetry.lock generated
View File

@ -361,6 +361,22 @@ python-dateutil = ">=2.8.2,<3.0.0"
requests = ">=2.28.2,<3.0.0"
typing-extensions = ">=4.7.1,<5.0.0"
[[package]]
name = "bgg-api"
version = "1.1.13"
description = "A Python API for boardgamegeek.com"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "bgg_api-1.1.13-py3-none-any.whl", hash = "sha256:6babe32ddb0ccbba7292b789770bd64ef523cab0d2cfd0a2c326cebce3e842e7"},
{file = "bgg_api-1.1.13.tar.gz", hash = "sha256:1e921b1d2818157418abb90d4ae7a50d8b071f1ade4ab47add1c8fdfa333e6dc"},
]
[package.dependencies]
requests = ">=2.31.0,<3.0.0"
requests-cache = ">=1.1.1,<2.0.0"
[[package]]
name = "billiard"
version = "4.2.1"
@ -519,6 +535,33 @@ dev = ["CacheControl[filecache,redis]", "build", "cherrypy", "codespell[tomli]",
filecache = ["filelock (>=3.8.0)"]
redis = ["redis (>=2.10.5)"]
[[package]]
name = "cattrs"
version = "25.2.0"
description = "Composable complex class support for attrs and dataclasses."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "cattrs-25.2.0-py3-none-any.whl", hash = "sha256:539d7eedee7d2f0706e4e109182ad096d608ba84633c32c75ef3458f1d11e8f1"},
{file = "cattrs-25.2.0.tar.gz", hash = "sha256:f46c918e955db0177be6aa559068390f71988e877c603ae2e56c71827165cc06"},
]
[package.dependencies]
attrs = ">=24.3.0"
exceptiongroup = {version = ">=1.1.1", markers = "python_version < \"3.11\""}
typing-extensions = ">=4.12.2"
[package.extras]
bson = ["pymongo (>=4.4.0)"]
cbor2 = ["cbor2 (>=5.4.6)"]
msgpack = ["msgpack (>=1.0.5)"]
msgspec = ["msgspec (>=0.19.0) ; implementation_name == \"cpython\""]
orjson = ["orjson (>=3.10.7) ; implementation_name == \"cpython\""]
pyyaml = ["pyyaml (>=6.0)"]
tomlkit = ["tomlkit (>=0.11.8)"]
ujson = ["ujson (>=5.10.0)"]
[[package]]
name = "celery"
version = "5.4.0"
@ -1602,6 +1645,21 @@ files = [
[package.extras]
devel = ["colorama", "json-spec", "jsonschema", "pylint", "pytest", "pytest-benchmark", "pytest-cache", "validictory"]
[[package]]
name = "feedparser"
version = "6.0.12"
description = "Universal feed parser, handles RSS 0.9x, RSS 1.0, RSS 2.0, CDF, Atom 0.3, and Atom 1.0 feeds"
optional = false
python-versions = ">=3.6"
groups = ["main"]
files = [
{file = "feedparser-6.0.12-py3-none-any.whl", hash = "sha256:6bbff10f5a52662c00a2e3f86a38928c37c48f77b3c511aedcd51de933549324"},
{file = "feedparser-6.0.12.tar.gz", hash = "sha256:64f76ce90ae3e8ef5d1ede0f8d3b50ce26bcce71dd8ae5e82b1cd2d4a5f94228"},
]
[package.dependencies]
sgmllib3k = "*"
[[package]]
name = "filelock"
version = "3.18.0"
@ -4293,6 +4351,37 @@ urllib3 = ">=1.21.1,<3"
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
[[package]]
name = "requests-cache"
version = "1.2.1"
description = "A persistent cache for python requests"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "requests_cache-1.2.1-py3-none-any.whl", hash = "sha256:1285151cddf5331067baa82598afe2d47c7495a1334bfe7a7d329b43e9fd3603"},
{file = "requests_cache-1.2.1.tar.gz", hash = "sha256:68abc986fdc5b8d0911318fbb5f7c80eebcd4d01bfacc6685ecf8876052511d1"},
]
[package.dependencies]
attrs = ">=21.2"
cattrs = ">=22.2"
platformdirs = ">=2.5"
requests = ">=2.22"
url-normalize = ">=1.4"
urllib3 = ">=1.25.5"
[package.extras]
all = ["boto3 (>=1.15)", "botocore (>=1.18)", "itsdangerous (>=2.0)", "pymongo (>=3)", "pyyaml (>=6.0.1)", "redis (>=3)", "ujson (>=5.4)"]
bson = ["bson (>=0.5)"]
docs = ["furo (>=2023.3,<2024.0)", "linkify-it-py (>=2.0,<3.0)", "myst-parser (>=1.0,<2.0)", "sphinx (>=5.0.2,<6.0.0)", "sphinx-autodoc-typehints (>=1.19)", "sphinx-automodapi (>=0.14)", "sphinx-copybutton (>=0.5)", "sphinx-design (>=0.2)", "sphinx-notfound-page (>=0.8)", "sphinxcontrib-apidoc (>=0.3)", "sphinxext-opengraph (>=0.9)"]
dynamodb = ["boto3 (>=1.15)", "botocore (>=1.18)"]
json = ["ujson (>=5.4)"]
mongodb = ["pymongo (>=3)"]
redis = ["redis (>=3)"]
security = ["itsdangerous (>=2.0)"]
yaml = ["pyyaml (>=6.0.1)"]
[[package]]
name = "requests-oauthlib"
version = "2.0.0"
@ -4403,6 +4492,17 @@ enabler = ["pytest-enabler (>=2.2)"]
test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"]
type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"]
[[package]]
name = "sgmllib3k"
version = "1.0.0"
description = "Py3k port of sgmllib."
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "sgmllib3k-1.0.0.tar.gz", hash = "sha256:7868fb1c8bfa764c1ac563d3cf369c381d1325d36124933a726f29fcdaa812e9"},
]
[[package]]
name = "shellingham"
version = "1.5.4"
@ -4730,6 +4830,20 @@ webencodings = ">=0.4"
doc = ["sphinx", "sphinx_rtd_theme"]
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]]
name = "tld"
version = "0.13"
@ -4973,6 +5087,24 @@ tzdata = {version = "*", markers = "platform_system == \"Windows\""}
[package.extras]
devenv = ["check-manifest", "pytest (>=4.3)", "pytest-cov", "pytest-mock (>=3.3)", "zest.releaser"]
[[package]]
name = "url-normalize"
version = "2.2.1"
description = "URL normalization for Python"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "url_normalize-2.2.1-py3-none-any.whl", hash = "sha256:3deb687587dc91f7b25c9ae5162ffc0f057ae85d22b1e15cf5698311247f567b"},
{file = "url_normalize-2.2.1.tar.gz", hash = "sha256:74a540a3b6eba1d95bdc610c24f2c0141639f3ba903501e61a52a8730247ff37"},
]
[package.dependencies]
idna = ">=3.3"
[package.extras]
dev = ["mypy", "pre-commit", "pytest", "pytest-cov", "pytest-socket", "ruff"]
[[package]]
name = "urllib3"
version = "1.26.20"
@ -5499,4 +5631,4 @@ cffi = ["cffi (>=1.11)"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.9,<3.12"
content-hash = "3a483aefea0a3afebf187b17b7df72a158788024ca8121b512b39567fb5ec8ca"
content-hash = "f89cff0d1019afe54e4df89a8debf50b79776c474e60d48fcae1e7c70daa3761"

View File

@ -56,6 +56,9 @@ poetry-bumpversion = "^0.3.3"
orgparse = "^0.4.20250520"
tmdbv3api = "^1.9.0"
themoviedb = "^1.0.2"
feedparser = "^6.0.12"
titlecase = "^2.4.1"
bgg-api = "^1.1.13"
[tool.poetry.group.test]
optional = true

View File

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

View File

@ -34,7 +34,7 @@ def test_track():
Track.objects.create(
title="Emotion",
artist=Artist.objects.create(name="Carly Rae Jepsen"),
run_time_seconds=60,
base_run_time_seconds=60,
)
@ -115,6 +115,12 @@ def mopidy_podcast_request_data():
mopidy_uri=mopidy_uri, artist="NPR", album="Up First"
).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:
name = "Emotion"

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

@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Optional
import requests
from bs4 import BeautifulSoup
from django.contrib.auth import get_user_model
from django.conf import settings
User = get_user_model()
if TYPE_CHECKING:
@ -17,6 +18,8 @@ SEARCH_ID_URL = (
"https://boardgamegeek.com/xmlapi/search?search={query}&exact=1"
)
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:
@ -37,10 +40,9 @@ def take_first(thing: Optional[list]) -> str:
def lookup_boardgame_id_from_bgg(title: str) -> Optional[int]:
soup = None
headers = {"User-Agent": "Vrobbler 0.11.12"}
game_id = None
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:
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:
soup = None
game_dict = {}
headers = {"User-Agent": "Vrobbler 0.11.12"}
title = ""
bgg_id = None
@ -73,7 +74,7 @@ def lookup_boardgame_from_bgg(lookup_id: str) -> dict:
bgg_id = lookup_boardgame_id_from_bgg(title)
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:
soup = BeautifulSoup(r.text, "xml")
@ -109,7 +110,8 @@ def push_scrobble_to_bgg(scrobble: "Scrobble", user: User) -> Optional[bool]:
login_payload = {
"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

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
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
from typing import Optional, Any
from uuid import uuid4
from django import forms
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.core.files.base import ContentFile
from django.db import models
@ -191,6 +191,10 @@ class BoardGame(ScrobblableMixin):
publisher = models.ForeignKey(
BoardGamePublisher, **BNULL, on_delete=models.DO_NOTHING
)
publishers = models.ManyToManyField(
BoardGamePublisher,
related_name="board_games",
)
designers = models.ManyToManyField(
BoardGameDesigner,
related_name="board_games",
@ -224,6 +228,7 @@ class BoardGame(ScrobblableMixin):
options={"quality": 75},
)
rating = models.FloatField(**BNULL)
bgg_rank = models.IntegerField(**BNULL)
max_players = models.PositiveSmallIntegerField(**BNULL)
min_players = models.PositiveSmallIntegerField(**BNULL)
published_date = models.DateField(**BNULL)
@ -301,29 +306,58 @@ class BoardGame(ScrobblableMixin):
# Go get cover image if the URL is present
if cover_url and not self.cover:
headers = {"User-Agent": "Vrobbler 0.11.12"}
r = requests.get(cover_url, headers=headers)
logger.debug(r.status_code)
if r.status_code == 200:
fname = f"{self.title}_cover_{self.uuid}.jpg"
self.cover.save(fname, ContentFile(r.content), save=True)
logger.debug("Loaded cover image from BGGeek")
self.save_image_from_url(cover_url)
def save_image_from_url(self, url):
headers = {"User-Agent": "Vrobbler 0.11.12"}
r = requests.get(url, headers=headers)
if r.status_code == 200:
fname = f"{self.title}_cover_{self.uuid}.jpg"
self.cover.save(fname, ContentFile(r.content), save=True)
@classmethod
def find_or_create(
cls, lookup_id: str, data: Optional[dict] = {}
) -> Optional["BoardGame"]:
cls, lookup_id: str, data: dict[str, Any] = {}
) -> "BoardGame":
"""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:
data = lookup_boardgame_from_bgg(lookup_id)
if game:
logger.info("Board game exists in database.", extra={"lookup_id": lookup_id, "data": data})
return game
if data and not boardgame:
boardgame, created = cls.objects.get_or_create(
title=data["title"], bggeek_id=lookup_id
)
if created:
boardgame.fix_metadata(data=data)
bgg_data = lookup_boardgame_from_bgg(data.get("name"))
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:
chess, created = BoardGame.objects.get_or_create(title="Chess")
if created:
chess.run_time_seconds = 1800
chess.base_run_time_seconds = 1800
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(
user_id=user.id,
timestamp=game_dict.get("createdAt"),

View File

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

View File

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

View File

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

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 dataclasses import dataclass
import logging
from datetime import datetime
from typing import Optional
from uuid import uuid4
import requests
from books.constants import READCOMICSONLINE_URL
from books.openlibrary import (
lookup_author_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.contrib.auth import get_user_model
from django.core.files.base import ContentFile
@ -18,27 +22,25 @@ from django.urls import reverse
from django_extensions.db.models import TimeStampedModel
from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit
from scrobbles.dataclasses import BaseLogData, LongPlayLogData
from scrobbles.mixins import (
LongPlayScrobblableMixin,
ObjectWithGenres,
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 thefuzz import fuzz
from vrobbler.apps.books.comicvine import (
ComicVineClient,
lookup_comic_from_comicvine,
)
from vrobbler.apps.books.locg import (
lookup_comic_by_locg_slug,
lookup_comic_from_locg,
lookup_comic_writer_by_locg_slug,
)
from books.sources.google import lookup_book_from_google
from books.sources.semantic import lookup_paper_from_semantic
from scrobbles.dataclasses import BaseLogData, LongPlayLogData
from vrobbler.apps.books.sources.comicvine import (
ComicVineClient,
lookup_comic_from_comicvine,
)
COMICVINE_API_KEY = getattr(settings, "COMICVINE_API_KEY", "")
@ -62,6 +64,7 @@ class BookLogData(BaseLogData, LongPlayLogData):
pages_read: Optional[int] = None
page_start: Optional[int] = None
page_end: Optional[int] = None
resume_url: Optional[str] = None
_excluded_fields = {"koreader_hash", "page_data"}
@ -135,6 +138,7 @@ class Book(LongPlayScrobblableMixin):
)
title = models.CharField(max_length=255)
original_title = models.CharField(max_length=255, **BNULL)
authors = models.ManyToManyField(Author, blank=True)
koreader_data_by_hash = models.JSONField(**BNULL)
isbn_13 = models.CharField(max_length=255, **BNULL)
@ -145,6 +149,13 @@ class Book(LongPlayScrobblableMixin):
publish_date = models.DateField(**BNULL)
publisher = models.CharField(max_length=255, **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)
cover = models.ImageField(upload_to="books/covers/", **BNULL)
cover_small = ImageSpecField(
@ -163,7 +174,11 @@ class Book(LongPlayScrobblableMixin):
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}"
@property
@ -188,9 +203,45 @@ class Book(LongPlayScrobblableMixin):
def get_absolute_url(self):
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
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.
@ -201,7 +252,7 @@ class Book(LongPlayScrobblableMixin):
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."""
# 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:
logger.info(
"Found exact match for book by title", extra={"title": title}
@ -214,15 +265,22 @@ class Book(LongPlayScrobblableMixin):
)
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 = []
authors = book_dict.pop("authors")
cover_url = book_dict.pop("cover_url")
try:
genres = book_dict.pop("generes")
except:
genres = []
authors = book_dict.pop("authors", [])
cover_url = book_dict.pop("cover_url", "")
genres = book_dict.pop("generes", [])
if authors:
for author_str in authors:
@ -248,7 +306,7 @@ class Book(LongPlayScrobblableMixin):
return book
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)
if r.status_code == 200:
fname = f"{self.title}_{self.uuid}.jpg"
@ -344,7 +402,7 @@ class Book(LongPlayScrobblableMixin):
self.cover.save(fname, ContentFile(r.content), save=True)
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
)

View File

@ -3,7 +3,6 @@ ComicVine API Information & Documentation:
https://comicvine.gamespot.com/api/
https://comicvine.gamespot.com/api/documentation
"""
import json
import logging
from django.conf import settings
@ -200,34 +199,72 @@ class ComicVineClient(object):
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", "")
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 {}
client = ComicVineClient(
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():
logger.warn("No result found on ComicVine", extra={"title": title})
raw_results = client.search(title).get("results")
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 {}
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 = {
"title": title,
"cover_url": result.get("image").get("original_url"),
"comicvine_data": {
"id": result.get("id"),
"site_detail_url": result.get("site_detail_url"),
"description": result.get("description"),
"image": result.get("image").get("original_url"),
},
"original_title": original_title,
"issue_number": found_result.get("issue_number"),
"volume_number": found_result.get("volume_number"),
"cover_url": found_result.get("image").get("original_url"),
"comicvine_id": found_result.get("id"),
"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

View File

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

View File

@ -67,7 +67,7 @@ def lookup_paper_from_semantic(title: str) -> dict:
paper_dict["openaccess_pdf_url"] = result.get("openAccessPdf", {}).get(
"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
)
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

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

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.19 on 2025-09-11 13:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('foods', '0002_alter_food_run_time_seconds'),
]
operations = [
migrations.AddField(
model_name='food',
name='calories',
field=models.IntegerField(blank=True, null=True),
),
]

View File

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

View File

@ -8,14 +8,15 @@ from django.urls import reverse
from django_extensions.db.models import TimeStampedModel
from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit
from scrobbles.dataclasses import BaseLogData
from scrobbles.dataclasses import BaseLogData, WithPeopleLogData
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
BNULL = {"blank": True, "null": True}
@dataclass
class FoodLogData(BaseLogData):
class FoodLogData(BaseLogData, WithPeopleLogData):
calories: Optional[int] = None
meal: Optional[str] = None
rating: Optional[str] = None
@ -48,6 +49,7 @@ class FoodCategory(TimeStampedModel):
class Food(ScrobblableMixin):
description = models.TextField(**BNULL)
calories = models.IntegerField(**BNULL)
allrecipe_image = models.ImageField(upload_to="food/recipe/", **BNULL)
allrecipe_image_small = ImageSpecField(
source="allrecipe_image",
@ -72,7 +74,8 @@ class Food(ScrobblableMixin):
@property
def subtitle(self):
return self.category.name
if self.category:
return self.category.name
@property
def strings(self) -> ScrobblableConstants:

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

@ -1,5 +1,4 @@
from dataclasses import dataclass
from typing import Optional
from django.apps import apps
from django.db import models
from django.urls import reverse

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

@ -1,11 +1,13 @@
from decimal import Decimal, getcontext
import logging
from dataclasses import dataclass
from decimal import Decimal
from typing import Dict
from django.contrib.auth import get_user_model
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import models
from django.urls import reverse
from scrobbles.dataclasses import BaseLogData, WithPeopleLogData
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
logger = logging.getLogger(__name__)
@ -15,6 +17,9 @@ User = get_user_model()
GEOLOC_ACCURACY = int(getattr(settings, "GEOLOC_ACCURACY", 4))
GEOLOC_PROXIMITY = Decimal(getattr(settings, "GEOLOC_PROXIMITY", "0.0001"))
@dataclass
class GeoLocationLogData(BaseLogData, WithPeopleLogData):
pass
class GeoLocation(ScrobblableMixin):
COMPLETION_PERCENT = getattr(settings, "LOCATION_COMPLETION_PERCENT", 100)
@ -36,9 +41,13 @@ class GeoLocation(ScrobblableMixin):
def get_absolute_url(self):
return reverse(
"locations:geo_location_detail", kwargs={"slug": self.uuid}
"locations:geolocation_detail", kwargs={"slug": self.uuid}
)
@property
def logdata_cls(self):
return GeoLocationLogData
@classmethod
def find_or_create(cls, data_dict: Dict) -> "GeoLocation":
"""Given a data dict from GPSLogger, does the heavy lifting of looking up

View File

@ -8,11 +8,11 @@ urlpatterns = [
path(
"locations/",
views.GeoLocationListView.as_view(),
name="geo_locations_list",
name="geolocation_list",
),
path(
"locations/<slug:slug>/",
views.GeoLocationDetailView.as_view(),
name="geo_location_detail",
name="geolocation_detail",
),
]

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

@ -42,7 +42,7 @@ class Mood(ScrobblableMixin):
return str(self.uuid)
def get_absolute_url(self):
return reverse("moods:mood-detail", kwargs={"slug": self.uuid})
return reverse("moods:mood_detail", kwargs={"slug": self.uuid})
@property
def subtitle(self) -> str:

View File

@ -5,10 +5,10 @@ app_name = "moods"
urlpatterns = [
path("moods/", views.MoodListView.as_view(), name="mood-list"),
path("moods/", views.MoodListView.as_view(), name="mood_list"),
path(
"moods/<slug:slug>/",
views.MoodDetailView.as_view(),
name="mood-detail",
name="mood_detail",
),
]

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

@ -573,7 +573,7 @@ class Album(TimeStampedModel):
alt_name = name
album = Album.objects.filter(
name=found_name, musicbrainz_id=album_dict.get("mbid")
musicbrainz_id=album_dict.get("mbid")
).first()
if not album:
@ -677,7 +677,7 @@ class Track(ScrobblableMixin):
lookup_keys = {"title": title, "artist": artist}
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}")
track = cls.objects.filter(**lookup_keys).first()
if track:
@ -699,7 +699,7 @@ class Track(ScrobblableMixin):
if album:
track.albums.add(album)
if enrich or not track.run_time_seconds:
if enrich or not track.base_run_time_seconds:
logger.info(
f"Enriching track {track}",
extra={
@ -715,7 +715,7 @@ class Track(ScrobblableMixin):
except Exception:
print("No musicbrainz result found, cannot enrich")
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
if commit:
track.save()

View File

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

View File

@ -143,43 +143,46 @@ class PodcastEpisode(ScrobblableMixin):
def find_or_create(
cls,
title: str,
podcast_name: str,
pub_date: str,
number: int = 0,
episode_num: int = 0,
base_run_time_seconds: int = 2400,
mopidy_uri: str = "",
producer_name: str = "",
run_time_seconds: int = 1800,
podcast_name: str = "",
podcast_producer: str = "",
podcast_description: str = "",
enrich: bool = True,
) -> "PodcastEpisode":
"""Given a data dict from Mopidy, finds or creates a podcast and
producer before saving the epsiode so it can be scrobbled.
"""
log_context={"mopidy_uri": mopidy_uri, "media_type": "Podcast"}
producer = None
if producer_name:
producer = Producer.find_or_create(producer_name)
if podcast_producer:
producer = Producer.find_or_create(podcast_producer)
podcast = Podcast.objects.filter(
name__iexact=podcast_name,
).first()
if not podcast:
podcast = Podcast.objects.create(
name=podcast_name, producer=producer
)
if enrich:
podcast.fix_metadata()
podcast, created = Podcast.objects.get_or_create(name=podcast_name, defaults={"description": podcast_description})
log_context["podcast_id"] = podcast.id
log_context["podcast_name"] = podcast.name
if created:
logger.info("Created new podcast", extra=log_context)
if enrich and created:
logger.info("Enriching new podcast", extra=log_context)
podcast.fix_metadata()
episode = cls.objects.filter(
title__iexact=title, podcast=podcast
).first()
if not episode:
episode = cls.objects.create(
title=title,
podcast=podcast,
run_time_seconds=run_time_seconds,
number=number,
pub_date=pub_date,
mopidy_uri=mopidy_uri,
)
episode, created = cls.objects.get_or_create(
title=title,
podcast=podcast,
defaults={
"base_run_time_seconds": base_run_time_seconds,
"number": episode_num,
"pub_date": pub_date,
"mopidy_uri": mopidy_uri,
}
)
if created:
log_context["episode_id"] = episode.id
log_context["episode_title"] = episode.title
logger.info("Created new podcast episode", extra=log_context)
return episode

View File

@ -1,7 +1,10 @@
import logging
import os
from typing import Any
from urllib.parse import unquote
import feedparser
import requests
from dateutil.parser import ParserError, parse
from podcasts.models import PodcastEpisode
@ -10,26 +13,95 @@ logger = logging.getLogger(__name__)
# TODO This should be configurable in settings or per deploy
PODCAST_DATE_FORMAT = "YYYY-MM-DD"
def parse_duration(d):
if not d:
return None
if d.isdigit():
return int(d)
parts = [int(p) for p in d.split(":")]
while len(parts) < 3:
parts.insert(0, 0)
h, m, s = parts
return h * 3600 + m * 60 + s
def fetch_metadata_from_rss(uri: str) -> dict[str, Any]:
log_context = {"mopidy_uri": uri, "media_type": "Podcast"}
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:
resp = requests.get(rss_url, timeout=10)
feed = feedparser.parse(resp.text)
except IndexError:
logger.warning("Tried to parse uri as RSS feed, but no target found", extra=log_context)
return podcast_data
podcast_publisher = getattr(feed.feed, "itunes_publisher", "")
try:
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_name": getattr(feed.feed, "title", ""),
# "podcast_description": getattr(feed.feed, "description", ""),
# "podcast_link": getattr(feed.feed, "link", ""),
"podcast_producer": podcast_publisher or podcast_owner or podcast_other
}
for entry in feed.entries:
if entry.get("guid") == target_guid:
logger.info("🎧 Episode found in RSS feed", extra=log_context)
podcast_data["title"] = entry.title
podcast_data["episode_num"] = int(entry.get("itunes_episode", 0))
podcast_data["pub_date"] = parse(entry.get("published", None))
podcast_data["base_run_time_seconds"] = parse_duration(entry.get("itunes_duration", None))
# podcast_data["description"] = entry.get("description", None)
# podcast_data["episode_url"] = entry.enclosures[0].href if entry.get("enclosures") else None
return podcast_data
else:
logger.info("Episode not found in RSS feed.")
def parse_mopidy_uri(uri: str) -> dict[str, Any]:
podcast_data: dict[str, Any] = {}
def parse_mopidy_uri(uri: str) -> dict:
logger.debug(f"Parsing URI: {uri}")
if "podcast+https" in uri:
return fetch_metadata_from_rss(uri)
parsed_uri = os.path.splitext(unquote(uri))[0].split("/")
podcast_data = {
"title": parsed_uri[-1],
"episode_num": None,
"podcast_name": parsed_uri[-2].strip(),
"pub_date": None,
}
episode_str = parsed_uri[-1]
podcast_name = parsed_uri[-2].strip()
episode_num = None
episode_num_pad = 0
try:
# Without episode numbers the date will lead
pub_date = parse(episode_str[0:10])
podcast_data["pub_date"] = parse(episode_str[0:10])
except ParserError:
episode_num = int(episode_str.split("-")[0])
episode_num_pad = len(str(episode_num)) + 1
podcast_data["episode_num"] = int(episode_str.split("-")[0])
episode_num_pad = len(str(podcast_data["episode_num"])) + 1
try:
# Beacuse we have epsiode numbers on
pub_date = parse(
podcast_data["pub_date"] = parse(
episode_str[
episode_num_pad : len(PODCAST_DATE_FORMAT)
+ episode_num_pad
@ -39,41 +111,19 @@ def parse_mopidy_uri(uri: str) -> dict:
pub_date = ""
gap_to_strip = 0
if pub_date:
if podcast_data["pub_date"]:
gap_to_strip += len(PODCAST_DATE_FORMAT)
if episode_num:
if podcast_data["episode_num"]:
gap_to_strip += episode_num_pad
episode_name = episode_str[gap_to_strip:].replace("-", " ").strip()
podcast_data["title"] = episode_str[gap_to_strip:].replace("-", " ").strip()
return {
"episode_filename": episode_name,
"episode_num": episode_num,
"podcast_name": podcast_name,
"pub_date": pub_date,
}
return podcast_data
def get_or_create_podcast(post_data: dict) -> PodcastEpisode:
logger.info("Looking up podcast", extra={"post_data": post_data, "media_type": "Podcast"})
mopidy_uri = post_data.get("mopidy_uri", "")
parsed_data = parse_mopidy_uri(mopidy_uri)
producer_dict = {"name": post_data.get("artist")}
podcast_name = post_data.get("album")
if not podcast_name:
podcast_name = parsed_data.get("podcast_name")
podcast_dict = {"name": podcast_name}
episode_name = parsed_data.get("episode_filename")
episode_dict = {
"title": episode_name,
"run_time_seconds": post_data.get("run_time"),
"number": parsed_data.get("episode_num"),
"pub_date": parsed_data.get("pub_date"),
"mopidy_uri": mopidy_uri,
}
return PodcastEpisode.find_or_create(
podcast_dict, producer_dict, episode_dict
)
return PodcastEpisode.find_or_create(**parsed_data)

View File

@ -1,6 +1,7 @@
from django.views import generic
from podcasts.models import Podcast
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
class PodcastListView(generic.ListView):
model = Podcast

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

@ -18,6 +18,9 @@ PLAY_AGAIN_MEDIA = {
"bricksets": "BrickSet",
"trails": "Trail",
"beers": "Beer",
"foods": "Food",
"locations": "GeoLocation",
"videos": "Video",
}
MEDIA_END_PADDING_SECONDS = {
@ -35,6 +38,7 @@ SCROBBLE_CONTENT_URLS = {
"-t": ["https://app.todoist.com/app/task/{id}"],
"-p": ["https://www.ipdb.plus/IPDb/puzzle.php?id="],
"-l": ["https://brickset.com/sets/"],
"-c": ["https://readcomicsonline.ru"],
}
EXCLUDE_FROM_NOW_PLAYING = ("GeoLocation",)
@ -50,6 +54,7 @@ MANUAL_SCROBBLE_FNS = {
"-t": "manual_scrobble_task",
"-p": "manual_scrobble_puzzle",
"-l": "manual_scrobble_brickset",
"-c": "manual_scrobble_book",
}

View File

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

View File

@ -12,7 +12,7 @@ import pytz
from beers.models import Beer
from boardgames.models import BoardGame
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 dataclass_wizard.errors import ParseError
from django.conf import settings
@ -655,6 +655,13 @@ class Scrobble(TimeStampedModel):
(scrobble.elapsed_time)
)
# Remove any locations without titles
if "GeoLocation" in scrobbles_by_type.keys():
for loc_scrobble in scrobbles_by_type["GeoLocation"]:
if not loc_scrobble.media_obj.title:
scrobbles_by_type["GeoLocation"].remove(loc_scrobble)
scrobbles_by_type["GeoLocation_count"] -= 1
return scrobbles_by_type
@classmethod
@ -768,7 +775,7 @@ class Scrobble(TimeStampedModel):
and user.profile.redirect_to_webpage
):
logger.info(f"Redirecting to {self.media_obj.url}")
redirect_url = self.media_obj.get_read_url()
redirect_url = self.media_obj.url
if (
self.media_type == self.MediaType.VIDEO
@ -815,7 +822,7 @@ class Scrobble(TimeStampedModel):
"""
is_stale = False
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:
is_stale = True
return is_stale
@ -892,8 +899,9 @@ class Scrobble(TimeStampedModel):
if self.played_to_completion:
if self.playback_position_seconds:
return self.playback_position_seconds
if self.media_obj.run_time_seconds:
if self.media_obj and self.media_obj.run_time_seconds:
return self.media_obj.run_time_seconds
return (timezone.now() - self.timestamp).seconds
@property
@ -983,7 +991,7 @@ class Scrobble(TimeStampedModel):
@property
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(
"[scrobbling] cannot be updated, long play media",
extra={
@ -1070,6 +1078,8 @@ class Scrobble(TimeStampedModel):
media_obj = self.puzzle
if self.task:
media_obj = self.task
if self.food:
media_obj = self.food
return media_obj
def __str__(self):
@ -1121,6 +1131,8 @@ class Scrobble(TimeStampedModel):
media_query = models.Q(**{key: media})
scrobble_data[key + "_id"] = media.id
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)
scrobble = (
@ -1144,7 +1156,7 @@ class Scrobble(TimeStampedModel):
)
return scrobble
if not skip_in_progress_check:
if not skip_in_progress_check or read_log_page:
logger.info(
f"[create_or_update] check for existing scrobble to update ",
extra={
@ -1160,15 +1172,35 @@ class Scrobble(TimeStampedModel):
# If it's marked as stopped, send it through our update mechanism, which will complete it
if scrobble and (
scrobble.can_be_updated
or (read_log_page and scrobble.can_be_updated)
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"]
return scrobble.update(scrobble_data)
# Discard status before creating
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(
f"[scrobbling] creating new scrobble",
extra={
@ -1361,6 +1393,9 @@ class Scrobble(TimeStampedModel):
if class_name in LONG_PLAY_MEDIA.values():
self.finish_long_play()
if class_name == "Book":
self.calculate_reading_stats()
logger.info(
f"[scrobbling] stopped",
extra={
@ -1456,3 +1491,40 @@ class Scrobble(TimeStampedModel):
beyond_completion = False
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

@ -47,8 +47,8 @@ class ScrobbleNtfyNotification(ScrobbleNotification):
self.click_url = self.url_tmpl.format(path=self.scrobble.finish_url)
self.title = "Finish " + self.media_obj.strings.verb.lower() + "?"
if self.scrobble.log and isinstance(self.scrobble.log, dict) and self.scrobble.log.get("description"):
self.ntfy_str += f" - {self.scrobble.log.get('description')}"
if self.scrobble.log and isinstance(self.scrobble.log, dict) and self.scrobble.log.get("title"):
self.ntfy_str += f" - {self.scrobble.log.get('title')}"
def send(self):
if (
@ -72,7 +72,7 @@ class MoodNtfyNotification(BasicNtfyNotification):
def __init__(self, profile, **kwargs):
super().__init__(profile)
self.ntfy_str: str = "Would you like to check in about your mood?"
self.click_url = self.url_tmpl.format(path=reverse("moods:mood-list"))
self.click_url = self.url_tmpl.format(path=reverse("moods:mood_list"))
self.title = "Mood Check-in!"
def send(self):

View File

@ -7,7 +7,9 @@ import pendulum
import pytz
from beers.models import Beer
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 dateutil.parser import parse
from django.utils import timezone
@ -27,7 +29,12 @@ from scrobbles.constants import (
)
from scrobbles.models import Scrobble
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.thesportsdb import lookup_event_from_thesportsdb
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:
parsed_data = parse_mopidy_uri(post_data.get("mopidy_uri", ""))
podcast_name = post_data.get(
"album", parsed_data.get("podcast_name", "")
)
if not parsed_data:
logger.warning("Tried to scrobble podcast but no uri found", extra={"post_data": post_data})
return Scrobble()
media_obj = PodcastEpisode.find_or_create(
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", ""),
)
media_obj = PodcastEpisode.find_or_create(**parsed_data)
else:
media_obj = Track.find_or_create(
title=post_data.get("name", ""),
@ -262,13 +262,43 @@ def manual_scrobble_video_game(
def manual_scrobble_book(
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 = {
"user_id": user_id,
"timestamp": timezone.now(),
"playback_position_seconds": 0,
"source": "Vrobbler",
"source": source,
"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(
@ -313,29 +355,6 @@ def manual_scrobble_board_game(
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(
bgstat_data: dict[str, Any], user_id: int
) -> list[Scrobble]:
@ -365,11 +384,11 @@ def email_scrobble_board_game(
log_data = {}
for game in game_list:
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"):
base_games[game.get("id")] = enriched_game
elif game.get("isExpansion"):
expansions[game.get("id")] = enriched_game
base_games[game.get("id")] = game_obj
if game.get("isExpansion"):
expansions[game.get("id")] = game_obj
locations = {}
for location_dict in bgstat_data.get("locations", []):
@ -539,6 +558,8 @@ def manual_scrobble_from_url(
if content_key == "-i" and "v=" in url:
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:
item_id = "tt" + str(item_id)
@ -752,7 +773,6 @@ def emacs_scrobble_task(
user_id=user_id,
in_progress=True,
log__orgmode_id=orgmode_id,
log__source="orgmode",
task=task,
).last()
@ -875,8 +895,13 @@ def manual_scrobble_webpage(
)
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

View File

@ -1,19 +1,27 @@
import hashlib
import logging
import requests
import re
from datetime import datetime, timedelta
from datetime import date, datetime, timedelta
from typing import TYPE_CHECKING, Optional
from urllib.parse import urlparse
from zoneinfo import ZoneInfo
import pendulum
import pytz
from django.apps import apps
from django.contrib.auth import get_user_model
from django.db import models
from django.db.models.fields.json import KeyTextTransform
from django.db.models.functions import Cast, TruncDate
from django.utils import timezone
from profiles.models import UserProfile
from profiles.utils import now_user_timezone
from scrobbles.constants import LONG_PLAY_MEDIA
from scrobbles.notifications import MoodNtfyNotification, ScrobbleNtfyNotification
from scrobbles.notifications import (
MoodNtfyNotification,
ScrobbleNtfyNotification,
)
from scrobbles.tasks import (
process_koreader_import,
process_lastfm_import,
@ -21,6 +29,9 @@ from scrobbles.tasks import (
)
from webdav.client import get_webdav_client
if TYPE_CHECKING:
from scrobbles.models import Scrobble
logger = logging.getLogger(__name__)
User = get_user_model()
@ -345,3 +356,97 @@ def extract_domain(url):
+ parsed_url.netloc.split(".")[-1]
)
return domain
def fix_playback_position_seconds(*scrobbles: "Scrobble", commit=True) -> list["Scrobble"]:
updated_scrobbles = list()
for scrobble in scrobbles:
if not scrobble.media_obj:
logger.info(f"No media object found for scrobble {scrobble.id}, cannot update elapsed time")
continue
if scrobble.media_type == "Track" and scrobble.media_obj.run_time_seconds:
too_long = scrobble.playback_position_seconds > scrobble.media_obj.run_time_seconds
zero = scrobble.playback_position_seconds == 0
null = not scrobble.playback_position_seconds
if too_long or zero or null:
scrobble.playback_position_seconds = scrobble.media_obj.run_time_seconds
updated_scrobbles.append(scrobble)
if commit:
scrobble.save(update_fields=["playback_position_seconds"])
return updated_scrobbles
def base_scrobble_qs(user_id: int) -> models.QuerySet:
"""Base queryset with calories extracted as integer and day annotated."""
from scrobbles.models import Scrobble
return (
Scrobble.objects
.annotate(day=TruncDate("timestamp"))
.annotate(calories_int=Cast(KeyTextTransform("calories", "log"), models.IntegerField()))
.filter(calories_int__isnull=False, user_id=user_id)
)
def get_daily_calories_for_user_by_day(user_id: int, date: date| str) -> int:
"""Return total calories for a user on a specific day."""
if isinstance(date, str):
date = pendulum.parse(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"))
return agg["total_calories"] or 0
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."""
qs = (
base_scrobble_qs(user_id)
.values("day")
.annotate(total_calories=models.Sum("calories_int"))
.order_by("day")
)
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

@ -2,26 +2,27 @@ import calendar
import json
import logging
from datetime import datetime, timedelta
from dateutil.relativedelta import relativedelta
from django.shortcuts import redirect
import pendulum
import pytz
from dateutil.relativedelta import relativedelta
from django.apps import apps
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Count, Q, Max
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
from django.db.models import Count, Max, Q
from django.db.models.query import QuerySet
from django.http import FileResponse, HttpResponseRedirect, JsonResponse
from django.shortcuts import redirect
from django.urls import reverse_lazy
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import DetailView, FormView, TemplateView
from django.views.generic.edit import CreateView
from django.views.generic.list import ListView
from moods.models import Mood
from music.aggregators import live_charts, scrobble_counts, week_of_scrobbles
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from pendulum.parsing.exceptions import ParserError
from rest_framework import status
from rest_framework.decorators import (
api_view,
@ -54,10 +55,10 @@ from scrobbles.tasks import (
process_tsv_import,
)
from scrobbles.utils import (
get_daily_calories_for_user_by_day,
get_long_plays_completed,
get_long_plays_in_progress,
)
from moods.models import Mood
logger = logging.getLogger(__name__)
@ -146,7 +147,7 @@ class RecentScrobbleList(ListView):
if date_str:
try:
date = pendulum.parse(date_str)
except:
except ParserError:
pass
if date_str:
if date_str == "this_week" or "-W" in date_str:
@ -230,6 +231,7 @@ class RecentScrobbleList(ListView):
user=self.request.user,
)
data["counts"] = [] # scrobble_counts(user)
data["daily_calories"] = get_daily_calories_for_user_by_day(self.request.user.id, date)
else:
data["weekly_data"] = week_of_scrobbles()
data["counts"] = scrobble_counts()
@ -602,8 +604,7 @@ def scrobble_start(request, uuid):
"[scrobble_start] media object not found",
extra={"uuid": uuid, "user_id": user.id},
)
# TODO Log that we couldn't find a media obj to scrobble
return
raise Exception("No media object provided to scrobble")
scrobble = None
user_id = request.user.id
@ -623,9 +624,9 @@ def scrobble_start(request, uuid):
if (
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(success_url)
@ -940,19 +941,22 @@ class ScrobbleDetailView(DetailView):
slug_url_kwarg = "uuid"
def get_form_class(self):
return self.object.media_obj.logdata_cls.form()
return self.object.media_obj.logdata_cls().form()
def get_form(self):
FormClass = self.get_form_class()
log = self.object.log or {}
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_fixed = notes_str.encode("utf-8").decode(
"unicode_escape"
)
log["notes"] = notes_str_fixed
notes_str_fixed = notes_str.encode("utf-8").decode(
"unicode_escape"
)
log["notes"] = notes_str_fixed
return FormClass(initial=log)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -108,7 +108,7 @@ def lookup_video_from_skatevideosite(title: str) -> Optional[dict]:
.replace("(", "")
.replace(")", "")
)
run_time_seconds = (
base_run_time_seconds = (
int(
detail_soup.find("div", class_="p-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", "")),
"video_type": "S",
"year": year,
"run_time_seconds": run_time_seconds,
"base_run_time_seconds": run_time_seconds,
"cover_url": str(result.find("img").get("src")),
}

View File

@ -72,7 +72,7 @@ def lookup_video_from_tmdb(
return video_metadata
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.overview = media.overview
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.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.youtube_id = youtube_id
video_metadata.cover_url = (

View File

@ -5,7 +5,6 @@ app_name = "videos"
urlpatterns = [
# path('', views.scrobble_endpoint, name='scrobble-list'),
path("movies/", views.MovieListView.as_view(), name="movie_list"),
path("series/", views.SeriesListView.as_view(), name="series_list"),
path(
@ -13,6 +12,7 @@ urlpatterns = [
views.SeriesDetailView.as_view(),
name="series_detail",
),
path('videos/', views.VideoListView.as_view(), name='video_list'),
path(
"video/<slug:slug>/",
views.VideoDetailView.as_view(),

View File

@ -27,10 +27,11 @@ class SeriesDetailView(LoginRequiredMixin, generic.DetailView):
context_data["scrobbles"] = self.object.scrobbles_for_user(user_id)
next_episode_id = self.object.last_scrobbled_episode(
user_id
).next_imdb_id
).next_imdb_id or ""
if self.object.is_episode_playing(user_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

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:
self._update_domain_from_url()
if not self.run_time_seconds or force:
self.run_time_seconds = self.estimated_time_to_read_in_seconds
if not self.base_run_time_seconds or force:
self.base_run_time_seconds = self.estimated_time_to_read_in_seconds
if 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_SECRET = os.getenv("VROBBLER_IGDB_CLIENT_SECRET")
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_PROXIMITY = os.getenv("VROBBLER_GEOLOC_PROXIMITY", "0.0001")
POINTS_FOR_MOVEMENT_HISTORY = os.getenv(

View File

@ -1,8 +1,10 @@
{% load urlreplace %}
{% load naturalduration %}
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Latest</th>
<th scope="col">Title</th>
<th scope="col">Scrobbles</th>
<th scope="col">Start</th>
@ -10,13 +12,16 @@
</thead>
<tbody>
{% for obj in object_list %}
{% if obj.title %}
<tr>
<td><a href="{{obj.scrobble_set.last.get_absolute_url}}">{{obj.scrobble_set.last.local_timestamp}}
<td><a href="{{obj.get_absolute_url}}">{{obj}}</a></td>
{% if request.user.is_authenticated %}
<td>{{obj.scrobble_count}}</td>
<td><a type="button" class="btn btn-sm btn-primary" href="{{obj.start_url}}">Scrobble</a></td>
{% endif %}
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>

View File

@ -219,7 +219,7 @@
{% if scrobble.media_obj.primary_image_url %}<div style="float:left;padding-right:10px;padding-bottom:10px;"><img src="{{scrobble.media_obj.primary_image_url}}" /></div>{% endif %}
<p><a href="{{scrobble.media_obj.get_absolute_url}}">{{scrobble.media_obj.title}}</a></p>
{% if scrobble.media_obj.subtitle %}<p><em><a href="{{scrobble.media_obj.subtitle.get_absolute_url}}">{{scrobble.media_obj.subtitle}}</a></em></p>{% endif %}
{% if scrobble.logdata %}{% if scrobble.logdata.description %}<p><em>{{scrobble.logdata.description}}</em></p>{% endif %}{% endif %}
{% if scrobble.logdata %}{% if scrobble.logdata.title%}<p><em>{{scrobble.logdata.title}}</em></p>{% endif %}{% endif %}
<p><small>{{scrobble.local_timestamp|naturaltime}} from {{scrobble.source}}</small></p>
<div class="progress-bar" style="margin-right:5px;">
<span class="progress-bar-fill" style="width: {{scrobble.percent_played}}%;"></span>

View File

@ -55,7 +55,7 @@
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">Publisher</th>
<th scope="col">Location</th>
<th scope="col">Players</th>
</tr>
</thead>
@ -63,7 +63,7 @@
{% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
<tr>
<td><a href={{scrobble.get_absolute_url}}>{{scrobble.local_timestamp}}</a></td>
<td>{{scrobble.media_obj.publisher}}</td>
<td>{{scrobble.logdata.location }}</td>
<td>{% if scrobble.logdata.player_log %}{{scrobble.logdata.player_log}}{% else %}No data{% endif %}</td>
</tr>
{% endfor %}

View File

@ -26,7 +26,20 @@
</div>
</div>
<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>
{% for s in scrobbles %}
{% if forloop.first %}
<p><a href="{{s.logdata.resume_url}}">Resume reading</a></p>
{% endif %}
{% endfor %}
</div>
<div class="row">
<div class="col-md">

View File

@ -0,0 +1,37 @@
{% extends "base_list.html" %}
{% load static %}
{% block title %}{{object.title}}{% endblock %}
{% block lists %}
<div class="row webpage">
<div class="webpage-metadata">
<p>{{object.description}}</p>
</div>
</div>
<div class="row">
<div class="col-md">
<h3>Last scrobbles</h3>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">Calories</th>
<th scope="col">Notes</th>
</tr>
</thead>
<tbody>
{% for scrobble in scrobbles.all %}
<tr>
<td><a href={{scrobble.get_absolute_url}}>{{scrobble.local_timestamp}}</a></td>
<td>{% if scrobble.logdata.calories %}{{scrobble.logdata.calories}}{% else %}{{scrobble.media_obj.calories}}{% endif %}</td>
<td>{% for note in scrobble.logdata.notes %}{{note}}{% if not forloop.last %}; {% endif%}{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,23 @@
{% extends "base_list.html" %}
{% block title %}Foods{% endblock %}
{% block head_extra %}
<style>
dl { width: 210px; float:left; margin-right: 10px; }
dt a { color:white; text-decoration: none; font-size:smaller; }
img { height:200px; width: 200px; object-fit: cover; }
dd .right { float:right; }
</style>
{% endblock %}
{% block lists %}
<div class="row">
<div class="col-md">
<div class="table-responsive">
{% include "_scrobblable_list.html" %}
</div>
</div>
</div>
{% endblock %}

View File

@ -57,12 +57,16 @@
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">With</th>
<th scope="col">Notes</th>
</tr>
</thead>
<tbody>
{% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
{% for scrobble in object.scrobble_set.all|dictsortreversed:"timestamp" %}
<tr>
<td>{{scrobble.local_timestamp|naturaltime}}</td>
<td><a href={{scrobble.get_absolute_url}}>{{scrobble.local_timestamp}}</a></td>
<td>{% for person in scrobble.logdata.with_people%}{{person}}{% if not forloop.last %}, {% endif%}{% endfor %}</td>
<td>{% for note in scrobble.logdata.notes %}{{note}}{% if not forloop.last %}; {% endif%}{% endfor %}
</tr>
{% endfor %}
</tbody>

View File

@ -67,13 +67,7 @@
</tr>
</thead>
<tbody>
{% for location in object_list %}
<tr>
<td>{{location.scrobble_set.count}}</td>
<td>{{location.title}}</td>
<td><a href="{{location.get_absolute_url}}">{{location.lat}}x{{location.lon}}</a></td>
</tr>
{% endfor %}
{% include "_scrobblable_list.html" %}
</tbody>
</table>
</div>

View File

@ -28,7 +28,7 @@
<tbody>
{% for scrobble in scrobbles.all %}
<tr>
<td>{{scrobble.local_timestamp}}</td>
<td><a href={{scrobble.get_absolute_url}}>{{scrobble.local_timestamp}}</a></td>
</tr>
{% endfor %}
</tbody>

View File

@ -82,7 +82,7 @@
<tbody>
{% for scrobble in object.scrobbles %}
<tr>
<td>{{scrobble.local_timestamp}}</td>
<td><a href={{scrobble.get_absolute_url}}>{{scrobble.local_timestamp}}</a></td>
<td><a href="{{scrobble.track.get_absolute_url}}">{{scrobble.track.title}}</a></td>
<td><a href="{{scrobble.track.artist.get_absolute_url}}">{{scrobble.track.artist.name}}</a></td>
</tr>

View File

@ -83,7 +83,7 @@
<tbody>
{% for scrobble in object.scrobbles %}
<tr>
<td>{{scrobble.local_timestamp}}</td>
<td><a href={{scrobble.get_absolute_url}}>{{scrobble.local_timestamp}}</a></td>
<td><a href="{{scrobble.track.get_absolute_url}}">{{scrobble.track.title}}</a></td>
<td><a href="{{scrobble.track.primary_album.get_absolute_url}}">{{scrobble.track.primary_album.name}}</a></td>
</tr>

View File

@ -28,7 +28,7 @@
<tbody>
{% for scrobble in scrobbles.all %}
<tr>
<td>{{scrobble.local_timestamp}}</td>
<td><a href={{scrobble.get_absolute_url}}>{{scrobble.local_timestamp}}</a></td>
<td><a href="{{scrobble.track.get_absolute_url}}">{{scrobble.track.title}}</a></td>
<td><a href="{{scrobble.track.primary_album.get_absolute_url}}">{{scrobble.track.primary_album}}</a></td>
<td><a href="{{scrobble.track.artist.get_absolute_url}}">{{scrobble.track.artist}}</a></td>

View File

@ -20,40 +20,7 @@
<hr />
<div class="col-md">
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Scrobbles</th>
<th scope="col">Track</th>
<th scope="col">Artist</th>
</tr>
</thead>
<tbody>
{% for track in object_list %}
<tr>
<td>{{track.scrobble_set.count}}</td>
<td><a href="{{track.get_absolute_url}}">{{track}}</a></td>
<td><a href="{{track.artist.get_absolute_url}}">{{track.artist}}</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="pagination" style="margin-bottom:50px;">
<span class="page-links">
{% if page_obj.has_previous %}
<a href="?page={{ page_obj.previous_page_number }}">previous</a>
{% endif %}
<span class="page-current">
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
</span>
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}">next</a>
{% endif %}
</span>
<div class="table-responsive">{% include "_scrobblable_list.html" %}</div>
</div>
</div>
{% endblock %}

View File

@ -45,7 +45,7 @@
<tbody>
{% for scrobble in scrobbles.all %}
<tr>
<td>{{scrobble.local_timestamp}}</td>
<td><a href={{scrobble.get_absolute_url}}>{{scrobble.local_timestamp}}</a></td>
<td>{{scrobble.podcast_episode}}</td>
</tr>
{% endfor %}

View File

@ -1,5 +1,6 @@
{% extends "base_list.html" %}
{% block title %}Podcasts{% endblock %}
{% block lists %}
<div class="row">
<div class="col-md">
@ -7,20 +8,19 @@
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Series</th>
<th scope="col">Episode</th>
<th scope="col">Podcast</th>
<th scope="col">Scrobbles</th>
<th scope="col">All time</th>
</tr>
</thead>
<tbody>
{% for obj in object_list %}
{% for episode in obj.episode_set.all %}
{% for obj in object_list.all %}
{{obj.episodes}}
{% for episode in obj.podcastepisode_set.all %}
<tr>
<td><a href="{{episode.get_absolute_url}}">{{episode}}</a></td>
<td><a href="{{obj.get_absolute_url}}">{{obj}}</a></td>
<td>{{episode.scrobble_set.count}}</td>
<td></td>
</tr>
{% endfor %}
{% endfor %}

View File

@ -57,7 +57,7 @@
<tbody>
{% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
<tr>
<td>{{scrobble.local_timestamp}}</td>
<td><a href={{scrobble.get_absolute_url}}>{{scrobble.local_timestamp}}</a></td>
</tr>
{% endfor %}
</tbody>

View File

@ -13,8 +13,27 @@
{% include "scrobbles/_scrobble_table.html" %}
{% endwith %}
{% else %}
No tracks today
<p>No tracks today</p>
{% endif %}
<h3><a href="{% url 'foods:food_list' %}">Food</a></h3>
{% if Food %}
{% with scrobbles=Food count=Food_count time=Food_time %}
{% include "scrobbles/_scrobble_table.html" %}
{% endwith %}
{% else %}
<p>No food today</p>
{% endif %}
<h3><a href="{% url 'moods:mood_list' %}">Moods</a></h3>
{% if Mood %}
{% with scrobbles=Mood count=Mood_count time=Mood_time %}
{% include "scrobbles/_scrobble_table.html" %}
{% endwith %}
{% else %}
<p>No moods felt today </p>
{% endif %}
</div>
<div class="col-md">
@ -27,7 +46,7 @@
<p>No tasks today</p>
{% endif %}
<h3><a href="{% url 'videos:movie_list' %}">Videos</a></h3>
<h3><a href="{% url 'videos:video_list' %}">Videos</a></h3>
{% if Video %}
{% with scrobbles=Video count=Video_count time=Video_time %}
{% include "scrobbles/_scrobble_table.html" %}
@ -81,11 +100,13 @@
<p>No board games today</p>
{% endif %}
<h3><a href="{% url 'beers:beer_list' %}">Beers</a></h3>
{% if Beer %}
<h4>Beers</h4>
{% with scrobbles=Beer count=Beer_count time=Beer_time %}
{% include "scrobbles/_scrobble_table.html" %}
{% endwith %}
{% else %}
<p>No beer today</p>
{% endif %}
<h3><a href="{% url 'bricksets:brickset_list' %}">Brick sets</a></h3>
@ -97,7 +118,7 @@
<p>No brick sets today</p>
{% endif %}
<h3><a href="{% url 'puzzles:puzzle_list' %}">Puzzles</aa></h3>
<h3><a href="{% url 'puzzles:puzzle_list' %}">Puzzles</a></h3>
{% if Puzzle %}
{% with scrobbles=Puzzle count=Puzzle_count time=Puzzle_time %}
{% include "scrobbles/_scrobble_table.html" %}
@ -112,7 +133,16 @@
{% include "scrobbles/_scrobble_table.html" %}
{% endwith %}
{% else %}
<p>No books today</p>
<p>No books read today</p>
{% endif %}
<h3><a href="{% url 'locations:geolocation_list' %}">Locations</a></h3>
{% if GeoLocation %}
{% with scrobbles=GeoLocation count=GeoLocation_count time=GeoLocation_time %}
{% include "scrobbles/_scrobble_table.html" %}
{% endwith %}
{% else %}
<p>No locations visited today</p>
{% endif %}
</div>

View File

@ -1,10 +1,10 @@
{% load humanize %}
{% load naturalduration %}
<tr {% if scrobble.in_progress %}class="in-progress"{% endif %}>
<td>{% if scrobble.in_progress %}{{scrobble.media_obj.strings.verb}} now | <a class="right" href="{% url "scrobbles:finish" scrobble.uuid %}">Finish</a>{% else %}{{scrobble.local_timestamp|naturaltime}}{% endif %}</td>
<td>{% if scrobble.in_progress %}<a href="{{scrobble.get_absolute_url}}">{{scrobble.media_obj.strings.verb}} now</a> | <a class="right" href="{% url "scrobbles:finish" scrobble.uuid %}">Finish</a>{% else %}<a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp|naturaltime}}</a>{% endif %}</td>
<td>
{% if scrobble.media_type == "Task" %}
<p><em><a href="{{scrobble.media_obj.get_absolute_url}}">{{scrobble.media_obj.title|truncatechars_html:45}} - {% if scrobble.logdata %}{% if scrobble.logdata.description %}{{scrobble.logdata.description}}{% endif %}{% endif %}</a></em></p>
<p><em><a href="{{scrobble.media_obj.get_absolute_url}}">{{scrobble.media_obj.title|truncatechars_html:45}} - {% if scrobble.logdata %}{% if scrobble.logdata.title%}{{scrobble.logdata.title}}{% endif %}{% endif %}</a></em></p>
{% else %}
<a href="{{scrobble.media_obj.get_absolute_url}}">{{scrobble.media_obj|truncatechars_html:45}}</a>
{% endif %}

View File

@ -4,7 +4,7 @@
<div class="tab-pane fade show" id="latest-beers" role="tabpanel"
aria-labelledby="latest-beers-tab">
<div class="table-responsive">
{{count}} scrobble {% if time %}| {{time|natural_duration}}{% endif %}
{{count}} scrobble {% if time %}| {{time|natural_duration}}{% endif %}{% if daily_calories %}| {{daily_calories}} calories{% endif %}
<table class="table table-striped table-sm">
<thead>
<tr>

View File

@ -12,7 +12,9 @@
<h1>{{ object.media_obj }} - {{object.media_type}}</h1>
<!-- Your existing detail page content -->
{% if object.logdata.avg_seconds_per_page %}
<p>Rate: {{object.logdata.avg_seconds_per_page}}s per page</p>
{% endif %}
<h2>Edit Log</h2>
<form method="post" class="needs-validation" novalidate>

View File

@ -20,7 +20,7 @@
<tbody>
{% for scrobble in scrobbles.all %}
<tr>
<td>{{scrobble.local_timestamp}}</td>
<td><a href={{scrobble.get_absolute_url}}>{{scrobble.local_timestamp}}</a></td>
<td>{{scrobble.media_obj.round.season.name}}</td>
<td>{{scrobble.media_obj.round.season.league}}</td>
</tr>

View File

@ -57,7 +57,7 @@
<tbody>
{% for scrobble in object.scrobble_set.all|dictsortreversed:"timestamp" %}
<tr>
<td>{{scrobble.local_timestamp}}</td>
<td><a href={{scrobble.get_absolute_url}}>{{scrobble.local_timestamp}}</a></td>
</tr>
{% endfor %}
</tbody>

View File

@ -1,28 +1,32 @@
{% extends "base_list.html" %}
{% block title %}Movies{% endblock %}
{% block head_extra %}
<style>
dl {
width: 210px;
float: left;
margin-right: 10px;
}
dt a {
color: white;
text-decoration: none;
font-size: smaller;
}
img {
height: 200px;
width: 200px;
object-fit: cover;
}
dd .right {
float: right;
}
</style>
{% endblock %}
{% block lists %}
<div class="row">
<div class="col-md">
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Title</th>
<th scope="col">Scrobbles</th>
<th scope="col">All time</th>
</tr>
</thead>
<tbody>
{% for obj in object_list %}
<tr>
<td><a href="{{obj.get_absolute_url}}">{{obj}}</a></td>
<td>{{obj.scrobble_set.count}}</td>
<td></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="col-md">
<div class="table-responsive">{% include "_scrobblable_list.html" %}</div>
</div>
</div>
{% endblock %}
{% endblock %}

View File

@ -60,7 +60,7 @@
<tbody>
{% for scrobble in scrobbles %}
<tr>
<td>{{scrobble.local_timestamp}}</td>
<td><a href={{scrobble.get_absolute_url}}>{{scrobble.local_timestamp}}</a></td>
<td><a href="{{scrobble.media_obj.get_absolute_url}}">{{scrobble.media_obj.title}}</a></td>
<td>{{scrobble.media_obj.season_number}}</td>
<td>{{scrobble.media_obj.episode_number}}</td>

View File

@ -1,32 +1,32 @@
{% extends "base_list.html" %}
{% block title %}Series{% endblock %}
{% block head_extra %}
<style>
dl {
width: 210px;
float: left;
margin-right: 10px;
}
dt a {
color: white;
text-decoration: none;
font-size: smaller;
}
img {
height: 200px;
width: 200px;
object-fit: cover;
}
dd .right {
float: right;
}
</style>
{% endblock %}
{% block lists %}
<div class="row">
<div class="col-md">
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Series</th>
<th scope="col">Episode</th>
<th scope="col">Scrobbles</th>
<th scope="col">All time</th>
</tr>
</thead>
<tbody>
{% for obj in object_list %}
{% for video in obj.video_set.all %}
<tr>
<td><a href="{{video.get_absolute_url}}">{{video}}</a></td>
<td><a href="{{obj.get_absolute_url}}">{{obj}}</a></td>
<td>{{video.scrobble_set.count}}</td>
<td></td>
</tr>
{% endfor %}
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="col-md">
<div class="table-responsive">{% include "_scrobblable_list.html" %}</div>
</div>
</div>
{% endblock %}
{% endblock %}

View File

@ -87,7 +87,7 @@ dd {
<tbody>
{% for scrobble in scrobbles.all %}
<tr>
<td>{{scrobble.local_timestamp}}</td>
<td><a href={{scrobble.get_absolute_url}}>{{scrobble.local_timestamp}}</a></td>
</tr>
{% endfor %}
</tbody>

View File

@ -0,0 +1,32 @@
{% extends "base_list.html" %}
{% block title %}Videos{% endblock %}
{% block head_extra %}
<style>
dl {
width: 210px;
float: left;
margin-right: 10px;
}
dt a {
color: white;
text-decoration: none;
font-size: smaller;
}
img {
height: 200px;
width: 200px;
object-fit: cover;
}
dd .right {
float: right;
}
</style>
{% endblock %}
{% block lists %}
<div class="row">
<div class="col-md">
<div class="table-responsive">{% include "_scrobblable_list.html" %}</div>
</div>
</div>
{% endblock %}

View File

@ -48,12 +48,14 @@
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">Notes</th>
</tr>
</thead>
<tbody>
{% for scrobble in scrobbles.all %}
<tr>
<td>{{scrobble.local_timestamp}}</td>
<td><a href={{scrobble.get_absolute_url}}>{{scrobble.local_timestamp}}</a></td>
<td>{% for note in scrobble.logdata.notes %}{{note}}{% if not forloop.last %}; {% endif%}{% endfor %}
</tr>
{% endfor %}
</tbody>