Compare commits

...

37 Commits
18.5 ... 23

Author SHA1 Message Date
1a1de02843 [release] 23 2025-08-17 12:41:10 -04:00
a1868e7b2c [project] Updating TODOs 2025-08-17 12:38:27 -04:00
52494651bf [scrobbles] Add dynamic forms for LogData classes 2025-08-17 12:38:11 -04:00
1093aa2376 [music] Fix getting album when duplicated name 2025-08-06 12:40:10 -04:00
d1f04c15a9 [music] Fix breaking on w. 2025-08-06 11:03:40 -04:00
fd3487c225 [tasks] A few little clean ups 2025-08-06 10:59:32 -04:00
df91526b0c [videogames] Fix showing platform in logdata 2025-08-05 10:18:10 -04:00
70f103db6f [boardgames] Remove print statements 2025-08-05 02:04:17 -04:00
b0b32821e3 [scrobbles] Clean up todoist logs too 2025-08-05 02:04:07 -04:00
278cab32ea [scrobbles] Start cleaning up logdata 2025-08-05 02:01:31 -04:00
06e075553a [scrobbles] CLean up some dataclasses 2025-08-05 01:56:20 -04:00
833368c8d7 [profiles] Clean up default task lookup 2025-08-05 01:56:03 -04:00
f70bab30d0 [scrobbles] Log errors when parsing fails 2025-08-05 00:13:44 -04:00
f230af89eb [videogames] Add scrobbles to views 2025-08-05 00:13:08 -04:00
bbc27209ab [templates] Clean up long play nonsense for video games 2025-08-05 00:12:47 -04:00
b7638c648a [templates] Fix video game detail page 2025-08-04 19:57:06 -04:00
c8926cf887 [scrobbles] Fix bug in mixin import 2025-08-03 11:33:44 -04:00
b8dd3ee258 [tests] Shim to fix broken import 2025-08-03 11:13:23 -04:00
dc965687c2 [puzzles] Add puzzles to homepage 2025-08-03 02:01:29 -04:00
ebc66bbf64 [puzzles] Add templates 2025-08-03 02:00:26 -04:00
d04db0ecb5 [scrobbles] Fix dataclass parsing and add puzzles to urls 2025-08-03 01:59:56 -04:00
fc72b23b11 [music] Fix timezones for TSV imports 2025-08-02 23:35:22 -04:00
a681b4d63b [notifications] Fix a few typos 2025-07-30 18:34:22 -04:00
c452ac24e0 [notifications] Send mood check-in 2025-07-30 18:30:18 -04:00
ae889bff7d [tasks] Fix bug in note str method 2025-07-30 17:50:59 -04:00
99dc86dc27 [moods] Fix mood list view 2025-07-30 16:05:48 -04:00
8eefcb8290 [tasks] Fix emacs metadata 2025-07-30 16:05:34 -04:00
ad0f9a54d0 [tasks] Fix dataclass models 2025-07-30 15:46:18 -04:00
1531b77b5c [tests] Fix metadata test 2025-07-30 13:59:11 -04:00
9437fdba60 [scrobbles] Fix log data parsing for tasks and boardgames
Add pagination to task and board game detail pages
2025-07-30 11:37:57 -04:00
a7551ef162 [music] Weird hack to get timezone for LFM scrobbles
Last.fm seems to send timestamps for scrobbles with a timezone of UTC
but the actual timezone is already localized. But that means we can't
extract the timezone we want, even though the timestamp is already in
the right timezone for storage.
2025-07-28 10:52:02 -04:00
c20204a6ea [music] Turns out lastfm already has our timeszone 2025-07-28 09:14:25 -04:00
685de842ea [views] Fix showing only a users scrobbles 2025-07-26 21:31:44 -04:00
7d13967708 [scrobbles] Fix admin filtering 2025-07-26 20:57:23 -04:00
109697a746 [project] Bump version 2025-07-26 10:19:34 -04:00
dde28f4aff [importers] Fix setting timezones before all imports 2025-07-26 10:18:43 -04:00
2f6ed3770f [books] Fix bad import after moving webdav to importers 2025-07-26 01:49:37 -04:00
60 changed files with 1226 additions and 391 deletions

View File

