Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de9b4ee9c1 | |||
| bf9a6a9679 | |||
| 709fed5cfe | |||
| b7df6299d0 | |||
| be16d513ef | |||
| 15d27f6d94 | |||
| c8292d1c06 | |||
| 68f821fce1 | |||
| ed2ed59f65 | |||
| 17a7bb52fa | |||
| bbac142b40 |
147
PROJECT.org
147
PROJECT.org
@ -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,152 @@ 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 [#B] Allow browing a user's favorited media :favorites:feature:
|
||||
:PROPERTIES:
|
||||
:ID: 5c2cf004-d01f-4576-9bbb-974235e7408a
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
We should have a global view `/favorites/` that shows the logged in users's
|
||||
favorited media objects.
|
||||
|
||||
* Version 50.1 [1/1]
|
||||
** DONE [#B] Fix bug in charts where only #1 is displayed :charts:templates:
|
||||
:PROPERTIES:
|
||||
:ID: 7136dffb-e6b7-184b-48ac-bb09bae0b0f0
|
||||
:END:
|
||||
|
||||
* Version 50.0 [2/2]
|
||||
** DONE [#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.
|
||||
|
||||
And users should be able to also control whether all scrobbles of a specific
|
||||
type are shared or not. Maybe this could be a JSONField in profile that contains
|
||||
a media_type key with a visibility type for a value, and if it's not present,
|
||||
sharing defaults to private?
|
||||
|
||||
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.
|
||||
|
||||
*** Changes
|
||||
|
||||
- Added `media_type_visibility` JSONField to UserProfile (migration 0038)
|
||||
- Created `BulkVisibilityView` at `/settings/visibility/` with:
|
||||
- Radio toggle to make all non-shared scrobbles Public or Private
|
||||
- Per-media-type dropdown for each of the 20 media types (inherit/public/shared/private)
|
||||
- Created `BulkVisibilityForm` with dynamic media_type fields
|
||||
- Created `profiles/visibility_settings.html` template with visibility stats + filter links
|
||||
- Added link from main settings page to visibility settings
|
||||
- Added `?visibility=` filter support to `ScrobbleListView` (public/shared/private)
|
||||
- Added filter indicator to `scrobble_all_list.html`
|
||||
- Updated `Scrobble.create()` to check `user.profile.media_type_visibility` for media-type-specific defaults before falling back to PRIVATE
|
||||
|
||||
|
||||
|
||||
** DONE [#A] Replace columsn of Top Artists, Tracks and Series with Maloja widget :templates:charts:
|
||||
:PROPERTIES:
|
||||
:ID: 3946afb1-932c-46fe-a188-f4c9add1a491
|
||||
:END:
|
||||
|
||||
|
||||
*** Description
|
||||
|
||||
The tables are fine, but Maloja widgets are better. We should drop the top track table, add top albums
|
||||
and replace top artists and top tv series with the Maloja style widgets.
|
||||
|
||||
|
||||
* 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:
|
||||
:ID: 0b619837-729a-cd74-7a97-6fa2a148b27d
|
||||
:END:
|
||||
|
||||
* Version 48.2 [1/1]
|
||||
** DONE [#A] Lock down scrobbles and use sqids to share them :feature:sharing:scrobbles:
|
||||
:PROPERTIES:
|
||||
|
||||
14
poetry.lock
generated
14
poetry.lock
generated
@ -4966,6 +4966,18 @@ files = [
|
||||
{file = "soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqids"
|
||||
version = "0.5.2"
|
||||
description = "Generate YouTube-like ids from numbers."
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "sqids-0.5.2-py3-none-any.whl", hash = "sha256:0089ba823e21fd44290c7225f02fb0b5140c36e41959c04d86d3f6f2513799be"},
|
||||
{file = "sqids-0.5.2.tar.gz", hash = "sha256:5ac08f0c5c9b6814bc2e7c79ee5931e0849d25d95c50e415771b022a44f58af9"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlparse"
|
||||
version = "0.5.5"
|
||||
@ -6020,4 +6032,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 = "78ba52d0e6ea492efceb14fcd42ace25abfb66d42c3aff28f2fe1a31a9aa03b5"
|
||||
content-hash = "cc5b3b44071d6b0ab4f05189580232cc129b4ed694ab3f0673c3d838c3af0f8a"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "vrobbler"
|
||||
version = "48.2"
|
||||
version = "50.1"
|
||||
description = ""
|
||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||
|
||||
@ -63,6 +63,7 @@ gpxpy = "^1.6.2"
|
||||
fitparse = "^1.2.0"
|
||||
lxml = ">=5.5.0"
|
||||
vaderSentiment = "^3.3.2"
|
||||
sqids = "^0.5.2"
|
||||
|
||||
[tool.poetry.group.test]
|
||||
optional = true
|
||||
|
||||
@ -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
|
||||
|
||||
@ -7,7 +7,10 @@ register = template.Library()
|
||||
def get_item(dictionary, key):
|
||||
if isinstance(dictionary, dict):
|
||||
return dictionary.get(key)
|
||||
return None
|
||||
try:
|
||||
return dictionary[int(key)]
|
||||
except (IndexError, KeyError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
@register.filter
|
||||
|
||||
@ -114,108 +114,106 @@ class ChartRecordView(TemplateView):
|
||||
context["current_week"] = current_week
|
||||
context["current_day"] = current_day
|
||||
|
||||
if chart_type == "maloja":
|
||||
context["chart_keys"] = {
|
||||
"today": "Today",
|
||||
"week": "This Week",
|
||||
"month": "This Month",
|
||||
"year": "This Year",
|
||||
"all": "All Time",
|
||||
}
|
||||
context["chart_keys"] = {
|
||||
"today": "Today",
|
||||
"week": "This Week",
|
||||
"month": "This Month",
|
||||
"year": "This Year",
|
||||
"all": "All Time",
|
||||
}
|
||||
|
||||
context["maloja_charts"] = {
|
||||
"artist": {
|
||||
"today": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"artist",
|
||||
year=current_year,
|
||||
month=current_month,
|
||||
day=current_day,
|
||||
)
|
||||
),
|
||||
"week": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"artist",
|
||||
year=current_year,
|
||||
week=current_week,
|
||||
)
|
||||
),
|
||||
"month": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"artist",
|
||||
year=current_year,
|
||||
month=current_month,
|
||||
)
|
||||
),
|
||||
"year": list(
|
||||
self.get_charts_for_period(user, "artist", year=current_year)
|
||||
),
|
||||
"all": list(self.get_charts_for_period(user, "artist")),
|
||||
},
|
||||
"track": {
|
||||
"today": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"track",
|
||||
year=current_year,
|
||||
month=current_month,
|
||||
day=current_day,
|
||||
)
|
||||
),
|
||||
"week": list(
|
||||
self.get_charts_for_period(
|
||||
user, "track", year=current_year, week=current_week
|
||||
)
|
||||
),
|
||||
"month": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"track",
|
||||
year=current_year,
|
||||
month=current_month,
|
||||
)
|
||||
),
|
||||
"year": list(
|
||||
self.get_charts_for_period(user, "track", year=current_year)
|
||||
),
|
||||
"all": list(self.get_charts_for_period(user, "track")),
|
||||
},
|
||||
"tv_series": {
|
||||
"today": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"tv_series",
|
||||
year=current_year,
|
||||
month=current_month,
|
||||
day=current_day,
|
||||
)
|
||||
),
|
||||
"week": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"tv_series",
|
||||
year=current_year,
|
||||
week=current_week,
|
||||
)
|
||||
),
|
||||
"month": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"tv_series",
|
||||
year=current_year,
|
||||
month=current_month,
|
||||
)
|
||||
),
|
||||
"year": list(
|
||||
self.get_charts_for_period(user, "tv_series", year=current_year)
|
||||
),
|
||||
"all": list(self.get_charts_for_period(user, "tv_series")),
|
||||
},
|
||||
}
|
||||
return context
|
||||
context["maloja_charts"] = {
|
||||
"artist": {
|
||||
"today": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"artist",
|
||||
year=current_year,
|
||||
month=current_month,
|
||||
day=current_day,
|
||||
)
|
||||
),
|
||||
"week": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"artist",
|
||||
year=current_year,
|
||||
week=current_week,
|
||||
)
|
||||
),
|
||||
"month": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"artist",
|
||||
year=current_year,
|
||||
month=current_month,
|
||||
)
|
||||
),
|
||||
"year": list(
|
||||
self.get_charts_for_period(user, "artist", year=current_year)
|
||||
),
|
||||
"all": list(self.get_charts_for_period(user, "artist")),
|
||||
},
|
||||
"album": {
|
||||
"today": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"album",
|
||||
year=current_year,
|
||||
month=current_month,
|
||||
day=current_day,
|
||||
)
|
||||
),
|
||||
"week": list(
|
||||
self.get_charts_for_period(
|
||||
user, "album", year=current_year, week=current_week
|
||||
)
|
||||
),
|
||||
"month": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"album",
|
||||
year=current_year,
|
||||
month=current_month,
|
||||
)
|
||||
),
|
||||
"year": list(
|
||||
self.get_charts_for_period(user, "album", year=current_year)
|
||||
),
|
||||
"all": list(self.get_charts_for_period(user, "album")),
|
||||
},
|
||||
"tv_series": {
|
||||
"today": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"tv_series",
|
||||
year=current_year,
|
||||
month=current_month,
|
||||
day=current_day,
|
||||
)
|
||||
),
|
||||
"week": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"tv_series",
|
||||
year=current_year,
|
||||
week=current_week,
|
||||
)
|
||||
),
|
||||
"month": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"tv_series",
|
||||
year=current_year,
|
||||
month=current_month,
|
||||
)
|
||||
),
|
||||
"year": list(
|
||||
self.get_charts_for_period(user, "tv_series", year=current_year)
|
||||
),
|
||||
"all": list(self.get_charts_for_period(user, "tv_series")),
|
||||
},
|
||||
}
|
||||
|
||||
if not date_param:
|
||||
context["period"] = "current"
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
from django import forms
|
||||
|
||||
from profiles.models import UserProfile
|
||||
from scrobbles.constants import Visibility
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
|
||||
class UserProfileForm(forms.ModelForm):
|
||||
@ -45,3 +47,51 @@ class UserProfileForm(forms.ModelForm):
|
||||
"archivebox_password": forms.PasswordInput(render_value=True),
|
||||
"webdav_pass": forms.PasswordInput(render_value=True),
|
||||
}
|
||||
|
||||
|
||||
MEDIA_TYPE_LABELS = {
|
||||
mt.value: mt.label for mt in Scrobble.MediaType
|
||||
}
|
||||
|
||||
INHERIT = ""
|
||||
|
||||
|
||||
class BulkVisibilityForm(forms.Form):
|
||||
bulk_action = forms.ChoiceField(
|
||||
choices=[
|
||||
(Visibility.PUBLIC, "Public"),
|
||||
(Visibility.PRIVATE, "Private"),
|
||||
],
|
||||
widget=forms.RadioSelect,
|
||||
required=False,
|
||||
label="Set all non-shared scrobbles to",
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.profile = kwargs.pop("profile")
|
||||
super().__init__(*args, **kwargs)
|
||||
media_types = Scrobble.MediaType.values
|
||||
choices = [
|
||||
(Visibility.PUBLIC, "Public"),
|
||||
(Visibility.SHARED, "Shared"),
|
||||
(Visibility.PRIVATE, "Private"),
|
||||
]
|
||||
existing_overrides = self.profile.media_type_visibility or {}
|
||||
for mt in sorted(media_types):
|
||||
label = MEDIA_TYPE_LABELS.get(mt, mt)
|
||||
self.fields[f"media_type_{mt}"] = forms.ChoiceField(
|
||||
choices=choices,
|
||||
required=False,
|
||||
label=label,
|
||||
initial=existing_overrides.get(mt, Visibility.PRIVATE),
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
cleaned = super().clean()
|
||||
overrides = {}
|
||||
for mt in Scrobble.MediaType.values:
|
||||
val = cleaned.get(f"media_type_{mt}")
|
||||
if val:
|
||||
overrides[mt] = val
|
||||
cleaned["media_type_visibility"] = overrides
|
||||
return cleaned
|
||||
|
||||
@ -0,0 +1,20 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("profiles", "0037_alter_userprofile_default_scrobble_visibility"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="media_type_visibility",
|
||||
field=models.JSONField(
|
||||
blank=True,
|
||||
default=dict,
|
||||
help_text='Per-media-type visibility overrides, e.g. {"Video": "public", "Track": "private"}',
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -90,6 +90,12 @@ class UserProfile(TimeStampedModel):
|
||||
default="private",
|
||||
)
|
||||
|
||||
media_type_visibility = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
help_text="Per-media-type visibility overrides, e.g. {\"Video\": \"public\", \"Track\": \"private\"}",
|
||||
)
|
||||
|
||||
home_scrobble_limit = models.IntegerField(default=20)
|
||||
|
||||
weigh_in_units = models.CharField(
|
||||
|
||||
@ -6,4 +6,9 @@ app_name = "profiles"
|
||||
|
||||
urlpatterns = [
|
||||
path("settings/", views.ProfileFormView.as_view(), name="profile_settings"),
|
||||
path(
|
||||
"settings/visibility/",
|
||||
views.BulkVisibilityView.as_view(),
|
||||
name="bulk_visibility",
|
||||
),
|
||||
]
|
||||
|
||||
@ -1,8 +1,12 @@
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.db.models import Count, Q
|
||||
from django.http.response import HttpResponseBadRequest
|
||||
from django.urls import reverse_lazy
|
||||
from django.views.generic import FormView
|
||||
from profiles.forms import UserProfileForm
|
||||
from profiles.forms import BulkVisibilityForm, UserProfileForm
|
||||
from scrobbles.constants import Visibility
|
||||
from scrobbles.models import Scrobble
|
||||
from tasks.todoist import generate_todoist_oauth_url
|
||||
|
||||
|
||||
@ -30,3 +34,46 @@ class ProfileFormView(LoginRequiredMixin, FormView):
|
||||
context["profile"] = self.request.user.profile
|
||||
context["todoist_oauth_url"] = generate_todoist_oauth_url(self.request.user.id)
|
||||
return context
|
||||
|
||||
|
||||
class BulkVisibilityView(LoginRequiredMixin, FormView):
|
||||
template_name = "profiles/visibility_settings.html"
|
||||
form_class = BulkVisibilityForm
|
||||
success_url = reverse_lazy("profiles:bulk_visibility")
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs["profile"] = self.request.user.profile
|
||||
return kwargs
|
||||
|
||||
def form_valid(self, form):
|
||||
request = self.request
|
||||
profile = request.user.profile
|
||||
|
||||
bulk_action = form.cleaned_data.get("bulk_action")
|
||||
if bulk_action:
|
||||
qs = Scrobble.objects.filter(
|
||||
user=request.user,
|
||||
).exclude(visibility=Visibility.SHARED)
|
||||
total = qs.count()
|
||||
qs.update(visibility=bulk_action)
|
||||
messages.success(
|
||||
request,
|
||||
f"Updated {total} scrobble(s) to {bulk_action}.",
|
||||
)
|
||||
|
||||
profile.media_type_visibility = form.cleaned_data["media_type_visibility"]
|
||||
profile.save(update_fields=["media_type_visibility"])
|
||||
messages.success(request, "Per-media-type visibility overrides saved.")
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
profile = self.request.user.profile
|
||||
qs = Scrobble.objects.filter(user=self.request.user)
|
||||
ctx["scrobble_count"] = qs.count()
|
||||
ctx["visibility_counts"] = qs.values("visibility").annotate(
|
||||
count=Count("id")
|
||||
)
|
||||
ctx["profile"] = profile
|
||||
return ctx
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
),
|
||||
]
|
||||
@ -1612,6 +1612,18 @@ class Scrobble(TimeStampedModel):
|
||||
cls,
|
||||
scrobble_data: dict,
|
||||
) -> "Scrobble":
|
||||
if "visibility" not in scrobble_data:
|
||||
user = scrobble_data.get("user")
|
||||
media_type = scrobble_data.get("media_type")
|
||||
override = None
|
||||
if user and media_type:
|
||||
try:
|
||||
profile = user.profile
|
||||
overrides = profile.media_type_visibility or {}
|
||||
override = overrides.get(media_type)
|
||||
except user.__class__.profile.RelatedObjectDoesNotExist:
|
||||
pass
|
||||
scrobble_data["visibility"] = override or Visibility.PRIVATE
|
||||
scrobble = cls.objects.create(**scrobble_data)
|
||||
if not (
|
||||
scrobble.media_type == cls.MediaType.GEO_LOCATION
|
||||
|
||||
@ -371,6 +371,9 @@ class ScrobbleListView(LoginRequiredMixin, ListView):
|
||||
qs = qs.filter(id__in=matching_ids).distinct()
|
||||
else:
|
||||
tag_list = []
|
||||
visibility_param = self.request.GET.get("visibility", "")
|
||||
if visibility_param in ("public", "shared", "private"):
|
||||
qs = qs.filter(visibility=visibility_param)
|
||||
self.tag_list = tag_list
|
||||
self._full_queryset = qs
|
||||
return qs
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
<a href="{% url 'charts:charts-home' %}" class="btn btn-sm btn-outline-secondary">Charts</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% block grid_view_button %}
|
||||
<div class="btn-group me-2">
|
||||
{% if view == 'grid' %}
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"><a href="?{% urlreplace view='list' %}">List View</a>
|
||||
@ -22,6 +23,7 @@
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"><a href="?{% urlreplace view='grid' %}">Grid View</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block charts_button %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
{% extends "base_list.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Charts{% if period_str %} - {{ period_str }}{% endif %}{% endblock %}
|
||||
|
||||
@ -12,11 +13,46 @@
|
||||
.nav-tabs {
|
||||
cursor: pointer;
|
||||
}
|
||||
.image-wrapper {
|
||||
contain: content;
|
||||
}
|
||||
.image-wrapper :hover {
|
||||
background: rgba(0,0,0,0.3);
|
||||
}
|
||||
.caption {
|
||||
position: fixed;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
padding: 3px;
|
||||
font-size: 90%;
|
||||
color: white;
|
||||
background: rgba(0,0,0,0.4);
|
||||
}
|
||||
.caption-medium {
|
||||
position: fixed;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
padding: 3px;
|
||||
font-size: 75%;
|
||||
color: white;
|
||||
background: rgba(0,0,0,0.4);
|
||||
}
|
||||
.caption-small {
|
||||
position: fixed;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
padding: 3px;
|
||||
font-size: 60%;
|
||||
color: white;
|
||||
background: rgba(0,0,0,0.4);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block lists %}
|
||||
|
||||
{% block grid_view_button %}{% endblock %}
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<a href="{% url 'charts:spotify-tracks' %}" class="btn btn-sm btn-outline-secondary">🎵 Spotify Tracks</a>
|
||||
@ -24,10 +60,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if chart_type == "maloja" %}
|
||||
{% include "scrobbles/_top_charts.html" %}
|
||||
{% else %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="btn-group mb-3" role="group">
|
||||
@ -88,43 +120,9 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% include "scrobbles/_top_charts.html" %}
|
||||
|
||||
<div class="row">
|
||||
{% if charts.artist %}
|
||||
<div class="col-md-6 col-lg-4 chart-section">
|
||||
<h3>🎤 Top Artists</h3>
|
||||
<ul class="list-group">
|
||||
{% for chart in charts.artist|slice:":20" %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<span class="me-2"><strong>#{{chart.rank}}</strong></span>
|
||||
<a href="{{chart.artist.get_absolute_url}}">{{chart.artist.name}}</a>
|
||||
<span class="badge bg-primary rounded-pill">{{chart.count}}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li class="list-group-item">
|
||||
<a href="{% url 'charts:chart-detail' 'artist' %}">View all »</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if charts.album %}
|
||||
<div class="col-md-6 col-lg-4 chart-section">
|
||||
<h3>💿 Top Albums</h3>
|
||||
<ul class="list-group">
|
||||
{% for chart in charts.album|slice:":20" %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<span class="me-2"><strong>#{{chart.rank}}</strong></span>
|
||||
<a href="{{chart.album.get_absolute_url}}">{{chart.album.name}}</a>
|
||||
<span class="badge bg-primary rounded-pill">{{chart.count}}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li class="list-group-item">
|
||||
<a href="{% url 'charts:chart-detail' 'album' %}">View all »</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if charts.track %}
|
||||
<div class="col-md-6 col-lg-4 chart-section">
|
||||
<h3>🎵 Top Tracks</h3>
|
||||
@ -143,24 +141,6 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if charts.tv_series %}
|
||||
<div class="col-md-6 col-lg-4 chart-section">
|
||||
<h3>📺 Top TV Series</h3>
|
||||
<ul class="list-group">
|
||||
{% for chart in charts.tv_series|slice:":20" %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<span class="me-2"><strong>#{{chart.rank}}</strong></span>
|
||||
<a href="{{chart.tv_series.get_absolute_url}}">{{chart.tv_series.name}}</a>
|
||||
<span class="badge bg-primary rounded-pill">{{chart.count}}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li class="list-group-item">
|
||||
<a href="{% url 'charts:chart-detail' 'tv_series' %}">View all »</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if charts.video %}
|
||||
<div class="col-md-6 col-lg-4 chart-section">
|
||||
<h3>🎬 Top Videos</h3>
|
||||
@ -314,6 +294,4 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@ -106,6 +106,7 @@
|
||||
{% block title %}Settings{% endblock %}
|
||||
{% block details %}
|
||||
<p class="settings-link"><a href="{% url 'people:person_form' %}">Manage People</a></p>
|
||||
<p class="settings-link"><a href="{% url 'profiles:bulk_visibility' %}">Scrobble Visibility Settings</a></p>
|
||||
<form method="post" class="settings-form">{% csrf_token %}
|
||||
{% for field in form %}
|
||||
{% if field.name == "enable_public_widgets" %}
|
||||
|
||||
117
vrobbler/templates/profiles/visibility_settings.html
Normal file
117
vrobbler/templates/profiles/visibility_settings.html
Normal file
@ -0,0 +1,117 @@
|
||||
{% extends "base_detail.html" %}
|
||||
|
||||
{% block title %}Scrobble Visibility Settings{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
.vis-form {
|
||||
max-width: 700px;
|
||||
}
|
||||
.vis-form h3 {
|
||||
margin-top: 24px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.vis-form h4 {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.vis-form label {
|
||||
font-weight: 600;
|
||||
margin-right: 12px;
|
||||
}
|
||||
.vis-form .bulk-radio-group {
|
||||
margin: 8px 0 16px 0;
|
||||
}
|
||||
.vis-form .bulk-radio-group label {
|
||||
font-weight: normal;
|
||||
display: block;
|
||||
margin: 4px 0;
|
||||
}
|
||||
.vis-form .media-type-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 6px 0;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.vis-form .media-type-row:nth-child(even) {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
.vis-form .media-type-row label {
|
||||
font-weight: normal;
|
||||
min-width: 130px;
|
||||
}
|
||||
.vis-form select {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.vis-form input[type="submit"] {
|
||||
margin-top: 20px;
|
||||
padding: 10px 24px;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
.vis-form input[type="submit"]:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
.vis-stats {
|
||||
margin: 16px 0;
|
||||
padding: 12px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.vis-stats strong {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.vis-stats ul {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block details %}
|
||||
<p><a href="{% url 'profiles:profile_settings' %}">« Back to settings</a></p>
|
||||
|
||||
<div class="vis-stats">
|
||||
<strong>Current scrobble visibility ({{ scrobble_count }} total)</strong>
|
||||
<ul>
|
||||
{% for item in visibility_counts %}
|
||||
<li><a href="{% url 'scrobbles:scrobble-list' %}?visibility={{ item.visibility }}">{{ item.visibility|title }}</a>: {{ item.count }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<form method="post" class="vis-form">{% csrf_token %}
|
||||
<h3>Bulk Update</h3>
|
||||
<p>Choose a visibility to apply to all scrobbles that are <strong>not</strong> currently "Shared".</p>
|
||||
<div class="bulk-radio-group">
|
||||
{% for radio in form.bulk_action %}
|
||||
<label>{{ radio.tag }} {{ radio.choice_label }}</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary><h3 style="display:inline">Per-Media-Type Defaults</h3></summary>
|
||||
<p>Override default visibility for new scrobbles of specific types.</p>
|
||||
{% for field in form %}
|
||||
{% if field.name != "bulk_action" %}
|
||||
<div class="media-type-row">
|
||||
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
|
||||
{{ field }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</details>
|
||||
|
||||
<input type="submit" value="Save Visibility Settings">
|
||||
</form>
|
||||
{% endblock %}
|
||||
@ -76,44 +76,44 @@
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<h2>🎵 Top Tracks</h2>
|
||||
<ul class="nav nav-tabs" id="trackTab" role="tablist">
|
||||
<h2>💿 Top Albums</h2>
|
||||
<ul class="nav nav-tabs" id="albumTab" role="tablist">
|
||||
{% for key, name in chart_keys.items %}
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link {% if forloop.counter == 2 %}active{% endif %}"
|
||||
id="track-{{key}}-tab" data-bs-toggle="tab" data-bs-target="#track-{{key}}"
|
||||
id="album-{{key}}-tab" data-bs-toggle="tab" data-bs-target="#album-{{key}}"
|
||||
type="button" role="tab">{{name}}</button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<div class="tab-content" id="trackTabContent" class="maloja-chart">
|
||||
<div class="tab-content" id="albumTabContent" class="maloja-chart">
|
||||
{% for key, name in chart_keys.items %}
|
||||
{% with maloja_charts.track|get_item:key as tracks %}
|
||||
<div class="tab-pane fade {% if forloop.counter == 2 %}show active{% endif %}" id="track-{{key}}" role="tabpanel">
|
||||
{% if tracks.0 %}
|
||||
{% with maloja_charts.album|get_item:key as albums %}
|
||||
<div class="tab-pane fade {% if forloop.counter == 2 %}show active{% endif %}" id="album-{{key}}" role="tabpanel">
|
||||
{% if albums.0 %}
|
||||
<div style="display:block">
|
||||
<div style="float:left;">
|
||||
<div class="image-wrapper" style="display:flex; flex-wrap: wrap; margin:0">
|
||||
<div class="caption">#1 {{tracks.0.track.title}}</div>
|
||||
{% if tracks.0.track.album.cover_image %}
|
||||
<a href="{{tracks.0.track.get_absolute_url}}"><img src="{{tracks.0.track.album.cover_image_medium.url}}" width="300px"></a>
|
||||
<div class="caption">#1 {{albums.0.album.title}}</div>
|
||||
{% if albums.0.album.cover_image %}
|
||||
<a href="{{albums.0.album.get_absolute_url}}"><img src="{{albums.0.album.cover_image_medium.url}}" width="300px"></a>
|
||||
{% else %}
|
||||
<a href="{{tracks.0.track.get_absolute_url}}"><img src="{% static 'images/not-found.jpg' %}" width="300px"></a>
|
||||
<a href="{{albums.0.album.get_absolute_url}}"><img src="{% static 'images/not-found.jpg' %}" width="300px"></a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div style="float:left; width:300px;">
|
||||
<div style="display:flex; flex-wrap: wrap;">
|
||||
{% for i in "2345" %}
|
||||
{% with tracks|get_item:forloop.counter as track %}
|
||||
{% if track %}
|
||||
{% with albums|get_item:forloop.counter as album %}
|
||||
{% if album %}
|
||||
<div class="image-wrapper" style="width:50%">
|
||||
<div class="caption-medium">#{{forloop.counter|add:1}} {{track.track.title}}</div>
|
||||
{% if track.track.album.cover_image %}
|
||||
<a href="{{track.track.get_absolute_url}}"><img src="{{track.track.album.cover_image_medium.url}}" width="150px"></a>
|
||||
<div class="caption-medium">#{{forloop.counter|add:1}} {{album.album.title}}</div>
|
||||
{% if album.album.cover_image %}
|
||||
<a href="{{album.album.get_absolute_url}}"><img src="{{album.album.cover_image_medium.url}}" width="150px"></a>
|
||||
{% else %}
|
||||
<a href="{{track.track.get_absolute_url}}"><img src="{% static 'images/not-found.jpg' %}" width="150px"></a>
|
||||
<a href="{{album.album.get_absolute_url}}"><img src="{% static 'images/not-found.jpg' %}" width="150px"></a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@ -124,14 +124,14 @@
|
||||
<div style="float:left; width:300px;">
|
||||
<div style="display:flex; flex-wrap: wrap;">
|
||||
{% for i in "67891011121314" %}
|
||||
{% with tracks|get_item:forloop.counter|add:5 as track %}
|
||||
{% if track %}
|
||||
{% with albums|get_item:forloop.counter|add:5 as album %}
|
||||
{% if album %}
|
||||
<div class="image-wrapper" style="width:33%">
|
||||
<div class="caption-small">#{{forloop.counter|add:6}} {{track.track.title}}</div>
|
||||
{% if track.track.album.cover_image %}
|
||||
<a href="{{track.track.get_absolute_url}}"><img src="{{track.track.album.cover_image_medium.url}}" width="100px"></a>
|
||||
<div class="caption-small">#{{forloop.counter|add:6}} {{album.album.title}}</div>
|
||||
{% if album.album.cover_image %}
|
||||
<a href="{{album.album.get_absolute_url}}"><img src="{{album.album.cover_image_medium.url}}" width="100px"></a>
|
||||
{% else %}
|
||||
<a href="{{track.track.get_absolute_url}}"><img src="{% static 'images/not-found.jpg' %}" width="100px"></a>
|
||||
<a href="{{album.album.get_absolute_url}}"><img src="{% static 'images/not-found.jpg' %}" width="100px"></a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@ -13,6 +13,9 @@
|
||||
<p class="text-muted small mb-0">Total time: {{ total_time_seconds|natural_duration }}</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if request.GET.visibility %}
|
||||
<h6 class="text-muted">Filter: {{ request.GET.visibility|title }} scrobbles only</h6>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user