Compare commits

...

53 Commits
20 ... 27

Author SHA1 Message Date
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
36cfdd6f6c [release] 24.0 2025-08-19 00:57:52 -04:00
b11d87af75 [logdata] Janky fix to people and platform ids 2025-08-19 00:46:08 -04:00
1cf50209a4 [bricksets] Add templates 2025-08-18 20:41:16 -04:00
8a5486fb2c [templates] Remove sidebar, add dashboard links 2025-08-18 20:28:43 -04:00
135d6e65fa [templates] Make vrobbler to go home page 2025-08-17 20:23:02 -04:00
965f2dd41b [tasks] Use title, not description 2025-08-17 20:21:08 -04:00
1a1de02843 [release] 23 2025-08-17 12:41:10 -04:00
a1868e7b2c [project] Updating TODOs 2025-08-17 12:38:27 -04:00
52494651bf [scrobbles] Add dynamic forms for LogData classes 2025-08-17 12:38:11 -04:00
1093aa2376 [music] Fix getting album when duplicated name 2025-08-06 12:40:10 -04:00
d1f04c15a9 [music] Fix breaking on w. 2025-08-06 11:03:40 -04:00
fd3487c225 [tasks] A few little clean ups 2025-08-06 10:59:32 -04:00
df91526b0c [videogames] Fix showing platform in logdata 2025-08-05 10:18:10 -04:00
70f103db6f [boardgames] Remove print statements 2025-08-05 02:04:17 -04:00
b0b32821e3 [scrobbles] Clean up todoist logs too 2025-08-05 02:04:07 -04:00
278cab32ea [scrobbles] Start cleaning up logdata 2025-08-05 02:01:31 -04:00
06e075553a [scrobbles] CLean up some dataclasses 2025-08-05 01:56:20 -04:00
833368c8d7 [profiles] Clean up default task lookup 2025-08-05 01:56:03 -04:00
f70bab30d0 [scrobbles] Log errors when parsing fails 2025-08-05 00:13:44 -04:00
f230af89eb [videogames] Add scrobbles to views 2025-08-05 00:13:08 -04:00
bbc27209ab [templates] Clean up long play nonsense for video games 2025-08-05 00:12:47 -04:00
b7638c648a [templates] Fix video game detail page 2025-08-04 19:57:06 -04:00
c8926cf887 [scrobbles] Fix bug in mixin import 2025-08-03 11:33:44 -04:00
b8dd3ee258 [tests] Shim to fix broken import 2025-08-03 11:13:23 -04:00
77 changed files with 1379 additions and 488 deletions

View File

@ -1,6 +1,20 @@
#+title: Vrobbler Project #+title: Vrobbler Project
* Overview * 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 * Features
** Beer ** Beer
*** Triggers *** Triggers
@ -70,7 +84,6 @@ fetching and simple saving.
**** Bookmarklet **** Bookmarklet
*** Metadata sources *** Metadata sources
**** Scraper **** Scraper
* Release history
* Chores * Chores
** DONE Document various vrobbler features :chore:personal:project:vrobbler:documentation: ** DONE Document various vrobbler features :chore:personal:project:vrobbler:documentation:
:PROPERTIES: :PROPERTIES:
@ -79,72 +92,8 @@ fetching and simple saving.
:LOGBOOK: :LOGBOOK:
CLOCK: [2025-07-09 Wed 09:55]--[2025-07-09 Wed 10:15] => 0:20 CLOCK: [2025-07-09 Wed 09:55]--[2025-07-09 Wed 10:15] => 0:20
:END: :END:
* Backlog [1/22] * Backlog [3/27]
** TODO [#A] Add classmethod for metadata fetching to tracks :vrobbler:feature:music:personal:project: ** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :vrobbler:personal:bug:music:scrobbles:
:PROPERTIES:
:ID: bc4b45e5-4c65-13c5-ab7b-1937d3fbf5c2
:END:
:LOGBOOK:
CLOCK: [2025-07-09 Wed 10:15]
:END:
** TODO Look in comments for a timestamp for start from BG stats if the time is missing :vrobbler:feature:boardgames:project:personal:
** 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.
** TODO [#C] Move to using more robust mopidy-webhooks pacakge form pypi :utility:improvement: ** TODO [#C] Move to using more robust mopidy-webhooks pacakge form pypi :utility:improvement:
:PROPERTIES: :PROPERTIES:
:ID: ab31fdc3-359c-1b1d-6b9d-546b476021ba :ID: ab31fdc3-359c-1b1d-6b9d-546b476021ba
@ -443,17 +392,190 @@ it's annoying.
** TODO [#C] Allow users to see tasks on calendar view :vrobbler:personal:project:templates:feature: ** TODO [#C] Allow users to see tasks on calendar view :vrobbler:personal:project:templates:feature:
https://codepen.io/oliviale/pen/QYqybo 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 [#C] Come up with a possible flow using WebDAV and super-productivity for tasks :personal:feature:project:vrobbler:tasks:
* Version 19.0 ** 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] Add classmethod for metadata fetching to tracks :vrobbler:feature:music:personal:project:
:PROPERTIES:
:ID: bc4b45e5-4c65-13c5-ab7b-1937d3fbf5c2
:END:
** TODO [#A] Tasks from org-mode should properly update notes and leave them out of the body :vrobbler:bug:tasks:
** TODO [#A] Fix 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] 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.
* 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:
:ID: d5cce807-1f45-ef19-45a4-9f7069fa2a93
:END:
** DONE Removed sidebar and add links to headers :personal:feature:templates:scrobbles:
:PROPERTIES:
:ID: 1a1c0aa6-0313-c8be-1676-5d6adddef0a4
:END:
* Version 23.0 [3/3]
** DONE Add dynamic forms for LogData classes :personal:feature:vrobbler:project:forms:logdata:
:PROPERTIES:
:ID: 0db889a1-f262-fba2-7fed-ed99eded1c88
:END:
** DONE Look in comments for a timestamp for start from BG stats if the time is missing :vrobbler:feature:boardgames:project:personal:
** DONE Fix long play scrobbles to provide better data :vrobbler:feature:scrobbles:longplay:personal:project:
:PROPERTIES:
:ID: 99f6bd77-dc8f-6ed1-0321-32a52c944264
:END:
* Version 19.0 [1/1]
** DONE Add periodic check for mood :vrobbler:feature:moods:personal:project: ** DONE Add periodic check for mood :vrobbler:feature:moods:personal:project:
:PROPERTIES: :PROPERTIES:
:ID: 55404488-c69f-0dd5-838e-1d1e15c873eb :ID: 55404488-c69f-0dd5-838e-1d1e15c873eb
:END: :END:
* Version 18.7 * Version 18.7 [1/1]
** DONE Use the timezone history log to fix old Scrobbles that fall into those timezone blocks :vrobbler:chore:scrobbles:project:personal: ** DONE Use the timezone history log to fix old Scrobbles that fall into those timezone blocks :vrobbler:chore:scrobbles:project:personal:
:PROPERTIES: :PROPERTIES:
:ID: 9d055ac1-584b-20c8-7ad9-9ce36b329dc7 :ID: 9d055ac1-584b-20c8-7ad9-9ce36b329dc7
:END: :END:
* Version 18.4 * Version 18.4 [2/2]
** DONE Track timezone changes for profiles :vrobbler:feature:profiles:personal:project: ** DONE Track timezone changes for profiles :vrobbler:feature:profiles:personal:project:
:PROPERTIES: :PROPERTIES:
:ID: 89ec867f-29fd-82f1-be17-b49dddc30c78 :ID: 89ec867f-29fd-82f1-be17-b49dddc30c78
@ -466,12 +588,12 @@ https://codepen.io/oliviale/pen/QYqybo
- Note taken on [2025-07-20 Sun 16:21] - Note taken on [2025-07-20 Sun 16:21]
This thing is kicking my butt. As it stands it works, but the scrobbles are not assigned to the tracks properly. This thing is kicking my butt. As it stands it works, but the scrobbles are not assigned to the tracks properly.
* Version 18.3 * Version 18.3 [1/1]
** DONE Add timezone awarness to IMAP importer :personal:project:vrobbler:feature:importer:imap:timezones: ** DONE Add timezone awarness to IMAP importer :personal:project:vrobbler:feature:importer:imap:timezones:
:PROPERTIES: :PROPERTIES:
:ID: 05837b7c-96aa-6190-3678-e2ae7c7cac75 :ID: 05837b7c-96aa-6190-3678-e2ae7c7cac75
:END: :END:
* Version 18 * Version 18 [4/4]
** DONE Condense tracks of the same title by the same artist with multiple albums :vrobbler:feature:music:project:personal: ** DONE Condense tracks of the same title by the same artist with multiple albums :vrobbler:feature:music:project:personal:
:PROPERTIES: :PROPERTIES:
:ID: b39fcec8-59fd-eab0-5809-b8144c7d2708 :ID: b39fcec8-59fd-eab0-5809-b8144c7d2708
@ -592,7 +714,7 @@ https://codepen.io/oliviale/pen/QYqybo
:PROPERTIES: :PROPERTIES:
:ID: 23f485e3-988c-6198-c79d-91fdf92f001c :ID: 23f485e3-988c-6198-c79d-91fdf92f001c
:END: :END:
* Version 17.0 * Version 17.0 [6/6]
** DONE [#A] Fix bug in new task label lookup for Emacs/Org-mode :vrobbler:bug:tasks: ** DONE [#A] Fix bug in new task label lookup for Emacs/Org-mode :vrobbler:bug:tasks:
:PROPERTIES: :PROPERTIES:
:ID: 683fb109-dfc4-85e4-80f0-ea618434f61e :ID: 683fb109-dfc4-85e4-80f0-ea618434f61e
@ -655,7 +777,7 @@ Not sure if the problem is in my Emacs hook sending or Vrobbler itself.
:PROPERTIES: :PROPERTIES:
:ID: df58f8d0-fa4a-2037-c7d7-e5388c239042 :ID: df58f8d0-fa4a-2037-c7d7-e5388c239042
:END: :END:
* Version 0.16.0 * Version 0.16.0 [19/19]
** DONE [#A] Jellyfin, bandcamp tracks from Mopidy create duplicate music tracks :bug:scrobbling:music: ** DONE [#A] Jellyfin, bandcamp tracks from Mopidy create duplicate music tracks :bug:scrobbling:music:
:PROPERTIES: :PROPERTIES:
:ID: 670e8634-49b5-dce9-1684-14f2ffb797f1 :ID: 670e8634-49b5-dce9-1684-14f2ffb797f1
@ -760,7 +882,7 @@ out using that.
** DONE Fix bug in Jellyfin scrobbles that spam more scrobbles after completion :scrobbling:videos:bug: ** DONE Fix bug in Jellyfin scrobbles that spam more scrobbles after completion :scrobbling:videos:bug:
This was fixed a while ago, but there's a new manifested bug. Going to create a This was fixed a while ago, but there's a new manifested bug. Going to create a
separate bug tracking ticket for that. separate bug tracking ticket for that.
* Version 0.11.4 * Version 0.11.4 [9/9]
** DONE Add rudimentary video game scrobbling :improvement:content:videogames: ** DONE Add rudimentary video game scrobbling :improvement:content:videogames:
CLOSED: [2023-03-07 Tue 11:11] CLOSED: [2023-03-07 Tue 11:11]
** DONE Add ability to scrobble from KOReader statistics files :improvement:books:content: ** DONE Add ability to scrobble from KOReader statistics files :improvement:books:content:

28
poetry.lock generated
View File

@ -1602,6 +1602,21 @@ files = [
[package.extras] [package.extras]
devel = ["colorama", "json-spec", "jsonschema", "pylint", "pytest", "pytest-benchmark", "pytest-cache", "validictory"] 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]] [[package]]
name = "filelock" name = "filelock"
version = "3.18.0" version = "3.18.0"
@ -4403,6 +4418,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)"] 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"] 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]] [[package]]
name = "shellingham" name = "shellingham"
version = "1.5.4" version = "1.5.4"
@ -5499,4 +5525,4 @@ cffi = ["cffi (>=1.11)"]
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = ">=3.9,<3.12" python-versions = ">=3.9,<3.12"
content-hash = "3a483aefea0a3afebf187b17b7df72a158788024ca8121b512b39567fb5ec8ca" content-hash = "cd3b566597e09aa444f9af30f95f94f922bf3dca71fbd05c887fb10cbc11d7bf"

View File

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

View File

@ -1,6 +1,6 @@
import pytest import pytest
from scrobbles.dataclasses import BoardGameLogData, BoardGameScoreLogData #from scrobbles.dataclasses import BoardGameLogData, BoardGameScoreLogData
@pytest.mark.skip("Need to get local tests running working again") @pytest.mark.skip("Need to get local tests running working again")

View File

@ -119,8 +119,6 @@ def push_scrobble_to_bgg(scrobble: "Scrobble", user: User) -> Optional[bool]:
data=json.dumps(login_payload), data=json.dumps(login_payload),
headers=headers, headers=headers,
) )
print(p)
players = [] players = []
if scrobble.log: if scrobble.log:
for player in scrobble.log.get("players"): for player in scrobble.log.get("players"):
@ -153,4 +151,3 @@ def push_scrobble_to_bgg(scrobble: "Scrobble", user: User) -> Optional[bool]:
data=json.dumps(play_payload), data=json.dumps(play_payload),
headers=headers, headers=headers,
) )
print(r)

