Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c0d2881585 | |||
| 41a68291a4 | |||
| 0a411bedf4 | |||
| f2b67b38dc | |||
| 662ebe66b9 |
19
PROJECT.org
19
PROJECT.org
@ -88,7 +88,8 @@ fetching and simple saving.
|
||||
*** Metadata sources
|
||||
**** Scraper
|
||||
|
||||
* Backlog [0/22] :vrobbler:project:personal:
|
||||
* Backlog [0/23] :vrobbler:project:personal:
|
||||
** TODO [#C] After transition to linux add curl_cffi as webpage scrapper again :webpages:metadata:
|
||||
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles:
|
||||
:PROPERTIES:
|
||||
:ID: 702462cf-d54b-48c6-8a7c-78b8de751deb
|
||||
@ -604,6 +605,22 @@ independent of the email flow it was originally creatdd for
|
||||
|
||||
** TODO [#B] Is there way to create unique slugs for media instances :media_types:
|
||||
|
||||
* Version 58.4 [2/2]
|
||||
** DONE [#B] Allow people all trends or individual trends :trends:profiles:
|
||||
:PROPERTIES:
|
||||
:ID: 1d081152-abd1-73c2-a625-903565a10c6c
|
||||
:END:
|
||||
** DONE [#A] Fix a bug in board game scorelog data :boardgames:logdata:
|
||||
:PROPERTIES:
|
||||
:ID: 014bab30-13bf-fae7-e678-4666a8d38ae4
|
||||
:END:
|
||||
|
||||
* Version 58.3 [1/1]
|
||||
** DONE [#A] Remove curl-cffi as it doesn't work on FreeBSD :webpages:deps:
|
||||
:PROPERTIES:
|
||||
:ID: 6bc1b0dd-e449-3d32-a176-46451e793e5d
|
||||
:END:
|
||||
|
||||
* Version 58.2 [2/2]
|
||||
** DONE [#B] Add more robust webpage scraping :webpages:metadata:
|
||||
:PROPERTIES:
|
||||
|
||||
53
poetry.lock
generated
53
poetry.lock
generated
@ -654,6 +654,7 @@ description = "Foreign Function Interface for Python calling C code."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
markers = "platform_python_implementation != \"PyPy\" or sys_platform == \"darwin\""
|
||||
files = [
|
||||
{file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"},
|
||||
{file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"},
|
||||
@ -1237,48 +1238,6 @@ ssh = ["bcrypt (>=3.1.5)"]
|
||||
test = ["certifi (>=2024)", "cryptography-vectors (==46.0.6)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
|
||||
test-randomorder = ["pytest-randomly"]
|
||||
|
||||
[[package]]
|
||||
name = "curl-cffi"
|
||||
version = "0.15.0"
|
||||
description = "libcurl ffi bindings for Python, with impersonation support."
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "curl_cffi-0.15.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:bda66404010e9ed743b1b83c20c86f24fe21a9a6873e17479d6e67e29d8ded28"},
|
||||
{file = "curl_cffi-0.15.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a25620d9bf989c9c029a7d1642999c4c265abb0bad811deb2f77b0b5b2b12e5b"},
|
||||
{file = "curl_cffi-0.15.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:582e570aa2586b96ed47cf4a17586b9a3c462cbe43f780487c3dc245c6ef1527"},
|
||||
{file = "curl_cffi-0.15.0-cp310-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:838e48212447d9c81364b04707a5c861daf08f8320f9ecb3406a8919d1d5c3b3"},
|
||||
{file = "curl_cffi-0.15.0-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b6c847d86283b07ae69bb72c82eb8a59242277142aa35b89850f89e792a02fc"},
|
||||
{file = "curl_cffi-0.15.0-cp310-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e5e69eee735f659287e2c84444319d68a1fa68dd37abf228943a4074864283a"},
|
||||
{file = "curl_cffi-0.15.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa1323950224db24f4c510d010b3affa02196ca853fb424191fa917a513d3f4b"},
|
||||
{file = "curl_cffi-0.15.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:41f80170ba844009273b2660da1964ec31e99e5719d16b3422ada87177e32e13"},
|
||||
{file = "curl_cffi-0.15.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1977e1e12cfb5c11352cbb74acef1bed24eb7d226dab61ca57c168c21acd4d61"},
|
||||
{file = "curl_cffi-0.15.0-cp310-abi3-win_amd64.whl", hash = "sha256:5a0c1896a0d5a5ac1eb89cd24b008d2b718dd1df6fd2f75451b59ca66e49e572"},
|
||||
{file = "curl_cffi-0.15.0-cp310-abi3-win_arm64.whl", hash = "sha256:a6d57f8389273a3a1f94370473c74897467bcc36af0a17336989780c507fa43d"},
|
||||
{file = "curl_cffi-0.15.0-cp313-abi3-android_24_arm64_v8a.whl", hash = "sha256:4682dc38d4336e0eb0b185374db90a760efde63cbea994b4e63f3521d44c4c92"},
|
||||
{file = "curl_cffi-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:967ad7355bd8e9586f8c2d02eaa99953747549e7ea4a9b25cd53353e6b67fe6d"},
|
||||
{file = "curl_cffi-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7e63539d0d839d0a8c5eacf86229bc68c57803547f35e0db7ee0986328b478c3"},
|
||||
{file = "curl_cffi-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08c799b89740b9bc49c09fbc3d5907f13ac1f845ca52620507ef9466d4639dd5"},
|
||||
{file = "curl_cffi-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b7a92767a888ee90147e18964b396d8435ff42737030d6fb00824ffd6094805"},
|
||||
{file = "curl_cffi-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:829cc357061ecb99cc2d406301f609a039e05665322f5c025ec67c38b0dc49ce"},
|
||||
{file = "curl_cffi-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:408d6f14e346841cd889c2e0962832bb235ba3b6749ebf609f347f747da5e60f"},
|
||||
{file = "curl_cffi-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b624c7ce087bfda967a013ed0a64702a525444e5b6e97d23534d567ccc6525aa"},
|
||||
{file = "curl_cffi-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0b6c0543b993996670e9e4b78e305a2d60809d5681903ffb5568e21a387434d3"},
|
||||
{file = "curl_cffi-0.15.0.tar.gz", hash = "sha256:ea0c67652bf6893d34ee0f82c944f37e488f6147e9421bef1771cc6545b02ded"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
certifi = ">=2024.2.2"
|
||||
cffi = ">=2.0.0"
|
||||
rich = "*"
|
||||
|
||||
[package.extras]
|
||||
build = ["cibuildwheel", "wheel"]
|
||||
dev = ["charset_normalizer (>=3.3.2,<4.0)", "coverage (>=6.4.1,<7.0)", "cryptography (>=46.0.4,<47.0)", "httpx (==0.23.1)", "mypy (>=1.9.0,<2.0)", "pytest (>=8.1.1,<9.0)", "pytest-asyncio (>=0.23.6,<1.0)", "pytest-trio (>=0.8.0,<1.0)", "ruff (>=0.3.5,<1.0)", "trio (>=0.25.0,<1.0)", "trustme (>=1.1.0,<2.0)", "typing_extensions", "uvicorn (>=0.29.0,<1.0)", "websockets (>=14.0)"]
|
||||
extra = ["lxml_html_clean", "markdownify (>=1.1.0)", "readability-lxml (>=0.8.1)"]
|
||||
test = ["charset_normalizer (>=3.3.2,<4.0)", "cryptography (>=46.0.4,<47.0)", "httpx (==0.23.1)", "litestar (>=2.19.0,<3.0)", "proxy.py (>=2.4.3,<3.0)", "pytest (>=8.1.1,<9.0)", "pytest-asyncio (>=0.23.6,<1.0)", "pytest-trio (>=0.8.0,<1.0)", "python-multipart (>=0.0.9,<1.0)", "trio (>=0.25.0,<1.0)", "trustme (>=1.1.0,<2.0)", "typing_extensions", "uvicorn (>=0.29.0,<1.0)", "websockets (>=14.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "dacite"
|
||||
version = "1.9.2"
|
||||
@ -2930,7 +2889,7 @@ version = "4.0.0"
|
||||
description = "Python port of markdown-it. Markdown parsing, done right!"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main", "test"]
|
||||
groups = ["test"]
|
||||
files = [
|
||||
{file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"},
|
||||
{file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"},
|
||||
@ -3026,7 +2985,7 @@ version = "0.1.2"
|
||||
description = "Markdown URL utilities"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main", "test"]
|
||||
groups = ["test"]
|
||||
files = [
|
||||
{file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
|
||||
{file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
|
||||
@ -4228,7 +4187,7 @@ description = "C parser in Python"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
markers = "implementation_name != \"PyPy\""
|
||||
markers = "(platform_python_implementation != \"PyPy\" or sys_platform == \"darwin\") and implementation_name != \"PyPy\""
|
||||
files = [
|
||||
{file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"},
|
||||
{file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"},
|
||||
@ -5407,7 +5366,7 @@ version = "14.3.3"
|
||||
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
|
||||
optional = false
|
||||
python-versions = ">=3.8.0"
|
||||
groups = ["main", "test"]
|
||||
groups = ["test"]
|
||||
files = [
|
||||
{file = "rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d"},
|
||||
{file = "rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b"},
|
||||
@ -6870,4 +6829,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 = "8b1c608beddabe6884d4c2614170ccd71986daccb7db3944cb36531937fdee4e"
|
||||
content-hash = "beac677c269bb8618ca802e5f92f7558391d8d26f1b2150f3c8a6d3417848cb1"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "vrobbler"
|
||||
version = "58.2"
|
||||
version = "58.4"
|
||||
description = ""
|
||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||
|
||||
@ -12,7 +12,6 @@ python-dateutil = "^2.8.2"
|
||||
python-dotenv = ">=0.20.0,<2"
|
||||
python-json-logger = "^2.0.2"
|
||||
cloudscraper = "^1.2.71"
|
||||
curl-cffi = "^0.15.0"
|
||||
colorlog = "^6.6.0"
|
||||
httpx = "<=0.27.2"
|
||||
djangorestframework = "^3.13.1"
|
||||
|
||||
@ -120,7 +120,12 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
|
||||
def player_log(self) -> str:
|
||||
if self.players:
|
||||
return ", ".join(
|
||||
[BoardGameScoreLogData(**player).__str__() for player in self.players]
|
||||
[
|
||||
BoardGameScoreLogData(
|
||||
**{k: v for k, v in player.items() if k in BoardGameScoreLogData.__dataclass_fields__}
|
||||
).__str__()
|
||||
for player in self.players
|
||||
]
|
||||
)
|
||||
return ""
|
||||
|
||||
@ -136,7 +141,9 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
|
||||
if self.players:
|
||||
players_html = []
|
||||
for player_data in self.players:
|
||||
player = BoardGameScoreLogData(**player_data)
|
||||
player = BoardGameScoreLogData(
|
||||
**{k: v for k, v in player_data.items() if k in BoardGameScoreLogData.__dataclass_fields__}
|
||||
)
|
||||
player_info = player.name
|
||||
if player.score:
|
||||
player_info += f" ({player.score})"
|
||||
|
||||
@ -42,6 +42,7 @@ class UserProfileForm(forms.ModelForm):
|
||||
"home_scrobble_limit",
|
||||
"live_now_playing",
|
||||
"weigh_in_units",
|
||||
"trends_disabled",
|
||||
]
|
||||
widgets = {
|
||||
"lastfm_password": forms.PasswordInput(render_value=True),
|
||||
|
||||
@ -0,0 +1,27 @@
|
||||
# Generated by Django 4.2.29 on 2026-06-25 20:43
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("profiles", "0039_userprofile_live_now_playing"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="disabled_trends",
|
||||
field=models.JSONField(
|
||||
blank=True,
|
||||
default=list,
|
||||
help_text="List of trend slugs the user has disabled",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="trends_disabled",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@ -106,6 +106,14 @@ class UserProfile(TimeStampedModel):
|
||||
default=WeighUnit.METRIC,
|
||||
)
|
||||
|
||||
trends_disabled = models.BooleanField(default=False)
|
||||
|
||||
disabled_trends = models.JSONField(
|
||||
default=list,
|
||||
blank=True,
|
||||
help_text="List of trend slugs the user has disabled",
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f"User profile for {self.user}"
|
||||
|
||||
|
||||
@ -38,15 +38,36 @@ class Command(BaseCommand):
|
||||
overall_start = timezone.now()
|
||||
ok_count = 0
|
||||
fail_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for user in users:
|
||||
total_trends = len(TREND_REGISTRY)
|
||||
self.stdout.write(f" {user} ({user.id}): {total_trends} trends...")
|
||||
try:
|
||||
profile = user.profile
|
||||
if profile.trends_disabled:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f" {user} ({user.id}): trends disabled globally, skipping"
|
||||
)
|
||||
)
|
||||
skipped_count += len(TREND_REGISTRY)
|
||||
continue
|
||||
disabled_trends = set(profile.disabled_trends or [])
|
||||
except Exception:
|
||||
disabled_trends = set()
|
||||
|
||||
active_slugs = [
|
||||
s for s in TREND_REGISTRY if s not in disabled_trends
|
||||
]
|
||||
total_trends = len(active_slugs)
|
||||
self.stdout.write(
|
||||
f" {user} ({user.id}): {total_trends} trends ("
|
||||
f"{len(disabled_trends & set(TREND_REGISTRY.keys()))} disabled)..."
|
||||
)
|
||||
user_start = timezone.now()
|
||||
user_ok = 0
|
||||
user_fail = 0
|
||||
|
||||
for idx, (slug, _) in enumerate(TREND_REGISTRY.items(), start=1):
|
||||
for idx, slug in enumerate(active_slugs, start=1):
|
||||
periods = get_supported_periods(slug)
|
||||
self.stdout.write(f" [{idx}/{total_trends}] {slug}...\n")
|
||||
for period in periods:
|
||||
@ -76,7 +97,7 @@ class Command(BaseCommand):
|
||||
overall_elapsed = (timezone.now() - overall_start).total_seconds()
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Done! {ok_count} OK, {fail_count} failed "
|
||||
f"Done! {ok_count} OK, {fail_count} failed, {skipped_count} skipped "
|
||||
f"({overall_elapsed:.1f}s across {total_users} user(s))"
|
||||
)
|
||||
)
|
||||
|
||||
@ -26,7 +26,17 @@ def compute_user_trends(user_id):
|
||||
logger.warning("User %s not found, skipping trends", user_id)
|
||||
return
|
||||
|
||||
total = len(TREND_REGISTRY)
|
||||
try:
|
||||
profile = user.profile
|
||||
if profile.trends_disabled:
|
||||
logger.info("User %s (%d) has trends disabled, skipping", user, user_id)
|
||||
return
|
||||
disabled = set(profile.disabled_trends or [])
|
||||
except Exception:
|
||||
disabled = set()
|
||||
|
||||
active_slugs = [s for s in TREND_REGISTRY if s not in disabled]
|
||||
total = len(active_slugs)
|
||||
logger.info(
|
||||
"Computing %d trends for user %s (%d)",
|
||||
total,
|
||||
@ -34,7 +44,7 @@ def compute_user_trends(user_id):
|
||||
user_id,
|
||||
)
|
||||
|
||||
for idx, (slug, _) in enumerate(TREND_REGISTRY.items(), start=1):
|
||||
for idx, slug in enumerate(active_slugs, start=1):
|
||||
compute_single_trend.delay(user_id, slug)
|
||||
|
||||
logger.info("Dispatched all %d trends for user %s (%d)", total, user, user_id)
|
||||
@ -52,6 +62,21 @@ def compute_single_trend(user_id, slug):
|
||||
logger.warning("Unknown trend slug '%s' for user %d", slug, user_id)
|
||||
return
|
||||
|
||||
try:
|
||||
profile = user.profile
|
||||
if profile.trends_disabled:
|
||||
logger.info(
|
||||
"User %d has trends disabled, skipping '%s'", user_id, slug
|
||||
)
|
||||
return
|
||||
if slug in (profile.disabled_trends or []):
|
||||
logger.info(
|
||||
"User %d has trend '%s' disabled, skipping", user_id, slug
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
periods = get_supported_periods(slug)
|
||||
|
||||
for period in periods:
|
||||
|
||||
@ -3,6 +3,10 @@
|
||||
{% block title %}{{ trend.title }}{% endblock %}
|
||||
|
||||
{% block lists %}
|
||||
{% if trend_not_found %}
|
||||
<div class="alert alert-warning">Trend not found.</div>
|
||||
|
||||
{% else %}
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<a href="{% url 'trends:trends-home' %}" class="btn btn-sm btn-outline-secondary mb-2">← All Trends</a>
|
||||
@ -35,13 +39,21 @@
|
||||
{% if computed_at %}
|
||||
<small class="text-muted">Last computed: {{ computed_at|date:"F j, Y H:i" }}</small>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-2">
|
||||
<form method="post" action="{% url 'trends:trend-toggle-disabled' trend.slug %}" class="d-inline">
|
||||
{% csrf_token %}
|
||||
{% if trend_disabled %}
|
||||
<button type="submit" class="btn btn-sm btn-outline-success">Enable this trend</button>
|
||||
{% else %}
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">Disable this trend</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if trend_not_found %}
|
||||
<div class="alert alert-warning">Trend not found.</div>
|
||||
|
||||
{% elif data is None %}
|
||||
{% if data is None %}
|
||||
<div class="alert alert-info">
|
||||
No data computed yet for this period. Trends are updated once daily, check back later.
|
||||
</div>
|
||||
@ -85,5 +97,6 @@
|
||||
{% elif trend.slug == "mood-weather" %}
|
||||
{% include "trends/_mood_weather.html" %}
|
||||
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@ -25,11 +25,23 @@
|
||||
</a>
|
||||
</h5>
|
||||
<p class="card-text text-muted">{{ trend.description }}</p>
|
||||
{% if trend.computed_at %}
|
||||
<small class="text-muted">Last computed: {{ trend.computed_at|date:"M j, Y H:i" }}</small>
|
||||
{% else %}
|
||||
<span class="badge bg-warning text-dark">Pending</span>
|
||||
{% endif %}
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
{% if trend.computed_at %}
|
||||
<small class="text-muted">Last computed: {{ trend.computed_at|date:"M j, Y H:i" }}</small>
|
||||
{% else %}
|
||||
<span class="badge bg-warning text-dark">Pending</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<form method="post" action="{% url 'trends:trend-toggle-disabled' trend.slug %}" class="m-0">
|
||||
{% csrf_token %}
|
||||
{% if trend.disabled %}
|
||||
<button type="submit" class="btn btn-sm btn-outline-success">Enable</button>
|
||||
{% else %}
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">Disable</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,9 +1,18 @@
|
||||
from django.urls import path
|
||||
from trends.views import TrendDetailView, TrendListView
|
||||
from trends.views import (
|
||||
ToggleTrendDisabledView,
|
||||
TrendDetailView,
|
||||
TrendListView,
|
||||
)
|
||||
|
||||
app_name = "trends"
|
||||
|
||||
urlpatterns = [
|
||||
path("trends/", TrendListView.as_view(), name="trends-home"),
|
||||
path("trends/<slug:trend_slug>/", TrendDetailView.as_view(), name="trend-detail"),
|
||||
path(
|
||||
"trends/<slug:trend_slug>/toggle/",
|
||||
ToggleTrendDisabledView.as_view(),
|
||||
name="trend-toggle-disabled",
|
||||
),
|
||||
]
|
||||
|
||||
@ -3,6 +3,7 @@ from datetime import timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
from trends.models import PERIOD_CHOICES, TrendResult
|
||||
from trends.trends import TREND_REGISTRY
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -35,6 +36,13 @@ TREND_PERIOD_OVERRIDES = {
|
||||
}
|
||||
|
||||
|
||||
def get_disabled_trends(user):
|
||||
profile = user.profile
|
||||
if profile.trends_disabled:
|
||||
return set(TREND_REGISTRY.keys())
|
||||
return set(profile.disabled_trends or [])
|
||||
|
||||
|
||||
def get_supported_periods(trend_slug):
|
||||
if trend_slug in TREND_PERIOD_OVERRIDES:
|
||||
slugs = TREND_PERIOD_OVERRIDES[trend_slug]
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.views.generic import TemplateView
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse
|
||||
from django.views.generic import TemplateView, View
|
||||
from trends.models import TrendResult
|
||||
from trends.trends import TREND_REGISTRY
|
||||
from trends.utils import get_period_nav, get_supported_periods
|
||||
from trends.utils import get_disabled_trends, get_period_nav, get_supported_periods
|
||||
|
||||
TREND_METADATA = {
|
||||
"activity-distribution": {
|
||||
@ -78,6 +81,8 @@ class TrendListView(LoginRequiredMixin, TemplateView):
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
disabled = get_disabled_trends(self.request.user)
|
||||
|
||||
results = TrendResult.objects.filter(
|
||||
user=self.request.user,
|
||||
).order_by("trend_slug", "-computed_at")
|
||||
@ -89,8 +94,11 @@ class TrendListView(LoginRequiredMixin, TemplateView):
|
||||
|
||||
trends = []
|
||||
for slug in TREND_REGISTRY:
|
||||
if slug in disabled:
|
||||
continue
|
||||
meta = TREND_METADATA.get(slug, {})
|
||||
result = latest_by_slug.get(slug)
|
||||
is_disabled = slug in (self.request.user.profile.disabled_trends or [])
|
||||
trends.append(
|
||||
{
|
||||
"slug": slug,
|
||||
@ -99,6 +107,7 @@ class TrendListView(LoginRequiredMixin, TemplateView):
|
||||
"icon": meta.get("icon", ""),
|
||||
"computed_at": result.computed_at if result else None,
|
||||
"has_data": result is not None,
|
||||
"disabled": is_disabled,
|
||||
}
|
||||
)
|
||||
ctx["trends"] = trends
|
||||
@ -148,4 +157,25 @@ class TrendDetailView(LoginRequiredMixin, TemplateView):
|
||||
ctx["computed_at"] = None
|
||||
ctx["data"] = None
|
||||
|
||||
disabled = get_disabled_trends(self.request.user)
|
||||
ctx["trend_disabled"] = slug in disabled
|
||||
ctx["globally_disabled"] = self.request.user.profile.trends_disabled
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
class ToggleTrendDisabledView(LoginRequiredMixin, View):
|
||||
def post(self, request, trend_slug):
|
||||
profile = request.user.profile
|
||||
disabled = set(profile.disabled_trends or [])
|
||||
if trend_slug in disabled:
|
||||
disabled.discard(trend_slug)
|
||||
messages.success(request, f"Trend re-enabled.")
|
||||
else:
|
||||
disabled.add(trend_slug)
|
||||
messages.success(request, f"Trend disabled.")
|
||||
profile.disabled_trends = list(disabled)
|
||||
profile.save(update_fields=["disabled_trends"])
|
||||
return HttpResponseRedirect(
|
||||
request.META.get("HTTP_REFERER", reverse("trends:trends-home"))
|
||||
)
|
||||
|
||||
@ -2,8 +2,6 @@ import logging
|
||||
from typing import Dict, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
import cloudscraper
|
||||
from curl_cffi import requests as curl_requests
|
||||
import pendulum
|
||||
import requests
|
||||
import trafilatura
|
||||
@ -50,32 +48,32 @@ class Domain(TimeStampedModel):
|
||||
def _fetch_url_raw(url: str) -> Optional[str]:
|
||||
"""Fetch raw HTML for a URL.
|
||||
|
||||
Tries three strategies in order:
|
||||
Tries two strategies in order:
|
||||
1. trafilatura (standard, works for most sites)
|
||||
2. cloudscraper (handles basic Cloudflare JS challenges)
|
||||
3. curl_cffi (impersonates browser TLS fingerprint for aggressive
|
||||
Cloudflare / bot detection)
|
||||
2. cloudscraper (handles Cloudflare JS challenges)
|
||||
|
||||
cloudscraper is lazy-imported so deployments that cannot compile
|
||||
its dependencies (e.g. FreeBSD) still work.
|
||||
"""
|
||||
raw = trafilatura.fetch_url(url)
|
||||
if raw:
|
||||
return raw
|
||||
|
||||
logger.debug("trafilatura returned nothing for %s, trying cloudscraper", url)
|
||||
try:
|
||||
scraper = cloudscraper.create_scraper()
|
||||
resp = scraper.get(url, timeout=30)
|
||||
resp.raise_for_status()
|
||||
return resp.text
|
||||
except Exception as exc:
|
||||
logger.debug("cloudscraper failed for %s: %s, trying curl_cffi", url, exc)
|
||||
try:
|
||||
resp = curl_requests.get(
|
||||
url, impersonate="chrome124", timeout=30
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.text
|
||||
except Exception as e:
|
||||
logger.warning("curl_cffi also failed for %s: %s", url, e)
|
||||
return None
|
||||
import cloudscraper
|
||||
except ImportError:
|
||||
logger.debug("cloudscraper not available")
|
||||
else:
|
||||
try:
|
||||
scraper = cloudscraper.create_scraper()
|
||||
resp = scraper.get(url, timeout=30)
|
||||
resp.raise_for_status()
|
||||
return resp.text
|
||||
except Exception as exc:
|
||||
logger.debug("cloudscraper failed for %s: %s", url, exc)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class WebPage(ScrobblableMixin):
|
||||
|
||||
@ -130,6 +130,11 @@
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% elif field.name == "trends_disabled" %}
|
||||
<p class="checkbox-row">
|
||||
{{ field }}
|
||||
{{ field.label_tag }}
|
||||
</p>
|
||||
{% elif field.name == "home_scrobble_limit" %}
|
||||
<p>
|
||||
{{ field.label_tag }}
|
||||
|
||||
@ -76,9 +76,11 @@
|
||||
<div class="btn-group me-2">
|
||||
<a href="{% url 'scrobbles:scrobble-list' %}" class="btn btn-sm btn-outline-secondary">Live</a>
|
||||
</div>
|
||||
{% if not user.profile.trends_disabled %}
|
||||
<div class="btn-group me-2">
|
||||
<a href="{% url 'trends:trends-home' %}" class="btn btn-sm btn-outline-secondary">Trends</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="btn-group me-2">
|
||||
{% if user.profile.lastfm_username and not user.profile.lastfm_auto_import %}
|
||||
<form action="{% url 'scrobbles:lastfm-import' %}" method="get">
|
||||
|
||||
Reference in New Issue
Block a user