Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a880a2f2f | |||
| 248d3f2d3e | |||
| e243fec679 | |||
| de9b4ee9c1 | |||
| bf9a6a9679 | |||
| 709fed5cfe | |||
| b7df6299d0 | |||
| be16d513ef | |||
| 15d27f6d94 | |||
| c8292d1c06 |
182
PROJECT.org
182
PROJECT.org
@ -534,23 +534,197 @@ 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"
|
to go try to enrich the media instance. Should this enrichment fail, tag the scrobble as "enrichment-failed"
|
||||||
log a warning and move on.
|
log a warning and move on.
|
||||||
|
|
||||||
** TODO [#A] Allow updating all a user's scrobble visibility at once :scrobbles:sharing:feature:
|
** 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.2 [2/2]
|
||||||
|
** DONE [#B] Koreader imports only import single-page scrobbles the next day :bug:books:importers:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: b50141fd-cda6-4a3a-afd3-cd8499e7523e
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
When you read a single page in a book in Koreader and try to import it, the scrobble is only
|
||||||
|
created the day after, not on the day of the reading.
|
||||||
|
|
||||||
|
** DONE [#A] Fix bugs in celery tasks causing imports to fail :bug:celery:tasks:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: d1171cb0-6413-44b8-a68a-019a4d2fb285
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
Seems like all celery tasks are failing for different reasons except the chart
|
||||||
|
updates.
|
||||||
|
|
||||||
|
*** Errors
|
||||||
|
**** scrobbles.tasks.send_notification_for_in_progress
|
||||||
|
#+begin_src bash
|
||||||
|
KeyError: 'track'
|
||||||
|
|
||||||
|
During handling of the above exception, another exception occurred:
|
||||||
|
|
||||||
|
Traceback (most recent call last):
|
||||||
|
File "/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py", line 89, in _execute
|
||||||
|
return self.cursor.execute(sql, params)
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
psycopg2.errors.UndefinedColumn: column music_track.artist_id does not exist
|
||||||
|
LINE 1: ..."."title", "music_track"."base_run_time_seconds", "music_tra...
|
||||||
|
^
|
||||||
|
HINT: Perhaps you meant to reference the column "music_track.artist_fk_id".
|
||||||
|
#+end_src
|
||||||
|
|
||||||
|
**** scrobbles.tasks.import_from_webdav_all_users
|
||||||
|
#+begin_src bash
|
||||||
|
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/scrobbles/importers/webdav.py", line 166, in scan_webdav_for_koreader
|
||||||
|
if last_import and last_import.webdav_etag and remote_etag:
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
AttributeError: 'KoReaderImport' object has no attribute 'webdav_etag'
|
||||||
|
#+end_src
|
||||||
|
|
||||||
|
**** scrobbles.tasks.process_bgstats_import
|
||||||
|
#+begin_src bash
|
||||||
|
|
||||||
|
Traceback (most recent call last):
|
||||||
|
File "/usr/local/lib/python3.11/site-packages/django/db/backends/utils.py", line 89, in _execute
|
||||||
|
return self.cursor.execute(sql, params)
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
psycopg2.errors.NotNullViolation: null value in column "visibility" of relation "scrobbles_scrobble" violates not-null constraint
|
||||||
|
DETAIL: Failing row contains (374463, 2026-06-11 13:27:06.528319+00, 2026-06-11 13:27:06.52834+00, 2026-06-11 13:17:34+00, 180, f, f, BG Stats, 1, null, t, {"players": [{"new": false, "win": false, "rank": 0, "role": "",..., null, null, null, 8e73ceec-b731-4623-9637-712bbf9f76ce, null, null, null, null, , null, BoardGame, 324, null, null, America/New_York, null, null, , null, null, , null, null, null, null, null, null, null, null, null, null, null).
|
||||||
|
#+end_src
|
||||||
|
|
||||||
|
* 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:
|
:PROPERTIES:
|
||||||
:ID: 9ed2ec65-bf69-4300-965c-6a7d3ef7ea03
|
:ID: 9ed2ec65-bf69-4300-965c-6a7d3ef7ea03
|
||||||
:END:
|
:END:
|
||||||
|
|
||||||
*** Description
|
*** Description
|
||||||
|
|
||||||
We now have the ability to share or unshare scrobbles and create private links. We should add a toggle
|
We now have the ability to share or unshare scrobbles and create private links.
|
||||||
in the user's settings that will bulk make all their scrobbles public or private, so that a user
|
We should add a toggle in the user's settings that will bulk make all their
|
||||||
can either share everything, or lock their account down.
|
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.
|
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
|
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
|
are either public, shared or private. Probably this could be done with a
|
||||||
?visbility=<> filter on the /scrobbles/ page.
|
?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]
|
* Version 49.0 [1/1]
|
||||||
** DONE [#A] Fix broken tests with new sharing and add tests :scrobbles:sharing:tests:
|
** DONE [#A] Fix broken tests with new sharing and add tests :scrobbles:sharing:tests:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "vrobbler"
|
name = "vrobbler"
|
||||||
version = "49.0"
|
version = "50.2"
|
||||||
description = ""
|
description = ""
|
||||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||||
|
|
||||||
|
|||||||
@ -187,18 +187,20 @@ def build_page_data(page_rows: list, book_map: dict, user_tz=None) -> dict:
|
|||||||
book_ids_not_found.append(koreader_book_id)
|
book_ids_not_found.append(koreader_book_id)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if "pages" not in book_map[koreader_book_id].keys():
|
book_map[koreader_book_id].setdefault("pages", [])
|
||||||
book_map[koreader_book_id]["pages"] = {}
|
|
||||||
|
|
||||||
page_number = page_row[KoReaderPageStatColumn.PAGE.value]
|
page_number = page_row[KoReaderPageStatColumn.PAGE.value]
|
||||||
duration = page_row[KoReaderPageStatColumn.DURATION.value]
|
duration = page_row[KoReaderPageStatColumn.DURATION.value]
|
||||||
start_ts = page_row[KoReaderPageStatColumn.START_TIME.value]
|
start_ts = page_row[KoReaderPageStatColumn.START_TIME.value]
|
||||||
|
|
||||||
book_map[koreader_book_id]["pages"][page_number] = {
|
book_map[koreader_book_id]["pages"].append(
|
||||||
"duration": duration,
|
{
|
||||||
"start_ts": start_ts,
|
"page_number": page_number,
|
||||||
"end_ts": start_ts + duration,
|
"duration": duration,
|
||||||
}
|
"start_ts": start_ts,
|
||||||
|
"end_ts": start_ts + duration,
|
||||||
|
}
|
||||||
|
)
|
||||||
if book_ids_not_found:
|
if book_ids_not_found:
|
||||||
logger.info(f"Found pages for books not in file: {set(book_ids_not_found)}")
|
logger.info(f"Found pages for books not in file: {set(book_ids_not_found)}")
|
||||||
return book_map
|
return book_map
|
||||||
@ -225,11 +227,12 @@ def build_scrobbles_from_book_map(book_map: dict, user: "User") -> list["Scrobbl
|
|||||||
pages_processed = 0
|
pages_processed = 0
|
||||||
total_pages_read = len(book_map[koreader_book_id]["pages"])
|
total_pages_read = len(book_map[koreader_book_id]["pages"])
|
||||||
ordered_pages = sorted(
|
ordered_pages = sorted(
|
||||||
book_map[koreader_book_id]["pages"].items(),
|
book_map[koreader_book_id]["pages"],
|
||||||
key=lambda x: x[1]["start_ts"],
|
key=lambda x: x["start_ts"],
|
||||||
)
|
)
|
||||||
|
|
||||||
for cur_page_number, stats in ordered_pages:
|
for stats in ordered_pages:
|
||||||
|
page_number = stats["page_number"]
|
||||||
pages_processed += 1
|
pages_processed += 1
|
||||||
|
|
||||||
seconds_from_last_page = 0
|
seconds_from_last_page = 0
|
||||||
@ -243,12 +246,14 @@ def build_scrobbles_from_book_map(book_map: dict, user: "User") -> list["Scrobbl
|
|||||||
)
|
)
|
||||||
|
|
||||||
end_of_reading = pages_processed == total_pages_read
|
end_of_reading = pages_processed == total_pages_read
|
||||||
big_jump_to_this_page = (cur_page_number - last_page_number) > 10
|
big_jump_to_this_page = (page_number - last_page_number) > 10
|
||||||
is_session_gap = seconds_from_last_page > SESSION_GAP_SECONDS
|
is_session_gap = seconds_from_last_page > SESSION_GAP_SECONDS
|
||||||
if (is_session_gap and not big_jump_to_this_page) or end_of_reading:
|
if (is_session_gap and not big_jump_to_this_page) or end_of_reading:
|
||||||
should_create_scrobble = True
|
should_create_scrobble = True
|
||||||
|
|
||||||
if should_create_scrobble:
|
if should_create_scrobble:
|
||||||
|
if not scrobble_page_data:
|
||||||
|
scrobble_page_data[page_number] = stats
|
||||||
scrobble_page_data = dict(
|
scrobble_page_data = dict(
|
||||||
sorted(
|
sorted(
|
||||||
scrobble_page_data.items(),
|
scrobble_page_data.items(),
|
||||||
@ -276,13 +281,6 @@ def build_scrobbles_from_book_map(book_map: dict, user: "User") -> list["Scrobbl
|
|||||||
datetime.fromtimestamp(int(last_page.get("end_ts")))
|
datetime.fromtimestamp(int(last_page.get("end_ts")))
|
||||||
)
|
)
|
||||||
|
|
||||||
# Adjust for Daylight Saving Time
|
|
||||||
# if timestamp.dst() == timedelta(
|
|
||||||
# 0
|
|
||||||
# ) or stop_timestamp.dst() == timedelta(0):
|
|
||||||
# timestamp = timestamp - timedelta(hours=1)
|
|
||||||
# stop_timestamp = stop_timestamp - timedelta(hours=1)
|
|
||||||
|
|
||||||
scrobble = Scrobble.objects.filter(
|
scrobble = Scrobble.objects.filter(
|
||||||
timestamp=timestamp,
|
timestamp=timestamp,
|
||||||
book_id=book_id,
|
book_id=book_id,
|
||||||
@ -291,7 +289,7 @@ def build_scrobbles_from_book_map(book_map: dict, user: "User") -> list["Scrobbl
|
|||||||
|
|
||||||
if not scrobble:
|
if not scrobble:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Queueing scrobble for {book_id}, page {cur_page_number}"
|
f"Queueing scrobble for {book_id}, page {page_number}"
|
||||||
)
|
)
|
||||||
log_data = {
|
log_data = {
|
||||||
"koreader_hash": book_dict.get("hash"),
|
"koreader_hash": book_dict.get("hash"),
|
||||||
@ -324,9 +322,9 @@ def build_scrobbles_from_book_map(book_map: dict, user: "User") -> list["Scrobbl
|
|||||||
scrobble_page_data = {}
|
scrobble_page_data = {}
|
||||||
|
|
||||||
# We accumulate pages for the scrobble until we should create a new one
|
# We accumulate pages for the scrobble until we should create a new one
|
||||||
scrobble_page_data[cur_page_number] = stats
|
scrobble_page_data[page_number] = stats
|
||||||
|
|
||||||
last_page_number = cur_page_number
|
last_page_number = page_number
|
||||||
prev_page_stats = stats
|
prev_page_stats = stats
|
||||||
if pages_not_found:
|
if pages_not_found:
|
||||||
logger.info(f"Pages not found for books: {set(pages_not_found)}")
|
logger.info(f"Pages not found for books: {set(pages_not_found)}")
|
||||||
|
|||||||
@ -32,8 +32,6 @@ class KoReaderBookRows:
|
|||||||
DEFAULT_STR = "N/A"
|
DEFAULT_STR = "N/A"
|
||||||
DEFAULT_INT = 0
|
DEFAULT_INT = 0
|
||||||
DEFAULT_TIME = 1703800469
|
DEFAULT_TIME = 1703800469
|
||||||
BOOK_ROWS = []
|
|
||||||
PAGE_STATS_ROWS = []
|
|
||||||
|
|
||||||
def _gen_random_row(self, i):
|
def _gen_random_row(self, i):
|
||||||
wiggle = random.randrange(15)
|
wiggle = random.randrange(15)
|
||||||
@ -110,6 +108,8 @@ class KoReaderBookRows:
|
|||||||
end_session = True
|
end_session = True
|
||||||
|
|
||||||
def __init__(self, book_count=0, **kwargs):
|
def __init__(self, book_count=0, **kwargs):
|
||||||
|
self.BOOK_ROWS = []
|
||||||
|
self.PAGE_STATS_ROWS = []
|
||||||
self._generate_random_book_rows(book_count)
|
self._generate_random_book_rows(book_count)
|
||||||
self._generate_custom_book_row(**kwargs)
|
self._generate_custom_book_row(**kwargs)
|
||||||
self._generate_random_page_stats_rows()
|
self._generate_random_page_stats_rows()
|
||||||
|
|||||||
@ -7,7 +7,10 @@ register = template.Library()
|
|||||||
def get_item(dictionary, key):
|
def get_item(dictionary, key):
|
||||||
if isinstance(dictionary, dict):
|
if isinstance(dictionary, dict):
|
||||||
return dictionary.get(key)
|
return dictionary.get(key)
|
||||||
return None
|
try:
|
||||||
|
return dictionary[int(key)]
|
||||||
|
except (IndexError, KeyError, TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@register.filter
|
@register.filter
|
||||||
|
|||||||
@ -114,108 +114,106 @@ class ChartRecordView(TemplateView):
|
|||||||
context["current_week"] = current_week
|
context["current_week"] = current_week
|
||||||
context["current_day"] = current_day
|
context["current_day"] = current_day
|
||||||
|
|
||||||
if chart_type == "maloja":
|
context["chart_keys"] = {
|
||||||
context["chart_keys"] = {
|
"today": "Today",
|
||||||
"today": "Today",
|
"week": "This Week",
|
||||||
"week": "This Week",
|
"month": "This Month",
|
||||||
"month": "This Month",
|
"year": "This Year",
|
||||||
"year": "This Year",
|
"all": "All Time",
|
||||||
"all": "All Time",
|
}
|
||||||
}
|
|
||||||
|
|
||||||
context["maloja_charts"] = {
|
context["maloja_charts"] = {
|
||||||
"artist": {
|
"artist": {
|
||||||
"today": list(
|
"today": list(
|
||||||
self.get_charts_for_period(
|
self.get_charts_for_period(
|
||||||
user,
|
user,
|
||||||
"artist",
|
"artist",
|
||||||
year=current_year,
|
year=current_year,
|
||||||
month=current_month,
|
month=current_month,
|
||||||
day=current_day,
|
day=current_day,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
"week": list(
|
"week": list(
|
||||||
self.get_charts_for_period(
|
self.get_charts_for_period(
|
||||||
user,
|
user,
|
||||||
"artist",
|
"artist",
|
||||||
year=current_year,
|
year=current_year,
|
||||||
week=current_week,
|
week=current_week,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
"month": list(
|
"month": list(
|
||||||
self.get_charts_for_period(
|
self.get_charts_for_period(
|
||||||
user,
|
user,
|
||||||
"artist",
|
"artist",
|
||||||
year=current_year,
|
year=current_year,
|
||||||
month=current_month,
|
month=current_month,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
"year": list(
|
"year": list(
|
||||||
self.get_charts_for_period(user, "artist", year=current_year)
|
self.get_charts_for_period(user, "artist", year=current_year)
|
||||||
),
|
),
|
||||||
"all": list(self.get_charts_for_period(user, "artist")),
|
"all": list(self.get_charts_for_period(user, "artist")),
|
||||||
},
|
},
|
||||||
"track": {
|
"album": {
|
||||||
"today": list(
|
"today": list(
|
||||||
self.get_charts_for_period(
|
self.get_charts_for_period(
|
||||||
user,
|
user,
|
||||||
"track",
|
"album",
|
||||||
year=current_year,
|
year=current_year,
|
||||||
month=current_month,
|
month=current_month,
|
||||||
day=current_day,
|
day=current_day,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
"week": list(
|
"week": list(
|
||||||
self.get_charts_for_period(
|
self.get_charts_for_period(
|
||||||
user, "track", year=current_year, week=current_week
|
user, "album", year=current_year, week=current_week
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
"month": list(
|
"month": list(
|
||||||
self.get_charts_for_period(
|
self.get_charts_for_period(
|
||||||
user,
|
user,
|
||||||
"track",
|
"album",
|
||||||
year=current_year,
|
year=current_year,
|
||||||
month=current_month,
|
month=current_month,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
"year": list(
|
"year": list(
|
||||||
self.get_charts_for_period(user, "track", year=current_year)
|
self.get_charts_for_period(user, "album", year=current_year)
|
||||||
),
|
),
|
||||||
"all": list(self.get_charts_for_period(user, "track")),
|
"all": list(self.get_charts_for_period(user, "album")),
|
||||||
},
|
},
|
||||||
"tv_series": {
|
"tv_series": {
|
||||||
"today": list(
|
"today": list(
|
||||||
self.get_charts_for_period(
|
self.get_charts_for_period(
|
||||||
user,
|
user,
|
||||||
"tv_series",
|
"tv_series",
|
||||||
year=current_year,
|
year=current_year,
|
||||||
month=current_month,
|
month=current_month,
|
||||||
day=current_day,
|
day=current_day,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
"week": list(
|
"week": list(
|
||||||
self.get_charts_for_period(
|
self.get_charts_for_period(
|
||||||
user,
|
user,
|
||||||
"tv_series",
|
"tv_series",
|
||||||
year=current_year,
|
year=current_year,
|
||||||
week=current_week,
|
week=current_week,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
"month": list(
|
"month": list(
|
||||||
self.get_charts_for_period(
|
self.get_charts_for_period(
|
||||||
user,
|
user,
|
||||||
"tv_series",
|
"tv_series",
|
||||||
year=current_year,
|
year=current_year,
|
||||||
month=current_month,
|
month=current_month,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
"year": list(
|
"year": list(
|
||||||
self.get_charts_for_period(user, "tv_series", year=current_year)
|
self.get_charts_for_period(user, "tv_series", year=current_year)
|
||||||
),
|
),
|
||||||
"all": list(self.get_charts_for_period(user, "tv_series")),
|
"all": list(self.get_charts_for_period(user, "tv_series")),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return context
|
|
||||||
|
|
||||||
if not date_param:
|
if not date_param:
|
||||||
context["period"] = "current"
|
context["period"] = "current"
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
|
|
||||||
from profiles.models import UserProfile
|
from profiles.models import UserProfile
|
||||||
|
from scrobbles.constants import Visibility
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
|
|
||||||
class UserProfileForm(forms.ModelForm):
|
class UserProfileForm(forms.ModelForm):
|
||||||
@ -45,3 +47,51 @@ class UserProfileForm(forms.ModelForm):
|
|||||||
"archivebox_password": forms.PasswordInput(render_value=True),
|
"archivebox_password": forms.PasswordInput(render_value=True),
|
||||||
"webdav_pass": 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",
|
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)
|
home_scrobble_limit = models.IntegerField(default=20)
|
||||||
|
|
||||||
weigh_in_units = models.CharField(
|
weigh_in_units = models.CharField(
|
||||||
|
|||||||
@ -6,4 +6,9 @@ app_name = "profiles"
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("settings/", views.ProfileFormView.as_view(), name="profile_settings"),
|
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.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from django.db.models import Count, Q
|
||||||
from django.http.response import HttpResponseBadRequest
|
from django.http.response import HttpResponseBadRequest
|
||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy
|
||||||
from django.views.generic import FormView
|
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
|
from tasks.todoist import generate_todoist_oauth_url
|
||||||
|
|
||||||
|
|
||||||
@ -30,3 +34,46 @@ class ProfileFormView(LoginRequiredMixin, FormView):
|
|||||||
context["profile"] = self.request.user.profile
|
context["profile"] = self.request.user.profile
|
||||||
context["todoist_oauth_url"] = generate_todoist_oauth_url(self.request.user.id)
|
context["todoist_oauth_url"] = generate_todoist_oauth_url(self.request.user.id)
|
||||||
return context
|
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,
|
in_progress=False,
|
||||||
media_type=Scrobble.MediaType.TRACK,
|
media_type=Scrobble.MediaType.TRACK,
|
||||||
timezone=tz_timestamp.tzinfo.name,
|
timezone=tz_timestamp.tzinfo.name,
|
||||||
|
visibility="private",
|
||||||
)
|
)
|
||||||
# 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)
|
||||||
|
|||||||
@ -111,6 +111,7 @@ def import_scale_csv(file_path, user_id):
|
|||||||
played_to_completion=True,
|
played_to_completion=True,
|
||||||
in_progress=False,
|
in_progress=False,
|
||||||
media_type=Scrobble.MediaType.TASK,
|
media_type=Scrobble.MediaType.TASK,
|
||||||
|
visibility="private",
|
||||||
)
|
)
|
||||||
new_scrobbles.append(new_scrobble)
|
new_scrobbles.append(new_scrobble)
|
||||||
|
|
||||||
|
|||||||
@ -305,6 +305,7 @@ def import_trail_gpx(file_path, user_id, original_filename=None):
|
|||||||
played_to_completion=True,
|
played_to_completion=True,
|
||||||
in_progress=False,
|
in_progress=False,
|
||||||
media_type=Scrobble.MediaType.TRAIL,
|
media_type=Scrobble.MediaType.TRAIL,
|
||||||
|
visibility="private",
|
||||||
)
|
)
|
||||||
|
|
||||||
_, ext = os.path.splitext(file_path)
|
_, ext = os.path.splitext(file_path)
|
||||||
|
|||||||
@ -80,6 +80,7 @@ def import_audioscrobbler_tsv_file(file_path, user_id):
|
|||||||
in_progress=False,
|
in_progress=False,
|
||||||
media_type=Scrobble.MediaType.TRACK,
|
media_type=Scrobble.MediaType.TRACK,
|
||||||
timezone=timestamp.tzinfo.name,
|
timezone=timestamp.tzinfo.name,
|
||||||
|
visibility="private",
|
||||||
)
|
)
|
||||||
existing = Scrobble.objects.filter(
|
existing = Scrobble.objects.filter(
|
||||||
timestamp=timestamp, track=track, user=user
|
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,
|
cls,
|
||||||
scrobble_data: dict,
|
scrobble_data: dict,
|
||||||
) -> "Scrobble":
|
) -> "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)
|
scrobble = cls.objects.create(**scrobble_data)
|
||||||
if not (
|
if not (
|
||||||
scrobble.media_type == cls.MediaType.GEO_LOCATION
|
scrobble.media_type == cls.MediaType.GEO_LOCATION
|
||||||
|
|||||||
@ -371,6 +371,9 @@ class ScrobbleListView(LoginRequiredMixin, ListView):
|
|||||||
qs = qs.filter(id__in=matching_ids).distinct()
|
qs = qs.filter(id__in=matching_ids).distinct()
|
||||||
else:
|
else:
|
||||||
tag_list = []
|
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.tag_list = tag_list
|
||||||
self._full_queryset = qs
|
self._full_queryset = qs
|
||||||
return qs
|
return qs
|
||||||
|
|||||||
@ -145,7 +145,7 @@ CELERY_BEAT_SCHEDULE = {
|
|||||||
},
|
},
|
||||||
"import-from-webdav": {
|
"import-from-webdav": {
|
||||||
"task": "scrobbles.tasks.import_from_webdav_all_users",
|
"task": "scrobbles.tasks.import_from_webdav_all_users",
|
||||||
"schedule": crontab(minute="*/3"),
|
"schedule": crontab(minute="*/2"),
|
||||||
},
|
},
|
||||||
# Deprecated: BG Stats files now picked up from WebDAV var/bgstats/
|
# Deprecated: BG Stats files now picked up from WebDAV var/bgstats/
|
||||||
# "import-from-imap": {
|
# "import-from-imap": {
|
||||||
|
|||||||
@ -15,6 +15,7 @@
|
|||||||
<a href="{% url 'charts:charts-home' %}" class="btn btn-sm btn-outline-secondary">Charts</a>
|
<a href="{% url 'charts:charts-home' %}" class="btn btn-sm btn-outline-secondary">Charts</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% block grid_view_button %}
|
||||||
<div class="btn-group me-2">
|
<div class="btn-group me-2">
|
||||||
{% if view == 'grid' %}
|
{% if view == 'grid' %}
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary"><a href="?{% urlreplace view='list' %}">List View</a>
|
<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>
|
<button type="button" class="btn btn-sm btn-outline-secondary"><a href="?{% urlreplace view='grid' %}">Grid View</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endblock %}
|
||||||
{% block charts_button %}{% endblock %}
|
{% block charts_button %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
{% extends "base_list.html" %}
|
{% extends "base_list.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
{% block title %}Charts{% if period_str %} - {{ period_str }}{% endif %}{% endblock %}
|
{% block title %}Charts{% if period_str %} - {{ period_str }}{% endif %}{% endblock %}
|
||||||
|
|
||||||
@ -12,11 +13,46 @@
|
|||||||
.nav-tabs {
|
.nav-tabs {
|
||||||
cursor: pointer;
|
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>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block lists %}
|
{% block lists %}
|
||||||
|
|
||||||
|
{% block grid_view_button %}{% endblock %}
|
||||||
|
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<a href="{% url 'charts:spotify-tracks' %}" class="btn btn-sm btn-outline-secondary">🎵 Spotify Tracks</a>
|
<a href="{% url 'charts:spotify-tracks' %}" class="btn btn-sm btn-outline-secondary">🎵 Spotify Tracks</a>
|
||||||
@ -24,10 +60,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if chart_type == "maloja" %}
|
|
||||||
{% include "scrobbles/_top_charts.html" %}
|
|
||||||
{% else %}
|
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="btn-group mb-3" role="group">
|
<div class="btn-group mb-3" role="group">
|
||||||
@ -88,43 +120,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% include "scrobbles/_top_charts.html" %}
|
||||||
|
|
||||||
<div class="row">
|
<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 %}
|
{% if charts.track %}
|
||||||
<div class="col-md-6 col-lg-4 chart-section">
|
<div class="col-md-6 col-lg-4 chart-section">
|
||||||
<h3>🎵 Top Tracks</h3>
|
<h3>🎵 Top Tracks</h3>
|
||||||
@ -143,24 +141,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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 %}
|
{% if charts.video %}
|
||||||
<div class="col-md-6 col-lg-4 chart-section">
|
<div class="col-md-6 col-lg-4 chart-section">
|
||||||
<h3>🎬 Top Videos</h3>
|
<h3>🎬 Top Videos</h3>
|
||||||
@ -314,6 +294,4 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -106,6 +106,7 @@
|
|||||||
{% block title %}Settings{% endblock %}
|
{% block title %}Settings{% endblock %}
|
||||||
{% block details %}
|
{% block details %}
|
||||||
<p class="settings-link"><a href="{% url 'people:person_form' %}">Manage People</a></p>
|
<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 %}
|
<form method="post" class="settings-form">{% csrf_token %}
|
||||||
{% for field in form %}
|
{% for field in form %}
|
||||||
{% if field.name == "enable_public_widgets" %}
|
{% 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>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<h2>🎵 Top Tracks</h2>
|
<h2>💿 Top Albums</h2>
|
||||||
<ul class="nav nav-tabs" id="trackTab" role="tablist">
|
<ul class="nav nav-tabs" id="albumTab" role="tablist">
|
||||||
{% for key, name in chart_keys.items %}
|
{% for key, name in chart_keys.items %}
|
||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
<button class="nav-link {% if forloop.counter == 2 %}active{% endif %}"
|
<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>
|
type="button" role="tab">{{name}}</button>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</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 %}
|
{% for key, name in chart_keys.items %}
|
||||||
{% with maloja_charts.track|get_item:key as tracks %}
|
{% with maloja_charts.album|get_item:key as albums %}
|
||||||
<div class="tab-pane fade {% if forloop.counter == 2 %}show active{% endif %}" id="track-{{key}}" role="tabpanel">
|
<div class="tab-pane fade {% if forloop.counter == 2 %}show active{% endif %}" id="album-{{key}}" role="tabpanel">
|
||||||
{% if tracks.0 %}
|
{% if albums.0 %}
|
||||||
<div style="display:block">
|
<div style="display:block">
|
||||||
<div style="float:left;">
|
<div style="float:left;">
|
||||||
<div class="image-wrapper" style="display:flex; flex-wrap: wrap; margin:0">
|
<div class="image-wrapper" style="display:flex; flex-wrap: wrap; margin:0">
|
||||||
<div class="caption">#1 {{tracks.0.track.title}}</div>
|
<div class="caption">#1 {{albums.0.album.title}}</div>
|
||||||
{% if tracks.0.track.album.cover_image %}
|
{% if albums.0.album.cover_image %}
|
||||||
<a href="{{tracks.0.track.get_absolute_url}}"><img src="{{tracks.0.track.album.cover_image_medium.url}}" width="300px"></a>
|
<a href="{{albums.0.album.get_absolute_url}}"><img src="{{albums.0.album.cover_image_medium.url}}" width="300px"></a>
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="float:left; width:300px;">
|
<div style="float:left; width:300px;">
|
||||||
<div style="display:flex; flex-wrap: wrap;">
|
<div style="display:flex; flex-wrap: wrap;">
|
||||||
{% for i in "2345" %}
|
{% for i in "2345" %}
|
||||||
{% with tracks|get_item:forloop.counter as track %}
|
{% with albums|get_item:forloop.counter as album %}
|
||||||
{% if track %}
|
{% if album %}
|
||||||
<div class="image-wrapper" style="width:50%">
|
<div class="image-wrapper" style="width:50%">
|
||||||
<div class="caption-medium">#{{forloop.counter|add:1}} {{track.track.title}}</div>
|
<div class="caption-medium">#{{forloop.counter|add:1}} {{album.album.title}}</div>
|
||||||
{% if track.track.album.cover_image %}
|
{% if album.album.cover_image %}
|
||||||
<a href="{{track.track.get_absolute_url}}"><img src="{{track.track.album.cover_image_medium.url}}" width="150px"></a>
|
<a href="{{album.album.get_absolute_url}}"><img src="{{album.album.cover_image_medium.url}}" width="150px"></a>
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -124,14 +124,14 @@
|
|||||||
<div style="float:left; width:300px;">
|
<div style="float:left; width:300px;">
|
||||||
<div style="display:flex; flex-wrap: wrap;">
|
<div style="display:flex; flex-wrap: wrap;">
|
||||||
{% for i in "67891011121314" %}
|
{% for i in "67891011121314" %}
|
||||||
{% with tracks|get_item:forloop.counter|add:5 as track %}
|
{% with albums|get_item:forloop.counter|add:5 as album %}
|
||||||
{% if track %}
|
{% if album %}
|
||||||
<div class="image-wrapper" style="width:33%">
|
<div class="image-wrapper" style="width:33%">
|
||||||
<div class="caption-small">#{{forloop.counter|add:6}} {{track.track.title}}</div>
|
<div class="caption-small">#{{forloop.counter|add:6}} {{album.album.title}}</div>
|
||||||
{% if track.track.album.cover_image %}
|
{% if album.album.cover_image %}
|
||||||
<a href="{{track.track.get_absolute_url}}"><img src="{{track.track.album.cover_image_medium.url}}" width="100px"></a>
|
<a href="{{album.album.get_absolute_url}}"><img src="{{album.album.cover_image_medium.url}}" width="100px"></a>
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@ -13,6 +13,9 @@
|
|||||||
<p class="text-muted small mb-0">Total time: {{ total_time_seconds|natural_duration }}</p>
|
<p class="text-muted small mb-0">Total time: {{ total_time_seconds|natural_duration }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if request.GET.visibility %}
|
||||||
|
<h6 class="text-muted">Filter: {{ request.GET.visibility|title }} scrobbles only</h6>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user