Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4bf22c96e9 | |||
| dec7a79509 | |||
| 371e1d654c |
25
PROJECT.org
25
PROJECT.org
@ -93,11 +93,7 @@ fetching and simple saving.
|
||||
:LOGBOOK:
|
||||
CLOCK: [2025-07-09 Wed 09:55]--[2025-07-09 Wed 10:15] => 0:20
|
||||
:END:
|
||||
* Backlog [0/13] :vrobbler:project:personal:
|
||||
** TODO [#C] Add sentiment parsing for Scrobbles with notes :vrobbler:project:scrobbles:sentiment:
|
||||
:PROPERTIES:
|
||||
:ID: 37781d6a-f3b0-48b2-bf98-33c2c791cf85
|
||||
:END:
|
||||
* Backlog [0/12] :vrobbler:project:personal:
|
||||
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :vrobbler:personal:bug:music:scrobbles:
|
||||
:PROPERTIES:
|
||||
:ID: 702462cf-d54b-48c6-8a7c-78b8de751deb
|
||||
@ -486,7 +482,7 @@ whatever time KoReader reports, we need to know, given the date and the user
|
||||
profile's historic timezone, how many hours to adjust the KoReader time to get
|
||||
to GMT to save it in the database.
|
||||
|
||||
** TODO [#A] Make IMAP and WebDAV configurable :webdav:feature:imap:importers:
|
||||
** TODO [#B] Make IMAP and WebDAV configurable :webdav:feature:imap:importers:
|
||||
:PROPERTIES:
|
||||
:ID: b1426d92-2feb-4d15-9738-d5b7b0594f96
|
||||
:END:
|
||||
@ -509,6 +505,23 @@ needed import celery task. This is how the WebDAV celery task currently works.
|
||||
This would also be an opporunity to clean up the code around WebDAV imports
|
||||
and make them more re-usable for other import services.
|
||||
|
||||
* Version 46.0 [1/1]
|
||||
** DONE [#C] Add sentiment parsing for Scrobbles with notes :scrobbles:sentiment:
|
||||
:PROPERTIES:
|
||||
:ID: 37781d6a-f3b0-48b2-bf98-33c2c791cf85
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Not sure how useful this would be, but I wonder if we can add a `sentiment` JSONField
|
||||
on each scrobble that can store the output of VADER over the notes in a scrobble with notes.
|
||||
|
||||
I'm not sure that the value prop here is worth the storage and processing time.
|
||||
|
||||
But if we do add it, it should be a process that scans for scrobbles with both notes and no
|
||||
sentiment field value (unless --overwrite is used) and just run periodically.
|
||||
|
||||
|
||||
* Version 45.1 [1/1]
|
||||
** DONE [#B] Mopidy favorites or monthly playlist adds should look at all scrobbles :bug:mopidy:favorites:tracks:
|
||||
:PROPERTIES:
|
||||
|
||||
2
justfile
2
justfile
@ -21,5 +21,5 @@ push:
|
||||
|
||||
release kind="minor":
|
||||
poetry run python scripts/release.py {{kind}}
|
||||
@push
|
||||
just push
|
||||
|
||||
|
||||
17
poetry.lock
generated
17
poetry.lock
generated
@ -5439,6 +5439,21 @@ brotli = ["brotli (==1.0.9) ; os_name != \"nt\" and python_version < \"3\" and p
|
||||
secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress ; python_version == \"2.7\"", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"]
|
||||
socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "vadersentiment"
|
||||
version = "3.3.2"
|
||||
description = "VADER Sentiment Analysis. VADER (Valence Aware Dictionary and sEntiment Reasoner) is a lexicon and rule-based sentiment analysis tool that is specifically attuned to sentiments expressed in social media, and works well on texts from other domains."
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "vaderSentiment-3.3.2-py2.py3-none-any.whl", hash = "sha256:3bf1d243b98b1afad575b9f22bc2cb1e212b94ff89ca74f8a23a588d024ea311"},
|
||||
{file = "vaderSentiment-3.3.2.tar.gz", hash = "sha256:5d7c06e027fc8b99238edb0d53d970cf97066ef97654009890b83703849632f9"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
requests = "*"
|
||||
|
||||
[[package]]
|
||||
name = "vine"
|
||||
version = "5.1.0"
|
||||
@ -6005,4 +6020,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.11,<3.15"
|
||||
content-hash = "bd3f14a9cfce403db426af98774f1e3c41b97283aa43f4bd80f84594ee0dd726"
|
||||
content-hash = "78ba52d0e6ea492efceb14fcd42ace25abfb66d42c3aff28f2fe1a31a9aa03b5"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "vrobbler"
|
||||
version = "45.1"
|
||||
version = "46.0"
|
||||
description = ""
|
||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||
|
||||
@ -62,6 +62,7 @@ recipe-scrapers = "^15.11.0"
|
||||
gpxpy = "^1.6.2"
|
||||
fitparse = "^1.2.0"
|
||||
lxml = ">=5.5.0"
|
||||
vaderSentiment = "^3.3.2"
|
||||
|
||||
[tool.poetry.group.test]
|
||||
optional = true
|
||||
|
||||
@ -124,7 +124,7 @@ def main():
|
||||
|
||||
if not done_items:
|
||||
print("No DONE items found in Backlog — nothing to release.")
|
||||
sys.exit(0)
|
||||
sys.exit(1)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 4. Build the new Version section text
|
||||
|
||||
@ -0,0 +1,68 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import models
|
||||
|
||||
from scrobbles.models import Scrobble
|
||||
from scrobbles.utils import analyze_scrobble_sentiment
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Backfill VADER sentiment analysis for scrobbles with notes"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--commit",
|
||||
action="store_true",
|
||||
help="Actually update scrobble logs with sentiment data",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--overwrite",
|
||||
action="store_true",
|
||||
help="Re-analyze scrobbles that already have sentiment data",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
commit = options["commit"]
|
||||
overwrite = options["overwrite"]
|
||||
|
||||
qs = Scrobble.objects.filter(
|
||||
~models.Q(log__notes__isnull=True)
|
||||
& ~models.Q(log__notes=[])
|
||||
& ~models.Q(log__notes={})
|
||||
)
|
||||
if not overwrite:
|
||||
qs = qs.filter(log__sentiment__isnull=True)
|
||||
|
||||
total = qs.count()
|
||||
analyzed_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
self.stdout.write(f"Found {total} scrobbles to process")
|
||||
|
||||
for scrobble in qs.iterator():
|
||||
if commit:
|
||||
analyzed = analyze_scrobble_sentiment(scrobble, overwrite=overwrite)
|
||||
else:
|
||||
notes_str = ""
|
||||
if scrobble.logdata:
|
||||
notes_str = scrobble.logdata.notes_as_str()
|
||||
analyzed = bool(notes_str)
|
||||
|
||||
if analyzed:
|
||||
analyzed_count += 1
|
||||
if commit:
|
||||
scores = (scrobble.log or {}).get("sentiment", {})
|
||||
self.stdout.write(
|
||||
f" Updated scrobble {scrobble.id}: compound={scores.get('compound', 'N/A')}"
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
f" [DRY RUN] Would analyze scrobble {scrobble.id}"
|
||||
)
|
||||
else:
|
||||
skipped_count += 1
|
||||
|
||||
self.stdout.write(
|
||||
f"\nAnalyzed {analyzed_count} scrobbles, skipped {skipped_count}"
|
||||
)
|
||||
if not commit:
|
||||
self.stdout.write("Run with --commit to persist changes")
|
||||
@ -882,8 +882,14 @@ class Scrobble(TimeStampedModel):
|
||||
logger.warning("Log data could not be loaded", e)
|
||||
return logdata_cls()
|
||||
|
||||
# Strip log-only keys (stored in JSONField but not part of LogData dataclass)
|
||||
logdata_kwargs = {
|
||||
k: v for k, v in log_dict.items()
|
||||
if k in logdata_cls.__dataclass_fields__
|
||||
}
|
||||
|
||||
try:
|
||||
return logdata_cls(**log_dict)
|
||||
return logdata_cls(**logdata_kwargs)
|
||||
except ParseError as e:
|
||||
logger.warning(
|
||||
"Could not parse log data",
|
||||
|
||||
@ -12,6 +12,7 @@ from charts.utils import (
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -544,6 +545,30 @@ def send_mood_checkin():
|
||||
send_mood_checkin_reminders()
|
||||
|
||||
|
||||
@shared_task
|
||||
def backfill_scrobble_sentiment():
|
||||
"""Backfill VADER sentiment for scrobbles with notes (replaces @hourly cron)."""
|
||||
from scrobbles.models import Scrobble
|
||||
from scrobbles.utils import analyze_scrobble_sentiment
|
||||
|
||||
qs = Scrobble.objects.filter(
|
||||
models.Q(log__notes__isnull=False)
|
||||
& ~models.Q(log__notes=[])
|
||||
& ~models.Q(log__notes={})
|
||||
& models.Q(log__sentiment__isnull=True)
|
||||
)
|
||||
|
||||
count = 0
|
||||
for scrobble in qs.iterator():
|
||||
if analyze_scrobble_sentiment(scrobble):
|
||||
count += 1
|
||||
|
||||
logger.info(
|
||||
"Backfilled sentiment for %d scrobbles",
|
||||
count,
|
||||
)
|
||||
|
||||
|
||||
@shared_task
|
||||
def add_favorite_to_mopidy_playlist(favorite_id):
|
||||
from scrobbles.models import FavoriteMedia
|
||||
|
||||
@ -802,3 +802,32 @@ def tokenize_title_to_tags(title: str) -> list[str]:
|
||||
w.lower() for w in cleaned.split() if w.lower() not in STOPWORDS and len(w) > 2
|
||||
]
|
||||
return words
|
||||
|
||||
|
||||
def analyze_scrobble_sentiment(scrobble, overwrite=False) -> bool:
|
||||
"""Run VADER sentiment analysis on a scrobble's notes.
|
||||
|
||||
Stores result in log["sentiment"] as a dict with keys:
|
||||
neg, neu, pos, compound.
|
||||
|
||||
Returns True if analyzed, False if skipped (no notes or already done).
|
||||
"""
|
||||
from vaderSentiment.vaderSentiment import SentimentIntensityAnalyzer
|
||||
|
||||
log = scrobble.log or {}
|
||||
if not overwrite and log.get("sentiment") is not None:
|
||||
return False
|
||||
|
||||
notes_str = ""
|
||||
if scrobble.logdata:
|
||||
notes_str = scrobble.logdata.notes_as_str()
|
||||
if not notes_str:
|
||||
return False
|
||||
|
||||
analyzer = SentimentIntensityAnalyzer()
|
||||
scores = analyzer.polarity_scores(notes_str)
|
||||
|
||||
log["sentiment"] = scores
|
||||
scrobble.log = log
|
||||
scrobble.save(update_fields=["log"])
|
||||
return True
|
||||
|
||||
@ -163,6 +163,10 @@ CELERY_BEAT_SCHEDULE = {
|
||||
"task": "scrobbles.tasks.send_mood_checkin",
|
||||
"schedule": crontab(hour="*/4", minute=0),
|
||||
},
|
||||
"backfill-scrobble-sentiment": {
|
||||
"task": "scrobbles.tasks.backfill_scrobble_sentiment",
|
||||
"schedule": crontab(minute="0"),
|
||||
},
|
||||
}
|
||||
|
||||
INSTALLED_APPS = [
|
||||
|
||||
@ -154,6 +154,20 @@
|
||||
{% if notes_html %}
|
||||
<div class="mb-3">
|
||||
<h4>Notes</h4>
|
||||
<span class="badge fs-8
|
||||
{% if sentiment.compound >= 0.5 %}bg-success
|
||||
{% elif sentiment.compound >= 0.05 %}bg-info text-dark
|
||||
{% elif sentiment.compound > -0.05 %}bg-secondary
|
||||
{% elif sentiment.compound > -0.5 %}bg-warning text-dark
|
||||
{% else %}bg-danger
|
||||
{% endif %}">
|
||||
{% if sentiment.compound >= 0.5 %}Positive
|
||||
{% elif sentiment.compound >= 0.05 %}Slightly positive
|
||||
{% elif sentiment.compound > -0.05 %}Neutral
|
||||
{% elif sentiment.compound > -0.5 %}Slightly negative
|
||||
{% else %}Negative
|
||||
{% endif %}
|
||||
</span>
|
||||
<div class="notes-list">
|
||||
{{ notes_html|safe }}
|
||||
</div>
|
||||
@ -161,6 +175,13 @@
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% with sentiment=object.log.sentiment %}
|
||||
{% if sentiment %}
|
||||
<div class="mb-3">
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% if object.logdata.avg_seconds_per_page %}
|
||||
<p>Rate: {{object.logdata.avg_seconds_per_page}}s per page</p>
|
||||
{% endif %}
|
||||
|
||||
Reference in New Issue
Block a user