Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a504e45de | |||
| 7618d0ba30 | |||
| ce4dc40033 | |||
| b0b22b79dc | |||
| 6471413681 | |||
| 50b10689fc | |||
| 85bddb6cba | |||
| c285b0d3b3 | |||
| 671fe8d86f | |||
| 89817110de | |||
| ee01e3d8df | |||
| a70343d6f3 | |||
| 3e72042c24 | |||
| 087c7775ae | |||
| 3f71065ad6 | |||
| 801672124f | |||
| 811e9c1ce9 | |||
| 415b32bdc7 | |||
| 22319c807a | |||
| f9ba6fec14 | |||
| 5f55163147 |
@ -95,6 +95,15 @@ jobs:
|
|||||||
poetry build
|
poetry build
|
||||||
git checkout vrobbler/_commit.py
|
git checkout vrobbler/_commit.py
|
||||||
|
|
||||||
|
- name: Clean old wheels from server
|
||||||
|
uses: appleboy/ssh-action@v1.0.3
|
||||||
|
with:
|
||||||
|
host: vrobbler.service
|
||||||
|
username: root
|
||||||
|
key: ${{ secrets.JAIL_KEY }}
|
||||||
|
script: |
|
||||||
|
rm -f /var/lib/vrobbler/dist/*.whl
|
||||||
|
|
||||||
- name: Copy wheel to server and deploy
|
- name: Copy wheel to server and deploy
|
||||||
uses: appleboy/scp-action@v1.0.0
|
uses: appleboy/scp-action@v1.0.0
|
||||||
with:
|
with:
|
||||||
@ -118,7 +127,7 @@ jobs:
|
|||||||
pip uninstall -y vrobbler
|
pip uninstall -y vrobbler
|
||||||
pip install /var/lib/vrobbler/dist/*.whl
|
pip install /var/lib/vrobbler/dist/*.whl
|
||||||
rm -f /var/lib/vrobbler/dist/*.whl
|
rm -f /var/lib/vrobbler/dist/*.whl
|
||||||
python -c "import vrobbler; print(f'vrobbler {vrobbler.__version__} installed OK')"
|
python3 -c "import vrobbler; print(f'vrobbler {vrobbler.__version__} installed OK')"
|
||||||
vrobbler migrate
|
vrobbler migrate
|
||||||
vrobbler collectstatic --noinput
|
vrobbler collectstatic --noinput
|
||||||
immortalctl restart vrobbler-celery && immortalctl restart vrobbler-celerybeat && immortalctl restart vrobbler
|
immortalctl restart vrobbler-celery && immortalctl restart vrobbler-celerybeat && immortalctl restart vrobbler
|
||||||
|
|||||||
179
PROJECT.org
179
PROJECT.org
@ -93,7 +93,7 @@ fetching and simple saving.
|
|||||||
:LOGBOOK:
|
:LOGBOOK:
|
||||||
CLOCK: [2025-07-09 Wed 09:55]--[2025-07-09 Wed 10:15] => 0:20
|
CLOCK: [2025-07-09 Wed 09:55]--[2025-07-09 Wed 10:15] => 0:20
|
||||||
:END:
|
:END:
|
||||||
* Backlog [0/15] :vrobbler:project:personal:
|
* Backlog [0/13] :vrobbler:project:personal:
|
||||||
** TODO [#C] Add sentiment parsing for Scrobbles with notes :vrobbler:project:scrobbles:sentiment:
|
** TODO [#C] Add sentiment parsing for Scrobbles with notes :vrobbler:project:scrobbles:sentiment:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: 37781d6a-f3b0-48b2-bf98-33c2c791cf85
|
:ID: 37781d6a-f3b0-48b2-bf98-33c2c791cf85
|
||||||
@ -459,11 +459,11 @@ https://app.todoist.com/app/task/add-a-csv-endpoint-for-users-book-reads-that-li
|
|||||||
- Note taken on [2025-09-25 Thu 10:51]
|
- Note taken on [2025-09-25 Thu 10:51]
|
||||||
|
|
||||||
As an example https://comicbookroundup.com/comic-books/reviews/humanoids-publishing/the-history-of-science-fiction
|
As an example https://comicbookroundup.com/comic-books/reviews/humanoids-publishing/the-history-of-science-fiction
|
||||||
** TODO [#B] Find page numbers for comic books from ComicVine :vrobbler:feature:books:personal:project:
|
** TODO [#B] Find page numbers for comic books from ComicVine :feature:books:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: 79f867c3-1288-4143-b6bf-2a452983ee9f
|
:ID: 79f867c3-1288-4143-b6bf-2a452983ee9f
|
||||||
:END:
|
:END:
|
||||||
** TODO [#B] Fix koreader scrobble imports to use DST properly :vrobbler:personal:bug:books:imports:
|
** TODO [#B] Fix koreader scrobble imports to use DST properly :bug:books:imports:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: 79758cba-a440-48b6-a637-efb88827acf2
|
:ID: 79758cba-a440-48b6-a637-efb88827acf2
|
||||||
:END:
|
:END:
|
||||||
@ -489,7 +489,69 @@ whatever time KoReader reports, we need to know, given the date and the user
|
|||||||
profile's historic timezone, how many hours to adjust the KoReader time to get
|
profile's historic timezone, how many hours to adjust the KoReader time to get
|
||||||
to GMT to save it in the database.
|
to GMT to save it in the database.
|
||||||
|
|
||||||
** TODO [#A] Orgmode tasks are not updated if in progress :tasks:orgmode:bug:
|
** TODO [#B] Add ability to add mopidy tracks to Monthly playlists :feature:favorites:tracks:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: c872ff0a-e71f-415f-b5a6-e62ea9634d14
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
Now that we can favorite a mopidy track and have it added to a Favorites playlist, it would
|
||||||
|
be great if we could also populate a monthly_mopidy_playlist_pattern in a user profile and,
|
||||||
|
if configured, you could press "Add to monthly playlist" button on a given track that has
|
||||||
|
a mopidy_uri in it's log, and it would be added to the playlist.
|
||||||
|
|
||||||
|
The patterns would be based on traditional Django date formatting patterns: https://gregbrown.co/code/date-format
|
||||||
|
|
||||||
|
So "Y m F" would yield "2026 05 May" if the link is clicked in May of 2026.
|
||||||
|
|
||||||
|
* Version 44.0 [1/1]
|
||||||
|
** DONE [#B] Add favorite feature for scrobbles :feature:favorites:scrobbles:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 2780ae5f-fe23-49a5-8b33-d19e7f3e8ec6
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
Would be great to have a FavoriteMedia data model that would accept any media_type ID and a user_id
|
||||||
|
marking that media as a favorite for that user.
|
||||||
|
|
||||||
|
Additionally, for tracks, we should add the ability to set a "favorites_mopidy_playlist" in a user profile
|
||||||
|
and if populated, and a track media type is favorited, and the track has a mopidy_uri value in a scrobble log,
|
||||||
|
send a POST to the mopidy server RPC endpoint for the favorite playlist and add the track.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
* Version 43.0 [5/5]
|
||||||
|
** DONE [#B] Can we show a graph of all past Weigh-in tasks :scale:tasks:graphs:javascript:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: ae499d87-03bf-4e48-9b2c-1a421a46af11
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
I wonder if, as a special type of task, Weigh-in's could show a graph of the
|
||||||
|
metrics that are stored against all the past weigh-ins?
|
||||||
|
|
||||||
|
The graph would contain all Weigh-in scrobbles for that user, no matter which
|
||||||
|
date is being viewed, and the highlighted value on the graph would be the date
|
||||||
|
being viewed.
|
||||||
|
|
||||||
|
Probably could use something like chart.js although maybe that's too heavy?
|
||||||
|
|
||||||
|
And can we have each metric overlayed on the same graph?
|
||||||
|
|
||||||
|
** DONE [#B] When viewing scrobbles by tag, sum the total time :scrobbles:tags:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: d51f23df-c2c5-4e1a-b000-67c89032af02
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
On scrobbles filtered by tags, we should see a sum of the time spent doing those tasks, in a human
|
||||||
|
readable format like "X days, X hours, X minutes and X seconds"
|
||||||
|
|
||||||
|
** DONE [#A] Orgmode tasks are not updated if in progress :tasks:orgmode:bug:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: 7dcebb2c-7c4c-4ac5-bee6-c2e36c3811f9
|
:ID: 7dcebb2c-7c4c-4ac5-bee6-c2e36c3811f9
|
||||||
:END:
|
:END:
|
||||||
@ -505,7 +567,106 @@ different. And the same for comments. If a comment (by timestamp key) is
|
|||||||
different in the webhook than what's in the scrobble.log, update the comment in
|
different in the webhook than what's in the scrobble.log, update the comment in
|
||||||
the scrobble.log
|
the scrobble.log
|
||||||
|
|
||||||
** TODO [#C] Fix bug where Weigh-in imports do not set title :bug:tasks:scale:
|
** DONE [#A] Ignore tag 'inprogress' for Tasks :bug:tasks:tags:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: cd37c1ec-e2fc-b93c-daf8-6b329712c3f1
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
When scrobbling tasks from Todoist, the tag `inprogress` is always in the
|
||||||
|
payload, because that's how we parse tasks starting from the Todoist webhooks.
|
||||||
|
|
||||||
|
But we don't really need anything tagged as `inprogress` Can we ignore this tag
|
||||||
|
when applying tags to Task scrobbles coming from Todoist?`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
** DONE [#A] Deploys are now throwing an unknown version error :bug:tooling:releases:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 3870f9d3-b5ed-4b87-9e8c-9bf905bfb766
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
Almost everything is working, but for some reason `__version__` does not seem to
|
||||||
|
exist.
|
||||||
|
|
||||||
|
#+begin_src sh
|
||||||
|
out: Installing collected packages: vrobbler
|
||||||
|
out: Successfully installed vrobbler-42.0
|
||||||
|
err: WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv
|
||||||
|
err: Traceback (most recent call last):
|
||||||
|
err: File "<string>", line 1, in <module>
|
||||||
|
err: AttributeError: module 'vrobbler' has no attribute '__version__'
|
||||||
|
2026/06/04 17:18:15 Process exited with status 1
|
||||||
|
failed to remove container: Error response from daemon: removal of container c8ac64bee9b6bf5978d2c16f299e5ac271d8bbf7192b7a4023c3712bc2444f8b is already in progress
|
||||||
|
❌ Failure - Main Install wheel and restart services
|
||||||
|
exit with `FAILURE`: 1
|
||||||
|
#+end_src
|
||||||
|
|
||||||
|
|
||||||
|
* Version 42.0 [1/1]
|
||||||
|
** DONE [#B] Add ability to add track to current Mopidy queue :feature:mopidy:tracks:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 79d5b580-4ea6-461b-4c6c-2c950d8b3e4c
|
||||||
|
:END:
|
||||||
|
|
||||||
|
|
||||||
|
* Version 41.0 [5/5]
|
||||||
|
** DONE [#B] For any scrobble detail page with notes display them better :templates:notes:scrobbles:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: c0dcf9da-227f-4a22-bcd9-9d46053607d9
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
Currently notes are displayed as little post-it notes. This is cute, but not terribly useful.
|
||||||
|
|
||||||
|
We should update note rendering to be a simple newest to oldest display in a
|
||||||
|
single column with the timestamp has a small header, and the content rendered as
|
||||||
|
markdown with a small bar or horizontal divider marking them from the next note.
|
||||||
|
|
||||||
|
** DONE [#A] Imports should send notifications :feature:notifications:imports:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 6f78f8d5-ecaa-4d8a-a666-ae4e27653191
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
Currently importing board games sends out a ntfy message when a scrobble is
|
||||||
|
created.
|
||||||
|
|
||||||
|
We should do the same thing for other import types; namely: gpx, ebird, and scale.
|
||||||
|
|
||||||
|
** DONE [#A] Board game imports send duplicate ntfy message :bug:notifications:boardgames:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 8f067432-0399-4b79-9e93-727edcccedbd
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
When a board game scrobble is created via a bgstats import, ntfy messages are
|
||||||
|
sent.
|
||||||
|
|
||||||
|
But right now they are duplicated (two are sent at the same time). Can we review
|
||||||
|
the code to see why this is happening and fix it?
|
||||||
|
|
||||||
|
** DONE [#A] Too many geolocation notifications go out :bug:notifications:geolocations:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 6357ad7a-fe4e-49dd-a063-55d87e459c17
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
Currently ntfy gets overwhelemed when there's more than a hundred or so messages left in a queue on a client.
|
||||||
|
|
||||||
|
It would be nice if we could not spam ntfy, and this is especially true with Geolocations, where we really
|
||||||
|
don't need to alert folks unless they have a named Geolocation (has a title). Can we adjust the ntfy
|
||||||
|
sending for Geolocations to only send if the scrobbled location has a title?
|
||||||
|
|
||||||
|
|
||||||
|
** DONE [#C] Fix bug where Weigh-in imports do not set title :bug:tasks:scale:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: 622e354a-8e66-4ecd-9e1c-a53f0a2ec362
|
:ID: 622e354a-8e66-4ecd-9e1c-a53f0a2ec362
|
||||||
:END:
|
:END:
|
||||||
@ -516,6 +677,14 @@ Currently when we import a scale CSV row and create data, the title is left
|
|||||||
blank which makes it look funny in a list view. Let's save the weight as the
|
blank which makes it look funny in a list view. Let's save the weight as the
|
||||||
title of the Weigh-in task.
|
title of the Weigh-in task.
|
||||||
|
|
||||||
|
|
||||||
|
* Version 40.2 [1/1]
|
||||||
|
** DONE [#A] Try fixing deploy bugs again :tooling:releases:bug:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 15894943-be1d-200f-8400-a136770ad9d2
|
||||||
|
:END:
|
||||||
|
|
||||||
|
|
||||||
* Version 40.1 [2/2]
|
* Version 40.1 [2/2]
|
||||||
** DONE [#A] Releases are still broken :bug:releases:tooling:
|
** DONE [#A] Releases are still broken :bug:releases:tooling:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "vrobbler"
|
name = "vrobbler"
|
||||||
version = "40.1"
|
version = "44.0"
|
||||||
description = ""
|
description = ""
|
||||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||||
|
|
||||||
@ -107,6 +107,8 @@ exclude_dirs = ["*/tests/*", "*/migrations/*"]
|
|||||||
[tool.poetry.scripts]
|
[tool.poetry.scripts]
|
||||||
vrobbler = "vrobbler.cli:main"
|
vrobbler = "vrobbler.cli:main"
|
||||||
|
|
||||||
|
[tool.poetry_bumpversion.file."vrobbler/__init__.py"]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core>=1.0.0"]
|
requires = ["poetry-core>=1.0.0"]
|
||||||
build-backend = "poetry.core.masonry.api"
|
build-backend = "poetry.core.masonry.api"
|
||||||
|
|||||||
@ -2,4 +2,5 @@
|
|||||||
# Django starts so that shared_task will use this app.
|
# Django starts so that shared_task will use this app.
|
||||||
from .celery import app as celery_app
|
from .celery import app as celery_app
|
||||||
|
|
||||||
__all__ = ("celery_app",)
|
__version__ = "42.0"
|
||||||
|
__all__ = ("celery_app", "__version__")
|
||||||
|
|||||||
@ -8,6 +8,7 @@ from django.contrib.auth import get_user_model
|
|||||||
|
|
||||||
from birds.models import Bird, BirdSightingEntry, BirdSightingLogData, BirdingLocation
|
from birds.models import Bird, BirdSightingEntry, BirdSightingLogData, BirdingLocation
|
||||||
from scrobbles.models import Scrobble
|
from scrobbles.models import Scrobble
|
||||||
|
from scrobbles.notifications import ScrobbleNtfyNotification
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
@ -183,4 +184,6 @@ def import_birding_csv(file_path, user_id):
|
|||||||
|
|
||||||
created = Scrobble.objects.bulk_create(new_scrobbles)
|
created = Scrobble.objects.bulk_create(new_scrobbles)
|
||||||
logger.info(f"Created {len(created)} birding scrobbles")
|
logger.info(f"Created {len(created)} birding scrobbles")
|
||||||
|
for scrobble in created:
|
||||||
|
ScrobbleNtfyNotification(scrobble).send()
|
||||||
return created
|
return created
|
||||||
|
|||||||
@ -31,6 +31,8 @@ class UserProfileForm(forms.ModelForm):
|
|||||||
"webdav_auto_import",
|
"webdav_auto_import",
|
||||||
"ntfy_url",
|
"ntfy_url",
|
||||||
"ntfy_enabled",
|
"ntfy_enabled",
|
||||||
|
"mopidy_api_url",
|
||||||
|
"favorites_mopidy_playlist",
|
||||||
"redirect_to_webpage",
|
"redirect_to_webpage",
|
||||||
"enable_public_widgets",
|
"enable_public_widgets",
|
||||||
"widget_custom_css",
|
"widget_custom_css",
|
||||||
|
|||||||
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.30 on 2026-06-04 16:27
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("profiles", "0032_userprofile_weigh_in_units"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="mopidy_api_url",
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 4.2.30 on 2026-06-05 14:55
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("profiles", "0033_userprofile_mopidy_api_url"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="favorites_mopidy_playlist",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Playlist name (e.g. 'Favorites'). Will map to m3u:Favorites.m3u8",
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -64,6 +64,12 @@ class UserProfile(TimeStampedModel):
|
|||||||
ntfy_url = models.CharField(max_length=255, **BNULL)
|
ntfy_url = models.CharField(max_length=255, **BNULL)
|
||||||
ntfy_enabled = models.BooleanField(default=False)
|
ntfy_enabled = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
mopidy_api_url = models.CharField(max_length=255, **BNULL)
|
||||||
|
favorites_mopidy_playlist = models.CharField(
|
||||||
|
max_length=255, **BNULL,
|
||||||
|
help_text="Playlist name (e.g. 'Favorites'). Will map to m3u:Favorites.m3u8",
|
||||||
|
)
|
||||||
|
|
||||||
redirect_to_webpage = models.BooleanField(default=True)
|
redirect_to_webpage = models.BooleanField(default=True)
|
||||||
|
|
||||||
enable_public_widgets = models.BooleanField(default=False)
|
enable_public_widgets = models.BooleanField(default=False)
|
||||||
|
|||||||
@ -4,6 +4,7 @@ from scrobbles.models import (
|
|||||||
AudioScrobblerTSVImport,
|
AudioScrobblerTSVImport,
|
||||||
BGStatsImport,
|
BGStatsImport,
|
||||||
EBirdCSVImport,
|
EBirdCSVImport,
|
||||||
|
FavoriteMedia,
|
||||||
KoReaderImport,
|
KoReaderImport,
|
||||||
LastFmImport,
|
LastFmImport,
|
||||||
RetroarchImport,
|
RetroarchImport,
|
||||||
@ -164,3 +165,28 @@ class ScrobbleAdmin(admin.ModelAdmin):
|
|||||||
def get_queryset(self, request):
|
def get_queryset(self, request):
|
||||||
qs = super().get_queryset(request).exclude(timestamp__year=None)
|
qs = super().get_queryset(request).exclude(timestamp__year=None)
|
||||||
return qs
|
return qs
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(FavoriteMedia)
|
||||||
|
class FavoriteMediaAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("user", "media_type", "sent_to_mopidy", "created")
|
||||||
|
list_filter = ("media_type", "sent_to_mopidy", "user")
|
||||||
|
date_hierarchy = "created"
|
||||||
|
raw_id_fields = (
|
||||||
|
"video",
|
||||||
|
"track",
|
||||||
|
"podcast_episode",
|
||||||
|
"sport_event",
|
||||||
|
"book",
|
||||||
|
"video_game",
|
||||||
|
"board_game",
|
||||||
|
"geo_location",
|
||||||
|
"task",
|
||||||
|
"mood",
|
||||||
|
"brick_set",
|
||||||
|
"trail",
|
||||||
|
"beer",
|
||||||
|
"web_page",
|
||||||
|
"life_event",
|
||||||
|
"birding_location",
|
||||||
|
)
|
||||||
|
|||||||
@ -83,43 +83,110 @@ class BaseLogData(JSONDataclass):
|
|||||||
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_timestamp(ts: str) -> str:
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import re
|
||||||
|
|
||||||
|
cleaned = ts.strip().strip("[]")
|
||||||
|
dt = None
|
||||||
|
|
||||||
|
if cleaned.isdigit():
|
||||||
|
try:
|
||||||
|
seconds = int(cleaned)
|
||||||
|
dt = datetime.fromtimestamp(seconds, tz=timezone.utc)
|
||||||
|
except (ValueError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if dt is None:
|
||||||
|
for fmt in [
|
||||||
|
"%Y-%m-%dT%H:%M:%S.%fZ",
|
||||||
|
"%Y-%m-%dT%H:%M:%SZ",
|
||||||
|
"%Y-%m-%dT%H:%M:%S.%f",
|
||||||
|
"%Y-%m-%dT%H:%M:%S",
|
||||||
|
]:
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(cleaned, fmt)
|
||||||
|
break
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if dt is None:
|
||||||
|
m = re.match(
|
||||||
|
r"(\d{4}-\d{2}-\d{2})\s+\w{3}\s+(\d{2}:\d{2})", cleaned
|
||||||
|
)
|
||||||
|
if m:
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(
|
||||||
|
f"{m.group(1)} {m.group(2)}", "%Y-%m-%d %H:%M"
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if dt is None:
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(cleaned)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if dt:
|
||||||
|
return dt.strftime("%b %-d, %Y %-I:%M %p")
|
||||||
|
return ts
|
||||||
|
|
||||||
def notes_as_html(self) -> str:
|
def notes_as_html(self) -> str:
|
||||||
import html
|
import bleach
|
||||||
|
import markdown
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
if not self.notes:
|
if not self.notes:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
html_notes = []
|
md = markdown.Markdown(extensions=["extra"])
|
||||||
|
allowed_tags = [
|
||||||
|
"p", "br", "strong", "em", "a", "ul", "ol", "li",
|
||||||
|
"code", "pre", "blockquote", "h1", "h2", "h3", "h4", "h5", "h6",
|
||||||
|
"hr", "img",
|
||||||
|
]
|
||||||
|
|
||||||
|
note_items = []
|
||||||
|
|
||||||
if isinstance(self.notes, dict):
|
if isinstance(self.notes, dict):
|
||||||
for ts, text in self.notes.items():
|
for ts, text in sorted(self.notes.items(), reverse=True):
|
||||||
note_text = " ".join(text.strip().split())
|
note_items.append((ts, text.strip()))
|
||||||
html_notes.append(
|
|
||||||
f'<div class="sticky-note">{ts}: {note_text}</div>'
|
|
||||||
)
|
|
||||||
elif isinstance(self.notes, str):
|
elif isinstance(self.notes, str):
|
||||||
for line in self.notes.split("\n"):
|
for line in self.notes.split("\n"):
|
||||||
if line.strip():
|
if line.strip():
|
||||||
escaped_line = html.escape(line)
|
note_items.append((None, line.strip()))
|
||||||
html_notes.append(f'<div class="sticky-note">{escaped_line}</div>')
|
|
||||||
elif isinstance(self.notes, list):
|
elif isinstance(self.notes, list):
|
||||||
for note in self.notes:
|
for note in self.notes:
|
||||||
if isinstance(note, dict):
|
if isinstance(note, dict):
|
||||||
timestamp, note_text = next(iter(note.items()))
|
timestamp, note_text = next(iter(note.items()))
|
||||||
note_text = " ".join(note_text.strip().split())
|
note_items.append((timestamp, note_text.strip()))
|
||||||
html_notes.append(
|
|
||||||
f'<div class="sticky-note">{timestamp}: {note_text}</div>'
|
|
||||||
)
|
|
||||||
elif isinstance(note, str):
|
elif isinstance(note, str):
|
||||||
for line in note.split("\n"):
|
for line in note.split("\n"):
|
||||||
if line.strip():
|
if line.strip():
|
||||||
escaped_line = html.escape(line)
|
note_items.append((None, line.strip()))
|
||||||
html_notes.append(
|
|
||||||
f'<div class="sticky-note">{escaped_line}</div>'
|
|
||||||
)
|
|
||||||
|
|
||||||
return mark_safe("".join(html_notes))
|
html_parts = []
|
||||||
|
for i, (ts, text) in enumerate(note_items):
|
||||||
|
if i > 0:
|
||||||
|
html_parts.append('<hr class="note-divider">')
|
||||||
|
|
||||||
|
ts_html = ""
|
||||||
|
if ts:
|
||||||
|
ts_html = f'<h5 class="note-timestamp">{self._format_timestamp(ts)}</h5>'
|
||||||
|
|
||||||
|
content_html = bleach.clean(
|
||||||
|
md.convert(text),
|
||||||
|
tags=allowed_tags,
|
||||||
|
strip=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
html_parts.append(
|
||||||
|
f'<div class="note-item">{ts_html}<div class="note-content">{content_html}</div></div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
return mark_safe("".join(html_parts))
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@ -5,6 +5,7 @@ from datetime import datetime, timedelta
|
|||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
from scrobbles.models import Scrobble
|
from scrobbles.models import Scrobble
|
||||||
|
from scrobbles.notifications import ScrobbleNtfyNotification
|
||||||
from tasks.models import Task
|
from tasks.models import Task
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -85,6 +86,11 @@ def import_scale_csv(file_path, user_id):
|
|||||||
|
|
||||||
log_dict["unit_type"] = user.profile.weigh_in_units
|
log_dict["unit_type"] = user.profile.weigh_in_units
|
||||||
|
|
||||||
|
weight_val = log_dict.get("weight")
|
||||||
|
if weight_val is not None:
|
||||||
|
unit = "kg" if user.profile.weigh_in_units == "metric" else "lbs"
|
||||||
|
log_dict["title"] = f"{weight_val} {unit}"
|
||||||
|
|
||||||
existing = Scrobble.objects.filter(
|
existing = Scrobble.objects.filter(
|
||||||
timestamp=start,
|
timestamp=start,
|
||||||
task=weigh_in,
|
task=weigh_in,
|
||||||
@ -110,4 +116,6 @@ def import_scale_csv(file_path, user_id):
|
|||||||
|
|
||||||
created = Scrobble.objects.bulk_create(new_scrobbles)
|
created = Scrobble.objects.bulk_create(new_scrobbles)
|
||||||
logger.info(f"Created {len(created)} weigh-in scrobbles")
|
logger.info(f"Created {len(created)} weigh-in scrobbles")
|
||||||
|
for scrobble in created:
|
||||||
|
ScrobbleNtfyNotification(scrobble).send()
|
||||||
return created
|
return created
|
||||||
|
|||||||
@ -12,6 +12,7 @@ from django.core.files import File
|
|||||||
|
|
||||||
from locations.models import GeoLocation
|
from locations.models import GeoLocation
|
||||||
from scrobbles.models import Scrobble
|
from scrobbles.models import Scrobble
|
||||||
|
from scrobbles.notifications import ScrobbleNtfyNotification
|
||||||
from trails.models import Trail, TrailLogData
|
from trails.models import Trail, TrailLogData
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -328,4 +329,6 @@ def import_trail_gpx(file_path, user_id, original_filename=None):
|
|||||||
|
|
||||||
created = Scrobble.objects.bulk_create(new_scrobbles)
|
created = Scrobble.objects.bulk_create(new_scrobbles)
|
||||||
logger.info(f"Created {len(created)} trail scrobbles")
|
logger.info(f"Created {len(created)} trail scrobbles")
|
||||||
|
for scrobble in created:
|
||||||
|
ScrobbleNtfyNotification(scrobble).send()
|
||||||
return created
|
return created
|
||||||
|
|||||||
287
vrobbler/apps/scrobbles/migrations/0089_favoritemedia.py
Normal file
287
vrobbler/apps/scrobbles/migrations/0089_favoritemedia.py
Normal file
@ -0,0 +1,287 @@
|
|||||||
|
# Generated by Django 4.2.30 on 2026-06-05 14:55
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django_extensions.db.fields
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("boardgames", "0015_alter_boardgame_genre"),
|
||||||
|
("trails", "0009_trail_route_waypoint"),
|
||||||
|
("moods", "0008_alter_mood_genre"),
|
||||||
|
("birds", "0002_birdingcsvimport"),
|
||||||
|
("bricksets", "0005_alter_brickset_genre"),
|
||||||
|
("music", "0036_artist_similar_artists"),
|
||||||
|
("puzzles", "0006_alter_puzzle_genre"),
|
||||||
|
("videogames", "0015_alter_videogame_genre"),
|
||||||
|
("lifeevents", "0005_alter_lifeevent_genre"),
|
||||||
|
("beers", "0008_alter_beer_genre"),
|
||||||
|
("foods", "0007_alter_food_genre"),
|
||||||
|
("tasks", "0007_alter_task_genre"),
|
||||||
|
("books", "0036_alter_book_genre_alter_paper_genre"),
|
||||||
|
("videos", "0030_alter_channel_genre_alter_series_genre_and_more"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
("podcasts", "0020_alter_podcast_genre_alter_podcastepisode_genre"),
|
||||||
|
("webpages", "0009_alter_webpage_genre"),
|
||||||
|
("sports", "0018_alter_sportevent_genre"),
|
||||||
|
("locations", "0010_clean_start"),
|
||||||
|
("scrobbles", "0088_scalecsvimport_file_hash"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="FavoriteMedia",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"created",
|
||||||
|
django_extensions.db.fields.CreationDateTimeField(
|
||||||
|
auto_now_add=True, verbose_name="created"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"modified",
|
||||||
|
django_extensions.db.fields.ModificationDateTimeField(
|
||||||
|
auto_now=True, verbose_name="modified"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"uuid",
|
||||||
|
models.UUIDField(
|
||||||
|
blank=True, default=uuid.uuid4, editable=False, null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"media_type",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("Video", "Video"),
|
||||||
|
("Track", "Track"),
|
||||||
|
("PodcastEpisode", "Podcast episode"),
|
||||||
|
("SportEvent", "Sport event"),
|
||||||
|
("Book", "Book"),
|
||||||
|
("Paper", "Paper"),
|
||||||
|
("VideoGame", "Video game"),
|
||||||
|
("BoardGame", "Board game"),
|
||||||
|
("GeoLocation", "GeoLocation"),
|
||||||
|
("Trail", "Trail"),
|
||||||
|
("Beer", "Beer"),
|
||||||
|
("Puzzle", "Puzzle"),
|
||||||
|
("Food", "Food"),
|
||||||
|
("Task", "Task"),
|
||||||
|
("WebPage", "Web Page"),
|
||||||
|
("LifeEvent", "Life event"),
|
||||||
|
("Mood", "Mood"),
|
||||||
|
("BrickSet", "Brick set"),
|
||||||
|
("Channel", "Channel"),
|
||||||
|
("BirdingLocation", "Birding location"),
|
||||||
|
],
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("sent_to_mopidy", models.BooleanField(default=False)),
|
||||||
|
(
|
||||||
|
"beer",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="beers.beer",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"birding_location",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="birds.birdinglocation",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"board_game",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="boardgames.boardgame",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"book",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="books.book",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"brick_set",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="bricksets.brickset",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"channel",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="videos.channel",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"food",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="foods.food",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"geo_location",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="locations.geolocation",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"life_event",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="lifeevents.lifeevent",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"mood",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="moods.mood",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"paper",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="books.paper",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"podcast_episode",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="podcasts.podcastepisode",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"puzzle",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="puzzles.puzzle",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"sport_event",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="sports.sportevent",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"task",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="tasks.task",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"track",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="music.track",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"trail",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="trails.trail",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"video",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="videos.video",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"video_game",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="videogames.videogame",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"web_page",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="webpages.webpage",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["-created"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -1529,7 +1529,12 @@ class Scrobble(TimeStampedModel):
|
|||||||
scrobble_data: dict,
|
scrobble_data: dict,
|
||||||
) -> "Scrobble":
|
) -> "Scrobble":
|
||||||
scrobble = cls.objects.create(**scrobble_data)
|
scrobble = cls.objects.create(**scrobble_data)
|
||||||
ScrobbleNtfyNotification(scrobble).send()
|
if not (
|
||||||
|
scrobble.media_type == cls.MediaType.GEO_LOCATION
|
||||||
|
and scrobble.media_obj
|
||||||
|
and not scrobble.media_obj.title
|
||||||
|
):
|
||||||
|
ScrobbleNtfyNotification(scrobble).send()
|
||||||
return scrobble
|
return scrobble
|
||||||
|
|
||||||
def stop(self, timestamp=None, force_finish=False) -> None:
|
def stop(self, timestamp=None, force_finish=False) -> None:
|
||||||
@ -1714,3 +1719,131 @@ class Scrobble(TimeStampedModel):
|
|||||||
|
|
||||||
if commit and "pages_read" in self.log:
|
if commit and "pages_read" in self.log:
|
||||||
self.save(update_fields=["log"])
|
self.save(update_fields=["log"])
|
||||||
|
|
||||||
|
|
||||||
|
class FavoriteMedia(TimeStampedModel):
|
||||||
|
|
||||||
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
|
|
||||||
|
video = models.ForeignKey(Video, on_delete=models.CASCADE, **BNULL)
|
||||||
|
channel = models.ForeignKey("videos.Channel", on_delete=models.CASCADE, **BNULL)
|
||||||
|
track = models.ForeignKey(Track, on_delete=models.CASCADE, **BNULL)
|
||||||
|
podcast_episode = models.ForeignKey(
|
||||||
|
PodcastEpisode, on_delete=models.CASCADE, **BNULL
|
||||||
|
)
|
||||||
|
sport_event = models.ForeignKey(SportEvent, on_delete=models.CASCADE, **BNULL)
|
||||||
|
book = models.ForeignKey(Book, on_delete=models.CASCADE, **BNULL)
|
||||||
|
paper = models.ForeignKey(Paper, on_delete=models.CASCADE, **BNULL)
|
||||||
|
video_game = models.ForeignKey(VideoGame, on_delete=models.CASCADE, **BNULL)
|
||||||
|
board_game = models.ForeignKey(BoardGame, on_delete=models.CASCADE, **BNULL)
|
||||||
|
geo_location = models.ForeignKey(GeoLocation, on_delete=models.CASCADE, **BNULL)
|
||||||
|
beer = models.ForeignKey(Beer, on_delete=models.CASCADE, **BNULL)
|
||||||
|
puzzle = models.ForeignKey(Puzzle, on_delete=models.CASCADE, **BNULL)
|
||||||
|
food = models.ForeignKey(Food, on_delete=models.CASCADE, **BNULL)
|
||||||
|
trail = models.ForeignKey(Trail, on_delete=models.CASCADE, **BNULL)
|
||||||
|
task = models.ForeignKey(Task, on_delete=models.CASCADE, **BNULL)
|
||||||
|
web_page = models.ForeignKey(WebPage, on_delete=models.CASCADE, **BNULL)
|
||||||
|
life_event = models.ForeignKey(LifeEvent, on_delete=models.CASCADE, **BNULL)
|
||||||
|
mood = models.ForeignKey(Mood, on_delete=models.CASCADE, **BNULL)
|
||||||
|
brick_set = models.ForeignKey(BrickSet, on_delete=models.CASCADE, **BNULL)
|
||||||
|
birding_location = models.ForeignKey(
|
||||||
|
BirdingLocation, on_delete=models.CASCADE, **BNULL
|
||||||
|
)
|
||||||
|
media_type = models.CharField(
|
||||||
|
max_length=20, choices=Scrobble.MediaType.choices
|
||||||
|
)
|
||||||
|
sent_to_mopidy = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user} favorites {self.media_obj}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_obj(self):
|
||||||
|
media_obj = None
|
||||||
|
if self.video:
|
||||||
|
media_obj = self.video
|
||||||
|
if self.track:
|
||||||
|
media_obj = self.track
|
||||||
|
if self.podcast_episode:
|
||||||
|
media_obj = self.podcast_episode
|
||||||
|
if self.sport_event:
|
||||||
|
media_obj = self.sport_event
|
||||||
|
if self.book:
|
||||||
|
media_obj = self.book
|
||||||
|
if self.video_game:
|
||||||
|
media_obj = self.video_game
|
||||||
|
if self.board_game:
|
||||||
|
media_obj = self.board_game
|
||||||
|
if self.geo_location:
|
||||||
|
media_obj = self.geo_location
|
||||||
|
if self.web_page:
|
||||||
|
media_obj = self.web_page
|
||||||
|
if self.life_event:
|
||||||
|
media_obj = self.life_event
|
||||||
|
if self.mood:
|
||||||
|
media_obj = self.mood
|
||||||
|
if self.brick_set:
|
||||||
|
media_obj = self.brick_set
|
||||||
|
if self.trail:
|
||||||
|
media_obj = self.trail
|
||||||
|
if self.beer:
|
||||||
|
media_obj = self.beer
|
||||||
|
if self.puzzle:
|
||||||
|
media_obj = self.puzzle
|
||||||
|
if self.task:
|
||||||
|
media_obj = self.task
|
||||||
|
if self.food:
|
||||||
|
media_obj = self.food
|
||||||
|
if self.channel:
|
||||||
|
media_obj = self.channel
|
||||||
|
if self.birding_location:
|
||||||
|
media_obj = self.birding_location
|
||||||
|
return media_obj
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def toggle(cls, media_obj, user):
|
||||||
|
media_type = media_obj.__class__.__name__
|
||||||
|
if media_type not in Scrobble.MediaType.list():
|
||||||
|
raise ValueError(f"Unknown media type: {media_type}")
|
||||||
|
|
||||||
|
fk_map = {
|
||||||
|
"Video": "video",
|
||||||
|
"Channel": "channel",
|
||||||
|
"Track": "track",
|
||||||
|
"PodcastEpisode": "podcast_episode",
|
||||||
|
"SportEvent": "sport_event",
|
||||||
|
"Book": "book",
|
||||||
|
"Paper": "paper",
|
||||||
|
"VideoGame": "video_game",
|
||||||
|
"BoardGame": "board_game",
|
||||||
|
"GeoLocation": "geo_location",
|
||||||
|
"Beer": "beer",
|
||||||
|
"Puzzle": "puzzle",
|
||||||
|
"Food": "food",
|
||||||
|
"Trail": "trail",
|
||||||
|
"Task": "task",
|
||||||
|
"WebPage": "web_page",
|
||||||
|
"LifeEvent": "life_event",
|
||||||
|
"Mood": "mood",
|
||||||
|
"BrickSet": "brick_set",
|
||||||
|
"BirdingLocation": "birding_location",
|
||||||
|
}
|
||||||
|
|
||||||
|
fk = fk_map.get(media_type)
|
||||||
|
if not fk:
|
||||||
|
raise ValueError(f"No FK mapping for media type: {media_type}")
|
||||||
|
|
||||||
|
existing = cls.objects.filter(user=user, **{fk: media_obj}).first()
|
||||||
|
if existing:
|
||||||
|
existing.delete()
|
||||||
|
return None
|
||||||
|
|
||||||
|
return cls.objects.create(
|
||||||
|
user=user,
|
||||||
|
media_type=media_type,
|
||||||
|
**{fk: media_obj},
|
||||||
|
)
|
||||||
|
|||||||
@ -562,7 +562,6 @@ def email_scrobble_board_game(
|
|||||||
scrobble.played_to_completion = True
|
scrobble.played_to_completion = True
|
||||||
scrobble.save()
|
scrobble.save()
|
||||||
scrobbles_created.append(scrobble)
|
scrobbles_created.append(scrobble)
|
||||||
ScrobbleNtfyNotification(scrobble).send()
|
|
||||||
|
|
||||||
return scrobbles_created
|
return scrobbles_created
|
||||||
|
|
||||||
@ -762,7 +761,10 @@ def todoist_scrobble_task(
|
|||||||
|
|
||||||
todoist_task["title"] = todoist_task.pop("description")
|
todoist_task["title"] = todoist_task.pop("description")
|
||||||
todoist_task["description"] = todoist_task.pop("details")
|
todoist_task["description"] = todoist_task.pop("details")
|
||||||
todoist_task["labels"] = todoist_task.pop("todoist_label_list", [])
|
labels = todoist_task.pop("todoist_label_list", [])
|
||||||
|
todoist_task["labels"] = [
|
||||||
|
l for l in labels if l.lower() != "inprogress"
|
||||||
|
]
|
||||||
todoist_task.pop("todoist_type")
|
todoist_task.pop("todoist_type")
|
||||||
todoist_task.pop("todoist_event")
|
todoist_task.pop("todoist_event")
|
||||||
|
|
||||||
|
|||||||
@ -4,8 +4,14 @@ from django.db.models.signals import post_delete, post_save
|
|||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from scrobbles.models import Scrobble
|
from scrobbles.models import FavoriteMedia, Scrobble
|
||||||
from scrobbles.tasks import CHARTABLE_MEDIA_TYPES, SCROBBLES_WITHOUT_CHARTS, update_charts_for_timestamp
|
from scrobbles.tasks import (
|
||||||
|
add_favorite_to_mopidy_playlist,
|
||||||
|
CHARTABLE_MEDIA_TYPES,
|
||||||
|
remove_favorite_from_mopidy_playlist,
|
||||||
|
SCROBBLES_WITHOUT_CHARTS,
|
||||||
|
update_charts_for_timestamp,
|
||||||
|
)
|
||||||
from scrobbles.utils import tokenize_title_to_tags
|
from scrobbles.utils import tokenize_title_to_tags
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -72,3 +78,28 @@ def add_tags_from_task_title(sender, instance, **kwargs):
|
|||||||
for tag in new_tags:
|
for tag in new_tags:
|
||||||
if tag not in existing_tags:
|
if tag not in existing_tags:
|
||||||
instance.tags.add(tag)
|
instance.tags.add(tag)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=FavoriteMedia)
|
||||||
|
def add_to_mopidy_playlist_on_favorite(sender, instance, created, **kwargs):
|
||||||
|
if not created:
|
||||||
|
return
|
||||||
|
if instance.media_type != Scrobble.MediaType.TRACK:
|
||||||
|
return
|
||||||
|
if not instance.track:
|
||||||
|
return
|
||||||
|
|
||||||
|
add_favorite_to_mopidy_playlist.delay(instance.id)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_delete, sender=FavoriteMedia)
|
||||||
|
def remove_from_mopidy_playlist_on_unfavorite(sender, instance, **kwargs):
|
||||||
|
if instance.media_type != Scrobble.MediaType.TRACK:
|
||||||
|
return
|
||||||
|
if not instance.track_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
remove_favorite_from_mopidy_playlist.delay(
|
||||||
|
user_id=instance.user_id,
|
||||||
|
track_id=instance.track_id,
|
||||||
|
)
|
||||||
|
|||||||
@ -542,3 +542,37 @@ def send_mood_checkin():
|
|||||||
from vrobbler.apps.scrobbles.utils import send_mood_checkin_reminders
|
from vrobbler.apps.scrobbles.utils import send_mood_checkin_reminders
|
||||||
|
|
||||||
send_mood_checkin_reminders()
|
send_mood_checkin_reminders()
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def add_favorite_to_mopidy_playlist(favorite_id):
|
||||||
|
from scrobbles.models import FavoriteMedia
|
||||||
|
from scrobbles.utils import add_track_to_mopidy_favorites_playlist
|
||||||
|
|
||||||
|
favorite = FavoriteMedia.objects.filter(id=favorite_id).first()
|
||||||
|
if not favorite:
|
||||||
|
return
|
||||||
|
add_track_to_mopidy_favorites_playlist(favorite)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def remove_favorite_from_mopidy_playlist(user_id, track_id):
|
||||||
|
from music.models import Track
|
||||||
|
from scrobbles.utils import remove_track_from_mopidy_favorites_playlist
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
try:
|
||||||
|
user = User.objects.get(id=user_id)
|
||||||
|
track = Track.objects.get(id=track_id)
|
||||||
|
except (User.DoesNotExist, Track.DoesNotExist):
|
||||||
|
return
|
||||||
|
|
||||||
|
import types
|
||||||
|
|
||||||
|
proxy = types.SimpleNamespace(
|
||||||
|
media_type="Track",
|
||||||
|
user=user,
|
||||||
|
user_id=user.id,
|
||||||
|
track=track,
|
||||||
|
)
|
||||||
|
remove_track_from_mopidy_favorites_playlist(proxy)
|
||||||
|
|||||||
@ -158,7 +158,17 @@ urlpatterns = [
|
|||||||
views.ScrobbleDetailView.as_view(),
|
views.ScrobbleDetailView.as_view(),
|
||||||
name="detail",
|
name="detail",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"scrobbles/<slug:uuid>/add-to-mopidy-queue/",
|
||||||
|
views.add_to_mopidy_queue,
|
||||||
|
name="add-to-mopidy-queue",
|
||||||
|
),
|
||||||
path("scrobbles/<slug:uuid>/start/", views.scrobble_start, name="start"),
|
path("scrobbles/<slug:uuid>/start/", views.scrobble_start, name="start"),
|
||||||
path("scrobbles/<slug:uuid>/finish/", views.scrobble_finish, name="finish"),
|
path("scrobbles/<slug:uuid>/finish/", views.scrobble_finish, name="finish"),
|
||||||
path("scrobbles/<slug:uuid>/cancel/", views.scrobble_cancel, name="cancel"),
|
path("scrobbles/<slug:uuid>/cancel/", views.scrobble_cancel, name="cancel"),
|
||||||
|
path(
|
||||||
|
"favorite/<str:media_type>/<int:object_id>/toggle/",
|
||||||
|
views.toggle_favorite,
|
||||||
|
name="toggle-favorite",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -87,8 +87,7 @@ def get_scrobbles_for_media(media_obj, user: User) -> models.QuerySet:
|
|||||||
return Scrobble.objects.filter(media_query, user=user)
|
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:
|
def get_long_plays_in_progress(user: User) -> dict:
|
||||||
@ -423,6 +422,209 @@ def get_daily_calorie_dict_for_user(user_id: int) -> dict[date, int]:
|
|||||||
return {entry["day"]: entry["total_calories"] for entry in qs}
|
return {entry["day"]: entry["total_calories"] for entry in qs}
|
||||||
|
|
||||||
|
|
||||||
|
def _mopidy_rpc(profile, method, params=None):
|
||||||
|
rpc_url = profile.mopidy_api_url.rstrip("/") + "/rpc"
|
||||||
|
payload = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"method": method,
|
||||||
|
}
|
||||||
|
if params:
|
||||||
|
payload["params"] = params
|
||||||
|
resp = requests.post(rpc_url, json=payload, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
result = resp.json()
|
||||||
|
if result.get("error"):
|
||||||
|
raise RuntimeError(f'Mopidy error: {result["error"]}')
|
||||||
|
return result.get("result")
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_mopidy_playlist(profile):
|
||||||
|
playlist_name = profile.favorites_mopidy_playlist
|
||||||
|
# Strip any m3u: prefix and .m3u8 suffix the user may have included
|
||||||
|
playlist_name = playlist_name.removeprefix("m3u:").removesuffix(".m3u8")
|
||||||
|
|
||||||
|
try:
|
||||||
|
playlists = _mopidy_rpc(profile, "core.playlists.as_list") or []
|
||||||
|
for pl in playlists:
|
||||||
|
if pl.get("name") == playlist_name:
|
||||||
|
existing = _mopidy_rpc(
|
||||||
|
profile, "core.playlists.lookup", {"uri": pl["uri"]}
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
except (requests.RequestException, RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
result = _mopidy_rpc(
|
||||||
|
profile, "core.playlists.create",
|
||||||
|
{"name": playlist_name, "uri_scheme": "m3u"},
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Created Mopidy favorites playlist",
|
||||||
|
extra={"uri": result.get("uri") if result else playlist_name},
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def add_track_to_mopidy_favorites_playlist(favorite):
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
|
if favorite.media_type != "Track" or not favorite.track:
|
||||||
|
return
|
||||||
|
|
||||||
|
profile = favorite.user.profile
|
||||||
|
if not profile.favorites_mopidy_playlist or not profile.mopidy_api_url:
|
||||||
|
return
|
||||||
|
|
||||||
|
track = favorite.track
|
||||||
|
scrobble = (
|
||||||
|
Scrobble.objects.filter(
|
||||||
|
track=track,
|
||||||
|
user=favorite.user,
|
||||||
|
log__raw_data__mopidy_uri__isnull=False,
|
||||||
|
)
|
||||||
|
.order_by("-timestamp")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not scrobble:
|
||||||
|
logger.warning(
|
||||||
|
"No Mopidy URI found for track",
|
||||||
|
extra={"track_id": track.id, "user_id": favorite.user_id},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
mopidy_uri = scrobble.log["raw_data"]["mopidy_uri"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
playlist = _ensure_mopidy_playlist(profile)
|
||||||
|
if playlist and playlist.get("uri"):
|
||||||
|
existing_tracks = playlist.get("tracks") or []
|
||||||
|
track_uris = [t["uri"] for t in existing_tracks if isinstance(t, dict)]
|
||||||
|
if mopidy_uri in track_uris:
|
||||||
|
logger.info(
|
||||||
|
"Track already in Mopidy favorites playlist",
|
||||||
|
extra={"track_id": track.id, "mopidy_uri": mopidy_uri},
|
||||||
|
)
|
||||||
|
favorite.sent_to_mopidy = True
|
||||||
|
favorite.save(update_fields=["sent_to_mopidy"])
|
||||||
|
return
|
||||||
|
|
||||||
|
new_track = {"__model__": "Track", "uri": mopidy_uri}
|
||||||
|
existing_tracks.append(new_track)
|
||||||
|
_mopidy_rpc(
|
||||||
|
profile,
|
||||||
|
"core.playlists.save",
|
||||||
|
{
|
||||||
|
"playlist": {
|
||||||
|
"__model__": "Playlist",
|
||||||
|
"uri": playlist["uri"],
|
||||||
|
"name": playlist.get("name", "Favorites"),
|
||||||
|
"tracks": existing_tracks,
|
||||||
|
"last_modified": playlist.get("last_modified"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_mopidy_rpc(profile, "core.tracklist.add", {"uris": [mopidy_uri]})
|
||||||
|
|
||||||
|
favorite.sent_to_mopidy = True
|
||||||
|
favorite.save(update_fields=["sent_to_mopidy"])
|
||||||
|
logger.info(
|
||||||
|
"Added track to Mopidy favorites playlist",
|
||||||
|
extra={"track_id": track.id, "user_id": favorite.user_id},
|
||||||
|
)
|
||||||
|
except (requests.RequestException, RuntimeError) as e:
|
||||||
|
logger.debug(e)
|
||||||
|
logger.error(
|
||||||
|
"Failed to add track to Mopidy favorites playlist",
|
||||||
|
extra={"track_id": track.id, "user_id": favorite.user_id, "error": str(e)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resubmit_favorites_to_mopidy(user):
|
||||||
|
from scrobbles.models import FavoriteMedia
|
||||||
|
|
||||||
|
favorites = FavoriteMedia.objects.filter(
|
||||||
|
user=user,
|
||||||
|
media_type="Track",
|
||||||
|
track__isnull=False,
|
||||||
|
)
|
||||||
|
for favorite in favorites:
|
||||||
|
add_track_to_mopidy_favorites_playlist(favorite)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_track_from_mopidy_favorites_playlist(favorite):
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
|
if favorite.media_type != "Track" or not favorite.track:
|
||||||
|
return
|
||||||
|
|
||||||
|
profile = favorite.user.profile
|
||||||
|
if not profile.favorites_mopidy_playlist or not profile.mopidy_api_url:
|
||||||
|
return
|
||||||
|
|
||||||
|
track = favorite.track
|
||||||
|
scrobble = (
|
||||||
|
Scrobble.objects.filter(
|
||||||
|
track=track,
|
||||||
|
user=favorite.user,
|
||||||
|
log__raw_data__mopidy_uri__isnull=False,
|
||||||
|
)
|
||||||
|
.order_by("-timestamp")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not scrobble:
|
||||||
|
logger.warning(
|
||||||
|
"No Mopidy URI found for track",
|
||||||
|
extra={"track_id": track.id, "user_id": favorite.user_id},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
mopidy_uri = scrobble.log["raw_data"]["mopidy_uri"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
playlist = _ensure_mopidy_playlist(profile)
|
||||||
|
if playlist and playlist.get("uri"):
|
||||||
|
existing_tracks = playlist.get("tracks") or []
|
||||||
|
filtered = [
|
||||||
|
t for t in existing_tracks
|
||||||
|
if not (isinstance(t, dict) and t.get("uri") == mopidy_uri)
|
||||||
|
]
|
||||||
|
if len(filtered) == len(existing_tracks):
|
||||||
|
logger.info(
|
||||||
|
"Track not found in Mopidy favorites playlist",
|
||||||
|
extra={"track_id": track.id, "mopidy_uri": mopidy_uri},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
_mopidy_rpc(
|
||||||
|
profile,
|
||||||
|
"core.playlists.save",
|
||||||
|
{
|
||||||
|
"playlist": {
|
||||||
|
"__model__": "Playlist",
|
||||||
|
"uri": playlist["uri"],
|
||||||
|
"name": playlist.get("name", "Favorites"),
|
||||||
|
"tracks": filtered,
|
||||||
|
"last_modified": playlist.get("last_modified"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Removed track from Mopidy favorites playlist",
|
||||||
|
extra={"track_id": track.id, "user_id": favorite.user_id},
|
||||||
|
)
|
||||||
|
except (requests.RequestException, RuntimeError) as e:
|
||||||
|
logger.debug(e)
|
||||||
|
logger.error(
|
||||||
|
"Failed to remove track from Mopidy favorites playlist",
|
||||||
|
extra={"track_id": track.id, "user_id": favorite.user_id, "error": str(e)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def remove_last_part(url: str) -> str:
|
def remove_last_part(url: str) -> str:
|
||||||
url = url.rstrip("/")
|
url = url.rstrip("/")
|
||||||
if "/" not in url:
|
if "/" not in url:
|
||||||
@ -517,8 +719,6 @@ def tokenize_title_to_tags(title: str) -> list[str]:
|
|||||||
cleaned = re.sub(r"[^\w\s]", "", cleaned)
|
cleaned = re.sub(r"[^\w\s]", "", cleaned)
|
||||||
|
|
||||||
words = [
|
words = [
|
||||||
w.lower()
|
w.lower() for w in cleaned.split() if w.lower() not in STOPWORDS and len(w) > 2
|
||||||
for w in cleaned.split()
|
|
||||||
if w.lower() not in STOPWORDS and len(w) > 2
|
|
||||||
]
|
]
|
||||||
return words
|
return words
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import logging
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
import pendulum
|
import pendulum
|
||||||
import pytz
|
import pytz
|
||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
@ -12,7 +14,7 @@ from django.contrib import messages
|
|||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
|
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
|
||||||
from django.db.models import Count, Max, Q
|
from django.db.models import Count, Max, Q, Sum
|
||||||
from django.db.models.query import QuerySet
|
from django.db.models.query import QuerySet
|
||||||
from rest_framework.authentication import TokenAuthentication
|
from rest_framework.authentication import TokenAuthentication
|
||||||
from rest_framework.authtoken.models import Token
|
from rest_framework.authtoken.models import Token
|
||||||
@ -30,10 +32,11 @@ from django.http import (
|
|||||||
HttpResponseRedirect,
|
HttpResponseRedirect,
|
||||||
JsonResponse,
|
JsonResponse,
|
||||||
)
|
)
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
from django.views.decorators.http import require_POST
|
||||||
from django.views.generic import DetailView, FormView, TemplateView
|
from django.views.generic import DetailView, FormView, TemplateView
|
||||||
from django.views.generic.edit import CreateView
|
from django.views.generic.edit import CreateView
|
||||||
from django.views.generic.list import ListView
|
from django.views.generic.list import ListView
|
||||||
@ -72,6 +75,7 @@ from scrobbles.models import (
|
|||||||
AudioScrobblerTSVImport,
|
AudioScrobblerTSVImport,
|
||||||
BGStatsImport,
|
BGStatsImport,
|
||||||
EBirdCSVImport,
|
EBirdCSVImport,
|
||||||
|
FavoriteMedia,
|
||||||
KoReaderImport,
|
KoReaderImport,
|
||||||
LastFmImport,
|
LastFmImport,
|
||||||
RetroarchImport,
|
RetroarchImport,
|
||||||
@ -364,6 +368,7 @@ class ScrobbleListView(LoginRequiredMixin, ListView):
|
|||||||
else:
|
else:
|
||||||
tag_list = []
|
tag_list = []
|
||||||
self.tag_list = tag_list
|
self.tag_list = tag_list
|
||||||
|
self._full_queryset = qs
|
||||||
return qs
|
return qs
|
||||||
|
|
||||||
def _compute_overlap_groups(self, scrobbles):
|
def _compute_overlap_groups(self, scrobbles):
|
||||||
@ -430,6 +435,13 @@ class ScrobbleListView(LoginRequiredMixin, ListView):
|
|||||||
ctx["tag_list"] = getattr(self, "tag_list", [])
|
ctx["tag_list"] = getattr(self, "tag_list", [])
|
||||||
scrobbles = list(ctx.get("object_list", []))
|
scrobbles = list(ctx.get("object_list", []))
|
||||||
ctx["overlap_map"] = self._compute_overlap_groups(scrobbles)
|
ctx["overlap_map"] = self._compute_overlap_groups(scrobbles)
|
||||||
|
full_qs = getattr(self, "_full_queryset", None)
|
||||||
|
if full_qs is not None and getattr(self, "tag_list", []):
|
||||||
|
total = (
|
||||||
|
full_qs.aggregate(total=Sum("playback_position_seconds"))["total"]
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
ctx["total_time_seconds"] = total
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
@ -963,6 +975,117 @@ def scrobble_cancel(request, uuid):
|
|||||||
return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
|
return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
def add_to_mopidy_queue(request, uuid):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return redirect("scrobbles:detail", uuid=uuid)
|
||||||
|
|
||||||
|
scrobble = get_object_or_404(Scrobble, uuid=uuid, user=request.user)
|
||||||
|
mopidy_url = request.user.profile.mopidy_api_url
|
||||||
|
|
||||||
|
if not mopidy_url:
|
||||||
|
messages.add_message(
|
||||||
|
request,
|
||||||
|
messages.ERROR,
|
||||||
|
"Mopidy API URL not configured in your profile settings.",
|
||||||
|
)
|
||||||
|
return redirect("scrobbles:detail", uuid=uuid)
|
||||||
|
|
||||||
|
raw_data = scrobble.log.get("raw_data", {})
|
||||||
|
mopidy_uri = raw_data.get("mopidy_uri")
|
||||||
|
logger.debug(mopidy_uri)
|
||||||
|
|
||||||
|
if not mopidy_uri:
|
||||||
|
messages.add_message(
|
||||||
|
request,
|
||||||
|
messages.ERROR,
|
||||||
|
"No Mopidy URI found for this scrobble.",
|
||||||
|
)
|
||||||
|
return redirect("scrobbles:detail", uuid=uuid)
|
||||||
|
|
||||||
|
rpc_url = mopidy_url.rstrip("/") + "/rpc"
|
||||||
|
payload = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"method": "core.tracklist.add",
|
||||||
|
"params": {"uris": [mopidy_uri]},
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.post(rpc_url, json=payload, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
rpc_result = resp.json()
|
||||||
|
if rpc_result.get("error"):
|
||||||
|
messages.add_message(
|
||||||
|
request,
|
||||||
|
messages.ERROR,
|
||||||
|
f'Mopidy error: {rpc_result["error"]}',
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
msg = f'Added "{scrobble.media_obj}" to Mopidy queue.'
|
||||||
|
messages.add_message(request, messages.SUCCESS, msg)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
messages.add_message(
|
||||||
|
request,
|
||||||
|
messages.ERROR,
|
||||||
|
f"Failed to contact Mopidy: {e}",
|
||||||
|
)
|
||||||
|
|
||||||
|
return redirect("scrobbles:detail", uuid=uuid)
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
def toggle_favorite(request, media_type, object_id):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/"))
|
||||||
|
|
||||||
|
app_model_map = {
|
||||||
|
"Video": ("videos", "Video"),
|
||||||
|
"Channel": ("videos", "Channel"),
|
||||||
|
"Track": ("music", "Track"),
|
||||||
|
"PodcastEpisode": ("podcasts", "PodcastEpisode"),
|
||||||
|
"SportEvent": ("sports", "SportEvent"),
|
||||||
|
"Book": ("books", "Book"),
|
||||||
|
"Paper": ("books", "Paper"),
|
||||||
|
"VideoGame": ("videogames", "VideoGame"),
|
||||||
|
"BoardGame": ("boardgames", "BoardGame"),
|
||||||
|
"GeoLocation": ("locations", "GeoLocation"),
|
||||||
|
"Beer": ("beers", "Beer"),
|
||||||
|
"Puzzle": ("puzzles", "Puzzle"),
|
||||||
|
"Food": ("foods", "Food"),
|
||||||
|
"Trail": ("trails", "Trail"),
|
||||||
|
"Task": ("tasks", "Task"),
|
||||||
|
"WebPage": ("webpages", "WebPage"),
|
||||||
|
"LifeEvent": ("lifeevents", "LifeEvent"),
|
||||||
|
"Mood": ("moods", "Mood"),
|
||||||
|
"BrickSet": ("bricksets", "BrickSet"),
|
||||||
|
"BirdingLocation": ("birds", "BirdingLocation"),
|
||||||
|
}
|
||||||
|
|
||||||
|
app_label, model_name = app_model_map.get(media_type, (None, None))
|
||||||
|
if not app_label:
|
||||||
|
messages.add_message(
|
||||||
|
request, messages.ERROR, f"Unknown media type: {media_type}"
|
||||||
|
)
|
||||||
|
return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/"))
|
||||||
|
|
||||||
|
model = apps.get_model(app_label, model_name)
|
||||||
|
media_obj = get_object_or_404(model, id=object_id)
|
||||||
|
result = FavoriteMedia.toggle(media_obj, request.user)
|
||||||
|
|
||||||
|
is_favorited = result is not None
|
||||||
|
if not is_favorited:
|
||||||
|
msg = f'Removed "{media_obj}" from favorites.'
|
||||||
|
else:
|
||||||
|
msg = f'Added "{media_obj}" to favorites.'
|
||||||
|
|
||||||
|
if request.headers.get("x-requested-with") == "XMLHttpRequest":
|
||||||
|
return JsonResponse({"is_favorited": is_favorited, "message": msg})
|
||||||
|
|
||||||
|
messages.add_message(request, messages.SUCCESS, msg)
|
||||||
|
return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/"))
|
||||||
|
|
||||||
|
|
||||||
@api_view(["GET"])
|
@api_view(["GET"])
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
def export(request):
|
def export(request):
|
||||||
@ -1113,6 +1236,13 @@ class ScrobbleDetailView(DetailView):
|
|||||||
except EmptyPage:
|
except EmptyPage:
|
||||||
context["related_scrobbles"] = paginator.page(paginator.num_pages)
|
context["related_scrobbles"] = paginator.page(paginator.num_pages)
|
||||||
|
|
||||||
|
if self.request.user.is_authenticated:
|
||||||
|
fk_field = self.MEDIA_FK_MAP.get(media_type)
|
||||||
|
if fk_field and media_obj:
|
||||||
|
context["is_favorited"] = FavoriteMedia.objects.filter(
|
||||||
|
user=self.request.user, **{fk_field: media_obj}
|
||||||
|
).exists()
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -76,33 +76,62 @@ class TaskLogData(BaseLogData):
|
|||||||
return separator.join(lines).encode("utf-8").decode("unicode_escape")
|
return separator.join(lines).encode("utf-8").decode("unicode_escape")
|
||||||
|
|
||||||
def notes_as_html(self) -> str:
|
def notes_as_html(self) -> str:
|
||||||
|
import bleach
|
||||||
|
import markdown
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
from scrobbles.dataclasses import BaseLogData
|
||||||
|
|
||||||
if not self.notes:
|
if not self.notes:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
md = markdown.Markdown(extensions=["extra"])
|
||||||
|
allowed_tags = [
|
||||||
|
"p", "br", "strong", "em", "a", "ul", "ol", "li",
|
||||||
|
"code", "pre", "blockquote", "h1", "h2", "h3", "h4", "h5", "h6",
|
||||||
|
"hr", "img",
|
||||||
|
]
|
||||||
|
|
||||||
notes = self.notes
|
notes = self.notes
|
||||||
if isinstance(notes, dict):
|
if isinstance(notes, dict):
|
||||||
notes = [{k: v} for k, v in notes.items()]
|
notes = [{k: v} for k, v in notes.items()]
|
||||||
if isinstance(notes, str):
|
if isinstance(notes, str):
|
||||||
notes = [notes]
|
notes = [notes]
|
||||||
|
|
||||||
html_notes = []
|
note_items = []
|
||||||
for note in notes:
|
for note in notes:
|
||||||
if isinstance(note, dict):
|
if isinstance(note, dict):
|
||||||
timestamp, note_text = next(iter(note.items()))
|
timestamp, note_text = next(iter(note.items()))
|
||||||
note_text = " ".join(note_text.strip().split())
|
note_items.append((timestamp, note_text.strip()))
|
||||||
html_notes.append(
|
|
||||||
f'<div class="sticky-note">{timestamp}: {note_text}</div>'
|
|
||||||
)
|
|
||||||
elif isinstance(note, str):
|
elif isinstance(note, str):
|
||||||
escaped = note.encode("utf-8").decode("unicode_escape")
|
escaped = note.encode("utf-8").decode("unicode_escape")
|
||||||
for line in escaped.split("\n"):
|
for line in escaped.split("\n"):
|
||||||
if line.strip():
|
if line.strip():
|
||||||
html_notes.append(f'<div class="sticky-note">{line}</div>')
|
note_items.append((None, line.strip()))
|
||||||
elif isinstance(note, list):
|
elif isinstance(note, list):
|
||||||
for item in note:
|
for item in note:
|
||||||
if isinstance(item, str):
|
if isinstance(item, str):
|
||||||
html_notes.append(f'<div class="sticky-note">{item}</div>')
|
note_items.append((None, item.strip()))
|
||||||
return "".join(html_notes)
|
|
||||||
|
html_parts = []
|
||||||
|
for i, (ts, text) in enumerate(note_items):
|
||||||
|
if i > 0:
|
||||||
|
html_parts.append('<hr class="note-divider">')
|
||||||
|
|
||||||
|
ts_html = ""
|
||||||
|
if ts:
|
||||||
|
ts_html = f'<h5 class="note-timestamp">{BaseLogData._format_timestamp(ts)}</h5>'
|
||||||
|
|
||||||
|
content_html = bleach.clean(
|
||||||
|
md.convert(text),
|
||||||
|
tags=allowed_tags,
|
||||||
|
strip=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
html_parts.append(
|
||||||
|
f'<div class="note-item">{ts_html}<div class="note-content">{content_html}</div></div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
return mark_safe("".join(html_parts))
|
||||||
|
|
||||||
|
|
||||||
class Task(LongPlayScrobblableMixin):
|
class Task(LongPlayScrobblableMixin):
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
import pendulum
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.http import HttpResponseRedirect
|
from django.http import HttpResponseRedirect
|
||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy
|
||||||
@ -8,6 +9,7 @@ from rest_framework import status
|
|||||||
from rest_framework.decorators import api_view, permission_classes
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
from scrobbles.views import ScrobbleableDetailView, ScrobbleableListView
|
from scrobbles.views import ScrobbleableDetailView, ScrobbleableListView
|
||||||
from tasks.models import Task
|
from tasks.models import Task
|
||||||
|
|
||||||
@ -23,6 +25,72 @@ class TaskListView(ScrobbleableListView):
|
|||||||
class TaskDetailView(ScrobbleableDetailView):
|
class TaskDetailView(ScrobbleableDetailView):
|
||||||
model = Task
|
model = Task
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
ctx = super().get_context_data(**kwargs)
|
||||||
|
if self.object.title != "Weigh-in":
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
scrobbles = list(
|
||||||
|
Scrobble.objects.filter(
|
||||||
|
user=self.request.user,
|
||||||
|
task=self.object,
|
||||||
|
log__weight__isnull=False,
|
||||||
|
).order_by("timestamp")
|
||||||
|
)
|
||||||
|
if not scrobbles:
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
labels = []
|
||||||
|
weight_data = []
|
||||||
|
body_fat_data = []
|
||||||
|
bmi_data = []
|
||||||
|
for s in scrobbles:
|
||||||
|
ts = s.timestamp
|
||||||
|
if isinstance(ts, str):
|
||||||
|
ts = pendulum.parse(ts)
|
||||||
|
labels.append(ts.strftime("%Y-%m-%d"))
|
||||||
|
log = s.log if isinstance(s.log, dict) else {}
|
||||||
|
raw_weight = log.get("weight")
|
||||||
|
weight_data.append(
|
||||||
|
float(raw_weight) if raw_weight is not None else None
|
||||||
|
)
|
||||||
|
raw_bf = log.get("body_fat")
|
||||||
|
body_fat_data.append(
|
||||||
|
float(raw_bf) if raw_bf is not None else None
|
||||||
|
)
|
||||||
|
raw_bmi = log.get("bmi")
|
||||||
|
bmi_data.append(
|
||||||
|
float(raw_bmi) if raw_bmi is not None else None
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx["weighin_chart"] = {
|
||||||
|
"labels": labels,
|
||||||
|
"datasets": [
|
||||||
|
{
|
||||||
|
"label": "Weight",
|
||||||
|
"data": weight_data,
|
||||||
|
"borderColor": "#4bc0c0",
|
||||||
|
"fill": False,
|
||||||
|
"yAxisID": "y",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Body Fat %",
|
||||||
|
"data": body_fat_data,
|
||||||
|
"borderColor": "#ff6384",
|
||||||
|
"fill": False,
|
||||||
|
"yAxisID": "y1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "BMI",
|
||||||
|
"data": bmi_data,
|
||||||
|
"borderColor": "#36a2eb",
|
||||||
|
"fill": False,
|
||||||
|
"yAxisID": "y2",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
@api_view(["GET"])
|
@api_view(["GET"])
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
|
|||||||
@ -232,7 +232,7 @@ class EmacsWebhookView(APIView):
|
|||||||
status=status.HTTP_304_NOT_MODIFIED,
|
status=status.HTTP_304_NOT_MODIFIED,
|
||||||
)
|
)
|
||||||
|
|
||||||
if task_in_progress:
|
if scrobble and scrobble.in_progress:
|
||||||
emacs_scrobble_update_task(
|
emacs_scrobble_update_task(
|
||||||
post_data.get("source_id"),
|
post_data.get("source_id"),
|
||||||
post_data.get("notes") or [],
|
post_data.get("notes") or [],
|
||||||
|
|||||||
@ -8,21 +8,20 @@ def natural_duration(value):
|
|||||||
if not value:
|
if not value:
|
||||||
return
|
return
|
||||||
value = int(value)
|
value = int(value)
|
||||||
total_minutes = int(value / 60)
|
days = int(value / 86400)
|
||||||
hours = int(total_minutes / 60)
|
remainder = value % 86400
|
||||||
minutes = total_minutes - (hours * 60)
|
hours = int(remainder / 3600)
|
||||||
seconds = value % 60
|
minutes = int((remainder % 3600) / 60)
|
||||||
value_str = ""
|
seconds = remainder % 60
|
||||||
if seconds:
|
parts = []
|
||||||
value_str = f"{seconds} seconds"
|
if days:
|
||||||
if minutes:
|
parts.append(f"{days} day{'s' if days != 1 else ''}")
|
||||||
if value_str:
|
|
||||||
value_str = f"{minutes} minutes, " + value_str
|
|
||||||
else:
|
|
||||||
value_str = f"{minutes} minutes"
|
|
||||||
if hours:
|
if hours:
|
||||||
if value_str:
|
parts.append(f"{hours} hour{'s' if hours != 1 else ''}")
|
||||||
value_str = f"{hours} hours, " + value_str
|
if minutes:
|
||||||
else:
|
parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}")
|
||||||
value_str = f"{hours} hours"
|
if seconds or not parts:
|
||||||
return value_str
|
parts.append(f"{seconds} second{'s' if seconds != 1 else ''}")
|
||||||
|
if len(parts) == 1:
|
||||||
|
return parts[0]
|
||||||
|
return ", ".join(parts[:-1]) + " and " + parts[-1]
|
||||||
|
|||||||
@ -168,6 +168,35 @@
|
|||||||
}
|
}
|
||||||
#scrobble-form { width: 100% }
|
#scrobble-form { width: 100% }
|
||||||
|
|
||||||
|
.notes-list {
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-item {
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-timestamp {
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: #666;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-content {
|
||||||
|
font-size: 0.95em;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-content p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-divider {
|
||||||
|
margin: 8px 0;
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
.sticky-notes-container {
|
.sticky-notes-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@ -218,6 +247,9 @@
|
|||||||
a:not(.nav-link):not(.btn):not(.page-link):hover {
|
a:not(.nav-link):not(.btn):not(.page-link):hover {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
a.badge {
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
.table-striped > tbody > tr:nth-of-type(odd) {
|
.table-striped > tbody > tr:nth-of-type(odd) {
|
||||||
background-color: color-mix(in srgb, var(--accent) 6%, #fff);
|
background-color: color-mix(in srgb, var(--accent) 6%, #fff);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,9 @@
|
|||||||
<h1 class="h2">All Scrobbles</h1>
|
<h1 class="h2">All Scrobbles</h1>
|
||||||
{% if tag_list %}
|
{% if tag_list %}
|
||||||
<h6 class="text-muted">Tagged {{ tag_list|join:", " }}</h6>
|
<h6 class="text-muted">Tagged {{ tag_list|join:", " }}</h6>
|
||||||
|
{% if total_time_seconds %}
|
||||||
|
<p class="text-muted small mb-0">Total time: {{ total_time_seconds|natural_duration }}</p>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -26,6 +26,21 @@
|
|||||||
height: 400px;
|
height: 400px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
.heart-icon {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: fill 0.2s, stroke 0.2s;
|
||||||
|
}
|
||||||
|
.heart-icon.favorited {
|
||||||
|
fill: #e74c3c;
|
||||||
|
stroke: #e74c3c;
|
||||||
|
}
|
||||||
|
.heart-icon:not(.favorited) {
|
||||||
|
fill: none;
|
||||||
|
stroke: #888;
|
||||||
|
}
|
||||||
|
.heart-icon:not(.favorited):hover {
|
||||||
|
stroke: #e74c3c;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@ -33,13 +48,30 @@
|
|||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
|
||||||
<h1>
|
<h1 class="d-flex align-items-center gap-2">
|
||||||
{% if object.media_type == "Video" %}🎬{% elif object.media_type == "Track" %}🎵{% elif object.media_type == "PodcastEpisode" %}🎙️{% elif object.media_type == "SportEvent" %}⚽{% elif object.media_type == "Book" %}📚{% elif object.media_type == "Paper" %}📄{% elif object.media_type == "VideoGame" %}🎮{% elif object.media_type == "BoardGame" %}🎲{% elif object.media_type == "GeoLocation" %}📍{% elif object.media_type == "Trail" %}🥾{% elif object.media_type == "Beer" %}🍺{% elif object.media_type == "Puzzle" %}🧩{% elif object.media_type == "Food" %}🍔{% elif object.media_type == "Task" %}✅{% elif object.media_type == "WebPage" %}🌐{% elif object.media_type == "LifeEvent" %}🎉{% elif object.media_type == "Mood" %}😊{% elif object.media_type == "BrickSet" %}🧱{% elif object.media_type == "Channel" %}📺{% endif %}
|
{% if object.media_type == "Video" %}🎬{% elif object.media_type == "Track" %}🎵{% elif object.media_type == "PodcastEpisode" %}🎙️{% elif object.media_type == "SportEvent" %}⚽{% elif object.media_type == "Book" %}📚{% elif object.media_type == "Paper" %}📄{% elif object.media_type == "VideoGame" %}🎮{% elif object.media_type == "BoardGame" %}🎲{% elif object.media_type == "GeoLocation" %}📍{% elif object.media_type == "Trail" %}🥾{% elif object.media_type == "Beer" %}🍺{% elif object.media_type == "Puzzle" %}🧩{% elif object.media_type == "Food" %}🍔{% elif object.media_type == "Task" %}✅{% elif object.media_type == "WebPage" %}🌐{% elif object.media_type == "LifeEvent" %}🎉{% elif object.media_type == "Mood" %}😊{% elif object.media_type == "BrickSet" %}🧱{% elif object.media_type == "Channel" %}📺{% endif %}
|
||||||
{% if object.media_obj.get_absolute_url %}<a href="{{ object.media_obj.get_absolute_url }}">{% endif %}{{ object.media_obj }}{% if object.media_obj.get_absolute_url %}</a>{% endif %}</h1>
|
{% if object.media_obj.get_absolute_url %}<a href="{{ object.media_obj.get_absolute_url }}">{% endif %}{{ object.media_obj }}{% if object.media_obj.get_absolute_url %}</a>{% endif %}
|
||||||
|
{% if user.is_authenticated and object.media_obj %}
|
||||||
|
<button id="favorite-btn"
|
||||||
|
data-url="{% url 'scrobbles:toggle-favorite' object.media_type object.media_obj.id %}"
|
||||||
|
data-favorited="{{ is_favorited|yesno:'true,false' }}"
|
||||||
|
class="btn btn-sm p-0 border-0 bg-transparent">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" class="heart-icon{% if is_favorited %} favorited{% endif %}" id="heart-svg">
|
||||||
|
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</h1>
|
||||||
{% if object.media_type == "Task" and object.logdata.title %}
|
{% if object.media_type == "Task" and object.logdata.title %}
|
||||||
<h2>{{ object.logdata.title }}</h2>
|
<h2>{{ object.logdata.title }}</h2>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<h3 class="text-muted">{{ object.local_timestamp }}</h3>
|
<h3 class="text-muted">{{ object.local_timestamp }}</h3>
|
||||||
|
{% if object.media_type == "Track" and object.log.raw_data.mopidy_uri and user.profile.mopidy_api_url %}
|
||||||
|
<form method="post" action="{% url 'scrobbles:add-to-mopidy-queue' object.uuid %}" class="mb-1">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-secondary">add to mopidy queue</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
{% if object.media_type == "Track" %}
|
{% if object.media_type == "Track" %}
|
||||||
<p class="text-muted small">Source: {{ object.source }}{% if object.log.mopidy_source %} ({{ object.log.mopidy_source|capfirst }}){% endif %}</p>
|
<p class="text-muted small">Source: {{ object.source }}{% if object.log.mopidy_source %} ({{ object.log.mopidy_source|capfirst }}){% endif %}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -115,8 +147,8 @@
|
|||||||
{% with notes_html=object.logdata.notes_as_html %}
|
{% with notes_html=object.logdata.notes_as_html %}
|
||||||
{% if notes_html %}
|
{% if notes_html %}
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<h5>Notes</h5>
|
<h4>Notes</h4>
|
||||||
<div class="sticky-notes-container">
|
<div class="notes-list">
|
||||||
{{ notes_html|safe }}
|
{{ notes_html|safe }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -220,6 +252,30 @@
|
|||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
{{ log_form.media }}
|
{{ log_form.media }}
|
||||||
|
<script>
|
||||||
|
function getCookie(name) {
|
||||||
|
let value = "; " + document.cookie;
|
||||||
|
let parts = value.split("; " + name + "=");
|
||||||
|
if (parts.length === 2) return parts.pop().split(";").shift();
|
||||||
|
}
|
||||||
|
document.getElementById("favorite-btn")?.addEventListener("click", function() {
|
||||||
|
var btn = this;
|
||||||
|
var url = btn.dataset.url;
|
||||||
|
fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"X-CSRFToken": getCookie("csrftoken"),
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
var heart = document.getElementById("heart-svg");
|
||||||
|
btn.dataset.favorited = data.is_favorited ? "true" : "false";
|
||||||
|
heart.classList.toggle("favorited", data.is_favorited);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
{% if object.media_type == "Trail" and object.gpx_file %}
|
{% if object.media_type == "Trail" and object.gpx_file %}
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/leaflet-gpx@2.2.0/gpx.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/leaflet-gpx@2.2.0/gpx.min.js"></script>
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
{% load mathfilters %}
|
{% load mathfilters %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
{% load naturalduration %}
|
{% load naturalduration %}
|
||||||
|
{% load l10n %}
|
||||||
|
|
||||||
{% block title %}{{object.title}}{% endblock %}
|
{% block title %}{{object.title}}{% endblock %}
|
||||||
|
|
||||||
@ -39,6 +40,15 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if weighin_chart %}
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<canvas id="weighinChart" width="800" height="300"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<p>{{scrobbles.count}} scrobbles</p>
|
<p>{{scrobbles.count}} scrobbles</p>
|
||||||
<p>
|
<p>
|
||||||
@ -94,3 +104,61 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
{% if weighin_chart %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@2.9.4/dist/Chart.min.js"></script>
|
||||||
|
<script>
|
||||||
|
var ctx = document.getElementById('weighinChart').getContext('2d');
|
||||||
|
new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: {{ weighin_chart.labels|safe }},
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Weight',
|
||||||
|
data: {{ weighin_chart.datasets.0.data|safe }},
|
||||||
|
borderColor: '{{ weighin_chart.datasets.0.borderColor }}',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
pointRadius: 2,
|
||||||
|
fill: false,
|
||||||
|
yAxisID: 'y',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Body Fat %',
|
||||||
|
data: {{ weighin_chart.datasets.1.data|safe }},
|
||||||
|
borderColor: '{{ weighin_chart.datasets.1.borderColor }}',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
pointRadius: 2,
|
||||||
|
fill: false,
|
||||||
|
yAxisID: 'y1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'BMI',
|
||||||
|
data: {{ weighin_chart.datasets.2.data|safe }},
|
||||||
|
borderColor: '{{ weighin_chart.datasets.2.borderColor }}',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
pointRadius: 2,
|
||||||
|
fill: false,
|
||||||
|
yAxisID: 'y2',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
scales: {
|
||||||
|
xAxes: [{
|
||||||
|
ticks: { maxTicksLimit: 25, maxRotation: 45 },
|
||||||
|
}],
|
||||||
|
yAxes: [
|
||||||
|
{ id: 'y', type: 'linear', position: 'left', scaleLabel: { display: true, labelString: 'Weight' } },
|
||||||
|
{ id: 'y1', type: 'linear', position: 'right', scaleLabel: { display: true, labelString: 'Body Fat %' }, gridLines: { display: false } },
|
||||||
|
{ id: 'y2', type: 'linear', position: 'right', scaleLabel: { display: true, labelString: 'BMI' }, gridLines: { display: false } },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
legend: { position: 'bottom' },
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user