Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a880a2f2f | |||
| 248d3f2d3e | |||
| e243fec679 | |||
| de9b4ee9c1 | |||
| bf9a6a9679 | |||
| 709fed5cfe | |||
| b7df6299d0 | |||
| be16d513ef |
115
PROJECT.org
115
PROJECT.org
@ -534,23 +534,130 @@ async with the POST data stored in the log["raw_data"] and used by the celery en
|
||||
to go try to enrich the media instance. Should this enrichment fail, tag the scrobble as "enrichment-failed"
|
||||
log a warning and move on.
|
||||
|
||||
** TODO [#A] Allow updating all a user's scrobble visibility at once :scrobbles:sharing:feature:
|
||||
** 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:
|
||||
: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.
|
||||
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:
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "vrobbler"
|
||||
version = "49.1"
|
||||
version = "50.2"
|
||||
description = ""
|
||||
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)
|
||||
continue
|
||||
|
||||
if "pages" not in book_map[koreader_book_id].keys():
|
||||
book_map[koreader_book_id]["pages"] = {}
|
||||
book_map[koreader_book_id].setdefault("pages", [])
|
||||
|
||||
page_number = page_row[KoReaderPageStatColumn.PAGE.value]
|
||||
duration = page_row[KoReaderPageStatColumn.DURATION.value]
|
||||
start_ts = page_row[KoReaderPageStatColumn.START_TIME.value]
|
||||
|
||||
book_map[koreader_book_id]["pages"][page_number] = {
|
||||
"duration": duration,
|
||||
"start_ts": start_ts,
|
||||
"end_ts": start_ts + duration,
|
||||
}
|
||||
book_map[koreader_book_id]["pages"].append(
|
||||
{
|
||||
"page_number": page_number,
|
||||
"duration": duration,
|
||||
"start_ts": start_ts,
|
||||
"end_ts": start_ts + duration,
|
||||
}
|
||||
)
|
||||
if book_ids_not_found:
|
||||
logger.info(f"Found pages for books not in file: {set(book_ids_not_found)}")
|
||||
return book_map
|
||||
@ -225,11 +227,12 @@ def build_scrobbles_from_book_map(book_map: dict, user: "User") -> list["Scrobbl
|
||||
pages_processed = 0
|
||||
total_pages_read = len(book_map[koreader_book_id]["pages"])
|
||||
ordered_pages = sorted(
|
||||
book_map[koreader_book_id]["pages"].items(),
|
||||
key=lambda x: x[1]["start_ts"],
|
||||
book_map[koreader_book_id]["pages"],
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
if (is_session_gap and not big_jump_to_this_page) or end_of_reading:
|
||||
should_create_scrobble = True
|
||||
|
||||
if should_create_scrobble:
|
||||
if not scrobble_page_data:
|
||||
scrobble_page_data[page_number] = stats
|
||||
scrobble_page_data = dict(
|
||||
sorted(
|
||||
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")))
|
||||
)
|
||||
|
||||
# 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(
|
||||
timestamp=timestamp,
|
||||
book_id=book_id,
|
||||
@ -291,7 +289,7 @@ def build_scrobbles_from_book_map(book_map: dict, user: "User") -> list["Scrobbl
|
||||
|
||||
if not scrobble:
|
||||
logger.info(
|
||||
f"Queueing scrobble for {book_id}, page {cur_page_number}"
|
||||
f"Queueing scrobble for {book_id}, page {page_number}"
|
||||
)
|
||||
log_data = {
|
||||
"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 = {}
|
||||
|
||||
# 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
|
||||
if 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_INT = 0
|
||||
DEFAULT_TIME = 1703800469
|
||||
BOOK_ROWS = []
|
||||
PAGE_STATS_ROWS = []
|
||||
|
||||
def _gen_random_row(self, i):
|
||||
wiggle = random.randrange(15)
|
||||
@ -110,6 +108,8 @@ class KoReaderBookRows:
|
||||
end_session = True
|
||||
|
||||
def __init__(self, book_count=0, **kwargs):
|
||||
self.BOOK_ROWS = []
|
||||
self.PAGE_STATS_ROWS = []
|
||||
self._generate_random_book_rows(book_count)
|
||||
self._generate_custom_book_row(**kwargs)
|
||||
self._generate_random_page_stats_rows()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1613,7 +1613,17 @@ class Scrobble(TimeStampedModel):
|
||||
scrobble_data: dict,
|
||||
) -> "Scrobble":
|
||||
if "visibility" not in scrobble_data:
|
||||
scrobble_data["visibility"] = Visibility.PRIVATE
|
||||
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
|
||||
|
||||
@ -145,7 +145,7 @@ CELERY_BEAT_SCHEDULE = {
|
||||
},
|
||||
"import-from-webdav": {
|
||||
"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/
|
||||
# "import-from-imap": {
|
||||
|
||||
@ -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