@ -79,7 +79,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 [7/28] * Backlog [3/23]
** TODO [#A] Add classmethod for metadata fetching to tracks :vrobbler:feature:music:personal:project: ** TODO [#A] Add classmethod for metadata fetching to tracks :vrobbler:feature:music:personal:project:
:PROPERTIES: :PROPERTIES:
:ID: bc4b45e5-4c65-13c5-ab7b-1937d3fbf5c2 :ID: bc4b45e5-4c65-13c5-ab7b-1937d3fbf5c2
@ -88,7 +88,6 @@ CLOCK: [2025-07-09 Wed 09:55]--[2025-07-09 Wed 10:15] => 0:20
CLOCK: [2025-07-09 Wed 10:15] CLOCK: [2025-07-09 Wed 10:15]
:END: :END:
** TODO Look in comments for a timestamp for start from BG stats if the time is missing :vrobbler:feature:boardgames:project:personal:
** TODO Add importer class for IMAP imports :vrobbler:feature:imap:importers:project:personal: ** TODO Add importer class for IMAP imports :vrobbler:feature:imap:importers:project:personal:
** TODO Add youtube link in place of IMDB on video detail page :vrobbler:feature:videos:personal:project: ** TODO Add youtube link in place of IMDB on video detail page :vrobbler:feature:videos:personal:project:
** TODO [#A] Tasks from org-mode should properly update notes and leave them out of the body :vrobbler:bug:tasks: ** TODO [#A] Tasks from org-mode should properly update notes and leave them out of the body :vrobbler:bug:tasks:
@ -443,7 +442,27 @@ it's annoying.
** TODO [#C] Allow users to see tasks on calendar view :vrobbler:personal:project:templates:feature: ** TODO [#C] Allow users to see tasks on calendar view :vrobbler:personal:project:templates:feature:
https://codepen.io/oliviale/pen/QYqybo https://codepen.io/oliviale/pen/QYqybo
** TODO [#C] Come up with a possible flow using WebDAV and super-productivity for tasks :personal:feature:project:vrobbler:tasks: ** TODO [#C] Come up with a possible flow using WebDAV and super-productivity for tasks :personal:feature:project:vrobbler:tasks:
* Version 18.4 * Version 23.0 [3/3]
** DONE Add dynamic forms for LogData classes :personal:feature:vrobbler:project:forms:logdata:
:PROPERTIES:
:ID: 0db889a1-f262-fba2-7fed-ed99eded1c88
:END:
** DONE Look in comments for a timestamp for start from BG stats if the time is missing :vrobbler:feature:boardgames:project:personal:
** DONE Fix long play scrobbles to provide better data :vrobbler:feature:scrobbles:longplay:personal:project:
:PROPERTIES:
:ID: 99f6bd77-dc8f-6ed1-0321-32a52c944264
:END:
* Version 19.0 [1/1]
** DONE Add periodic check for mood :vrobbler:feature:moods:personal:project:
:PROPERTIES:
:ID: 55404488-c69f-0dd5-838e-1d1e15c873eb
:END:
* Version 18.7 [1/1]
** DONE Use the timezone history log to fix old Scrobbles that fall into those timezone blocks :vrobbler:chore:scrobbles:project:personal:
:PROPERTIES:
:ID: 9d055ac1-584b-20c8-7ad9-9ce36b329dc7
:END:
* Version 18.4 [2/2]
** DONE Track timezone changes for profiles :vrobbler:feature:profiles:personal:project: ** DONE Track timezone changes for profiles :vrobbler:feature:profiles:personal:project:
:PROPERTIES: :PROPERTIES:
:ID: 89ec867f-29fd-82f1-be17-b49dddc30c78 :ID: 89ec867f-29fd-82f1-be17-b49dddc30c78
@ -456,12 +475,12 @@ https://codepen.io/oliviale/pen/QYqybo
- Note taken on [2025-07-20 Sun 16:21] - Note taken on [2025-07-20 Sun 16:21]
This thing is kicking my butt. As it stands it works, but the scrobbles are not assigned to the tracks properly. This thing is kicking my butt. As it stands it works, but the scrobbles are not assigned to the tracks properly.
* Version 18.3 * Version 18.3 [1/1]
** DONE Add timezone awarness to IMAP importer :personal:project:vrobbler:feature:importer:imap:timezones: ** DONE Add timezone awarness to IMAP importer :personal:project:vrobbler:feature:importer:imap:timezones:
:PROPERTIES: :PROPERTIES:
:ID: 05837b7c-96aa-6190-3678-e2ae7c7cac75 :ID: 05837b7c-96aa-6190-3678-e2ae7c7cac75
:END: :END:
* Version 18 * Version 18 [4/4]
** DONE Condense tracks of the same title by the same artist with multiple albums :vrobbler:feature:music:project:personal: ** DONE Condense tracks of the same title by the same artist with multiple albums :vrobbler:feature:music:project:personal:
:PROPERTIES: :PROPERTIES:
:ID: b39fcec8-59fd-eab0-5809-b8144c7d2708 :ID: b39fcec8-59fd-eab0-5809-b8144c7d2708
@ -582,7 +601,7 @@ https://codepen.io/oliviale/pen/QYqybo
:PROPERTIES: :PROPERTIES:
:ID: 23f485e3-988c-6198-c79d-91fdf92f001c :ID: 23f485e3-988c-6198-c79d-91fdf92f001c
:END: :END:
* Version 17.0 * Version 17.0 [6/6]
** DONE [#A] Fix bug in new task label lookup for Emacs/Org-mode :vrobbler:bug:tasks: ** DONE [#A] Fix bug in new task label lookup for Emacs/Org-mode :vrobbler:bug:tasks:
:PROPERTIES: :PROPERTIES:
:ID: 683fb109-dfc4-85e4-80f0-ea618434f61e :ID: 683fb109-dfc4-85e4-80f0-ea618434f61e
@ -645,7 +664,7 @@ Not sure if the problem is in my Emacs hook sending or Vrobbler itself.
:PROPERTIES: :PROPERTIES:
:ID: df58f8d0-fa4a-2037-c7d7-e5388c239042 :ID: df58f8d0-fa4a-2037-c7d7-e5388c239042
:END: :END:
* Version 0.16.0 * Version 0.16.0 [19/19]
** DONE [#A] Jellyfin, bandcamp tracks from Mopidy create duplicate music tracks :bug:scrobbling:music: ** DONE [#A] Jellyfin, bandcamp tracks from Mopidy create duplicate music tracks :bug:scrobbling:music:
:PROPERTIES: :PROPERTIES:
:ID: 670e8634-49b5-dce9-1684-14f2ffb797f1 :ID: 670e8634-49b5-dce9-1684-14f2ffb797f1
@ -750,7 +769,7 @@ out using that.
** DONE Fix bug in Jellyfin scrobbles that spam more scrobbles after completion :scrobbling:videos:bug: ** DONE Fix bug in Jellyfin scrobbles that spam more scrobbles after completion :scrobbling:videos:bug:
This was fixed a while ago, but there's a new manifested bug. Going to create a This was fixed a while ago, but there's a new manifested bug. Going to create a
separate bug tracking ticket for that. separate bug tracking ticket for that.
* Version 0.11.4 * Version 0.11.4 [9/9]
** DONE Add rudimentary video game scrobbling :improvement:content:videogames: ** DONE Add rudimentary video game scrobbling :improvement:content:videogames:
CLOSED: [2023-03-07 Tue 11:11] CLOSED: [2023-03-07 Tue 11:11]
** DONE Add ability to scrobble from KOReader statistics files :improvement:books:content: ** DONE Add ability to scrobble from KOReader statistics files :improvement:books:content:

File diff suppressed because one or more lines are too long

View File

@ -7,23 +7,24 @@ from rest_framework.authtoken.models import Token
from boardgames.models import BoardGame from boardgames.models import BoardGame
from music.models import Track, Artist from music.models import Track, Artist
from scrobbles.models import Scrobble from scrobbles.models import Scrobble
from people.models import Person
User = get_user_model() User = get_user_model()
@pytest.fixture @pytest.fixture
def boardgame_scrobble(): def boardgame_scrobble():
user = User.objects.create( first = Person.objects.create(name="First Player")
email="test@exmaple.com", first_name="Test", last_name="User" second = Person.objects.create(name="Second Player")
)
return Scrobble.objects.create( return Scrobble.objects.create(
board_game=BoardGame.objects.create(title="Test Board Game"), board_game=BoardGame.objects.create(title="Test Board Game"),
media_type="BoardGame", media_type="BoardGame",
played_to_completion=True, played_to_completion=True,
log={ log={
"players": [ "players": [
{"user_id": user.id, "win": True, "score": 30, "color": "Blue"} {"person_id": first.id, "win": True, "score": 30, "color": "Blue"},
] {"person_id": second.id, "win": False, "score": 28, "color": "Red"}
],
}, },
) )

View File

@ -1,16 +1,15 @@
import pytest import pytest
from scrobbles.dataclasses import BoardGameLogData, BoardGameScoreLogData #from scrobbles.dataclasses import BoardGameLogData, BoardGameScoreLogData
@pytest.mark.skip("Need to get local tests running working again")
@pytest.mark.django_db @pytest.mark.django_db
def test_boardgame_log_data(boardgame_scrobble): def test_boardgame_log_data(boardgame_scrobble):
assert not boardgame_scrobble.geo_location
assert boardgame_scrobble.logdata == BoardGameLogData( assert boardgame_scrobble.logdata == BoardGameLogData(
players=[ players=[
BoardGameScoreLogData( BoardGameScoreLogData(
user_id=1, person_id=1,
name_str="",
bgg_username="", bgg_username="",
color="Blue", color="Blue",
character=None, character=None,
@ -18,10 +17,24 @@ def test_boardgame_log_data(boardgame_scrobble):
score=30, score=30,
win=True, win=True,
new=None, new=None,
) rank=None,
seat_order=None,
role=None
),
BoardGameScoreLogData(
person_id=2,
bgg_username="",
color="Red",
character=None,
team=None,
score=28,
win=False,
new=None,
rank=None,
seat_order=None,
role=None
),
], ],
location=None,
geo_location_id=None,
difficulty=None, difficulty=None,
solo=None, solo=None,
two_handed=None, two_handed=None,

View File

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

View File

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

View File

@ -1,8 +1,11 @@
from functools import cached_property
import logging import logging
from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from uuid import uuid4 from uuid import uuid4
from django import forms
import requests import requests
from boardgames.bgg import lookup_boardgame_from_bgg from boardgames.bgg import lookup_boardgame_from_bgg
from django.conf import settings from django.conf import settings
@ -12,14 +15,115 @@ from django.urls import reverse
from django_extensions.db.models import TimeStampedModel from django_extensions.db.models import TimeStampedModel
from imagekit.models import ImageSpecField from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit from imagekit.processors import ResizeToFit
from scrobbles.dataclasses import BoardGameLogData
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
from locations.models import GeoLocation from locations.models import GeoLocation
from people.models import Person
from scrobbles.dataclasses import BaseLogData, LongPlayLogData
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
BNULL = {"blank": True, "null": True} BNULL = {"blank": True, "null": True}
@dataclass
class BoardGameScoreLogData(BaseLogData):
person_id: Optional[int] = None
bgg_username: Optional[str] = None
color: Optional[str] = None
character: Optional[str] = None
team: Optional[str] = None
score: Optional[int] = None
win: Optional[bool] = None
new: Optional[bool] = None
rank: Optional[int] = None
seat_order: Optional[int] = None
role: Optional[str] = None
rank: Optional[int] = None
seat_order: Optional[int] = None
role: Optional[str] = None
lichess_username: Optional[str] = None
@property
def person(self) -> Optional[Person]:
return Person.objects.filter(id=self.person_id).first()
@property
def name(self) -> str:
name = ""
if self.person:
name = self.person.name
return name
def __str__(self) -> str:
out = self.name
if self.score:
out += f" {self.score}"
if self.color:
out += f" ({self.color})"
if self.win:
out += f" [W]"
return out
@dataclass
class BoardGameLogData(BaseLogData, LongPlayLogData):
players: Optional[list[BoardGameScoreLogData]] = None
location_id: Optional[int] = None
difficulty: Optional[int] = None
solo: Optional[bool] = None
two_handed: Optional[bool] = None
expansion_ids: Optional[int] = None
moves: Optional[list] = None
rated: Optional[str] = None
speed: Optional[str] = None
variant: Optional[str] = None
lichess_id: Optional[int] = None
board: Optional[str] = None
rounds: Optional[int] = None
details: Optional[str] = None
_excluded_fields = {
"lichess_id",
"speed",
"rated",
"moves",
"variant",
}
@cached_property
def location(self):
if not self.location_id:
return
return BoardGameLocation.objects.filter(id=self.location_id).first()
@cached_property
def player_log(self) -> str:
if self.players:
return ", ".join(
[
BoardGameScoreLogData(**player).__str__()
for player in self.players
]
)
return ""
@classmethod
def override_fields(cls) -> dict:
fields = {}
for base in cls.mro()[1:]:
if hasattr(base, "override_fields"):
base_fields = base.override_fields()
fields.update(base_fields)
custom_fields = {
"location_id": forms.ModelChoiceField(
queryset=BoardGameLocation.objects.all(),
required=False,
widget=forms.Select(),
)
}
fields.update(custom_fields)
return fields
class BoardGamePublisher(TimeStampedModel): class BoardGamePublisher(TimeStampedModel):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL) uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)

View File

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

View File

@ -9,8 +9,7 @@ import requests
from books.constants import BOOKS_TITLES_TO_IGNORE from books.constants import BOOKS_TITLES_TO_IGNORE
from django.apps import apps from django.apps import apps
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from profiles.utils import one_off_fix_colins_profile from scrobbles.notifications import ScrobbleNtfyNotification
from scrobbles.notifications import NtfyNotification
from stream_sqlite import stream_sqlite from stream_sqlite import stream_sqlite
from webdav.client import get_webdav_client from webdav.client import get_webdav_client
@ -286,17 +285,12 @@ def build_scrobbles_from_book_map(
datetime.fromtimestamp(int(last_page.get("end_ts"))) datetime.fromtimestamp(int(last_page.get("end_ts")))
) )
if user.id == 1 and not user.profile.timezone_change_log:
one_off_fix_colins_profile(user.profile)
# Adjust for Daylight Saving Time # Adjust for Daylight Saving Time
if timestamp.dst() == timedelta( if timestamp.dst() == timedelta(
0 0
) or stop_timestamp.dst() == timedelta(0): ) or stop_timestamp.dst() == timedelta(0):
timestamp = timestamp - timedelta(hours=1) timestamp = timestamp - timedelta(hours=1)
stop_timestamp = stop_timestamp - timedelta(hours=1) stop_timestamp = stop_timestamp - timedelta(hours=1)
else:
print("In DST! ", timestamp)
scrobble = Scrobble.objects.filter( scrobble = Scrobble.objects.filter(
timestamp=timestamp, timestamp=timestamp,
@ -413,7 +407,7 @@ def process_koreader_sqlite_file(file_path, user_id) -> list:
if new_scrobbles: if new_scrobbles:
created = Scrobble.objects.bulk_create(new_scrobbles) created = Scrobble.objects.bulk_create(new_scrobbles)
if created: if created:
NtfyNotification(created[-1]).send() ScrobbleNtfyNotification(created[-1]).send()
fix_long_play_stats_for_scrobbles(created) fix_long_play_stats_for_scrobbles(created)
logger.info( logger.info(
f"Created {len(created)} scrobbles", f"Created {len(created)} scrobbles",

View File

@ -1,6 +1,8 @@
from collections import OrderedDict from collections import OrderedDict
from dataclasses import dataclass
import logging import logging
from datetime import timedelta, datetime from datetime import datetime
from typing import Optional
from uuid import uuid4 from uuid import uuid4
import requests import requests
@ -20,7 +22,6 @@ from scrobbles.mixins import (
LongPlayScrobblableMixin, LongPlayScrobblableMixin,
ObjectWithGenres, ObjectWithGenres,
ScrobblableConstants, ScrobblableConstants,
ScrobblableMixin,
) )
from scrobbles.utils import get_scrobbles_for_media from scrobbles.utils import get_scrobbles_for_media
from taggit.managers import TaggableManager from taggit.managers import TaggableManager
@ -35,9 +36,9 @@ from vrobbler.apps.books.locg import (
lookup_comic_from_locg, lookup_comic_from_locg,
lookup_comic_writer_by_locg_slug, lookup_comic_writer_by_locg_slug,
) )
from vrobbler.apps.books.sources.google import lookup_book_from_google from books.sources.google import lookup_book_from_google
from vrobbler.apps.books.sources.semantic import lookup_paper_from_semantic from books.sources.semantic import lookup_paper_from_semantic
from vrobbler.apps.scrobbles.dataclasses import BookLogData from scrobbles.dataclasses import BaseLogData, LongPlayLogData
COMICVINE_API_KEY = getattr(settings, "COMICVINE_API_KEY", "") COMICVINE_API_KEY = getattr(settings, "COMICVINE_API_KEY", "")
@ -46,6 +47,33 @@ User = get_user_model()
BNULL = {"blank": True, "null": True} BNULL = {"blank": True, "null": True}
@dataclass
class BookPageLogData(BaseLogData):
page_number: Optional[int] = None
end_ts: Optional[int] = None
start_ts: Optional[int] = None
duration: Optional[int] = None
@dataclass
class BookLogData(BaseLogData, LongPlayLogData):
koreader_hash: Optional[str] = None
page_data: Optional[dict[int, BookPageLogData]] = None
pages_read: Optional[int] = None
page_start: Optional[int] = None
page_end: Optional[int] = None
_excluded_fields = {"koreader_hash", "page_data"}
def avg_seconds_per_page(self):
if self.page_data:
total_duration = 0
for page_num, stats in self.page_data.items():
total_duration += stats.get("duration", 0)
if total_duration:
return int(total_duration / len(self.page_data))
class Author(TimeStampedModel): class Author(TimeStampedModel):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL) uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
@ -161,7 +189,9 @@ class Book(LongPlayScrobblableMixin):
return reverse("books:book_detail", kwargs={"slug": self.uuid}) return reverse("books:book_detail", kwargs={"slug": self.uuid})
@classmethod @classmethod
def find_or_create(cls, title: str, enrich: bool = False, commit: bool = True): def find_or_create(
cls, title: str, enrich: bool = False, commit: bool = True
):
"""Given a title, get a Book instance. """Given a title, get a Book instance.
If the book is not already in our database, or overwrite is True, If the book is not already in our database, or overwrite is True,
@ -173,10 +203,15 @@ class Book(LongPlayScrobblableMixin):
# TODO use either a Google Books id identifier or author name like for tracks # TODO use either a Google Books id identifier or author name like for tracks
book, created = cls.objects.get_or_create(title=title) book, created = cls.objects.get_or_create(title=title)
if not created: if not created:
logger.info("Found exact match for book by title", extra={"title": title}) logger.info(
"Found exact match for book by title", extra={"title": title}
)
if not enrich: if not enrich:
logger.info("Found book by title, but not enriching", extra={"title": title}) logger.info(
"Found book by title, but not enriching",
extra={"title": title},
)
return book return book
book_dict = lookup_book_from_google(title) book_dict = lookup_book_from_google(title)

View File

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

View File

@ -1,3 +1,5 @@
from dataclasses import dataclass
from typing import Optional
from uuid import uuid4 from uuid import uuid4
from django.apps import apps from django.apps import apps
@ -6,12 +8,18 @@ from django.urls import reverse
from django_extensions.db.models import TimeStampedModel from django_extensions.db.models import TimeStampedModel
from imagekit.models import ImageSpecField from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit from imagekit.processors import ResizeToFit
from scrobbles.dataclasses import FoodLogData from scrobbles.dataclasses import BaseLogData
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
BNULL = {"blank": True, "null": True} BNULL = {"blank": True, "null": True}
@dataclass
class FoodLogData(BaseLogData):
meal: Optional[str] = None
rating: Optional[str] = None
class FoodCategory(TimeStampedModel): class FoodCategory(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL) uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
name = models.CharField(max_length=255) name = models.CharField(max_length=255)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,5 @@
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
import pendulum import pendulum
from datetime import datetime
from django.utils import timezone from django.utils import timezone
import logging import logging
from django.conf import settings from django.conf import settings
@ -56,6 +55,9 @@ class UserProfile(TimeStampedModel):
imap_pass = EncryptedField(**BNULL) imap_pass = EncryptedField(**BNULL)
imap_auto_import = models.BooleanField(default=False) imap_auto_import = models.BooleanField(default=False)
mood_checkin_enabled = models.BooleanField(default=False)
mood_checkin_frequency = models.CharField(max_length=20, default="hourly")
ntfy_url = models.CharField(max_length=255, **BNULL) ntfy_url = models.CharField(max_length=255, **BNULL)
ntfy_enabled = models.BooleanField(default=False) ntfy_enabled = models.BooleanField(default=False)
@ -115,14 +117,34 @@ class UserProfile(TimeStampedModel):
return timestamp.replace(tzinfo=timezone) return timestamp.replace(tzinfo=timezone)
def adjust_timezone_of_scrobbles(self, commit=False):
current_dt = None
scrobbles_to_change_qs_list = []
for boundry_dt in self.historic_timezone_changes:
if current_dt and boundry_dt:
logger.info(
f"Checking for scrobbles between {current_dt} and {boundry_dt} to update to {current_dt.tzinfo.name}"
)
scrobbles = self.user.scrobble_set.filter(
timestamp__gte=current_dt,
timestamp__lt=boundry_dt,
).exclude(timezone=current_dt.tzinfo.name)
scrobbles_to_change_qs_list.append(scrobbles)
logger.info(
f"Updating {scrobbles.count()} scrobble timezones to {current_dt.tzinfo.name}"
)
if commit:
scrobbles.update(timezone=current_dt.tzinfo.name)
current_dt = boundry_dt
return scrobbles_to_change_qs_list
@cached_property @cached_property
def task_context_tags(self) -> list[str]: def task_context_tags(self) -> list[str]:
tag_list = [ tag_list = settings.DEFAULT_TASK_CONTEXT_TAGS
t.strip().capitalize() tags = ""
for t in self.task_context_tags_str.split(",") if self.task_context_tags_str:
] tags = self.task_context_tags_str
tag_list = [t.strip().capitalize() for t in tags.split(",")]
if not tag_list:
tag_list = settings.DEFAULT_TASK_CONTEXT_TAG_LIST
return tag_list return tag_list

View File

@ -59,15 +59,15 @@ def start_of_year(dt, profile) -> datetime:
return start_of_day(dt, profile).replace(month=1, day=1) return start_of_day(dt, profile).replace(month=1, day=1)
def one_off_fix_colins_profile(profile): def fix_profile_historic_timezones(profile):
home_tz = "America/New_York" home_tz = "America/New_York"
europe = "2022-10-15 06:00:00" europe = "2023-10-15 06:00:00"
europe_end = "2023-12-16 12:00:00" europe_end = "2023-12-16 12:00:00"
europe_tz = "Europe/Paris" europe_tz = "Europe/Paris"
washington = "2023-04-28 06:00:00" washington = "2024-04-28 06:00:00"
washington_end = "2023-05-04 12:00:00" washington_end = "2024-05-04 12:00:00"
washington_tz = "America/Los_Angeles" washington_tz = "America/Los_Angeles"
camp = "2024-08-04 17:00:00" camp = "2024-08-04 17:00:00"
@ -78,6 +78,8 @@ def one_off_fix_colins_profile(profile):
summer_end = "2025-07-11 23:30:00" summer_end = "2025-07-11 23:30:00"
summer_tz = "America/Los_Angeles" summer_tz = "America/Los_Angeles"
profile.timezone_change_log = None
profile.timezone_change_log = "" profile.timezone_change_log = ""
profile.timezone_change_log += f"{europe_tz} - {pendulum.parse(europe)}\n" profile.timezone_change_log += f"{europe_tz} - {pendulum.parse(europe)}\n"
profile.timezone_change_log += ( profile.timezone_change_log += (

View File

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

View File

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

View File

@ -1,12 +1,12 @@
from functools import cached_property
import inspect
import json import json
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from typing import Optional from typing import Optional
from dataclass_wizard import JSONWizard from dataclass_wizard import JSONWizard
from django import forms
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from locations.models import GeoLocation from people.models import Person
from scrobbles.forms import form_from_dataclass
User = get_user_model() User = get_user_model()
@ -32,190 +32,54 @@ class JSONDataclass(JSONWizard):
@dataclass @dataclass
class ScrobbleLogData(JSONDataclass): class BaseLogData(JSONDataclass):
description: Optional[str] = None description: Optional[str] = None
notes: Optional[list[str]] = None
_excluded_fields = {}
@classmethod
def form(cls):
return form_from_dataclass(cls)
@classmethod
def override_fields(cls) -> dict:
return {}
@dataclass
class LongPlayLogData(JSONDataclass): class LongPlayLogData(JSONDataclass):
serial_scrobble_id: Optional[int]
long_play_complete: bool = False long_play_complete: bool = False
class WithOthersLogData(JSONDataclass): @dataclass
with_user_ids: Optional[list[int]] = None class WithPeopleLogData(JSONDataclass):
with_names_str: Optional[list[str]] = None with_people_ids: Optional[list[int]] = None
@property @property
def with_names(self) -> list[str]: def with_people(self) -> list["Person"]:
with_names = [] from people.models import Person
if self.with_user_ids:
with_names += [u.full_name for u in self.with_users if u]
if self.with_names_str:
with_names += [u for u in self.with_names_str]
return with_names
@property if not self.with_people_ids:
def with_users(self) -> list[User]: return []
with_users = [] return [
if self.with_user_ids: Person.objects.filter(id=pid).first()
with_users = [ for pid in self.with_people_ids
User.objects.filter(id=i).first() for i in self.with_user_ids ]
]
return with_users
@classmethod
@dataclass def override_fields(cls) -> dict:
class BoardGameScoreLogData(JSONDataclass): fields = {}
user_id: Optional[int] = None for base in cls.mro()[1:]:
name_str: str = "" if hasattr(base, "override_fields"):
bgg_username: str = "" base_fields = base.override_fields()
color: Optional[str] = None fields.update(base_fields)
character: Optional[str] = None custom_fields = {
team: Optional[str] = None "with_people_ids": forms.ModelMultipleChoiceField(
score: Optional[int] = None queryset=Person.objects.all(),
win: Optional[bool] = None required=False,
new: Optional[bool] = None widget=forms.SelectMultiple(attrs={"size": 10}),
)
@property }
def user(self) -> Optional[User]: fields.update(custom_fields)
user = None return fields
if self.user_id:
user = User.objects.filter(id=self.user_id).first()
return user
@property
def name(self) -> str:
name = self.name_str
if self.user_id:
name = self.user.first_name
return name
def __str__(self) -> str:
out = self.name
if self.score:
out += f" {self.score}"
if self.color:
out += f" ({self.color})"
if self.win:
out += f" [W]"
return out
@dataclass
class BoardGameLogData(LongPlayLogData):
serial_scrobble_id: Optional[int] = None
long_play_complete: Optional[bool] = None
players: Optional[list[BoardGameScoreLogData]] = None
location: Optional[str] = None
geo_location_id: Optional[int] = None
difficulty: Optional[int] = None
solo: Optional[bool] = None
two_handed: Optional[bool] = None
@cached_property
def geo_location(self) -> Optional[GeoLocation]:
if self.geo_location_id:
return GeoLocation.objects.filter(id=self.geo_location_id).first()
@dataclass
class BookPageLogData(JSONDataclass):
page_number: Optional[int] = None
end_ts: Optional[int] = None
start_ts: Optional[int] = None
duration: Optional[int] = None
@dataclass
class BookLogData(LongPlayLogData):
long_play_complete: Optional[bool] = None
koreader_hash: Optional[str] = None
page_data: Optional[dict[int, BookPageLogData]] = None
pages_read: Optional[int] = None
page_start: Optional[int] = None
page_end: Optional[int] = None
serial_scrobble_id: Optional[int] = None
details: Optional[str] = None
@dataclass
class LifeEventLogData(WithOthersLogData):
with_user_ids: Optional[list[int]] = None
with_names_str: Optional[list[str]] = None
location: Optional[str] = None
geo_location_id: Optional[int] = None
details: Optional[str] = None
def geo_location(self):
return GeoLocation.objects.filter(id=self.geo_location_id).first()
@dataclass
class MoodLogData(JSONDataclass):
reasons: Optional[str] = None
@dataclass
class VideoLogData(JSONDataclass):
title: str
video_type: str
run_time_seconds: int
kind: str
year: Optional[int]
episode_number: Optional[int] = None
source_url: Optional[str] = None
imdbID: Optional[str] = None
season_number: Optional[int] = None
cover_url: Optional[str] = None
next_imdb_id: Optional[int] = None
tv_series_id: Optional[str] = None
@dataclass
class VideoGameLogData(LongPlayLogData):
serial_scrobble_id: Optional[int] = None
long_play_complete: Optional[bool] = False
console: Optional[str] = None
emulated: Optional[bool] = False
emulator: Optional[str] = None
@dataclass
class BrickSetLogData(LongPlayLogData, WithOthersLogData):
serial_scrobble_id: Optional[int]
long_play_complete: bool = False
with_user_ids: Optional[list[int]] = None
with_names_str: Optional[list[str]] = None
@dataclass
class TrailLogData(WithOthersLogData):
with_user_ids: Optional[list[int]] = None
with_names_str: Optional[list[str]] = None
details: Optional[str] = None
effort: Optional[str] = None
difficulty: Optional[str] = None
@dataclass
class BeerLogData(WithOthersLogData):
with_user_ids: Optional[list[int]] = None
with_names_str: Optional[list[str]] = None
details: Optional[str] = None
rating: Optional[str] = None
notes: Optional[str] = None
@dataclass
class FoodLogData(JSONDataclass):
meal: Optional[str] = None
details: Optional[str] = None
rating: Optional[str] = None
notes: Optional[str] = None
@dataclass
class PuzzleLogData(JSONDataclass):
with_others: Optional[str] = None
rating: Optional[str] = None
notes: Optional[str] = None

View File

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

View File

@ -50,9 +50,10 @@ class LastFM:
enrich=True, enrich=True,
) )
timestamp = self.vrobbler_user.profile.get_timestamp_with_tz( tz_timestamp = self.vrobbler_user.profile.get_timestamp_with_tz(
lfm_scrobble.get("timestamp") lfm_scrobble.get("timestamp")
) )
timestamp = lfm_scrobble.get("timestamp")
stop_timestamp = timestamp + timedelta( stop_timestamp = timestamp + timedelta(
seconds=track.run_time_seconds seconds=track.run_time_seconds
) )
@ -65,7 +66,7 @@ class LastFM:
played_to_completion=True, played_to_completion=True,
in_progress=False, in_progress=False,
media_type=Scrobble.MediaType.TRACK, media_type=Scrobble.MediaType.TRACK,
timezone=timestamp.tzinfo.name, timezone=tz_timestamp.tzinfo.name,
) )
# Vrobbler scrobbles on finish, LastFM scrobbles on start # Vrobbler scrobbles on finish, LastFM scrobbles on start
seconds_eariler = timestamp - timedelta(seconds=20) seconds_eariler = timestamp - timedelta(seconds=20)

View File

@ -2,6 +2,7 @@ import codecs
import csv import csv
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
import requests import requests
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
@ -61,10 +62,10 @@ def import_audioscrobbler_tsv_file(file_path, user_id):
}, },
) )
continue continue
timestamp = datetime.fromtimestamp(
timestamp = user.profile.get_timestamp_with_tz( int(row[AsTsvColumn["TIMESTAMP"].value])
datetime.fromtimestamp(int(row[AsTsvColumn["TIMESTAMP"].value])) ).astimezone(ZoneInfo("UTC"))
) timestamp = user.profile.get_timestamp_with_tz(timestamp)
stop_timestamp = timestamp + timedelta(seconds=track.run_time_seconds) stop_timestamp = timestamp + timedelta(seconds=track.run_time_seconds)
new_scrobble = Scrobble( new_scrobble = Scrobble(

View File

@ -1,9 +1,11 @@
import logging
from books.koreader import fetch_file_from_webdav from books.koreader import fetch_file_from_webdav
from profiles.models import UserProfile from profiles.models import UserProfile
from scrobbles.models import KoReaderImport from scrobbles.models import KoReaderImport
from scrobbles.tasks import process_koreader_import from scrobbles.tasks import process_koreader_import
from scrobbles.utils import get_file_md5_hash
from webdav.client import get_webdav_client from webdav.client import get_webdav_client
import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -11,7 +13,7 @@ logger = logging.getLogger(__name__)
def import_from_webdav_for_all_users(restart=False): def import_from_webdav_for_all_users(restart=False):
"""Grab a list of all users with WebDAV enabled and kickoff imports for them""" """Grab a list of all users with WebDAV enabled and kickoff imports for them"""
# LastFmImport = apps.get_model("scrobbles", "LastFMImport") # WebDavImport = apps.get_model("scrobbles", "WebDavImport")
webdav_enabled_user_ids = UserProfile.objects.filter( webdav_enabled_user_ids = UserProfile.objects.filter(
webdav_url__isnull=False, webdav_url__isnull=False,
webdav_user__isnull=False, webdav_user__isnull=False,

View File

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

View File

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

View File

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

View File

@ -14,6 +14,7 @@ from boardgames.models import BoardGame
from books.koreader import process_koreader_sqlite_file from books.koreader import process_koreader_sqlite_file
from books.models import Book, Paper from books.models import Book, Paper
from bricksets.models import BrickSet from bricksets.models import BrickSet
from dataclass_wizard.errors import ParseError
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core.files import File from django.core.files import File
@ -33,6 +34,7 @@ from profiles.utils import (
end_of_day, end_of_day,
end_of_month, end_of_month,
end_of_week, end_of_week,
fix_profile_historic_timezones,
start_of_day, start_of_day,
start_of_month, start_of_month,
start_of_week, start_of_week,
@ -41,7 +43,7 @@ from puzzles.models import Puzzle
from scrobbles import dataclasses as logdata from scrobbles import dataclasses as logdata
from scrobbles.constants import LONG_PLAY_MEDIA, MEDIA_END_PADDING_SECONDS from scrobbles.constants import LONG_PLAY_MEDIA, MEDIA_END_PADDING_SECONDS
from scrobbles.importers.lastfm import LastFM from scrobbles.importers.lastfm import LastFM
from scrobbles.notifications import NtfyNotification from scrobbles.notifications import ScrobbleNtfyNotification
from scrobbles.stats import build_charts from scrobbles.stats import build_charts
from scrobbles.utils import get_file_md5_hash, media_class_to_foreign_key from scrobbles.utils import get_file_md5_hash, media_class_to_foreign_key
from sports.models import SportEvent from sports.models import SportEvent
@ -205,6 +207,9 @@ class KoReaderImport(BaseFileImportMixin):
def process(self, force=False): def process(self, force=False):
if self.user.id == 1:
fix_profile_historic_timezones(self.user.profile)
if self.processed_finished and not force: if self.processed_finished and not force:
logger.info( logger.info(
f"{self} already processed on {self.processed_finished}" f"{self} already processed on {self.processed_finished}"
@ -250,6 +255,9 @@ class AudioScrobblerTSVImport(BaseFileImportMixin):
def process(self, force=False): def process(self, force=False):
from scrobbles.importers.tsv import import_audioscrobbler_tsv_file from scrobbles.importers.tsv import import_audioscrobbler_tsv_file
if self.user.id == 1:
fix_profile_historic_timezones(self.user.profile)
if self.processed_finished and not force: if self.processed_finished and not force:
logger.info( logger.info(
f"{self} already processed on {self.processed_finished}" f"{self} already processed on {self.processed_finished}"
@ -280,6 +288,10 @@ class LastFmImport(BaseFileImportMixin):
def process(self, import_all=False): def process(self, import_all=False):
"""Import scrobbles found on LastFM""" """Import scrobbles found on LastFM"""
if self.user.id == 1:
fix_profile_historic_timezones(self.user.profile)
if self.processed_finished: if self.processed_finished:
logger.info( logger.info(
f"{self} already processed on {self.processed_finished}" f"{self} already processed on {self.processed_finished}"
@ -327,6 +339,9 @@ class RetroarchImport(BaseFileImportMixin):
def process(self, import_all=False, force=False): def process(self, import_all=False, force=False):
"""Import scrobbles found on Retroarch""" """Import scrobbles found on Retroarch"""
if self.user.id == 1:
fix_profile_historic_timezones(self.user.profile)
if self.processed_finished and not force: if self.processed_finished and not force:
logger.info( logger.info(
f"{self} already processed on {self.processed_finished}" f"{self} already processed on {self.processed_finished}"
@ -682,6 +697,12 @@ class Scrobble(TimeStampedModel):
return super(Scrobble, self).save(*args, **kwargs) return super(Scrobble, self).save(*args, **kwargs)
def get_absolute_url(self):
if not self.uuid:
self.uuid = uuid4()
self.save()
return reverse("scrobbles:detail", kwargs={"uuid": self.uuid})
def push_to_archivebox(self): def push_to_archivebox(self):
pushable_media = hasattr( pushable_media = hasattr(
self.media_obj, "push_to_archivebox" self.media_obj, "push_to_archivebox"
@ -704,11 +725,11 @@ class Scrobble(TimeStampedModel):
) )
@property @property
def logdata(self) -> Optional[logdata.JSONDataclass]: def logdata(self) -> Optional[logdata.BaseLogData]:
if self.media_obj: if self.media_obj:
logdata_cls = self.media_obj.logdata_cls logdata_cls = self.media_obj.logdata_cls
else: else:
logdata_cls = logdata.ScrobbleLogData logdata_cls = logdata.BaseLogData
log_dict = self.log log_dict = self.log
if isinstance(self.log, str): if isinstance(self.log, str):
@ -722,7 +743,20 @@ class Scrobble(TimeStampedModel):
if not log_dict: if not log_dict:
log_dict = {} log_dict = {}
return logdata_cls.from_dict(log_dict) try:
return logdata_cls(**log_dict)
except ParseError as e:
logger.warning(
"Could not parse log data",
extra={
"log_dict": log_dict,
"scrobble_id": self.id,
"error": e,
},
)
return logdata_cls()
except TypeError as e:
return logdata_cls()
def redirect_url(self, user_id) -> str: def redirect_url(self, user_id) -> str:
user = User.objects.filter(id=user_id).first() user = User.objects.filter(id=user_id).first()
@ -1039,8 +1073,7 @@ class Scrobble(TimeStampedModel):
return media_obj return media_obj
def __str__(self): def __str__(self):
timestamp = self.timestamp.strftime("%Y-%m-%d") return f"Scrobble of {self.media_obj} ({self.timestamp})"
return f"Scrobble of {self.media_obj} ({timestamp})"
def calc_reading_duration(self) -> int: def calc_reading_duration(self) -> int:
duration = 0 duration = 0
@ -1302,7 +1335,7 @@ class Scrobble(TimeStampedModel):
scrobble_data: dict, scrobble_data: dict,
) -> "Scrobble": ) -> "Scrobble":
scrobble = cls.objects.create(**scrobble_data) scrobble = cls.objects.create(**scrobble_data)
NtfyNotification(scrobble).send() 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:

View File

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

View File

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

View File

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

View File

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

View File

@ -13,7 +13,7 @@ from django.utils import timezone
from profiles.models import UserProfile from profiles.models import UserProfile
from profiles.utils import now_user_timezone from profiles.utils import now_user_timezone
from scrobbles.constants import LONG_PLAY_MEDIA from scrobbles.constants import LONG_PLAY_MEDIA
from scrobbles.notifications import NtfyNotification from scrobbles.notifications import MoodNtfyNotification, ScrobbleNtfyNotification
from scrobbles.tasks import ( from scrobbles.tasks import (
process_koreader_import, process_koreader_import,
process_lastfm_import, process_lastfm_import,
@ -318,7 +318,20 @@ def send_stop_notifications_for_in_progress_scrobbles() -> int:
).seconds ).seconds
if elapsed_scrobble_seconds > scrobble.media_obj.run_time_seconds: if elapsed_scrobble_seconds > scrobble.media_obj.run_time_seconds:
NtfyNotification(scrobble, end=True).send() ScrobbleNtfyNotification(scrobble, end=True).send()
notifications_sent += 1
return notifications_sent
def send_mood_checkin_reminders() -> int:
"""Get all profiles with mood check-ins enabled and checkin!"""
from profiles.models import UserProfile
now = timezone.now()
notifications_sent = 0
for profile in UserProfile.objects.filter(mood_checkin_enabled=True):
if profile.mood_checkin_frequency == "hourly" and now.minute == 0:
MoodNtfyNotification(profile).send()
notifications_sent += 1 notifications_sent += 1
return notifications_sent return notifications_sent

View File

@ -4,12 +4,13 @@ import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.shortcuts import redirect
import pendulum import pendulum
import pytz import pytz
from django.apps import apps from django.apps import apps
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Count, Q from django.db.models import Count, Q, Max
from django.db.models.query import QuerySet from django.db.models.query import QuerySet
from django.http import FileResponse, HttpResponseRedirect, JsonResponse from django.http import FileResponse, HttpResponseRedirect, JsonResponse
from django.urls import reverse_lazy from django.urls import reverse_lazy
@ -19,6 +20,8 @@ from django.views.generic import DetailView, FormView, TemplateView
from django.views.generic.edit import CreateView from django.views.generic.edit import CreateView
from django.views.generic.list import ListView from django.views.generic.list import ListView
from music.aggregators import live_charts, scrobble_counts, week_of_scrobbles from music.aggregators import live_charts, scrobble_counts, week_of_scrobbles
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from rest_framework import status from rest_framework import status
from rest_framework.decorators import ( from rest_framework.decorators import (
api_view, api_view,
@ -54,6 +57,7 @@ from scrobbles.utils import (
get_long_plays_completed, get_long_plays_completed,
get_long_plays_in_progress, get_long_plays_in_progress,
) )
from moods.models import Mood
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -64,15 +68,22 @@ class ScrobbleableListView(ListView):
def get_queryset(self): def get_queryset(self):
queryset = super().get_queryset() queryset = super().get_queryset()
if self.model == Mood:
return queryset
user_filter = Q()
if not self.request.user.is_anonymous: if not self.request.user.is_anonymous:
queryset = queryset.annotate( user_filter = Q(scrobble__user=self.request.user)
scrobble_count=Count("scrobble"),
filter=Q(scrobble__user=self.request.user), queryset = (
).order_by("-scrobble_count") queryset.filter(user_filter)
else: .annotate(
queryset = queryset.annotate( scrobble_count=Count("scrobble", distinct=True),
scrobble_count=Count("scrobble") last_scrobble=Max("scrobble__timestamp"),
).order_by("-scrobble_count") )
.filter(scrobble_count__gt=0)
.order_by("-last_scrobble")
)
return queryset return queryset
@ -80,13 +91,30 @@ class ScrobbleableDetailView(DetailView):
model = None model = None
slug_field = "uuid" slug_field = "uuid"
paginate_by = 200 # You can set this to whatever page size you want
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs) context_data = super().get_context_data(**kwargs)
context_data["scrobbles"] = list() scrobbles = []
if not self.request.user.is_anonymous: if not self.request.user.is_anonymous:
context_data["scrobbles"] = self.object.scrobble_set.filter( scrobbles = self.object.scrobble_set.filter(
user=self.request.user user=self.request.user
) ).order_by("-timestamp")
paginator = Paginator(scrobbles, self.paginate_by)
page_number = self.request.GET.get("page")
try:
page_obj = paginator.page(page_number)
except PageNotAnInteger:
page_obj = paginator.page(1)
except EmptyPage:
page_obj = paginator.page(paginator.num_pages)
context_data["page_obj"] = page_obj
context_data["scrobbles"] = page_obj.object_list
context_data["is_paginated"] = paginator.num_pages > 1
return context_data return context_data
@ -201,7 +229,7 @@ class RecentScrobbleList(ListView):
processed_finished__isnull=True, processed_finished__isnull=True,
user=self.request.user, user=self.request.user,
) )
data["counts"] = [] #scrobble_counts(user) data["counts"] = [] # scrobble_counts(user)
else: else:
data["weekly_data"] = week_of_scrobbles() data["weekly_data"] = week_of_scrobbles()
data["counts"] = scrobble_counts() data["counts"] = scrobble_counts()
@ -904,3 +932,57 @@ class ScrobbleStatusView(LoginRequiredMixin, TemplateView):
).first() ).first()
return data return data
class ScrobbleDetailView(DetailView):
model = Scrobble
slug_field = "uuid"
slug_url_kwarg = "uuid"
def get_form_class(self):
return self.object.media_obj.logdata_cls.form()
def get_form(self):
FormClass = self.get_form_class()
log = self.object.log or {}
initial_notes = log.get("notes", [])
if isinstance(initial_notes, list):
notes_str = "\n".join(initial_notes)
notes_str_fixed = notes_str.encode("utf-8").decode(
"unicode_escape"
)
log["notes"] = notes_str_fixed
return FormClass(initial=log)
def post(self, request, *args, **kwargs):
self.object = self.get_object()
FormClass = self.get_form_class()
form = FormClass(request.POST)
if form.is_valid():
data = form.cleaned_data.copy()
for field_name, field in form.fields.items():
if field.disabled:
original_value = (self.object.log or {}).get(field_name)
data[field_name] = original_value
if "with_people_ids" in data:
data["with_people_ids"] = [
p.id for p in data["with_people_ids"]
]
self.object.log = data
self.object.save(update_fields=["log"])
return redirect(self.object.get_absolute_url())
context = self.get_context_data(log_form=form)
return self.render_to_response(context)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
if "log_form" not in context:
context["log_form"] = self.get_form()
return context

View File

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

View File

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

View File

@ -1,14 +1,23 @@
from django.utils.translation import gettext_lazy as _ from dataclasses import dataclass
from typing import Optional
from django.apps import apps from django.apps import apps
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from scrobbles.dataclasses import TrailLogData from django.utils.translation import gettext_lazy as _
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
from locations.models import GeoLocation from locations.models import GeoLocation
from scrobbles.dataclasses import BaseLogData, WithPeopleLogData
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
BNULL = {"blank": True, "null": True} BNULL = {"blank": True, "null": True}
@dataclass
class TrailLogData(BaseLogData, WithPeopleLogData):
effort: Optional[str] = None
difficulty: Optional[str] = None
class Trail(ScrobblableMixin): class Trail(ScrobblableMixin):
class PrincipalType(models.TextChoices): class PrincipalType(models.TextChoices):
WOODS = "WOODS" WOODS = "WOODS"

View File

@ -1,6 +1,9 @@
from dataclasses import dataclass
import logging import logging
from typing import Optional
from uuid import uuid4 from uuid import uuid4
from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db import models from django.db import models
@ -8,7 +11,11 @@ from django.urls import reverse
from django_extensions.db.models import TimeStampedModel from django_extensions.db.models import TimeStampedModel
from imagekit.models import ImageSpecField from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit from imagekit.processors import ResizeToFit
from scrobbles.dataclasses import VideoGameLogData from scrobbles.dataclasses import (
BaseLogData,
LongPlayLogData,
WithPeopleLogData,
)
from scrobbles.mixins import LongPlayScrobblableMixin, ScrobblableConstants from scrobbles.mixins import LongPlayScrobblableMixin, ScrobblableConstants
from scrobbles.utils import get_scrobbles_for_media from scrobbles.utils import get_scrobbles_for_media
from videogames.igdb import lookup_game_id_from_gdb from videogames.igdb import lookup_game_id_from_gdb
@ -18,6 +25,36 @@ BNULL = {"blank": True, "null": True}
User = get_user_model() User = get_user_model()
@dataclass
class VideoGameLogData(BaseLogData, LongPlayLogData, WithPeopleLogData):
platform_id: Optional[int] = None
emulated: Optional[bool] = False
emulator: Optional[str] = None
@property
def platform(self):
if not self.platform_id:
return
return VideoGamePlatform.objects.filter(id=self.platform_id).first()
@classmethod
def override_fields(cls) -> dict:
fields = {}
for base in cls.mro()[1:]:
if hasattr(base, "override_fields"):
base_fields = base.override_fields()
fields.update(base_fields)
custom_fields = {
"platform_id": forms.ModelChoiceField(
queryset=VideoGamePlatform.objects.all(),
required=False,
widget=forms.Select(),
)
}
fields.update(custom_fields)
return fields
class VideoGamePlatform(TimeStampedModel): class VideoGamePlatform(TimeStampedModel):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL) uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
@ -33,6 +70,19 @@ class VideoGamePlatform(TimeStampedModel):
) )
@dataclass
class VideoGameLogData(BaseLogData, LongPlayLogData, WithPeopleLogData):
platform_id: Optional[int] = None
emulated: Optional[bool] = False
emulator: Optional[str] = None
@property
def platform(self) -> VideoGamePlatform | None:
if not self.platform_id:
return
return VideoGamePlatform.objects.filter(id=self.platform_id).first()
class VideoGameCollection(TimeStampedModel): class VideoGameCollection(TimeStampedModel):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL) uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)

View File

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

View File

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

View File

@ -329,5 +329,21 @@
</div> </div>
{% block extra_js %}{% endblock %} {% block extra_js %}{% endblock %}
<script>
(() => {
'use strict'
const forms = document.querySelectorAll('.needs-validation')
Array.from(forms).forEach(form => {
form.addEventListener('submit', event => {
if (!form.checkValidity()) {
event.preventDefault()
event.stopPropagation()
}
form.classList.add('was-validated')
}, false)
})
})()
</script>
</body> </body>
</html> </html>

View File

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

View File

@ -42,7 +42,7 @@
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<p>{{object.scrobble_set.count}} scrobbles</p> <p>{{scrobbles.count}} scrobbles</p>
<p> <p>
<a href="{{object.start_url}}">Play again</a> <a href="{{object.start_url}}">Play again</a>
</p> </p>
@ -56,20 +56,40 @@
<tr> <tr>
<th scope="col">Date</th> <th scope="col">Date</th>
<th scope="col">Publisher</th> <th scope="col">Publisher</th>
<th scope="col">Screenshot</th> <th scope="col">Players</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for scrobble in object.scrobble_set.all|dictsortreversed:"timestamp" %} {% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
<tr> <tr>
<td>{{scrobble.local_timestamp}}</td> <td><a href={{scrobble.get_absolute_url}}>{{scrobble.local_timestamp}}</a></td>
<td>{{scrobble.media_obj.publisher}}</td> <td>{{scrobble.media_obj.publisher}}</td>
<td>{% if scrobble.screenshot%}<img src="{{scrobble.screenshot.url}}" width=250 />{% endif %}</td> <td>{% if scrobble.logdata.player_log %}{{scrobble.logdata.player_log}}{% else %}No data{% endif %}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
{% if is_paginated %}
<div class="pagination">
{% if page_obj.has_previous %}
<a href="?page={{ page_obj.previous_page_number }}">&laquo; Previous</a>
{% endif %}
{% for num in page_obj.paginator.page_range %}
{% if num == page_obj.number %}
<strong>{{ num }}</strong>
{% else %}
<a href="?page={{ num }}">{{ num }}</a>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}">Next &raquo;</a>
{% endif %}
</div>
{% endif %}
</div> </div>
{% endblock %} {% endblock %}

View File

@ -26,15 +26,7 @@
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<p>{{object.scrobble_set.count}} scrobbles</p> <p>{{scrobbles.count}} scrobbles</p>
<p>Read {{object.scrobble_set.last.book_pages_read}} pages{% if object.scrobble_set.last.long_play_complete %} and completed{% else %}{% endif %}</p>
<p>
{% if object.scrobble_set.last.long_play_complete == True %}
<a href="">Read again</a>
{% else %}
<a href="">Resume reading</a>
{% endif %}
</p>
</div> </div>
<div class="row"> <div class="row">
<div class="col-md"> <div class="col-md">
@ -50,9 +42,9 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for scrobble in object.scrobble_set.all|dictsortreversed:"timestamp" %} {% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
<tr> <tr>
<td>{{scrobble.local_timestamp}}</td> <td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td>
<td>{% if scrobble.long_play_complete == True %}Yes{% endif %}</td> <td>{% if scrobble.long_play_complete == True %}Yes{% endif %}</td>
<td>{% if scrobble.in_progress %}Now reading{% else %}{{scrobble.session_pages_read}}{% endif %}</td> <td>{% if scrobble.in_progress %}Now reading{% else %}{{scrobble.session_pages_read}}{% endif %}</td>
<td>{% for author in scrobble.book.authors.all %}<a href="{{author.get_absolute_url}}">{{author}}</a>{% if not forloop.last %}, {% endif %}{% endfor %}</td> <td>{% for author in scrobble.book.authors.all %}<a href="{{author.get_absolute_url}}">{{author}}</a>{% if not forloop.last %}, {% endif %}{% endfor %}</td>

View File

@ -48,7 +48,7 @@
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<p>{{object.scrobble_set.count}} scrobbles</p> <p>{{scrobbles.count}} scrobbles</p>
<div class="row"> <div class="row">
<div class="col-md"> <div class="col-md">
<h3>Last scrobbles</h3> <h3>Last scrobbles</h3>
@ -60,7 +60,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for scrobble in object.scrobble_set.all|dictsortreversed:"timestamp" %} {% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
<tr> <tr>
<td>{{scrobble.local_timestamp|naturaltime}}</td> <td>{{scrobble.local_timestamp|naturaltime}}</td>
</tr> </tr>

View File

@ -26,7 +26,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for scrobble in object.scrobble_set.all %} {% for scrobble in scrobbles.all %}
<tr> <tr>
<td>{{scrobble.local_timestamp}}</td> <td>{{scrobble.local_timestamp}}</td>
</tr> </tr>

