Compare commits

...

65 Commits
0.16.2 ... 17.2

Author SHA1 Message Date
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
a872cf3611 [templates] Use TMDB rating ID if available 2025-06-13 11:47:42 -04:00
1f9713312b [videos] Fix lookup for mevie posters 2025-06-13 11:47:04 -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
ddd5ce1392 Merge branch 'develop' 2025-06-13 11:21:07 -04:00
7a75b31b56 [todos] Update task for metadata fixing 2025-06-13 11:20:34 -04:00
24ac545f55 [videos] Switch to TMDB for scraping videos 2025-06-13 11:19:15 -04:00
d5da8ae701 [todos] Update priority task 2025-06-13 09:43:08 -04:00
53a04d064d [videos] At least stop IMDB from breaking 2025-06-13 09:15:48 -04:00
c0871a3b9e [scrobbles] Fix failing tests 2025-06-12 09:57:32 -04:00
d917dd8b2c [tasks] Fix source checking for emacs/orgmode tasks when updating 2025-06-12 09:54:16 -04:00
6fc8084f2d [scrobbles] Fix log of media type 2025-06-11 12:01:02 -04:00
41d6fe8ff6 [todos] Add new task 2025-06-10 11:27:37 -04:00
73838312cd [tasks] Clean up emacs webhook flow 2025-06-10 11:06:47 -04:00
eb2dd4c839 [todos] Clean up todos and add new milestone 17 2025-06-10 10:05:40 -04:00
1a0c4f69f0 Merge branch 'develop' 2025-06-09 11:26:25 -04:00
356e579558 [brickset] Little fix to scrobble of bricksets 2025-06-09 10:40:12 -04:00
e980e3c5c9 [brickset] Fix manual scrobbling and admin 2025-06-09 10:36:13 -04:00
8773542099 [tasks] Fix source being orgmode not emacs 2025-06-08 03:05:43 -04:00
70378c9968 [tasks] Fix updating and stopping tasks from Emacs 2025-06-08 02:40:07 -04:00
c871087496 [tasks] First try at adding an emacs webhook 2025-06-08 01:09:03 -04:00
059d7780a0 [templates] Add counts and durations to new tables 2025-06-07 20:02:21 -04:00
db36329011 Merge branch 'develop' 2025-06-07 18:11:08 -04:00
69aa80e6c1 [templates] Allow going back and forward in time 2025-06-07 15:17:56 -04:00
99a6e5107b [scrobbles] Add orgparse and start of emacs webhook 2025-06-06 17:25:04 -04:00
39e2fdce27 [music] Fix allmusic lookup 2025-06-06 17:24:41 -04:00
b2f98d780b [middleware] Add auto timezone setting 2025-06-06 09:49:53 -04:00
07b5dc6a2c [templates] Clean up dashboard 2025-06-06 09:49:08 -04:00
7207ca385e [scrobbles] Add two new methods to Scrobble model 2025-06-06 09:48:15 -04:00
2a254e28d0 [templates] Start redesigning the dashboard 2025-05-18 23:20:47 -04:00
d4377e49ac [music] Fix getting album metadata 2025-05-18 23:19:17 -04:00
9dc0a818ff [templates] Fix chart loading 2025-05-18 23:19:05 -04:00
5e672fc9ed [webpages] Add title to webpage reading 2025-05-18 09:37:53 -04:00
6d194d227e Merge branch 'develop' 2025-05-11 01:43:55 -04:00
ed217cbad2 [beers] Blacken and clean up imports 2025-05-11 01:42:30 -04:00
3cca30dc70 [puzzles] Add puzzle lookup via IPDB 2025-05-11 01:41:43 -04:00
d3a15d5e7b Merge branch 'develop' 2025-05-11 00:03:48 -04:00
deeaa0af4b [puzzles] Fix misnamed ipdb field 2025-05-10 23:58:44 -04:00
dd71bdd38c [puzzles] Add puzzle model and hooks 2025-05-10 23:36:03 -04:00
d066a98282 [importers] Fixes TSV comma missing 2025-04-15 16:32:14 -04:00
73bc4a1cd1 [webpages] Fixes 500 errors when webpage lookup fails 2025-04-15 16:18:26 -04:00
45 changed files with 1839 additions and 759 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,196 +1,64 @@
#+title: TODOs
A fun way to keep track of things in the project to fix or improve.
* Backlog [0/17]
** TODO [#A] Tasks from org-mode should properlly update notes and leave them out of the body :vrobbler:bug:tasks:
** TODO [#A] Fix small bug in views for TV series where next episode is now None :vrobbler:bug:personal:videos:
* Version 0.16.0
** TODO [#A] Jellyfin, bandcamp tracks from Mopidy create duplicate music tracks :bug:scrobbling:music:
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 everytime. This has to be cleaend up
by condensing the duplicated tracks into the original proper track.
But it opens a bigger quesiton 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
** 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
,
#+begin_src python
ERROR django.request:241 log_response Internal Server Error: /series/c24100d1-da45-4abe-86bf-27cfce9b1f89/
Traceback (most recent call last):
File "/usr/local/lib/python3.11/site-packages/django/core/handlers/exception.py", line 55, in inner
response = get_response(request)
^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/django/core/handlers/base.py", line 197, in _get_response
response = wrapped_callback(request, *callback_args, **callback_kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/django/views/generic/base.py", line 104, in view
return self.dispatch(request, *args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/django/contrib/auth/mixins.py", line 73, in dispatch
return super().dispatch(request, *args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/django/views/generic/base.py", line 143, in dispatch
return handler(request, *args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/django/views/generic/detail.py", line 109, in get
context = self.get_context_data(object=self.object)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/videos/views.py", line 33, in get_context_data
context_data["next_episode_id"] = "tt" + next_episode_id
~~~~~^~~~~~~~~~~~~~~~~
TypeError: can only concatenate str (not "NoneType") to str
#+end_src
** DONE [#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]
** TODO [#A] Send periodic check notifications for board games and beer that ask if you're still scrobbling :vrobbler:personal:project:beers:boardgames:notifications:feature:
** TODO [#A] Fix raw text webpage title not truncating to 254 chars :vrobbler:personal:bug:webpages:
** TODO [#A] Fix koreader scrobble imports to use DST properly :vrobbler:personal:bug:books:imports:
The page data has the canonical date something was read in it, but it seems to be an hour off. I traced this back to being off during DST, so we just need the importer to be aware of whether a user is using DST or not and roll back an hour for part of the year.
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
Also, we'd need to adjust any old scrobbles that took place with DST off to roll them back by an hour.
** TODO [#A] Create small utility to clean up tracks scrobbled with wonky playback times :vrobbler:personal:bug:music:scrobbles:
** TODO [#B] Add AllTrails as a source for Trail data :vrobbler:trails:feature:personal:project:
Pretty clear, I would love to make trails more useful. Historically I wasn't
hiking a lot, which made the source for this a bit silly. But it's clear that
AllTrails is the best source, though having TrailForks is nice to.
** TODO [#B] Add `garmin_activity_id` to the TrailMetadataLog class :vrobbler:trails:feature:personal:project:
Would be nice to have some loose connection to the actual event in my Garmin profile.
** TODO [#B] Explore a way to add metadata editing to scrobbles after saving :vrobbler:spike:scrobbling:personal:project:
Could be as simple as a JSON form on the scrobble detail page (do I have have one of those yet?).
** TODO [#B] Explore a good way to show notes and descriptions from scrobbles to users :personal:project:scrobbling:vrobbler:spike:
** TODO [#B] Add webdav syncing to retroarch imports :vrobbler:videogames:webdav:feature:project:personal:
** TODO [#B] Add CSV endpoint for book scrobbles that LibraryThing can ingest :personal:project:books:feature:export:
https://app.todoist.com/app/task/add-a-csv-endpoint-for-users-book-reads-that-library-thing-can-ingest-6X7QPMRp265xMXqg#comment-6X7QrXq6gJjMP4hg
** TODO [#C] Fix bug where podcast scrobbling creates duplicate Podcast :project:vrobbler:scrobbling:podcasts:bug:personal:
Rather than pick up an existing Podcast using the podcast title in the mopidy
file name, Vrobbler creates a new podcast with no enriched data. Not a big deal
for my use as the volume of podcasts I listen to makes manual fixes easy. But
it's annoying.
** TODO [#C] Move to using more robust mopidy-webhooks pacakge form pypi :utility:improvement:
:PROPERTIES:
:ID: ab31fdc3-359c-1b1d-6b9d-546b476021ba
:END:
*** Example payloads from mopidy-webhooks
**** Podcast playback ended
#+begin_src json
@ -481,4 +349,265 @@ has to re-populate when the server restarts.
}
}
#+end_src
** TODO Fix bug in podcast scrobbling where a second scrobble is created after completion :scrobbling:podcasts:bug:
** 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:
* Version 17.1 [1/1]
** 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: 1ec89c57-0bb8-3401-33bd-ba65127ed36b
: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
** 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
** 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.

62
poetry.lock generated
View File

@ -1065,6 +1065,21 @@ ssh = ["bcrypt (>=3.1.5)"]
test = ["certifi", "cryptography-vectors (==43.0.3)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"]
test-randomorder = ["pytest-randomly"]
[[package]]
name = "dacite"
version = "1.9.2"
description = "Simple creation of data classes from dictionaries."
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "dacite-1.9.2-py3-none-any.whl", hash = "sha256:053f7c3f5128ca2e9aceb66892b1a3c8936d02c686e707bee96e19deef4bc4a0"},
{file = "dacite-1.9.2.tar.gz", hash = "sha256:6ccc3b299727c7aa17582f0021f6ae14d5de47c7227932c47fec4cdfefd26f09"},
]
[package.extras]
dev = ["black", "coveralls", "mypy", "pre-commit", "pylint", "pytest (>=5)", "pytest-benchmark", "pytest-cov"]
[[package]]
name = "dataclass-wizard"
version = "0.22.0"
@ -2228,6 +2243,7 @@ python-versions = "*"
groups = ["main"]
files = [
{file = "jusText-3.0.1-py2.py3-none-any.whl", hash = "sha256:e0fb882dd7285415709f4b7466aed23d6b98b7b89404c36e8a2e730facfed02b"},
{file = "justext-3.0.1-py2.py3-none-any.whl", hash = "sha256:0a5225c5cd7c5a124fec7bfa9a55110a73135e8b58ce784470af67d051ac9fd3"},
{file = "justext-3.0.1.tar.gz", hash = "sha256:b6ed2fb6c5d21618e2e34b2295c4edfc0bcece3bd549ed5c8ef5a8d20f0b3451"},
]
@ -2891,6 +2907,18 @@ rsa = ["cryptography (>=3.0.0)"]
signals = ["blinker (>=1.4.0)"]
signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"]
[[package]]
name = "orgparse"
version = "0.4.20250520"
description = "orgparse - Emacs org-mode parser in Python"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "orgparse-0.4.20250520-py3-none-any.whl", hash = "sha256:24d454432385016ae91c3518c8357a3a31fdd4cebfbb0c5926cf31247bf4c7e3"},
{file = "orgparse-0.4.20250520.tar.gz", hash = "sha256:6472fd16ddcabb523918505c865263abfa05ac80b593e668a089cda291b1a2de"},
]
[[package]]
name = "packaging"
version = "24.2"
@ -4597,6 +4625,23 @@ files = [
[package.dependencies]
rapidfuzz = ">=3.0.0,<4.0.0"
[[package]]
name = "themoviedb"
version = "1.0.2"
description = "A modern and easy to use API wrapper for The Movie Database (TMDb) API v3 written in Python"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "themoviedb-1.0.2-py3-none-any.whl", hash = "sha256:badf85e91010c7085509f40270bf2a40ea30ee5ef3ed6fb3ec332c5e50adb576"},
{file = "themoviedb-1.0.2.tar.gz", hash = "sha256:7835615142a44e7ca25e48645a3a3c5e06b382e8c518c38c3537effa9a2596ce"},
]
[package.dependencies]
aiohttp = ">=3.8.4"
dacite = ">=1.8.0"
requests = ">=2.31.0"
[[package]]
name = "time-machine"
version = "2.16.0"
@ -4697,6 +4742,21 @@ files = [
{file = "tld-0.13.tar.gz", hash = "sha256:93dde5e1c04bdf1844976eae440706379d21f4ab235b73c05d7483e074fb5629"},
]
[[package]]
name = "tmdbv3api"
version = "1.9.0"
description = "A lightweight Python library for The Movie Database (TMDb) API."
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "tmdbv3api-1.9.0-py3-none-any.whl", hash = "sha256:2bcd8c6e8902397860715a71045f200ecc3ee06804ecf786cb4c1e09b2deeba8"},
{file = "tmdbv3api-1.9.0.tar.gz", hash = "sha256:504c5da6b99c4516ff160a01576112d097f209c0534f943c15c4b56cbd92c33b"},
]
[package.dependencies]
requests = "*"
[[package]]
name = "toml"
version = "0.10.2"
@ -5439,4 +5499,4 @@ cffi = ["cffi (>=1.11)"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.9,<3.12"
content-hash = "17358679b06dd15b7f119307013ed578ef81dbf31c592bc2ca11d13787a81215"
content-hash = "3a483aefea0a3afebf187b17b7df72a158788024ca8121b512b39567fb5ec8ca"

View File

@ -53,6 +53,9 @@ django-oauth-toolkit = "^3.0.1"
meta-yt = "^0.1.9"
berserk = "^0.13.2"
poetry-bumpversion = "^0.3.3"
orgparse = "^0.4.20250520"
tmdbv3api = "^1.9.0"
themoviedb = "^1.0.2"
[tool.poetry.group.test]
optional = true

View File

@ -1,6 +1,4 @@
import json
import logging
import urllib
from typing import Optional
import requests
@ -87,7 +85,9 @@ def get_ibu_from_soup(soup) -> Optional[int]:
def get_rating_from_soup(soup) -> str:
rating = ""
try:
rating = float(soup.find(class_="num").get_text().strip("(").strip(")"))
rating = float(
soup.find(class_="num").get_text().strip("(").strip(")")
)
except AttributeError:
rating = None
except ValueError:
@ -130,7 +130,6 @@ def get_beer_from_untappd_id(untappd_id: str) -> dict:
return beer_dict
soup = BeautifulSoup(response.text, "html.parser")
beer_dict["untappd_id"] = untappd_id
beer_dict["title"] = get_title_from_soup(soup)
beer_dict["description"] = get_description_from_soup(soup)
beer_dict["styles"] = get_styles_from_soup(soup)

View File

@ -16,7 +16,9 @@ class BrickSet(LongPlayScrobblableMixin):
number = models.CharField(max_length=10, **BNULL)
release_year = models.IntegerField(**BNULL)
piece_count = models.IntegerField(**BNULL)
brickset_rating = models.DecimalField(max_digits=3, decimal_places=1, **BNULL)
brickset_rating = models.DecimalField(
max_digits=3, decimal_places=1, **BNULL
)
lego_item_number = models.CharField(max_length=10, **BNULL)
box_image = models.ImageField(upload_to="brickset/boxes/", **BNULL)
box_image_small = ImageSpecField(
@ -48,13 +50,23 @@ class BrickSet(LongPlayScrobblableMixin):
def get_absolute_url(self):
return reverse("bricksets:brickset_detail", kwargs={"slug": self.uuid})
def __str__(self) -> str:
name = str(self.title)
if not self.title:
name = str(self.number)
return name
@property
def logdata_cls(self):
return BrickSetLogData
@classmethod
def find_or_create(cls, title: str) -> "BrickSet":
return cls.objects.filter(title=title).first()
def find_or_create(cls, brickset_id: str) -> "BrickSet":
brickset = cls.objects.filter(number=brickset_id).first()
if not brickset:
# TODO: enrich this from the website
brickset = cls.objects.create(number=brickset_id)
return brickset
@property
def primary_image_url(self) -> str:

View File

@ -16,6 +16,7 @@ class AlbumAdmin(admin.ModelAdmin):
"theaudiodb_mood",
"musicbrainz_id",
)
raw_id_fields = ("album_artist",)
list_filter = (
"theaudiodb_score",
"theaudiodb_genre",
@ -53,6 +54,10 @@ class TrackAdmin(admin.ModelAdmin):
"artist",
"musicbrainz_id",
)
raw_id_fields = (
"album",
"artist",
)
list_filter = ("album", "artist")
search_fields = ("title",)
ordering = ("-created",)

View File

@ -161,7 +161,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
@ -307,8 +307,15 @@ class Album(TimeStampedModel):
self.save(update_fields=["album_artist"])
def scrape_allmusic(self, force=False) -> None:
if not self.name:
logger.warning(
"Album without a name cannot be scraped",
extra={"album_id": self.id},
)
return
if not self.allmusic_id or force:
slug = get_allmusic_slug(self.name, self.album_artist.name)
slug = get_allmusic_slug(self.album_artist.name, self.name)
if not slug:
logger.info(
f"No allmsuic link for {self} by {self.album_artist}"
@ -317,7 +324,9 @@ class Album(TimeStampedModel):
self.allmusic_id = slug
self.save(update_fields=["allmusic_id"])
allmusic_data = scrape_data_from_allmusic(self.allmusic_link)
allmusic_data = None
if self.allmusic_link:
allmusic_data = scrape_data_from_allmusic(self.allmusic_link)
if not allmusic_data:
logger.info(f"No allmsuic data for {self} by {self.album_artist}")

View File

@ -56,7 +56,7 @@ class UserProfile(TimeStampedModel):
return pytz.timezone(self.timezone)
@cached_property
def task_context_tags(self) -> list:
def task_context_tags(self) -> list[str]:
tag_list = [
t.strip().capitalize()
for t in self.task_context_tags_str.split(",")

View File

@ -0,0 +1,29 @@
# Generated by Django 4.2.19 on 2025-05-11 03:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("puzzles", "0002_alter_puzzle_dimensions_in_inches"),
]
operations = [
migrations.RenameField(
model_name="puzzle",
old_name="igdb_id",
new_name="ipdb_id",
),
migrations.RemoveField(
model_name="puzzle",
name="igdb_image",
),
migrations.AddField(
model_name="puzzle",
name="ipdb_image",
field=models.ImageField(
blank=True, null=True, upload_to="puzzles/ipdb/"
),
),
]

View File

@ -1,12 +1,14 @@
from uuid import uuid4
from puzzles.sources import ipdb
import requests
from django.apps import apps
from django.core.files.base import ContentFile
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 puzzles.sources import ipdb
from scrobbles.dataclasses import PuzzleLogData
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
@ -31,17 +33,17 @@ class Puzzle(ScrobblableMixin):
material = models.CharField(max_length=50, **BNULL)
cut_style = models.CharField(max_length=100, **BNULL)
pieces_count = models.IntegerField(**BNULL)
igdb_id = models.CharField(max_length=255, **BNULL)
ipdb_id = models.CharField(max_length=255, **BNULL)
barcode = models.CharField(max_length=13, **BNULL)
igdb_image = models.ImageField(upload_to="puzzles/igdb/", **BNULL)
igdb_image_small = ImageSpecField(
source="igdb_image",
ipdb_image = models.ImageField(upload_to="puzzles/ipdb/", **BNULL)
ipdb_image_small = ImageSpecField(
source="ipdb_image",
processors=[ResizeToFit(100, 100)],
format="JPEG",
options={"quality": 60},
)
igdb_image_medium = ImageSpecField(
source="igdb_image",
ipdb_image_medium = ImageSpecField(
source="ipdb_image",
processors=[ResizeToFit(300, 300)],
format="JPEG",
options={"quality": 75},
@ -65,10 +67,10 @@ class Puzzle(ScrobblableMixin):
return ScrobblableConstants(verb="Solving", tags="puzzle")
@property
def igdb_link(self) -> str:
def ipdb_link(self) -> str:
link = ""
if self.igdb_id:
link = f"https://www.ipdb.plus/IPDb/puzzle.php?id={self.igdb_id}"
if self.ipdb_id:
link = f"https://www.ipdb.plus/IPDb/puzzle.php?id={self.ipdb_id}"
return link
@property
@ -88,17 +90,31 @@ class Puzzle(ScrobblableMixin):
if not puzzle:
puzzle_dict = ipdb.get_puzzle_from_ipdb_id(ipdb_id)
manufacturer_name = puzzle_dict.pop("manufacturer")
manufacturer, _created = PuzzleManufacturer.objects.get_or_create(
name=manufacturer_name
)
ipdb_dict["manufacturer_id"] = manufacturer.id
manufacturer_name = puzzle_dict.pop("manufacturer", None)
if manufacturer_name:
(
manufacturer,
_created,
) = PuzzleManufacturer.objects.get_or_create(
name=manufacturer_name
)
puzzle_dict["manufacturer_id"] = manufacturer.id
genres = puzzle_dict.pop("genres")
genres = puzzle_dict.pop("genres", None)
cover_url = puzzle_dict.pop("ipdb_image_url", None)
puzzle = Puzzle.objects.create(**puzzle_dict)
if genres:
puzzle.genre.add(*genres)
if cover_url:
r = requests.get(cover_url)
if r.status_code == 200:
fname = f"{puzzle.title}_{puzzle.uuid}.jpg"
puzzle.ipdb_image.save(
fname, ContentFile(r.content), save=True
)
return puzzle
def scrobbles(self, user_id):

View File

@ -1,5 +1,246 @@
#!/usr/bin/env python3
import logging
from typing import Optional
import requests
from bs4 import BeautifulSoup
logger = logging.getLogger(__name__)
IPDB_URL = "https://www.ipdb.plus/IPDb/puzzle.php?id={id}"
def get_first(key: str, result: dict) -> str:
obj = ""
if obj_list := result.get(key):
obj = obj_list[0]
return obj
def get_title_from_soup(soup) -> str:
title = ""
try:
title = soup.find("h1").get_text()
except AttributeError:
pass
except ValueError:
pass
return title
def get_manufacturer_from_soup(soup) -> str:
manufacturer = ""
try:
manufacturer = (
soup.find(class_="infobox").div.contents[0].split("|")[0].strip()
)
except AttributeError:
pass
except ValueError:
pass
except IndexError:
pass
return manufacturer
def get_pieces_count_from_soup(soup) -> int:
pieces = 0
try:
pieces = int(
soup.find(class_="infobox")
.div.contents[0]
.split("|")[1]
.split(" ")[1]
)
except AttributeError:
pass
except ValueError:
pass
except IndexError:
pass
return pieces
def get_publish_year_from_soup(soup) -> int:
year = 1900
try:
year = int(
soup.find(class_="infobox").div.contents[0].split("|")[2].strip()
)
except AttributeError:
pass
except ValueError:
pass
except IndexError:
pass
return year
def get_material_from_soup(soup) -> str:
material = ""
try:
material = (
soup.findAll(class_="infobox")[1]
.findAll("div")[1]
.contents[2]
.split("|")[-1]
.strip()
)
except AttributeError:
pass
except ValueError:
pass
except IndexError:
pass
return material
def get_cut_style_from_soup(soup) -> str:
cut_style = ""
try:
cut_style = (
soup.findAll(class_="infobox")[1]
.findAll("div")[1]
.contents[2]
.split("|")[-2]
.strip()
)
except AttributeError:
pass
except ValueError:
pass
except IndexError:
pass
return cut_style
def get_barcode_from_soup(soup) -> str:
barcode = ""
try:
barcode = (
soup.findAll(class_="infobox")[2]
.findAll("div")[0]
.contents[0]
.split("|")[-1]
.split(":")[-1]
.strip()
)
except AttributeError:
pass
except ValueError:
pass
except IndexError:
pass
if not barcode:
try:
barcode = (
soup.findAll(class_="infobox")[3]
.findAll("div")[0]
.contents[0]
.split("|")[-1]
.split(":")[-1]
.strip()
)
except AttributeError:
pass
except ValueError:
pass
except IndexError:
pass
return barcode
def get_image_url_from_soup(soup) -> str:
url = ""
try:
url = (
soup.find(class_="image-container").contents[0].contents[0]["src"]
)
except AttributeError:
pass
except ValueError:
pass
except IndexError:
pass
return url
def get_orientation_from_soup(soup) -> str:
orientation = ""
try:
orientation = (
soup.findAll(class_="infobox")[1]
.findAll("div")[1]
.contents[0]
.split("|")[0]
.strip()
)
except AttributeError:
pass
except ValueError:
pass
except IndexError:
pass
return orientation
def get_dimensions_from_soup(soup) -> str:
dimensions = ""
try:
height = (
soup.findAll(class_="infobox")[1]
.findAll("div")[1]
.contents[1]["data-bs-height"]
)
width = (
soup.findAll(class_="infobox")[1]
.findAll("div")[1]
.contents[1]["data-bs-width"]
)
dimensions = "x".join([height, width])
except AttributeError:
pass
except ValueError:
pass
except IndexError:
pass
return dimensions
def get_rating_from_soup(soup) -> str:
rating = ""
try:
rating = float(
soup.find(class_="num").get_text().strip("(").strip(")")
)
except AttributeError:
rating = None
except ValueError:
rating = None
return rating
def get_puzzle_from_ipdb_id(ipdb_id: str) -> dict:
return {}
puzzle_url = IPDB_URL.format(id=ipdb_id)
headers = {"User-Agent": "Vrobbler 0.11.12"}
response = requests.get(puzzle_url, headers=headers)
puzzle_dict = {"ipdb_id": ipdb_id}
if response.status_code != 200:
logger.warn(
"Bad response from untappd.com", extra={"response": response}
)
return puzzle_dict
soup = BeautifulSoup(response.text, "html.parser")
puzzle_dict["title"] = get_title_from_soup(soup)
puzzle_dict["orientation"] = get_orientation_from_soup(soup)
puzzle_dict["manufacturer"] = get_manufacturer_from_soup(soup)
puzzle_dict["pieces_count"] = get_pieces_count_from_soup(soup)
puzzle_dict["ipdb_image_url"] = get_image_url_from_soup(soup)
puzzle_dict["dimensions_in_inches"] = get_dimensions_from_soup(soup)
puzzle_dict["publish_year"] = get_publish_year_from_soup(soup)
puzzle_dict["material"] = get_material_from_soup(soup)
puzzle_dict["cut_style"] = get_cut_style_from_soup(soup)
puzzle_dict["barcode"] = get_barcode_from_soup(soup)
return puzzle_dict

View File

@ -27,7 +27,7 @@ class ScrobbleInline(admin.TabularInline):
"geo_location",
"task",
"mood",
"brickset",
"brick_set",
"trail",
"beer",
"web_page",
@ -122,9 +122,12 @@ class ScrobbleAdmin(admin.ModelAdmin):
"video_game",
"board_game",
"geo_location",
"puzzle",
"paper",
"food",
"task",
"mood",
"brickset",
"brick_set",
"trail",
"beer",
"web_page",

View File

@ -27,14 +27,14 @@ MEDIA_END_PADDING_SECONDS = {
TODOIST_TASK_URL = "https://app.todoist.com/app/task/{id}"
SCROBBLE_CONTENT_URLS = {
"-i": "https://www.imdb.com/title/",
"-s": "https://www.thesportsdb.com/event/",
"-g": "https://boardgamegeek.com/boardgame/",
"-u": "https://untappd.com/",
"-b": "https://www.amazon.com/",
"-t": "https://app.todoist.com/app/task/{id}",
"-i": "https://www.youtube.com/watch?v=",
"-p": "https://www.ipdb.plus/IPDb/puzzle.php?id=",
"-i": ["https://www.imdb.com/title/", "https://www.youtube.com/watch?v="],
"-s": ["https://www.thesportsdb.com/event/"],
"-g": ["https://boardgamegeek.com/boardgame/"],
"-u": ["https://untappd.com/"],
"-b": ["https://www.amazon.com/"],
"-t": ["https://app.todoist.com/app/task/{id}"],
"-p": ["https://www.ipdb.plus/IPDb/puzzle.php?id="],
"-l": ["https://brickset.com/sets/"],
}
EXCLUDE_FROM_NOW_PLAYING = ("GeoLocation",)
@ -49,6 +49,7 @@ MANUAL_SCROBBLE_FNS = {
"-w": "manual_scrobble_webpage",
"-t": "manual_scrobble_task",
"-p": "manual_scrobble_puzzle",
"-l": "manual_scrobble_brickset",
}

View File

@ -135,6 +135,7 @@ class BookLogData(LongPlayLogData):
page_start: Optional[int] = None
page_end: Optional[int] = None
serial_scrobble_id: Optional[int] = None
details: Optional[str] = None
@dataclass

View File

@ -0,0 +1,28 @@
import logging
from django.conf import settings
from django.utils import timezone
logger = logging.getLogger(__name__)
class TimezoneMiddleware(object):
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
return self.get_response(request)
def __getUserTimeZone(self, request):
# info = IPResolver(request).getGeoInfo()
# return pytz.country_timezones[info["country_code"]][0]
if not request.user.is_anonymous:
return request.user.profile.timezone
def process_request(self, request):
try:
tz = self.__getUserTimeZone(request) or settings.TIME_ZONE
timezone.activate(tz)
logger.info('Time zone "%s" activated' % str(tz))
except Exception as e:
logger.error("Unable to set timezone: %s" % str(e))

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.19 on 2025-06-09 14:33
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0069_scrobble_puzzle_alter_scrobble_media_type"),
]
operations = [
migrations.RenameField(
model_name="scrobble",
old_name="brickset",
new_name="brick_set",
),
]

View File

@ -92,7 +92,6 @@ class ScrobblableMixin(TimeStampedModel):
"media_type": self.__class__,
"user_id": user_id,
"scrobble_data": scrobble_data,
"media_type": Scrobble.MediaType.WEBPAGE,
},
)
return Scrobble.create_or_update(self, user_id, scrobble_data)

View File

@ -2,6 +2,7 @@ import calendar
import datetime
import json
import logging
from collections import defaultdict
from typing import Optional
from uuid import uuid4
@ -514,6 +515,10 @@ class Scrobble(TimeStampedModel):
MOOD = "Mood", "Mood"
BRICKSET = "BrickSet", "Brick set"
@classmethod
def list(cls):
return list(map(lambda c: c.value, cls))
uuid = models.UUIDField(editable=False, **BNULL)
video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL)
track = models.ForeignKey(Track, on_delete=models.DO_NOTHING, **BNULL)
@ -544,7 +549,7 @@ class Scrobble(TimeStampedModel):
LifeEvent, on_delete=models.DO_NOTHING, **BNULL
)
mood = models.ForeignKey(Mood, on_delete=models.DO_NOTHING, **BNULL)
brickset = models.ForeignKey(
brick_set = models.ForeignKey(
BrickSet, on_delete=models.DO_NOTHING, **BNULL
)
media_type = models.CharField(
@ -597,6 +602,59 @@ class Scrobble(TimeStampedModel):
long_play_seconds = models.BigIntegerField(**BNULL)
long_play_complete = models.BooleanField(**BNULL)
@classmethod
def for_year(cls, user, year):
return cls.objects.filter(timestamp__year=year, user=user).order_by(
"-timestamp"
)
@classmethod
def for_month(cls, user, year, month):
return cls.objects.filter(
timestamp__year=year, timestamp__month=month, user=user
).order_by("-timestamp")
@classmethod
def for_day(cls, user, year, month, day):
return cls.objects.filter(
timestamp__year=year,
timestamp__month=month,
timestamp__day=day,
user=user,
).order_by("-timestamp")
@classmethod
def for_week(cls, user, year, week):
return cls.objects.filter(
timestamp__year=year, timestamp__week=week, user=user
).order_by("-timestamp")
@classmethod
def as_dict_by_type(cls, scrobble_qs: models.QuerySet) -> dict:
scrobbles_by_type = defaultdict(list)
for scrobble in scrobble_qs:
scrobbles_by_type[scrobble.media_type].append(scrobble)
if not scrobbles_by_type.get(scrobble.media_type + "_count"):
scrobbles_by_type[scrobble.media_type + "_count"] = 0
scrobbles_by_type[scrobble.media_type + "_count"] += 1
if not scrobbles_by_type.get(scrobble.media_type + "_time"):
scrobbles_by_type[scrobble.media_type + "_time"] = 0
scrobbles_by_type[scrobble.media_type + "_time"] += int(
(scrobble.elapsed_time)
)
return scrobbles_by_type
@classmethod
def in_progress_for_user(cls, user_id: int) -> models.QuerySet:
return cls.objects.filter(
user=user_id,
in_progress=True,
played_to_completion=False,
is_paused=False,
)
@property
def last_serial_scrobble(self) -> Optional["Scrobble"]:
from scrobbles.models import Scrobble
@ -788,6 +846,15 @@ class Scrobble(TimeStampedModel):
def is_long_play(self) -> bool:
return self.media_obj.__class__.__name__ in LONG_PLAY_MEDIA.values()
@property
def elapsed_time(self) -> int | None:
if self.played_to_completion:
if self.playback_position_seconds:
return self.playback_position_seconds
if self.media_obj.run_time_seconds:
return self.media_obj.run_time_seconds
return (timezone.now() - self.timestamp).seconds
@property
def percent_played(self) -> int:
if not self.media_obj:
@ -801,7 +868,7 @@ class Scrobble(TimeStampedModel):
playback_seconds = self.playback_position_seconds
if not playback_seconds:
playback_seconds = (timezone.now() - self.timestamp).seconds
playback_seconds = self.elapsed_time
run_time_secs = self.media_obj.run_time_seconds
percent = int((playback_seconds / run_time_secs) * 100)
@ -917,6 +984,16 @@ class Scrobble(TimeStampedModel):
)
return True
@classmethod
def by_date(cls, media_type: str = "Track"):
cls.objects.filter(media_type=media_type).values(
"timestamp__date"
).annotate(count=models.Count("id")).values(
"timestamp__date", "count"
).order_by(
"-count",
)
@property
def media_obj(self):
media_obj = None
@ -942,8 +1019,8 @@ class Scrobble(TimeStampedModel):
media_obj = self.life_event
if self.mood:
media_obj = self.mood
if self.brickset:
media_obj = self.brickset
if self.brick_set:
media_obj = self.brick_set
if self.trail:
media_obj = self.trail
if self.beer:

View File

@ -8,6 +8,7 @@ import pytz
from beers.models import Beer
from boardgames.models import BoardGame
from books.models import Book
from bricksets.models import BrickSet
from dateutil.parser import parse
from django.utils import timezone
from locations.constants import LOCATION_PROVIDERS
@ -23,20 +24,16 @@ from scrobbles.constants import (
SCROBBLE_CONTENT_URLS,
)
from scrobbles.models import Scrobble
from scrobbles.utils import convert_to_seconds
from scrobbles.utils import convert_to_seconds, extract_domain
from sports.models import SportEvent
from sports.thesportsdb import lookup_event_from_thesportsdb
from tasks.models import Task
from videogames.howlongtobeat import lookup_game_from_hltb
from videogames.models import VideoGame
from videos.models import Video
from tasks.utils import get_title_from_labels
from webpages.models import WebPage
from vrobbler.apps.tasks.constants import (
TODOIST_TITLE_PREFIX_LABELS,
TODOIST_TITLE_SUFFIX_LABELS,
)
logger = logging.getLogger(__name__)
@ -322,14 +319,12 @@ def manual_scrobble_from_url(
we know about the content type, and routes it to the appropriate media
scrobbler. Otherwise, return nothing."""
content_key = ""
try:
domain = url.split("//")[-1].split("/")[0]
except IndexError:
domain = None
domain = extract_domain(url)
for key, content_url in SCROBBLE_CONTENT_URLS.items():
if domain in content_url:
content_key = key
for key, content_urls in SCROBBLE_CONTENT_URLS.items():
for content_url in content_urls:
if domain in content_url:
content_key = key
item_id = None
if not content_key:
@ -343,8 +338,10 @@ def manual_scrobble_from_url(
except IndexError:
pass
if content_key == "-i":
if content_key == "-i" and "v=" in url:
item_id = url.split("v=")[1].split("&")[0]
elif content_key == "-i" and "title/tt" in url:
item_id = "tt" + str(item_id)
scrobble_fn = MANUAL_SCROBBLE_FNS[content_key]
return eval(scrobble_fn)(item_id, user_id, action=action)
@ -412,27 +409,9 @@ def todoist_scrobble_task(
user_id: int,
started: bool = False,
stopped: bool = False,
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", []), context_list)
task = Task.find_or_create(title)
timestamp = pendulum.parse(todoist_task.get("updated_at", timezone.now()))
@ -500,6 +479,137 @@ def todoist_scrobble_task(
return scrobble
def emacs_scrobble_update_task(
emacs_id: str, emacs_notes: dict, user_id: int
) -> Optional[Scrobble]:
scrobble = Scrobble.objects.filter(
in_progress=True,
user_id=user_id,
log__source_id=emacs_id,
log__source="orgmode",
).first()
if not scrobble:
logger.info(
"[emacs_scrobble_update_task] no task found",
extra={
"emacs_notes": emacs_notes,
"user_id": user_id,
"media_type": Scrobble.MediaType.TASK,
},
)
return
notes_updated = False
for note in emacs_notes:
existing_note_ts = [
n.get("timestamp") for n in scrobble.log.get("notes", [])
]
if not scrobble.log.get('notes"'):
scrobble.log["notes"] = []
if note.get("timestamp") not in existing_note_ts:
scrobble.log["notes"].append(
{note.get("timestamp"): note.get("content")}
)
notes_updated = True
if notes_updated:
scrobble.save(update_fields=["log"])
logger.info(
"[emacs_scrobble_update_task] emacs note added",
extra={
"emacs_note": emacs_notes,
"user_id": user_id,
"media_type": Scrobble.MediaType.TASK,
},
)
return scrobble
def emacs_scrobble_task(
task_data: dict,
user_id: int,
started: bool = False,
stopped: bool = False,
context_list: list[str] = [],
) -> Scrobble | None:
source_id = task_data.get("source_id")
title = get_title_from_labels(task_data.get("labels", []), context_list)
task = Task.find_or_create(title)
timestamp = pendulum.parse(task_data.get("updated_at", timezone.now()))
in_progress_scrobble = Scrobble.objects.filter(
user_id=user_id,
in_progress=True,
log__source_id=source_id,
log__source="orgmode",
task=task,
).last()
if not in_progress_scrobble and stopped:
logger.info(
"[emacs_scrobble_task] cannot stop already stopped task",
extra={
"emacs_id": source_id,
},
)
return
if in_progress_scrobble and started:
logger.info(
"[emacs_scrobble_task] cannot start already started task",
extra={
"emacs_id": source_id,
},
)
return in_progress_scrobble
# Finish an in-progress scrobble
if in_progress_scrobble and stopped:
logger.info(
"[emacs_scrobble_task] finishing",
extra={
"emacs_id": source_id,
},
)
in_progress_scrobble.stop(timestamp=timestamp, force_finish=True)
return in_progress_scrobble
if in_progress_scrobble:
return in_progress_scrobble
notes = task_data.pop("notes")
if notes:
task_data["notes"] = []
for note in notes:
task_data["notes"].append(
{note.get("timestamp"): note.get("content")}
)
scrobble_dict = {
"user_id": user_id,
"timestamp": timestamp,
"playback_position_seconds": 0,
"source": "Org-mode",
"log": task_data,
}
logger.info(
"[emacs_scrobble_task] creating",
extra={
"task_id": task.id,
"user_id": user_id,
"scrobble_dict": scrobble_dict,
"media_type": Scrobble.MediaType.TASK,
},
)
scrobble = Scrobble.create_or_update(task, user_id, scrobble_dict)
return scrobble
def manual_scrobble_task(url: str, user_id: int, action: Optional[str] = None):
source_id = re.findall(r"\d+", url)[0]
@ -708,3 +818,34 @@ def manual_scrobble_puzzle(
# TODO Kick out a process to enrich the media here, and in every scrobble event
return Scrobble.create_or_update(puzzle, user_id, scrobble_dict)
def manual_scrobble_brickset(
brickset_id: str, user_id: int, action: Optional[str] = None
):
brickset = BrickSet.find_or_create(brickset_id)
if not brickset:
logger.error(f"No brickset found for Brickset ID {brickset_id}")
return
scrobble_dict = {
"user_id": user_id,
"timestamp": timezone.now(),
"playback_position_seconds": 0,
"source": "Vrobbler",
"log": {"serial_scrobble_id": ""},
}
logger.info(
"[vrobbler-scrobble] brickset scrobble request received",
extra={
"brickset_id": brickset.id,
"user_id": user_id,
"scrobble_dict": scrobble_dict,
"media_type": Scrobble.MediaType.BRICKSET,
},
)
# TODO Kick out a process to enrich the media here, and in every scrobble event
# TODO Need to check for past scrobbles and auto populate serial scrobble id if possible
return Scrobble.create_or_update(brickset, user_id, scrobble_dict)

View File

@ -1,6 +1,6 @@
from django.urls import path
from scrobbles import views
from tasks.webhooks import todoist_webhook
from tasks.webhooks import todoist_webhook, emacs_webhook
app_name = "scrobbles"
@ -52,6 +52,13 @@ urlpatterns = [
name="mopidy-webhook",
),
path("webhook/todoist/", todoist_webhook, name="todoist-webhook"),
path("webhook/emacs/", emacs_webhook, name="emacs_webhook"),
path("export/", views.export, name="export"),
path(
"imports/",
views.ScrobbleImportListView.as_view(),
name="import-detail",
),
path("export/", views.export, name="export"),
path(
"imports/",

View File

@ -3,6 +3,7 @@ import logging
import re
from datetime import datetime, timedelta, tzinfo
from sqlite3 import IntegrityError
from urllib.parse import urlparse
import pytz
from django.apps import apps
@ -17,9 +18,10 @@ from scrobbles.tasks import (
process_lastfm_import,
process_retroarch_import,
)
from vrobbler.apps.scrobbles.notifications import NtfyNotification
from webdav.client import get_webdav_client
from vrobbler.apps.scrobbles.notifications import NtfyNotification
logger = logging.getLogger(__name__)
User = get_user_model()
@ -67,7 +69,8 @@ def get_scrobbles_for_media(media_obj, user: User) -> models.QuerySet:
return Scrobble.objects.filter(media_query, user=user)
def get_recently_played_board_games(user: User) -> dict: ...
def get_recently_played_board_games(user: User) -> dict:
...
def get_long_plays_in_progress(user: User) -> dict:
@ -187,8 +190,8 @@ def delete_zombie_scrobbles(dry_run=True):
def import_from_webdav_for_all_users(restart=False):
"""Grab a list of all users with WebDAV enabled and kickoff imports for them"""
from scrobbles.models import KoReaderImport
from books.koreader import fetch_file_from_webdav
from scrobbles.models import KoReaderImport
# LastFmImport = apps.get_model("scrobbles", "LastFMImport")
webdav_enabled_user_ids = UserProfile.objects.filter(
@ -271,6 +274,7 @@ def get_file_md5_hash(file_path: str) -> str:
file_hash.update(chunk)
return file_hash.hexdigest()
def deduplicate_tracks(commit=False) -> int:
from music.models import Track
@ -293,7 +297,11 @@ def deduplicate_tracks(commit=False) -> int:
try:
other.delete()
except IntegrityError as e:
print("could not delete ", other.id, f": IntegrityError {e}")
print(
"could not delete ",
other.id,
f": IntegrityError {e}",
)
return len(dups)
@ -318,3 +326,13 @@ def send_stop_notifications_for_in_progress_scrobbles() -> int:
notifications_sent += 1
return notifications_sent
def extract_domain(url):
parsed_url = urlparse(url)
domain = (
parsed_url.netloc.split(".")[-2]
+ "."
+ parsed_url.netloc.split(".")[-1]
)
return domain

View File

@ -2,10 +2,11 @@ import calendar
import json
import logging
from datetime import datetime, timedelta
from dateutil.relativedelta import relativedelta
import pendulum
import pytz
from django.apps import apps
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Count, Q
@ -52,7 +53,6 @@ from scrobbles.tasks import (
from scrobbles.utils import (
get_long_plays_completed,
get_long_plays_in_progress,
get_recently_played_board_games,
)
logger = logging.getLogger(__name__)
@ -107,39 +107,101 @@ class RecentScrobbleList(ListView):
def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs)
user = self.request.user
data["date"] = ""
if user.is_authenticated:
self.queryset = self.get_queryset().filter(user=user)
user_id = self.request.user.id
completed_for_user = Scrobble.objects.filter(
played_to_completion=True, user=user
)
data["long_play_in_progress"] = get_long_plays_in_progress(user)
data["play_again"] = get_recently_played_board_games(user)
data["video_scrobble_list"] = completed_for_user.filter(
video__isnull=False
).order_by("-timestamp")[:15]
data["podcast_scrobble_list"] = completed_for_user.filter(
podcast_episode__isnull=False
).order_by("-timestamp")[:15]
data["sport_scrobble_list"] = completed_for_user.filter(
sport_event__isnull=False
).order_by("-timestamp")[:15]
data["videogame_scrobble_list"] = completed_for_user.filter(
video_game__isnull=False
).order_by("-timestamp")[:15]
data["boardgame_scrobble_list"] = completed_for_user.filter(
board_game__isnull=False
).order_by("-timestamp")[:15]
today = timezone.localtime(timezone.now())
date_str = self.request.GET.get("date", "")
date = today
if date_str:
try:
date = pendulum.parse(date_str)
except:
pass
if date_str:
if date_str == "this_week" or "-W" in date_str:
next_date = date + timedelta(weeks=+2)
prev_date = date + timedelta(weeks=-1)
if date.isocalendar()[1] < today.isocalendar()[1]:
data[
"next_link"
] = f"?date={next_date.strftime('%Y-W%W')}"
data["prev_link"] = f"?date={prev_date.strftime('%Y-W%W')}"
data[
"title"
] = f"Week {date.strftime('%-W')} of {date.year}"
data = data | Scrobble.as_dict_by_type(
Scrobble.for_week(
user_id, date.year, date.isocalendar()[1]
)
)
elif date_str == "this_month" or date_str.count("-") == 1:
next_date = date + relativedelta(months=1)
prev_date = date + relativedelta(months=-1)
if date.month < today.month:
data[
"next_link"
] = f"?date={next_date.strftime('%Y-%m')}"
data["title"] = f"{date.strftime('%B %Y')}"
data["prev_link"] = f"?date={prev_date.strftime('%Y-%m')}"
data = data | Scrobble.as_dict_by_type(
Scrobble.for_month(user_id, date.year, date.month)
)
elif date_str == "today" or date_str.count("-") == 2:
next_date = date + timedelta(days=1)
prev_date = date - timedelta(days=1)
data[
"prev_link"
] = f"?date={prev_date.strftime('%Y-%m-%d')}"
if date < today:
data[
"next_link"
] = f"?date={next_date.strftime('%Y-%m-%d')}"
if date == today:
data["title"] = "Today"
else:
data["title"] = f"{date.strftime('%Y-%m-%d')}"
data[
"today_link"
] = f"?date={today.strftime('%Y-%m-%d')}"
data = data | Scrobble.as_dict_by_type(
Scrobble.for_day(
user_id, date.year, date.month, date.day
)
)
elif date_str == "this_year" or date_str.count("-") == 0:
next_date = date + relativedelta(years=+1)
prev_date = date + relativedelta(years=-1)
data["next_link"] = ""
if date.year < today.year:
data["title"] = f"{date.strftime('%Y')}"
data["next_link"] = f"?date={next_date.year}"
data["title"] = f"{date.year}"
data["prev_link"] = f"?date={prev_date.year}"
data = data | Scrobble.as_dict_by_type(
Scrobble.for_year(user_id, date.year)
)
else:
next_date = today - timedelta(days=1)
prev_date = today - timedelta(days=1)
data["title"] = "Today"
data["next_link"] = ""
data["prev_link"] = f"?date={prev_date.strftime('%Y-%m-%d')}"
data = data | Scrobble.as_dict_by_type(
Scrobble.for_day(user_id, date.year, date.month, date.day)
)
data[
"today_link"
] = "" # f"?date={today.strftime('%Y-%m-%d')}"
data["active_imports"] = AudioScrobblerTSVImport.objects.filter(
processing_started__isnull=False,
processed_finished__isnull=True,
user=self.request.user,
)
data["counts"] = scrobble_counts(user)
data["counts"] = [] #scrobble_counts(user)
else:
data["weekly_data"] = week_of_scrobbles()
data["counts"] = scrobble_counts()
@ -149,9 +211,7 @@ class RecentScrobbleList(ListView):
return data
def get_queryset(self):
return Scrobble.objects.filter(
track__isnull=False, in_progress=False
).order_by("-timestamp")[:15]
return Scrobble.objects.all().order_by("-timestamp")
class ScrobbleLongPlaysView(TemplateView):
@ -698,45 +758,13 @@ class ChartRecordView(TemplateView):
media_type = self.request.GET.get("media", "Track")
user = self.request.user
params = {}
context_data["chart_type"] = self.request.GET.get(
"chart_type", "maloja"
)
context_data["artist_charts"] = {}
if not date:
limit = 20
artist_params = {"user": user, "media_type": "Artist"}
context_data["current_artist_charts"] = {
"today": live_charts(
**artist_params, chart_period="today", limit=limit
),
"week": live_charts(
**artist_params, chart_period="week", limit=limit
),
"month": live_charts(
**artist_params, chart_period="month", limit=limit
),
"year": live_charts(
**artist_params, chart_period="year", limit=limit
),
"all": live_charts(**artist_params, limit=limit),
}
track_params = {"user": user, "media_type": "Track"}
context_data["current_track_charts"] = {
"today": live_charts(
**track_params, chart_period="today", limit=limit
),
"week": live_charts(
**track_params, chart_period="week", limit=limit
),
"month": live_charts(
**track_params, chart_period="month", limit=limit
),
"year": live_charts(
**track_params, chart_period="year", limit=limit
),
"all": live_charts(**track_params, limit=limit),
}
limit = 14
artist = {"user": user, "media_type": "Artist", "limit": limit}
# This is weird. They don't display properly as QuerySets, so we cast to lists
context_data["chart_keys"] = {

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

@ -50,7 +50,8 @@ class Task(LongPlayScrobblableMixin):
url = ""
scrobble = self.scrobbles(user_id).first()
if scrobble:
url = TODOIST_TASK_URL.format(id=scrobble.logdata.todoist_id)
if scrobble.log.get("source") == "todoist":
url = TODOIST_TASK_URL.format(id=scrobble.logdata.todist_id)
return url
def subtitle_for_user(self, user_id):

View File

@ -0,0 +1,20 @@
import logging
from django.conf import settings
logger = logging.getLogger(__name__)
def get_title_from_labels(labels: list[str], user_context_labels: list[str] = []) -> str:
title = "Unknown"
task_context_labels: list = user_context_labels or settings.DEFAULT_TASK_CONTEXT_TAG_LIST
for label in labels:
# TODO We may also want to take a user list of labels instead
if label in task_context_labels:
title = label.capitalize()
if title == "Unknown":
logger.warning(
"Missing a prefix and suffix tag for task",
extra={"labels": labels},
)
return title

View File

@ -1,16 +1,18 @@
import json
import logging
import pendulum
from django.views.decorators.csrf import csrf_exempt
from profiles.models import UserProfile
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from scrobbles.scrobblers import (
emacs_scrobble_task,
emacs_scrobble_update_task,
todoist_scrobble_task,
todoist_scrobble_update_task,
)
from profiles.models import UserProfile
logger = logging.getLogger(__name__)
@ -29,7 +31,7 @@ def todoist_webhook(request):
todoist_type, todoist_event = post_data.get("event_name").split(":")
event_data = post_data.get("event_data", {})
is_item_type = todoist_type == "item"
is_note_type = todoist_type == "note"
is_note_type = todoist_tyllll = "note"
new_labels = event_data.get("labels", [])
old_labels = (
post_data.get("event_data_extra", {})
@ -69,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):
@ -88,16 +90,17 @@ 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,
context_list=user_profile.task_context_tags,
)
if todoist_note:
@ -118,3 +121,60 @@ def todoist_webhook(request):
extra={"scrobble_id": scrobble.id},
)
return Response({"scrobble_id": scrobble.id}, status=status.HTTP_200_OK)
@csrf_exempt
@permission_classes([IsAuthenticated])
@api_view(["POST"])
def emacs_webhook(request):
try:
post_data = json.loads(request.data)
except TypeError:
post_data = request.data
logger.info(
"[emacs_webhook] called",
extra={"post_data": post_data},
)
task_in_progress = post_data.get("state") == "STRT"
task_stopped = post_data.get("state") == "DONE"
post_data["source_id"] = post_data.pop("emacs_id")
# TODO: Figure out why token auth is not working
user_id = request.user.id
if not user_id:
user_id = 1
user_profile = UserProfile.objects.filter(user_id=user_id)
scrobble = None
if post_data.get("source_id"):
scrobble = emacs_scrobble_task(
post_data,
user_id,
started=task_in_progress,
stopped=task_stopped,
context_list=user_profile.task_context_tags,
)
if not scrobble:
logger.info(
"[emacs_webhook] finished with no note or task found",
extra={"scrobble_id": None},
)
return Response(
{"error": "No scrobble found to be updated"},
status=status.HTTP_304_NOT_MODIFIED,
)
if task_in_progress and post_data.get("notes"):
emacs_scrobble_update_task(
post_data.get("source_id"),
post_data.get("notes"),
user_id,
)
logger.info(
"[emacs_webhook] finished",
extra={"scrobble_id": scrobble.id},
)
return Response({"scrobble_id": scrobble.id}, status=status.HTTP_200_OK)

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

@ -9,9 +9,7 @@ from locations.models import GeoLocation
BNULL = {"blank": True, "null": True}
class Trail(ScrobblableMixin):
class PrincipalType(models.TextChoices):
WOODS = "WOODS"
ROAD = "ROAD"
@ -19,7 +17,7 @@ class Trail(ScrobblableMixin):
MOUNTAIN = "MOUNTAIN"
class ActivityType(models.TextChoices):
WALK= "WALK"
WALK = "WALK"
HIKE = "HIKE"
RUN = "RUN"
BIKE = "BIKE"
@ -39,8 +37,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

@ -25,6 +25,7 @@ class VideoMetadata:
60 # Silly default, but things break if this is 0 or null
)
imdb_id: Optional[str]
tmdb_id: Optional[str]
youtube_id: Optional[str]
# IMDB specific
@ -35,6 +36,7 @@ class VideoMetadata:
tv_series_id: Optional[int]
plot: Optional[str]
imdb_rating: Optional[str]
tmdb_rating: Optional[str]
cover_url: Optional[str]
overview: Optional[str]
@ -57,6 +59,8 @@ class VideoMetadata:
def as_dict_with_cover_and_genres(self) -> tuple:
video_dict = vars(self)
cover = video_dict.pop("cover_url")
genres = video_dict.pop("genres")
cover = None
if "cover_url" in video_dict.keys():
cover = video_dict.pop("cover_url", "")
genres = video_dict.pop("genres", [])
return video_dict, cover, genres

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.19 on 2025-06-13 15:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('videos', '0022_alter_video_run_time_seconds'),
]
operations = [
migrations.AddField(
model_name='video',
name='tmdb_rating',
field=models.FloatField(blank=True, null=True),
),
]

View File

@ -2,7 +2,6 @@ import logging
from typing import Optional
from uuid import uuid4
import pendulum
import requests
from django.conf import settings
from django.core.files.base import ContentFile
@ -21,8 +20,8 @@ from scrobbles.mixins import (
from taggit.managers import TaggableManager
from videos.metadata import VideoMetadata
from videos.sources.imdb import lookup_video_from_imdb
from vrobbler.apps.videos.sources.youtube import lookup_video_from_youtube
from videos.sources.tmdb import lookup_video_from_tmdb
from videos.sources.youtube import lookup_video_from_youtube
YOUTUBE_VIDEO_URL = "https://www.youtube.com/watch?v="
YOUTUBE_CHANNEL_URL = "https://www.youtube.com/channel/"
@ -208,6 +207,7 @@ class Video(ScrobblableMixin):
next_imdb_id = models.CharField(max_length=20, **BNULL)
imdb_id = models.CharField(max_length=20, **BNULL)
imdb_rating = models.FloatField(**BNULL)
tmdb_rating = models.FloatField(**BNULL)
cover_image = models.ImageField(upload_to="videos/video/", **BNULL)
cover_image_small = ImageSpecField(
source="cover_image",
@ -309,11 +309,13 @@ class Video(ScrobblableMixin):
def get_from_imdb_id(
cls, imdb_id: str, overwrite: bool = False
) -> "Video":
if "tt" in imdb_id:
imdb_id = imdb_id[2:]
video, created = cls.objects.get_or_create(imdb_id=imdb_id)
if not created and not overwrite:
return video
vdict, cover, genres = lookup_video_from_imdb(
vdict, cover, genres = lookup_video_from_tmdb(
imdb_id
).as_dict_with_cover_and_genres()
if created or overwrite:

View File

@ -72,26 +72,30 @@ def lookup_video_from_imdb(
video_metadata.video_type = VideoType.MOVIE.value
series_name = None
if imdb_result.get("kind") == "episode":
series_name = imdb_result.get("episode of", None).data.get(
"title", None
)
series, _ = Series.objects.get_or_create(name=series_name)
video_metadata.video_type = VideoType.TV_EPISODE.value
video_metadata.tv_series_id = series.id
try:
series_name = imdb_result.get("episode of", None).data.get(
"title", None
)
except IndexError:
series_name = None
if series_name:
series, _ = Series.objects.get_or_create(name=series_name)
video_metadata.video_type = VideoType.TV_EPISODE.value
video_metadata.tv_series_id = series.id
if imdb_result.get("runtimes"):
video_metadata.run_time_seconds = (
int(imdb_result.get("runtimes")[0]) * 60
)
video_metadata.imdb_id = name_or_id
video_metadata.title = imdb_result.get("title", "")
video_metadata.imdb_id = imdb_result.get("imdbID")
video_metadata.episode_number = imdb_result.get("episode", None)
video_metadata.season_number = imdb_result.get("season", None)
video_metadata.next_imdb_id = imdb_result.get("next episode", None)
video_metadata.year = imdb_result.get("year", None)
video_metadata.plot = imdb_result.get("plot outline")
video_metadata.imdb_rating = imdb_result.get("rating")
video_metadata.genres = imdb_result.get("genres")
video_metadata.plot = imdb_result.get("plot outline", "")
video_metadata.imdb_rating = imdb_result.get("rating", None)
video_metadata.genres = imdb_result.get("genres", [])
return video_metadata

View File

@ -0,0 +1,81 @@
import logging
import pendulum
from django.conf import settings
from themoviedb import TMDb
from tmdbv3api import TV, Movie, TMDb as TMDb_direct
from videos.metadata import VideoMetadata, VideoType
TMDB_KEY = getattr(settings, "TMDB_API_KEY", "")
tmdb_direct = TMDb_direct()
tmdb_direct.api_key = TMDB_KEY
tmdb = TMDb(key=TMDB_KEY, language="en-US", region="US")
TMDB_IMAGE_URL = "https://image.tmdb.org/t/p/original"
logger = logging.getLogger(__name__)
def lookup_video_from_tmdb(
name_or_id: str, kind: str = "movie"
) -> VideoMetadata:
from videos.models import Series
imdb_id = name_or_id
if name_or_id.startswith("tt"):
imdb_id = name_or_id[2:]
video_metadata = VideoMetadata(imdb_id=imdb_id)
tmdb_result = tmdb.find().by_imdb("tt" + imdb_id)
if not tmdb_result:
logger.info(
"[lookup_video_from_tmdb] no video found on tmdb",
extra={"name_or_id": name_or_id},
)
return None
video_metadata = VideoMetadata(imdb_id=imdb_id)
media = None
show = None
if len(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]
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
if not media:
logger.warning("Video not found on TMDB", extra={"imdb_id":imdb_id})
return video_metadata
video_metadata.tmdb_id = media.id
video_metadata.run_time_seconds = media.runtime * 60
video_metadata.plot = media.overview
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

@ -163,6 +163,7 @@ SITE_ID = 1
MIDDLEWARE = [
"vrobbler.health_check.HealthCheckMiddleware",
"vrobbler.apps.scrobbles.middleware.TimezoneMiddleware",
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",

View File

@ -1,212 +1,90 @@
{% load humanize %}
{% load naturalduration %}
<div>
<h2>Last Scrobbles</h2>
<p>Today <b>{{counts.today}}</b> | This Week <b>{{counts.week}}</b> | This Month <b>{{counts.month}}</b> | This Year <b>{{counts.year}}</b> | All Time <b>{{counts.alltime}}</b></p>
</div>
<div class="row">
<ul class="nav nav-tabs" id="myTab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="home-tab" data-bs-toggle="tab" data-bs-target="#latest-listened"
type="button" role="tab" aria-controls="home" aria-selected="true">Tracks</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#latest-watched"
type="button" role="tab" aria-controls="profile" aria-selected="false">Videos</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#latest-podcasted"
type="button" role="tab" aria-controls="profile" aria-selected="false">Podcasts</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#latest-sports"
type="button" role="tab" aria-controls="profile" aria-selected="false">Sports</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#latest-videogames"
type="button" role="tab" aria-controls="profile" aria-selected="false">Video Games</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#latest-boardgames"
type="button" role="tab" aria-controls="profile" aria-selected="false">Board Games</button>
</li>
</ul>
<div class="tab-content" id="myTabContent2">
<div class="tab-pane fade show active" id="latest-listened" role="tabpanel"
aria-labelledby="latest-listened-tab">
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Time</th>
<th scope="col">Album</th>
<th scope="col">Track</th>
<th scope="col">Artist</th>
</tr>
</thead>
<tbody>
{% for scrobble in object_list %}
<tr>
<td>{{scrobble.timestamp|naturaltime}}</td>
{% if scrobble.track.album.cover_image %}
<td><a href="{{scrobble.track.album.get_absolute_url}}"><img src="{{scrobble.track.album.cover_image_small.url}}" width=25 height=25 style="border:1px solid black;" /></aa></td>
{% else %}
<td><a href="{{scrobble.track.album.get_absolute_url}}">{{scrobble.track.album.name}}</a></td>
{% endif %}
<td><a href="{{scrobble.track.get_absolute_url }}">{{scrobble.track.title}}</a></td>
<td><a href="{{scrobble.track.artist.get_absolute_url }}">{{scrobble.track.artist.name}}</aa></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="tab-pane fade show" id="latest-watched" role="tabpanel"
aria-labelledby="latest-watched-tab">
<h2>Latest watched</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Time</th>
<th scope="col">Cover</th>
<th scope="col">Title</th>
<th scope="col">Series</th>
</tr>
</thead>
<tbody>
{% for scrobble in video_scrobble_list %}
<tr>
<td>{{scrobble.timestamp|naturaltime}}</td>
{% if scrobble.video.cover_image %}
<td><img src="{{scrobble.media_obj.cover_image_medium.url}}" width=25 height=25 style="border:1px solid black;" /></td>
{% else %}
<td></td>
{% endif %}
<td><a href="{{scrobble.video.get_absolute_url }}">{% if scrobble.video.tv_series%}S{{scrobble.video.season_number}}E{{scrobble.video.episode_number}} -{%endif %} {{scrobble.video.title}}</a></td>
<td><a href="{{scrobble.video.tv_series.get_absolute_url }}">{% if scrobble.video.tv_series %}{{scrobble.video.tv_series}}</a>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="tab-pane fade show" id="latest-sports" role="tabpanel" aria-labelledby="latest-sports-tab">
<h2>Latest Sports</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">Title</th>
<th scope="col">Round</th>
<th scope="col">League</th>
</tr>
</thead>
<tbody>
{% for scrobble in sport_scrobble_list %}
<tr>
<td>{{scrobble.timestamp|naturaltime}}</td>
<td>{{scrobble.sport_event.title}}</td>
<td>{{scrobble.sport_event.round.name}}</td>
<td>{{scrobble.sport_event.round.season.league}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="tab-pane fade show" id="latest-podcasted" role="tabpanel"
aria-labelledby="latest-podcasted-tab">
<h2>Latest Podcasted</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">Title</th>
<th scope="col">Podcast</th>
</tr>
</thead>
<tbody>
{% for scrobble in podcast_scrobble_list %}
<tr>
<td>{{scrobble.timestamp|naturaltime}}</td>
<td>{{scrobble.podcast_episode.title}}</td>
<td>{{scrobble.podcast_episode.podcast}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="tab-pane fade show" id="latest-videogames" role="tabpanel"
aria-labelledby="latest-videogames-tab">
<h2>Latest Video Games</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">Cover/Screenshot</th>
<th scope="col">Title</th>
<th scope="col">Time played (mins)</th>
<th scope="col">Percent complete</th>
</tr>
</thead>
<tbody>
{% for scrobble in videogame_scrobble_list %}
<tr>
<td>{{scrobble.timestamp|naturaltime}}</td>
{% if scrobble.screenshot %}
<td><img src="{{scrobble.screenshot_medium.url}}" width=25 height=25 style="border:1px solid black;" /></td>
{% else %}
{% if scrobble.media_obj.hltb_cover %}
<td><img src="{{scrobble.media_obj.hltb_cover_medium.url}}" width=25 height=25 style="border:1px solid black;" /></td>
{% endif %}
{% endif %}
<td><a href="{{scrobble.media_obj.get_absolute_url}}">{{scrobble.media_obj.title}}</a></td>
<td>{{scrobble.playback_position_seconds|natural_duration}}</td>
<td>{{scrobble.percent_played}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="tab-pane fade show" id="latest-boardgames" role="tabpanel"
aria-labelledby="latest-boardgames-tab">
<h2>Latest Board Games</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">Cover</th>
<th scope="col">Title</th>
<th scope="col">Time played (mins)</th>
</tr>
</thead>
<tbody>
{% for scrobble in boardgame_scrobble_list %}
<tr>
<td>{{scrobble.timestamp|naturaltime}}</td>
<td><img src="{{scrobble.media_obj.cover_medium.url}}" width=25 height=25 style="border:1px solid black;" /></td>
<td><a href="{{scrobble.media_obj.get_absolute_url}}">{{scrobble.media_obj.title}}</a></td>
<td>{{scrobble.playback_position_seconds|natural_duration}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div>
<p>Today <b>{{counts.today}}</b> | This Week <b>{{counts.week}}</b> | This Month <b>{{counts.month}}</b> | This Year <b>{{counts.year}}</b> | All Time <b>{{counts.alltime}}</b></p>
</div>
</div>
<div class="row">
<div class="col-md">
{% if Track %}
<h2>Music</h2>
{% with scrobbles=Track count=Track_count time=Track_time %}
{% include "scrobbles/_scrobble_table.html" %}
{% endwith %}
{% endif %}
</div>
<div class="col-md">
{% if Task %}
<h2>Latest tasks</h2>
{% with scrobbles=Task count=Task_count time=Task_time %}
{% include "scrobbles/_scrobble_table.html" %}
{% endwith %}
{% endif %}
{% if Video %}
<h2>Videos</h2>
{% with scrobbles=Video count=Video_count time=Video_time %}
{% include "scrobbles/_scrobble_table.html" %}
{% endwith %}
{% endif %}
{% if WebPage %}
<h4>Web pages</h4>
{% with scrobbles=WebPage count=WebPage_count time=WebPage_time %}
{% include "scrobbles/_scrobble_table.html" %}
{% endwith %}
{% endif %}
{% if SportEvent %}
<h2>Sports</h2>
{% with scrobbles=SportEvent count=SportEvent_count time=SportEvent_time %}
{% include "scrobbles/_scrobble_table.html" %}
{% endwith %}
{% endif %}
{% if PodcastEpisode %}
<h2>Latest podcasts</h2>
{% with scrobbles=PodcastEpisode count=PodcastEpisode_count time=PodcastEpisode_time %}
{% include "scrobbles/_scrobble_table.html" %}
{% endwith %}
{% endif %}
{% if VideoGame %}
<h4>Video games</h4>
{% with scrobbles=VideoGame count=VideoGame_count time=VideoGame_time %}
{% include "scrobbles/_scrobble_table.html" %}
{% endwith %}
{% endif %}
{% if BoardGame %}
<h4>Board games</h4>
{% with scrobbles=BoardGame count=BoardGame_count time=BoardGame_time %}
{% include "scrobbles/_scrobble_table.html" %}
{% endwith %}
{% endif %}
{% if Beer %}
<h4>Beers</h4>
{% with scrobbles=Beer count=Beer_count time=Beer_time %}
{% include "scrobbles/_scrobble_table.html" %}
{% endwith %}
{% endif %}
{% if BrickSet %}
<h4>Brick sets</h4>
{% with scrobbles=BrickSet count=BrickSet_count time=BrickSet_time %}
{% include "scrobbles/_scrobble_table.html" %}
{% endwith %}
{% endif %}
{% if Book %}
<h4>Books</h4>
{% with scrobbles=Book count=Book_count time=Book_time %}
{% include "scrobbles/_scrobble_table.html" %}
{% endwith %}
{% endif %}
</div>
</div>

View File

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

View File

@ -0,0 +1,23 @@
{% load humanize %}
{% load naturalduration %}
<div class="tab-pane fade show" id="latest-beers" role="tabpanel"
aria-labelledby="latest-beers-tab">
<div class="table-responsive">
{{count}} scrobble {% if time %}| {{time|natural_duration}}{% endif %}
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">Title</th>
<th scope="col">Time</th>
</tr>
</thead>
<tbody>
{% for scrobble in scrobbles %}
{% include "scrobbles/_row.html" %}
{% endfor %}
</tbody>
</table>
</div>
</div>

View File

@ -1,173 +1,176 @@
{% load static %}
<h2>Top Artist</h2>
<ul class="nav nav-tabs" id="artistTab" role="tablist">
{% for key, name in chart_keys.items %}
<li class="nav-item" role="presentation">
<button class="nav-link {% if forloop.counter == 2 %}active{% endif %}"
id="artist-{{key}}-tab" data-bs-toggle="tab" data-bs-target="#artist-{{key}}"
type="button" role="tab" aria-controls="home" aria-selected="true">{{name}}</button>
</li>
{% endfor %}
</ul>
<div class="tab-content" id="artistTabContent" class="maloja-chart">
{% for key, artists in current_artist_charts.items %}
<div class="tab-pane fade {% if forloop.counter == 2 %}show active{% endif %}" id="artist-{{key}}" role="tabpanel" aria-labelledby="artist-{{key}}-tab">
<div style="display:block">
<div style="float:left;">
<div class="image-wrapper" style="display:flex; flex-wrap: wrap; margin:0">
<div class="caption">#1 {{artists.0.name}}</div>
{% if artists.0 %}
{% if artists.0.thumbnail %}
<a href="{{artists.0.get_absolute_url}}"><img lt="{{artists.0.name}}" src="{{artists.0.thumbnail_medium.url}}" width="300px"></a>
{% else %}
<a href="{{artists.0.get_absolute_url}}"><img lt="{{artists.0.name}}" src="{% static "images/not-found.jpg" %}" width="300px"></a>
{% endif %}
{% endif %}
</div>
</div>
<div style="float:left; width:300px;">
<div style="display:flex; flex-wrap: wrap;">
<div class="image-wrapper" class="image-wrapper" style="width:50%">
<div class="caption-medium">#2 {{artists.1.name}}</div>
{% if artists.1 %}
{% if artists.1.thumbnail %}
<a href="{{artists.1.get_absolute_url}}"><img lt="{{artists.1.name}}" src="{{artists.1.thumbnail_medium.url}}" width="150px"></a>
<div class="row">
<h2>Top Artist</h2>
<ul class="nav nav-tabs" id="artistTab" role="tablist">
{% for key, name in chart_keys.items %}
<li class="nav-item" role="presentation">
<button class="nav-link {% if forloop.counter == 2 %}active{% endif %}"
id="artist-{{key}}-tab" data-bs-toggle="tab" data-bs-target="#artist-{{key}}"
type="button" role="tab" aria-controls="home" aria-selected="true">{{name}}</button>
</li>
{% endfor %}
</ul>
<div class="tab-content" id="artistTabContent" class="maloja-chart">
{% for key, artists in current_artist_charts.items %}
<div class="tab-pane fade {% if forloop.counter == 2 %}show active{% endif %}" id="artist-{{key}}" role="tabpanel" aria-labelledby="artist-{{key}}-tab">
<div style="display:block">
<div style="float:left;">
<div class="image-wrapper" style="display:flex; flex-wrap: wrap; margin:0">
<div class="caption">#1 {{artists.0.name}}</div>
{% if artists.0 %}
{% if artists.0.thumbnail %}
<a href="{{artists.0.get_absolute_url}}"><img lt="{{artists.0.name}}" src="{{artists.0.thumbnail_medium.url}}" width="300px"></a>
{% else %}
<a href="{{artists.1.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="150px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" class="image-wrapper" style="width:50%">
<div class="caption-medium">#3 {{artists.2.name}}</div>
{% if artists.2 %}
{% if artists.2.thumbnail %}
<a href="{{artists.2.get_absolute_url}}"><img src="{{artists.2.thumbnail_medium.url}}" width="150px"></a>
{% else %}
<a href="{{artists.2.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="150px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" class="image-wrapper" style="width:50%">
<div class="caption-medium">#4 {{artists.3.name}}</div>
{% if artists.3 %}
{% if artists.3.thumbnail %}
<a href="{{artists.3.get_absolute_url}}"><img src="{{artists.3.thumbnail_medium.url}}" width="150px"></a>
{% else %}
<a href="{{artists.3.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="150px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" class="image-wrapper" style="width:50%">
<div class="caption-medium">#5 {{artists.4.name}}</div>
{% if artists.4 %}
{% if artists.4.thumbnail %}
<a href="{{artists.4.get_absolute_url}}"><img src="{{artists.4.thumbnail_medium.url}}" width="150px"></a>
{% else %}
<a href="{{artists.4.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="150px"></a>
<a href="{{artists.0.get_absolute_url}}"><img lt="{{artists.0.name}}" src="{% static "images/not-found.jpg" %}" width="300px"></a>
{% endif %}
{% endif %}
</div>
</div>
</div>
<div style="float:left; width:300px;">
<div style="display:flex; flex-wrap: wrap;">
<div class="image-wrapper" class="image-wrapper" class="image-wrapper" style="width:33;">
<div class="caption-small">#6 {{artists.5.name}}</div>
{% if artists.5 %}
{% if artists.5.thumbnail %}
<a href="{{artists.5.get_absolute_url}}"><img src="{{artists.5.thumbnail_medium.url}}" width="100px"></a>
{% else %}
<a href="{{artists.5.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a>
{% endif %}
{% endif %}
<div style="float:left; width:300px;">
<div style="display:flex; flex-wrap: wrap;">
<div class="image-wrapper" class="image-wrapper" style="width:50%">
<div class="caption-medium">#2 {{artists.1.name}}</div>
{% if artists.1 %}
{% if artists.1.thumbnail %}
<a href="{{artists.1.get_absolute_url}}"><img lt="{{artists.1.name}}" src="{{artists.1.thumbnail_medium.url}}" width="150px"></a>
{% else %}
<a href="{{artists.1.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="150px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" class="image-wrapper" style="width:50%">
<div class="caption-medium">#3 {{artists.2.name}}</div>
{% if artists.2 %}
{% if artists.2.thumbnail %}
<a href="{{artists.2.get_absolute_url}}"><img src="{{artists.2.thumbnail_medium.url}}" width="150px"></a>
{% else %}
<a href="{{artists.2.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="150px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" class="image-wrapper" style="width:50%">
<div class="caption-medium">#4 {{artists.3.name}}</div>
{% if artists.3 %}
{% if artists.3.thumbnail %}
<a href="{{artists.3.get_absolute_url}}"><img src="{{artists.3.thumbnail_medium.url}}" width="150px"></a>
{% else %}
<a href="{{artists.3.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="150px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" class="image-wrapper" style="width:50%">
<div class="caption-medium">#5 {{artists.4.name}}</div>
{% if artists.4 %}
{% if artists.4.thumbnail %}
<a href="{{artists.4.get_absolute_url}}"><img src="{{artists.4.thumbnail_medium.url}}" width="150px"></a>
{% else %}
<a href="{{artists.4.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="150px"></a>
{% endif %}
{% endif %}
</div>
</div>
<div class="image-wrapper" class="image-wrapper" style="width:33;">
<div class="caption-small">#7 {{artists.6.name}}</div>
{% if artists.6 %}
{% if artists.6.thumbnail %}
<a href="{{artists.6.get_absolute_url}}"><img src="{{artists.6.thumbnail_medium.url}}" width="100px"></a>
{% else %}
<a href="{{artists.6.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" class="image-wrapper" style="width:33;">
<div class="caption-small">#8 {{artists.7.name}}</div>
{% if artists.7 %}
{% if artists.7.thumbnail %}
<a href="{{artists.7.get_absolute_url}}"><img src="{{artists.7.thumbnail_medium.url}}" width="100px"></a>
{% else %}
<a href="{{artists.7.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" class="image-wrapper" style="width:33;">
<div class="caption-small">#9 {{artists.8.name}}</div>
{% if artists.8 %}
{% if artists.8.thumbnail %}
<a href="{{artists.8.get_absolute_url}}"><img src="{{artists.8.thumbnail_medium.url}}" width="100px"></a>
{% else %}
<a href="{{artists.8.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" class="image-wrapper" style="width:33;">
<div class="caption-small">#10 {{artists.9.name}}</div>
{% if artists.9 %}
{% if artists.9.thumbnail %}
<a href="{{artists.9.get_absolute_url}}"><img src="{{artists.9.thumbnail_medium.url}}" width="100px"></a>
{% else %}
<a href="{{artists.9.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" class="image-wrapper" style="width:33;">
<div class="caption-small">#11 {{artists.10.name}}</div>
{% if artists.10 %}
{% if artists.10.thumbnail %}
<a href="{{artists.10.get_absolute_url}}"><img src="{{artists.10.thumbnail_medium.url}}" width="100px"></a>
{% else %}
<a href="{{artists.10.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" class="image-wrapper" style="width:33;">
<div class="caption-small">#12 {{artists.11.name}}</div>
{% if artists.11 %}
{% if artists.11.thumbnail %}
<a href="{{artists.11.get_absolute_url}}"><img src="{{artists.11.thumbnail_medium.url}}" width="100px"></a>
{% else %}
<a href="{{artists.11.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" class="image-wrapper" style="width:33;">
<div class="caption-small">#13 {{artists.12.name}}</div>
{% if artists.12 %}
{% if artists.12.thumbnail %}
<a href="{{artists.12.get_absolute_url}}"><img src="{{artists.12.thumbnail_medium.url}}" width="100px"></a>
{% else %}
<a href="{{artists.12.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" class="image-wrapper" style="width:33;">
<div class="caption-small">#14 {{artists.13.name}}</div>
{% if artists.13 %}
{% if artists.13.thumbnail %}
<a href="{{artists.13.get_absolute_url}}"><img src="{{artists.13.thumbnail_medium.url}}" width="100px"></a>
{% else %}
<a href="{{artists.13.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a>
{% endif %}
{% endif %}
</div>
<div style="float:left; width:300px;">
<div style="display:flex; flex-wrap: wrap;">
<div class="image-wrapper" class="image-wrapper" class="image-wrapper" style="width:33;">
<div class="caption-small">#6 {{artists.5.name}}</div>
{% if artists.5 %}
{% if artists.5.thumbnail %}
<a href="{{artists.5.get_absolute_url}}"><img src="{{artists.5.thumbnail_medium.url}}" width="100px"></a>
{% else %}
<a href="{{artists.5.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" class="image-wrapper" style="width:33;">
<div class="caption-small">#7 {{artists.6.name}}</div>
{% if artists.6 %}
{% if artists.6.thumbnail %}
<a href="{{artists.6.get_absolute_url}}"><img src="{{artists.6.thumbnail_medium.url}}" width="100px"></a>
{% else %}
<a href="{{artists.6.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" class="image-wrapper" style="width:33;">
<div class="caption-small">#8 {{artists.7.name}}</div>
{% if artists.7 %}
{% if artists.7.thumbnail %}
<a href="{{artists.7.get_absolute_url}}"><img src="{{artists.7.thumbnail_medium.url}}" width="100px"></a>
{% else %}
<a href="{{artists.7.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" class="image-wrapper" style="width:33;">
<div class="caption-small">#9 {{artists.8.name}}</div>
{% if artists.8 %}
{% if artists.8.thumbnail %}
<a href="{{artists.8.get_absolute_url}}"><img src="{{artists.8.thumbnail_medium.url}}" width="100px"></a>
{% else %}
<a href="{{artists.8.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" class="image-wrapper" style="width:33;">
<div class="caption-small">#10 {{artists.9.name}}</div>
{% if artists.9 %}
{% if artists.9.thumbnail %}
<a href="{{artists.9.get_absolute_url}}"><img src="{{artists.9.thumbnail_medium.url}}" width="100px"></a>
{% else %}
<a href="{{artists.9.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" class="image-wrapper" style="width:33;">
<div class="caption-small">#11 {{artists.10.name}}</div>
{% if artists.10 %}
{% if artists.10.thumbnail %}
<a href="{{artists.10.get_absolute_url}}"><img src="{{artists.10.thumbnail_medium.url}}" width="100px"></a>
{% else %}
<a href="{{artists.10.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" class="image-wrapper" style="width:33;">
<div class="caption-small">#12 {{artists.11.name}}</div>
{% if artists.11 %}
{% if artists.11.thumbnail %}
<a href="{{artists.11.get_absolute_url}}"><img src="{{artists.11.thumbnail_medium.url}}" width="100px"></a>
{% else %}
<a href="{{artists.11.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" class="image-wrapper" style="width:33;">
<div class="caption-small">#13 {{artists.12.name}}</div>
{% if artists.12 %}
{% if artists.12.thumbnail %}
<a href="{{artists.12.get_absolute_url}}"><img src="{{artists.12.thumbnail_medium.url}}" width="100px"></a>
{% else %}
<a href="{{artists.12.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a>
{% endif %}
{% endif %}
</div>
<div class="image-wrapper" class="image-wrapper" style="width:33;">
<div class="caption-small">#14 {{artists.13.name}}</div>
{% if artists.13 %}
{% if artists.13.thumbnail %}
<a href="{{artists.13.get_absolute_url}}"><img src="{{artists.13.thumbnail_medium.url}}" width="100px"></a>
{% else %}
<a href="{{artists.13.get_absolute_url}}"><img src="{% static "images/not-found.jpg" %}" width="100px"></a>
{% endif %}
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% endfor %}
</div>
</div>
<div class="row">

View File

@ -2,12 +2,54 @@
{% block title %}{{name}}{% endblock %}
{% block head_extra %}
<style>
.container { margin-bottom:100px; }
h2 { padding-top:20px; }
.image-wrapper {
contain: content;
}
.image-wrapper :hover {
background:rgba(0,0,0,0.3);
}
.caption {
position: fixed;
top: 5px;
left: 5px;
padding: 3px;
font-size: 90%;
color:white;
background:rgba(0,0,0,0.4);
}
.caption-medium {
position: fixed;
top: 5px;
left: 5px;
padding: 3px;
font-size: 75%;
color:white;
background:rgba(0,0,0,0.4);
}
.caption-small {
position: fixed;
top: 5px;
left: 5px;
padding: 3px;
font-size: 60%;
color:white;
background:rgba(0,0,0,0.4);
}
</style>
{% endblock %}
{% block lists %}
<div "calss="row>
{% include "scrobbles/_top_charts.html" %}
</div>
{% if chart_type == "maloja" %}
{% include "scrobbles/_top_charts.html" %}
{% else %}
<div class="row">
{% if artist_charts %}
<div class="col-md">
@ -151,5 +193,6 @@
</div>
{% endif %}
</div>
{% endif %}
{% endblock %}

View File

@ -43,15 +43,38 @@
background:rgba(0,0,0,0.4);
}
.in-progress {
background: #CCFFFF;
}
.in-progress a { color: black }
</style>
{% endblock %}
{% block content %}
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4">
<div
class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">Dashboard</h1>
<h1 class="h2">{% if date %}{{date|naturaltime}}{% else %}{{title}}{% endif %}</h1>
<div class="btn-toolbar mb-2 mb-md-0">
{% if user.is_authenticated %}
<div class="btn-group me-2">
{% if user.profile.lastfm_username and not user.profile.lastfm_auto_import %}
<form action="{% url 'scrobbles:lastfm-import' %}" method="get">
<button type="submit" class="btn btn-sm btn-outline-secondary">Last.fm Sync</button>
</form>
{% endif %}
{% if prev_link %}
<a type="button" class="btn btn-sm btn-outline-secondary" href="{{prev_link}}"
data-bs-target="#">Previous</a>
{% endif %}
{% if today_link %}
<a type="button" class="btn btn-sm btn-outline-secondary" href="{{today_link}}"
data-bs-target="#">Today</a>
{% endif %}
{% if next_link %}
<a type="button" class="btn btn-sm btn-outline-secondary" href="{{next_link}}"
data-bs-target="#">Next</a>
{% endif %}
</div>
<div class="btn-group me-2">
{% if user.profile.lastfm_username and not user.profile.lastfm_auto_import %}
@ -69,11 +92,13 @@
<button type="button" class="btn btn-sm btn-outline-secondary dropdown-toggle" id="graphDateButton"
data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<div data-feather="calendar"></div>
This week
{{title}}
</button>
<div class="dropdown-menu" data-bs-toggle="#graphDataChange" aria-labelledby="graphDateButton">
<a class="dropdown-item" href="#">This month</a>
<a class="dropdown-item" href="#">This year</a>
<a class="dropdown-item" href="?date=today">Today</a>
<a class="dropdown-item" href="?date=this_week">This week</a>
<a class="dropdown-item" href="?date=this_month">This month</a>
<a class="dropdown-item" href="?date=this_year">This year</a>
</div>
</div>
</div>

View File

@ -62,6 +62,7 @@
<td>{{scrobble.timestamp}}</td>
<td><a href="{{scrobble.get_media_source_url}}">{{scrobble.logdata.description}}</a></td>
<td>{{scrobble.source}}</td>
<td>{{scrobble.log.notes}}</td>
</tr>
{% endfor %}
</tbody>

View File

@ -57,6 +57,7 @@ dd {
<div class="row header">
<div class="cover image-wrapper">
{% if object.imdb_rating %}<div class="caption">{{object.imdb_rating}}</div>{% endif %}
{% if object.tmdb_rating %}<div class="caption">{{object.tmdb_rating}}</div>{% endif %}
<img src="{% if object.cover_image %}{{object.cover_image.url}}{% else %}{% static 'images/no-video-cover.jpg' %}{% endif %}" width="400px" />
<div class="caption-footer">{{object.year}}{% if object.tv_series %} | <b>S</b>{{object.season_number}} <b>E</b>{{object.episode_number}}{% endif %}</div>
</div>

View File

@ -1,5 +1,8 @@
<html>
<style>label { display:none; } textarea {height:8em; width:100%}</style>
<head>
<style>label { display:none; } textarea {height:8em; width:100%}</style>
<title>Reading {{object.title}}</title>
</head>
<body>
<form method=post>{% csrf_token %}
{{form.as_p}}
@ -7,7 +10,7 @@
<input type="submit" value="Finish" />
</form>
<a href="{{object.url}}" target="_blank">Open in new window</a>
<iframe style="height:78%; width:100%" src="{{object.url}}" title="{{object.url}}" allowfullscreen sandbox>
<iframe style="height:84%; width:100%" src="{{object.url}}" title="{{object.url}}" allowfullscreen sandbox>
<p>
Page could not be opened, use link above to read.
</p>