View File

@ -5,6 +5,7 @@ from datetime import datetime
from typing import Optional from typing import Optional
from uuid import uuid4 from uuid import uuid4
from django import forms
import requests import requests
from boardgames.bgg import lookup_boardgame_from_bgg from boardgames.bgg import lookup_boardgame_from_bgg
from django.conf import settings from django.conf import settings
@ -79,9 +80,20 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
board: Optional[str] = None board: Optional[str] = None
rounds: Optional[int] = None rounds: Optional[int] = None
details: Optional[str] = None details: Optional[str] = None
# Legacy
learning: Optional[bool] = None _excluded_fields = {
scenario: Optional[str] = None "lichess_id",
"speed",
"rated",
"moves",
"variant",
}
@cached_property
def location(self):
if not self.location_id:
return
return BoardGameLocation.objects.filter(id=self.location_id).first()
@cached_property @cached_property
def player_log(self) -> str: def player_log(self) -> str:
@ -94,6 +106,23 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
) )
return "" return ""
@classmethod
def override_fields(cls) -> dict:
fields = {}
for base in cls.mro()[1:]:
if hasattr(base, "override_fields"):
base_fields = base.override_fields()
fields.update(base_fields)
custom_fields = {
"location_id": forms.ModelChoiceField(
queryset=BoardGameLocation.objects.all(),
required=False,
widget=forms.Select(),
)
}
fields.update(custom_fields)
return fields
class BoardGamePublisher(TimeStampedModel): class BoardGamePublisher(TimeStampedModel):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)

View File

@ -291,8 +291,6 @@ def build_scrobbles_from_book_map(
) or stop_timestamp.dst() == timedelta(0): ) or stop_timestamp.dst() == timedelta(0):
timestamp = timestamp - timedelta(hours=1) timestamp = timestamp - timedelta(hours=1)
stop_timestamp = stop_timestamp - timedelta(hours=1) stop_timestamp = stop_timestamp - timedelta(hours=1)
else:
print("In DST! ", timestamp)
scrobble = Scrobble.objects.filter( scrobble = Scrobble.objects.filter(
timestamp=timestamp, timestamp=timestamp,

View File

@ -1,7 +1,7 @@
from collections import OrderedDict from collections import OrderedDict
from dataclasses import dataclass from dataclasses import dataclass
import logging import logging
from datetime import timedelta, datetime from datetime import datetime
from typing import Optional from typing import Optional
from uuid import uuid4 from uuid import uuid4
@ -22,7 +22,6 @@ from scrobbles.mixins import (
LongPlayScrobblableMixin, LongPlayScrobblableMixin,
ObjectWithGenres, ObjectWithGenres,
ScrobblableConstants, ScrobblableConstants,
ScrobblableMixin,
) )
from scrobbles.utils import get_scrobbles_for_media from scrobbles.utils import get_scrobbles_for_media
from taggit.managers import TaggableManager from taggit.managers import TaggableManager
@ -64,6 +63,16 @@ class BookLogData(BaseLogData, LongPlayLogData):
page_start: Optional[int] = None page_start: Optional[int] = None
page_end: Optional[int] = None page_end: Optional[int] = None
_excluded_fields = {"koreader_hash", "page_data"}
def avg_seconds_per_page(self):
if self.page_data:
total_duration = 0
for page_num, stats in self.page_data.items():
total_duration += stats.get("duration", 0)
if total_duration:
return int(total_duration / len(self.page_data))
class Author(TimeStampedModel): class Author(TimeStampedModel):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)

View File

