Compare commits

...

10 Commits
58.2 ... 58.6

Author SHA1 Message Date
08752e30a4 [release] Bump to version 58.6
All checks were successful
build / test (push) Successful in 2m5s
deploy / test (push) Successful in 2m17s
deploy / build-and-deploy (push) Successful in 34s
- Cleanup commands should check for broken images
2026-06-30 16:04:37 -04:00
619718c045 [charts] Fix chart page missing tables 2026-06-30 16:04:09 -04:00
cb23d5a5be [metadata] Fix cleanup scripts to check for dead images 2026-06-30 16:03:29 -04:00
ec4c190e6c [release] Bump to version 58.5
All checks were successful
build / test (push) Successful in 2m15s
deploy / test (push) Successful in 2m14s
deploy / build-and-deploy (push) Successful in 56s
- The maloja style charts are messed up
2026-06-30 15:02:16 -04:00
58126928c7 [charts] Fix maloja charts acting weird
Some checks failed
build / test (push) Has been cancelled
2026-06-30 15:01:57 -04:00
c0d2881585 [release] Bump to version 58.4
All checks were successful
build / test (push) Successful in 2m13s
deploy / test (push) Successful in 2m10s
deploy / build-and-deploy (push) Successful in 53s
- Allow people all trends or individual trends
- Fix a bug in board game scorelog data
2026-06-25 20:02:34 -04:00
41a68291a4 [trends] Allow disabling one or many or all trends
All checks were successful
build / test (push) Successful in 2m22s
2026-06-25 18:58:23 -04:00
0a411bedf4 [boardgames] Fix bug in logdata
All checks were successful
build / test (push) Successful in 2m23s
2026-06-24 19:09:43 -04:00
f2b67b38dc [release] Bump to version 58.3
All checks were successful
build / test (push) Successful in 2m2s
deploy / test (push) Successful in 2m5s
deploy / build-and-deploy (push) Successful in 34s
- Remove curl-cffi as it doesn't work on FreeBSD
2026-06-23 23:19:10 -04:00
662ebe66b9 [webpages] Remove curl_cffi as it doesn't work on FreeBSD 2026-06-23 23:17:08 -04:00
25 changed files with 755 additions and 357 deletions

View File

