Compare commits

..

4 Commits
48.3 ... 49.1

Author SHA1 Message Date
15d27f6d94 [release] Bump to version 49.1
All checks were successful
build / test (push) Successful in 2m7s
deploy / test (push) Successful in 2m3s
deploy / build-and-deploy (push) Successful in 33s
- Fix bug with missing default visbility for scrobbles
2026-06-09 13:16:53 -04:00
c8292d1c06 [scrobbles] Back fill visibility field
Some checks failed
build / test (push) Has been cancelled
2026-06-09 13:15:57 -04:00
68f821fce1 [release] Bump to version 49.0
Some checks failed
build / test (push) Successful in 1m59s
deploy / test (push) Successful in 2m9s
deploy / build-and-deploy (push) Failing after 2m20s
- Fix broken tests with new sharing and add tests
2026-06-09 12:48:22 -04:00
ed2ed59f65 [scrobbles] Fix tests around visbility 2026-06-09 12:48:01 -04:00
9 changed files with 412 additions and 3 deletions

View File

@ -88,7 +88,7 @@ fetching and simple saving.
*** Metadata sources
**** Scraper
* Backlog [0/14] :vrobbler:project:personal:
* Backlog [0/15] :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
@ -534,7 +534,96 @@ async with the POST data stored in the log["raw_data"] and used by the celery en
to go try to enrich the media instance. Should this enrichment fail, tag the scrobble as "enrichment-failed"
log a warning and move on.
** TODO [#A] Allow updating all a user's scrobble visibility at once :scrobbles:sharing:feature:
:PROPERTIES:
:ID: 9ed2ec65-bf69-4300-965c-6a7d3ef7ea03
:END:
*** Description
We now have the ability to share or unshare scrobbles and create private links. We should add a toggle
in the user's settings that will bulk make all their scrobbles public or private, so that a user
can either share everything, or lock their account down.
This should not affect scrobbles that are in the "Shared" visibility state.
Additionally, users's should have links in their settings to see what scrobbles
are either public, shared or private. Probably this could be done with a
?visbility=<> filter on the /scrobbles/ page.
* Version 49.1 [1/1]
** DONE [#A] Fix bug with missing default visbility for scrobbles :bug:scrobbles:sharing:
:PROPERTIES:
:ID: 20843992-6453-9a9a-cde6-2c2b6677db23
:END:
*** Description
We can't scrobble anything now because visbility is not null, but has no default
value.
*** Notes
- Note taken on [2026-06-09 Tue 13:14]
The full stack trace:
#+begin_src sh
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/scrobbles/models.py", line 1430, in create_or_update
elif "log" in scrobble_data.keys() and scrobble.log:
^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/scrobbles/models.py", line 1583, in create
)
File "/usr/local/lib/python3.11/site-packages/django/db/models/manager.py", line 87, in manager_method
return getattr(self.get_queryset(), name)(*args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/django/db/models/query.py", line 658, in create
obj.save(force_insert=True, using=self.db)
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/scrobbles/models.py", line 870, in save
if self.media_obj:
^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/django_extensions/db/models.py", line 22, in save
super().save(**kwargs)
File "/usr/local/lib/python3.11/site-packages/django/db/models/base.py", line 814, in save
self.save_base(
File "/usr/local/lib/python3.11/site-packages/django/db/models/base.py", line 877, in save_base
updated = self._save_table(
^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/django/db/models/base.py", line 1020, in _save_table
results = self._do_insert(
^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/django/db/models/base.py", line 1061, in _do_insert
return manager._insert(
^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/django/db/models/manager.py", line 87, in manager_method
return getattr(self.get_queryset(), name)(*args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/django/db/models/query.py", line 1805, in _insert
return query.get_compiler(using=using).execute_sql(returning_fields)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/django/db/models/sql/compiler.py", line 1822, in execute_sql
cursor.execute(sql, params)
File "/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py", line 67, in execute
return self._execute_with_wrappers(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py", line 80, in _execute_with_wrappers
return executor(sql, params, many, context)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py", line 84, in _execute
with self.db.wrap_database_errors:
File "/usr/local/lib/python3.11/site-packages/django/db/utils.py", line 91, in __exit__
raise dj_exc_value.with_traceback(traceback) from exc_value
File "/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py", line 89, in _execute
return self.cursor.execute(sql, params)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
django.db.utils.IntegrityError: null value in column "visibility" of relation "scrobbles_scrobble" violates not-null constraint
DETAIL: Failing row contains (373813, 2026-06-09 17:13:38.11355+00, 2026-06-09 17:13:38.113566+00, 2026-06-09 17:13:36+00, 0, f, f, Todoist, 1, null, t, {"title": "Animal chores", "labels": ["chore", "farm"], "todoist..., null, null, null, 68680dbf-f9a9-476c-b1c7-adbd231bbab6, null, null, null, null, , null, Task, null, null, null, America/New_York, null, null, , null, null, , 72, null, null, null, null, null, null, null, null, null, null).
#+end_src
* Version 49.0 [1/1]
** DONE [#A] Fix broken tests with new sharing and add tests :scrobbles:sharing:tests:
:PROPERTIES:
:ID: 10ecd169-eaee-8554-d4ee-f1d34bfad99f
:END:
* Version 48.3 [1/1]
** DONE [#A] Fix bug in missing sqids dep :dependencies:project:
:PROPERTIES:

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "vrobbler"
version = "48.3"
version = "49.1"
description = ""
authors = ["Colin Powell <colin@unbl.ink>"]

View File

@ -8,7 +8,8 @@ from django.urls import reverse
from django.utils import timezone
from music.models import Album, Artist, Track
from podcasts.models import PodcastEpisode
from scrobbles.models import Scrobble
from scrobbles.models import Scrobble, ShareViewLog
from scrobbles.sqids import encode_scrobble_share
from tasks.models import Task
@ -512,6 +513,7 @@ def test_scrobble_detail_view_with_notes_as_flat_list(client):
task=task,
media_type="Task",
user=user,
visibility="public",
log={
"notes": ["First note", "Second note"],
"description": "Test description",
@ -534,6 +536,7 @@ def test_scrobble_detail_view_with_notes_as_dict_timestamps(client):
task=task,
media_type="Task",
user=user,
visibility="public",
log={
"notes": [
{"2024-01-01 10:00:00": "Note at first timestamp"},
@ -562,6 +565,7 @@ def test_scrobble_detail_view_with_notes_and_labels(client):
task=task,
media_type="Task",
user=user,
visibility="public",
log={
"notes": [
{"2024-01-01 10:00:00": "Note with label"},
@ -739,3 +743,293 @@ def test_gps_webhook_creates_location(client, valid_auth_token):
)
assert response.status_code == 200
assert "scrobble_id" in response.data
@pytest.mark.django_db
def test_share_view_shared_visibility(client):
user = get_user_model().objects.create_user(
username="shareuser", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=user, visibility="shared",
timestamp=timezone.now(),
)
url = reverse(
"scrobbles:shared-detail",
kwargs={"sqid": encode_scrobble_share(scrobble.id, scrobble.share_token_version)},
)
response = client.get(url)
assert response.status_code == 200
@pytest.mark.django_db
def test_share_view_public_visibility(client):
user = get_user_model().objects.create_user(
username="shareuser2", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=user, visibility="public",
timestamp=timezone.now(),
)
url = reverse(
"scrobbles:shared-detail",
kwargs={"sqid": encode_scrobble_share(scrobble.id, scrobble.share_token_version)},
)
response = client.get(url)
assert response.status_code == 200
@pytest.mark.django_db
def test_share_view_private_visibility_returns_404(client):
user = get_user_model().objects.create_user(
username="shareuser3", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=user, visibility="private",
timestamp=timezone.now(),
)
url = reverse(
"scrobbles:shared-detail",
kwargs={"sqid": encode_scrobble_share(scrobble.id, scrobble.share_token_version)},
)
response = client.get(url)
assert response.status_code == 404
@pytest.mark.django_db
def test_share_view_invalid_sqid_returns_404(client):
url = reverse("scrobbles:shared-detail", kwargs={"sqid": "InvalidSqid123"})
response = client.get(url)
assert response.status_code == 404
@pytest.mark.django_db
def test_share_view_expired_token_returns_404(client):
user = get_user_model().objects.create_user(
username="shareuser4", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=user, visibility="shared",
timestamp=timezone.now(),
)
old_sqid = encode_scrobble_share(scrobble.id, scrobble.share_token_version)
scrobble.regenerate_share_token()
url = reverse("scrobbles:shared-detail", kwargs={"sqid": old_sqid})
response = client.get(url)
assert response.status_code == 404
@pytest.mark.django_db
def test_share_view_increments_count_and_logs_view(client):
user = get_user_model().objects.create_user(
username="shareuser5", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=user, visibility="shared",
timestamp=timezone.now(),
)
assert scrobble.share_view_count == 0
url = reverse(
"scrobbles:shared-detail",
kwargs={"sqid": encode_scrobble_share(scrobble.id, scrobble.share_token_version)},
)
response = client.get(url)
assert response.status_code == 200
scrobble.refresh_from_db()
assert scrobble.share_view_count == 1
assert ShareViewLog.objects.filter(scrobble=scrobble).count() == 1
log_entry = ShareViewLog.objects.filter(scrobble=scrobble).first()
assert log_entry.ip_address == "127.0.0.1"
assert log_entry.user_agent == ""
assert log_entry.referrer == ""
@pytest.mark.django_db
def test_explore_view_shows_only_public_scrobbles(client):
user = get_user_model().objects.create_user(
username="exploreuser", password="testpass"
)
public_task = Task.objects.create(title="Public Task Title")
shared_task = Task.objects.create(title="Shared Task Title")
private_task = Task.objects.create(title="Private Task Title")
ts = timezone.now()
public_scrobble = Scrobble.objects.create(
task=public_task, media_type="Task", user=user, visibility="public",
timestamp=ts,
)
Scrobble.objects.create(
task=shared_task, media_type="Task", user=user, visibility="shared",
timestamp=ts,
)
Scrobble.objects.create(
task=private_task, media_type="Task", user=user, visibility="private",
timestamp=ts,
)
url = reverse("scrobbles:explore")
response = client.get(url)
assert response.status_code == 200
content = response.content.decode()
assert "Public Task Title" in content
assert "Shared Task Title" not in content
assert "Private Task Title" not in content
@pytest.mark.django_db
def test_change_visibility_owner_can_change(client):
user = get_user_model().objects.create_user(
username="visuser", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=user, visibility="private",
timestamp=timezone.now(),
)
client.force_login(user)
url = reverse("scrobbles:change-visibility", kwargs={"uuid": scrobble.uuid})
response = client.post(url, {"visibility": "shared"})
assert response.status_code == 302
scrobble.refresh_from_db()
assert scrobble.visibility == "shared"
@pytest.mark.django_db
def test_change_visibility_non_owner_gets_404(client):
owner = get_user_model().objects.create_user(
username="owner", password="testpass"
)
other = get_user_model().objects.create_user(
username="other", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=owner, visibility="private",
timestamp=timezone.now(),
)
client.force_login(other)
url = reverse("scrobbles:change-visibility", kwargs={"uuid": scrobble.uuid})
response = client.post(url, {"visibility": "shared"})
assert response.status_code == 404
scrobble.refresh_from_db()
assert scrobble.visibility == "private"
@pytest.mark.django_db
def test_change_visibility_anonymous_redirects_to_login(client):
user = get_user_model().objects.create_user(
username="anontest", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=user, visibility="private",
timestamp=timezone.now(),
)
url = reverse("scrobbles:change-visibility", kwargs={"uuid": scrobble.uuid})
response = client.post(url, {"visibility": "shared"})
assert response.status_code == 302
assert "/login/" in response.url
@pytest.mark.django_db
def test_regenerate_share_token_invalidates_old_sqid(client):
user = get_user_model().objects.create_user(
username="regentest", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=user, visibility="shared",
timestamp=timezone.now(),
)
old_sqid = encode_scrobble_share(scrobble.id, scrobble.share_token_version)
client.force_login(user)
url = reverse("scrobbles:regenerate-share-token", kwargs={"uuid": scrobble.uuid})
response = client.post(url)
assert response.status_code == 302
scrobble.refresh_from_db()
assert scrobble.share_token_version == 1
old_url = reverse("scrobbles:shared-detail", kwargs={"sqid": old_sqid})
old_response = client.get(old_url)
assert old_response.status_code == 404
new_sqid = encode_scrobble_share(scrobble.id, scrobble.share_token_version)
new_url = reverse("scrobbles:shared-detail", kwargs={"sqid": new_sqid})
new_response = client.get(new_url)
assert new_response.status_code == 200
@pytest.mark.django_db
def test_share_analytics_owner_can_view(client):
user = get_user_model().objects.create_user(
username="analyticsuser", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=user, visibility="shared",
timestamp=timezone.now(),
)
client.force_login(user)
url = reverse("scrobbles:share-analytics", kwargs={"uuid": scrobble.uuid})
response = client.get(url)
assert response.status_code == 200
@pytest.mark.django_db
def test_share_analytics_non_owner_gets_404(client):
owner = get_user_model().objects.create_user(
username="analyticsowner", password="testpass"
)
other = get_user_model().objects.create_user(
username="analyticsother", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=owner, visibility="shared",
timestamp=timezone.now(),
)
client.force_login(other)
url = reverse("scrobbles:share-analytics", kwargs={"uuid": scrobble.uuid})
response = client.get(url)
assert response.status_code == 404
@pytest.mark.django_db
def test_share_analytics_shows_view_logs(client):
user = get_user_model().objects.create_user(
username="analyticsviews", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=user, visibility="shared",
timestamp=timezone.now(),
)
sqid = encode_scrobble_share(scrobble.id, scrobble.share_token_version)
share_url = reverse("scrobbles:shared-detail", kwargs={"sqid": sqid})
client.get(share_url)
client.get(share_url)
client.force_login(user)
url = reverse("scrobbles:share-analytics", kwargs={"uuid": scrobble.uuid})
response = client.get(url)
assert response.status_code == 200
content = response.content.decode()
assert "127.0.0.1" in content
assert ShareViewLog.objects.filter(scrobble=scrobble).count() == 2

View File

@ -76,6 +76,7 @@ class LastFM:
in_progress=False,
media_type=Scrobble.MediaType.TRACK,
timezone=tz_timestamp.tzinfo.name,
visibility="private",
)
# Vrobbler scrobbles on finish, LastFM scrobbles on start
seconds_eariler = timestamp - timedelta(seconds=20)

View File

@ -111,6 +111,7 @@ def import_scale_csv(file_path, user_id):
played_to_completion=True,
in_progress=False,
media_type=Scrobble.MediaType.TASK,
visibility="private",
)
new_scrobbles.append(new_scrobble)

View File

@ -305,6 +305,7 @@ def import_trail_gpx(file_path, user_id, original_filename=None):
played_to_completion=True,
in_progress=False,
media_type=Scrobble.MediaType.TRAIL,
visibility="private",
)
_, ext = os.path.splitext(file_path)

View File

@ -80,6 +80,7 @@ def import_audioscrobbler_tsv_file(file_path, user_id):
in_progress=False,
media_type=Scrobble.MediaType.TRACK,
timezone=timestamp.tzinfo.name,
visibility="private",
)
existing = Scrobble.objects.filter(
timestamp=timestamp, track=track, user=user

View File

@ -0,0 +1,20 @@
from django.db import migrations
def backfill_null_visibility(apps, schema_editor):
Scrobble = apps.get_model("scrobbles", "Scrobble")
Scrobble.objects.filter(visibility__isnull=True).update(visibility="private")
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0094_scrobble_share_view_count_alter_scrobble_visibility_and_more"),
]
operations = [
migrations.RunPython(
backfill_null_visibility,
reverse_code=migrations.RunPython.noop,
),
]

View File

@ -1612,6 +1612,8 @@ class Scrobble(TimeStampedModel):
cls,
scrobble_data: dict,
) -> "Scrobble":
if "visibility" not in scrobble_data:
scrobble_data["visibility"] = Visibility.PRIVATE
scrobble = cls.objects.create(**scrobble_data)
if not (
scrobble.media_type == cls.MediaType.GEO_LOCATION