Compare commits

...

162 Commits
16.7 ... 32

Author SHA1 Message Date
050add8543 [books] Add utility urls to model and scrobbles 2025-10-22 14:18:01 -04:00
8faf0296a6 [project] Finish book resume link task 2025-10-22 12:18:40 -04:00
f209f3b107 [books] Set restart and resume urls on comic book scrobbles 2025-10-22 12:18:08 -04:00
b233b60ae0 [books] Add bookmark_url to logdata 2025-10-22 01:00:25 -04:00
e1d4a7c5a4 [books] Fix looking up comic by original title 2025-10-20 22:47:32 -04:00
59e8339e94 [releases] Fix comic books scrobbling, mostly 2025-10-20 17:17:18 -04:00
9277db97e5 [books] Fix comic scrobbles overrwriting one another 2025-10-20 17:15:54 -04:00
e755dc6641 Fix bug where title not found 2025-10-20 17:02:52 -04:00
782f5c15d6 [books] Calc stats and dont die when title not found 2025-10-20 17:02:34 -04:00
2f4fae7d02 [books] Short circut google lookup if it fails 2025-10-20 16:12:01 -04:00
4b7c5aa58d [books] Fix bad lookups for creating books 2025-10-20 16:11:20 -04:00
d4f82f2d6f [releases] Adding comic reading 2025-10-20 15:51:07 -04:00
106d25c20f [webpages] Redirect back to the page 2025-10-20 15:46:28 -04:00
d77caa2783 [scrobblers] Allow stopping reading comics 2025-10-20 15:46:10 -04:00
b5bfad73ef [books] Allow comic scrobbling to update per page 2025-10-20 15:41:02 -04:00
274b2704ed [scrobbles] Clean up type in logs 2025-10-20 14:55:27 -04:00
80fcb6c002 [books] Clean up google searches 2025-10-20 14:54:53 -04:00
c6f3c90006 [releases] Catching up project with actual releases 2025-10-14 12:29:45 -04:00
387dee7d37 [podcasts] Hotfix looking up podcast data from feed URLS 2025-10-14 12:28:17 -04:00
188e899357 [podcasts] Fix podcast data 2025-10-14 11:55:22 -04:00
30b005fa46 [scrobbles] Stop any scrobble when stop is called 2025-10-14 11:51:33 -04:00
72f739ee5a [podcasts] Try to clean up lookups 2025-10-14 11:45:47 -04:00
56ee14512d [podcasts] Actually test new lookup method 2025-10-14 11:30:57 -04:00
8c947d35dd [release] Bump version to 27.0 2025-10-14 11:18:05 -04:00
61bab1f734 [podcasts] Clean up lookup and creation 2025-10-14 11:17:20 -04:00
42ce6df9bd [templates] Small fix to obj title missing 2025-10-14 10:58:54 -04:00
cbd46df4bc [scrobbles] Add Food and Geolocation to long play & manual comics 2025-10-14 10:58:31 -04:00
e7203cdb9b [podcasts] Add parsing of RSS feed urls 2025-10-14 10:55:09 -04:00
7246adfeb6 [project] Update and reorganize todos 2025-09-30 09:36:03 -04:00
a5606951c5 [templates] Fix missing Beer placeholder 2025-09-23 00:43:49 -04:00
0b4537b7ed [food] Add calories per day 2025-09-16 15:28:28 -04:00
6306390f82 [release] 26 2025-09-11 18:58:48 -04:00
350d3ceb14 [templates] Move moods around 2025-09-11 18:56:34 -04:00
a1ff82bfec [templates] Cleaning up templates and datalog forms 2025-09-11 18:55:45 -04:00
92c0c668b3 [locations] Add locations to dashboard 2025-09-11 18:29:28 -04:00
3b77feda45 [templates] Add links to scrobbles
Ultimately these should probably be templated
2025-09-11 18:03:35 -04:00
45c402f8c1 [music] Fix bug in generating log data form 2025-09-11 17:57:17 -04:00
90a1398438 [foods] Adjust fields on food data log 2025-09-11 10:44:48 -04:00
c7a81802ac [release] 25.0 2025-09-11 09:45:02 -04:00
a9a8678ac0 [project] Update toods 2025-09-11 09:43:23 -04:00
cbf0583871 [foods] Add calories to food model 2025-09-11 09:41:22 -04:00
5cac1fe109 [templates] Fix food templates and such 2025-09-11 09:41:12 -04:00
6782ed312d [templates] Add food to homepage 2025-09-11 09:33:34 -04:00
fda505ea4e [scrobbles] Fix calc of elapsed time 2025-09-11 09:33:29 -04:00
8db111f66f [food] Fix lookup for food object 2025-09-11 09:27:32 -04:00
ee1cae496a [music] Back and forth ... let us not use album name 2025-09-11 09:08:14 -04:00
9403c68184 [videos] Fix small bug in views 2025-09-11 09:06:07 -04:00
96030f4a99 [boardgames] Fix expansion checking 2025-09-11 09:05:50 -04:00
a8c3925af4 [project] Check off another task 2025-08-20 11:40:18 -04:00
a2f507a976 [videos] Wire up generic video list view 2025-08-20 11:39:27 -04:00
7a7edc6e47 [templates] Fix some bugs and clean up list views 2025-08-20 11:27:10 -04:00
af6c39fb85 [templates] Clean up task titles 2025-08-19 12:37:26 -04:00
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
dc965687c2 [puzzles] Add puzzles to homepage 2025-08-03 02:01:29 -04:00
ebc66bbf64 [puzzles] Add templates 2025-08-03 02:00:26 -04:00
d04db0ecb5 [scrobbles] Fix dataclass parsing and add puzzles to urls 2025-08-03 01:59:56 -04:00
fc72b23b11 [music] Fix timezones for TSV imports 2025-08-02 23:35:22 -04:00
a681b4d63b [notifications] Fix a few typos 2025-07-30 18:34:22 -04:00
c452ac24e0 [notifications] Send mood check-in 2025-07-30 18:30:18 -04:00
ae889bff7d [tasks] Fix bug in note str method 2025-07-30 17:50:59 -04:00
99dc86dc27 [moods] Fix mood list view 2025-07-30 16:05:48 -04:00
8eefcb8290 [tasks] Fix emacs metadata 2025-07-30 16:05:34 -04:00
ad0f9a54d0 [tasks] Fix dataclass models 2025-07-30 15:46:18 -04:00
1531b77b5c [tests] Fix metadata test 2025-07-30 13:59:11 -04:00
9437fdba60 [scrobbles] Fix log data parsing for tasks and boardgames
Add pagination to task and board game detail pages
2025-07-30 11:37:57 -04:00
a7551ef162 [music] Weird hack to get timezone for LFM scrobbles
Last.fm seems to send timestamps for scrobbles with a timezone of UTC
but the actual timezone is already localized. But that means we can't
extract the timezone we want, even though the timestamp is already in
the right timezone for storage.
2025-07-28 10:52:02 -04:00
c20204a6ea [music] Turns out lastfm already has our timeszone 2025-07-28 09:14:25 -04:00
685de842ea [views] Fix showing only a users scrobbles 2025-07-26 21:31:44 -04:00
7d13967708 [scrobbles] Fix admin filtering 2025-07-26 20:57:23 -04:00
109697a746 [project] Bump version 2025-07-26 10:19:34 -04:00
dde28f4aff [importers] Fix setting timezones before all imports 2025-07-26 10:18:43 -04:00
2f6ed3770f [books] Fix bad import after moving webdav to importers 2025-07-26 01:49:37 -04:00
e3d1cfb838 [books] Fix webdav importer 2025-07-25 23:40:39 -04:00
1821ac0d7b [project] Update tasks 2025-07-25 22:55:33 -04:00
4eb8289e55 [scrobbles] LastFM only creates import if there are imports 2025-07-25 22:53:31 -04:00
66e805542c [scrobbles] Add notification to board game imports 2025-07-25 21:28:08 -04:00
f91b127a2c [scrobbles] Allow skipping checks for existing scrobbles 2025-07-25 17:36:19 -04:00
b2077678e2 [music] Fix timezones on lastfm imports 2025-07-25 17:35:56 -04:00
5427198185 [profiles] Just black 2025-07-25 17:35:10 -04:00
2bdba14cd6 [boardgames] Fix import from imap for timezones 2025-07-25 17:34:57 -04:00
95d8c4e4d6 [profiles] Clean up timezone stuff 2025-07-25 17:33:59 -04:00
6ab7745151 [music] Use found name to look it up 2025-07-25 10:50:00 -04:00
8b062a6c1d [music] Add tracks migration 2025-07-25 10:44:06 -04:00
cd48e7a402 [boardgames] Fix migration path 2025-07-25 10:40:53 -04:00
22830b0cea [profiles] Fix extra newline in one off 2025-07-25 10:24:33 -04:00
fd36034f6d [templates] Fix album use and local_timestamp 2025-07-25 10:21:20 -04:00
edf9fbd9c1 [music] Reorganize importer and fix lookups 2025-07-25 10:20:49 -04:00
e8e989bb63 [music] Add albums to tracks and utility to condense tracks 2025-07-20 22:00:06 -04:00
69401d11c8 [importers] Reorganize importers a little 2025-07-20 16:43:50 -04:00
759caef45d [books] Move one off creator to profile utils 2025-07-20 16:31:20 -04:00
9514861b32 [tests] Skip failing tests 2025-07-19 21:09:51 -04:00
aa644aa9cf [project] Start adding features and update todos 2025-07-19 01:56:23 -04:00
94820b1d9c [scrobbles] Exclude geolocs from stop notifications 2025-07-19 01:56:03 -04:00
4db8793d5c [books] Allow timezone changes when importing from KOReader
Turns out you need a city-based timezone for DST stuff to work properly.
The US/Eastern timezone doesn't mess with DST because it can be so wonky
in different regions. So while we fix timezone defaulting to a
DST-friendly timezone too.
2025-07-19 01:54:27 -04:00
7c6e895ae4 [books] Start cleaning up get_from_google method 2025-07-09 13:46:06 -04:00
b1b67528bf [music] Fix find_or_create for tracks 2025-07-09 13:39:10 -04:00
dd54a33159 [profiles] Remove paswords from admin 2025-07-06 11:26:38 -04:00
92c4f91e5a [boardgames] Add check for learning plays from BG stats 2025-07-06 10:02:14 -04:00
838b19e996 [boardgames] Remove expansion_ids key if not needed 2025-07-06 00:01:01 -04:00
3808277025 [boardgames] Add comments and bgstats_id to scrobbles 2025-07-05 23:55:00 -04:00
f64863f2bc [scrobblers] Connect designers to board games 2025-07-03 22:02:27 -04:00
2c199c0e93 [boardgames] Check if scrobble exists first 2025-07-03 20:19:30 -04:00
4924ef316f [scrobbles] Add a check for Garmin emails 2025-07-03 16:26:29 -04:00
64cb17e91f [scrobbles] Add management command for imap 2025-07-03 14:59:33 -04:00
1fd325823b [boardgames] Clean up email parser to work with many plays 2025-07-03 00:34:51 -04:00
1590ce5f18 [boardgames] Adding email scrobbler for BG Stats 2025-07-02 23:01:13 -04:00
3548c29f97 [people] Add a more general people app 2025-07-02 11:00:06 -04:00
0fa831fa42 [boardgames] Start adding email scrobbling for board games 2025-07-02 10:55:24 -04:00
a2f64a98c3 [project] Update tasks 2025-07-02 10:29:13 -04:00
872ca17432 [tasks] Hackety hack 2025-07-02 09:29:15 -04:00
224c165d72 [tasks] Fix bad matching in label titles 2025-06-27 11:54:14 -04:00
bf7d2514f2 [tasks] Clean up param names and loggin 2025-06-27 11:50:30 -04:00
4e37bc5ab9 [tasks] Fix bug in looking up user profile 2025-06-27 11:42:37 -04:00
125da84f4e [tasks] Fix emacs scrobbling of tasks 2025-06-27 11:32:09 -04:00
36ceb4c7fe [release] 17.1 2025-06-27 10:52:50 -04:00
88a3831975 [tasks] Make tasks use user profile string 2025-06-27 10:48:05 -04:00
63361964ca [tasks] Add optional user context labels to get title 2025-06-27 10:37:09 -04:00
40b54b27f4 [tasks] Actually add the new utils file 2025-06-26 14:50:11 -04:00
a7eca4b9a7 [tasks] Actually get title consistently 2025-06-26 14:09:17 -04:00
d152412e99 [books] Add optional details key to log data for books 2025-06-25 10:21:26 -04:00
3ba6c6b6e4 [project] Add new bug in tasks
Though honestly, it will probably be a config setting in the Emacs hook,
but we'll see. Actually, maybe the emacs hook code should end up in this
repo somewhere 🤔
2025-06-24 14:25:20 -04:00
bffbf47c2f [release] 17.0 2025-06-24 10:56:14 -04:00
f4e81da533 [project] Completing task for label bug 2025-06-24 10:55:01 -04:00
4b7f5459be [project] Add new task for TV series bug 2025-06-24 10:53:57 -04:00
c68b0e9d7e [tasks] Fix bug in emacs labels 2025-06-24 10:52:47 -04:00
32ec65116b [tasks] Move to single context for task titles 2025-06-22 21:26:20 -04:00
da8d26fcd9 [trails] Add alltrails and gaiagps ids to model 2025-06-22 20:26:37 -04:00
d33954e494 [project] Tidy up todos 2025-06-17 10:53:26 -04:00
1b306d6493 [project] Move todos.org file to PROJECT.org 2025-06-17 10:50:30 -04:00
c881143e1b [todos] Update backlog with tasks from Todoist 2025-06-17 10:18:48 -04:00
141700fcb3 [music] Fixes RYM slug including commas 2025-06-16 09:38:19 -04:00
7357b5bfec [todos] Update todo formatting 2025-06-16 09:35:30 -04:00
99cabd0007 [todos] Finish another task! 2025-06-13 12:35:24 -04:00
cf77e12cc3 [tasks] Add description of task to the template 2025-06-13 12:34:55 -04:00
3f2cbbb34a [videos] Fix TMDB lookup for movies 2025-06-13 12:10:43 -04:00
650ecf12c6 Merge branch 'main' into develop 2025-06-13 11:55:04 -04:00
d7cc009d07 [ci] Make deploys on happen on tags 2025-06-13 11:52:14 -04:00
159e555d7c [templates] Remove global counts from home page 2025-06-13 11:33:21 -04:00
981f4f9c9a [videos] Quick fix 2025-06-13 11:23:37 -04:00
135 changed files with 5191 additions and 1743 deletions

View File

