Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 125da84f4e | |||
| 36ceb4c7fe | |||
| 88a3831975 | |||
| 63361964ca | |||
| 40b54b27f4 | |||
| a7eca4b9a7 | |||
| d152412e99 | |||
| 3ba6c6b6e4 | |||
| bffbf47c2f | |||
| f4e81da533 | |||
| 4b7f5459be | |||
| c68b0e9d7e | |||
| 32ec65116b | |||
| da8d26fcd9 | |||
| d33954e494 | |||
| 1b306d6493 | |||
| c881143e1b | |||
| 141700fcb3 | |||
| 7357b5bfec | |||
| 99cabd0007 | |||
| cf77e12cc3 | |||
| 3f2cbbb34a | |||
| 650ecf12c6 | |||
| d7cc009d07 | |||
| a872cf3611 | |||
| 1f9713312b | |||
| 159e555d7c | |||
| 981f4f9c9a | |||
| ddd5ce1392 | |||
| 7a75b31b56 | |||
| 24ac545f55 | |||
| d5da8ae701 | |||
| 53a04d064d | |||
| c0871a3b9e | |||
| d917dd8b2c | |||
| 6fc8084f2d | |||
| 41d6fe8ff6 | |||
| 73838312cd | |||
| eb2dd4c839 | |||
| 1a0c4f69f0 | |||
| 356e579558 | |||
| e980e3c5c9 | |||
| 8773542099 | |||
| 70378c9968 | |||
| c871087496 | |||
| 059d7780a0 | |||
| db36329011 | |||
| 69aa80e6c1 | |||
| 99a6e5107b | |||
| 39e2fdce27 | |||
| b2f98d780b | |||
| 07b5dc6a2c | |||
| 7207ca385e | |||
| 2a254e28d0 | |||
| d4377e49ac | |||
| 9dc0a818ff | |||
| 5e672fc9ed | |||
| 6d194d227e | |||
| ed217cbad2 | |||
| 3cca30dc70 | |||
| d3a15d5e7b | |||
| deeaa0af4b | |||
| dd71bdd38c | |||
| d066a98282 | |||
| 73bc4a1cd1 |
@ -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:
|
||||
|
||||
@ -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 Jackolantern’s 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 Jackolantern’s 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
62
poetry.lock
generated
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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",)
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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(",")
|
||||
|
||||
@ -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/"
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
28
vrobbler/apps/scrobbles/middleware.py
Normal file
28
vrobbler/apps/scrobbles/middleware.py
Normal 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))
|
||||
@ -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",
|
||||
),
|
||||
]
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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/",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"] = {
|
||||
|
||||
@ -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",
|
||||
]
|
||||
@ -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):
|
||||
|
||||
20
vrobbler/apps/tasks/utils.py
Normal file
20
vrobbler/apps/tasks/utils.py
Normal 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
|
||||
@ -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)
|
||||
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
@ -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})
|
||||
|
||||
61
vrobbler/apps/trails/sources/gaiagps.py
Normal file
61
vrobbler/apps/trails/sources/gaiagps.py
Normal 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)
|
||||
@ -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
|
||||
|
||||
18
vrobbler/apps/videos/migrations/0023_video_tmdb_rating.py
Normal file
18
vrobbler/apps/videos/migrations/0023_video_tmdb_rating.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
81
vrobbler/apps/videos/sources/tmdb.py
Normal file
81
vrobbler/apps/videos/sources/tmdb.py
Normal 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
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
|
||||
13
vrobbler/templates/scrobbles/_row.html
Normal file
13
vrobbler/templates/scrobbles/_row.html
Normal 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>
|
||||
23
vrobbler/templates/scrobbles/_scrobble_table.html
Normal file
23
vrobbler/templates/scrobbles/_scrobble_table.html
Normal 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>
|
||||
@ -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">
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user