@ -88,7 +88,8 @@ fetching and simple saving.
*** Metadata sources *** Metadata sources
**** Scraper **** 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: ** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles:
:PROPERTIES: :PROPERTIES:
:ID: 702462cf-d54b-48c6-8a7c-78b8de751deb :ID: 702462cf-d54b-48c6-8a7c-78b8de751deb
@ -604,6 +605,34 @@ independent of the email flow it was originally creatdd for
** TODO [#B] Is there way to create unique slugs for media instances :media_types: ** TODO [#B] Is there way to create unique slugs for media instances :media_types:
* Version 58.6 [1/1]
** DONE [#B] Cleanup commands should check for broken images :metadata:cleanup:
:PROPERTIES:
:ID: bacce321-73c7-ae1f-bfa7-c3ee517b5441
:END:
* Version 58.5 [1/1]
** DONE [#A] The maloja style charts are messed up :templates:charts:
:PROPERTIES:
:ID: 987397a2-7e74-4eb1-87cc-4c8bbe1c7b23
:END:
* 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] * Version 58.2 [2/2]
** DONE [#B] Add more robust webpage scraping :webpages:metadata: ** DONE [#B] Add more robust webpage scraping :webpages:metadata:
:PROPERTIES: :PROPERTIES:

53
poetry.lock generated
View File

@ -654,6 +654,7 @@ description = "Foreign Function Interface for Python calling C code."
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"] groups = ["main"]
markers = "platform_python_implementation != \"PyPy\" or sys_platform == \"darwin\""
files = [ 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_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"},
{file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, {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 = ["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"] 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]] [[package]]
name = "dacite" name = "dacite"
version = "1.9.2" version = "1.9.2"
@ -2930,7 +2889,7 @@ version = "4.0.0"
description = "Python port of markdown-it. Markdown parsing, done right!" description = "Python port of markdown-it. Markdown parsing, done right!"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["main", "test"] groups = ["test"]
files = [ files = [
{file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"}, {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"}, {file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"},
@ -3026,7 +2985,7 @@ version = "0.1.2"
description = "Markdown URL utilities" description = "Markdown URL utilities"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["main", "test"] groups = ["test"]
files = [ files = [
{file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
{file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
@ -4228,7 +4187,7 @@ description = "C parser in Python"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["main"] groups = ["main"]
markers = "implementation_name != \"PyPy\"" markers = "(platform_python_implementation != \"PyPy\" or sys_platform == \"darwin\") and implementation_name != \"PyPy\""
files = [ files = [
{file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"},
{file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, {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" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
optional = false optional = false
python-versions = ">=3.8.0" python-versions = ">=3.8.0"
groups = ["main", "test"] groups = ["test"]
files = [ files = [
{file = "rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d"}, {file = "rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d"},
{file = "rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b"}, {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] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = ">=3.11,<3.15" python-versions = ">=3.11,<3.15"
content-hash = "8b1c608beddabe6884d4c2614170ccd71986daccb7db3944cb36531937fdee4e" content-hash = "beac677c269bb8618ca802e5f92f7558391d8d26f1b2150f3c8a6d3417848cb1"

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "vrobbler" name = "vrobbler"
version = "58.2" version = "58.6"
description = "" description = ""
authors = ["Colin Powell <colin@unbl.ink>"] authors = ["Colin Powell <colin@unbl.ink>"]
@ -12,7 +12,6 @@ python-dateutil = "^2.8.2"
python-dotenv = ">=0.20.0,<2" python-dotenv = ">=0.20.0,<2"
python-json-logger = "^2.0.2" python-json-logger = "^2.0.2"
cloudscraper = "^1.2.71" cloudscraper = "^1.2.71"
curl-cffi = "^0.15.0"
colorlog = "^6.6.0" colorlog = "^6.6.0"
httpx = "<=0.27.2" httpx = "<=0.27.2"
djangorestframework = "^3.13.1" djangorestframework = "^3.13.1"

View File

@ -120,7 +120,12 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
def player_log(self) -> str: def player_log(self) -> str:
if self.players: if self.players:
return ", ".join( 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 "" return ""
@ -136,7 +141,9 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
if self.players: if self.players:
players_html = [] players_html = []
for player_data in self.players: 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 player_info = player.name
if player.score: if player.score:
player_info += f" ({player.score})" player_info += f" ({player.score})"

View File

@ -18,8 +18,17 @@ MISSING_ALL = [
"publish_year", "publish_year",
] ]
def _cover_missing_or_broken(book) -> bool:
if not bool(book.cover):
return True
try:
return not book.cover.storage.exists(book.cover.name)
except Exception:
return True
MISSING_GROUPS = { MISSING_GROUPS = {
"cover": lambda b: not bool(b.cover), "cover": _cover_missing_or_broken,
"summary": lambda b: not b.summary, "summary": lambda b: not b.summary,
"isbn": lambda b: not b.isbn_13 and not b.isbn_10, "isbn": lambda b: not b.isbn_13 and not b.isbn_10,
"pages": lambda b: b.pages is None, "pages": lambda b: b.pages is None,

View File

@ -114,6 +114,51 @@ class ChartRecordView(TemplateView):
context["current_week"] = current_week context["current_week"] = current_week
context["current_day"] = current_day context["current_day"] = current_day
# Resolve date parameters
if date_param:
parts = date_param.split("-")
year = int(parts[0])
week = None
month = None
day = None
if len(parts) >= 2 and parts[1].startswith("W"):
week = int(parts[1].lstrip("W"))
elif len(parts) >= 2 and parts[1]:
try:
month = int(parts[1])
except ValueError:
pass
if len(parts) >= 3:
if parts[2].startswith("W"):
week = int(parts[2].lstrip("W"))
elif not parts[2].startswith("W"):
day = int(parts[2])
context["period"] = "historical"
context["year"] = year
context["month"] = month
context["month_name"] = calendar.month_name[month] if month else None
context["week"] = week
context["day"] = day
period_str = str(year)
if month:
period_str = f"{calendar.month_name[month]} {period_str}"
if week:
period_str = f"Week {week}, {period_str}"
if day:
period_str = f"{calendar.month_name[month]} {day}, {year}"
context["period_str"] = period_str
else:
year = current_year
month = current_month
week = current_week
day = current_day
context["period"] = "current"
context["year"] = current_year
context["month"] = current_month
context["month_name"] = calendar.month_name[current_month]
context["week"] = current_week
context["day"] = current_day
context["chart_keys"] = { context["chart_keys"] = {
"today": "Today", "today": "Today",
"week": "This Week", "week": "This Week",
@ -126,295 +171,132 @@ class ChartRecordView(TemplateView):
"artist": { "artist": {
"today": list( "today": list(
self.get_charts_for_period( self.get_charts_for_period(
user, user, "artist", year=year, month=month, day=day,
"artist",
year=current_year,
month=current_month,
day=current_day,
) )
), ),
"week": list( "week": list(
self.get_charts_for_period( self.get_charts_for_period(
user, user, "artist", year=year, week=week,
"artist",
year=current_year,
week=current_week,
) )
), ),
"month": list( "month": list(
self.get_charts_for_period( self.get_charts_for_period(
user, user, "artist", year=year, month=month,
"artist",
year=current_year,
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=year)
), ),
"all": list(self.get_charts_for_period(user, "artist")), "all": list(self.get_charts_for_period(user, "artist")),
}, },
"album": { "album": {
"today": list( "today": list(
self.get_charts_for_period( self.get_charts_for_period(
user, user, "album", year=year, month=month, day=day,
"album",
year=current_year,
month=current_month,
day=current_day,
) )
), ),
"week": list( "week": list(
self.get_charts_for_period( self.get_charts_for_period(
user, "album", year=current_year, week=current_week user, "album", year=year, week=week,
) )
), ),
"month": list( "month": list(
self.get_charts_for_period( self.get_charts_for_period(
user, user, "album", year=year, month=month,
"album",
year=current_year,
month=current_month,
) )
), ),
"year": list( "year": list(
self.get_charts_for_period(user, "album", year=current_year) self.get_charts_for_period(user, "album", year=year)
), ),
"all": list(self.get_charts_for_period(user, "album")), "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", year=year, month=month, day=day,
"tv_series",
year=current_year,
month=current_month,
day=current_day,
) )
), ),
"week": list( "week": list(
self.get_charts_for_period( self.get_charts_for_period(
user, user, "tv_series", year=year, week=week,
"tv_series",
year=current_year,
week=current_week,
) )
), ),
"month": list( "month": list(
self.get_charts_for_period( self.get_charts_for_period(
user, user, "tv_series", year=year, month=month,
"tv_series",
year=current_year,
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=year)
), ),
"all": list(self.get_charts_for_period(user, "tv_series")), "all": list(self.get_charts_for_period(user, "tv_series")),
}, },
} }
# List-group tables default to week-level when no date param (matches active tab)
if not date_param: if not date_param:
context["period"] = "current" list_year = current_year
context["year"] = current_year list_month = None
context["month"] = current_month list_week = current_week
context["month_name"] = calendar.month_name[current_month] list_day = None
context["week"] = current_week
context["day"] = current_day
context["charts"] = {
"artist": list(
self.get_charts_for_period(
user, "artist", year=current_year, limit=20
)
),
"album": list(
self.get_charts_for_period(
user, "album", year=current_year, limit=20
)
),
"track": list(
self.get_charts_for_period(
user, "track", year=current_year, limit=20
)
),
"tv_series": list(
self.get_charts_for_period(
user, "tv_series", year=current_year, limit=20
)
),
"video": list(
self.get_charts_for_period(
user, "video", year=current_year, limit=20
)
),
"board_game": list(
self.get_charts_for_period(
user, "board_game", year=current_year, limit=20
)
),
"book": list(
self.get_charts_for_period(
user, "book", year=current_year, limit=20
)
),
"food": list(
self.get_charts_for_period(
user, "food", year=current_year, limit=20
)
),
"podcast": list(
self.get_charts_for_period(
user, "podcast", year=current_year, limit=20
)
),
"trail": list(
self.get_charts_for_period(
user, "trail", year=current_year, limit=20
)
),
}
else: else:
parts = date_param.split("-") list_year = year
year = int(parts[0]) list_month = month
list_week = week
list_day = day
week = None context["charts"] = {
month = None "artist": list(
day = None self.get_charts_for_period(
user, "artist", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
if len(parts) >= 2 and parts[1].startswith("W"): )
week = int(parts[1].lstrip("W")) ),
elif len(parts) >= 2 and parts[1]: "album": list(
try: self.get_charts_for_period(
month = int(parts[1]) user, "album", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
except ValueError: )
pass ),
"track": list(
if len(parts) >= 3: self.get_charts_for_period(
if parts[2].startswith("W"): user, "track", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
week = int(parts[2].lstrip("W")) )
elif not parts[2].startswith("W"): ),
day = int(parts[2]) "tv_series": list(
self.get_charts_for_period(
context["period"] = "historical" user, "tv_series", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
context["year"] = year )
context["month"] = month ),
context["month_name"] = calendar.month_name[month] if month else None "video": list(
context["week"] = week self.get_charts_for_period(
context["day"] = day user, "video", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
)
period_str = str(year) ),
if month: "board_game": list(
period_str = f"{calendar.month_name[month]} {period_str}" self.get_charts_for_period(
if week: user, "board_game", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
period_str = f"Week {week}, {period_str}" )
if day: ),
period_str = f"{calendar.month_name[month]} {day}, {year}" "book": list(
context["period_str"] = period_str self.get_charts_for_period(
user, "book", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
context["charts"] = { )
"artist": list( ),
self.get_charts_for_period( "food": list(
user, self.get_charts_for_period(
"artist", user, "food", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
year=year, )
month=month, ),
week=week, "podcast": list(
day=day, self.get_charts_for_period(
) user, "podcast", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
), )
"album": list( ),
self.get_charts_for_period( "trail": list(
user, self.get_charts_for_period(
"album", user, "trail", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
year=year, )
month=month, ),
week=week, }
day=day,
)
),
"track": list(
self.get_charts_for_period(
user,
"track",
year=year,
month=month,
week=week,
day=day,
)
),
"tv_series": list(
self.get_charts_for_period(
user,
"tv_series",
year=year,
month=month,
week=week,
day=day,
)
),
"video": list(
self.get_charts_for_period(
user,
"video",
year=year,
month=month,
week=week,
day=day,
)
),
"board_game": list(
self.get_charts_for_period(
user,
"board_game",
year=year,
month=month,
week=week,
day=day,
)
),
"book": list(
self.get_charts_for_period(
user,
"book",
year=year,
month=month,
week=week,
day=day,
)
),
"food": list(
self.get_charts_for_period(
user,
"food",
year=year,
month=month,
week=week,
day=day,
)
),
"podcast": list(
self.get_charts_for_period(
user,
"podcast",
year=year,
month=month,
week=week,
day=day,
)
),
"trail": list(
self.get_charts_for_period(
user,
"trail",
year=year,
month=month,
week=week,
day=day,
)
),
}
bird_data = self.get_bird_chart_data( bird_data = self.get_bird_chart_data(
user, user,

View File

@ -0,0 +1,202 @@
import logging
from django.core.management.base import BaseCommand
from django.db import models, transaction
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "Enrich artist and album metadata (covers, thumbnails) from MusicBrainz and TheAudioDB"
def add_arguments(self, parser):
parser.add_argument(
"--force",
action="store_true",
help="Overwrite existing cover image and metadata",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be done without making changes",
)
parser.add_argument(
"--artists",
action="store_true",
help="Only process artists",
)
parser.add_argument(
"--albums",
action="store_true",
help="Only process albums",
)
parser.add_argument(
"--needs-metadata",
action="store_true",
help="Only process items missing metadata or with broken images",
)
def _has_broken_image(self, obj, field_name: str) -> bool:
field = getattr(obj, field_name, None)
if not field or not field.name:
return False
try:
return not field.storage.exists(field.name)
except Exception:
return True
def handle(self, *args, **options):
from music.models import Album, Artist
force = options["force"]
dry_run = options["dry_run"]
only_artists = options["artists"]
only_albums = options["albums"]
needs_metadata = options["needs_metadata"]
if not only_artists and not only_albums:
only_artists = only_albums = True
updated_total = 0
errors_total = 0
if only_artists:
updated_total += self._process_artists(force, dry_run, needs_metadata)
errors_total = 0 # reset per section
if only_albums:
updated_total += self._process_albums(force, dry_run, needs_metadata)
self.stdout.write(
self.style.SUCCESS(f"\nDone! {updated_total} items processed")
)
def _get_artists(self, needs_metadata: bool):
from music.models import Artist
qs = Artist.objects.all()
if needs_metadata:
qs = qs.filter(
models.Q(theaudiodb_id__isnull=True)
| models.Q(theaudiodb_id="")
| models.Q(thumbnail__isnull=True)
| models.Q(thumbnail="")
)
broken = []
if needs_metadata:
broken_qs = Artist.objects.exclude(
models.Q(thumbnail__isnull=True) | models.Q(thumbnail=""),
)
for artist in broken_qs.iterator():
if self._has_broken_image(artist, "thumbnail"):
broken.append(artist)
return list(qs) + broken
def _get_albums(self, needs_metadata: bool):
from music.models import Album
qs = Album.objects.all()
if needs_metadata:
qs = qs.filter(
models.Q(cover_image__isnull=True)
| models.Q(cover_image="")
| models.Q(cover_image="default-image-replace-me")
)
broken = []
if needs_metadata:
broken_qs = Album.objects.exclude(
models.Q(cover_image__isnull=True) | models.Q(cover_image=""),
)
for album in broken_qs.iterator():
if self._has_broken_image(album, "cover_image"):
broken.append(album)
return list(qs) + broken
def _process_artists(self, force, dry_run, needs_metadata):
from music.models import Artist
artists = self._get_artists(needs_metadata) if needs_metadata else list(Artist.objects.all())
total = len(artists)
self.stdout.write(f"Processing {total} artists")
if dry_run:
for artist in artists:
has_tadb = bool(artist.theaudiodb_id)
has_thumb = bool(artist.thumbnail)
thumb_broken = self._has_broken_image(artist, "thumbnail")
status = f"theaudiodb_id={'' if has_tadb else ''}"
if thumb_broken:
status += ", thumbnail=BROKEN"
elif has_thumb:
status += ", thumbnail=✓"
else:
status += ", thumbnail=✗"
self.stdout.write(f" [DRY RUN] Would fix {artist.name} ({status})")
return 0
updated = 0
errors = 0
for artist in artists:
try:
with transaction.atomic():
artist.fix_metadata(
force_update=force or self._has_broken_image(artist, "thumbnail")
)
updated += 1
self.stdout.write(f" [ARTIST {updated}/{total}] {artist.name}")
except Exception as e:
errors += 1
self.stdout.write(
self.style.ERROR(f" Error updating artist {artist.name}: {e}")
)
self.stdout.write(
self.style.SUCCESS(f"\nArtists done! {updated} updated, {errors} errors")
)
return updated
def _process_albums(self, force, dry_run, needs_metadata):
from music.models import Album
albums = self._get_albums(needs_metadata) if needs_metadata else list(Album.objects.all())
total = len(albums)
self.stdout.write(f"Processing {total} albums")
if dry_run:
for album in albums:
has_cover = bool(album.cover_image)
cover_broken = self._has_broken_image(album, "cover_image")
if cover_broken:
status = "cover=BROKEN"
elif has_cover:
status = "cover=✓"
else:
status = "cover=✗"
self.stdout.write(f" [DRY RUN] Would fix {album.name} ({status})")
return 0
updated = 0
errors = 0
for album in albums:
try:
with transaction.atomic():
if self._has_broken_image(album, "cover_image") or force:
album.fetch_artwork(force=True)
else:
album.fix_metadata()
updated += 1
self.stdout.write(f" [ALBUM {updated}/{total}] {album.name}")
except Exception as e:
errors += 1
self.stdout.write(
self.style.ERROR(f" Error updating album {album.name}: {e}")
)
self.stdout.write(
self.style.SUCCESS(f"\nAlbums done! {updated} updated, {errors} errors")
)
return updated

View File

@ -42,6 +42,7 @@ class UserProfileForm(forms.ModelForm):
"home_scrobble_limit", "home_scrobble_limit",
"live_now_playing", "live_now_playing",
"weigh_in_units", "weigh_in_units",
"trends_disabled",
] ]
widgets = { widgets = {
"lastfm_password": forms.PasswordInput(render_value=True), "lastfm_password": forms.PasswordInput(render_value=True),

View File

@ -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),
),
]

View File

@ -106,6 +106,14 @@ class UserProfile(TimeStampedModel):
default=WeighUnit.METRIC, 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): def __str__(self):
return f"User profile for {self.user}" return f"User profile for {self.user}"

View File

@ -38,15 +38,36 @@ class Command(BaseCommand):
overall_start = timezone.now() overall_start = timezone.now()
ok_count = 0 ok_count = 0
fail_count = 0 fail_count = 0
skipped_count = 0
for user in users: for user in users:
total_trends = len(TREND_REGISTRY) try:
self.stdout.write(f" {user} ({user.id}): {total_trends} trends...") 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_start = timezone.now()
user_ok = 0 user_ok = 0
user_fail = 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) periods = get_supported_periods(slug)
self.stdout.write(f" [{idx}/{total_trends}] {slug}...\n") self.stdout.write(f" [{idx}/{total_trends}] {slug}...\n")
for period in periods: for period in periods:
@ -76,7 +97,7 @@ class Command(BaseCommand):
overall_elapsed = (timezone.now() - overall_start).total_seconds() overall_elapsed = (timezone.now() - overall_start).total_seconds()
self.stdout.write( self.stdout.write(
self.style.SUCCESS( 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))" f"({overall_elapsed:.1f}s across {total_users} user(s))"
) )
) )

View File

@ -26,7 +26,17 @@ def compute_user_trends(user_id):
logger.warning("User %s not found, skipping trends", user_id) logger.warning("User %s not found, skipping trends", user_id)
return 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( logger.info(
"Computing %d trends for user %s (%d)", "Computing %d trends for user %s (%d)",
total, total,
@ -34,7 +44,7 @@ def compute_user_trends(user_id):
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) compute_single_trend.delay(user_id, slug)
logger.info("Dispatched all %d trends for user %s (%d)", total, user, user_id) 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) logger.warning("Unknown trend slug '%s' for user %d", slug, user_id)
return 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) periods = get_supported_periods(slug)
for period in periods: for period in periods:

View File

@ -3,6 +3,10 @@
{% block title %}{{ trend.title }}{% endblock %} {% block title %}{{ trend.title }}{% endblock %}
{% block lists %} {% block lists %}
{% if trend_not_found %}
<div class="alert alert-warning">Trend not found.</div>
{% else %}
<div class="row mb-3"> <div class="row mb-3">
<div class="col-12"> <div class="col-12">
<a href="{% url 'trends:trends-home' %}" class="btn btn-sm btn-outline-secondary mb-2">&larr; All Trends</a> <a href="{% url 'trends:trends-home' %}" class="btn btn-sm btn-outline-secondary mb-2">&larr; All Trends</a>
@ -35,13 +39,21 @@
{% if computed_at %} {% if computed_at %}
<small class="text-muted">Last computed: {{ computed_at|date:"F j, Y H:i" }}</small> <small class="text-muted">Last computed: {{ computed_at|date:"F j, Y H:i" }}</small>
{% endif %} {% 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>
</div> </div>
{% if trend_not_found %} {% if data is None %}
<div class="alert alert-warning">Trend not found.</div>
{% elif data is None %}
<div class="alert alert-info"> <div class="alert alert-info">
No data computed yet for this period. Trends are updated once daily, check back later. No data computed yet for this period. Trends are updated once daily, check back later.
</div> </div>
@ -85,5 +97,6 @@
{% elif trend.slug == "mood-weather" %} {% elif trend.slug == "mood-weather" %}
{% include "trends/_mood_weather.html" %} {% include "trends/_mood_weather.html" %}
{% endif %}
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@ -25,11 +25,23 @@
</a> </a>
</h5> </h5>
<p class="card-text text-muted">{{ trend.description }}</p> <p class="card-text text-muted">{{ trend.description }}</p>
{% if trend.computed_at %} <div class="d-flex justify-content-between align-items-center">
<small class="text-muted">Last computed: {{ trend.computed_at|date:"M j, Y H:i" }}</small> <div>
{% else %} {% if trend.computed_at %}
<span class="badge bg-warning text-dark">Pending</span> <small class="text-muted">Last computed: {{ trend.computed_at|date:"M j, Y H:i" }}</small>
{% endif %} {% 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> </div>
</div> </div>

View File

@ -1,9 +1,18 @@
from django.urls import path from django.urls import path
from trends.views import TrendDetailView, TrendListView from trends.views import (
ToggleTrendDisabledView,
TrendDetailView,
TrendListView,
)
app_name = "trends" app_name = "trends"
urlpatterns = [ urlpatterns = [
path("trends/", TrendListView.as_view(), name="trends-home"), path("trends/", TrendListView.as_view(), name="trends-home"),
path("trends/<slug:trend_slug>/", TrendDetailView.as_view(), name="trend-detail"), path("trends/<slug:trend_slug>/", TrendDetailView.as_view(), name="trend-detail"),
path(
"trends/<slug:trend_slug>/toggle/",
ToggleTrendDisabledView.as_view(),
name="trend-toggle-disabled",
),
] ]

View File

@ -3,6 +3,7 @@ from datetime import timedelta
from django.utils import timezone from django.utils import timezone
from trends.models import PERIOD_CHOICES, TrendResult from trends.models import PERIOD_CHOICES, TrendResult
from trends.trends import TREND_REGISTRY
logger = logging.getLogger(__name__) 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): def get_supported_periods(trend_slug):
if trend_slug in TREND_PERIOD_OVERRIDES: if trend_slug in TREND_PERIOD_OVERRIDES:
slugs = TREND_PERIOD_OVERRIDES[trend_slug] slugs = TREND_PERIOD_OVERRIDES[trend_slug]

View File

@ -1,8 +1,11 @@
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin 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.models import TrendResult
from trends.trends import TREND_REGISTRY 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 = { TREND_METADATA = {
"activity-distribution": { "activity-distribution": {
@ -78,6 +81,8 @@ class TrendListView(LoginRequiredMixin, TemplateView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs) ctx = super().get_context_data(**kwargs)
disabled = get_disabled_trends(self.request.user)
results = TrendResult.objects.filter( results = TrendResult.objects.filter(
user=self.request.user, user=self.request.user,
).order_by("trend_slug", "-computed_at") ).order_by("trend_slug", "-computed_at")
@ -89,8 +94,11 @@ class TrendListView(LoginRequiredMixin, TemplateView):
trends = [] trends = []
for slug in TREND_REGISTRY: for slug in TREND_REGISTRY:
if slug in disabled:
continue
meta = TREND_METADATA.get(slug, {}) meta = TREND_METADATA.get(slug, {})
result = latest_by_slug.get(slug) result = latest_by_slug.get(slug)
is_disabled = slug in (self.request.user.profile.disabled_trends or [])
trends.append( trends.append(
{ {
"slug": slug, "slug": slug,
@ -99,6 +107,7 @@ class TrendListView(LoginRequiredMixin, TemplateView):
"icon": meta.get("icon", ""), "icon": meta.get("icon", ""),
"computed_at": result.computed_at if result else None, "computed_at": result.computed_at if result else None,
"has_data": result is not None, "has_data": result is not None,
"disabled": is_disabled,
} }
) )
ctx["trends"] = trends ctx["trends"] = trends
@ -148,4 +157,25 @@ class TrendDetailView(LoginRequiredMixin, TemplateView):
ctx["computed_at"] = None ctx["computed_at"] = None
ctx["data"] = 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 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"))
)

View File

@ -17,8 +17,8 @@ MISSING_ALL = [
] ]
MISSING_GROUPS = { MISSING_GROUPS = {
"cover": lambda g: not bool(g.cover), "cover": lambda g: _image_missing_or_broken(g, "cover"),
"screenshot": lambda g: not bool(g.screenshot), "screenshot": lambda g: _image_missing_or_broken(g, "screenshot"),
"summary": lambda g: not g.summary, "summary": lambda g: not g.summary,
"rating": lambda g: g.rating is None, "rating": lambda g: g.rating is None,
"release_date": lambda g: g.release_date is None, "release_date": lambda g: g.release_date is None,
@ -28,6 +28,16 @@ MISSING_GROUPS = {
} }
def _image_missing_or_broken(game, field_name) -> bool:
field = getattr(game, field_name)
if not bool(field):
return True
try:
return not field.storage.exists(field.name)
except Exception:
return True
def _game_matches(game, flags): def _game_matches(game, flags):
if not flags: if not flags:
return False return False
@ -103,6 +113,7 @@ class Command(BaseCommand):
if all_missing: if all_missing:
flags = MISSING_ALL flags = MISSING_ALL
fix_broken_images = True
if not flags and not game_id and not force and not fix_broken_images: if not flags and not game_id and not force and not fix_broken_images:
self.stdout.write( self.stdout.write(

View File

@ -30,6 +30,19 @@ class Command(BaseCommand):
action="store_true", action="store_true",
help="Only process channels with a twitch_id", help="Only process channels with a twitch_id",
) )
parser.add_argument(
"--needs-metadata",
action="store_true",
help="Only process channels missing youtube_id, twitch_id, cover image, or with broken cover image",
)
def _has_broken_image(self, channel) -> bool:
if not channel.cover_image or not channel.cover_image.name:
return False
try:
return not channel.cover_image.storage.exists(channel.cover_image.name)
except Exception:
return True
def handle(self, *args, **options): def handle(self, *args, **options):
from videos.models import Channel from videos.models import Channel
@ -38,6 +51,7 @@ class Command(BaseCommand):
dry_run = options["dry_run"] dry_run = options["dry_run"]
youtube_only = options["youtube_only"] youtube_only = options["youtube_only"]
twitch_only = options["twitch_only"] twitch_only = options["twitch_only"]
needs_metadata = options["needs_metadata"]
qs = Channel.objects.all() qs = Channel.objects.all()
@ -45,29 +59,61 @@ class Command(BaseCommand):
qs = qs.exclude(youtube_id__isnull=True).exclude(youtube_id="") qs = qs.exclude(youtube_id__isnull=True).exclude(youtube_id="")
elif twitch_only: elif twitch_only:
qs = qs.exclude(twitch_id__isnull=True).exclude(twitch_id="") qs = qs.exclude(twitch_id__isnull=True).exclude(twitch_id="")
elif needs_metadata:
no_id = models.Q(youtube_id__isnull=True) & models.Q(twitch_id__isnull=True)
no_id |= models.Q(youtube_id="") & models.Q(twitch_id="")
no_id |= models.Q(youtube_id__isnull=True) & models.Q(twitch_id="")
no_id |= models.Q(youtube_id="") & models.Q(twitch_id__isnull=True)
qs = qs.filter(
no_id
| models.Q(cover_image__isnull=True)
| models.Q(cover_image="")
)
else: else:
qs = qs.filter( qs = qs.filter(
models.Q(youtube_id__isnull=False) | models.Q(twitch_id__isnull=False) models.Q(youtube_id__isnull=False) | models.Q(twitch_id__isnull=False)
).exclude(youtube_id="", twitch_id="") ).exclude(youtube_id="", twitch_id="")
total = qs.count() total = qs.count()
self.stdout.write(f"Processing {total} channels") self.stdout.write(f"Processing {total} channels from DB filter")
broken_channels = []
if needs_metadata:
broken_qs = Channel.objects.filter(
cover_image__isnull=False,
).exclude(
cover_image="",
)
if youtube_only:
broken_qs = broken_qs.exclude(youtube_id__isnull=True).exclude(youtube_id="")
elif twitch_only:
broken_qs = broken_qs.exclude(twitch_id__isnull=True).exclude(twitch_id="")
for channel in broken_qs.iterator():
if self._has_broken_image(channel):
broken_channels.append(channel)
all_channels = list(qs) + broken_channels
total = len(all_channels)
self.stdout.write(f"Total channels to process: {total}")
if dry_run: if dry_run:
for channel in qs.iterator(): for channel in all_channels:
source = "youtube" if channel.youtube_id else "twitch" source = "youtube" if channel.youtube_id else "twitch"
identifier = channel.youtube_id or channel.twitch_id identifier = channel.youtube_id or channel.twitch_id
status = f"({source}: {identifier})"
if self._has_broken_image(channel):
status += " [image BROKEN]"
self.stdout.write( self.stdout.write(
f" [DRY RUN] Would fix {channel.name} ({source}: {identifier})" f" [DRY RUN] Would fix {channel.name} {status}"
) )
return return
updated = 0 updated = 0
errors = 0 errors = 0
for channel in qs.iterator(): for channel in all_channels:
try: try:
with transaction.atomic(): with transaction.atomic():
channel.fix_metadata(force=force) channel.fix_metadata(force=force or self._has_broken_image(channel))
updated += 1 updated += 1
source = "youtube" if channel.youtube_id else "twitch" source = "youtube" if channel.youtube_id else "twitch"
self.stdout.write(f" [{updated}/{total}] {channel.name} ({source})") self.stdout.write(f" [{updated}/{total}] {channel.name} ({source})")

View File

@ -1,4 +1,5 @@
import logging import logging
import os
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.db import models, transaction from django.db import models, transaction
@ -28,9 +29,18 @@ class Command(BaseCommand):
parser.add_argument( parser.add_argument(
"--needs-metadata", "--needs-metadata",
action="store_true", action="store_true",
help="Only process series missing imdb_id or cover image", help="Only process series missing imdb_id or with broken cover image",
) )
def _has_broken_image(self, series) -> bool:
"""Check if a series has a cover_image set but the file is missing."""
if not series.cover_image:
return False
try:
return not os.path.exists(series.cover_image.path)
except Exception:
return True
def handle(self, *args, **options): def handle(self, *args, **options):
from videos.models import Series from videos.models import Series
@ -51,25 +61,50 @@ class Command(BaseCommand):
) )
total = qs.count() total = qs.count()
self.stdout.write(f"Processing {total} series") self.stdout.write(f"Processing {total} series from DB filter")
# Also find series with broken cover images
broken_image_series = []
if needs_metadata:
broken_qs = Series.objects.filter(
cover_image__isnull=False,
).exclude(
models.Q(imdb_id__isnull=True)
| models.Q(imdb_id="")
| models.Q(cover_image__isnull=True)
| models.Q(cover_image=""),
)
if imdb_id:
broken_qs = broken_qs.filter(imdb_id=imdb_id)
for series in broken_qs.iterator():
if self._has_broken_image(series):
broken_image_series.append(series)
all_series = list(qs) + broken_image_series
total = len(all_series)
self.stdout.write(f"Total series to process: {total}")
if dry_run: if dry_run:
for series in qs.iterator(): for series in all_series:
has_imdb = bool(series.imdb_id) has_imdb = bool(series.imdb_id)
has_image = bool(series.cover_image) has_image = bool(series.cover_image)
self.stdout.write( image_broken = self._has_broken_image(series)
f" [DRY RUN] Would fix {series.name}" status = f"imdb_id={'' if has_imdb else ''}"
f" (imdb_id={'' if has_imdb else ''}" if image_broken:
f", image={'' if has_image else ''})" status += ", image=BROKEN"
) elif has_image:
status += ", image=✓"
else:
status += ", image=✗"
self.stdout.write(f" [DRY RUN] Would fix {series.name} ({status})")
return return
updated = 0 updated = 0
errors = 0 errors = 0
for series in qs.iterator(): for series in all_series:
try: try:
with transaction.atomic(): with transaction.atomic():
series.fix_metadata(force_update=force) series.fix_metadata(force_update=force or self._has_broken_image(series))
updated += 1 updated += 1
self.stdout.write(f" [{updated}/{total}] {series.name}") self.stdout.write(f" [{updated}/{total}] {series.name}")
except Exception as e: except Exception as e:

View File

@ -2,8 +2,6 @@ import logging
from typing import Dict, Optional from typing import Dict, Optional
from uuid import uuid4 from uuid import uuid4
import cloudscraper
from curl_cffi import requests as curl_requests
import pendulum import pendulum
import requests import requests
import trafilatura import trafilatura
@ -50,32 +48,32 @@ class Domain(TimeStampedModel):
def _fetch_url_raw(url: str) -> Optional[str]: def _fetch_url_raw(url: str) -> Optional[str]:
"""Fetch raw HTML for a URL. """Fetch raw HTML for a URL.
Tries three strategies in order: Tries two strategies in order:
1. trafilatura (standard, works for most sites) 1. trafilatura (standard, works for most sites)
2. cloudscraper (handles basic Cloudflare JS challenges) 2. cloudscraper (handles Cloudflare JS challenges)
3. curl_cffi (impersonates browser TLS fingerprint for aggressive
Cloudflare / bot detection) cloudscraper is lazy-imported so deployments that cannot compile
its dependencies (e.g. FreeBSD) still work.
""" """
raw = trafilatura.fetch_url(url) raw = trafilatura.fetch_url(url)
if raw: if raw:
return raw return raw
logger.debug("trafilatura returned nothing for %s, trying cloudscraper", url) logger.debug("trafilatura returned nothing for %s, trying cloudscraper", url)
try: try:
scraper = cloudscraper.create_scraper() import cloudscraper
resp = scraper.get(url, timeout=30) except ImportError:
resp.raise_for_status() logger.debug("cloudscraper not available")
return resp.text else:
except Exception as exc: try:
logger.debug("cloudscraper failed for %s: %s, trying curl_cffi", url, exc) scraper = cloudscraper.create_scraper()
try: resp = scraper.get(url, timeout=30)
resp = curl_requests.get( resp.raise_for_status()
url, impersonate="chrome124", timeout=30 return resp.text
) except Exception as exc:
resp.raise_for_status() logger.debug("cloudscraper failed for %s: %s", url, exc)
return resp.text
except Exception as e: return None
logger.warning("curl_cffi also failed for %s: %s", url, e)
return None
class WebPage(ScrobblableMixin): class WebPage(ScrobblableMixin):

View File

@ -122,7 +122,61 @@
{% include "scrobbles/_top_charts.html" %} {% include "scrobbles/_top_charts.html" %}
<div class="row"> <div class="row mt-4">
{% 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 &raquo;</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 &raquo;</a>
</li>
</ul>
</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 &raquo;</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>

View File

@ -130,6 +130,11 @@
</ul> </ul>
</div> </div>
{% endif %} {% endif %}
{% elif field.name == "trends_disabled" %}
<p class="checkbox-row">
{{ field }}
{{ field.label_tag }}
</p>
{% elif field.name == "home_scrobble_limit" %} {% elif field.name == "home_scrobble_limit" %}
<p> <p>
{{ field.label_tag }} {{ field.label_tag }}

View File

@ -49,11 +49,12 @@
</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 "67891011121314" %} {% for i in "123456789" %}
{% with artists|get_item:forloop.counter|add:5 as artist %} {% with forloop.counter|add:4 as idx %}
{% with artists|get_item:idx as artist %}
{% if artist %} {% if artist %}
<div class="image-wrapper" style="width:33%"> <div class="image-wrapper" style="width:33%">
<div class="caption-small">#{{forloop.counter|add:6}} {{artist.artist.name}}</div> <div class="caption-small">#{{forloop.counter|add:5}} {{artist.artist.name}}</div>
{% if artist.artist.thumbnail %} {% if artist.artist.thumbnail %}
<a href="{{artist.artist.get_absolute_url}}"><img src="{{artist.artist.thumbnail_medium.url}}" width="100px"></a> <a href="{{artist.artist.get_absolute_url}}"><img src="{{artist.artist.thumbnail_medium.url}}" width="100px"></a>
{% else %} {% else %}
@ -62,6 +63,7 @@
</div> </div>
{% endif %} {% endif %}
{% endwith %} {% endwith %}
{% endwith %}
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
@ -95,7 +97,7 @@
<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 {{albums.0.album.title}}</div> <div class="caption">#1 {{albums.0.album.name}}</div>
{% if albums.0.album.cover_image %} {% 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> <a href="{{albums.0.album.get_absolute_url}}"><img src="{{albums.0.album.cover_image_medium.url}}" width="300px"></a>
{% else %} {% else %}
@ -109,7 +111,7 @@
{% with albums|get_item:forloop.counter as album %} {% with albums|get_item:forloop.counter as album %}
{% if album %} {% if album %}
<div class="image-wrapper" style="width:50%"> <div class="image-wrapper" style="width:50%">
<div class="caption-medium">#{{forloop.counter|add:1}} {{album.album.title}}</div> <div class="caption-medium">#{{forloop.counter|add:1}} {{album.album.name}}</div>
{% if album.album.cover_image %} {% if album.album.cover_image %}
<a href="{{album.album.get_absolute_url}}"><img src="{{album.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 %}
@ -123,11 +125,12 @@
</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 "67891011121314" %} {% for i in "123456789" %}
{% with albums|get_item:forloop.counter|add:5 as album %} {% with forloop.counter|add:4 as idx %}
{% with albums|get_item:idx as album %}
{% if album %} {% if album %}
<div class="image-wrapper" style="width:33%"> <div class="image-wrapper" style="width:33%">
<div class="caption-small">#{{forloop.counter|add:6}} {{album.album.title}}</div> <div class="caption-small">#{{forloop.counter|add:5}} {{album.album.name}}</div>
{% if album.album.cover_image %} {% if album.album.cover_image %}
<a href="{{album.album.get_absolute_url}}"><img src="{{album.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 %}
@ -136,6 +139,7 @@
</div> </div>
{% endif %} {% endif %}
{% endwith %} {% endwith %}
{% endwith %}
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
@ -197,11 +201,12 @@
</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 "67891011121314" %} {% for i in "123456789" %}
{% with shows|get_item:forloop.counter|add:5 as show %} {% with forloop.counter|add:4 as idx %}
{% with shows|get_item:idx as show %}
{% if show %} {% if show %}
<div class="image-wrapper" style="width:33%"> <div class="image-wrapper" style="width:33%">
<div class="caption-small">#{{forloop.counter|add:6}} {{show.tv_series.name}}</div> <div class="caption-small">#{{forloop.counter|add:5}} {{show.tv_series.name}}</div>
{% if show.tv_series.cover_image %} {% if show.tv_series.cover_image %}
<a href="{{show.tv_series.get_absolute_url}}"><img src="{{show.tv_series.cover_small.url}}" width="100px" height="100px" style="object-fit: cover"></a> <a href="{{show.tv_series.get_absolute_url}}"><img src="{{show.tv_series.cover_small.url}}" width="100px" height="100px" style="object-fit: cover"></a>
{% else %} {% else %}
@ -210,6 +215,7 @@
</div> </div>
{% endif %} {% endif %}
{% endwith %} {% endwith %}
{% endwith %}
{% endfor %} {% endfor %}
</div> </div>
</div> </div>

View File

@ -76,9 +76,11 @@
<div class="btn-group me-2"> <div class="btn-group me-2">
<a href="{% url 'scrobbles:scrobble-list' %}" class="btn btn-sm btn-outline-secondary">Live</a> <a href="{% url 'scrobbles:scrobble-list' %}" class="btn btn-sm btn-outline-secondary">Live</a>
</div> </div>
{% if not user.profile.trends_disabled %}
<div class="btn-group me-2"> <div class="btn-group me-2">
<a href="{% url 'trends:trends-home' %}" class="btn btn-sm btn-outline-secondary">Trends</a> <a href="{% url 'trends:trends-home' %}" class="btn btn-sm btn-outline-secondary">Trends</a>
</div> </div>
{% endif %}
<div class="btn-group me-2"> <div class="btn-group me-2">
{% if user.profile.lastfm_username and not user.profile.lastfm_auto_import %} {% if user.profile.lastfm_username and not user.profile.lastfm_auto_import %}
<form action="{% url 'scrobbles:lastfm-import' %}" method="get"> <form action="{% url 'scrobbles:lastfm-import' %}" method="get">