@ -1,9 +1,11 @@
from dataclasses import dataclass from dataclasses import dataclass
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from imagekit.models import ImageSpecField from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit from imagekit.processors import ResizeToFit
from scrobbles.mixins import LongPlayScrobblableMixin from scrobbles.mixins import LongPlayScrobblableMixin
from vrobbler.apps.scrobbles.dataclasses import ( from vrobbler.apps.scrobbles.dataclasses import (
BaseLogData, BaseLogData,
LongPlayLogData, LongPlayLogData,

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
import logging import logging
from typing import Dict, Optional from dataclasses import dataclass
from typing import Optional
from uuid import uuid4 from uuid import uuid4
import musicbrainzngs import musicbrainzngs
@ -15,24 +16,26 @@ from imagekit.processors import ResizeToFit
from music.allmusic import get_allmusic_slug, scrape_data_from_allmusic from music.allmusic import get_allmusic_slug, scrape_data_from_allmusic
from music.bandcamp import get_bandcamp_slug from music.bandcamp import get_bandcamp_slug
from music.musicbrainz import ( from music.musicbrainz import (
get_album_metadata,
get_album_metadata_with_artist, get_album_metadata_with_artist,
get_artist_metadata_extended,
get_recording_mbid_exact, get_recording_mbid_exact,
get_track_metadata_with_artist, get_track_metadata_with_artist,
lookup_album_dict_from_mb,
lookup_album_from_mb,
lookup_track_from_mb,
lookup_artist_from_mb,
) )
from music.theaudiodb import lookup_album_from_tadb, lookup_artist_from_tadb from music.theaudiodb import lookup_album_from_tadb, lookup_artist_from_tadb
from music.utils import clean_artist_name from music.utils import clean_artist_name
from scrobbles.dataclasses import BaseLogData
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
BNULL = {"blank": True, "null": True} BNULL = {"blank": True, "null": True}
@dataclass
class TrackLogData(BaseLogData):
mopidy_source: Optional[str] = None
rockbox_info: Optional[str] = None
rating: Optional[int] = None
class Artist(TimeStampedModel): class Artist(TimeStampedModel):
"""Represents a music artist. """Represents a music artist.
@ -529,13 +532,15 @@ class Album(TimeStampedModel):
logger.info( logger.info(
f"Could not find album {name} with artist {artist.name} on musicbrainz" f"Could not find album {name} with artist {artist.name} on musicbrainz"
) )
album, created = Album.objects.get_or_create( album = Album.objects.filter(name=name).first()
name=name, if not album:
) album, created = Album.objects.get_or_create(
if created: name=name,
# album.fix_metadata() )
# album.fetch_artwork() if created:
... # album.fix_metadata()
# album.fetch_artwork()
...
return album return album
if not artist: if not artist:
@ -568,7 +573,7 @@ class Album(TimeStampedModel):
alt_name = name alt_name = name
album = Album.objects.filter( album = Album.objects.filter(
name=found_name, musicbrainz_id=album_dict.get("mbid") musicbrainz_id=album_dict.get("mbid")
).first() ).first()
if not album: if not album:
@ -605,6 +610,9 @@ class Track(ScrobblableMixin):
def __str__(self): def __str__(self):
return f"{self.title} by {self.artist}" return f"{self.title} by {self.artist}"
def logdata_cls(self):
return TrackLogData
@property @property
def primary_album(self): def primary_album(self):
if self.album: if self.album:

View File

@ -10,11 +10,11 @@ logger = logging.getLogger(__name__)
def clean_artist_name(name: str) -> str: def clean_artist_name(name: str) -> str:
"""Remove featured names from artist string.""" """Remove featured names from artist string."""
if " feat. " in name.lower(): if " feat. " in name.lower():
name = re.split("feat.", name, flags=re.IGNORECASE)[0].strip() name = re.split(" feat. ", name, flags=re.IGNORECASE)[0].strip()
if " w. " in name.lower(): if " w. " in name.lower():
name = re.split("feat.", name, flags=re.IGNORECASE)[0].strip() name = re.split(" w. ", name, flags=re.IGNORECASE)[0].strip()
if " featuring " in name.lower(): if " featuring " in name.lower():
name = re.split("featuring", name, flags=re.IGNORECASE)[0].strip() name = re.split(" featuring ", name, flags=re.IGNORECASE)[0].strip()
# if " & " in name.lower() and "of the wand" not in name.lower(): # if " & " in name.lower() and "of the wand" not in name.lower():
# name = re.split("&", name, flags=re.IGNORECASE)[0].strip() # name = re.split("&", name, flags=re.IGNORECASE)[0].strip()

View File

@ -15,3 +15,6 @@ class Person(TimeStampedModel):
bgg_username = models.CharField(max_length=100, **BNULL) bgg_username = models.CharField(max_length=100, **BNULL)
lichess_username = models.CharField(max_length=100, **BNULL) lichess_username = models.CharField(max_length=100, **BNULL)
bio = models.TextField(**BNULL) bio = models.TextField(**BNULL)
def __str__(self):
return self.name

View File

@ -144,6 +144,7 @@ class PodcastEpisode(ScrobblableMixin):
cls, cls,
title: str, title: str,
podcast_name: str, podcast_name: str,
podcast_description: str,
pub_date: str, pub_date: str,
number: int = 0, number: int = 0,
mopidy_uri: str = "", mopidy_uri: str = "",
@ -155,31 +156,33 @@ class PodcastEpisode(ScrobblableMixin):
producer before saving the epsiode so it can be scrobbled. producer before saving the epsiode so it can be scrobbled.
""" """
log_context={"mopidy_uri": mopidy_uri, "media_type": "Podcast"}
producer = None producer = None
if producer_name: if producer_name:
producer = Producer.find_or_create(producer_name) producer = Producer.find_or_create(producer_name)
podcast = Podcast.objects.filter( podcast, created = Podcast.objects.get_or_create(name=podcast_name, defaults={"description": podcast_description})
name__iexact=podcast_name, log_context["podcast_id"] = podcast.id
).first() log_context["podcast_name"] = podcast.name
if not podcast: if created:
podcast = Podcast.objects.create( logger.info("Created new podcast", extra=log_context)
name=podcast_name, producer=producer if enrich and created:
) logger.info("Enriching new podcast", extra=log_context)
if enrich: podcast.fix_metadata()
podcast.fix_metadata()
episode = cls.objects.filter( episode, created = cls.objects.get_or_create(
title__iexact=title, podcast=podcast title=title,
).first() podcast=podcast,
if not episode: defaults={
episode = cls.objects.create( "run_time_seconds": run_time_seconds,
title=title, "number": number,
podcast=podcast, "pub_date": pub_date,
run_time_seconds=run_time_seconds, "mopidy_uri": mopidy_uri,
number=number, }
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 return episode

View File

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

View File

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

View File

@ -141,12 +141,10 @@ class UserProfile(TimeStampedModel):
@cached_property @cached_property
def task_context_tags(self) -> list[str]: def task_context_tags(self) -> list[str]:
tag_list = [ tag_list = settings.DEFAULT_TASK_CONTEXT_TAGS
t.strip().capitalize() tags = ""
for t in self.task_context_tags_str.split(",") if self.task_context_tags_str:
] tags = self.task_context_tags_str
tag_list = [t.strip().capitalize() for t in tags.split(",")]
if not tag_list:
tag_list = settings.DEFAULT_TASK_CONTEXT_TAG_LIST
return tag_list return tag_list

View File

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

View File

@ -3,9 +3,10 @@ from dataclasses import asdict, dataclass
from typing import Optional from typing import Optional
from dataclass_wizard import JSONWizard from dataclass_wizard import JSONWizard
from django import forms
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from locations.models import GeoLocation
from people.models import Person from people.models import Person
from scrobbles.forms import form_from_dataclass
User = get_user_model() User = get_user_model()
@ -32,14 +33,23 @@ class JSONDataclass(JSONWizard):
@dataclass @dataclass
class BaseLogData(JSONDataclass): class BaseLogData(JSONDataclass):
details: Optional[str] = None description: Optional[str] = None
notes: Optional[str] = None notes: Optional[list[str]] = None
_excluded_fields = {}
@classmethod
def form(cls):
return form_from_dataclass(cls)
@classmethod
def override_fields(cls) -> dict:
return {}
@dataclass @dataclass
class LongPlayLogData(JSONDataclass): class LongPlayLogData(JSONDataclass):
complete: Optional[bool] = None long_play_complete: bool = False
serial_scrobble_id: Optional[int] = None
@dataclass @dataclass
@ -52,4 +62,24 @@ class WithPeopleLogData(JSONDataclass):
if not self.with_people_ids: if not self.with_people_ids:
return [] return []
return [Person.objects.filter(id=pid) for pid in self.with_people_ids] return [
Person.objects.filter(id=pid).first()
for pid in self.with_people_ids
]
@classmethod
def override_fields(cls) -> dict:
fields = {}
for base in cls.mro()[1:]:
if hasattr(base, "override_fields"):
base_fields = base.override_fields()
fields.update(base_fields)
custom_fields = {
"with_people_ids": forms.ModelMultipleChoiceField(
queryset=Person.objects.all(),
required=False,
widget=forms.SelectMultiple(attrs={"size": 10}),
)
}
fields.update(custom_fields)
return fields

View File

@ -1,3 +1,6 @@
from dataclasses import fields
from typing import Union, get_args, get_origin
from django import forms from django import forms
@ -23,3 +26,87 @@ class ScrobbleForm(forms.Form):
} }
), ),
) )
# Mapping of types to Django form field classes
TYPE_FIELD_MAP = {
int: forms.IntegerField,
float: forms.FloatField,
bool: forms.BooleanField,
str: forms.CharField,
dict: forms.JSONField,
list: forms.JSONField,
}
# Optional: type-to-widget mapping
TYPE_WIDGET_MAP = {
str: forms.TextInput(attrs={"size": 80}),
dict: forms.Textarea(attrs={"rows": 10, "cols": 80}),
list: forms.Textarea(attrs={"rows": 6, "cols": 80}),
bool: forms.CheckboxInput(),
}
def django_form_field_from_type(field_type, required=True):
origin = get_origin(field_type)
# Handle Optional / Union
if origin is Union:
args = get_args(field_type)
if type(None) in args:
required = False
non_none_type = [arg for arg in args if arg is not type(None)][0]
return django_form_field_from_type(
non_none_type, required=required
)
# Determine actual type
base_type = origin if origin else field_type
field_class = TYPE_FIELD_MAP.get(base_type, forms.CharField)
widget = TYPE_WIDGET_MAP.get(base_type)
return (
field_class(required=required, widget=widget)
if widget
else field_class(required=required)
)
def form_from_dataclass(dataclass):
form_fields = {}
override_fields = {}
for klass in dataclass.mro():
if hasattr(klass, "override_fields"):
print(klass, ": ", klass.override_fields())
override_fields.update(klass.override_fields())
print("overrides: ", override_fields)
for f in fields(dataclass):
print(f)
if f.name in override_fields:
form_fields[f.name] = override_fields[f.name]
continue
required = f.default is None and f.default_factory is None
form_fields[f.name] = django_form_field_from_type(
f.type, required=required
)
if f.name in dataclass._excluded_fields:
form_fields[f.name].disabled = True
form_cls = type(f"{dataclass.__name__}Form", (forms.Form,), form_fields)
if "notes" in form_cls.base_fields:
form_cls.base_fields["notes"] = forms.CharField(
required=False,
widget=forms.Textarea(attrs={"rows": 4}),
)
def clean_notes(self):
notes_str = self.cleaned_data.get("notes", "")
return [
line.strip() for line in notes_str.splitlines() if line.strip()
]
form_cls.clean_notes = clean_notes
return form_cls

View File

@ -0,0 +1,27 @@
from django.core.management.base import BaseCommand
from vrobbler.apps.tasks.utils import (
convert_notes_to_dict,
convert_old_boardgame_log_to_new,
convert_old_orgmode_log_to_new,
convert_old_todoist_log_to_new,
)
class Command(BaseCommand):
def add_arguments(self, parser):
parser.add_argument(
"--commit",
action="store_true",
help="Commit changes",
)
def handle(self, *args, **options):
commit = False
if options["commit"]:
commit = True
else:
print("No changes will be saved, use --commit to save")
convert_old_orgmode_log_to_new(commit)
convert_old_todoist_log_to_new(commit)
convert_notes_to_dict(commit)
convert_old_boardgame_log_to_new(commit)

View File

