Compare commits

..

3 Commits
45.1 ... 46.0

Author SHA1 Message Date
4bf22c96e9 [release] Bump to version 46.0
All checks were successful
build / test (push) Successful in 1m57s
deploy / test (push) Successful in 2m9s
deploy / build-and-deploy (push) Successful in 43s
- Add sentiment parsing for Scrobbles with notes
2026-06-05 19:42:21 -04:00
dec7a79509 [scrobbles] Add basic sentiment analysis
All checks were successful
build / test (push) Successful in 2m4s
2026-06-05 19:35:45 -04:00
371e1d654c [tooling] Release in one step
All checks were successful
build / test (push) Successful in 1m57s
2026-06-05 14:58:35 -04:00
11 changed files with 193 additions and 11 deletions

View File

@ -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:

View File

@ -21,5 +21,5 @@ push:
release kind="minor":
poetry run python scripts/release.py {{kind}}
@push
just push

17
poetry.lock generated
View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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")

View File

@ -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",

View File

@ -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

View File

@ -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

View File

@ -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 = [

View File

@ -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 %}