@ -39,8 +39,8 @@ steps:
- vrobbler collectstatic --noinput
- immortalctl restart celery && immortalctl restart vrobbler
when:
branch:
- main
ref:
- refs/tags/*
- name: build success notification
image: parrazam/drone-ntfy:0.3-linux-amd64
when:

View File

@ -1,260 +1,103 @@
#+title: TODOs
#+title: Vrobbler Project
* Version 17
** DONE [#A] Investigate new source of video metadata :personal:project:video:imdb:
* Overview
Vrobbler began humbly enough as a way to use Jellyfin's webhook to keep track of
the shows and movies I was watching. More specifically, I broke my ankle a few
days after Christmas in 2022 and spent the next four months very slowly
recovering after surgical repair. So once I had the webhook working, and
scrobbling videos, it was only a matter of time till I expaned it to mopidy to
replicate LastFM. Then I added board games, books via KoReader, sports events,
podcasts ... it just keeps going. Vrobbler is now a sort of Frankenstein's
monster of scrobbling an entire life.
I am still unconvinced I can keep this going, but being able to scrobble org
tasks, Todoist tasks, web pages I've read and trails I've hiked has turned out
to be sometimes cathartic and sometimes functional as I try to remember when I
did a thing.
* Features
** Beer
*** Triggers
**** Bookmarklet
**** Manual
*** Metadata sources
**** Untappd
** Book
*** Triggers
**** Webdav via KoReader
**** Manual
*** Metadata sources
**** Google Books
This is the preferred method at this time. Also, the Book model implements a
`find_or_create` classmethod which is an example of an interface we can use for
other data models to get metadata in a way that provides easy testing, bulk
fetching and simple saving.
**** OpenLibrary
**** ComicVine
** Board Game
*** Triggers
**** IMAP import
**** Bookmarklet
**** Manual
** Location
*** Triggers
**** GPSLogger (Android)
*** Metadata sources
**** User input
** Music
*** Triggers
**** Last.FM
**** Rockbox files
**** Mopidy
**** Jellyfin
*** Metadata sources
**** Musicbrainz
** Podcast
*** Triggers
**** Mopidy
*** Metadata sources
**** Google Podcasts
**** PodcastIndex
** Sport
*** Triggers
**** Bookmarklet
**** Manual
*** Metadata sources
**** Thes Sports DB
** Task
*** Triggers
**** Todoist
**** Org-mode
*** Metadata sources
**** User profile
** Trails
** Video
*** Triggers
**** Jellyfin
**** Bookmarklet
**** Manual
*** Metadata sources
**** IMDB
**** Youtube
** Web Page
*** Triggers
**** Bookmarklet
*** Metadata sources
**** Scraper
* Chores
** DONE Document various vrobbler features :chore:personal:project:vrobbler:documentation:
:PROPERTIES:
:ID: df2b486c-1170-5199-c312-9bc87760d962
:ID: 514e9285-96f1-265f-56df-118c12f60918
:END:
Cinemagoer broke and I probably should find a more reilable source of video data.
- Note taken on [2025-06-13 Fri 11:19]
TMDB is much more reliable, but does require an API key. That's all setup now,
so hopefully this breaking IMDB crap is over.
** DONE IMDB video lookups are failing :personal:bug:video:imdb:
:PROPERTIES:
:ID: 38f1081f-37b4-f4f2-79aa-c1e87eca4b69
:END:
<2025-06-13 Fri>
- Note taken on [2025-06-13 Fri 08:24]
Looks like Cinemagoer is broken: https://github.com/cinemagoer/cinemagoer/issues/537
** DONE [#A] Emacs is not syncing notes :personal:scrobbling:emacs:bug:
:PROPERTIES:
:ID: c79cd491-b30f-0945-d84b-b8cac7562791
:END:
<2025-06-12 Thu 9:30>
Not sure if the problem is in my Emacs hook sending or Vrobbler itself.
- Note taken on [2025-06-12 Thu 09:47]
Adding a quick note to check on it
- Note taken on [2025-06-12 Thu 09:50]
Ah ha. All the messing about with the source field meant that I was looking
for `emacs` as a source but the hook was initially setting sources to
`orgmode` I think I prefer `orgmode` as the source, so updating it thusly.
Fixed in `490d60cbbb1f8bf90b5fc47d8685b15bdc1d485b`
** TODO [#A] Show the description of a task in the string rep for a scrobble of a Task :personal:project:scrobbling:vrobbler:feature:
** 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 [#C] Replace commas in the bandcamp URL for artists with nothing :vrobbler:music:bug:personal:
:PROPERTIES:
:ID: 9b30d67b-91f0-a480-dfaa-5d9dc090e76c
:END:
** 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.
* Version 0.16.0
** DONE [#A] Jellyfin, bandcamp tracks from Mopidy create duplicate music tracks :bug:scrobbling:music:
:PROPERTIES:
:ID: 670e8634-49b5-dce9-1684-14f2ffb797f1
:END:
Effectively, any track that comes in without a MusicBrainz ID does some funky
lookup where it doesn't find a track without an MB id and the track title /
artist combination and creates a new track every time. This has to be cleaned up
by condensing the duplicated tracks into the original proper track.
But it opens a bigger question about how much MB id should the drive the app
lookup. If it can't be depended on to exist from all sources, it really can't be
canonical. Instead, the combination of track title / artist is really the best
we can do. Last.fm also has this problem, where it doesn't know about albums and
definitely does not know or care about MB ids.
** DONE Add a user profile page with ability to change settings :profiles:improvement:
- Note taken on [2025-04-04 Fri 10:51]
[[orgit-rev:~/src/code.unbl.ink/secstate/vrobbler/::93c16d80ecff4cd1663cf9ec40fbe6d8f58c3e44][~/src/code.unbl.ink/secstate/vrobbler/ (magit-rev 93c16d8)]]
https://code.unbl.ink/secstate/vrobbler/commit/93c16d80ecff4cd1663cf9ec40fbe6d8f58c3e44
** DONE What to do with Youtube videos from LastFM and web-scrobbler :bug:source:lastfm:
- Note taken on [2025-04-04 Fri 10:46]
Nothing. Over the last few months I built out a youtube model in videos and
use a bookmarklet scrobbling pattern. Now web-scrobbler is just disabled for
Youtube.
May want to revisit this at some point and only scrobble tracks from Youtube,
because many people use YT for music listening.
** DONE [#C] Consider a purge command for duplicated and stuck in-progress scrobbles :utililty:improvement:
CLOSED: [2023-04-06 Thu 14:09]
** DONE Add a "stop_timestamp" so we don't rely on content length :improvement:scrobbling:
CLOSED: [2023-04-02 Sun 23:58]
Essentially, we currently have the timestamp as when the content began
scrobbling and then calculate the finish time from the length of the content.
This works pretty well because we know how long most things are.
But in some cases, sports events or long podcasts, we may start mid-way through
an event or finish halfway through but still want to mark it as done. In these
cases, knowing the finish time could be useful, especially when interfacing with
other scrobblers which may have different definitions of when a scrobble
finishes or started.
** DONE Fix bug with Various Artist albums being labeled with first artist as album artist :scrobbling:bug:music:
CLOSED: [2023-03-27 Mon 20:18]
:LOGBOOK:
CLOCK: [2023-03-26 Sun 22:01]--[2023-03-27 Mon 01:07] => 3:06
CLOCK: [2025-07-09 Wed 09:55]--[2025-07-09 Wed 10:15] => 0:20
:END:
** DONE Fix bug with weekly aggregator being blank on Sundays :aggregators:music:bug:
CLOSED: [2023-03-26 Sun 13:52]
** DONE Fix KoReader scrobbling to use pages rather than time of last read :scrobbling:books:improvement:
CLOSED: [2023-03-26 Sun 13:51]
:LOGBOOK:
CLOCK: [2023-03-26 Sun 13:11]--[2023-03-26 Sun 13:51] => 0:40
:END:
** DONE [#A] Add django-storage to store files on S3 :settings:improvement:
CLOSED: [2023-03-24 Fri 14:46]
:LOGBOOK:
CLOCK: [2023-03-24 Fri 10:47]--[2023-03-24 Fri 14:46] => 3:59
CLOCK: [2023-03-24 Fri 10:36]--[2023-03-24 Fri 10:40] => 0:04
:END:
** DONE Fix vrobbler settings not using booleans :settings:bug:
CLOSED: [2023-03-24 Fri 10:45]
:LOGBOOK:
CLOCK: [2023-03-24 Fri 10:40]--[2023-03-24 Fri 10:46] => 0:06
:END:
** DONE Update weekly live chart to be 7-day continuous rather than weekly :views:bug:
CLOSED: [2023-03-24 Fri 00:31]
The live view will be blank every Monday, no reason to tie it to a day of the
week. It should be "the last 7 days"
** DONE [#B] Implement a detail view for TV shows :improvement:views:
CLOSED: [2023-03-22 Wed 17:05]
** DONE [#B] Implement a detail view for Movies :improvement:views:
CLOSED: [2023-03-22 Wed 17:05]
** DONE Add "service provider" to TV Series, and use that for source when available :bug:scrobbling:
CLOSED: [2023-03-22 Wed 17:04]
** DONE Add view for long-play content (books, video games) to restart them :views:improvement:
CLOSED: [2023-03-22 Wed 17:01]
** DONE Add live chart view like Maloja :improvement:views:
CLOSED: [2023-03-07 Tue 11:13]
** DONE [#C] Figure out how to add to web-scrobbler :improvement:scrobbling:
CLOSED: [2023-03-22 Wed 17:06]
An example:
https://github.com/web-scrobbler/web-scrobbler/blob/master/src/core/background/scrobbler/maloja-scrobbler.js
This is actually going to be moot because we can import from LastFM, and
web-scrobbler integrates well with LastFM. The only thing to think through here
now is what to do with all the garbage web-scrobbler sometimes pushes to LastFM
from Youtube (all videos get pushed, sigh).
** DONE Add Amazon scraper to look up books when OL fails :books:improvement:
This turned out to be a non-starter ... Amazon is aggressive at disallowing
scraping quality. And all the OSS tools out there are stuck in an arms race
trying to keep them from breaking.
That said, Google Books actually has a decent API (for now), and I've built this
out using that.
** 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
separate bug tracking ticket for that.
* Version 0.11.4
** DONE Add rudimentary video game scrobbling :improvement:content:videogames:
CLOSED: [2023-03-07 Tue 11:11]
** DONE Add ability to scrobble from KOReader statistics files :improvement:books:content:
CLOSED: [2023-03-07 Tue 11:11]
** DONE [#A] Fix fetching artwork without release group :bug:
CLOSED: [2023-01-29 Sun 14:27]
When we get artwork from Musicbrianz, and it's not found, we should check for
release groups as well. This will stop issues with missing artwork because of
obscure MB release matches.
** DONE [#A] Fix Jellyfin music scrobbling N+1 past 90 completion percent :bug:
CLOSED: [2023-01-30 Mon 18:31]
:LOGBOOK:
CLOCK: [2023-01-30 Mon 18:00]--[2023-01-30 Mon 18:31] => 0:31
:END:
If we play music from Jellyfin and the track reaches 90% completion, the
scrobbling goes crazy and starts creating new scrobbles with every update.
The cause is pretty simple, but the solution is hard. We want to mark a scrobble
as complete for the following conditions:
- Play stopped and percent played beyond 90%
- Play completely finished
But if we keep listening beyond 90, we should basically ignore updates (or just
update the existing scrobble)
** DONE [#A] Add support for Audioscrobbler tab-separated file uploads :improvement:
CLOSED: [2023-02-03 Fri 16:52]
An example of the format:
#+begin_src csv
,
#AUDIOSCROBBLER/1.1
#TZ/UNKNOWN
#CLIENT/Rockbox sansaclipplus $Revision$
75 Dollar Bill I Was Real I Was Real 4 1015 S 1740494944 64ff5f53-d187-4512-827e-7606c69e66ff
75 Dollar Bill I Was Real I Was Real 4 1015 S 1740494990 64ff5f53-d187-4512-827e-7606c69e66ff
311 311 Down 1 173 S 1740495003 00476c23-fd9e-464b-9b27-a62d69f3d4f4
311 311 Down 1 173 L 1740495049 00476c23-fd9e-464b-9b27-a62d69f3d4f4
311 311 Down 1 173 L 1740495113 00476c23-fd9e-464b-9b27-a62d69f3d4f4
311 311 Random 2 187 S 1740495190 530c09f3-46fe-4d90-b11f-7b63bcb4b373
311 311 Random 2 187 L 1740495194 530c09f3-46fe-4d90-b11f-7b63bcb4b373
311 311 Jackolanterns Weather 3 204 L 1740495382 cc3b2dec-5d99-47ea-8930-20bf258be4ea
311 311 All Mixed Up 4 182 L 1740495586 980a78b5-5bdd-4f50-9e3a-e13261e2817b
311 311 Hive 5 179 L 1740495768 18f6dc98-d3a2-4f81-b967-97359d14c68c
311 311 Guns (Are for Pussies) 6 137 L 1740495948 5e97ed9f-c8cc-4282-9cbe-f8e17aee5128
311 311 Misdirected Hostility 7 179 S 1740496085 61ff2c1a-fc9c-44c3-8da1-5e50a44245af
,
#+end_src
** DONE [#B] Allow scrobbling music without MB IDs by grabbing them before scrobble :improvement:
CLOSED: [2023-02-17 Fri 00:10]
This would allow a few nice flows. One, you'd be able to record the play of an
entire album by just dropping the muscibrainz_id in. This could be helpful for
offline listening. It would also mean bad metadata from mopidy would not break
scrobbling.
** DONE When updating musicbrainz IDs, clear and run fetch artwrok :improvement:
CLOSED: [2023-02-17 Fri 00:11]
** DONE [#A] Add ability to manually scrobble albums or tracks from MB :improvement:
CLOSED: [2023-03-07 Tue 11:09]
Given a UUID from musicbrainz, we should be able to scrobble an album or
individual track.
** DONE [#C] Implement keeping track of week/month/year chart-toppers :improvement:
CLOSED: [2023-03-07 Tue 11:10]
:LOGBOOK:
CLOCK: [2023-01-30 Mon 16:30]--[2023-01-30 Mon 18:00] => 1:30
:END:
Maloja does this cool thing where artists and tracks get recorded as the top
track of a given week, month or year. They get gold, silver or bronze stars for
their place in the time period.
I could see this being implemented as a separate Chart table which gets
populated at the end of a time period and has a start and end date that defines
a period, along with a one, two, three instance.
Of course, it could also be a data model without a table, where it runs some fun
calculations, stores it's values in Redis as a long-term lookup table and just
has to re-populate when the server restarts.
* Backlog
* Backlog [2/26]
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :vrobbler:personal:bug:music:scrobbles:
** TODO [#C] Move to using more robust mopidy-webhooks pacakge form pypi :utility:improvement:
:PROPERTIES:
:ID: ab31fdc3-359c-1b1d-6b9d-546b476021ba
:END:
*** Example payloads from mopidy-webhooks
**** Podcast playback ended
#+begin_src json
@ -545,3 +388,621 @@ has to re-populate when the server restarts.
}
}
#+end_src
** TODO [#C] User should be able to enable auto trail tracking via amail reader with Garmin LiveTrack URLs :vrobbler:trails:project:feature:personal:
** TODO [#C] Allow users to see tasks on calendar view :vrobbler:personal:project:templates:feature:
https://codepen.io/oliviale/pen/QYqybo
** TODO [#C] Come up with a possible flow using WebDAV and super-productivity for tasks :personal:feature:project:vrobbler:tasks:
** TODO [#B] Add importer class for IMAP imports :vrobbler:feature:imap:importers:project:personal:
** TODO [#B] Clean up follow up notifications for board games and beer that ask if you're still scrobbling :vrobbler:personal:project:beers:boardgames:notifications:feature:
- Note taken on [2025-09-30 Tue 09:32]
I added this feature in a very rough way, but now we should add "Action"
headers so that we can either Finish or Cancel the associated scrobble:
https://docs.ntfy.sh/publish/#send-http-request
** TODO [#B] Add AllTrails as a source for Trail data :vrobbler:trails:feature:personal:project:
Pretty clear, I would love to make trails more useful. Historically I wasn't
hiking a lot, which made the source for this a bit silly. But it's clear that
AllTrails is the best source, though having TrailForks is nice to.
** TODO [#B] Add `garmin_activity_id` to the TrailMetadataLog class :vrobbler:trails:feature:personal:project:
Would be nice to have some loose connection to the actual event in my Garmin profile.
** TODO [#B] Explore a way to add metadata editing to scrobbles after saving :vrobbler:spike:scrobbling:personal:project:
Could be as simple as a JSON form on the scrobble detail page (do I have have one of those yet?).
** TODO [#B] Explore a good way to show notes and descriptions from scrobbles to users :personal:project:scrobbling:vrobbler:spike:
** TODO [#B] Add webdav syncing to retroarch imports :vrobbler:videogames:webdav:feature:project:personal:
** TODO [#B] Add CSV endpoint for book scrobbles that LibraryThing can ingest :personal:project:books:feature:export:
https://app.todoist.com/app/task/add-a-csv-endpoint-for-users-book-reads-that-library-thing-can-ingest-6X7QPMRp265xMXqg#comment-6X7QrXq6gJjMP4hg
** TODO [#B] Add youtube link in place of IMDB on video detail page :vrobbler:feature:videos:personal:project:
** TODO [#B] Fix PuzzleLogData has no attribute form :vrobbler:puzzles:personal:project:logdata:
** TODO [#B] Scrape ComicBookRoundUp ratings for comic book metadata :vrobbler:books:feature:comicbook:personal:project:
- Note taken on [2025-09-25 Thu 10:51]
As an example https://comicbookroundup.com/comic-books/reviews/humanoids-publishing/the-history-of-science-fiction
** TODO [#B] Add PuzzleLogData class with with_people and completed :vrobbler:feature:puzzles:logdata:personal:project:
** TODO [#A] 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.
** DONE [#B] Save path to reading source on book scrobbles and show it on the detail page :vrobbler:feature:books:personal:project:
:PROPERTIES:
:ID: f1ef3945-e6e4-66c1-b72e-3cede7a0f84a
:END:
** DONE [#B] Move comic resume URL to next page and check if it exists :vrobbler:feature:books:personal:project:
:PROPERTIES:
:ID: 9fe09567-11a3-7083-53c7-07458a9591d0
:END:
* Version 31.0 [3/3]
** DONE [#A] Stop comic book webpage scrobbles from overwriting old scrobbles :vrobbler:personal:bug:books:scrobbling:
:PROPERTIES:
:ID: 4b2ec068-a281-a88b-c31d-6248d6eb0aa0
:END:
** DONE [#A] Add page calculation to manually scrobbled books :vrobbler:personal:feature:books:scrobbling:
:PROPERTIES:
:ID: b2e313b3-5c35-57e7-8933-627535baf34b
:END:
** DONE [#A] Fix bug in scrobbling comics where google fails :vrobbler:personal:bug:books:scrobbling:
:PROPERTIES:
:ID: 9a870c05-6d20-0803-d35d-c03fbe1d0ee1
:END:
* Version 30.0 [3/3]
** DONE [#A] Fix readcomicsonline browsing to update pages :vrobbler:books:feature:comicbook:personal:project:scrobbling:
:PROPERTIES:
:ID: 981b215a-6473-5fc7-d4cc-51b3eddec4c3
:END:
** DONE [#B] Redirect webpages back to the original page when starting or stopping :vrobbler:project:webpages:bug:
:PROPERTIES:
:ID: 6183d03a-452b-51d5-cceb-5bfeada947aa
:END:
** DONE [#B] Fix ComicVine as source for comic book metadata :vrobbler:books:feature:comicbook:personal:project:scrobbling:
:PROPERTIES:
:ID: d22cec3f-117f-f203-33a5-efbefa8a5cee
:END:
* Version 29.0 [1/1]
** DONE HOTFIX podcast lookups, final
* Version 28.0 [1/1]
** DONE HOTFIX podcast lookups
* Version 27.0 [3/3]
** DONE [#A] Fix bug where podcast scrobbling creates duplicate Podcast :project:vrobbler:scrobbling:podcasts:bug:personal:
:PROPERTIES:
:ID: 7377ef6c-5fa7-9e4e-9080-f9810a76118c
:END:
Rather than pick up an existing Podcast using the podcast title in the mopidy
file name, Vrobbler creates a new podcast with no enriched data. Not a big deal
for my use as the volume of podcasts I listen to makes manual fixes easy. But
it's annoying.
** DONE [#A] Allow reading comic books from readcomicsoline.ru :vrobbler:books:feature:comicbook:personal:project:scrobbling:
:PROPERTIES:
:ID: 7c7e9ecc-b675-68c3-764f-ef771ce5d88f
:END:
- Note taken on [2025-09-25 Thu 10:52]
Things to consider are whether we scrobble the issue on one page, send it to
archivebox? (yes), and how best to enrich the data
** DONE [#A] Add RSS feed lookups to podcasts :vrobbler:personal:feature:podcasts:
:PROPERTIES:
:ID: d60645b0-7578-97c1-0278-05bd9de4269c
:END:
- Note taken on [2025-10-14 Tue 10:08]
Turns out the Podcast plugin for mopidy does a pretty good job of showing the
latest file without having to scroll the bottom using only Muse to not parse
the podcast title name. BUT, now we're getting urls like this:
https://nsf.libsyn.com/rss#77e01251-cb20-4609-b577-d48e985d2e7b
This is great, because there's more context there, but it has to read out of
the RSS feed. We should add a check in the podcast util to sniff out the file
referenced in the # in that url and populate the info from there. This should
actually be much more reliable than the current state of the podcast lookup
which depends on the file to be name properly.
* Version 26.0 [3/3]
** DONE Clean up templates for scrobble details :vrobbler:personal:bug:templates:
:PROPERTIES:
:ID: 43dc1e02-c110-5b49-0ac7-4c4f7656d1aa
:END:
** DONE Add named locations visited to dashboard :vrobbler:personal:feature:locations:templates:
:PROPERTIES:
:ID: ebc365a1-cef4-f75d-569f-c24b072ef5a4
:END:
** DONE Add moods to dashboard :vrobbler:moods:feature:templates:personal:
:PROPERTIES:
:ID: c03a38ce-b337-f4fa-adba-aee08d4329f5
:END:
* Version 25.0 [3/3]
** DONE Add basic food templates and fix urls :food:vrobbler:personal:project:bug:urls:
:PROPERTIES:
:ID: 3de3459e-8e7e-abba-e068-b919a819d3e3
:END:
** DONE [#C] Fix how elapsed time is calculated :vrobbler:personal:project:scrobbles:bug:
:PROPERTIES:
:ID: cff58fc4-06ac-8016-4eae-130b51e3c9b7
:END:
** DONE Fix templates for videos and dashboard links :personal:feature:project:vrobbler:templates:
:PROPERTIES:
:ID: 7debfbaf-cdd8-f49b-57ff-804bfe7c9236
:END:
* Version 24.0 [2/2]
** DONE Clean up logdata for various media :personal:feature:project:vrobbler:logdata:
:PROPERTIES:
: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:
:PROPERTIES:
:ID: 55404488-c69f-0dd5-838e-1d1e15c873eb
:END:
* 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:
:PROPERTIES:
:ID: 9d055ac1-584b-20c8-7ad9-9ce36b329dc7
:END:
* Version 18.4 [2/2]
** DONE Track timezone changes for profiles :vrobbler:feature:profiles:personal:project:
:PROPERTIES:
:ID: 89ec867f-29fd-82f1-be17-b49dddc30c78
:END:
[2025-07-11 14:23]
** DONE Only create a LastFM import if there are files to import :vrobbler:feature:lastfm:importers:project:personal:
:PROPERTIES:
:ID: 7a1456af-c5f1-6385-b34f-0be24a6b65b0
:END:
- 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.
* Version 18.3 [1/1]
** DONE Add timezone awarness to IMAP importer :personal:project:vrobbler:feature:importer:imap:timezones:
:PROPERTIES:
:ID: 05837b7c-96aa-6190-3678-e2ae7c7cac75
:END:
* Version 18 [4/4]
** DONE Condense tracks of the same title by the same artist with multiple albums :vrobbler:feature:music:project:personal:
:PROPERTIES:
:ID: b39fcec8-59fd-eab0-5809-b8144c7d2708
:END:
** DONE Import from BG stats a "learning" log field when "Learning to play" is in the comment :vrobbler:feature:boardgames:project:personal:
:PROPERTIES:
:ID: fda59fab-4349-e99e-54c6-9f1392a1c474
:END:
** DONE [#A] Add email importer for BG stats file uploads :vrobbler:feature:boardgames:personal:project:
:PROPERTIES:
:ID: 116fe738-7966-615c-d195-ccff0337b101
:END:
#+begin_src json example of a file
{
"about": "This is a Play file that can be read by Board Game Stats. If you see this text, try to use a share, export or open-in function to open it with Board Game Stats.",
"players": [
{
"uuid": "31f8b92e-11d8-4162-88b1-fd9c79eea249",
"id": 2,
"name": "Colin",
"isAnonymous": false,
"modificationDate": "2025-07-01 18:10:32",
"metaData": "{\"isNpc\":0}"
},
{
"uuid": "00074700-cf4e-4ad3-b334-d35805bb0d90",
"id": 4,
"name": "Asa Sewell",
"isAnonymous": false,
"modificationDate": "2025-07-01 18:03:37"
}
],
"locations": [
{
"uuid": "14f7389c-767f-4725-9b35-906c407b293c",
"id": 3,
"name": "Timberwyck Farm",
"modificationDate": "2025-07-01 18:03:38"
}
],
"games": [
{
"uuid": "043a2851-f201-467a-a60c-0b0a7e9c33d2",
"id": 333,
"name": "Ghost Fightin' Treasure Hunters: Anniversary Edition",
"modificationDate": "2025-07-02 01:37:14",
"cooperative": true,
"highestWins": true,
"noPoints": false,
"usesTeams": false,
"urlThumb": "https://cf.geekdo-images.com/DHA-mcH3zzw_OjfDxOPj1A__thumb/img/UhaIm4KIDIiraUc44QIvSAbMUXI=/fit-in/200x150/filters:strip_icc()/pic8266874.jpg",
"urlImage": "https://cf.geekdo-images.com/DHA-mcH3zzw_OjfDxOPj1A__original/img/2-Lb6nLePhn0I0Hh2j1pOtbO4rg=/0x0/filters:format(jpeg)/pic8266874.jpg",
"bggName": "Ghost Fightin' Treasure Hunters: Anniversary Edition",
"bggYear": 2024,
"bggId": 422668,
"designers": "Brian Yu",
"isBaseGame": 1,
"isExpansion": 0,
"rating": 75,
"minPlayerCount": 2,
"maxPlayerCount": 5,
"minPlayTime": 30,
"maxPlayTime": 0,
"minAge": 8
}
],
"plays": [
{
"uuid": "bae3f29e-5e1e-45d8-b409-47a665c8d5b5",
"modificationDate": "2025-07-02 01:37:59",
"entryDate": "2025-07-02 01:31:38",
"playDate": "2025-07-02 01:31:38",
"usesTeams": false,
"durationMin": 23,
"ignored": false,
"manualWinner": true,
"rounds": 3,
"scoresheet": "{\"bggId\":244711,\"version\":1,\"langCode\":\"en\",\"scoreType\":\"bestTotalWins\",\"groups\":[{\"templateId\":\"1\",\"maxRepeat\":-1,\"repetition\":1,\"hasSubTotal\":false,\"hideSingleGroupLabel\":false,\"isExtra\":false,\"rows\":[{\"templateId\":\"vptrack\",\"label\":\"VP track\",\"repetition\":1,\"repeatable\":false,\"negative\":false,\"isExtra\":false,\"scores\":{}},{\"templateId\":\"objectives\",\"label\":\"Objectives\",\"repetition\":1,\"repeatable\":false,\"negative\":false,\"isExtra\":false,\"scores\":{}},{\"templateId\":\"mastercards\",\"label\":\"Master cards\",\"repetition\":1,\"repeatable\":false,\"negative\":false,\"isExtra\":false,\"scores\":{}}]}]}",
"locationRefId": 3,
"gameRefId": 333,
"board": "",
"scoringSetting": 4,
"metaData": "{\"playUsedGameCopy\":2}",
"playerScores": [
{
"score": "",
"winner": true,
"newPlayer": true,
"startPlayer": false,
"playerRefId": 4,
"role": "",
"rank": 0,
"seatOrder": 0,
"metaData": "{\"scoreUuid\":\"00074700-cf4e-4ad3-b334-d35805bb0d90\"}"
},
{
"score": "",
"winner": true,
"newPlayer": true,
"startPlayer": false,
"playerRefId": 2,
"role": "",
"rank": 0,
"seatOrder": 0,
"metaData": "{\"scoreUuid\":\"31f8b92e-11d8-4162-88b1-fd9c79eea249\"}"
}
],
"expansionPlays": []
}
],
"userInfo": {
"meRefId": 2
}
}
#+end_src
** DONE [#B] Fix task app to only use one tag for the context a task was done in and allow configurable contexts by user profile :personal:vrobbler:feature:tasks:project:
:PROPERTIES:
:ID: 23f485e3-988c-6198-c79d-91fdf92f001c
:END:
* Version 17.0 [6/6]
** DONE [#A] Fix bug in new task label lookup for Emacs/Org-mode :vrobbler:bug:tasks:
:PROPERTIES:
:ID: 683fb109-dfc4-85e4-80f0-ea618434f61e
:END:
** DONE [#C] Replace commas in the bandcamp URL for artists with nothing :vrobbler:music:bug:personal:
:PROPERTIES:
:ID: 9b30d67b-91f0-a480-dfaa-5d9dc090e76c
:END:
- Note taken on [2025-06-16 Mon 09:36]
This firt appeared with Black Country, New Road, where the RYM slug generator
leaves commas in and ends up sending you to a 404. I suspect this wont be the
first tweak we'll need to this, as the RYM link creator is really just
guessing based on the artist name at the path.
** DONE [#A] Investigate new source of video metadata :personal:project:video:imdb:
:PROPERTIES:
:ID: df2b486c-1170-5199-c312-9bc87760d962
:END:
Cinemagoer broke and I probably should find a more reilable source of video data.
- Note taken on [2025-06-13 Fri 11:19]
TMDB is much more reliable, but does require an API key. That's all setup now,
so hopefully this breaking IMDB crap is over.
** DONE [#A] IMDB video lookups are failing :personal:bug:video:imdb:
:PROPERTIES:
:ID: 38f1081f-37b4-f4f2-79aa-c1e87eca4b69
:END:
<2025-06-13 Fri>
- Note taken on [2025-06-13 Fri 08:24]
Looks like Cinemagoer is broken: https://github.com/cinemagoer/cinemagoer/issues/537
** DONE [#A] Emacs is not syncing notes :personal:scrobbling:emacs:bug:
:PROPERTIES:
:ID: c79cd491-b30f-0945-d84b-b8cac7562791
:END:
<2025-06-12 Thu 9:30>
Not sure if the problem is in my Emacs hook sending or Vrobbler itself.
- Note taken on [2025-06-12 Thu 09:47]
Adding a quick note to check on it
- Note taken on [2025-06-12 Thu 09:50]
Ah ha. All the messing about with the source field meant that I was looking
for `emacs` as a source but the hook was initially setting sources to
`orgmode` I think I prefer `orgmode` as the source, so updating it thusly.
Fixed in `490d60cbbb1f8bf90b5fc47d8685b15bdc1d485b`
** DONE [#A] Show the description of a task in the string rep for a scrobble of a Task :personal:project:scrobbling:vrobbler:feature:
:PROPERTIES:
:ID: df58f8d0-fa4a-2037-c7d7-e5388c239042
:END:
* Version 0.16.0 [19/19]
** DONE [#A] Jellyfin, bandcamp tracks from Mopidy create duplicate music tracks :bug:scrobbling:music:
:PROPERTIES:
:ID: 670e8634-49b5-dce9-1684-14f2ffb797f1
:END:
Effectively, any track that comes in without a MusicBrainz ID does some funky
lookup where it doesn't find a track without an MB id and the track title /
artist combination and creates a new track every time. This has to be cleaned up
by condensing the duplicated tracks into the original proper track.
But it opens a bigger question about how much MB id should the drive the app
lookup. If it can't be depended on to exist from all sources, it really can't be
canonical. Instead, the combination of track title / artist is really the best
we can do. Last.fm also has this problem, where it doesn't know about albums and
definitely does not know or care about MB ids.
** DONE Add a user profile page with ability to change settings :profiles:improvement:
- Note taken on [2025-04-04 Fri 10:51]
[[orgit-rev:~/src/code.unbl.ink/secstate/vrobbler/::93c16d80ecff4cd1663cf9ec40fbe6d8f58c3e44][~/src/code.unbl.ink/secstate/vrobbler/ (magit-rev 93c16d8)]]
https://code.unbl.ink/secstate/vrobbler/commit/93c16d80ecff4cd1663cf9ec40fbe6d8f58c3e44
** DONE What to do with Youtube videos from LastFM and web-scrobbler :bug:source:lastfm:
- Note taken on [2025-04-04 Fri 10:46]
Nothing. Over the last few months I built out a youtube model in videos and
use a bookmarklet scrobbling pattern. Now web-scrobbler is just disabled for
Youtube.
May want to revisit this at some point and only scrobble tracks from Youtube,
because many people use YT for music listening.
** DONE [#C] Consider a purge command for duplicated and stuck in-progress scrobbles :utililty:improvement:
CLOSED: [2023-04-06 Thu 14:09]
** DONE Add a "stop_timestamp" so we don't rely on content length :improvement:scrobbling:
CLOSED: [2023-04-02 Sun 23:58]
Essentially, we currently have the timestamp as when the content began
scrobbling and then calculate the finish time from the length of the content.
This works pretty well because we know how long most things are.
But in some cases, sports events or long podcasts, we may start mid-way through
an event or finish halfway through but still want to mark it as done. In these
cases, knowing the finish time could be useful, especially when interfacing with
other scrobblers which may have different definitions of when a scrobble
finishes or started.
** DONE Fix bug with Various Artist albums being labeled with first artist as album artist :scrobbling:bug:music:
CLOSED: [2023-03-27 Mon 20:18]
:LOGBOOK:
CLOCK: [2023-03-26 Sun 22:01]--[2023-03-27 Mon 01:07] => 3:06
:END:
** DONE Fix bug with weekly aggregator being blank on Sundays :aggregators:music:bug:
CLOSED: [2023-03-26 Sun 13:52]
** DONE Fix KoReader scrobbling to use pages rather than time of last read :scrobbling:books:improvement:
CLOSED: [2023-03-26 Sun 13:51]
:LOGBOOK:
CLOCK: [2023-03-26 Sun 13:11]--[2023-03-26 Sun 13:51] => 0:40
:END:
** DONE [#A] Add django-storage to store files on S3 :settings:improvement:
CLOSED: [2023-03-24 Fri 14:46]
:LOGBOOK:
CLOCK: [2023-03-24 Fri 10:47]--[2023-03-24 Fri 14:46] => 3:59
CLOCK: [2023-03-24 Fri 10:36]--[2023-03-24 Fri 10:40] => 0:04
:END:
** DONE Fix vrobbler settings not using booleans :settings:bug:
CLOSED: [2023-03-24 Fri 10:45]
:LOGBOOK:
CLOCK: [2023-03-24 Fri 10:40]--[2023-03-24 Fri 10:46] => 0:06
:END:
** DONE Update weekly live chart to be 7-day continuous rather than weekly :views:bug:
CLOSED: [2023-03-24 Fri 00:31]
The live view will be blank every Monday, no reason to tie it to a day of the
week. It should be "the last 7 days"
** DONE [#B] Implement a detail view for TV shows :improvement:views:
CLOSED: [2023-03-22 Wed 17:05]
** DONE [#B] Implement a detail view for Movies :improvement:views:
CLOSED: [2023-03-22 Wed 17:05]
** DONE Add "service provider" to TV Series, and use that for source when available :bug:scrobbling:
CLOSED: [2023-03-22 Wed 17:04]
** DONE Add view for long-play content (books, video games) to restart them :views:improvement:
CLOSED: [2023-03-22 Wed 17:01]
** DONE Add live chart view like Maloja :improvement:views:
CLOSED: [2023-03-07 Tue 11:13]
** DONE [#C] Figure out how to add to web-scrobbler :improvement:scrobbling:
CLOSED: [2023-03-22 Wed 17:06]
An example:
https://github.com/web-scrobbler/web-scrobbler/blob/master/src/core/background/scrobbler/maloja-scrobbler.js
This is actually going to be moot because we can import from LastFM, and
web-scrobbler integrates well with LastFM. The only thing to think through here
now is what to do with all the garbage web-scrobbler sometimes pushes to LastFM
from Youtube (all videos get pushed, sigh).
** DONE Add Amazon scraper to look up books when OL fails :books:improvement:
This turned out to be a non-starter ... Amazon is aggressive at disallowing
scraping quality. And all the OSS tools out there are stuck in an arms race
trying to keep them from breaking.
That said, Google Books actually has a decent API (for now), and I've built this
out using that.
** 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
separate bug tracking ticket for that.
* Version 0.11.4 [9/9]
** DONE Add rudimentary video game scrobbling :improvement:content:videogames:
CLOSED: [2023-03-07 Tue 11:11]
** DONE Add ability to scrobble from KOReader statistics files :improvement:books:content:
CLOSED: [2023-03-07 Tue 11:11]
** DONE [#A] Fix fetching artwork without release group :bug:
CLOSED: [2023-01-29 Sun 14:27]
When we get artwork from Musicbrianz, and it's not found, we should check for
release groups as well. This will stop issues with missing artwork because of
obscure MB release matches.
** DONE [#A] Fix Jellyfin music scrobbling N+1 past 90 completion percent :bug:
CLOSED: [2023-01-30 Mon 18:31]
:LOGBOOK:
CLOCK: [2023-01-30 Mon 18:00]--[2023-01-30 Mon 18:31] => 0:31
:END:
If we play music from Jellyfin and the track reaches 90% completion, the
scrobbling goes crazy and starts creating new scrobbles with every update.
The cause is pretty simple, but the solution is hard. We want to mark a scrobble
as complete for the following conditions:
- Play stopped and percent played beyond 90%
- Play completely finished
But if we keep listening beyond 90, we should basically ignore updates (or just
update the existing scrobble)
** DONE [#A] Add support for Audioscrobbler tab-separated file uploads :improvement:
CLOSED: [2023-02-03 Fri 16:52]
An example of the format:
#+begin_src csv
,
#AUDIOSCROBBLER/1.1
#TZ/UNKNOWN
#CLIENT/Rockbox sansaclipplus $Revision$
75 Dollar Bill I Was Real I Was Real 4 1015 S 1740494944 64ff5f53-d187-4512-827e-7606c69e66ff
75 Dollar Bill I Was Real I Was Real 4 1015 S 1740494990 64ff5f53-d187-4512-827e-7606c69e66ff
311 311 Down 1 173 S 1740495003 00476c23-fd9e-464b-9b27-a62d69f3d4f4
311 311 Down 1 173 L 1740495049 00476c23-fd9e-464b-9b27-a62d69f3d4f4
311 311 Down 1 173 L 1740495113 00476c23-fd9e-464b-9b27-a62d69f3d4f4
311 311 Random 2 187 S 1740495190 530c09f3-46fe-4d90-b11f-7b63bcb4b373
311 311 Random 2 187 L 1740495194 530c09f3-46fe-4d90-b11f-7b63bcb4b373
311 311 Jackolanterns Weather 3 204 L 1740495382 cc3b2dec-5d99-47ea-8930-20bf258be4ea
311 311 All Mixed Up 4 182 L 1740495586 980a78b5-5bdd-4f50-9e3a-e13261e2817b
311 311 Hive 5 179 L 1740495768 18f6dc98-d3a2-4f81-b967-97359d14c68c
311 311 Guns (Are for Pussies) 6 137 L 1740495948 5e97ed9f-c8cc-4282-9cbe-f8e17aee5128
311 311 Misdirected Hostility 7 179 S 1740496085 61ff2c1a-fc9c-44c3-8da1-5e50a44245af
,
#+end_src
** DONE [#B] Allow scrobbling music without MB IDs by grabbing them before scrobble :improvement:
CLOSED: [2023-02-17 Fri 00:10]
This would allow a few nice flows. One, you'd be able to record the play of an
entire album by just dropping the muscibrainz_id in. This could be helpful for
offline listening. It would also mean bad metadata from mopidy would not break
scrobbling.
** DONE When updating musicbrainz IDs, clear and run fetch artwrok :improvement:
CLOSED: [2023-02-17 Fri 00:11]
** DONE [#A] Add ability to manually scrobble albums or tracks from MB :improvement:
CLOSED: [2023-03-07 Tue 11:09]
Given a UUID from musicbrainz, we should be able to scrobble an album or
individual track.
** DONE [#C] Implement keeping track of week/month/year chart-toppers :improvement:
CLOSED: [2023-03-07 Tue 11:10]
:LOGBOOK:
CLOCK: [2023-01-30 Mon 16:30]--[2023-01-30 Mon 18:00] => 1:30
:END:
Maloja does this cool thing where artists and tracks get recorded as the top
track of a given week, month or year. They get gold, silver or bronze stars for
their place in the time period.
I could see this being implemented as a separate Chart table which gets
populated at the end of a time period and has a start and end date that defines
a period, along with a one, two, three instance.
Of course, it could also be a data model without a table, where it runs some fun
calculations, stores it's values in Redis as a long-term lookup table and just
has to re-populate when the server restarts.

File diff suppressed because one or more lines are too long

42
poetry.lock generated
View File

@ -1602,6 +1602,21 @@ files = [
[package.extras]
devel = ["colorama", "json-spec", "jsonschema", "pylint", "pytest", "pytest-benchmark", "pytest-cache", "validictory"]
[[package]]
name = "feedparser"
version = "6.0.12"
description = "Universal feed parser, handles RSS 0.9x, RSS 1.0, RSS 2.0, CDF, Atom 0.3, and Atom 1.0 feeds"
optional = false
python-versions = ">=3.6"
groups = ["main"]
files = [
{file = "feedparser-6.0.12-py3-none-any.whl", hash = "sha256:6bbff10f5a52662c00a2e3f86a38928c37c48f77b3c511aedcd51de933549324"},
{file = "feedparser-6.0.12.tar.gz", hash = "sha256:64f76ce90ae3e8ef5d1ede0f8d3b50ce26bcce71dd8ae5e82b1cd2d4a5f94228"},
]
[package.dependencies]
sgmllib3k = "*"
[[package]]
name = "filelock"
version = "3.18.0"
@ -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)"]
type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"]
[[package]]
name = "sgmllib3k"
version = "1.0.0"
description = "Py3k port of sgmllib."
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "sgmllib3k-1.0.0.tar.gz", hash = "sha256:7868fb1c8bfa764c1ac563d3cf369c381d1325d36124933a726f29fcdaa812e9"},
]
[[package]]
name = "shellingham"
version = "1.5.4"
@ -4730,6 +4756,20 @@ webencodings = ">=0.4"
doc = ["sphinx", "sphinx_rtd_theme"]
test = ["pytest", "ruff"]
[[package]]
name = "titlecase"
version = "2.4.1"
description = "Python Port of John Gruber's titlecase.pl"
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "titlecase-2.4.1.tar.gz", hash = "sha256:7d83a277ccbbda11a2944e78a63e5ccaf3d32f828c594312e4862f9a07f635f5"},
]
[package.extras]
regex = ["regex (>=2020.4.4)"]
[[package]]
name = "tld"
version = "0.13"
@ -5499,4 +5539,4 @@ cffi = ["cffi (>=1.11)"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.9,<3.12"
content-hash = "3a483aefea0a3afebf187b17b7df72a158788024ca8121b512b39567fb5ec8ca"
content-hash = "2e297ef6f8c524840a381ad793946c87b601d81afd569e882fe58120a5f84626"

View File

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

View File

@ -7,23 +7,24 @@ from rest_framework.authtoken.models import Token
from boardgames.models import BoardGame
from music.models import Track, Artist
from scrobbles.models import Scrobble
from people.models import Person
User = get_user_model()
@pytest.fixture
def boardgame_scrobble():
user = User.objects.create(
email="test@exmaple.com", first_name="Test", last_name="User"
)
first = Person.objects.create(name="First Player")
second = Person.objects.create(name="Second Player")
return Scrobble.objects.create(
board_game=BoardGame.objects.create(title="Test Board Game"),
media_type="BoardGame",
played_to_completion=True,
log={
"players": [
{"user_id": user.id, "win": True, "score": 30, "color": "Blue"}
]
{"person_id": first.id, "win": True, "score": 30, "color": "Blue"},
{"person_id": second.id, "win": False, "score": 28, "color": "Red"}
],
},
)
@ -114,6 +115,12 @@ def mopidy_podcast_request_data():
mopidy_uri=mopidy_uri, artist="NPR", album="Up First"
).request_json
@pytest.fixture
def mopidy_podcast_https_request_data():
mopidy_uri = "podcast+https://feeds.npr.org/510318/podcast.xml#85b9c4c4-ae09-43d9-8853-31ccf43f68e6"
return MopidyRequest(
mopidy_uri=mopidy_uri, artist="NPR", album="Up First"
).request_json
class JellyfinTrackRequest:
name = "Emotion"

View File

@ -1,16 +1,15 @@
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.django_db
def test_boardgame_log_data(boardgame_scrobble):
assert not boardgame_scrobble.geo_location
assert boardgame_scrobble.logdata == BoardGameLogData(
players=[
BoardGameScoreLogData(
user_id=1,
name_str="",
person_id=1,
bgg_username="",
color="Blue",
character=None,
@ -18,10 +17,24 @@ def test_boardgame_log_data(boardgame_scrobble):
score=30,
win=True,
new=None,
)
rank=None,
seat_order=None,
role=None
),
BoardGameScoreLogData(
person_id=2,
bgg_username="",
color="Red",
character=None,
team=None,
score=28,
win=False,
new=None,
rank=None,
seat_order=None,
role=None
),
],
location=None,
geo_location_id=None,
difficulty=None,
solo=None,
two_handed=None,

View File

@ -30,6 +30,7 @@ def test_bad_mopidy_request_data(client, valid_auth_token):
)
@pytest.mark.skip("Need to refactor")
@pytest.mark.django_db
@patch("music.utils.lookup_artist_from_mb", return_value={})
@patch(
@ -105,6 +106,7 @@ def test_scrobble_mopidy_podcast(
assert scrobble.media_obj.title == "Up First"
@pytest.mark.skip("Need to refactor")
@pytest.mark.django_db
@patch("music.utils.lookup_artist_from_mb", return_value={})
@patch(
@ -149,6 +151,7 @@ def test_scrobble_jellyfin_track(
assert scrobble.media_obj.title == "Emotion"
@pytest.mark.skip("Need to refactor")
@pytest.mark.django_db
@patch("music.utils.lookup_artist_from_mb", return_value={})
@patch(
@ -199,6 +202,7 @@ def test_scrobble_jellyfin_track_update(
assert scrobble.media_obj.title == "Emotion"
@pytest.mark.skip("Need to refactor")
@pytest.mark.django_db
@patch("music.utils.lookup_artist_from_mb", return_value={})
@patch(

View File

@ -1,18 +1,25 @@
from dataclasses import dataclass
from typing import Optional
from uuid import uuid4
from beers.untappd import get_beer_from_untappd_id, get_rating_from_soup
from beers.untappd import get_beer_from_untappd_id
from django.apps import apps
from django.db import models
from django.urls import reverse
from django_extensions.db.models import TimeStampedModel
from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit
from scrobbles.dataclasses import BeerLogData
from scrobbles.dataclasses import BaseLogData
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
BNULL = {"blank": True, "null": True}
@dataclass
class BeerLogData(BaseLogData):
rating: Optional[str] = None
class BeerStyle(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
name = models.CharField(max_length=255)

View File

@ -1,6 +1,11 @@
from django.contrib import admin
from boardgames.models import BoardGame, BoardGamePublisher
from boardgames.models import (
BoardGame,
BoardGameLocation,
BoardGamePublisher,
BoardGameDesigner,
)
from scrobbles.admin import ScrobbleInline
@ -15,13 +20,34 @@ class BoardGamePublisherAdmin(admin.ModelAdmin):
ordering = ("-created",)
@admin.register(BoardGameDesigner)
class BoardGameDesignerAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"name",
"uuid",
)
ordering = ("-created",)
@admin.register(BoardGameLocation)
class BoardGameLocationAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"name",
"uuid",
"geo_location",
)
ordering = ("-created",)
@admin.register(BoardGame)
class GameAdmin(admin.ModelAdmin):
class BoardGameAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"bggeek_id",
"title",
"published_date",
"published_year",
)
search_fields = ("title",)
ordering = ("-created",)

View File

@ -100,8 +100,8 @@ def lookup_boardgame_from_bgg(lookup_id: str) -> dict:
def push_scrobble_to_bgg(scrobble: "Scrobble", user: User) -> Optional[bool]:
bgg_username = user.profile.bgg_username
bgg_password = user.profile.bgg_password
bgg_username = "secstate" # user.profile.bgg_username
bgg_password = "yYFCKnfo8AK89lc68q0S"
if not bgg_username or bgg_password:
return
@ -119,24 +119,20 @@ def push_scrobble_to_bgg(scrobble: "Scrobble", user: User) -> Optional[bool]:
data=json.dumps(login_payload),
headers=headers,
)
players = []
if scrobble.metadata:
for player in scrobble.metadata.players:
if player["user_id"]:
player_user = User.objects.filter(
id=player["user_id"]
).first()
if player_user:
if player_user.bgg_username:
player["username"] = player_user.bgg_username
else:
player["name"] = player_user.username
player["win"] = player.get("win")
player["color"] = player.get("color")
player["new"] = player.get("new")
player["score"] = player.get("score")
players.append(player)
if scrobble.log:
for player in scrobble.log.get("players"):
player_person = Person.objects.filter(
id=player.get("person_id")
).first()
if player_person.get("bgg_username"):
player["username"] = player_person.get("bgg_username")
player["name"] = player_person.get("name")
player["win"] = player.get("win")
# player["role"] = player.get("role")
player["new"] = player.get("new")
player["score"] = player.get("score")
players.append(player)
play_payload = {
"playdate": scrobble.timestamp.date.strftime("%Y-%m-%d"),
@ -150,3 +146,8 @@ def push_scrobble_to_bgg(scrobble: "Scrobble", user: User) -> Optional[bool]:
"objecttype": "thing",
"ajax": 1,
}
r = s.post(
"https://boardgamegeek.com/geekplay.php",
data=json.dumps(play_payload),
headers=headers,
)

View File

@ -0,0 +1,167 @@
# Generated by Django 4.2.19 on 2025-07-03 01:57
from django.db import migrations, models
import django.db.models.deletion
import django_extensions.db.fields
import uuid
class Migration(migrations.Migration):
dependencies = [
("locations", "0007_alter_geolocation_run_time_seconds"),
("boardgames", "0007_alter_boardgame_run_time_seconds"),
]
operations = [
migrations.CreateModel(
name="BoardGameDesigner",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created",
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name="created"
),
),
(
"modified",
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name="modified"
),
),
("name", models.CharField(max_length=255)),
(
"uuid",
models.UUIDField(
blank=True,
default=uuid.uuid4,
editable=False,
null=True,
),
),
("bgg_id", models.IntegerField(blank=True, null=True)),
("bio", models.TextField(blank=True, null=True)),
],
options={
"get_latest_by": "modified",
"abstract": False,
},
),
migrations.RenameField(
model_name="boardgamepublisher",
old_name="igdb_id",
new_name="bgg_id",
),
migrations.AddField(
model_name="boardgame",
name="bgstats_id",
field=models.UUIDField(blank=True, null=True),
),
migrations.AddField(
model_name="boardgame",
name="cooperative",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="boardgame",
name="expansion_for_boardgame",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to="boardgames.boardgame",
),
),
migrations.AddField(
model_name="boardgame",
name="highest_wins",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="boardgame",
name="max_play_time",
field=models.IntegerField(blank=True, null=True),
),
migrations.AddField(
model_name="boardgame",
name="min_play_time",
field=models.IntegerField(blank=True, null=True),
),
migrations.AddField(
model_name="boardgame",
name="no_points",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="boardgame",
name="uses_teams",
field=models.BooleanField(default=False),
),
migrations.CreateModel(
name="BoardGameLocation",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created",
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name="created"
),
),
(
"modified",
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name="modified"
),
),
("name", models.CharField(max_length=255)),
(
"uuid",
models.UUIDField(
blank=True,
default=uuid.uuid4,
editable=False,
null=True,
),
),
("bgstats_id", models.UUIDField(blank=True, null=True)),
("description", models.TextField(blank=True, null=True)),
(
"geo_location",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to="locations.geolocation",
),
),
],
options={
"get_latest_by": "modified",
"abstract": False,
},
),
migrations.AddField(
model_name="boardgame",
name="designers",
field=models.ManyToManyField(
related_name="board_games", to="boardgames.boardgamedesigner"
),
),
]

View File

@ -0,0 +1,33 @@
# Generated by Django 4.2.19 on 2025-07-03 02:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("boardgames", "0008_boardgamedesigner_and_more"),
]
operations = [
migrations.AlterField(
model_name="boardgame",
name="cooperative",
field=models.BooleanField(blank=True, default=False, null=True),
),
migrations.AlterField(
model_name="boardgame",
name="highest_wins",
field=models.BooleanField(blank=True, default=True, null=True),
),
migrations.AlterField(
model_name="boardgame",
name="no_points",
field=models.BooleanField(blank=True, default=False, null=True),
),
migrations.AlterField(
model_name="boardgame",
name="uses_teams",
field=models.BooleanField(blank=True, default=False, null=True),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.19 on 2025-07-03 04:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("boardgames", "0009_alter_boardgame_cooperative_and_more"),
]
operations = [
migrations.AddField(
model_name="boardgame",
name="published_year",
field=models.IntegerField(blank=True, null=True),
),
]

View File

@ -1,8 +1,11 @@
from functools import cached_property
import logging
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
from uuid import uuid4
from django import forms
import requests
from boardgames.bgg import lookup_boardgame_from_bgg
from django.conf import settings
@ -12,18 +15,120 @@ from django.urls import reverse
from django_extensions.db.models import TimeStampedModel
from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit
from scrobbles.dataclasses import BoardGameLogData
from locations.models import GeoLocation
from people.models import Person
from scrobbles.dataclasses import BaseLogData, LongPlayLogData
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
logger = logging.getLogger(__name__)
BNULL = {"blank": True, "null": True}
@dataclass
class BoardGameScoreLogData(BaseLogData):
person_id: Optional[int] = None
bgg_username: Optional[str] = None
color: Optional[str] = None
character: Optional[str] = None
team: Optional[str] = None
score: Optional[int] = None
win: Optional[bool] = None
new: Optional[bool] = None
rank: Optional[int] = None
seat_order: Optional[int] = None
role: Optional[str] = None
rank: Optional[int] = None
seat_order: Optional[int] = None
role: Optional[str] = None
lichess_username: Optional[str] = None
@property
def person(self) -> Optional[Person]:
return Person.objects.filter(id=self.person_id).first()
@property
def name(self) -> str:
name = ""
if self.person:
name = self.person.name
return name
def __str__(self) -> str:
out = self.name
if self.score:
out += f" {self.score}"
if self.color:
out += f" ({self.color})"
if self.win:
out += f" [W]"
return out
@dataclass
class BoardGameLogData(BaseLogData, LongPlayLogData):
players: Optional[list[BoardGameScoreLogData]] = None
location_id: Optional[int] = None
difficulty: Optional[int] = None
solo: Optional[bool] = None
two_handed: Optional[bool] = None
expansion_ids: Optional[int] = None
moves: Optional[list] = None
rated: Optional[str] = None
speed: Optional[str] = None
variant: Optional[str] = None
lichess_id: Optional[int] = None
board: Optional[str] = None
rounds: Optional[int] = None
details: Optional[str] = None
_excluded_fields = {
"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
def player_log(self) -> str:
if self.players:
return ", ".join(
[
BoardGameScoreLogData(**player).__str__()
for player in self.players
]
)
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):
name = models.CharField(max_length=255)
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
logo = models.ImageField(upload_to="games/platform-logos/", **BNULL)
igdb_id = models.IntegerField(**BNULL)
bgg_id = models.IntegerField(**BNULL)
def __str__(self):
return self.name
@ -34,6 +139,39 @@ class BoardGamePublisher(TimeStampedModel):
)
class BoardGameDesigner(TimeStampedModel):
name = models.CharField(max_length=255)
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
bgg_id = models.IntegerField(**BNULL)
bio = models.TextField(**BNULL)
def __str__(self) -> str:
return str(self.name)
def get_absolute_url(self):
return reverse(
"boardgames:designer_detail", kwargs={"slug": self.uuid}
)
class BoardGameLocation(TimeStampedModel):
name = models.CharField(max_length=255)
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
bgstats_id = models.UUIDField(**BNULL)
description = models.TextField(**BNULL)
geo_location = models.ForeignKey(
GeoLocation, **BNULL, on_delete=models.DO_NOTHING
)
def __str__(self) -> str:
return str(self.name)
def get_absolute_url(self):
return reverse(
"boardgames:location_detail", kwargs={"slug": self.uuid}
)
class BoardGame(ScrobblableMixin):
COMPLETION_PERCENT = getattr(
settings, "BOARD_GAME_COMPLETION_PERCENT", 100
@ -53,6 +191,10 @@ class BoardGame(ScrobblableMixin):
publisher = models.ForeignKey(
BoardGamePublisher, **BNULL, on_delete=models.DO_NOTHING
)
designers = models.ManyToManyField(
BoardGameDesigner,
related_name="board_games",
)
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
description = models.TextField(**BNULL)
cover = models.ImageField(upload_to="boardgames/covers/", **BNULL)
@ -85,8 +227,19 @@ class BoardGame(ScrobblableMixin):
max_players = models.PositiveSmallIntegerField(**BNULL)
min_players = models.PositiveSmallIntegerField(**BNULL)
published_date = models.DateField(**BNULL)
published_year = models.IntegerField(**BNULL)
recommended_age = models.PositiveSmallIntegerField(**BNULL)
bggeek_id = models.CharField(max_length=255, **BNULL)
bgstats_id = models.UUIDField(**BNULL)
uses_teams = models.BooleanField(default=False, **BNULL)
cooperative = models.BooleanField(default=False, **BNULL)
highest_wins = models.BooleanField(default=True, **BNULL)
no_points = models.BooleanField(default=False, **BNULL)
min_play_time = models.IntegerField(**BNULL)
max_play_time = models.IntegerField(**BNULL)
expansion_for_boardgame = models.ForeignKey(
"self", **BNULL, on_delete=models.DO_NOTHING
)
def __str__(self):
return self.title
@ -128,7 +281,7 @@ class BoardGame(ScrobblableMixin):
publisher_name = data.pop("publisher_name")
if year:
data["published_date"] = datetime(int(year), 1, 1)
data["published_year"] = int(year)
if not data["min_players"]:
data.pop("min_players")

View File

@ -3,7 +3,7 @@ from boardgames.models import BoardGame
from django.conf import settings
from django.contrib.auth import get_user_model
from scrobbles.models import Scrobble
from scrobbles.notifications import NtfyNotification
from scrobbles.notifications import ScrobbleNtfyNotification
User = get_user_model()
@ -124,5 +124,5 @@ def import_chess_games_for_all_users():
if scrobbles_to_create:
created = Scrobble.objects.bulk_create(scrobbles_to_create)
for scrobble in created:
NtfyNotification(scrobble).send()
ScrobbleNtfyNotification(scrobble).send()
return scrobbles_to_create

View File

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

View File

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

View File

@ -3,14 +3,14 @@ import re
import sqlite3
from datetime import datetime, timedelta
from enum import Enum
from zoneinfo import ZoneInfo
import pytz
import requests
from books.constants import BOOKS_TITLES_TO_IGNORE
from django.apps import apps
from django.contrib.auth import get_user_model
from scrobbles.notifications import ScrobbleNtfyNotification
from stream_sqlite import stream_sqlite
from scrobbles.notifications import NtfyNotification
from webdav.client import get_webdav_client
logger = logging.getLogger(__name__)
@ -278,53 +278,17 @@ def build_scrobbles_from_book_map(
)
continue
timezone = user.profile.timezone
timestamp = user.profile.get_timestamp_with_tz(
datetime.fromtimestamp(int(first_page.get("start_ts")))
)
stop_timestamp = user.profile.get_timestamp_with_tz(
datetime.fromtimestamp(int(last_page.get("end_ts")))
)
timestamp = datetime.fromtimestamp(
int(first_page.get("start_ts"))
).replace(tzinfo=pytz.timezone(timezone))
# Add a shim here temporarily to fix imports while we were in France
# if date is between 10/15 and 12/15, cast it to Europe/Central
if (
datetime(2023, 10, 15).replace(
tzinfo=pytz.timezone("Europe/Paris")
)
<= timestamp
<= datetime(2023, 12, 15).replace(
tzinfo=pytz.timezone("Europe/Paris")
)
):
timezone = "Europe/Paris"
if (
datetime(2024, 4, 28).replace(
tzinfo=pytz.timezone("US/Pacific")
)
<= timestamp
<= datetime(2024, 5, 4).replace(
tzinfo=pytz.timezone("US/Pacific")
)
):
timezone = "US/Pacific"
if (
datetime(2024, 8, 4).replace(
tzinfo=pytz.timezone("Canada/Atlantic")
)
<= timestamp
<= datetime(2024, 8, 10).replace(
tzinfo=pytz.timezone("Canada/Atlantic")
)
):
timezone = "Canada/Atlantic"
stop_timestamp = datetime.fromtimestamp(
int(last_page.get("end_ts"))
).replace(tzinfo=pytz.timezone(timezone))
if (
timestamp.tzinfo._dst.seconds == 0
or stop_timestamp.tzinfo._dst.seconds == 0
):
# Adjust for Daylight Saving Time
if timestamp.dst() == timedelta(
0
) or stop_timestamp.dst() == timedelta(0):
timestamp = timestamp - timedelta(hours=1)
stop_timestamp = stop_timestamp - timedelta(hours=1)
@ -356,7 +320,7 @@ def build_scrobbles_from_book_map(
in_progress=False,
played_to_completion=True,
long_play_complete=False,
timezone=timezone,
timezone=timestamp.tzinfo.name,
)
)
# Then start over
@ -398,9 +362,9 @@ def process_koreader_sqlite_file(file_path, user_id) -> list:
new_scrobbles = []
user = User.objects.filter(id=user_id).first()
tz = pytz.utc
tz = ZoneInfo("UTC")
if user:
tz = user.profile.timezone
tz = user.profile.tzinfo
is_os_file = "https://" not in file_path
if is_os_file:
@ -443,7 +407,7 @@ def process_koreader_sqlite_file(file_path, user_id) -> list:
if new_scrobbles:
created = Scrobble.objects.bulk_create(new_scrobbles)
if created:
NtfyNotification(created[-1]).send()
ScrobbleNtfyNotification(created[-1]).send()
fix_long_play_stats_for_scrobbles(created)
logger.info(
f"Created {len(created)} scrobbles",

View File

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

View File

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

View File

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

View File

@ -1,13 +1,19 @@
from collections import OrderedDict
import logging
from datetime import timedelta, datetime
from collections import OrderedDict
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
from uuid import uuid4
import requests
from books.constants import READCOMICSONLINE_URL
from books.openlibrary import (
lookup_author_from_openlibrary,
lookup_book_from_openlibrary,
)
from books.sources.google import lookup_book_from_google
from books.sources.semantic import lookup_paper_from_semantic
from books.utils import get_comic_issue_url
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.files.base import ContentFile
@ -16,28 +22,25 @@ from django.urls import reverse
from django_extensions.db.models import TimeStampedModel
from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit
from scrobbles.dataclasses import BaseLogData, LongPlayLogData
from scrobbles.mixins import (
LongPlayScrobblableMixin,
ObjectWithGenres,
ScrobblableConstants,
ScrobblableMixin,
)
from scrobbles.utils import get_scrobbles_for_media
from scrobbles.utils import get_scrobbles_for_media, next_url_if_exists
from taggit.managers import TaggableManager
from thefuzz import fuzz
from vrobbler.apps.books.comicvine import (
ComicVineClient,
lookup_comic_from_comicvine,
)
from vrobbler.apps.books.locg import (
lookup_comic_by_locg_slug,
lookup_comic_from_locg,
lookup_comic_writer_by_locg_slug,
)
from vrobbler.apps.books.sources.google import lookup_book_from_google
from vrobbler.apps.books.sources.semantic import lookup_paper_from_semantic
from vrobbler.apps.scrobbles.dataclasses import BookLogData
from vrobbler.apps.books.sources.comicvine import (
ComicVineClient,
lookup_comic_from_comicvine,
)
COMICVINE_API_KEY = getattr(settings, "COMICVINE_API_KEY", "")
@ -46,6 +49,34 @@ User = get_user_model()
BNULL = {"blank": True, "null": True}
@dataclass
class BookPageLogData(BaseLogData):
page_number: Optional[int] = None
end_ts: Optional[int] = None
start_ts: Optional[int] = None
duration: Optional[int] = None
@dataclass
class BookLogData(BaseLogData, LongPlayLogData):
koreader_hash: Optional[str] = None
page_data: Optional[dict[int, BookPageLogData]] = None
pages_read: Optional[int] = None
page_start: Optional[int] = None
page_end: Optional[int] = None
resume_url: Optional[str] = None
_excluded_fields = {"koreader_hash", "page_data"}
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):
name = models.CharField(max_length=255)
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
@ -107,6 +138,7 @@ class Book(LongPlayScrobblableMixin):
)
title = models.CharField(max_length=255)
original_title = models.CharField(max_length=255, **BNULL)
authors = models.ManyToManyField(Author, blank=True)
koreader_data_by_hash = models.JSONField(**BNULL)
isbn_13 = models.CharField(max_length=255, **BNULL)
@ -117,6 +149,13 @@ class Book(LongPlayScrobblableMixin):
publish_date = models.DateField(**BNULL)
publisher = models.CharField(max_length=255, **BNULL)
first_sentence = models.TextField(**BNULL)
# ComicVine
comicvine_id = models.CharField(max_length=255, **BNULL)
readcomics_url = models.CharField(max_length=255, **BNULL)
next_readcomics_url = models.CharField(max_length=255, **BNULL)
issue_number = models.IntegerField(max_length=5, **BNULL)
volume_number = models.IntegerField(max_length=5, **BNULL)
# OpenLibrary
openlibrary_id = models.CharField(max_length=255, **BNULL)
cover = models.ImageField(upload_to="books/covers/", **BNULL)
cover_small = ImageSpecField(
@ -135,7 +174,11 @@ class Book(LongPlayScrobblableMixin):
genre = TaggableManager(through=ObjectWithGenres)
def __str__(self):
def __str__(self) -> str:
if self.issue_number and "Issue" not in str(self.title):
return f"{self.title} - Issue {self.issue_number}"
if self.volume_number and "Volume" not in str(self.title):
return f"{self.title} - Volume {self.volume_number}"
return f"{self.title}"
@property
@ -161,44 +204,109 @@ class Book(LongPlayScrobblableMixin):
return reverse("books:book_detail", kwargs={"slug": self.uuid})
@classmethod
def get_from_google(cls, title: str, overwrite: bool = False):
def get_from_comicvine(cls, title: str, overwrite: bool = False, force_new: bool =False) -> "Book":
book, created = cls.objects.get_or_create(title=title)
if not created and not overwrite:
if not created:
return book
book_dict = lookup_book_from_google(title)
book_dict = lookup_comic_from_comicvine(title)
if created or overwrite:
author_list = []
authors = book_dict.pop("authors")
cover_url = book_dict.pop("cover_url")
try:
genres = book_dict.pop("generes")
except:
genres = []
if authors:
for author_str in authors:
if author_str:
author_dicts = book_dict.pop("author_dicts")
if author_dicts:
for author_dict in author_dicts:
if author_dict.get("authorId"):
author, a_created = Author.objects.get_or_create(
name=author_str
semantic_id=author_dict.get("authorId")
)
author_list.append(author)
if a_created:
# TODO enrich author
author.name = author_dict.get("name")
author.save()
# TODO enrich author?
...
for k, v in book_dict.items():
setattr(book, k, v)
book.save()
if author_list:
book.authors.add(*author_list)
genres = book_dict.pop("genres", [])
if genres:
book.genre.add(*genres)
return book
@classmethod
def find_or_create(
cls, title: str, url: str = "", enrich: bool = False, commit: bool = True
):
"""Given a title, get a Book instance.
If the book is not already in our database, or overwrite is True,
this method will enrich the Book with data from Google.
By default this method will also save the data back to the model. If you'd
like to batch create, use commit=False and you'll get an unsaved but enriched
instance back which you can then save at your convenience."""
# TODO use either a Google Books id identifier or author name like for tracks
book, created = cls.objects.get_or_create(original_title=title)
if not created:
logger.info(
"Found exact match for book by title", extra={"title": title}
)
if not enrich:
logger.info(
"Found book by title, but not enriching",
extra={"title": title},
)
return book
book_dict = None
if READCOMICSONLINE_URL in url:
book_dict = lookup_comic_from_comicvine(title)
book_dict["readcomics_url"] = get_comic_issue_url(url)
book_dict["next_readcomics_url"] = next_url_if_exists(book_dict["readcomics_url"])
if not book_dict:
book_dict = lookup_book_from_google(title)
if not book_dict:
logger.warning("No book found in any source, using data as is", extra={"title": title})
author_list = []
authors = book_dict.pop("authors", [])
cover_url = book_dict.pop("cover_url", "")
genres = book_dict.pop("generes", [])
if authors:
for author_str in authors:
if author_str:
author, a_created = Author.objects.get_or_create(
name=author_str
)
author_list.append(author)
if a_created:
# TODO enrich author
...
for k, v in book_dict.items():
setattr(book, k, v)
if commit:
book.save()
book.save_image_from_url(cover_url)
book.genre.add(*genres)
book.authors.add(*author_list)
return book
def save_image_from_url(self, url: str, force_update: bool = False):
if not self.cover or (force_update and url):
if url and (not self.cover or force_update):
r = requests.get(url)
if r.status_code == 200:
fname = f"{self.title}_{self.uuid}.jpg"
@ -378,27 +486,6 @@ class Book(LongPlayScrobblableMixin):
progress = int((last_scrobble.last_page_read / self.pages) * 100)
return progress
@classmethod
def find_or_create(cls, lookup_id: str, author: str = "") -> "Book":
book = cls.objects.filter(openlibrary_id=lookup_id).first()
if not book:
data = lookup_book_from_openlibrary(lookup_id, author)
if not data:
logger.error(
f"No book found on openlibrary, or in our database for {lookup_id}"
)
return book
book, book_created = cls.objects.get_or_create(
isbn_13=data["isbn"]
)
if book_created:
book.fix_metadata(data=data)
return book
class Paper(LongPlayScrobblableMixin):
"""Keeps track of Academic Papers"""

View File

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

View File

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

View File

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

View File

@ -1,15 +1,25 @@
from django.apps import apps
from dataclasses import dataclass
from django.db import models
from django.urls import reverse
from django_extensions.db.models import TimeStampedModel
from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit
from scrobbles.dataclasses import BrickSetLogData
from scrobbles.mixins import LongPlayScrobblableMixin
from vrobbler.apps.scrobbles.dataclasses import (
BaseLogData,
LongPlayLogData,
WithPeopleLogData,
)
BNULL = {"blank": True, "null": True}
@dataclass
class BrickSetLogData(BaseLogData, WithPeopleLogData, LongPlayLogData):
pass
class BrickSet(LongPlayScrobblableMixin):
""""""

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

@ -1,3 +1,5 @@
from dataclasses import dataclass
from typing import Optional
from uuid import uuid4
from django.apps import apps
@ -6,12 +8,19 @@ from django.urls import reverse
from django_extensions.db.models import TimeStampedModel
from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit
from scrobbles.dataclasses import FoodLogData
from scrobbles.dataclasses import BaseLogData, WithPeopleLogData
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
BNULL = {"blank": True, "null": True}
@dataclass
class FoodLogData(BaseLogData, WithPeopleLogData):
calories: Optional[int] = None
meal: Optional[str] = None
rating: Optional[str] = None
class FoodCategory(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
name = models.CharField(max_length=255)
@ -40,6 +49,7 @@ class FoodCategory(TimeStampedModel):
class Food(ScrobblableMixin):
description = models.TextField(**BNULL)
calories = models.IntegerField(**BNULL)
allrecipe_image = models.ImageField(upload_to="food/recipe/", **BNULL)
allrecipe_image_small = ImageSpecField(
source="allrecipe_image",
@ -64,7 +74,8 @@ class Food(ScrobblableMixin):
@property
def subtitle(self):
return self.category.name
if self.category:
return self.category.name
@property
def strings(self) -> ScrobblableConstants:

View File

@ -1,12 +1,18 @@
from dataclasses import dataclass
from django.apps import apps
from django.db import models
from django.urls import reverse
from scrobbles.dataclasses import LifeEventLogData
from scrobbles.dataclasses import BaseLogData, WithPeopleLogData
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
BNULL = {"blank": True, "null": True}
@dataclass
class LifeEventLogData(BaseLogData, WithPeopleLogData):
pass
class LifeEvent(ScrobblableMixin):
description = models.TextField(**BNULL)

View File

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

View File

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

View File

@ -1,19 +1,25 @@
import logging
from dataclasses import dataclass
from typing import Optional
from django.contrib.auth import get_user_model
from django.db import models
from django.urls import reverse
from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit
from scrobbles.dataclasses import BaseLogData
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
from vrobbler.apps.scrobbles.dataclasses import MoodLogData
logger = logging.getLogger(__name__)
BNULL = {"blank": True, "null": True}
User = get_user_model()
@dataclass
class MoodLogData(BaseLogData):
reasons: Optional[str] = None
class Mood(ScrobblableMixin):
description = models.TextField(**BNULL)
image = models.ImageField(upload_to="moods/", **BNULL)
@ -36,7 +42,7 @@ class Mood(ScrobblableMixin):
return str(self.uuid)
def get_absolute_url(self):
return reverse("moods:mood-detail", kwargs={"slug": self.uuid})
return reverse("moods:mood_detail", kwargs={"slug": self.uuid})
@property
def subtitle(self) -> str:

View File

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

View File

@ -50,17 +50,17 @@ class TrackAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"title",
"album",
"primary_album",
"artist",
"musicbrainz_id",
)
raw_id_fields = (
"album",
"artist",
)
raw_id_fields = ("artist", "albums", "album")
list_filter = ("album", "artist")
search_fields = ("title",)
ordering = ("-created",)
filter_horizontal = [
"albums",
]
inlines = [
ScrobbleInline,
]

View File

@ -1,162 +0,0 @@
import logging
from datetime import datetime, timedelta
import pylast
import pytz
from django.conf import settings
from music.models import Track
logger = logging.getLogger(__name__)
PYLAST_ERRORS = tuple(
getattr(pylast, exc_name)
for exc_name in (
"ScrobblingError",
"NetworkError",
"MalformedResponseError",
"WSError",
)
if hasattr(pylast, exc_name)
)
class LastFM:
def __init__(self, user):
try:
self.client = pylast.LastFMNetwork(
api_key=getattr(settings, "LASTFM_API_KEY"),
api_secret=getattr(settings, "LASTFM_SECRET_KEY"),
username=user.profile.lastfm_username,
password_hash=pylast.md5(user.profile.lastfm_password),
)
self.user = self.client.get_user(user.profile.lastfm_username)
self.vrobbler_user = user
except PYLAST_ERRORS as e:
logger.error(f"Error during Last.fm setup: {e}")
def import_from_lastfm(self, last_processed=None):
"""Given a last processed time, import all scrobbles from LastFM since then"""
from scrobbles.models import Scrobble
new_scrobbles = []
source = "Last.fm"
lastfm_scrobbles = self.get_last_scrobbles(time_from=last_processed)
for lfm_scrobble in lastfm_scrobbles:
track = Track.find_or_create(
title=lfm_scrobble.get("title"),
artist_name=lfm_scrobble.get("artist"),
album_name=lfm_scrobble.get("album"),
)
timezone = settings.TIME_ZONE
if self.vrobbler_user.profile:
timezone = self.vrobbler_user.profile.timezone
timestamp = lfm_scrobble.get("timestamp")
new_scrobble = Scrobble(
user=self.vrobbler_user,
timestamp=timestamp,
source=source,
track=track,
timezone=timezone,
played_to_completion=True,
in_progress=False,
media_type=Scrobble.MediaType.TRACK,
)
# Vrobbler scrobbles on finish, LastFM scrobbles on start
seconds_eariler = timestamp - timedelta(seconds=20)
seconds_later = timestamp + timedelta(seconds=20)
existing = Scrobble.objects.filter(
created__gte=seconds_eariler,
created__lte=seconds_later,
track=track,
).first()
if existing:
logger.debug(f"Skipping existing scrobble {new_scrobble}")
continue
logger.debug(f"Queued scrobble {new_scrobble} for creation")
new_scrobbles.append(new_scrobble)
created = Scrobble.objects.bulk_create(new_scrobbles)
# TODO Add a notification for users that their import is complete
logger.info(
f"Last.fm import fnished",
extra={
"scrobbles_created": len(created),
"user_id": self.vrobbler_user,
"lastfm_user": self.user,
},
)
return created
def get_last_scrobbles(self, time_from=None, time_to=None):
"""Given a user, Last.fm api key, and secret key, grab a list of scrobbled
tracks"""
lfm_params = {}
scrobbles = []
if time_from:
lfm_params["time_from"] = int(time_from.timestamp())
if time_to:
lfm_params["time_to"] = int(time_to.timestamp())
# if not time_from and not time_to:
lfm_params["limit"] = None
found_scrobbles = self.user.get_recent_tracks(**lfm_params)
# TOOD spin this out into a celery task over certain threshold of found scrobbles?
for scrobble in found_scrobbles:
logger.debug(f"Processing {scrobble}")
run_time = None
mbid = None
artist = None
log_dict = {"scrobble": scrobble}
try:
run_time = int(scrobble.track.get_duration() / 1000)
mbid = scrobble.track.get_mbid()
artist = scrobble.track.get_artist().name
log_dict["artist"] = artist
log_dict["mbid"] = mbid
log_dict["run_time"] = run_time
except pylast.MalformedResponseError as e:
logger.warning(e)
except pylast.WSError as e:
logger.info(
"LastFM barfed trying to get the track for {scrobble.track}",
extra=log_dict,
)
except pylast.NetworkError as e:
logger.info(
"LastFM barfed trying to get the track for {scrobble.track}",
extra=log_dict,
)
if not artist:
logger.info(
f"Silly LastFM, no artist found for scrobble",
extra=log_dict,
)
continue
# TODO figure out if this will actually work
# timestamp = datetime.fromtimestamp(int(scrobble.timestamp), UTC)
timestamp = datetime.utcfromtimestamp(
int(scrobble.timestamp)
).replace(tzinfo=pytz.utc)
logger.info(
f"Scrobble appended to list for bulk create", extra=log_dict
)
scrobbles.append(
{
"artist": artist,
"album": scrobble.album,
"title": scrobble.track.title,
"mbid": mbid,
"run_time_seconds": run_time,
"timestamp": timestamp,
}
)
return scrobbles

View File

@ -0,0 +1,20 @@
# Generated by Django 4.2.19 on 2025-07-20 20:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("music", "0026_album_alt_names"),
]
operations = [
migrations.AddField(
model_name="track",
name="albums",
field=models.ManyToManyField(
blank=True, null=True, related_name="tracks", to="music.album"
),
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 4.2.19 on 2025-07-25 14:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("music", "0027_track_albums"),
]
operations = [
migrations.AlterField(
model_name="track",
name="albums",
field=models.ManyToManyField(
related_name="tracks", to="music.album"
),
),
]

View File

@ -1,5 +1,6 @@
import logging
from typing import Dict, Optional
from dataclasses import dataclass
from typing import Optional
from uuid import uuid4
import musicbrainzngs
@ -14,14 +15,27 @@ from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit
from music.allmusic import get_allmusic_slug, scrape_data_from_allmusic
from music.bandcamp import get_bandcamp_slug
from music.musicbrainz import lookup_album_dict_from_mb, lookup_track_from_mb
from music.musicbrainz import (
get_album_metadata_with_artist,
get_recording_mbid_exact,
get_track_metadata_with_artist,
)
from music.theaudiodb import lookup_album_from_tadb, lookup_artist_from_tadb
from music.utils import clean_artist_name
from scrobbles.dataclasses import BaseLogData
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
logger = logging.getLogger(__name__)
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):
"""Represents a music artist.
@ -161,7 +175,7 @@ class Artist(TimeStampedModel):
@property
def rym_link(self):
artist_slug = self.name.lower().replace(" ", "-")
artist_slug = self.name.lower().replace(" ", "-").replace(",", "")
return f"https://rateyourmusic.com/artist/{artist_slug}/"
@property
@ -170,58 +184,76 @@ class Artist(TimeStampedModel):
return f"https://bandcamp.com/search?q={artist}&item_type=b"
@classmethod
def find_or_create(cls, name: str, musicbrainz_id: str = "") -> "Artist":
from music.musicbrainz import lookup_artist_from_mb
from music.utils import clean_artist_name
def find_or_create(
cls, name: str, album_name: str = "", track_name: str = ""
) -> "Artist":
"""The biggest challenge to finding artists is that the search often
fails miserably unless you can look it up along with an album or a track name.
if not name:
raise Exception("Must have name to lookup artist")
Thus, when we find or create an artist, we should always provide an optional
album name or track name, but probably not both."""
if album_name:
logger.info(
f"Looking for artist with name {name} and album {album_name}"
)
if track_name:
logger.info(
f"Looking for artist with name {name} and track {track_name}"
)
keys = {}
artist = None
name = clean_artist_name(name)
keys["name"] = name
artist = cls.objects.filter(name=name).first()
# Check for name/mbid combo, just mbid and then just name
if musicbrainz_id:
artist = cls.objects.filter(
name=name, musicbrainz_id=musicbrainz_id
).first()
if not artist:
artist = cls.objects.filter(musicbrainz_id=musicbrainz_id).first()
if not artist:
artist = cls.objects.filter(
models.Q(name=name) | models.Q(alt_names__icontains=name)
).first()
if artist:
return artist
# Does not exist, look it up from Musicbrainz
if not artist:
alt_name = None
try:
artist_dict = lookup_artist_from_mb(name)
musicbrainz_id = musicbrainz_id or artist_dict.get("id", "")
if name != artist_dict.get("name", ""):
alt_name = name
name = artist_dict.get("name", "")
except ValueError:
pass
# alt_name = None
artist_dict = {}
if album_name:
album_dict = get_album_metadata_with_artist(album_name, name)
if album_dict:
artist_dict = album_dict.get("primary_artist")
if track_name:
track_dict = get_track_metadata_with_artist(track_name, name)
if track_dict:
artist_dict = track_dict.get("primary_artist")
if musicbrainz_id:
artist = cls.objects.filter(
musicbrainz_id=musicbrainz_id
).first()
if artist and alt_name:
if not artist.alt_names:
artist.alt_names = alt_name
else:
artist.alt_names += f"\\{alt_name}"
artist.save(update_fields=["alt_names"])
if not artist_dict:
artist, created = cls.objects.get_or_create(name=name)
if created:
artist.fix_metadata()
return artist
musicbrainz_id = artist_dict.get("mbid")
found_name = artist_dict.get("name", name)
if found_name and name != found_name:
alt_name = found_name
artist = cls.objects.filter(
name=found_name, musicbrainz_id=musicbrainz_id
).first()
if not artist:
artist = cls.objects.create(
name=name, musicbrainz_id=musicbrainz_id, alt_names=alt_name
name=found_name,
musicbrainz_id=musicbrainz_id,
)
# TODO maybe this should be spun off into an async task?
artist.fix_metadata()
# TODO: See if this alt_names stuff actually works or causes hard to debug problems
# If we did find our artist, but the found name is slightly differnt, record that
# if artist and alt_name:
# if not artist.alt_names:
# artist.alt_names = alt_name
# else:
# artist.alt_names += f"\\{alt_name}"
# logger.info(
# f"Add alt_name {alt_name} to artist {artist}",
# extra={"alt_name": alt_name, "artist_id": artist.id},
# )
# artist.save(update_fields=["alt_names"])
return artist
@ -314,7 +346,7 @@ class Album(TimeStampedModel):
)
return
if not self.allmusic_id or force:
if self.album_artist and (not self.allmusic_id or force):
slug = get_allmusic_slug(self.album_artist.name, self.name)
if not slug:
logger.info(
@ -345,7 +377,12 @@ class Album(TimeStampedModel):
logger.info(f"No data for {self} found in TheAudioDB")
return
Album.objects.filter(pk=self.pk).update(**album_data)
try:
Album.objects.filter(pk=self.pk).update(**album_data)
except:
logger.info(
f"Could not save info for album {self} with data {album_data}"
)
def scrape_bandcamp(self, force=False) -> None:
if not self.bandcamp_id or force:
@ -484,65 +521,77 @@ class Album(TimeStampedModel):
return f"https://bandcamp.com/search?q={album} {artist}&item_type=a"
@classmethod
def find_or_create(
cls, name: str, artist_name: str, musicbrainz_id: str = ""
) -> "Album":
if not name or not artist_name:
raise Exception(
"Must have at least name and artist name to lookup album"
def find_or_create(cls, name: str, artist_name: str) -> "Album":
logger.info(
f"Looking for album with name {name} and artist_name {artist_name}"
)
artist = Artist.find_or_create(artist_name, album_name=name)
album_dict = get_album_metadata_with_artist(name, artist.name)
if not album_dict:
logger.info(
f"Could not find album {name} with artist {artist.name} on musicbrainz"
)
album = None
if musicbrainz_id:
album = cls.objects.filter(
musicbrainz_id=musicbrainz_id,
name=name,
album_artist__name=artist_name,
).first()
if not album and musicbrainz_id:
album = cls.objects.filter(
musicbrainz_id=musicbrainz_id,
).first()
if not album:
album = cls.objects.filter(
models.Q(name=name) | models.Q(alt_names__icontains=name),
album_artist__name=artist_name,
).first()
if not album:
alt_name = None
try:
album_dict = lookup_album_dict_from_mb(
name, artist_name=artist_name
)
musicbrainz_id = musicbrainz_id or album_dict.get("mb_id", "")
found_name = album_dict.get("title", "")
if found_name and name != found_name:
alt_name = name
name = found_name
except ValueError:
pass
if musicbrainz_id:
album = cls.objects.filter(
musicbrainz_id=musicbrainz_id
).first()
if album and alt_name:
if not album.alt_names:
album.alt_names = alt_name
else:
album.alt_names += f"\\{alt_name}"
album.save(update_fields=["alt_names"])
album = Album.objects.filter(name=name).first()
if not album:
artist = Artist.find_or_create(name=artist_name)
album = cls.objects.create(
album, created = Album.objects.get_or_create(
name=name,
album_artist=artist,
musicbrainz_id=musicbrainz_id,
alt_names=alt_name,
)
# TODO maybe do this in a separate process?
album.fix_metadata()
if created:
# album.fix_metadata()
# album.fetch_artwork()
...
return album
if not artist:
artist_dict = album_dict.get("primary_artist", {})
if artist_dict:
artist = Artist.objects.filter(
musicbrainz_id=artist_dict.get("mbid"),
).first()
if not artist:
artist = Artist.objects.create(
musicbrainz_id=artist_dict.get("mbid"),
)
extra_artists = []
if not artist and len(album_dict.get("all_artists")) > 1:
artist = Artist.objects.filter(name="Various Artists").first()
extra_artists.append(artist)
if not artist:
raise Exception("No album artist found, and not a compliation")
album = cls.objects.filter(
models.Q(name=name) | models.Q(alt_names__icontains=name),
album_artist=artist,
).first()
alt_name = None
found_name = album_dict.get("album_title", name)
if found_name and name != found_name:
alt_name = name
album = Album.objects.filter(
musicbrainz_id=album_dict.get("mbid")
).first()
if not album:
year = None
if album_dict.get("release_date"):
year = album_dict.get("release_date", "").split("-")[0]
album = Album.objects.create(
name=found_name,
musicbrainz_id=album_dict.get("mbid"),
musicbrainz_releasegroup_id=album_dict.get(
"release_group_mbid"
),
year=year,
album_artist=artist,
alt_names=alt_name,
)
album.artists.add(*extra_artists)
album.fetch_artwork()
return album
@ -550,12 +599,8 @@ class Album(TimeStampedModel):
class Track(ScrobblableMixin):
COMPLETION_PERCENT = getattr(settings, "MUSIC_COMPLETION_PERCENT", 100)
class Opinion(models.IntegerChoices):
DOWN = -1, "Thumbs down"
NEUTRAL = 0, "No opinion"
UP = 1, "Thumbs up"
artist = models.ForeignKey(Artist, on_delete=models.DO_NOTHING)
albums = models.ManyToManyField(Album, related_name="tracks")
album = models.ForeignKey(Album, on_delete=models.DO_NOTHING, **BNULL)
musicbrainz_id = models.CharField(max_length=255, **BNULL)
@ -565,6 +610,15 @@ class Track(ScrobblableMixin):
def __str__(self):
return f"{self.title} by {self.artist}"
def logdata_cls(self):
return TrackLogData
@property
def primary_album(self):
if self.album:
return self.album
return self.albums.order_by("year").first()
def get_absolute_url(self):
return reverse("music:track_detail", kwargs={"slug": self.uuid})
@ -589,91 +643,85 @@ class Track(ScrobblableMixin):
url = ""
if self.artist.thumbnail:
url = self.artist.thumbnail_medium.url
if self.album and self.album.cover_image:
url = self.album.cover_image_medium.url
if self.primary_album and self.primary_album.cover_image:
url = self.primary_album.cover_image_medium.url
return url
@classmethod
def find_or_create(
cls,
title: str = "",
musicbrainz_id: str = "",
album_name: str = "",
artist_name: str = "",
enrich: bool = True,
run_time_seconds: Optional[int] = None,
album_name: str = "",
run_time_seconds: int | None = None,
enrich: bool = False,
commit: bool = True,
) -> "Track":
# TODO we can use Q to build queries here based on whether we have mbid and album name
track = None
# Full look up with MB ID
"""Given a name, try to find the track by the artist from Musicbrainz.
As a basic conceit we trust the source for giving us the track and artist
name
Optionally, we can update any found artists with overwrite."""
album = None
if album_name:
track = cls.objects.filter(
musicbrainz_id=musicbrainz_id,
title=title,
artist__name=artist_name,
album__name=album_name,
).first()
# Full look up without album
if not track:
track = cls.objects.filter(
musicbrainz_id=musicbrainz_id,
title=title,
artist__name=artist_name,
).first()
logger.info("Looking up album for: {album_name}")
album = Album.find_or_create(
name=album_name, artist_name=artist_name
)
artist = album.album_artist
else:
artist = Artist.find_or_create(artist_name, track_name=title)
if not artist:
artist = Artist.find_or_create(artist_name)
# Full look up without MB ID
if not track:
track = cls.objects.filter(
title=title,
artist__name=artist_name,
album__name=album_name,
).first()
# Base look up without MB ID or album
if not track:
track = cls.objects.filter(
title=title,
artist__name=artist_name,
).first()
lookup_keys = {"title": title, "artist": artist}
if run_time_seconds:
lookup_keys["run_time_seconds"] = run_time_seconds
logger.info(f"Looking up track using: {lookup_keys}")
track = cls.objects.filter(**lookup_keys).first()
if track:
logger.info(
"Found match for track by name and artist, not going to musicbrainz ",
extra={
"track_id": track.id,
"title": title,
"artist_name": artist_name,
"run_time_seconds": run_time_seconds,
},
)
return track
if not track and enrich:
track_dict = lookup_track_from_mb(title, artist_name, album_name)
musicbrainz_id = musicbrainz_id or track_dict.get("id", "")
# TODO This only works some of the time
# try:
# album_name = album_name or track_dict.get("release-list")[
# 0
# ].get("title", "")
# except IndexError:
# pass
if not run_time_seconds:
run_time_seconds = int(
int(track_dict.get("length", 900000)) / 1000
track = cls.objects.filter(title=title, artist=artist).first()
if not track:
track, _ = cls.objects.get_or_create(title=title, artist=artist)
if album:
track.albums.add(album)
if enrich or not track.run_time_seconds:
logger.info(
f"Enriching track {track}",
extra={
"title": title,
"artist_name": artist_name,
"track_id": track.id,
},
)
try:
mbid, length = get_recording_mbid_exact(
title, artist_name, album_name
)
if title != track_dict.get("name", "") and track_dict.get(
"name", False
):
title = track_dict.get("name", "")
if musicbrainz_id:
track = cls.objects.filter(
musicbrainz_id=musicbrainz_id
).first()
if not track:
artist = Artist.find_or_create(name=artist_name)
album = None
if album_name:
album = Album.find_or_create(
name=album_name, artist_name=artist_name
)
track = cls.objects.create(
title=title,
album=album,
musicbrainz_id=musicbrainz_id,
artist=artist,
run_time_seconds=run_time_seconds,
)
# TODO maybe do this in a separate process?
track.fix_metadata()
except Exception:
print("No musicbrainz result found, cannot enrich")
return track
track.run_time_seconds = run_time_seconds or int(length / 1000)
track.musicbrainz_id = mbid
if commit:
track.save()
return track
def fix_metadata(self, force_update=False):
...

View File

@ -1,16 +1,17 @@
from datetime import datetime
import logging
from typing import Iterable
import musicbrainzngs
from dateutil.parser import parse
logger = logging.getLogger(__name__)
musicbrainzngs.set_useragent("Vrobbler", "1.0", "help@unbl.ink")
def lookup_album_from_mb(musicbrainz_id: str) -> dict:
release_dict = {}
musicbrainzngs.set_useragent("vrobbler", "0.3.0")
release_data = musicbrainzngs.get_release_by_id(
musicbrainz_id,
includes=["artists", "release-groups", "recordings"],
@ -51,7 +52,6 @@ def lookup_album_from_mb(musicbrainz_id: str) -> dict:
def lookup_album_dict_from_mb(release_name: str, artist_name: str) -> dict:
musicbrainzngs.set_useragent("vrobbler", "0.3.0")
top_result = {}
@ -84,7 +84,6 @@ def lookup_album_dict_from_mb(release_name: str, artist_name: str) -> dict:
def lookup_artist_from_mb(artist_name: str) -> dict:
musicbrainzngs.set_useragent("vrobbler", "0.3.0")
try:
top_result = musicbrainzngs.search_artists(artist=artist_name)[
@ -104,7 +103,7 @@ def lookup_artist_from_mb(artist_name: str) -> dict:
def lookup_track_from_mb(
track_name: str, artist_mb_id: str, album_mb_id: str
track_name: str, artist_mb_id: str, album_mb_id: str = ""
) -> dict:
logger.info(
"[lookup_track_from_mb] called",
@ -114,7 +113,6 @@ def lookup_track_from_mb(
"album_mb_id": album_mb_id,
},
)
musicbrainzngs.set_useragent("vrobbler", "0.3.0")
try:
results = musicbrainzngs.search_recordings(
@ -138,3 +136,352 @@ def lookup_track_from_mb(
return {}
return top_result
def get_album_metadata(album_name, artist_name, strict=True) -> dict:
"""
Get detailed metadata for an album from MusicBrainz.
:param album_name: Name of the album
:param artist_name: Name of the artist
:param strict: If True, only exact matches on album and artist (case-insensitive)
:return: dict with album metadata, or None if not found
"""
try:
result = musicbrainzngs.search_releases(
release=album_name, artist=artist_name, limit=5
)
for release in result.get("release-list", []):
title = release["title"]
primary_artist = release["artist-credit"][0]["artist"]["name"]
title_match = title.lower() == album_name.lower()
artist_match = primary_artist.lower() == artist_name.lower()
if not strict or (title_match and artist_match):
all_artists = [
ac["artist"]["name"]
for ac in release["artist-credit"]
if isinstance(ac, dict) and "artist" in ac
]
return {
"album_title": title,
"primary_artist": primary_artist,
"all_artists": all_artists,
"mbid": release["id"],
"release_date": release.get(
"date"
), # May be partial (e.g., just year)
"release_group_mbid": release["release-group"]["id"],
}
return {}
except musicbrainzngs.WebServiceError as e:
print("MusicBrainz error:", e)
return {}
def get_recording_mbid_exact(
track_title: str, artist_name: str, album_name: str
) -> tuple[str, int]:
try:
result = musicbrainzngs.search_releases(
artist=artist_name, release=album_name, limit=1
)
releases = result.get("release-list", [])
if not releases:
raise Exception("No releases found")
release_id = releases[0]["id"]
release_data = musicbrainzngs.get_release_by_id(
release_id, includes=["recordings"]
)
tracks = release_data["release"]["medium-list"][0]["track-list"]
for track in tracks:
if track["recording"]["title"].lower() == track_title.lower():
return track["recording"]["id"], int(
track["recording"]["length"]
)
raise Exception("No recording found")
except musicbrainzngs.WebServiceError as e:
print(f"MusicBrainz error: {e}")
raise Exception(e)
def get_artist_metadata_extended(artist_name, strict=True):
"""
Fetch artist metadata including MBID, name, origin, tags, and description.
:param artist_name: The artist's name
:param strict: If True, only return exact name match
:return: dict with metadata, or None if not found
"""
try:
# Step 1: Search for artist
search_results = musicbrainzngs.search_artists(
artist=artist_name, limit=5
)
for artist in search_results.get("artist-list", []):
if not strict or artist["name"].lower() == artist_name.lower():
mbid = artist["id"]
# Step 2: Get detailed info about the artist
details = musicbrainzngs.get_artist_by_id(
mbid, includes=["tags", "url-rels"]
)["artist"]
begin_date = details.get("life-span", {}).get("begin")
area = details.get("area", {}).get("name")
disambiguation = details.get("disambiguation")
tags = [t["name"] for t in details.get("tag-list", [])]
# Step 3: Try to find a Wikipedia or Wikidata link
description_url = None
for rel in details.get("url-relation-list", []):
if rel["type"] == "wikipedia":
description_url = rel["target"]
break
elif rel["type"] == "wikidata":
description_url = rel["target"]
return {
"mbid": mbid,
"name": details["name"],
"disambiguation": disambiguation,
"begin_date": begin_date,
"area": area,
"tags": tags,
"description_url": description_url, # user can fetch summary if needed
}
return None
except musicbrainzngs.WebServiceError as e:
print("MusicBrainz error:", e)
return None
def get_artist_metadata_brief(artist_id):
"""Fetch basic artist metadata by MBID."""
try:
details = musicbrainzngs.get_artist_by_id(
artist_id, includes=["tags", "aliases", "url-rels"]
)["artist"]
begin_date = details.get("life-span", {}).get("begin")
area = details.get("area", {}).get("name")
disambiguation = details.get("disambiguation")
tags = [t["name"] for t in details.get("tag-list", [])]
description_url = None
for rel in details.get("url-relation-list", []):
if rel["type"] == "wikipedia":
description_url = rel["target"]
break
elif rel["type"] == "wikidata" and not description_url:
description_url = rel["target"]
return {
"mbid": artist_id,
"name": details["name"],
"disambiguation": disambiguation,
"begin_date": begin_date,
"area": area,
"tags": tags,
"description_url": description_url,
}
except musicbrainzngs.WebServiceError as e:
print("MusicBrainz error (artist lookup):", e)
return None
def parse_date(date_str):
"""Parse MusicBrainz date format into sortable datetime object."""
if not date_str:
return None
for fmt in ("%Y-%m-%d", "%Y-%m", "%Y"):
try:
return datetime.strptime(date_str, fmt)
except ValueError:
continue
return None
def get_album_metadata_with_artist(album_name, artist_name, strict=True):
"""
Get metadata for the earliest release of an album and its primary artist.
:param album_name: Album title
:param artist_name: Name of the artist
:param strict: If True, enforce exact match for album and artist
:return: dict with album and primary artist metadata
"""
try:
result = musicbrainzngs.search_releases(
release=album_name, artist=artist_name, limit=100
)
query_album = album_name.strip().casefold()
query_artist = artist_name.strip().casefold()
valid_releases = []
for release in result.get("release-list", []):
release_title = release["title"].strip()
primary_artist = release["artist-credit"][0]["artist"]
artist_name_actual = primary_artist["name"].strip()
if strict:
if release_title.casefold() != query_album:
continue
if artist_name_actual.casefold() != query_artist:
continue
release_date = parse_date(release.get("date"))
valid_releases.append((release, release_date))
if not valid_releases:
return None
# Sort releases by earliest release date
valid_releases.sort(key=lambda x: x[1] or datetime.max)
release, _ = valid_releases[0]
primary_artist = release["artist-credit"][0]["artist"]
all_artists = [
ac["artist"]["name"]
for ac in release["artist-credit"]
if "artist" in ac
]
artist_metadata = get_artist_metadata_brief(primary_artist["id"])
return {
"album_title": release["title"],
"primary_artist_name": primary_artist["name"],
"all_artists": all_artists,
"mbid": release["id"],
"release_group_mbid": release["release-group"]["id"],
"release_date": release.get("date"),
"primary_artist": artist_metadata,
}
except musicbrainzngs.WebServiceError as e:
print("MusicBrainz error (album lookup):", e)
return None
def get_artist_metadata_brief(artist_id):
try:
details = musicbrainzngs.get_artist_by_id(
artist_id, includes=["tags", "aliases", "url-rels"]
)["artist"]
begin_date = details.get("life-span", {}).get("begin")
area = details.get("area", {}).get("name")
disambiguation = details.get("disambiguation")
tags = [t["name"] for t in details.get("tag-list", [])]
description_url = None
for rel in details.get("url-relation-list", []):
if rel["type"] == "wikipedia":
description_url = rel["target"]
break
elif rel["type"] == "wikidata" and not description_url:
description_url = rel["target"]
return {
"mbid": artist_id,
"name": details["name"],
"disambiguation": disambiguation,
"begin_date": begin_date,
"area": area,
"tags": tags,
"description_url": description_url,
}
except musicbrainzngs.WebServiceError as e:
print("MusicBrainz error (artist lookup):", e)
return None
def get_track_metadata_with_artist(track_title, artist_name, strict=True):
"""
Get metadata for the earliest-known recording of a track, including artist info.
:param track_title: Track title
:param artist_name: Artist name
:param strict: If True, match exactly (case-insensitive)
:return: dict with track + release + artist metadata
"""
try:
result = musicbrainzngs.search_recordings(
recording=track_title, artist=artist_name, limit=100
)
query_track = track_title.strip().casefold()
query_artist = artist_name.strip().casefold()
valid_candidates = []
for recording in result.get("recording-list", []):
rec_title = recording["title"].strip()
artist_credit = recording["artist-credit"][0]["artist"]
artist_name_actual = artist_credit["name"].strip()
if strict:
if rec_title.casefold() != query_track:
continue
if artist_name_actual.casefold() != query_artist:
continue
if "release-list" not in recording:
continue
for release in recording["release-list"]:
release_date = parse_date(release.get("date"))
if release_date:
valid_candidates.append(
(recording["id"], release, release_date)
)
if not valid_candidates:
return None
# Pick the earliest release
valid_candidates.sort(key=lambda x: x[2])
recording_id, release, _ = valid_candidates[0]
# Fetch full recording info
full_recording = musicbrainzngs.get_recording_by_id(
recording_id, includes=["artists", "releases"]
)["recording"]
primary_artist = full_recording["artist-credit"][0]["artist"]
all_artists = [
ac["artist"]["name"]
for ac in full_recording["artist-credit"]
if "artist" in ac
]
artist_metadata = get_artist_metadata_brief(primary_artist["id"])
return {
"track_title": full_recording["title"],
"length_ms": full_recording.get("length"),
"recording_mbid": recording_id,
"release_title": release["title"],
"release_date": release.get("date"),
"release_group_mbid": release["release-group"]["id"],
"primary_artist_name": primary_artist["name"],
"all_artists": all_artists,
"primary_artist": artist_metadata,
}
except musicbrainzngs.WebServiceError as e:
print("MusicBrainz error (track lookup):", e)
return None

View File

@ -1,184 +1,113 @@
import logging
import re
from typing import Optional
from music.musicbrainz import (
lookup_album_dict_from_mb,
lookup_artist_from_mb,
lookup_track_from_mb,
)
from django.db import IntegrityError, models, transaction
from music.constants import VARIOUS_ARTIST_DICT
from scrobbles.utils import convert_to_seconds
logger = logging.getLogger(__name__)
from music.models import Album, Artist, Track
def clean_artist_name(name: str) -> str:
"""Remove featured names from artist string."""
if "feat." in name.lower():
name = re.split("feat.", name, flags=re.IGNORECASE)[0].strip()
if "featuring" in name.lower():
name = re.split("featuring", name, flags=re.IGNORECASE)[0].strip()
if "&" in name.lower():
name = re.split("&", name, flags=re.IGNORECASE)[0].strip()
if " feat. " in name.lower():
name = re.split(" feat. ", name, flags=re.IGNORECASE)[0].strip()
if " w. " in name.lower():
name = re.split(" w. ", name, flags=re.IGNORECASE)[0].strip()
if " featuring " in name.lower():
name = re.split(" featuring ", name, flags=re.IGNORECASE)[0].strip()
# if " & " in name.lower() and "of the wand" not in name.lower():
# name = re.split("&", name, flags=re.IGNORECASE)[0].strip()
return name
# TODO These are depreacted, remove them eventually
def get_or_create_artist(name: str, mbid: str = "") -> Artist:
"""Get an Artist object from the database.
def get_or_create_various_artists() -> "Artist":
from music.models import Artist
Check if an artist with this name or Musicbrainz ID already exists.
Otherwise, go lookup artist data from Musicbrainz and create one.
"""
artist = None
name = clean_artist_name(name)
# Check for name/mbid combo, just mbid and then just name
artist = Artist.objects.filter(name=name, mbid=mbid).first()
if not artist:
artist = Artist.objects.filter(musicbrainz_id=mbid).first()
if not artist:
artist = Artist.objects.filter(name=name).first()
# Does not exist, look it up from Musicbrainz
if not artist:
artist_dict = lookup_artist_from_mb(name)
mbid = mbid or artist_dict.get("id", "")
if mbid:
artist = Artist.objects.filter(musicbrainz_id=mbid).first()
if not artist:
artist = Artist.objects.create(name=name, musicbrainz_id=mbid)
# TODO maybe this should be spun off into an async task?
artist.fix_metadata()
return artist
# TODO These are depreacted, remove them eventually
def get_or_create_album(
name: str, artist: Artist, mbid: str = None
) -> Optional[Album]:
album = None
album_dict = lookup_album_dict_from_mb(name, artist_name=artist.name)
name = name or album_dict.get("title", None)
if not name:
logger.debug(
f"Cannot get or create album by {artist} with no name ({name})"
)
return
album = Album.objects.filter(
musicbrainz_id=mbid, name=name, artists__in=[artist]
).first()
if not album:
mbid_group = album_dict.get("mb_group_id")
album = Album.objects.filter(
musicbrainz_releasegroup_id=mbid_group
).first()
if not album and name:
mbid = mbid or album_dict["mb_id"]
album, album_created = Album.objects.get_or_create(musicbrainz_id=mbid)
if album_created:
album.name = name
album.year = album_dict["year"]
album.musicbrainz_releasegroup_id = album_dict["mb_group_id"]
album.musicbrainz_albumartist_id = artist.musicbrainz_id
album.save(
update_fields=[
"name",
"musicbrainz_id",
"year",
"musicbrainz_releasegroup_id",
"musicbrainz_albumartist_id",
]
)
album.artists.add(artist)
album.fix_album_artist()
album.fetch_artwork()
album.scrape_allmusic()
if not album:
logger.warn(f"No album found for {name} and {mbid}")
album.fix_album_artist()
return album
# TODO These are depreacted, remove them eventually
def get_or_create_track(post_data: dict, post_keys: dict) -> Track:
try:
track_run_time_seconds = int(
post_data.get(post_keys.get("RUN_TIME"), 0)
)
except ValueError: # Sometimes we get run time as a string like "01:35"
track_run_time_seconds = convert_to_seconds(
post_data.get(post_keys.get("RUN_TIME"), 0)
)
artist_name = post_data.get(post_keys.get("ARTIST_NAME"), "")
artist_mb_id = post_data.get(post_keys.get("ARTIST_MB_ID"), "")
album_title = post_data.get(post_keys.get("ALBUM_NAME"), "")
album_mb_id = post_data.get(post_keys.get("ALBUM_MB_ID"), "")
track_title = post_data.get(post_keys.get("TRACK_TITLE"), "")
track_mb_id = post_data.get(post_keys.get("TRACK_MB_ID"), "")
artist = Artist.find_or_create(artist_name, artist_mb_id)
album = None
# We may get no album ID or title, in which case, skip
if album_mb_id or album_title:
album = Album.find_or_create(
album_title, str(artist.name), album_mb_id
)
track = None
if not track_mb_id and album:
try:
track_mb_id = lookup_track_from_mb(
track_title,
artist.musicbrainz_id,
album.musicbrainz_id,
).get("id", 0)
except TypeError:
pass
if not track_title and not track_mb_id:
logger.info(
"Cannot find track without either title or MB ID",
extra={"post_data": post_data},
)
return
if track_mb_id:
track = Track.objects.filter(musicbrainz_id=track_mb_id).first()
if not track and track_title:
track = Track.objects.filter(title=track_title, artist=artist).first()
if not track:
track = Track.objects.create(
title=track_title,
artist=artist,
album=album,
musicbrainz_id=track_mb_id,
run_time_seconds=track_run_time_seconds,
)
return track
def get_or_create_various_artists() -> Artist:
artist = Artist.objects.filter(name="Various Artists").first()
if not artist:
artist = Artist.objects.create(**VARIOUS_ARTIST_DICT)
logger.info("Created Various Artists placeholder")
return artist
def deduplicate_tracks(commit=False) -> int:
from music.models import Track
duplicates = (
Track.objects.values("artist", "title")
.annotate(dup_count=models.Count("id"))
.filter(dup_count__gt=1)
)
query = models.Q()
for dup in duplicates:
query |= models.Q(artist=dup["artist"], title=dup["title"])
duplicate_tracks = Track.objects.filter(query)
for b in duplicate_tracks:
tracks = Track.objects.filter(artist=b.artist, title=b.title)
first = tracks.first()
for other in tracks.exclude(id=first.id):
print("Moving scrobbles for", other.id, " to ", first.id)
if commit:
with transaction.atomic():
other.scrobble_set.update(track=first)
print("deleting ", other.id, " - ", other)
try:
other.delete()
except IntegrityError as e:
print(
"could not delete ",
other.id,
f": IntegrityError {e}",
)
return len(duplicate_tracks)
def condense_albums(commit: bool = False):
from music.models import Track
from scrobbles.models import Scrobble
processed_ids = []
for track in Track.objects.all():
albums_to_add = []
duplicates = (
Track.objects.filter(title=track.title, artist=track.artist)
.exclude(id=track.id)
.exclude(id__in=processed_ids)
)
if commit and track.album:
albums_to_add.append(track.album)
for dup_track in duplicates:
logger.info(f"Adding {dup_track.album} to {track} albums")
if commit and dup_track.album:
track.albums.add(dup_track.album)
# Find out if this track appears more than once
duplicates = Track.objects.filter(
title=track.title, artist=track.artist
)
if duplicates.count() > 1:
logger.info(f"Track appears more than once, condensing: {track}")
albums_to_add.extend([d.album for d in duplicates])
# Find all scrobbles
duplicate_ids = duplicates.values_list("id", flat=True)
scrobbles = Scrobble.objects.filter(track_id__in=duplicate_ids)
logger.info(
f"Found {scrobbles.count()} scrobbles to merge onto {track}"
)
if commit:
scrobbles.update(track=track)
track.albums.add(*list(set(albums_to_add)))
processed_ids.extend(duplicate_ids)
if commit:
Track.objects.filter(scrobble__isnull=True).delete()
return len(set(processed_ids))

View File

View File

@ -0,0 +1,10 @@
from django.contrib import admin
from people.models import Person
@admin.register(Person)
class PersonAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = ("name", "bgg_username", "bgstats_id")
ordering = ("-created",)
search_fields = ("name",)

View File

@ -0,0 +1,74 @@
# Generated by Django 4.2.19 on 2025-07-02 14:59
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django_extensions.db.fields
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Person",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created",
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name="created"
),
),
(
"modified",
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name="modified"
),
),
(
"name",
models.CharField(blank=True, max_length=100, null=True),
),
(
"bgstat_id",
models.CharField(blank=True, max_length=100, null=True),
),
(
"bgg_username",
models.CharField(blank=True, max_length=100, null=True),
),
(
"lichess_username",
models.CharField(blank=True, max_length=100, null=True),
),
("bio", models.TextField(blank=True, null=True)),
(
"user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"get_latest_by": "modified",
"abstract": False,
},
),
]

View File

@ -0,0 +1,22 @@
# Generated by Django 4.2.19 on 2025-07-03 02:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("people", "0001_initial"),
]
operations = [
migrations.RemoveField(
model_name="person",
name="bgstat_id",
),
migrations.AddField(
model_name="person",
name="bgstats_id",
field=models.UUIDField(blank=True, null=True),
),
]

View File

@ -0,0 +1,20 @@
from django.contrib.auth import get_user_model
from django.db import models
from django_extensions.db.models import TimeStampedModel
User = get_user_model()
BNULL = {"blank": True, "null": True}
class Person(TimeStampedModel):
"""A non-system user model that can be optionally associated with a User."""
user = models.ForeignKey(User, on_delete=models.DO_NOTHING, **BNULL)
name = models.CharField(max_length=100, **BNULL)
bgstats_id = models.UUIDField(**BNULL)
bgg_username = models.CharField(max_length=100, **BNULL)
lichess_username = models.CharField(max_length=100, **BNULL)
bio = models.TextField(**BNULL)
def __str__(self):
return self.name

View File

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

View File

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

View File

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

View File

@ -7,10 +7,13 @@ from profiles.models import UserProfile
class UserProfileAdmin(admin.ModelAdmin):
date_hierarchy = "created"
ordering = ("-created",)
readonly_fields = ("timezone_change_log",)
exclude = (
"twitch_token",
"twitch_client_secret",
"lastfm_password",
"webdav_pass",
"imap_pass",
"archivebox_password",
"todoist_auth_key",
"todoist_state",

View File

@ -0,0 +1,39 @@
# Generated by Django 4.2.19 on 2025-07-02 14:54
from django.db import migrations, models
import encrypted_field.fields
class Migration(migrations.Migration):
dependencies = [
("profiles", "0023_alter_userprofile_timezone"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="bgstat_id",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name="userprofile",
name="imap_auto_import",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="userprofile",
name="imap_pass",
field=encrypted_field.fields.EncryptedField(blank=True, null=True),
),
migrations.AddField(
model_name="userprofile",
name="imap_url",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name="userprofile",
name="imap_user",
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View File

@ -0,0 +1,21 @@
# Generated by Django 4.2.19 on 2025-07-03 02:28
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
(
"profiles",
"0024_userprofile_bgstat_id_userprofile_imap_auto_import_and_more",
),
]
operations = [
migrations.RenameField(
model_name="userprofile",
old_name="bgstat_id",
new_name="bgstats_id",
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.19 on 2025-07-11 22:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('profiles', '0025_rename_bgstat_id_userprofile_bgstats_id'),
]
operations = [
migrations.AddField(
model_name='userprofile',
name='timezone_change_log',
field=models.TextField(blank=True, null=True),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 4.2.19 on 2025-07-30 22:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('profiles', '0026_userprofile_timezone_change_log'),
]
operations = [
migrations.AddField(
model_name='userprofile',
name='mood_checkin_enabled',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='userprofile',
name='mood_checkin_frequency',
field=models.CharField(default='hourly', max_length=20),
),
]

View File

@ -1,4 +1,7 @@
import pytz
from zoneinfo import ZoneInfo
import pendulum
from django.utils import timezone
import logging
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import models
@ -10,6 +13,8 @@ from profiles.constants import PRETTY_TIMEZONE_CHOICES
User = get_user_model()
BNULL = {"blank": True, "null": True}
logger = logging.getLogger(__name__)
class UserProfile(TimeStampedModel):
user = models.OneToOneField(
@ -18,6 +23,7 @@ class UserProfile(TimeStampedModel):
timezone = models.CharField(
max_length=255, choices=PRETTY_TIMEZONE_CHOICES, default="UTC"
)
timezone_change_log = models.TextField(**BNULL)
lastfm_username = models.CharField(max_length=255, **BNULL)
lastfm_password = EncryptedField(**BNULL)
lastfm_auto_import = models.BooleanField(default=False)
@ -31,6 +37,7 @@ class UserProfile(TimeStampedModel):
task_context_tags_str = models.CharField(max_length=255, **BNULL)
bgstats_id = models.CharField(max_length=255, **BNULL)
bgg_username = models.CharField(max_length=255, **BNULL)
lichess_username = models.CharField(max_length=255, **BNULL)
@ -43,6 +50,14 @@ class UserProfile(TimeStampedModel):
webdav_pass = EncryptedField(**BNULL)
webdav_auto_import = models.BooleanField(default=False)
imap_url = models.CharField(max_length=255, **BNULL)
imap_user = models.CharField(max_length=255, **BNULL)
imap_pass = EncryptedField(**BNULL)
imap_auto_import = models.BooleanField(default=False)
mood_checkin_enabled = models.BooleanField(default=False)
mood_checkin_frequency = models.CharField(max_length=20, default="hourly")
ntfy_url = models.CharField(max_length=255, **BNULL)
ntfy_enabled = models.BooleanField(default=False)
@ -53,16 +68,83 @@ class UserProfile(TimeStampedModel):
@property
def tzinfo(self):
return pytz.timezone(self.timezone)
return ZoneInfo(self.timezone)
def save(self, *args, **kwargs):
if not self._state.adding:
old_instance = UserProfile.objects.get(pk=self.pk)
is_timezone_change = self.timezone != old_instance.timezone
if is_timezone_change:
logger.info(
"Updating timezone changelog for user",
extra={"profile_id": self.id},
)
previous_changes = old_instance.timezone_change_log
now = timezone.now().replace(microsecond=0)
new_log = f"{self.timezone} - {now}"
if previous_changes:
new_log = previous_changes + f"\n{new_log}"
self.timezone_change_log = new_log
super(UserProfile, self).save(*args, **kwargs)
@property
def historic_timezone_changes(self) -> list:
"""Return a list of datetimes with timezones for the specific changed time"""
history = [pendulum.datetime(1900, 1, 1, 0, 0, 0, tz=self.tzinfo.key)]
if self.timezone_change_log:
for change in self.timezone_change_log.split("\n"):
if " - " in change:
tz, date = change.split(" - ")
history.append(pendulum.parse(date).in_timezone(tz))
return history
def get_timestamp_with_tz(self, timestamp):
timezone = self.tzinfo
if self.timezone_change_log:
change_list = self.historic_timezone_changes
for idx, start in enumerate(change_list):
try:
end = change_list[idx + 1]
except IndexError:
end = None
if end:
if start <= timestamp.replace(tzinfo=end.timezone) <= end:
timezone = start.timezone
else:
if start <= timestamp.replace(tzinfo=start.timezone):
timezone = start.timezone
return timestamp.replace(tzinfo=timezone)
def adjust_timezone_of_scrobbles(self, commit=False):
current_dt = None
scrobbles_to_change_qs_list = []
for boundry_dt in self.historic_timezone_changes:
if current_dt and boundry_dt:
logger.info(
f"Checking for scrobbles between {current_dt} and {boundry_dt} to update to {current_dt.tzinfo.name}"
)
scrobbles = self.user.scrobble_set.filter(
timestamp__gte=current_dt,
timestamp__lt=boundry_dt,
).exclude(timezone=current_dt.tzinfo.name)
scrobbles_to_change_qs_list.append(scrobbles)
logger.info(
f"Updating {scrobbles.count()} scrobble timezones to {current_dt.tzinfo.name}"
)
if commit:
scrobbles.update(timezone=current_dt.tzinfo.name)
current_dt = boundry_dt
return scrobbles_to_change_qs_list
@cached_property
def task_context_tags(self) -> list:
tag_list = [
t.strip().capitalize()
for t in self.task_context_tags_str.split(",")
]
if not tag_list:
tag_list = settings.DEFAULT_TASK_CONTEXT_TAG_LIST
def task_context_tags(self) -> list[str]:
tag_list = settings.DEFAULT_TASK_CONTEXT_TAGS
tags = ""
if self.task_context_tags_str:
tags = self.task_context_tags_str
tag_list = [t.strip().capitalize() for t in tags.split(",")]
return tag_list

View File

@ -1,9 +1,10 @@
from datetime import datetime, timedelta
import pendulum
import pytz
from django.conf import settings
from django.utils import timezone
import calendar
from datetime import datetime, timedelta
# need to translate to a non-naive timezone, even if timezone == settings.TIME_ZONE, so we can compare two dates
def to_user_timezone(date, profile):
@ -56,3 +57,42 @@ def end_of_month(dt, profile) -> datetime:
def start_of_year(dt, profile) -> datetime:
return start_of_day(dt, profile).replace(month=1, day=1)
def fix_profile_historic_timezones(profile):
home_tz = "America/New_York"
europe = "2023-10-15 06:00:00"
europe_end = "2023-12-16 12:00:00"
europe_tz = "Europe/Paris"
washington = "2024-04-28 06:00:00"
washington_end = "2024-05-04 12:00:00"
washington_tz = "America/Los_Angeles"
camp = "2024-08-04 17:00:00"
camp_end = "2024-08-10 12:00:00"
camp_tz = "America/Halifax"
summer = "2025-07-09 06:00:00"
summer_end = "2025-07-11 23:30:00"
summer_tz = "America/Los_Angeles"
profile.timezone_change_log = None
profile.timezone_change_log = ""
profile.timezone_change_log += f"{europe_tz} - {pendulum.parse(europe)}\n"
profile.timezone_change_log += (
f"{home_tz} - {pendulum.parse(europe_end)}\n"
)
profile.timezone_change_log += (
f"{washington_tz} - {pendulum.parse(washington)}\n"
)
profile.timezone_change_log += (
f"{home_tz} - {pendulum.parse(washington_end)}\n"
)
profile.timezone_change_log += f"{camp_tz} - {pendulum.parse(camp)}\n"
profile.timezone_change_log += f"{home_tz} - {pendulum.parse(camp_end)}\n"
profile.timezone_change_log += f"{summer_tz} - {pendulum.parse(summer)}\n"
profile.timezone_change_log += f"{home_tz} - {pendulum.parse(summer_end)}"
profile.save()

View File

@ -1,3 +1,5 @@
from dataclasses import dataclass
from typing import Optional
from uuid import uuid4
import requests
@ -9,12 +11,19 @@ from django_extensions.db.models import TimeStampedModel
from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit
from puzzles.sources import ipdb
from scrobbles.dataclasses import PuzzleLogData
from scrobbles.dataclasses import JSONDataclass
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
BNULL = {"blank": True, "null": True}
@dataclass
class PuzzleLogData(JSONDataclass):
with_people: Optional[int] = None
rating: Optional[str] = None
notes: Optional[str] = None
class PuzzleManufacturer(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
name = models.CharField(max_length=255)
@ -58,6 +67,10 @@ class Puzzle(ScrobblableMixin):
def __str__(self):
return f"{self.title} ({self.pieces_count}) by {self.manufacturer}"
@property
def logdata_cls(self):
return PuzzleLogData
@property
def subtitle(self):
return self.manufacturer.name

View File

@ -102,7 +102,7 @@ class ChartRecordAdmin(admin.ModelAdmin):
@admin.register(Scrobble)
class ScrobbleAdmin(admin.ModelAdmin):
# date_hierarchy = "timestamp"
date_hierarchy = "timestamp"
list_display = (
"timestamp",
"media_name",
@ -112,6 +112,7 @@ class ScrobbleAdmin(admin.ModelAdmin):
"in_progress",
"is_paused",
"played_to_completion",
"user",
)
raw_id_fields = (
"video",
@ -139,6 +140,8 @@ class ScrobbleAdmin(admin.ModelAdmin):
"media_type",
"long_play_complete",
"source",
"timezone",
"user",
)
ordering = ("-timestamp",)
@ -147,3 +150,7 @@ class ScrobbleAdmin(admin.ModelAdmin):
def playback_percent(self, obj):
return obj.percent_played
def get_queryset(self, request):
qs = super().get_queryset(request).exclude(timestamp__year=None)
return qs

View File

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

View File

@ -1,12 +1,12 @@
from functools import cached_property
import inspect
import json
from dataclasses import asdict, dataclass
from typing import Optional
from dataclass_wizard import JSONWizard
from django import forms
from django.contrib.auth import get_user_model
from locations.models import GeoLocation
from people.models import Person
from scrobbles.forms import form_from_dataclass
User = get_user_model()
@ -32,189 +32,54 @@ class JSONDataclass(JSONWizard):
@dataclass
class ScrobbleLogData(JSONDataclass):
class BaseLogData(JSONDataclass):
description: 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
class LongPlayLogData(JSONDataclass):
serial_scrobble_id: Optional[int]
long_play_complete: bool = False
class WithOthersLogData(JSONDataclass):
with_user_ids: Optional[list[int]] = None
with_names_str: Optional[list[str]] = None
@dataclass
class WithPeopleLogData(JSONDataclass):
with_people_ids: Optional[list[int]] = None
@property
def with_names(self) -> list[str]:
with_names = []
if self.with_user_ids:
with_names += [u.full_name for u in self.with_users if u]
if self.with_names_str:
with_names += [u for u in self.with_names_str]
return with_names
def with_people(self) -> list["Person"]:
from people.models import Person
@property
def with_users(self) -> list[User]:
with_users = []
if self.with_user_ids:
with_users = [
User.objects.filter(id=i).first() for i in self.with_user_ids
]
return with_users
if not self.with_people_ids:
return []
return [
Person.objects.filter(id=pid).first()
for pid in self.with_people_ids
]
@dataclass
class BoardGameScoreLogData(JSONDataclass):
user_id: Optional[int] = None
name_str: str = ""
bgg_username: str = ""
color: Optional[str] = None
character: Optional[str] = None
team: Optional[str] = None
score: Optional[int] = None
win: Optional[bool] = None
new: Optional[bool] = None
@property
def user(self) -> Optional[User]:
user = None
if self.user_id:
user = User.objects.filter(id=self.user_id).first()
return user
@property
def name(self) -> str:
name = self.name_str
if self.user_id:
name = self.user.first_name
return name
def __str__(self) -> str:
out = self.name
if self.score:
out += f" {self.score}"
if self.color:
out += f" ({self.color})"
if self.win:
out += f" [W]"
return out
@dataclass
class BoardGameLogData(LongPlayLogData):
serial_scrobble_id: Optional[int] = None
long_play_complete: Optional[bool] = None
players: Optional[list[BoardGameScoreLogData]] = None
location: Optional[str] = None
geo_location_id: Optional[int] = None
difficulty: Optional[int] = None
solo: Optional[bool] = None
two_handed: Optional[bool] = None
@cached_property
def geo_location(self) -> Optional[GeoLocation]:
if self.geo_location_id:
return GeoLocation.objects.filter(id=self.geo_location_id).first()
@dataclass
class BookPageLogData(JSONDataclass):
page_number: Optional[int] = None
end_ts: Optional[int] = None
start_ts: Optional[int] = None
duration: Optional[int] = None
@dataclass
class BookLogData(LongPlayLogData):
long_play_complete: Optional[bool] = None
koreader_hash: Optional[str] = None
page_data: Optional[dict[int, BookPageLogData]] = None
pages_read: Optional[int] = None
page_start: Optional[int] = None
page_end: Optional[int] = None
serial_scrobble_id: Optional[int] = None
@dataclass
class LifeEventLogData(WithOthersLogData):
with_user_ids: Optional[list[int]] = None
with_names_str: Optional[list[str]] = None
location: Optional[str] = None
geo_location_id: Optional[int] = None
details: Optional[str] = None
def geo_location(self):
return GeoLocation.objects.filter(id=self.geo_location_id).first()
@dataclass
class MoodLogData(JSONDataclass):
reasons: Optional[str] = None
@dataclass
class VideoLogData(JSONDataclass):
title: str
video_type: str
run_time_seconds: int
kind: str
year: Optional[int]
episode_number: Optional[int] = None
source_url: Optional[str] = None
imdbID: Optional[str] = None
season_number: Optional[int] = None
cover_url: Optional[str] = None
next_imdb_id: Optional[int] = None
tv_series_id: Optional[str] = None
@dataclass
class VideoGameLogData(LongPlayLogData):
serial_scrobble_id: Optional[int] = None
long_play_complete: Optional[bool] = False
console: Optional[str] = None
emulated: Optional[bool] = False
emulator: Optional[str] = None
@dataclass
class BrickSetLogData(LongPlayLogData, WithOthersLogData):
serial_scrobble_id: Optional[int]
long_play_complete: bool = False
with_user_ids: Optional[list[int]] = None
with_names_str: Optional[list[str]] = None
@dataclass
class TrailLogData(WithOthersLogData):
with_user_ids: Optional[list[int]] = None
with_names_str: Optional[list[str]] = None
details: Optional[str] = None
effort: Optional[str] = None
difficulty: Optional[str] = None
@dataclass
class BeerLogData(WithOthersLogData):
with_user_ids: Optional[list[int]] = None
with_names_str: Optional[list[str]] = None
details: Optional[str] = None
rating: Optional[str] = None
notes: Optional[str] = None
@dataclass
class FoodLogData(JSONDataclass):
meal: Optional[str] = None
details: Optional[str] = None
rating: Optional[str] = None
notes: Optional[str] = None
@dataclass
class PuzzleLogData(JSONDataclass):
with_others: Optional[str] = None
rating: Optional[str] = None
notes: Optional[str] = None
@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
@ -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,107 @@
import email
import imaplib
import json
import logging
from email.header import decode_header
from profiles.models import UserProfile
from scrobbles.models import Scrobble
from scrobbles.scrobblers import email_scrobble_board_game
logger = logging.getLogger(__name__)
def import_scrobbles_from_imap() -> list[Scrobble]:
"""For all user profiles with IMAP creds, check inbox for scrobbleable email attachments."""
scrobbles_created: list[Scrobble] = []
active_profiles = UserProfile.objects.filter(imap_auto_import=True)
logger.info(
"Starting import of scrobbles from IMAP",
extra={"active_profiles": active_profiles},
)
for profile in active_profiles:
logger.info(
"Importing scrobbles from IMAP for user",
extra={"user_id": profile.user_id},
)
mail = imaplib.IMAP4_SSL(profile.imap_url)
mail.login(profile.imap_user, profile.imap_pass)
mail.select("INBOX") # TODO configure this in profile
# Search for unseen emails
status, messages = mail.search(None, "UnSeen")
if status != "OK":
logger.info("IMAP status not OK", extra={"status": status})
return
for uid in messages[0].split():
status, msg_data = mail.fetch(uid, "(RFC822)")
if status != "OK":
logger.info("IMAP status not OK", extra={"status": status})
continue
try:
message = email.message_from_bytes(msg_data[0][1])
logger.info(
"Processing email message", extra={"email_msg": message}
)
except IndexError:
logger.info("No email message data found")
return
# Decode subject safely
subject, encoding = decode_header(message["Subject"])[0]
if isinstance(subject, bytes):
subject = subject.decode(encoding or "utf-8")
if "live activity now!" in subject:
print("Found a Garmin email")
for part in message.walk():
if part.get_content_disposition() == "attachment":
filename = part.get_filename()
if filename:
# Decode the filename if necessary
decoded_name, encoding = decode_header(filename)[0]
if isinstance(decoded_name, bytes):
filename = decoded_name.decode(encoding or "utf-8")
file_data = part.get_payload(decode=True)
parsed_json = ""
# Try parsing JSON if applicable
parsed_json = {}
if filename.lower().endswith(".bgsplay"):
# TODO Pull this out into a parse_pgsplay function
try:
parsed_json = json.loads(
file_data.decode("utf-8")
)
except Exception as e:
logger.error(
"Failed to parse JSON file",
extra={"filename": filename, "error": e},
)
if not parsed_json:
logger.info(
"No JSON found in BG Stats file",
extra={"filename": filename},
)
continue
scrobbles_created = email_scrobble_board_game(
parsed_json, profile.user_id
)
mail.logout()
if scrobbles_created:
logger.info(
f"Creating {len(scrobbles_created)} new scrobbles",
extra={"scrobbles_created": scrobbles_created},
)
return scrobbles_created
logger.info(f"No new scrobbles found in IMAP folders")
return []

View File

@ -0,0 +1,169 @@
import logging
from datetime import datetime, timedelta
import pylast
import pytz
from django.conf import settings
from music.models import Track
logger = logging.getLogger(__name__)
PYLAST_ERRORS = tuple(
getattr(pylast, exc_name)
for exc_name in (
"ScrobblingError",
"NetworkError",
"MalformedResponseError",
"WSError",
)
if hasattr(pylast, exc_name)
)
class LastFM:
def __init__(self, user):
try:
self.client = pylast.LastFMNetwork(
api_key=getattr(settings, "LASTFM_API_KEY"),
api_secret=getattr(settings, "LASTFM_SECRET_KEY"),
username=user.profile.lastfm_username,
password_hash=pylast.md5(user.profile.lastfm_password),
)
self.user = self.client.get_user(user.profile.lastfm_username)
self.vrobbler_user = user
except PYLAST_ERRORS as e:
logger.error(f"Error during Last.fm setup: {e}")
def import_from_lastfm(self, last_processed=None):
"""Given a last processed time, import all scrobbles from LastFM since then"""
from scrobbles.models import Scrobble
new_scrobbles = []
source = "Last.fm"
lastfm_scrobbles = self.get_last_scrobbles(time_from=last_processed)
for lfm_scrobble in lastfm_scrobbles:
track = Track.find_or_create(
title=lfm_scrobble.get("title"),
artist_name=lfm_scrobble.get("artist"),
album_name=lfm_scrobble.get("album"),
enrich=True,
)
tz_timestamp = self.vrobbler_user.profile.get_timestamp_with_tz(
lfm_scrobble.get("timestamp")
)
timestamp = lfm_scrobble.get("timestamp")
stop_timestamp = timestamp + timedelta(
seconds=track.run_time_seconds
)
new_scrobble = Scrobble(
user=self.vrobbler_user,
timestamp=timestamp,
stop_timestamp=stop_timestamp,
source=source,
track=track,
played_to_completion=True,
in_progress=False,
media_type=Scrobble.MediaType.TRACK,
timezone=tz_timestamp.tzinfo.name,
)
# Vrobbler scrobbles on finish, LastFM scrobbles on start
seconds_eariler = timestamp - timedelta(seconds=20)
seconds_later = timestamp + timedelta(seconds=20)
existing = Scrobble.objects.filter(
created__gte=seconds_eariler,
created__lte=seconds_later,
track=track,
).first()
if existing:
logger.debug(f"Skipping existing scrobble {new_scrobble}")
continue
logger.debug(f"Queued scrobble {new_scrobble} for creation")
new_scrobbles.append(new_scrobble)
created = Scrobble.objects.bulk_create(new_scrobbles)
# TODO Add a notification for users that their import is complete
logger.info(
f"Last.fm import fnished",
extra={
"scrobbles_created": len(created),
"user_id": self.vrobbler_user,
"lastfm_user": self.user,
},
)
return created
def get_last_scrobbles(self, time_from=None, time_to=None, check=False):
"""Given a user, Last.fm api key, and secret key, grab a list of scrobbled
tracks"""
lfm_params = {}
scrobbles = []
if time_from:
lfm_params["time_from"] = int(time_from.timestamp())
if time_to:
lfm_params["time_to"] = int(time_to.timestamp())
# if not time_from and not time_to:
lfm_params["limit"] = None
found_scrobbles = self.user.get_recent_tracks(**lfm_params)
# TOOD spin this out into a celery task over certain threshold of found scrobbles?
if check and found_scrobbles:
return True
for scrobble in found_scrobbles:
logger.info(f"Processing {scrobble}")
run_time = None
mbid = None
artist = None
log_dict = {"scrobble": scrobble}
try:
run_time = int(scrobble.track.get_duration() / 1000)
mbid = scrobble.track.get_mbid()
artist = scrobble.track.get_artist().name
log_dict["artist"] = artist
log_dict["mbid"] = mbid
log_dict["run_time"] = run_time
except pylast.MalformedResponseError as e:
logger.warning(e)
except pylast.WSError as e:
logger.info(
"LastFM barfed trying to get the track for {scrobble.track}",
extra=log_dict,
)
except pylast.NetworkError as e:
logger.info(
"LastFM barfed trying to get the track for {scrobble.track}",
extra=log_dict,
)
if not artist:
logger.info(
f"Silly LastFM, no artist found for scrobble",
extra=log_dict,
)
continue
# TODO figure out if this will actually work
# timestamp = datetime.fromtimestamp(int(scrobble.timestamp), UTC)
timestamp = datetime.utcfromtimestamp(
int(scrobble.timestamp)
).replace(tzinfo=pytz.utc)
logger.info(
f"Scrobble appended to list for bulk create", extra=log_dict
)
scrobbles.append(
{
"artist": artist,
"album": scrobble.album,
"title": scrobble.track.title,
"mbid": mbid,
"run_time_seconds": run_time,
"timestamp": timestamp,
}
)
return scrobbles

View File

@ -1,23 +1,22 @@
import codecs
import csv
import logging
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
import pytz
import requests
from django.contrib.auth import get_user_model
from music.models import Track
from scrobbles.constants import AsTsvColumn
from scrobbles.models import Scrobble
from scrobbles.utils import timestamp_user_tz_to_utc
logger = logging.getLogger(__name__)
def process_audioscrobbler_tsv_file(file_path, user_id, user_tz=None):
def import_audioscrobbler_tsv_file(file_path, user_id):
"""Takes a path to a file of TSV data and imports it as past scrobbles"""
new_scrobbles = []
if not user_tz:
user_tz = pytz.utc
user = get_user_model().objects.get(id=user_id)
is_os_file = "https://" not in file_path
@ -44,11 +43,13 @@ def process_audioscrobbler_tsv_file(file_path, user_id, user_tz=None):
)
continue
album_name = row[AsTsvColumn["ALBUM_NAME"].value]
track = Track.find_or_create(
title=row[AsTsvColumn["TRACK_NAME"].value],
musicbrainz_id=row[AsTsvColumn["MB_ID"].value],
artist_name=row[AsTsvColumn["ARTIST_NAME"].value],
album_name=row[AsTsvColumn["ALBUM_NAME"].value],
album_name=album_name,
run_time_seconds=int(row[AsTsvColumn["RUN_TIME_SECONDS"].value]),
enrich=True,
)
# TODO Set all this up as constants
@ -61,23 +62,27 @@ def process_audioscrobbler_tsv_file(file_path, user_id, user_tz=None):
},
)
continue
timestamp = timestamp_user_tz_to_utc(
int(row[AsTsvColumn["TIMESTAMP"].value]), user_tz
)
timestamp = datetime.fromtimestamp(
int(row[AsTsvColumn["TIMESTAMP"].value])
).astimezone(ZoneInfo("UTC"))
timestamp = user.profile.get_timestamp_with_tz(timestamp)
stop_timestamp = timestamp + timedelta(seconds=track.run_time_seconds)
new_scrobble = Scrobble(
user_id=user_id,
user=user,
timestamp=timestamp,
stop_timestamp=stop_timestamp,
source=source,
log={"rockbox_info": rockbox_info},
log={"rockbox_info": rockbox_info, "album_name": album_name},
playback_position_seconds=track.run_time_seconds,
track=track,
played_to_completion=True,
in_progress=False,
media_type=Scrobble.MediaType.TRACK,
timezone=timestamp.tzinfo.name,
)
existing = Scrobble.objects.filter(
timestamp=timestamp, track=track
timestamp=timestamp, track=track, user=user
).first()
if existing:
logger.debug(f"Skipping existing scrobble {new_scrobble}")

View File

@ -0,0 +1,83 @@
import logging
from books.koreader import fetch_file_from_webdav
from profiles.models import UserProfile
from scrobbles.models import KoReaderImport
from scrobbles.tasks import process_koreader_import
from scrobbles.utils import get_file_md5_hash
from webdav.client import get_webdav_client
logger = logging.getLogger(__name__)
def import_from_webdav_for_all_users(restart=False):
"""Grab a list of all users with WebDAV enabled and kickoff imports for them"""
# WebDavImport = apps.get_model("scrobbles", "WebDavImport")
webdav_enabled_user_ids = UserProfile.objects.filter(
webdav_url__isnull=False,
webdav_user__isnull=False,
webdav_pass__isnull=False,
webdav_auto_import=True,
).values_list("user_id", flat=True)
logger.info(
f"Start import of {webdav_enabled_user_ids.count()} webdav accounts"
)
koreader_import_count = 0
for user_id in webdav_enabled_user_ids:
webdav_client = get_webdav_client(user_id)
try:
webdav_client.info("var/koreader/statistics.sqlite3")
koreader_found = True
except:
koreader_found = False
logger.info(
"No koreader stats file found on webdav",
extra={"user_id": user_id},
)
if koreader_found:
last_import = (
KoReaderImport.objects.filter(
user_id=user_id, processed_finished__isnull=False
)
.order_by("processed_finished")
.last()
)
koreader_file_path = fetch_file_from_webdav(1)
new_hash = get_file_md5_hash(koreader_file_path)
old_hash = None
if last_import:
old_hash = last_import.file_md5_hash()
if old_hash and new_hash == old_hash:
logger.info(
"koreader stats file has not changed",
extra={
"user_id": user_id,
"new_hash": new_hash,
"old_hash": old_hash,
"last_import_id": last_import.id,
},
)
continue
koreader_import, created = KoReaderImport.objects.get_or_create(
user_id=user_id, processed_finished__isnull=True
)
if not created and not restart:
logger.info(
f"Not resuming failed KoReader import {koreader_import.id} for user {user_id}, use restart=True to restart"
)
continue
koreader_import.save_sqlite_file_to_self(koreader_file_path)
process_koreader_import.delay(koreader_import.id)
koreader_import_count += 1
return koreader_import_count

View File

@ -0,0 +1,19 @@
from django.core.management.base import BaseCommand
from vrobbler.apps.music.utils import condense_albums
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
print(f"Condensing albums")
album_count = condense_albums(commit)
print(f"Condensed {album_count} albums")

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

@ -1,5 +1,5 @@
from django.core.management.base import BaseCommand
from vrobbler.apps.scrobbles.utils import deduplicate_tracks
from vrobbler.apps.music.utils import deduplicate_tracks
class Command(BaseCommand):
@ -14,5 +14,7 @@ class Command(BaseCommand):
commit = False
if options["commit"]:
commit = True
else:
print("No changes will be saved, use --commit to save")
dups = deduplicate_tracks(commit=commit)
print(f"Deduplicated {dups} music tracks")

View File

@ -0,0 +1,15 @@
from django.core.management.base import BaseCommand
from vrobbler.apps.scrobbles.importers.imap import import_scrobbles_from_imap
class Command(BaseCommand):
def add_arguments(self, parser):
parser.add_argument(
"--restart",
action="store_true",
help="Restart failed imports",
)
def handle(self, *args, **options):
count = len(import_scrobbles_from_imap())
print(f"Started {count} IMAP imports")

View File

@ -1,5 +1,5 @@
from django.core.management.base import BaseCommand
from vrobbler.apps.scrobbles.utils import import_from_webdav_for_all_users
from vrobbler.apps.scrobbles.importers import webdav
class Command(BaseCommand):
@ -14,5 +14,5 @@ class Command(BaseCommand):
restart = False
if options["restart"]:
restart = True
count = import_from_webdav_for_all_users(restart=restart)
count = webdav.import_from_webdav_for_all_users(restart=restart)
print(f"Started {count} WeDAV imports")

View File

@ -0,0 +1,10 @@
from django.core.management.base import BaseCommand
from vrobbler.apps.scrobbles.utils import (
send_mood_checkin_reminders
)
class Command(BaseCommand):
def handle(self, *args, **options):
sent_count = send_mood_checkin_reminders()
print(f"Sent {sent_count} mood check-in notifications")

View File

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

View File

@ -5,14 +5,16 @@ import logging
from collections import defaultdict
from typing import Optional
from uuid import uuid4
from zoneinfo import ZoneInfo
import pendulum
import pytz
from beers.models import Beer
from boardgames.models import BoardGame
from books.koreader import process_koreader_sqlite_file
from books.models import Book, Paper
from books.models import Book, Paper, BookPageLogData, BookLogData
from bricksets.models import BrickSet
from dataclass_wizard.errors import ParseError
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.files import File
@ -26,13 +28,13 @@ from imagekit.processors import ResizeToFit
from lifeevents.models import LifeEvent
from locations.models import GeoLocation
from moods.models import Mood
from music.lastfm import LastFM
from music.models import Artist, Track
from podcasts.models import PodcastEpisode
from profiles.utils import (
end_of_day,
end_of_month,
end_of_week,
fix_profile_historic_timezones,
start_of_day,
start_of_month,
start_of_week,
@ -40,7 +42,8 @@ from profiles.utils import (
from puzzles.models import Puzzle
from scrobbles import dataclasses as logdata
from scrobbles.constants import LONG_PLAY_MEDIA, MEDIA_END_PADDING_SECONDS
from scrobbles.notifications import NtfyNotification
from scrobbles.importers.lastfm import LastFM
from scrobbles.notifications import ScrobbleNtfyNotification
from scrobbles.stats import build_charts
from scrobbles.utils import get_file_md5_hash, media_class_to_foreign_key
from sports.models import SportEvent
@ -204,6 +207,9 @@ class KoReaderImport(BaseFileImportMixin):
def process(self, force=False):
if self.user.id == 1:
fix_profile_historic_timezones(self.user.profile)
if self.processed_finished and not force:
logger.info(
f"{self} already processed on {self.processed_finished}"
@ -247,7 +253,10 @@ class AudioScrobblerTSVImport(BaseFileImportMixin):
tsv_file = models.FileField(upload_to=get_path, **BNULL)
def process(self, force=False):
from scrobbles.tsv import process_audioscrobbler_tsv_file
from scrobbles.importers.tsv import import_audioscrobbler_tsv_file
if self.user.id == 1:
fix_profile_historic_timezones(self.user.profile)
if self.processed_finished and not force:
logger.info(
@ -257,13 +266,8 @@ class AudioScrobblerTSVImport(BaseFileImportMixin):
self.mark_started()
tz = None
user_id = None
if self.user:
user_id = self.user.id
tz = self.user.profile.tzinfo
scrobbles = process_audioscrobbler_tsv_file(
self.upload_file_path, user_id, user_tz=tz
scrobbles = import_audioscrobbler_tsv_file(
self.upload_file_path, self.user.id
)
self.record_log(scrobbles)
self.mark_finished()
@ -284,6 +288,10 @@ class LastFmImport(BaseFileImportMixin):
def process(self, import_all=False):
"""Import scrobbles found on LastFM"""
if self.user.id == 1:
fix_profile_historic_timezones(self.user.profile)
if self.processed_finished:
logger.info(
f"{self} already processed on {self.processed_finished}"
@ -331,6 +339,9 @@ class RetroarchImport(BaseFileImportMixin):
def process(self, import_all=False, force=False):
"""Import scrobbles found on Retroarch"""
if self.user.id == 1:
fix_profile_historic_timezones(self.user.profile)
if self.processed_finished and not force:
logger.info(
f"{self} already processed on {self.processed_finished}"
@ -644,6 +655,13 @@ class Scrobble(TimeStampedModel):
(scrobble.elapsed_time)
)
# Remove any locations without titles
if "GeoLocation" in scrobbles_by_type.keys():
for loc_scrobble in scrobbles_by_type["GeoLocation"]:
if not loc_scrobble.media_obj.title:
scrobbles_by_type["GeoLocation"].remove(loc_scrobble)
scrobbles_by_type["GeoLocation_count"] -= 1
return scrobbles_by_type
@classmethod
@ -686,6 +704,12 @@ class Scrobble(TimeStampedModel):
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):
pushable_media = hasattr(
self.media_obj, "push_to_archivebox"
@ -708,11 +732,11 @@ class Scrobble(TimeStampedModel):
)
@property
def logdata(self) -> Optional[logdata.JSONDataclass]:
def logdata(self) -> Optional[logdata.BaseLogData]:
if self.media_obj:
logdata_cls = self.media_obj.logdata_cls
else:
logdata_cls = logdata.ScrobbleLogData
logdata_cls = logdata.BaseLogData
log_dict = self.log
if isinstance(self.log, str):
@ -726,7 +750,20 @@ class Scrobble(TimeStampedModel):
if not log_dict:
log_dict = {}
return logdata_cls.from_dict(log_dict)
try:
return logdata_cls(**log_dict)
except ParseError as e:
logger.warning(
"Could not parse log data",
extra={
"log_dict": log_dict,
"scrobble_id": self.id,
"error": e,
},
)
return logdata_cls()
except TypeError as e:
return logdata_cls()
def redirect_url(self, user_id) -> str:
user = User.objects.filter(id=user_id).first()
@ -738,7 +775,7 @@ class Scrobble(TimeStampedModel):
and user.profile.redirect_to_webpage
):
logger.info(f"Redirecting to {self.media_obj.url}")
redirect_url = self.media_obj.get_read_url()
redirect_url = self.media_obj.url
if (
self.media_type == self.MediaType.VIDEO
@ -750,7 +787,18 @@ class Scrobble(TimeStampedModel):
@property
def tzinfo(self):
return pytz.timezone(self.timezone)
return ZoneInfo(self.timezone)
@property
def local_timestamp(self):
return timezone.localtime(self.timestamp, timezone=self.tzinfo)
@property
def local_stop_timestamp(self):
if self.stop_timestamp:
return timezone.localtime(
self.stop_timestamp, timezone=self.tzinfo
)
@property
def scrobble_media_key(self) -> str:
@ -851,8 +899,9 @@ class Scrobble(TimeStampedModel):
if self.played_to_completion:
if self.playback_position_seconds:
return self.playback_position_seconds
if self.media_obj.run_time_seconds:
if self.media_obj and self.media_obj.run_time_seconds:
return self.media_obj.run_time_seconds
return (timezone.now() - self.timestamp).seconds
@property
@ -942,7 +991,7 @@ class Scrobble(TimeStampedModel):
@property
def can_be_updated(self) -> bool:
if self.media_obj.__class__.__name__ in LONG_PLAY_MEDIA.values():
if self.media_obj.__class__.__name__ in LONG_PLAY_MEDIA.values() and self.source != "readcomicsonline.ru":
logger.info(
"[scrobbling] cannot be updated, long play media",
extra={
@ -1029,11 +1078,12 @@ class Scrobble(TimeStampedModel):
media_obj = self.puzzle
if self.task:
media_obj = self.task
if self.food:
media_obj = self.food
return media_obj
def __str__(self):
timestamp = self.timestamp.strftime("%Y-%m-%d")
return f"Scrobble of {self.media_obj} ({timestamp})"
return f"Scrobble of {self.media_obj} ({self.timestamp})"
def calc_reading_duration(self) -> int:
duration = 0
@ -1080,6 +1130,9 @@ class Scrobble(TimeStampedModel):
key = media_class_to_foreign_key(media.__class__.__name__)
media_query = models.Q(**{key: media})
scrobble_data[key + "_id"] = media.id
skip_in_progress_check = kwargs.get("skip_in_progress_check", False)
read_log_page = kwargs.get("read_log_page", None)
# Find our last scrobble of this media item (track, video, etc)
scrobble = (
@ -1092,7 +1145,6 @@ class Scrobble(TimeStampedModel):
)
source = scrobble_data.get("source", "Vrobbler")
mtype = media.__class__.__name__
mopidy_status = scrobble_data.get("mopidy_status", None)
# GeoLocations are a special case scrobble
if mtype == cls.MediaType.GEO_LOCATION:
@ -1104,27 +1156,51 @@ class Scrobble(TimeStampedModel):
)
return scrobble
logger.info(
f"[create_or_update] check for existing scrobble to update ",
extra={
"scrobble_id": scrobble.id if scrobble else None,
"media_type": mtype,
"media_id": media.id,
"scrobble_data": scrobble_data,
},
)
scrobble_data["playback_status"] = scrobble_data.pop("status", None)
# If it's marked as stopped, send it through our update mechanism, which will complete it
if scrobble and (
scrobble.can_be_updated
or scrobble_data["playback_status"] == "stopped"
):
if "log" in scrobble_data.keys() and scrobble.log:
scrobble_data["log"] = scrobble.log | scrobble_data["log"]
return scrobble.update(scrobble_data)
if not skip_in_progress_check or read_log_page:
logger.info(
f"[create_or_update] check for existing scrobble to update ",
extra={
"scrobble_id": scrobble.id if scrobble else None,
"media_type": mtype,
"media_id": media.id,
"scrobble_data": scrobble_data,
},
)
scrobble_data["playback_status"] = scrobble_data.pop(
"status", None
)
# If it's marked as stopped, send it through our update mechanism, which will complete it
if scrobble and (
scrobble.can_be_updated
or (read_log_page and scrobble.can_be_updated)
or scrobble_data["playback_status"] == "stopped"
):
if read_log_page:
page_list = scrobble.log.get("page_data", [])
if page_list:
for page in page_list:
if not page.get("end_ts", None):
page["end_ts"] = int(timezone.now().timestamp())
page["duration"] = page["end_ts"] - page.get("start_ts")
page_list.append(
BookPageLogData(
page_number=read_log_page,
start_ts=int(timezone.now().timestamp())
)
)
scrobble.log["page_data"] = page_list
scrobble.save(update_fields=["log"])
elif "log" in scrobble_data.keys() and scrobble.log:
scrobble_data["log"] = scrobble.log | scrobble_data["log"]
return scrobble.update(scrobble_data)
# Discard status before creating
scrobble_data.pop("playback_status")
if read_log_page:
scrobble_data["log"] = BookLogData(page_data=[BookPageLogData(page_number=read_log_page, start_ts=int(timezone.now().timestamp()))])
# Discard status before creating
scrobble_data.pop("playback_status")
logger.info(
f"[scrobbling] creating new scrobble",
extra={
@ -1291,7 +1367,7 @@ class Scrobble(TimeStampedModel):
scrobble_data: dict,
) -> "Scrobble":
scrobble = cls.objects.create(**scrobble_data)
NtfyNotification(scrobble).send()
ScrobbleNtfyNotification(scrobble).send()
return scrobble
def stop(self, timestamp=None, force_finish=False) -> None:
@ -1317,6 +1393,9 @@ class Scrobble(TimeStampedModel):
if class_name in LONG_PLAY_MEDIA.values():
self.finish_long_play()
if class_name == "Book":
self.calculate_reading_stats()
logger.info(
f"[scrobbling] stopped",
extra={
@ -1412,3 +1491,40 @@ class Scrobble(TimeStampedModel):
beyond_completion = False
return beyond_completion
def calculate_reading_stats(self, commit=True):
# --- Sort safely by numeric page_number ---
def safe_page_number(entry):
try:
return int(getattr("page_number", entry), 0)
except (ValueError, TypeError):
return float("inf") # push invalid entries to the end
page_data = self.log.get("page_data")
if not page_data:
logger.warning("No page data found to calculate")
return
if isinstance(page_data, dict):
logger.warning("Page data is dict, migrate koreader data")
return
page_data.sort(key=safe_page_number)
# --- Extract valid numeric page numbers ---
valid_pages = []
for page in page_data:
try:
valid_pages.append(int(page["page_number"]))
except (ValueError, TypeError):
continue
# --- Compute stats ---
if valid_pages:
self.log["page_start"] = min(valid_pages)
self.log["page_end"] = max(valid_pages)
self.log["pages_read"] = len(set(valid_pages))
if commit:
self.save(update_fields=["log"])

View File

@ -3,13 +3,26 @@ import requests
from django.conf import settings
from django.contrib.sites.models import Site
from django.urls import reverse
class Notification(ABC):
scrobble: "Scrobble"
class BasicNtfyNotification(ABC):
ntfy_headers: dict = {}
ntfy_url: str = ""
title: str = ""
def __init__(self, profile: "UserProfile"):
self.profile = profile.user
protocol = "http" if settings.DEBUG else "https"
domain = Site.objects.get_current().domain
self.url_tmpl = f'{protocol}://{domain}' + '{path}'
@abstractmethod
def send(self) -> None:
pass
class ScrobbleNotification(BasicNtfyNotification):
scrobble: "Scrobble"
def __init__(self, scrobble: "Scrobble"):
self.scrobble = scrobble
self.user = scrobble.user
@ -19,13 +32,12 @@ class Notification(ABC):
self.url_tmpl = f'{protocol}://{domain}' + '{path}'
@abstractmethod
def send(self) -> None:
pass
class NtfyNotification(Notification):
class ScrobbleNtfyNotification(ScrobbleNotification):
def __init__(self, scrobble, **kwargs):
super().__init__(scrobble)
self.ntfy_str: str = f"{self.scrobble.media_obj}"
@ -35,8 +47,8 @@ class NtfyNotification(Notification):
self.click_url = self.url_tmpl.format(path=self.scrobble.finish_url)
self.title = "Finish " + self.media_obj.strings.verb.lower() + "?"
if self.scrobble.log and isinstance(self.scrobble.log, dict) and self.scrobble.log.get("description"):
self.ntfy_str += f" - {self.scrobble.log.get('description')}"
if self.scrobble.log and isinstance(self.scrobble.log, dict) and self.scrobble.log.get("title"):
self.ntfy_str += f" - {self.scrobble.log.get('title')}"
def send(self):
if (
@ -55,3 +67,27 @@ class NtfyNotification(Notification):
"Click": self.click_url,
},
)
class MoodNtfyNotification(BasicNtfyNotification):
def __init__(self, profile, **kwargs):
super().__init__(profile)
self.ntfy_str: str = "Would you like to check in about your mood?"
self.click_url = self.url_tmpl.format(path=reverse("moods:mood_list"))
self.title = "Mood Check-in!"
def send(self):
if (
self.profile
and self.profile.ntfy_enabled
and self.profile.ntfy_url
):
requests.post(
self.profile.ntfy_url,
data=self.ntfy_str.encode(encoding="utf-8"),
headers={
"Title": self.title,
"Priority": "high",
"Tags": "smiley, check",
"Click": self.click_url,
},
)

View File

@ -1,13 +1,15 @@
import logging
import re
from datetime import datetime
from typing import Optional
from datetime import datetime, timedelta
from typing import Any, Optional
import pendulum
import pytz
from beers.models import Beer
from boardgames.models import BoardGame
from books.models import Book
from boardgames.models import BoardGame, BoardGameDesigner, BoardGameLocation
from books.constants import READCOMICSONLINE_URL
from books.models import Book, BookLogData, BookPageLogData
from books.utils import parse_readcomicsonline_uri
from bricksets.models import BrickSet
from dateutil.parser import parse
from django.utils import timezone
@ -15,8 +17,10 @@ from locations.constants import LOCATION_PROVIDERS
from locations.models import GeoLocation
from music.constants import JELLYFIN_POST_KEYS, MOPIDY_POST_KEYS
from music.models import Track
from people.models import Person
from podcasts.models import PodcastEpisode
from podcasts.utils import parse_mopidy_uri
from profiles.models import UserProfile
from puzzles.models import Puzzle
from scrobbles.constants import (
JELLYFIN_AUDIO_ITEM_TYPES,
@ -24,20 +28,22 @@ from scrobbles.constants import (
SCROBBLE_CONTENT_URLS,
)
from scrobbles.models import Scrobble
from scrobbles.utils import convert_to_seconds, extract_domain
from scrobbles.notifications import ScrobbleNtfyNotification
from scrobbles.utils import (
convert_to_seconds,
extract_domain,
remove_last_part,
next_url_if_exists,
)
from sports.models import SportEvent
from sports.thesportsdb import lookup_event_from_thesportsdb
from tasks.models import Task
from tasks.utils import get_title_from_labels
from videogames.howlongtobeat import lookup_game_from_hltb
from videogames.models import VideoGame
from videos.models import Video
from webpages.models import WebPage
from vrobbler.apps.tasks.constants import (
TODOIST_TITLE_PREFIX_LABELS,
TODOIST_TITLE_SUFFIX_LABELS,
)
logger = logging.getLogger(__name__)
@ -57,18 +63,11 @@ def mopidy_scrobble_media(post_data: dict, user_id: int) -> Scrobble:
if media_type == Scrobble.MediaType.PODCAST_EPISODE:
parsed_data = parse_mopidy_uri(post_data.get("mopidy_uri", ""))
podcast_name = post_data.get(
"album", parsed_data.get("podcast_name", "")
)
if not parsed_data:
logger.warning("Tried to scrobble podcast but no uri found", extra={"post_data": post_data})
return Scrobble()
media_obj = PodcastEpisode.find_or_create(
title=parsed_data.get("episode_filename", ""),
podcast_name=podcast_name,
producer_name=post_data.get("artist", ""),
number=parsed_data.get("episode_num", ""),
pub_date=parsed_data.get("pub_date", ""),
mopidy_uri=post_data.get("mopidy_uri", ""),
)
media_obj = PodcastEpisode.find_or_create(**parsed_data)
else:
media_obj = Track.find_or_create(
title=post_data.get("name", ""),
@ -135,7 +134,6 @@ def jellyfin_scrobble_media(
run_time_seconds=convert_to_seconds(
post_data.get("RunTime", 900000)
),
musicbrainz_id=post_data.get("Provider_musicbrainztrack", ""),
)
# A hack because we don't worry about updating music ... we either finish it or we don't
playback_position_seconds = 0
@ -264,13 +262,43 @@ def manual_scrobble_video_game(
def manual_scrobble_book(
title: str, user_id: int, action: Optional[str] = None
):
book = Book.get_from_google(title)
log = {}
source = "Vrobbler"
page = None
url = ""
if READCOMICSONLINE_URL in title:
url = title
title, volume, page = parse_readcomicsonline_uri(title)
if not title:
logger.info(
"[scrobblers] manual book scrobble request failed",
extra={
"title": title,
"user_id": user_id,
"media_type": Scrobble.MediaType.BOOK,
},
)
return
title = f"{title} - Issue {volume}"
if not page:
page = 1
logger.info("[scrobblers] Book page included in scrobble, should update!")
source = READCOMICSONLINE_URL.replace("https://", "")
# TODO: Check for scrobble of this book already and if so, update the page count
book = Book.find_or_create(title, url=url, enrich=True)
scrobble_dict = {
"user_id": user_id,
"timestamp": timezone.now(),
"playback_position_seconds": 0,
"source": "Vrobbler",
"source": source,
"long_play_complete": False,
}
@ -284,12 +312,21 @@ def manual_scrobble_book(
},
)
return Scrobble.create_or_update(book, user_id, scrobble_dict)
scrobble = Scrobble.create_or_update(book, user_id, scrobble_dict, read_log_page=page)
if action == "stop":
if url:
scrobble.log["resume_url"] = next_url_if_exists(url)
scrobble.save(update_fields=["log"])
scrobble.stop(force_finish=True)
return scrobble
def manual_scrobble_board_game(
bggeek_id: str, user_id: int, action: Optional[str] = None
):
) -> Scrobble | None:
boardgame = BoardGame.find_or_create(bggeek_id)
if not boardgame:
@ -315,6 +352,203 @@ def manual_scrobble_board_game(
return Scrobble.create_or_update(boardgame, user_id, scrobble_dict)
def find_and_enrich_board_game_data(game_dict: dict) -> BoardGame | None:
"""TODO Move this to a utility somewhere"""
game = BoardGame.find_or_create(game_dict.get("bggId"))
if game:
game.cooperative = game_dict.get("cooperative", False)
game.highest_wins = game_dict.get("highestWins", True)
game.no_points = game_dict.get("noPoints", False)
game.uses_teams = game_dict.get("useTeams", False)
game.bgstats_id = game_dict.get("uuid", None)
if not game.rating:
game.rating = game_dict.get("rating") / 10
game.save()
if game_dict.get("designers"):
for designer_name in game_dict.get("designers", "").split(", "):
designer, created = BoardGameDesigner.objects.get_or_create(
name=designer_name
)
game.designers.add(designer.id)
return game
def email_scrobble_board_game(
bgstat_data: dict[str, Any], user_id: int
) -> list[Scrobble]:
game_list: list = bgstat_data.get("games", [])
if not game_list:
logger.info(
"No game data from BG Stats, not scrobbling",
extra={"bgstat_data": bgstat_data},
)
return []
player_dict = {}
for player in bgstat_data.get("players", []):
if player.get("isAnonymous"):
person, _created = Person.objects.get_or_create(name="Anonymous")
else:
person, _created = Person.objects.get_or_create(
bgstats_id=player.get("uuid")
)
if not person.name:
person.name = player.get("name", "")
person.save()
player_dict[player.get("id")] = person
base_games = {}
expansions = {}
log_data = {}
for game in game_list:
logger.info(f"Finding and enriching {game.get('name')}")
enriched_game = find_and_enrich_board_game_data(game)
if game.get("isBaseGame"):
base_games[game.get("id")] = enriched_game
if game.get("isExpansion"):
expansions[game.get("id")] = enriched_game
locations = {}
for location_dict in bgstat_data.get("locations", []):
location, _created = BoardGameLocation.objects.get_or_create(
bgstats_id=location_dict.get("uuid")
)
update_fields = []
if not location.name:
location.name = location_dict.get("name")
update_fields.append("name")
geoloc = GeoLocation.objects.filter(
title__icontains=location.name
).first()
if geoloc:
location.geo_location = geoloc
update_fields.append("geo_location")
if update_fields:
location.save(update_fields=update_fields)
locations[location_dict.get("id")] = location
scrobbles_created = []
second = 0
for play_dict in bgstat_data.get("plays", []):
hour = None
minute = None
second = None
if "comments" in play_dict.keys():
for line in play_dict.get("comments", "").split("\n"):
if "Learning to play" in line:
log_data["learning"] = True
if "Start time:" in line:
start_time = line.split(": ")[1]
pieces = start_time.split(":")
hour = int(pieces[0])
minute = int(pieces[1])
try:
second = int(pieces[2])
except IndexError:
second = 0
log_data["notes"] = [play_dict.get("comments")]
log_data["expansion_ids"] = []
try:
base_game = base_games[play_dict.get("gameRefId")]
except KeyError:
try:
base_game = expansions[play_dict.get("gameRefId")]
except KeyError:
logger.info(
"Skipping scrobble of play, can't find game",
extra={"play_dict": play_dict},
)
continue
for eplay in play_dict.get("expansionPlays", []):
expansion = expansions[eplay.get("gameRefId")]
expansion.expansion_for_boardgame = base_game
expansion.save()
log_data["expansion_ids"].append(expansion.id)
if log_data.get("expansion_ids") == []:
log_data.pop("expansion_ids")
if play_dict.get("locationRefId", False):
log_data["location_id"] = locations[
play_dict.get("locationRefId")
].id
if play_dict.get("rounds", False):
log_data["rounds"] = play_dict.get("rounds")
if play_dict.get("board", False):
log_data["board"] = play_dict.get("board")
log_data["players"] = []
for score_dict in play_dict.get("playerScores", []):
log_data["players"].append(
{
"person_id": player_dict[score_dict.get("playerRefId")].id,
"new": score_dict.get("newPlayer"),
"win": score_dict.get("winner"),
"score": score_dict.get("score"),
"rank": score_dict.get("rank"),
"seat_order": score_dict.get("seatOrder"),
"role": score_dict.get("role"),
}
)
timestamp = parse(play_dict.get("playDate"))
if hour and minute:
logger.info(f"Scrobble playDate has manual start time {timestamp}")
timestamp = timestamp.replace(
hour=hour, minute=minute, second=second or 0
)
logger.info(f"Update to {timestamp}")
profile = UserProfile.objects.filter(user_id=user_id).first()
timestamp = profile.get_timestamp_with_tz(timestamp)
if play_dict.get("durationMin") > 0:
duration_seconds = play_dict.get("durationMin") * 60
else:
duration_seconds = base_game.run_time_seconds
stop_timestamp = timestamp + timedelta(seconds=duration_seconds)
logger.info(f"Creating scrobble for {base_game} at {timestamp}")
scrobble_dict = {
"user_id": user_id,
"timestamp": timestamp,
"playback_position_seconds": duration_seconds,
"source": "BG Stats",
"log": log_data,
}
scrobble = None
if timestamp.year > 2023:
logger.info(
"Scrobbles older than 2024 likely have no time associated just create it"
)
scrobble = Scrobble.objects.filter(
board_game=base_game, user_id=user_id, timestamp=timestamp
).first()
if scrobble:
logger.info(
"Scrobble already exists, skipping",
extra={"scrobble_dict": scrobble_dict, "user_id": user_id},
)
continue
scrobble = Scrobble.create_or_update(
base_game, user_id, scrobble_dict, skip_in_progress_check=True
)
scrobble.timezone = timestamp.tzinfo.name
scrobble.stop_timestamp = stop_timestamp
scrobble.in_progress = False
scrobble.played_to_completion = True
scrobble.save()
scrobbles_created.append(scrobble)
ScrobbleNtfyNotification(scrobble).send()
return scrobbles_created
def manual_scrobble_from_url(
url: str, user_id: int, action: Optional[str] = None
) -> Scrobble:
@ -344,6 +578,8 @@ def manual_scrobble_from_url(
if content_key == "-i" and "v=" in url:
item_id = url.split("v=")[1].split("&")[0]
elif content_key == "-c" and "comics" in url:
item_id = url
elif content_key == "-i" and "title/tt" in url:
item_id = "tt" + str(item_id)
@ -392,9 +628,9 @@ def todoist_scrobble_update_task(
)
return
existing_notes = scrobble.log.get("notes", {})
existing_notes[todoist_note.get("todoist_id")] = todoist_note.get("notes")
scrobble.log["notes"] = existing_notes
if not scrobble.log.get("notes"):
scrobble.log["notes"] = []
scrobble.log["notes"].append(todoist_note.get("notes"))
scrobble.save(update_fields=["log"])
logger.info(
"[todoist_scrobble_update_task] todoist note added",
@ -413,30 +649,14 @@ def todoist_scrobble_task(
user_id: int,
started: bool = False,
stopped: bool = False,
user_context_list: list[str] = [],
) -> Scrobble:
prefix = ""
suffix = ""
# TODO look up the user profile and instead of checking PREFIX/SUFFIX, check against
# user.profile.task_context_tags which will result in context-based tag titles
# We'd also have to migrate existing tasks to the new context based ones (maybe)
for label in todoist_task["todoist_label_list"]:
if label in TODOIST_TITLE_PREFIX_LABELS:
prefix = label
if label in TODOIST_TITLE_SUFFIX_LABELS:
suffix = label
if not prefix and suffix:
logger.warning(
"Missing a prefix and suffix tag for task",
extra={"todoist_scrobble_task": todoist_task},
)
title = " ".join([prefix.capitalize(), suffix.capitalize()])
title = get_title_from_labels(
todoist_task.get("todoist_label_list", []), user_context_list
)
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(
user_id=user_id,
in_progress=True,
@ -478,8 +698,12 @@ def todoist_scrobble_task(
)
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"
# TODO Should use updated_at from TOdoist, but parsing isn't working
todoist_task["title"] = todoist_task.pop("description")
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 = {
"user_id": user_id,
"timestamp": timestamp,
@ -507,8 +731,8 @@ def emacs_scrobble_update_task(
scrobble = Scrobble.objects.filter(
in_progress=True,
user_id=user_id,
log__source_id=emacs_id,
log__source="orgmode",
log__orgmode_id=emacs_id,
source="Org-mode",
).first()
if not scrobble:
@ -555,32 +779,20 @@ def emacs_scrobble_task(
user_id: int,
started: bool = False,
stopped: bool = False,
user_context_list: list[str] = [],
) -> Scrobble | None:
orgmode_id = task_data.get("source_id")
title = get_title_from_labels(
task_data.get("labels", []), user_context_list
)
prefix = ""
suffix = ""
source_id = task_data.get("source_id")
for label in task_data.get("labels"):
if label in TODOIST_TITLE_PREFIX_LABELS:
prefix = label
if label in TODOIST_TITLE_SUFFIX_LABELS:
suffix = label
if not prefix and not suffix:
logger.warning(
"Missing a prefix and suffix tag for task",
extra={"emacs_scrobble_task": task_data},
)
title = " ".join([prefix.capitalize(), suffix.capitalize()])
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(
user_id=user_id,
in_progress=True,
log__source_id=source_id,
log__orgmode_id=orgmode_id,
log__source="orgmode",
task=task,
).last()
@ -589,7 +801,7 @@ def emacs_scrobble_task(
logger.info(
"[emacs_scrobble_task] cannot stop already stopped task",
extra={
"emacs_id": source_id,
"orgmode_id": orgmode_id,
},
)
return
@ -598,7 +810,7 @@ def emacs_scrobble_task(
logger.info(
"[emacs_scrobble_task] cannot start already started task",
extra={
"emacs_id": source_id,
"ormode_id": orgmode_id,
},
)
return in_progress_scrobble
@ -608,7 +820,7 @@ def emacs_scrobble_task(
logger.info(
"[emacs_scrobble_task] finishing",
extra={
"emacs_id": source_id,
"orgmode_id": orgmode_id,
},
)
in_progress_scrobble.stop(timestamp=timestamp, force_finish=True)
@ -619,11 +831,17 @@ def emacs_scrobble_task(
notes = task_data.pop("notes")
if notes:
task_data["notes"] = []
for note in notes:
task_data["notes"].append(
{note.get("timestamp"): note.get("content")}
)
task_data["notes"] = [note.get("content") for note in notes]
task_data["title"] = task_data.pop("description")
task_data["description"] = task_data.pop("body")
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 = {
"user_id": user_id,
@ -698,8 +916,13 @@ def manual_scrobble_webpage(
)
scrobble = Scrobble.create_or_update(webpage, user_id, scrobble_dict)
# possibly async this?
scrobble.push_to_archivebox()
if action == "stop":
scrobble.stop(force_finish=True)
else:
# possibly async this?
scrobble.push_to_archivebox()
return scrobble

View File

@ -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(),
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>/finish/", views.scrobble_finish, name="finish"),
path("scrobble/<slug:uuid>/cancel/", views.scrobble_cancel, name="cancel"),

View File

@ -1,18 +1,27 @@
import hashlib
import logging
import requests
import re
from datetime import datetime, timedelta, tzinfo
from sqlite3 import IntegrityError
from datetime import date, datetime, timedelta
from typing import TYPE_CHECKING, Optional
from urllib.parse import urlparse
from zoneinfo import ZoneInfo
import pendulum
import pytz
from django.apps import apps
from django.contrib.auth import get_user_model
from django.db import models, transaction
from django.db import models
from django.db.models.fields.json import KeyTextTransform
from django.db.models.functions import Cast, TruncDate
from django.utils import timezone
from profiles.models import UserProfile
from profiles.utils import now_user_timezone
from scrobbles.constants import LONG_PLAY_MEDIA
from scrobbles.notifications import (
MoodNtfyNotification,
ScrobbleNtfyNotification,
)
from scrobbles.tasks import (
process_koreader_import,
process_lastfm_import,
@ -20,13 +29,14 @@ from scrobbles.tasks import (
)
from webdav.client import get_webdav_client
from vrobbler.apps.scrobbles.notifications import NtfyNotification
if TYPE_CHECKING:
from scrobbles.models import Scrobble
logger = logging.getLogger(__name__)
User = get_user_model()
def timestamp_user_tz_to_utc(timestamp: int, user_tz: tzinfo) -> datetime:
def timestamp_user_tz_to_utc(timestamp: int, user_tz: ZoneInfo) -> datetime:
return user_tz.localize(datetime.utcfromtimestamp(timestamp)).astimezone(
pytz.utc
)
@ -115,6 +125,8 @@ def get_long_plays_completed(user: User) -> list:
def import_lastfm_for_all_users(restart=False):
"""Grab a list of all users with LastFM enabled and kickoff imports for them"""
from scrobbles.importers.lastfm import LastFM
LastFmImport = apps.get_model("scrobbles", "LastFMImport")
lastfm_enabled_user_ids = UserProfile.objects.filter(
lastfm_username__isnull=False,
@ -125,6 +137,31 @@ def import_lastfm_for_all_users(restart=False):
lastfm_import_count = 0
for user_id in lastfm_enabled_user_ids:
lfm_import = LastFmImport.objects.filter(
user_id=user_id, processed_finished__isnull=False
).last()
if lfm_import:
last_processed = lfm_import.processed_finished
else:
logger.info(
f"Not resuming failed LastFM import {lfm_import.id} for user {user_id}, use restart=True to restart"
"No existing LastFM import, we should start a monthly parsing of lastFm for this user going back to 2002"
)
continue
lfm_client = LastFM(
user=get_user_model().objects.filter(id=user_id).first()
)
has_scrobbles = lfm_client.get_last_scrobbles(
time_from=last_processed, check=True
)
if not has_scrobbles:
logger.info("No new scrobbles to import from LastFM")
continue
lfm_import, created = LastFmImport.objects.get_or_create(
user_id=user_id, processed_finished__isnull=True
)
@ -275,36 +312,6 @@ def get_file_md5_hash(file_path: str) -> str:
return file_hash.hexdigest()
def deduplicate_tracks(commit=False) -> int:
from music.models import Track
# TODO This whole thing should iterate over users
dups = []
for t in Track.objects.all():
if Track.objects.filter(title=t.title, artist=t.artist).exists():
dups.append(t)
for b in dups:
tracks = Track.objects.filter(artist=b.artist, title=b.title)
first = tracks.first()
for other in tracks.exclude(id=first.id):
print("moving scrobbles for ", other.id, " to ", first.id)
if commit:
with transaction.atomic():
other.scrobble_set.update(track=first)
print("deleting ", other.id, " - ", other)
try:
other.delete()
except IntegrityError as e:
print(
"could not delete ",
other.id,
f": IntegrityError {e}",
)
return len(dups)
def send_stop_notifications_for_in_progress_scrobbles() -> int:
"""Get all inprogress scrobbles and check if they're passed their media obj length.
@ -313,7 +320,7 @@ def send_stop_notifications_for_in_progress_scrobbles() -> int:
scrobbles_in_progress_qs = Scrobble.objects.filter(
played_to_completion=False, in_progress=True
)
).exclude(media_type=Scrobble.MediaType.GEO_LOCATION)
notifications_sent = 0
for scrobble in scrobbles_in_progress_qs:
@ -322,7 +329,20 @@ def send_stop_notifications_for_in_progress_scrobbles() -> int:
).seconds
if elapsed_scrobble_seconds > scrobble.media_obj.run_time_seconds:
NtfyNotification(scrobble, end=True).send()
ScrobbleNtfyNotification(scrobble, end=True).send()
notifications_sent += 1
return notifications_sent
def send_mood_checkin_reminders() -> int:
"""Get all profiles with mood check-ins enabled and checkin!"""
from profiles.models import UserProfile
now = timezone.now()
notifications_sent = 0
for profile in UserProfile.objects.filter(mood_checkin_enabled=True):
if profile.mood_checkin_frequency == "hourly" and now.minute == 0:
MoodNtfyNotification(profile).send()
notifications_sent += 1
return notifications_sent
@ -336,3 +356,94 @@ def extract_domain(url):
+ parsed_url.netloc.split(".")[-1]
)
return domain
def fix_playback_position_seconds(*scrobbles: "Scrobble", commit=True) -> list["Scrobble"]:
updated_scrobbles = list()
for scrobble in scrobbles:
if not scrobble.media_obj:
logger.info(f"No media object found for scrobble {scrobble.id}, cannot update elapsed time")
continue
if scrobble.media_type == "Track" and scrobble.media_obj.run_time_seconds:
too_long = scrobble.playback_position_seconds > scrobble.media_obj.run_time_seconds
zero = scrobble.playback_position_seconds == 0
null = not scrobble.playback_position_seconds
if too_long or zero or null:
scrobble.playback_position_seconds = scrobble.media_obj.run_time_seconds
updated_scrobbles.append(scrobble)
if commit:
scrobble.save(update_fields=["playback_position_seconds"])
return updated_scrobbles
def base_scrobble_qs(user_id: int) -> models.QuerySet:
"""Base queryset with calories extracted as integer and day annotated."""
from scrobbles.models import Scrobble
return (
Scrobble.objects
.annotate(day=TruncDate("timestamp"))
.annotate(calories_int=Cast(KeyTextTransform("calories", "log"), models.IntegerField()))
.filter(calories_int__isnull=False, user_id=user_id)
)
def get_daily_calories_for_user_by_day(user_id: int, date: date| str) -> int:
"""Return total calories for a user on a specific day."""
if isinstance(date, str):
date = pendulum.parse(date)
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}
def remove_last_part(url: str) -> str:
url = url.rstrip('/')
if '/' not in url:
return url
return url.rsplit('/', 1)[0]
def next_url_if_exists(url: str) -> str:
# Normalize (remove trailing slash)
url = url.rstrip('/')
# Find last number in the URL path
match = re.search(r'(\d+)(?:/?$)', url)
if not match:
logger.info("No numeric segment found in the URL", extra={"url": url})
return ""
number = int(match.group(1))
new_number = number + 1
# Replace only the last occurrence of that number
new_url = re.sub(rf'{number}(?:/?$)', f'{new_number}/', url + '/', 1)
# Check if the new URL exists
try:
resp = requests.head(new_url, allow_redirects=True, timeout=5)
if resp.status_code == 200:
return new_url
else:
# Fallback: some sites may not support HEAD well — try GET
resp = requests.get(new_url, timeout=5)
if resp.status_code == 200:
return new_url
except requests.RequestException:
pass
# If it doesnt exist
return ""

View File

@ -2,23 +2,27 @@ import calendar
import json
import logging
from datetime import datetime, timedelta
from dateutil.relativedelta import relativedelta
import pendulum
import pytz
from dateutil.relativedelta import relativedelta
from django.apps import apps
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Count, Q
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
from django.db.models import Count, Max, Q
from django.db.models.query import QuerySet
from django.http import FileResponse, HttpResponseRedirect, JsonResponse
from django.shortcuts import redirect
from django.urls import reverse_lazy
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import DetailView, FormView, TemplateView
from django.views.generic.edit import CreateView
from django.views.generic.list import ListView
from moods.models import Mood
from music.aggregators import live_charts, scrobble_counts, week_of_scrobbles
from pendulum.parsing.exceptions import ParserError
from rest_framework import status
from rest_framework.decorators import (
api_view,
@ -51,6 +55,7 @@ from scrobbles.tasks import (
process_tsv_import,
)
from scrobbles.utils import (
get_daily_calories_for_user_by_day,
get_long_plays_completed,
get_long_plays_in_progress,
)
@ -64,15 +69,22 @@ class ScrobbleableListView(ListView):
def get_queryset(self):
queryset = super().get_queryset()
if self.model == Mood:
return queryset
user_filter = Q()
if not self.request.user.is_anonymous:
queryset = queryset.annotate(
scrobble_count=Count("scrobble"),
filter=Q(scrobble__user=self.request.user),
).order_by("-scrobble_count")
else:
queryset = queryset.annotate(
scrobble_count=Count("scrobble")
).order_by("-scrobble_count")
user_filter = Q(scrobble__user=self.request.user)
queryset = (
queryset.filter(user_filter)
.annotate(
scrobble_count=Count("scrobble", distinct=True),
last_scrobble=Max("scrobble__timestamp"),
)
.filter(scrobble_count__gt=0)
.order_by("-last_scrobble")
)
return queryset
@ -80,13 +92,30 @@ class ScrobbleableDetailView(DetailView):
model = None
slug_field = "uuid"
paginate_by = 200 # You can set this to whatever page size you want
def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs)
context_data["scrobbles"] = list()
scrobbles = []
if not self.request.user.is_anonymous:
context_data["scrobbles"] = self.object.scrobble_set.filter(
scrobbles = self.object.scrobble_set.filter(
user=self.request.user
)
).order_by("-timestamp")
paginator = Paginator(scrobbles, self.paginate_by)
page_number = self.request.GET.get("page")
try:
page_obj = paginator.page(page_number)
except PageNotAnInteger:
page_obj = paginator.page(1)
except EmptyPage:
page_obj = paginator.page(paginator.num_pages)
context_data["page_obj"] = page_obj
context_data["scrobbles"] = page_obj.object_list
context_data["is_paginated"] = paginator.num_pages > 1
return context_data
@ -118,7 +147,7 @@ class RecentScrobbleList(ListView):
if date_str:
try:
date = pendulum.parse(date_str)
except:
except ParserError:
pass
if date_str:
if date_str == "this_week" or "-W" in date_str:
@ -201,7 +230,8 @@ class RecentScrobbleList(ListView):
processed_finished__isnull=True,
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:
data["weekly_data"] = week_of_scrobbles()
data["counts"] = scrobble_counts()
@ -595,9 +625,9 @@ def scrobble_start(request, uuid):
if (
user.profile.redirect_to_webpage
and media_obj.__class__.__name__ == Scrobble.MediaType.WEBPAGE
and (media_obj.__class__.__name__ == Scrobble.MediaType.WEBPAGE or media_obj.__class__.__name__ == Scrobble.MediaType.BOOK)
):
logger.info(f"Redirecting to {media_obj} detail apge")
logger.info(f"Redirecting to {media_obj} detail page")
return HttpResponseRedirect(media_obj.url)
return HttpResponseRedirect(success_url)
@ -904,3 +934,60 @@ class ScrobbleStatusView(LoginRequiredMixin, TemplateView):
).first()
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,13 +0,0 @@
TODOIST_TITLE_PREFIX_LABELS = ["work", "home", "personal"]
TODOIST_TITLE_SUFFIX_LABELS = [
"bug",
"project",
"errand",
"chore",
"admin",
"meeting",
"habit",
"research",
"exercise",
"lifeevent",
]

View File

@ -1,11 +1,10 @@
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
from django.apps import apps
from django.db import models
from django.urls import reverse
from scrobbles.dataclasses import JSONDataclass
from scrobbles.dataclasses import BaseLogData
from scrobbles.mixins import LongPlayScrobblableMixin, ScrobblableConstants
BNULL = {"blank": True, "null": True}
@ -14,14 +13,41 @@ TODOIST_TASK_URL = "https://app.todoist.com/app/task/{id}"
@dataclass
class TaskLogData(JSONDataclass):
description: Optional[str] = None
class TaskLogData(BaseLogData):
title: Optional[str] = None
project: Optional[str] = None
labels: Optional[list[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_event: Optional[str] = None
todoist_type: Optional[str] = None
notes: Optional[dict] = None
todoist_project_id: Optional[str] = None
_excluded_fields = {
"labels",
"orgmode_id",
"orgmode_state",
"orgmode_properties",
"orgmode_drawers",
"orgmode_timestamps",
"todoist_id",
"todoist_project_id",
}
def notes_as_str(self) -> str:
"""Return formatted notes with line breaks and no keys"""
note_block = ""
if isinstance(self.notes, list):
note_block = "</br>".join(self.notes)
# DEPRECATED ... we don't store notes in dicts anymore
if isinstance(self.notes, dict):
for id, content in self.notes.items():
note_block += content + "</br>"
return note_block
class Task(LongPlayScrobblableMixin):
@ -42,9 +68,9 @@ class Task(LongPlayScrobblableMixin):
def strings(self) -> ScrobblableConstants:
return ScrobblableConstants(verb="Doing", tags="memo")
# @property
# def logdata_cls(self):
# return TaskLogData
@property
def logdata_cls(self):
return TaskLogData
def source_url_for_user(self, user_id) -> str:
url = ""
@ -56,7 +82,7 @@ class Task(LongPlayScrobblableMixin):
def subtitle_for_user(self, user_id):
scrobble = self.scrobbles(user_id).first()
return scrobble.logdata.description or ""
return scrobble.logdata.title or ""
@classmethod
def find_or_create(cls, title: str) -> "Task":

View File

@ -0,0 +1,106 @@
import logging
from datetime import timedelta
from django.conf import settings
from scrobbles.models import Scrobble
logger = logging.getLogger(__name__)
def get_title_from_labels(
labels: list[str], user_context_labels: list[str] = []
) -> str:
title = "Unknown"
for label in labels:
label = label.capitalize()
if label in user_context_labels:
title = label
continue
if title == "Unknown":
logger.warning(
"Missing a configured title context for task",
extra={
"labels": labels,
"user_context_labels": user_context_labels,
},
)
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

@ -71,9 +71,9 @@ def todoist_webhook(request):
"updated_at": task_data.get("updated_at"),
"details": task_data.get("description"),
"notes": event_data.get("content"),
"is_deleted": True
if event_data.get("is_deleted") == "true"
else False,
"is_deleted": (
True if event_data.get("is_deleted") == "true" else False
),
}
if (is_added and not todoist_note) or (is_updated and not todoist_task):
@ -90,20 +90,23 @@ def todoist_webhook(request):
)
return Response({}, status=status.HTTP_304_NOT_MODIFIED)
user_id = (
UserProfile.objects.filter(todoist_user_id=post_data.get("user_id"))
.first()
.user_id
)
user_profile = UserProfile.objects.filter(
todoist_user_id=post_data.get("user_id", None)
).first()
scrobble = None
if todoist_task:
scrobble = todoist_scrobble_task(
todoist_task, user_id, stopped=task_stopped
todoist_task,
user_profile.user_id,
stopped=task_stopped,
user_context_list=user_profile.task_context_tags,
)
if todoist_note:
scrobble = todoist_scrobble_update_task(todoist_note, user_id)
scrobble = todoist_scrobble_update_task(
todoist_note, user_profile.user_id
)
if not scrobble:
logger.info(
@ -143,10 +146,16 @@ def emacs_webhook(request):
if not user_id:
user_id = 1
user_profile = UserProfile.objects.filter(user_id=user_id).first()
scrobble = None
if post_data.get("source_id"):
scrobble = emacs_scrobble_task(
post_data, user_id, started=task_in_progress, stopped=task_stopped
post_data,
user_id,
started=task_in_progress,
stopped=task_stopped,
user_context_list=user_profile.task_context_tags,
)
if not scrobble:

View File

@ -0,0 +1,23 @@
# Generated by Django 4.2.19 on 2025-06-16 14:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("trails", "0004_alter_trail_run_time_seconds"),
]
operations = [
migrations.AddField(
model_name="trail",
name="alltrails_id",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name="trail",
name="gaiagps_id",
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View File

@ -1,17 +1,24 @@
from django.utils.translation import gettext_lazy as _
from dataclasses import dataclass
from typing import Optional
from django.apps import apps
from django.db import models
from django.urls import reverse
from scrobbles.dataclasses import TrailLogData
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
from django.utils.translation import gettext_lazy as _
from locations.models import GeoLocation
from scrobbles.dataclasses import BaseLogData, WithPeopleLogData
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
BNULL = {"blank": True, "null": True}
@dataclass
class TrailLogData(BaseLogData, WithPeopleLogData):
effort: Optional[str] = None
difficulty: Optional[str] = None
class Trail(ScrobblableMixin):
class PrincipalType(models.TextChoices):
WOODS = "WOODS"
ROAD = "ROAD"
@ -19,7 +26,7 @@ class Trail(ScrobblableMixin):
MOUNTAIN = "MOUNTAIN"
class ActivityType(models.TextChoices):
WALK= "WALK"
WALK = "WALK"
HIKE = "HIKE"
RUN = "RUN"
BIKE = "BIKE"
@ -39,8 +46,14 @@ class Trail(ScrobblableMixin):
)
strava_id = models.CharField(max_length=255, **BNULL)
trailforks_id = models.CharField(max_length=255, **BNULL)
principal_type = models.CharField(max_length=10, choices=PrincipalType.choices, **BNULL)
default_activity_type = models.CharField(max_length=10, choices=ActivityType.choices, **BNULL)
alltrails_id = models.CharField(max_length=255, **BNULL)
gaiagps_id = models.CharField(max_length=255, **BNULL)
principal_type = models.CharField(
max_length=10, choices=PrincipalType.choices, **BNULL
)
default_activity_type = models.CharField(
max_length=10, choices=ActivityType.choices, **BNULL
)
def get_absolute_url(self):
return reverse("trails:trail_detail", kwargs={"slug": self.uuid})

View File

@ -0,0 +1,61 @@
from typing import Optional
import requests
from bs4 import BeautifulSoup
def get_description_from_soup(soup) -> Optional[int]:
rating = None
try:
potential_desc = soup.find("div", id_="descriptionContainer")
potential_rating = soup.find("div", class_="allmusic-rating")
if potential_rating:
rating = int(strip_and_clean(potential_rating.get_text()))
except ValueError:
pass
return rating
def scrape_gaia_trail(url):
headers = {"User-Agent": "Mozilla/5.0"}
r = requests.get(url, headers=headers)
r.raise_for_status()
soup = BeautifulSoup(r.text, "html.parser")
# Title
title = soup.select_one("h1").get_text(strip=True)
# Stats are inside divs with classes like "flex" and label-value pairs
stats = {}
stat_blocks = soup.select("div.flex.flex-col.items-start.text-sm span")
for i in range(0, len(stat_blocks) - 1, 2):
label = stat_blocks[i].get_text(strip=True)
value = stat_blocks[i + 1].get_text(strip=True)
stats[label] = value
# Description (under 'Overview' header)
overview_heading = soup.find(
lambda tag: tag.name == "h2" and "Overview" in tag.text
)
if overview_heading:
desc_parts = []
for sib in overview_heading.next_siblings:
if sib.name and sib.name.startswith("h"):
break
if sib.name == "p":
desc_parts.append(sib.get_text(strip=True))
description = "\n\n".join(desc_parts).strip()
else:
description = None
return {
"url": url,
"title": title,
"stats": stats,
"description": description,
}
# Example usage
url = "https://www.gaiagps.com/hike/318136/"
trail_data = scrape_gaia_trail(url)
print(trail_data)

View File

@ -1,6 +1,9 @@
from dataclasses import dataclass
import logging
from typing import Optional
from uuid import uuid4
from django import forms
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import models
@ -8,7 +11,11 @@ from django.urls import reverse
from django_extensions.db.models import TimeStampedModel
from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit
from scrobbles.dataclasses import VideoGameLogData
from scrobbles.dataclasses import (
BaseLogData,
LongPlayLogData,
WithPeopleLogData,
)
from scrobbles.mixins import LongPlayScrobblableMixin, ScrobblableConstants
from scrobbles.utils import get_scrobbles_for_media
from videogames.igdb import lookup_game_id_from_gdb
@ -18,6 +25,29 @@ BNULL = {"blank": True, "null": True}
User = get_user_model()
@dataclass
class VideoGameLogData(BaseLogData, LongPlayLogData, WithPeopleLogData):
platform_id: Optional[int] = None
emulated: Optional[bool] = False
emulator: Optional[str] = None
@property
def platform(self):
if not self.platform_id:
return
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):
name = models.CharField(max_length=255)
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)

View File

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

View File

@ -1,3 +1,4 @@
from dataclasses import dataclass
import logging
from typing import Optional
from uuid import uuid4
@ -22,6 +23,7 @@ from videos.metadata import VideoMetadata
from videos.sources.imdb import lookup_video_from_imdb
from videos.sources.tmdb import lookup_video_from_tmdb
from videos.sources.youtube import lookup_video_from_youtube
from vrobbler.apps.scrobbles.dataclasses import BaseLogData, WithPeopleLogData
YOUTUBE_VIDEO_URL = "https://www.youtube.com/watch?v="
YOUTUBE_CHANNEL_URL = "https://www.youtube.com/channel/"
@ -31,6 +33,11 @@ logger = logging.getLogger(__name__)
BNULL = {"blank": True, "null": True}
@dataclass
class VideoLogData(BaseLogData, WithPeopleLogData):
rating: Optional[int] = None
class Channel(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
name = models.CharField(max_length=255)
@ -243,6 +250,10 @@ class Video(ScrobblableMixin):
def get_absolute_url(self):
return reverse("videos:video_detail", kwargs={"slug": self.uuid})
@property
def logdata_cls(self):
return VideoLogData
@property
def subtitle(self):
if self.tv_series:

View File

@ -1,17 +1,17 @@
import logging
import pendulum
from django.conf import settings
from themoviedb import TMDb
from tmdbv3api import TV, TMDb as TMDb_direct
from tmdbv3api import TV, Movie, TMDb as TMDb_direct
from videos.metadata import VideoMetadata, VideoType
key = getattr(settings, "TMDB_API_KEY", "33de8d24785931068ae356510dcfbac8")
key = "33de8d24785931068ae356510dcfbac8"
TMDB_KEY = getattr(settings, "TMDB_API_KEY", "")
tmdb_direct = TMDb_direct()
tmdb_direct.api_key = "33de8d24785931068ae356510dcfbac8"
tmdb_direct.api_key = TMDB_KEY
tmdb = TMDb(key=key, language="en-US", region="US")
tmdb = TMDb(key=TMDB_KEY, language="en-US", region="US")
TMDB_IMAGE_URL = "https://image.tmdb.org/t/p/original"
@ -28,7 +28,6 @@ def lookup_video_from_tmdb(
imdb_id = name_or_id[2:]
video_metadata = VideoMetadata(imdb_id=imdb_id)
imdb_result: dict = {}
tmdb_result = tmdb.find().by_imdb("tt" + imdb_id)
@ -44,21 +43,29 @@ def lookup_video_from_tmdb(
media = None
show = None
if len(tmdb_result.movie_results) > 0:
media = tmdb_result.movie_results[0]
media = Movie().details(tmdb_result.movie_results[0].id)
video_metadata.video_type = VideoType.MOVIE.value
video_metadata.title = media.title
video_metadata.cover_url = TMDB_IMAGE_URL + media.poster_path # TODO: enrich this with TMDB url
video_metadata.year = pendulum.parse(media.release_date).year
video_metadata.genres = [g.get("name", "") for g in media.genres]
if len(tmdb_result.tv_episode_results) > 0:
video_metadata.video_type = VideoType.TV_EPISODE.value
media = tmdb_result.tv_episode_results[0]
series_imdb_id = TV().external_ids(media.show_id).imdb_id[2:]
video_metadata.title = media.name
video_metadata.cover_url = TMDB_IMAGE_URL + media.still_path # TODO: enrich this with TMDB url
video_metadata.episode_number = media.episode_number
video_metadata.season_number = media.season_number
video_metadata.year = media.air_date.year
series_imdb_id = TV().external_ids(media.show_id).imdb_id[2:]
series, created = Series.objects.get_or_create(imdb_id=series_imdb_id)
if created:
show_data = TV().details(tv_id=media.show_id)
series.name = show_data.name
series.save()
video_metadata.tv_series_id = series.id
video_metadata.cover_url = TMDB_IMAGE_URL + media.still_path # TODO: enrich this with TMDB url
if not media:
logger.warning("Video not found on TMDB", extra={"imdb_id":imdb_id})
@ -66,13 +73,9 @@ def lookup_video_from_tmdb(
video_metadata.tmdb_id = media.id
video_metadata.run_time_seconds = media.runtime * 60
video_metadata.title = media.name
video_metadata.episode_number = media.episode_number
video_metadata.season_number = media.season_number
#video_metadata.next_imdb_id = imdb_result.get("next episode", None)
video_metadata.year = media.air_date.year
video_metadata.plot = media.overview
video_metadata.imdb_rating = media.vote_average
#video_metadata.genres = imdb_result.get("genres", [])
video_metadata.overview = media.overview
video_metadata.tmdb_rating = media.vote_average
#video_metadata.next_imdb_id = imdb_result.get("next episode", None)
return video_metadata

View File

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

View File

@ -67,10 +67,10 @@ class WebPage(ScrobblableMixin):
self.save(update_fields=["extract"])
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):
return reverse("webpages:webpage-read", kwargs={"slug": self.uuid})
return reverse("webpages:webpage_read", kwargs={"slug": self.uuid})
@property
def estimated_time_to_read_in_seconds(self):

View File

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

View File

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

View File

@ -92,7 +92,7 @@ DEFAULT_TASK_CONTEXT_TAGS = [
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
TIME_ZONE = os.getenv("VROBBLER_TIME_ZONE", "US/Eastern")
TIME_ZONE = os.getenv("VROBBLER_TIME_ZONE", "America/New_York")
ALLOWED_HOSTS = ["*"]
CSRF_TRUSTED_ORIGINS = [
@ -111,7 +111,7 @@ CELERY_TASK_ALWAYS_EAGER = (
)
CELERY_BROKER_URL = REDIS_URL if REDIS_URL else "memory://localhost/"
CELERY_RESULT_BACKEND = "django-db"
CELERY_TIMEZONE = os.getenv("VROBBLER_TIME_ZONE", "US/Eastern")
CELERY_TIMEZONE = os.getenv("VROBBLER_TIME_ZONE", "America/New_York")
CELERY_TASK_TRACK_STARTED = True
INSTALLED_APPS = [
@ -134,6 +134,7 @@ INSTALLED_APPS = [
"encrypted_field",
"profiles",
"scrobbles",
"people",
"videos",
"music",
"podcasts",

View File

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

View File

@ -175,7 +175,7 @@
</head>
<body>
<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">
<span class="navbar-toggler-icon"></span>
</button>
@ -208,85 +208,8 @@
{% endfor %}
</ul>
{% 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 %}
{% endblock %}
<hr/>
{% if now_playing_list and user.is_authenticated %}
<ul>
@ -296,8 +219,8 @@
{% if scrobble.media_obj.primary_image_url %}<div style="float:left;padding-right:10px;padding-bottom:10px;"><img src="{{scrobble.media_obj.primary_image_url}}" /></div>{% endif %}
<p><a href="{{scrobble.media_obj.get_absolute_url}}">{{scrobble.media_obj.title}}</a></p>
{% if scrobble.media_obj.subtitle %}<p><em><a href="{{scrobble.media_obj.subtitle.get_absolute_url}}">{{scrobble.media_obj.subtitle}}</a></em></p>{% endif %}
{% if scrobble.logdata %}{% if scrobble.logdata.description %}<p><em>{{scrobble.logdata.description}}</em></p>{% endif %}{% endif %}
<p><small>{{scrobble.timestamp|naturaltime}} from {{scrobble.source}}</small></p>
{% if scrobble.logdata %}{% if scrobble.logdata.title%}<p><em>{{scrobble.logdata.title}}</em></p>{% endif %}{% endif %}
<p><small>{{scrobble.local_timestamp|naturaltime}} from {{scrobble.source}}</small></p>
<div class="progress-bar" style="margin-right:5px;">
<span class="progress-bar-fill" style="width: {{scrobble.percent_played}}%;"></span>
</div>
@ -329,5 +252,21 @@
</div>
{% 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>
</html>

View File

@ -39,7 +39,7 @@
</div>
</div>
<div class="row">
<p>{{object.scrobble_set.count}} scrobbles</p>
<p>{{scrobbles.count}} scrobbles</p>
<p>
<a href="{{object.start_url}}">Drink again</a>
</p>
@ -55,9 +55,9 @@
</tr>
</thead>
<tbody>
{% for scrobble in object.scrobble_set.all|dictsortreversed:"timestamp" %}
{% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
<tr>
<td>{{scrobble.timestamp}}</td>
<td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td>
</tr>
{% endfor %}
</tbody>

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