View File

@ -9,7 +9,7 @@
{% endif %} {% endif %}
</div> </div>
<div class="row"> <div class="row">
<p>{{object.scrobble_set.count}} scrobbles</p> <p>{{scrobbles.count}} scrobbles</p>
{% if charts %} {% if charts %}
<p>{% for chart in charts %}<em><a href="{{chart.link}}">{{chart}}</a></em>{% if forloop.last %}{% else %} | {% endif %}{% endfor %}</p> <p>{% for chart in charts %}<em><a href="{{chart.link}}">{{chart}}</a></em>{% if forloop.last %}{% else %} | {% endif %}{% endfor %}</p>
{% endif %} {% endif %}
@ -26,7 +26,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for scrobble in object.scrobble_set.all %} {% for scrobble in scrobbles.all %}
<tr> <tr>
<td>{{scrobble.local_timestamp}}</td> <td>{{scrobble.local_timestamp}}</td>
<td><a href="{{scrobble.track.get_absolute_url}}">{{scrobble.track.title}}</a></td> <td><a href="{{scrobble.track.get_absolute_url}}">{{scrobble.track.title}}</a></td>

View File

@ -0,0 +1,68 @@
{% extends "base_list.html" %}
{% load mathfilters %}
{% load static %}
{% load naturalduration %}
{% block title %}{{object.title}}{% endblock %}
{% block head_extra %}
<style>
.cover img {
width: 250px;
}
.cover {
float: left;
width: 252px;
padding: 0;
}
.summary {
float: left;
width: 600px;
margin-left: 10px;
}
</style>
{% endblock %}
{% block lists %}
<div class="row">
<div class="summary">
{% if object.description%}
<p>{{object.description|safe|linebreaks|truncatewords:160}}</p>
<hr />
{% endif %}
<p style="float:right;">
<a href="{{object.untappd_link}}"><img src="{% static "images/untappd-logo.png" %}" width=35></a>
</p>
</div>
</div>
<div class="row">
<p>{{scrobbles.count}} scrobbles</p>
<p>
<a href="{{object.start_url}}">Drink again</a>
</p>
</div>
<div class="row">
<div class="col-md">
<h3>Last scrobbles</h3>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Date</th>
</tr>
</thead>
<tbody>
{% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
<tr>
<td>{{scrobble.local_timestamp}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,23 @@
{% extends "base_list.html" %}
{% block title %}Puzzles{% endblock %}
{% block head_extra %}
<style>
dl { width: 210px; float:left; margin-right: 10px; }
dt a { color:white; text-decoration: none; font-size:smaller; }
img { height:200px; width: 200px; object-fit: cover; }
dd .right { float:right; }
</style>
{% endblock %}
{% block lists %}
<div class="row">
<div class="col-md">
<div class="table-responsive">
{% include "_scrobblable_list.html" %}
</div>
</div>
</div>
{% endblock %}

View File

@ -79,6 +79,13 @@
{% endwith %} {% endwith %}
{% endif %} {% endif %}
{% if Puzzle %}
<h4>Puzzles</h4>
{% with scrobbles=Puzzle count=Puzzle_count time=Puzzle_time %}
{% include "scrobbles/_scrobble_table.html" %}
{% endwith %}
{% endif %}
{% if Book %} {% if Book %}
<h4>Books</h4> <h4>Books</h4>
{% with scrobbles=Book count=Book_count time=Book_time %} {% with scrobbles=Book count=Book_count time=Book_time %}

View File

@ -0,0 +1,38 @@
{% extends "base_list.html" %}
{% load form_tags %}
{% load mathfilters %}
{% load static %}
{% block title %}{{object.name}}{% endblock %}
{% block lists %}
<div class="row">
<h1>{{ object.media_obj }} - {{object.media_type}}</h1>
<!-- Your existing detail page content -->
<p>Rate: {{object.logdata.avg_seconds_per_page}}s per page</p>
<h2>Edit Log</h2>
<form method="post" class="needs-validation" novalidate>
{% csrf_token %}
{% for field in log_form %}
<div class="mb-3">
<label for="{{ field.id_for_label }}" class="form-label">{{ field.label }}</label>
{{ field|add_class:"form-control" }}
{% if field.help_text %}
<div class="form-text">{{ field.help_text }}</div>
{% endif %}
{% for error in field.errors %}
<div class="invalid-feedback d-block">{{ error }}</div>
{% endfor %}
</div>
{% endfor %}
<button type="submit" class="btn btn-primary">Save</button>
</form>
</div>
{% endblock %}

View File

@ -18,7 +18,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for scrobble in object.scrobble_set.all %} {% for scrobble in scrobbles.all %}
<tr> <tr>
<td>{{scrobble.local_timestamp}}</td> <td>{{scrobble.local_timestamp}}</td>
<td>{{scrobble.media_obj.round.season.name}}</td> <td>{{scrobble.media_obj.round.season.name}}</td>

View File

@ -22,6 +22,7 @@
width: 600px; width: 600px;
margin-left: 10px; margin-left: 10px;
} }
.pagination a { padding: 0 5px 0 5px; }
</style> </style>
{% endblock %} {% endblock %}
@ -39,7 +40,7 @@
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<p>{{object.scrobble_set.count}} scrobbles</p> <p>{{scrobbles.count}} scrobbles</p>
<p> <p>
<a href="{{object.start_url}}">Play again</a> <a href="{{object.start_url}}">Play again</a>
</p> </p>
@ -47,27 +48,49 @@
<div class="row"> <div class="row">
<div class="col-md"> <div class="col-md">
<h3>Last scrobbles</h3> <h3>Last scrobbles</h3>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-striped table-sm"> <table class="table table-striped table-sm">
<thead> <thead>
<tr> <tr>
<th scope="col">Date</th> <th scope="col">Date</th>
<th scope="col">Description</th> <th scope="col">Description</th>
<th scope="col">Notes</th>
<th scope="col">Source</th> <th scope="col">Source</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for scrobble in object.scrobble_set.all|dictsortreversed:"timestamp" %} {% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
<tr> <tr>
<td>{{scrobble.local_timestamp}}</td> <td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td>
<td><a href="{{scrobble.get_media_source_url}}">{{scrobble.logdata.description}}</a></td> <td><a href="{{scrobble.get_media_source_url}}">{{scrobble.logdata.description}}</a></td>
<td>{{scrobble.logdata.notes_as_str|safe}}</td>
<td>{{scrobble.source}}</td> <td>{{scrobble.source}}</td>
<td>{{scrobble.log.notes}}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
{% if is_paginated %}
<div class="pagination">
{% if page_obj.has_previous %}
<a href="?page={{ page_obj.previous_page_number }}">&laquo; Previous</a>
{% endif %}
{% for num in page_obj.paginator.page_range %}
{% if num == page_obj.number %}
<strong>{{ num }}</strong>
{% else %}
<a href="?page={{ num }}">{{ num }}</a>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}">Next &raquo;</a>
{% endif %}
</div>
{% endif %}
</div> </div>
{% endblock %} {% endblock %}