@ -65,6 +65,10 @@ class ScrobblableMixin(TimeStampedModel):
class Meta: class Meta:
abstract = True abstract = True
@classmethod
def is_long_play_media(cls) -> bool:
return False
def scrobble_for_user( def scrobble_for_user(
self, self,
user_id, user_id,
@ -111,9 +115,9 @@ class ScrobblableMixin(TimeStampedModel):
@property @property
def logdata_cls(self) -> None: def logdata_cls(self) -> None:
from scrobbles.dataclasses import ScrobbleLogData from scrobbles.dataclasses import BaseLogData
return ScrobbleLogData return BaseLogData
@property @property
def subtitle(self) -> str: def subtitle(self) -> str:
@ -136,6 +140,15 @@ class LongPlayScrobblableMixin(ScrobblableMixin):
class Meta: class Meta:
abstract = True abstract = True
@classmethod
def is_long_play_media(cls) -> bool:
return True
def is_complete(self) -> bool:
if self.log:
return bool(self.log.get("long_play_complete", None))
return False
def get_longplay_finish_url(self): def get_longplay_finish_url(self):
return reverse("scrobbles:longplay-finish", kwargs={"uuid": self.uuid}) return reverse("scrobbles:longplay-finish", kwargs={"uuid": self.uuid})

View File

@ -655,6 +655,13 @@ class Scrobble(TimeStampedModel):
(scrobble.elapsed_time) (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 return scrobbles_by_type
@classmethod @classmethod
@ -697,6 +704,12 @@ class Scrobble(TimeStampedModel):
return super(Scrobble, self).save(*args, **kwargs) return super(Scrobble, self).save(*args, **kwargs)
def get_absolute_url(self):
if not self.uuid:
self.uuid = uuid4()
self.save()
return reverse("scrobbles:detail", kwargs={"uuid": self.uuid})
def push_to_archivebox(self): def push_to_archivebox(self):
pushable_media = hasattr( pushable_media = hasattr(
self.media_obj, "push_to_archivebox" self.media_obj, "push_to_archivebox"
@ -738,13 +751,19 @@ class Scrobble(TimeStampedModel):
log_dict = {} log_dict = {}
try: try:
return logdata_cls.from_dict(log_dict) return logdata_cls(**log_dict)
except ParseError: except ParseError as e:
logger.warning( logger.warning(
"Could not parse log data", "Could not parse log data",
extra={"log_dict": log_dict, "scrobble_id": self.id}, extra={
"log_dict": log_dict,
"scrobble_id": self.id,
"error": e,
},
) )
return logdata_cls() return logdata_cls()
except TypeError as e:
return logdata_cls()
def redirect_url(self, user_id) -> str: def redirect_url(self, user_id) -> str:
user = User.objects.filter(id=user_id).first() user = User.objects.filter(id=user_id).first()
@ -880,8 +899,9 @@ class Scrobble(TimeStampedModel):
if self.played_to_completion: if self.played_to_completion:
if self.playback_position_seconds: if self.playback_position_seconds:
return 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 self.media_obj.run_time_seconds
return (timezone.now() - self.timestamp).seconds return (timezone.now() - self.timestamp).seconds
@property @property
@ -1058,11 +1078,12 @@ class Scrobble(TimeStampedModel):
media_obj = self.puzzle media_obj = self.puzzle
if self.task: if self.task:
media_obj = self.task media_obj = self.task
if self.food:
media_obj = self.food
return media_obj return media_obj
def __str__(self): def __str__(self):
timestamp = self.timestamp.strftime("%Y-%m-%d") return f"Scrobble of {self.media_obj} ({self.timestamp})"
return f"Scrobble of {self.media_obj} ({timestamp})"
def calc_reading_duration(self) -> int: def calc_reading_duration(self) -> int:
duration = 0 duration = 0

View File

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

View File

@ -368,7 +368,7 @@ def email_scrobble_board_game(
enriched_game = find_and_enrich_board_game_data(game) enriched_game = find_and_enrich_board_game_data(game)
if game.get("isBaseGame"): if game.get("isBaseGame"):
base_games[game.get("id")] = enriched_game base_games[game.get("id")] = enriched_game
elif game.get("isExpansion"): if game.get("isExpansion"):
expansions[game.get("id")] = enriched_game expansions[game.get("id")] = enriched_game
locations = {} locations = {}
@ -411,7 +411,7 @@ def email_scrobble_board_game(
except IndexError: except IndexError:
second = 0 second = 0
log_data["details"] = play_dict.get("comments") log_data["notes"] = [play_dict.get("comments")]
log_data["expansion_ids"] = [] log_data["expansion_ids"] = []
try: try:
base_game = base_games[play_dict.get("gameRefId")] base_game = base_games[play_dict.get("gameRefId")]
@ -587,9 +587,9 @@ def todoist_scrobble_update_task(
) )
return return
existing_notes = scrobble.log.get("notes", {}) if not scrobble.log.get("notes"):
existing_notes[todoist_note.get("todoist_id")] = todoist_note.get("notes") scrobble.log["notes"] = []
scrobble.log["notes"] = existing_notes scrobble.log["notes"].append(todoist_note.get("notes"))
scrobble.save(update_fields=["log"]) scrobble.save(update_fields=["log"])
logger.info( logger.info(
"[todoist_scrobble_update_task] todoist note added", "[todoist_scrobble_update_task] todoist note added",
@ -615,7 +615,7 @@ def todoist_scrobble_task(
) )
task = Task.find_or_create(title) task = Task.find_or_create(title)
timestamp = pendulum.parse(todoist_task.get("updated_at", timezone.now())) timestamp = pendulum.parse(todoist_task.pop("updated_at", timezone.now()))
in_progress_scrobble = Scrobble.objects.filter( in_progress_scrobble = Scrobble.objects.filter(
user_id=user_id, user_id=user_id,
in_progress=True, in_progress=True,
@ -657,8 +657,12 @@ def todoist_scrobble_task(
) )
return todoist_scrobble_task_finish(todoist_task, user_id, timestamp) return todoist_scrobble_task_finish(todoist_task, user_id, timestamp)
# Default to create new scrobble "if not in_progress_scrobble and in_progress_in_todoist" todoist_task["title"] = todoist_task.pop("description")
# TODO Should use updated_at from TOdoist, but parsing isn't working todoist_task["description"] = todoist_task.pop("details")
todoist_task["labels"] = todoist_task.pop("todoist_label_list", [])
todoist_task.pop("todoist_type")
todoist_task.pop("todoist_event")
scrobble_dict = { scrobble_dict = {
"user_id": user_id, "user_id": user_id,
"timestamp": timestamp, "timestamp": timestamp,
@ -686,8 +690,8 @@ def emacs_scrobble_update_task(
scrobble = Scrobble.objects.filter( scrobble = Scrobble.objects.filter(
in_progress=True, in_progress=True,
user_id=user_id, user_id=user_id,
log__source_id=emacs_id, log__orgmode_id=emacs_id,
log__source="orgmode", source="Org-mode",
).first() ).first()
if not scrobble: if not scrobble:
@ -736,18 +740,18 @@ def emacs_scrobble_task(
stopped: bool = False, stopped: bool = False,
user_context_list: list[str] = [], user_context_list: list[str] = [],
) -> Scrobble | None: ) -> Scrobble | None:
source_id = task_data.get("source_id") orgmode_id = task_data.get("source_id")
title = get_title_from_labels( title = get_title_from_labels(
task_data.get("labels", []), user_context_list task_data.get("labels", []), user_context_list
) )
task = Task.find_or_create(title) task = Task.find_or_create(title)
timestamp = pendulum.parse(task_data.get("updated_at", timezone.now())) timestamp = pendulum.parse(task_data.pop("updated_at", timezone.now()))
in_progress_scrobble = Scrobble.objects.filter( in_progress_scrobble = Scrobble.objects.filter(
user_id=user_id, user_id=user_id,
in_progress=True, in_progress=True,
log__source_id=source_id, log__orgmode_id=orgmode_id,
log__source="orgmode", log__source="orgmode",
task=task, task=task,
).last() ).last()
@ -756,7 +760,7 @@ def emacs_scrobble_task(
logger.info( logger.info(
"[emacs_scrobble_task] cannot stop already stopped task", "[emacs_scrobble_task] cannot stop already stopped task",
extra={ extra={
"emacs_id": source_id, "orgmode_id": orgmode_id,
}, },
) )
return return
@ -765,7 +769,7 @@ def emacs_scrobble_task(
logger.info( logger.info(
"[emacs_scrobble_task] cannot start already started task", "[emacs_scrobble_task] cannot start already started task",
extra={ extra={
"emacs_id": source_id, "ormode_id": orgmode_id,
}, },
) )
return in_progress_scrobble return in_progress_scrobble
@ -775,7 +779,7 @@ def emacs_scrobble_task(
logger.info( logger.info(
"[emacs_scrobble_task] finishing", "[emacs_scrobble_task] finishing",
extra={ extra={
"emacs_id": source_id, "orgmode_id": orgmode_id,
}, },
) )
in_progress_scrobble.stop(timestamp=timestamp, force_finish=True) in_progress_scrobble.stop(timestamp=timestamp, force_finish=True)
@ -786,11 +790,17 @@ def emacs_scrobble_task(
notes = task_data.pop("notes") notes = task_data.pop("notes")
if notes: if notes:
task_data["notes"] = [] task_data["notes"] = [note.get("content") for note in notes]
for note in notes: task_data["title"] = task_data.pop("description")
task_data["notes"].append( task_data["description"] = task_data.pop("body")
{note.get("timestamp"): note.get("content")} task_data["labels"] = task_data.pop("labels")
)
task_data["orgmode_id"] = task_data.pop("source_id")
task_data["orgmode_state"] = task_data.pop("state")
task_data["orgmode_properties"] = task_data.pop("properties")
task_data["orgmode_drawers"] = task_data.pop("drawers")
task_data["orgmode_timestamps"] = task_data.pop("timestamps")
task_data.pop("source")
scrobble_dict = { scrobble_dict = {
"user_id": user_id, "user_id": user_id,

View File

@ -0,0 +1,11 @@
from django import template
register = template.Library()
@register.filter(name="add_class")
def add_class(field, css_class):
# If the widget is CheckboxInput, skip adding 'form-control'
if field.field.widget.__class__.__name__ == "CheckboxInput":
return field.as_widget()
return field.as_widget(attrs={"class": css_class})

View File

@ -95,6 +95,11 @@ urlpatterns = [
views.ScrobbleLongPlaysView.as_view(), views.ScrobbleLongPlaysView.as_view(),
name="long-plays", name="long-plays",
), ),
path(
"scrobble/<slug:uuid>/",
views.ScrobbleDetailView.as_view(),
name="detail",
),
path("scrobble/<slug:uuid>/start/", views.scrobble_start, name="start"), path("scrobble/<slug:uuid>/start/", views.scrobble_start, name="start"),
path("scrobble/<slug:uuid>/finish/", views.scrobble_finish, name="finish"), path("scrobble/<slug:uuid>/finish/", views.scrobble_finish, name="finish"),
path("scrobble/<slug:uuid>/cancel/", views.scrobble_cancel, name="cancel"), path("scrobble/<slug:uuid>/cancel/", views.scrobble_cancel, name="cancel"),

View File

@ -1,19 +1,26 @@
import hashlib import hashlib
import logging import logging
import re 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 urllib.parse import urlparse
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
import pendulum
import pytz import pytz
from django.apps import apps from django.apps import apps
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db import models 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 django.utils import timezone
from profiles.models import UserProfile from profiles.models import UserProfile
from profiles.utils import now_user_timezone from profiles.utils import now_user_timezone
from scrobbles.constants import LONG_PLAY_MEDIA from scrobbles.constants import LONG_PLAY_MEDIA
from scrobbles.notifications import MoodNtfyNotification, ScrobbleNtfyNotification from scrobbles.notifications import (
MoodNtfyNotification,
ScrobbleNtfyNotification,
)
from scrobbles.tasks import ( from scrobbles.tasks import (
process_koreader_import, process_koreader_import,
process_lastfm_import, process_lastfm_import,
@ -21,6 +28,9 @@ from scrobbles.tasks import (
) )
from webdav.client import get_webdav_client from webdav.client import get_webdav_client
if TYPE_CHECKING:
from scrobbles.models import Scrobble
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
User = get_user_model() User = get_user_model()
@ -345,3 +355,56 @@ def extract_domain(url):
+ parsed_url.netloc.split(".")[-1] + parsed_url.netloc.split(".")[-1]
) )
return domain 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)
qs = base_scrobble_qs(user_id).filter(day=date)
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}

View File

@ -2,25 +2,27 @@ import calendar
import json import json
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from dateutil.relativedelta import relativedelta
import pendulum import pendulum
import pytz import pytz
from dateutil.relativedelta import relativedelta
from django.apps import apps from django.apps import apps
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Count, Q 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.db.models.query import QuerySet
from django.http import FileResponse, HttpResponseRedirect, JsonResponse from django.http import FileResponse, HttpResponseRedirect, JsonResponse
from django.shortcuts import redirect
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils import timezone from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.views.generic import DetailView, FormView, TemplateView from django.views.generic import DetailView, FormView, TemplateView
from django.views.generic.edit import CreateView from django.views.generic.edit import CreateView
from django.views.generic.list import ListView from django.views.generic.list import ListView
from moods.models import Mood
from music.aggregators import live_charts, scrobble_counts, week_of_scrobbles from music.aggregators import live_charts, scrobble_counts, week_of_scrobbles
from pendulum.parsing.exceptions import ParserError
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from rest_framework import status from rest_framework import status
from rest_framework.decorators import ( from rest_framework.decorators import (
api_view, api_view,
@ -53,10 +55,10 @@ from scrobbles.tasks import (
process_tsv_import, process_tsv_import,
) )
from scrobbles.utils import ( from scrobbles.utils import (
get_daily_calories_for_user_by_day,
get_long_plays_completed, get_long_plays_completed,
get_long_plays_in_progress, get_long_plays_in_progress,
) )
from moods.models import Mood
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -75,12 +77,17 @@ class ScrobbleableListView(ListView):
user_filter = Q(scrobble__user=self.request.user) user_filter = Q(scrobble__user=self.request.user)
queryset = ( queryset = (
queryset.filter(user_filter).annotate( queryset.filter(user_filter)
scrobble_count=Count("scrobble") .annotate(
).filter(scrobble_count__gt=0).order_by("-scrobble_count") scrobble_count=Count("scrobble", distinct=True),
last_scrobble=Max("scrobble__timestamp"),
)
.filter(scrobble_count__gt=0)
.order_by("-last_scrobble")
) )
return queryset return queryset
class ScrobbleableDetailView(DetailView): class ScrobbleableDetailView(DetailView):
model = None model = None
slug_field = "uuid" slug_field = "uuid"
@ -140,7 +147,7 @@ class RecentScrobbleList(ListView):
if date_str: if date_str:
try: try:
date = pendulum.parse(date_str) date = pendulum.parse(date_str)
except: except ParserError:
pass pass
if date_str: if date_str:
if date_str == "this_week" or "-W" in date_str: if date_str == "this_week" or "-W" in date_str:
@ -224,6 +231,7 @@ class RecentScrobbleList(ListView):
user=self.request.user, user=self.request.user,
) )
data["counts"] = [] # scrobble_counts(user) data["counts"] = [] # scrobble_counts(user)
data["daily_calories"] = get_daily_calories_for_user_by_day(self.request.user.id, date)
else: else:
data["weekly_data"] = week_of_scrobbles() data["weekly_data"] = week_of_scrobbles()
data["counts"] = scrobble_counts() data["counts"] = scrobble_counts()
@ -926,3 +934,60 @@ class ScrobbleStatusView(LoginRequiredMixin, TemplateView):
).first() ).first()
return data return data
class ScrobbleDetailView(DetailView):
model = Scrobble
slug_field = "uuid"
slug_url_kwarg = "uuid"
def get_form_class(self):
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):
notes_str = "\n".join(initial_notes)
notes_str_fixed = notes_str.encode("utf-8").decode(
"unicode_escape"
)
log["notes"] = notes_str_fixed
return FormClass(initial=log)
def post(self, request, *args, **kwargs):
self.object = self.get_object()
FormClass = self.get_form_class()
form = FormClass(request.POST)
if form.is_valid():
data = form.cleaned_data.copy()
for field_name, field in form.fields.items():
if field.disabled:
original_value = (self.object.log or {}).get(field_name)
data[field_name] = original_value
if data.get("with_people_ids", False):
data["with_people_ids"] = [
p.id for p in data["with_people_ids"]
]
if data.get("platform_id", False):
data["platform_id"] = data["platform_id"].id
self.object.log = data
self.object.save(update_fields=["log"])
return redirect(self.object.get_absolute_url())
context = self.get_context_data(log_form=form)
return self.render_to_response(context)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
if "log_form" not in context:
context["log_form"] = self.get_form()
return context

View File

@ -1,11 +1,10 @@
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime
from typing import Optional from typing import Optional
from django.apps import apps from django.apps import apps
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from scrobbles.dataclasses import JSONDataclass from scrobbles.dataclasses import BaseLogData
from scrobbles.mixins import LongPlayScrobblableMixin, ScrobblableConstants from scrobbles.mixins import LongPlayScrobblableMixin, ScrobblableConstants
BNULL = {"blank": True, "null": True} BNULL = {"blank": True, "null": True}
@ -14,27 +13,29 @@ TODOIST_TASK_URL = "https://app.todoist.com/app/task/{id}"
@dataclass @dataclass
class TaskLogData(JSONDataclass): class TaskLogData(BaseLogData):
description: Optional[str] = None
title: Optional[str] = None title: Optional[str] = None
project: Optional[str] = None labels: Optional[list[str]] = None
notes: Optional[dict] = None
updated_at: Optional[str] = None orgmode_id: Optional[str] = None
orgmode_state: Optional[str] = None
orgmode_properties: Optional[dict] = None
orgmode_drawers: Optional[list] = None
orgmode_timestamps: Optional[list] = None
todoist_id: Optional[str] = None todoist_id: Optional[str] = None
todoist_event: Optional[str] = None
todoist_type: Optional[str] = None
todoist_type: Optional[str] = None
todoist_label_list: Optional[list] = None
todoist_project_id: Optional[str] = None todoist_project_id: Optional[str] = None
body: Optional[str] = None
state: Optional[str] = None _excluded_fields = {
labels: Optional[str] = None "labels",
properties: Optional[list] = None "orgmode_id",
drawers: Optional[list] = None "orgmode_state",
source: Optional[str] = None "orgmode_properties",
source_id: Optional[str] = None "orgmode_drawers",
timestamps: Optional[list] = None "orgmode_timestamps",
details: Optional[str] = None "todoist_id",
"todoist_project_id",
}
def notes_as_str(self) -> str: def notes_as_str(self) -> str:
"""Return formatted notes with line breaks and no keys""" """Return formatted notes with line breaks and no keys"""
@ -42,6 +43,7 @@ class TaskLogData(JSONDataclass):
if isinstance(self.notes, list): if isinstance(self.notes, list):
note_block = "</br>".join(self.notes) note_block = "</br>".join(self.notes)
# DEPRECATED ... we don't store notes in dicts anymore
if isinstance(self.notes, dict): if isinstance(self.notes, dict):
for id, content in self.notes.items(): for id, content in self.notes.items():
note_block += content + "</br>" note_block += content + "</br>"
@ -80,7 +82,7 @@ class Task(LongPlayScrobblableMixin):
def subtitle_for_user(self, user_id): def subtitle_for_user(self, user_id):
scrobble = self.scrobbles(user_id).first() scrobble = self.scrobbles(user_id).first()
return scrobble.logdata.description or "" return scrobble.logdata.title or ""
@classmethod @classmethod
def find_or_create(cls, title: str) -> "Task": def find_or_create(cls, title: str) -> "Task":

View File

@ -1,22 +1,106 @@
import logging import logging
from datetime import timedelta
from django.conf import settings from django.conf import settings
from scrobbles.models import Scrobble
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def get_title_from_labels(labels: list[str], user_context_labels: list[str] = []) -> str:
def get_title_from_labels(
labels: list[str], user_context_labels: list[str] = []
) -> str:
title = "Unknown" title = "Unknown"
task_context_labels: list = user_context_labels or settings.DEFAULT_TASK_CONTEXT_TAG_LIST
for label in labels: for label in labels:
# TODO We may also want to take a user list of labels instead
label = label.capitalize() label = label.capitalize()
if label in task_context_labels: if label in user_context_labels:
title = label title = label
continue continue
if title == "Unknown": if title == "Unknown":
logger.warning( logger.warning(
"Missing a configured title context for task", "Missing a configured title context for task",
extra={"labels": labels, "task_context_labels": task_context_labels}, extra={
"labels": labels,
"user_context_labels": user_context_labels,
},
) )
return title return title
def convert_old_orgmode_log_to_new(commit=False):
scrobbles = Scrobble.objects.filter(
source="Org-mode", log__has_key="drawers"
)
for scrobble in scrobbles:
scrobble.log["title"] = scrobble.log.pop("description")
scrobble.log["description"] = scrobble.log.pop("details")
scrobble.log["orgmode_body"] = scrobble.log.pop("body")
scrobble.log["orgmode_state"] = scrobble.log.pop("state")
scrobble.log["orgmode_properties"] = scrobble.log.pop("properties")
scrobble.log["orgmode_drawers"] = scrobble.log.pop("drawers")
scrobble.log["orgmode_timestamps"] = scrobble.log.pop("timestamps")
scrobble.log["orgmode_id"] = scrobble.log.pop("source_id")
if commit:
scrobble.save(update_fields=["log"])
print(f"Updated {scrobbles.count()} orgmode tasks logs")
def convert_old_todoist_log_to_new(commit=False):
scrobbles = Scrobble.objects.filter(
source="Todoist", log__has_key="todoist_type"
)
for scrobble in scrobbles:
scrobble.log["title"] = scrobble.log.pop("description")
scrobble.log["description"] = scrobble.log.pop("details")
scrobble.log["todoist_id"] = scrobble.log.pop("source_id")
scrobble.log["labels"] = scrobble.log.pop("todoist_label_list")
scrobble.log.pop("todoist_type")
scrobble.log.pop("todoist_event")
print(f"Updating scrobble {scrobble.id}")
if commit:
scrobble.save(update_fields=["log"])
print(f"Updated {scrobbles.count()} todoist tasks logs")
def convert_notes_to_dict(commit=False):
scrobbles = Scrobble.objects.filter(log__notes__isnull=False)
count = 0
for scrobble in scrobbles:
if isinstance(scrobble.log, str):
print(f"Converting {scrobble} string note to dict")
if scrobble.log.get("notes") == "":
scrobble.log.pop("notes")
key = str(int(scrobble.timestamp.timestamp()))
notes = scrobble.log.pop("notes")
scrobble.log = {}
scrobble.log["notes"] = {key: notes}
count += 1
if isinstance(scrobble.log.get("notes"), list):
note_list = scrobble.log.pop("notes")
if all(isinstance(item, dict) for item in note_list):
scrobble.log["notes"] = [
value for d in note_list for value in d.values()
]
count += 1
if commit:
scrobble.save(update_fields=["log"])
print(f"Updated {count} todoist tasks scrobbles")
def convert_old_boardgame_log_to_new(commit=False):
scrobbles = Scrobble.objects.filter(
board_game__isnull=False, log__has_key="notes"
)
for scrobble in scrobbles:
if isinstance(scrobble.log.get("notes"), str):
scrobble.log["notes"] = [scrobble.log.pop("notes")]
if commit:
scrobble.save(update_fields=["log"])
print(f"Updated {scrobbles.count()} board game scrobbles")

View File

@ -3,6 +3,7 @@ import logging
from typing import Optional from typing import Optional
from uuid import uuid4 from uuid import uuid4
from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db import models from django.db import models
@ -30,11 +31,22 @@ class VideoGameLogData(BaseLogData, LongPlayLogData, WithPeopleLogData):
emulated: Optional[bool] = False emulated: Optional[bool] = False
emulator: Optional[str] = None emulator: Optional[str] = None
@property
def platform(self): def platform(self):
if not self.platform_id: if not self.platform_id:
return return
return VideoGamePlatform.objects.filter(id=self.platform_id).first() return VideoGamePlatform.objects.filter(id=self.platform_id).first()
@classmethod
def override_fields(cls) -> dict:
return {
"platform_id": forms.ModelChoiceField(
queryset=VideoGamePlatform.objects.all(),
required=False,
widget=forms.Select(),
)
}
class VideoGamePlatform(TimeStampedModel): class VideoGamePlatform(TimeStampedModel):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)

View File

@ -1,17 +1,21 @@
from django.views import generic
from videogames.models import VideoGame, VideoGamePlatform from videogames.models import VideoGame, VideoGamePlatform
from scrobbles.views import (
ScrobbleImportListView,
ScrobbleableDetailView,
ScrobbleableListView,
)
class VideoGameListView(generic.ListView): class VideoGameListView(ScrobbleableListView):
model = VideoGame model = VideoGame
paginate_by = 20 paginate_by = 40
class VideoGameDetailView(generic.DetailView): class VideoGameDetailView(ScrobbleableDetailView):
model = VideoGame model = VideoGame
slug_field = "uuid" slug_field = "uuid"
class VideoGamePlatformDetailView(generic.DetailView): class VideoGamePlatformDetailView(ScrobbleableDetailView):
model = VideoGamePlatform model = VideoGamePlatform
slug_field = "uuid" slug_field = "uuid"

View File

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

View File

@ -67,10 +67,10 @@ class WebPage(ScrobblableMixin):
self.save(update_fields=["extract"]) self.save(update_fields=["extract"])
def get_absolute_url(self): def get_absolute_url(self):
return reverse("webpages:webpage-detail", kwargs={"slug": self.uuid}) return reverse("webpages:webpage_detail", kwargs={"slug": self.uuid})
def get_read_url(self): def get_read_url(self):
return reverse("webpages:webpage-read", kwargs={"slug": self.uuid}) return reverse("webpages:webpage_read", kwargs={"slug": self.uuid})
@property @property
def estimated_time_to_read_in_seconds(self): def estimated_time_to_read_in_seconds(self):

View File

@ -5,15 +5,15 @@ app_name = "webpages"
urlpatterns = [ urlpatterns = [
path("webpages/", views.WebPageListView.as_view(), name="webpage-list"), path("webpages/", views.WebPageListView.as_view(), name="webpage_list"),
path( path(
"webpages/<slug:slug>/", "webpages/<slug:slug>/",
views.WebPageDetailView.as_view(), views.WebPageDetailView.as_view(),
name="webpage-detail", name="webpage_detail",
), ),
path( path(
"webpage/<slug:slug>/read/", "webpage/<slug:slug>/read/",
views.WebPageReadView.as_view(), views.WebPageReadView.as_view(),
name="webpage-read", name="webpage_read",
), ),
] ]

View File

@ -32,7 +32,7 @@ class WebPageReadView(
if latest_scrobble.played_to_completion: if latest_scrobble.played_to_completion:
redirect( redirect(
reverse( reverse(
"webpages:webpage-detail", kwargs={"slug": webpage.uuid} "webpages:webpage_detail", kwargs={"slug": webpage.uuid}
) )
) )
return super().get(*args, **kwargs) return super().get(*args, **kwargs)

View File

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

View File

@ -175,7 +175,7 @@
</head> </head>
<body> <body>
<header class="navbar navbar-dark sticky-top bg-dark flex-md-nowrap p-0 shadow"> <header class="navbar navbar-dark sticky-top bg-dark flex-md-nowrap p-0 shadow">
<a class="navbar-brand col-md-3 col-lg-2 me-0 px-3" href="#">Vrobbler</a> <a class="navbar-brand col-md-3 col-lg-2 me-0 px-3" href="/">Vrobbler</a>
<button class="navbar-toggler position-absolute d-md-none collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation"> <button class="navbar-toggler position-absolute d-md-none collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
@ -208,85 +208,8 @@
{% endfor %} {% endfor %}
</ul> </ul>
{% endif %} {% endif %}
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="/">
<span data-feather="music"></span>
Dashboard
</a>
</li>
{% if user.is_authenticated %}
<li class="nav-item">
<a class="nav-link" aria-current="page" href="/charts/">
<span data-feather="bar-chart"></span>
Charts
</a>
</li>
<li class="nav-item">
<a class="nav-link" aria-current="page" href="/locations/">
<span data-feather="map"></span>
Locations
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/long-plays/">
<span data-feather="playv"></span>
Long plays
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/moods/">
<span data-feather="smiley"></span>
Moods
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/books/">
<span data-feather="book"></span>
Books
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/video-games/">
<span data-feather="video"></span>
Video games
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/board-games/">
<span data-feather="board"></span>
Board games
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/trails/">
<span data-feather="trail"></span>
Trails
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/beers/">
<span data-feather="beer"></span>
Beers
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/imports/">
<span data-feather="log"></span>
Imports
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/admin/">
<span data-feather="key"></span>
Admin
</a>
</li>
{% endif %}
</ul>
{% block extra_nav %} {% block extra_nav %}
{% endblock %} {% endblock %}
<hr/>
{% if now_playing_list and user.is_authenticated %} {% if now_playing_list and user.is_authenticated %}
<ul> <ul>
@ -296,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 %} {% 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> <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.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> <p><small>{{scrobble.local_timestamp|naturaltime}} from {{scrobble.source}}</small></p>
<div class="progress-bar" style="margin-right:5px;"> <div class="progress-bar" style="margin-right:5px;">
<span class="progress-bar-fill" style="width: {{scrobble.percent_played}}%;"></span> <span class="progress-bar-fill" style="width: {{scrobble.percent_played}}%;"></span>
@ -329,5 +252,21 @@
</div> </div>
{% block extra_js %}{% endblock %} {% block extra_js %}{% endblock %}
<script>
(() => {
'use strict'
const forms = document.querySelectorAll('.needs-validation')
Array.from(forms).forEach(form => {
form.addEventListener('submit', event => {
if (!form.checkValidity()) {
event.preventDefault()
event.stopPropagation()
}
form.classList.add('was-validated')
}, false)
})
})()
</script>
</body> </body>
</html> </html>

View File

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

View File

@ -55,15 +55,15 @@
<thead> <thead>
<tr> <tr>
<th scope="col">Date</th> <th scope="col">Date</th>
<th scope="col">Publisher</th> <th scope="col">Location</th>
<th scope="col">Players</th> <th scope="col">Players</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %} {% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
<tr> <tr>
<td>{{scrobble.local_timestamp}}</td> <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> <td>{% if scrobble.logdata.player_log %}{{scrobble.logdata.player_log}}{% else %}No data{% endif %}</td>
</tr> </tr>
{% endfor %} {% endfor %}

View File

@ -27,14 +27,6 @@
</div> </div>
<div class="row"> <div class="row">
<p>{{scrobbles.count}} scrobbles</p> <p>{{scrobbles.count}} scrobbles</p>
<p>Read {{scrobbles.last.book_pages_read}} pages{% if scrobbles.last.long_play_complete %} and completed{% else %}{% endif %}</p>
<p>
{% if scrobbles.last.long_play_complete == True %}
<a href="">Read again</a>
{% else %}
<a href="">Resume reading</a>
{% endif %}
</p>
</div> </div>
<div class="row"> <div class="row">
<div class="col-md"> <div class="col-md">
@ -52,8 +44,8 @@
<tbody> <tbody>
{% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %} {% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
<tr> <tr>
<td>{{scrobble.local_timestamp}}</td> <td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td>
<td>{% if scrobble.long_play_complete == True %}Yes{% endif %}</td> <td>{% if scrobble.logdata.long_play_complete == True %}Yes{% endif %}</td>
<td>{% if scrobble.in_progress %}Now reading{% else %}{{scrobble.session_pages_read}}{% endif %}</td> <td>{% if scrobble.in_progress %}Now reading{% else %}{{scrobble.session_pages_read}}{% endif %}</td>
<td>{% for author in scrobble.book.authors.all %}<a href="{{author.get_absolute_url}}">{{author}}</a>{% if not forloop.last %}, {% endif %}{% endfor %}</td> <td>{% for author in scrobble.book.authors.all %}<a href="{{author.get_absolute_url}}">{{author}}</a>{% if not forloop.last %}, {% endif %}{% endfor %}</td>
</tr> </tr>

View File

@ -0,0 +1,56 @@
{% extends "base_list.html" %}
{% load mathfilters %}
{% load static %}
{% load naturalduration %}
{% block title %}{{object.title}}{% endblock %}
{% block lists %}
<div class="row">
{% if object.cover%}
<p style="float:left; width:402px; padding:0; border: 1px solid #ccc">
<img src="{{object.cover.url}}" width=400 />
</p>
{% endif %}
<div style="float:left; width:600px; margin-left:10px; ">
{% if object.summary %}
<p>{{object.summary|safe|linebreaks|truncatewords:160}}</p>
<hr />
{% endif %}
<p style="float:right;">
<a href="{{object.openlibrary_link}}"><img src="{% static " images/openlibrary-logo.png" %}" width=35></a>
<a href="{{object.amazon_link}}"><img src="{% static " images/amazon-logo.png" %}" width=35></a>
</p>
</div>
</div>
<div class="row">
<p>{{scrobbles.count}} scrobbles</p>
</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">Completed</th>
<th scope="col">Time</th>
</tr>
</thead>
<tbody>
{% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
<tr>
<td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td>
<td>{% if scrobble.logdata.long_play_complete == True %}Yes{% endif %}</td>
<td>{% if scrobble.in_progress %}Now reading{% else %}{{scrobble.session_pages_read}}{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,23 @@
{% extends "base_list.html" %}
{% block title %}Brick Sets{% 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

@ -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> <thead>
<tr> <tr>
<th scope="col">Date</th> <th scope="col">Date</th>
<th scope="col">With</th>
<th scope="col">Notes</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %} {% for scrobble in object.scrobble_set.all|dictsortreversed:"timestamp" %}
<tr> <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> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View File

@ -67,13 +67,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for location in object_list %} {% include "_scrobblable_list.html" %}
<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 %}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

@ -20,40 +20,7 @@
<hr /> <hr />
<div class="col-md"> <div class="col-md">
<div class="table-responsive"> <div class="table-responsive">{% include "_scrobblable_list.html" %}</div>
<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> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

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

View File

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

View File

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

View File

@ -7,90 +7,142 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col-md"> <div class="col-md">
<h3><a href="{% url 'music:tracks_list' %}">Tracks</a></h3>
{% if Track %} {% if Track %}
<h2>Music</h2>
{% with scrobbles=Track count=Track_count time=Track_time %} {% with scrobbles=Track count=Track_count time=Track_time %}
{% include "scrobbles/_scrobble_table.html" %} {% include "scrobbles/_scrobble_table.html" %}
{% endwith %} {% endwith %}
{% else %}
<p>No tracks today</p>
{% endif %} {% 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>
<div class="col-md"> <div class="col-md">
<h3><a href="{% url 'tasks:task_list' %}">Tasks</a></h3>
{% if Task %} {% if Task %}
<h2>Latest tasks</h2>
{% with scrobbles=Task count=Task_count time=Task_time %} {% with scrobbles=Task count=Task_count time=Task_time %}
{% include "scrobbles/_scrobble_table.html" %} {% include "scrobbles/_scrobble_table.html" %}
{% endwith %} {% endwith %}
{% else %}
<p>No tasks today</p>
{% endif %} {% endif %}
<h3><a href="{% url 'videos:video_list' %}">Videos</a></h3>
{% if Video %} {% if Video %}
<h2>Videos</h2>
{% with scrobbles=Video count=Video_count time=Video_time %} {% with scrobbles=Video count=Video_count time=Video_time %}
{% include "scrobbles/_scrobble_table.html" %} {% include "scrobbles/_scrobble_table.html" %}
{% endwith %} {% endwith %}
{% else %}
<p>No videos today</p>
{% endif %} {% endif %}
<h3><a href="{% url 'webpages:webpage_list' %}">Web pages</a></h3>
{% if WebPage %} {% if WebPage %}
<h4>Web pages</h4>
{% with scrobbles=WebPage count=WebPage_count time=WebPage_time %} {% with scrobbles=WebPage count=WebPage_count time=WebPage_time %}
{% include "scrobbles/_scrobble_table.html" %} {% include "scrobbles/_scrobble_table.html" %}
{% endwith %} {% endwith %}
{% else%}
<p>No web page read today</p>
{% endif %} {% endif %}
<h3><a href="{% url 'sports:event_list' %}">Sport events</a></h3>
{% if SportEvent %} {% if SportEvent %}
<h2>Sports</h2>
{% with scrobbles=SportEvent count=SportEvent_count time=SportEvent_time %} {% with scrobbles=SportEvent count=SportEvent_count time=SportEvent_time %}
{% include "scrobbles/_scrobble_table.html" %} {% include "scrobbles/_scrobble_table.html" %}
{% endwith %} {% endwith %}
{% else %}
<p>No sports today</p>
{% endif %} {% endif %}
<h3><a href="{% url 'podcasts:podcast_list' %}">Podcasts</a></h3>
{% if PodcastEpisode %} {% if PodcastEpisode %}
<h2>Latest podcasts</h2>
{% with scrobbles=PodcastEpisode count=PodcastEpisode_count time=PodcastEpisode_time %} {% with scrobbles=PodcastEpisode count=PodcastEpisode_count time=PodcastEpisode_time %}
{% include "scrobbles/_scrobble_table.html" %} {% include "scrobbles/_scrobble_table.html" %}
{% endwith %} {% endwith %}
{% else %}
<p>No podcasts today</p>
{% endif %} {% endif %}
<h3><a href="{% url "videogames:videogame_list" %}">Video games</a></h3>
{% if VideoGame %} {% if VideoGame %}
<h4>Video games</h4>
{% with scrobbles=VideoGame count=VideoGame_count time=VideoGame_time %} {% with scrobbles=VideoGame count=VideoGame_count time=VideoGame_time %}
{% include "scrobbles/_scrobble_table.html" %} {% include "scrobbles/_scrobble_table.html" %}
{% endwith %} {% endwith %}
{% else %}
<p>No video games today</p>
{% endif %} {% endif %}
<h3><a href="{% url "boardgames:boardgame_list" %}">Board games</a></h3>
{% if BoardGame %} {% if BoardGame %}
<h4>Board games</h4>
{% with scrobbles=BoardGame count=BoardGame_count time=BoardGame_time %} {% with scrobbles=BoardGame count=BoardGame_count time=BoardGame_time %}
{% include "scrobbles/_scrobble_table.html" %} {% include "scrobbles/_scrobble_table.html" %}
{% endwith %} {% endwith %}
{% else %}
<p>No board games today</p>
{% endif %} {% endif %}
<h3><a href="{% url 'beers:beer_list' %}">Beers</a></h3>
{% if Beer %} {% if Beer %}
<h4>Beers</h4>
{% with scrobbles=Beer count=Beer_count time=Beer_time %} {% with scrobbles=Beer count=Beer_count time=Beer_time %}
{% include "scrobbles/_scrobble_table.html" %} {% include "scrobbles/_scrobble_table.html" %}
{% endwith %} {% endwith %}
{% else %}
<p>No beer today</p>
{% endif %} {% endif %}
<h3><a href="{% url 'bricksets:brickset_list' %}">Brick sets</a></h3>
{% if BrickSet %} {% if BrickSet %}
<h4>Brick sets</h4>
{% with scrobbles=BrickSet count=BrickSet_count time=BrickSet_time %} {% with scrobbles=BrickSet count=BrickSet_count time=BrickSet_time %}
{% include "scrobbles/_scrobble_table.html" %} {% include "scrobbles/_scrobble_table.html" %}
{% endwith %} {% endwith %}
{% else %}
<p>No brick sets today</p>
{% endif %} {% endif %}
<h3><a href="{% url 'puzzles:puzzle_list' %}">Puzzles</a></h3>
{% if Puzzle %} {% if Puzzle %}
<h4>Puzzles</h4>
{% with scrobbles=Puzzle count=Puzzle_count time=Puzzle_time %} {% with scrobbles=Puzzle count=Puzzle_count time=Puzzle_time %}
{% include "scrobbles/_scrobble_table.html" %} {% include "scrobbles/_scrobble_table.html" %}
{% endwith %} {% endwith %}
{% else %}
<p>No puzzles today</p>
{% endif %} {% endif %}
<h3><a href="{% url 'books:book_list' %}">Books</a></h3>
{% if Book %} {% if Book %}
<h4>Books</h4>
{% with scrobbles=Book count=Book_count time=Book_time %} {% with scrobbles=Book count=Book_count time=Book_time %}
{% include "scrobbles/_scrobble_table.html" %} {% include "scrobbles/_scrobble_table.html" %}
{% endwith %} {% endwith %}
{% else %}
<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 %} {% endif %}
</div> </div>

View File

@ -1,10 +1,10 @@
{% load humanize %} {% load humanize %}
{% load naturalduration %} {% load naturalduration %}
<tr {% if scrobble.in_progress %}class="in-progress"{% endif %}> <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> <td>
{% if scrobble.media_type == "Task" %} {% 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 %} {% else %}
<a href="{{scrobble.media_obj.get_absolute_url}}">{{scrobble.media_obj|truncatechars_html:45}}</a> <a href="{{scrobble.media_obj.get_absolute_url}}">{{scrobble.media_obj|truncatechars_html:45}}</a>
{% endif %} {% endif %}

View File

@ -4,7 +4,7 @@
<div class="tab-pane fade show" id="latest-beers" role="tabpanel" <div class="tab-pane fade show" id="latest-beers" role="tabpanel"
aria-labelledby="latest-beers-tab"> aria-labelledby="latest-beers-tab">
<div class="table-responsive"> <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"> <table class="table table-striped table-sm">
<thead> <thead>
<tr> <tr>

View File

@ -0,0 +1,40 @@
{% extends "base_list.html" %}
{% load form_tags %}
{% load mathfilters %}
{% load static %}
{% block title %}{{object.name}}{% endblock %}
{% block lists %}
<div class="row">
<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>
{% csrf_token %}
{% for field in log_form %}
<div class="mb-3">
<label for="{{ field.id_for_label }}" class="form-label">{{ field.label }}</label>
{{ field|add_class:"form-control" }}
{% if field.help_text %}
<div class="form-text">{{ field.help_text }}</div>
{% endif %}
{% for error in field.errors %}
<div class="invalid-feedback d-block">{{ error }}</div>
{% endfor %}
</div>
{% endfor %}
<button type="submit" class="btn btn-primary">Save</button>
</form>
</div>
{% endblock %}

View File

@ -56,6 +56,14 @@
<h1 class="h2">{% if date %}{{date|naturaltime}}{% else %}{{title}}{% endif %}</h1> <h1 class="h2">{% if date %}{{date|naturaltime}}{% else %}{{title}}{% endif %}</h1>
<div class="btn-toolbar mb-2 mb-md-0"> <div class="btn-toolbar mb-2 mb-md-0">
{% if user.is_authenticated %} {% if user.is_authenticated %}
<div class="btn-group me-2">
<form action="/admin/" method="get">
<button type="submit" class="btn btn-sm btn-outline-secondary">
<span data-feather="key"></span>
Admin
</button>
</form>
</div>
<div class="btn-group me-2"> <div class="btn-group me-2">
{% if user.profile.lastfm_username and not user.profile.lastfm_auto_import %} {% if user.profile.lastfm_username and not user.profile.lastfm_auto_import %}
<form action="{% url 'scrobbles:lastfm-import' %}" method="get"> <form action="{% url 'scrobbles:lastfm-import' %}" method="get">

View File

@ -20,7 +20,7 @@
<tbody> <tbody>
{% for scrobble in scrobbles.all %} {% for scrobble in scrobbles.all %}
<tr> <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.name}}</td>
<td>{{scrobble.media_obj.round.season.league}}</td> <td>{{scrobble.media_obj.round.season.league}}</td>
</tr> </tr>

View File

@ -54,7 +54,7 @@
<thead> <thead>
<tr> <tr>
<th scope="col">Date</th> <th scope="col">Date</th>
<th scope="col">Description</th> <th scope="col">Title</th>
<th scope="col">Notes</th> <th scope="col">Notes</th>
<th scope="col">Source</th> <th scope="col">Source</th>
</tr> </tr>
@ -62,8 +62,8 @@
<tbody> <tbody>
{% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %} {% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
<tr> <tr>
<td>{{scrobble.local_timestamp}}</td> <td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td>
<td><a href="{{scrobble.get_media_source_url}}">{{scrobble.logdata.description}}</a></td> <td><a href="{{scrobble.get_media_source_url}}">{{scrobble.logdata.title}}</a></td>
<td>{{scrobble.logdata.notes_as_str|safe}}</td> <td>{{scrobble.logdata.notes_as_str|safe}}</td>
<td>{{scrobble.source}}</td> <td>{{scrobble.source}}</td>
</tr> </tr>

View File

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

View File

@ -60,15 +60,8 @@
</div> </div>
<div class="row"> <div class="row">
<p>{{scrobbles.count}} scrobbles</p> <p>{{scrobbles.count}} scrobbles</p>
{% if scrobbles.last.long_play_seconds %}
<p>{{scrobbles.last.long_play_seconds|natural_duration}}{% if scrobbles.last.long_play_complete %} and completed{% else %} spent playing{% endif %}</p>
{% endif %}
<p> <p>
{% if scrobbles.last.long_play_complete == True %}
<a href="">Play again</a> <a href="">Play again</a>
{% else %}
<a href="{{object.start_url}}">Resume playing</a>
{% endif %}
</p> </p>
</div> </div>
<div class="row"> <div class="row">
@ -88,7 +81,8 @@
<tbody> <tbody>
{% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %} {% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
<tr> <tr>
<td>{{scrobble.local-timestamp}}</td>
<td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td>
<td>{% if scrobble.long_play_complete == True %}Yes{% else %}Not yet{% endif %}</td> <td>{% if scrobble.long_play_complete == True %}Yes{% else %}Not yet{% endif %}</td>
<td>{% if scrobble.in_progress %}Now playing{% else %}{{scrobble.playback_position_seconds|natural_duration}}{% endif %}</td> <td>{% if scrobble.in_progress %}Now playing{% else %}{{scrobble.playback_position_seconds|natural_duration}}{% endif %}</td>
<td>{% for platform in scrobble.video_game.platforms.all %}<a href="{{platform.get_absolute_url}}">{{platform}}</a>{% if not forloop.last %}, {% endif %}{% endfor %}</td> <td>{% for platform in scrobble.video_game.platforms.all %}<a href="{{platform.get_absolute_url}}">{{platform}}</a>{% if not forloop.last %}, {% endif %}{% endfor %}</td>

View File

@ -1,28 +1,32 @@
{% extends "base_list.html" %} {% 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 %} {% block lists %}
<div class="row"> <div class="row">
<div class="col-md"> <div class="col-md">
<div class="table-responsive"> <div class="table-responsive">{% include "_scrobblable_list.html" %}</div>
<table class="table table-striped table-sm"> </div>
<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> </div>
{% endblock %} {% endblock %}

View File

@ -60,7 +60,7 @@
<tbody> <tbody>
{% for scrobble in scrobbles %} {% for scrobble in scrobbles %}
<tr> <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><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.season_number}}</td>
<td>{{scrobble.media_obj.episode_number}}</td> <td>{{scrobble.media_obj.episode_number}}</td>

View File

@ -1,32 +1,32 @@
{% extends "base_list.html" %} {% 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 %} {% block lists %}
<div class="row"> <div class="row">
<div class="col-md"> <div class="col-md">
<div class="table-responsive"> <div class="table-responsive">{% include "_scrobblable_list.html" %}</div>
<table class="table table-striped table-sm"> </div>
<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> </div>
{% endblock %} {% endblock %}

View File

@ -87,7 +87,7 @@ dd {
<tbody> <tbody>
{% for scrobble in scrobbles.all %} {% for scrobble in scrobbles.all %}
<tr> <tr>
<td>{{scrobble.local_timestamp}}</td> <td><a href={{scrobble.get_absolute_url}}>{{scrobble.local_timestamp}}</a></td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </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> <thead>
<tr> <tr>
<th scope="col">Date</th> <th scope="col">Date</th>
<th scope="col">Notes</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for scrobble in scrobbles.all %} {% for scrobble in scrobbles.all %}
<tr> <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> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>