View File

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

View File

@ -85,7 +85,7 @@ dd {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for scrobble in object.scrobble_set.all %} {% for scrobble in scrobbles.all %}
<tr> <tr>
<td>{{scrobble.local_timestamp}}</td> <td>{{scrobble.local_timestamp}}</td>
</tr> </tr>

View File

@ -51,7 +51,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for scrobble in object.scrobble_set.all %} {% for scrobble in scrobbles.all %}
<tr> <tr>
<td>{{scrobble.local_timestamp}}</td> <td>{{scrobble.local_timestamp}}</td>
</tr> </tr>

View File

@ -40,11 +40,13 @@ from vrobbler.apps.profiles import urls as profiles_urls
from vrobbler.apps.trails import urls as trails_urls from vrobbler.apps.trails import urls as trails_urls
from vrobbler.apps.beers import urls as beers_urls from vrobbler.apps.beers import urls as beers_urls
from vrobbler.apps.foods import urls as foods_urls from vrobbler.apps.foods import urls as foods_urls
from vrobbler.apps.puzzles import urls as puzzles_urls
from vrobbler.apps.videogames import urls as videogame_urls from vrobbler.apps.videogames import urls as videogame_urls
from vrobbler.apps.videos import urls as video_urls from vrobbler.apps.videos import urls as video_urls
from vrobbler.apps.videos.api.views import SeriesViewSet, VideoViewSet from vrobbler.apps.videos.api.views import SeriesViewSet, VideoViewSet
from vrobbler.apps.webpages import urls as webpages_urls from vrobbler.apps.webpages import urls as webpages_urls
#from vrobbler.apps.modern_ui import urls as modern_ui_urls
# from vrobbler.apps.modern_ui import urls as modern_ui_urls
router = routers.DefaultRouter() router = routers.DefaultRouter()
router.register(r"scrobbles", ScrobbleViewSet) router.register(r"scrobbles", ScrobbleViewSet)
@ -73,7 +75,7 @@ urlpatterns = [
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("accounts/", include("allauth.urls")), path("accounts/", include("allauth.urls")),
path("o/", include(oauth2_urls)), path("o/", include(oauth2_urls)),
#path("modern_ui/", include(modern_ui_urls, namespace="modern_ui")), # path("modern_ui/", include(modern_ui_urls, namespace="modern_ui")),
path("", include(music_urls, namespace="music")), path("", include(music_urls, namespace="music")),
path("", include(book_urls, namespace="books")), path("", include(book_urls, namespace="books")),
path("", include(video_urls, namespace="videos")), path("", include(video_urls, namespace="videos")),
@ -85,6 +87,7 @@ urlpatterns = [
path("", include(trails_urls, namespace="trails")), path("", include(trails_urls, namespace="trails")),
path("", include(beers_urls, namespace="beers")), path("", include(beers_urls, namespace="beers")),
path("", include(foods_urls, namespace="foods")), path("", include(foods_urls, namespace="foods")),
path("", include(puzzles_urls, namespace="puzzles")),
path("", include(tasks_urls, namespace="tasks")), path("", include(tasks_urls, namespace="tasks")),
path("", include(webpages_urls, namespace="webpages")), path("", include(webpages_urls, namespace="webpages")),
path("", include(podcast_urls, namespace="podcasts")), path("", include(podcast_urls, namespace="podcasts")),