Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 08752e30a4 | |||
| 619718c045 | |||
| cb23d5a5be | |||
| ec4c190e6c | |||
| 58126928c7 | |||
| c0d2881585 | |||
| 41a68291a4 | |||
| 0a411bedf4 | |||
| f2b67b38dc | |||
| 662ebe66b9 | |||
| 5e0dffdc7a | |||
| 2283a6c640 | |||
| 327ba94c63 |
48
PROJECT.org
48
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,51 @@ 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.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]
|
||||
** DONE [#B] Add more robust webpage scraping :webpages:metadata:
|
||||
:PROPERTIES:
|
||||
:ID: 84d9bfa5-75c0-0718-764e-379f7456602a
|
||||
:END:
|
||||
** DONE [#B] Time of Day Categories trend :trends:
|
||||
:PROPERTIES:
|
||||
:ID: 6598074f-2290-46db-967b-29f45d30be29
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Added a "Time of Day Categories" trend that groups scrobbles for Books, Trails,
|
||||
Birding Locations, and Board Games into Early Bird (5-10:59am), Day Jay (11am-6:59pm),
|
||||
and Night Owl (7pm-4:59am) buckets. Shows both overall and per-media-type breakdowns.
|
||||
|
||||
|
||||
* Version 58.1 [1/1]
|
||||
** DONE [#B] Add auto genre tagging for papers :books:papers:metadata:
|
||||
:PROPERTIES:
|
||||
|
||||
19
poetry.lock
generated
19
poetry.lock
generated
@ -967,6 +967,23 @@ prompt-toolkit = ">=3.0.36"
|
||||
[package.extras]
|
||||
testing = ["pytest (>=7.2.1)", "pytest-cov (>=4.0.0)", "tox (>=4.4.3)"]
|
||||
|
||||
[[package]]
|
||||
name = "cloudscraper"
|
||||
version = "1.2.71"
|
||||
description = "A Python module to bypass Cloudflare's anti-bot page."
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "cloudscraper-1.2.71-py2.py3-none-any.whl", hash = "sha256:76f50ca529ed2279e220837befdec892626f9511708e200d48d5bb76ded679b0"},
|
||||
{file = "cloudscraper-1.2.71.tar.gz", hash = "sha256:429c6e8aa6916d5bad5c8a5eac50f3ea53c9ac22616f6cb21b18dcc71517d0d3"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
pyparsing = ">=2.4.7"
|
||||
requests = ">=2.9.2"
|
||||
requests-toolbelt = ">=0.9.1"
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
@ -6812,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 = "3081efc33689e0cb1e2cc12709f2fbfd84a084ee4a083eebba80db2eb80fb0a6"
|
||||
content-hash = "beac677c269bb8618ca802e5f92f7558391d8d26f1b2150f3c8a6d3417848cb1"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "vrobbler"
|
||||
version = "58.1"
|
||||
version = "58.6"
|
||||
description = ""
|
||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||
|
||||
@ -11,6 +11,7 @@ django-extensions = "^3.1.5"
|
||||
python-dateutil = "^2.8.2"
|
||||
python-dotenv = ">=0.20.0,<2"
|
||||
python-json-logger = "^2.0.2"
|
||||
cloudscraper = "^1.2.71"
|
||||
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})"
|
||||
|
||||
@ -18,8 +18,17 @@ MISSING_ALL = [
|
||||
"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 = {
|
||||
"cover": lambda b: not bool(b.cover),
|
||||
"cover": _cover_missing_or_broken,
|
||||
"summary": lambda b: not b.summary,
|
||||
"isbn": lambda b: not b.isbn_13 and not b.isbn_10,
|
||||
"pages": lambda b: b.pages is None,
|
||||
|
||||
@ -114,6 +114,51 @@ class ChartRecordView(TemplateView):
|
||||
context["current_week"] = current_week
|
||||
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"] = {
|
||||
"today": "Today",
|
||||
"week": "This Week",
|
||||
@ -126,295 +171,132 @@ class ChartRecordView(TemplateView):
|
||||
"artist": {
|
||||
"today": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"artist",
|
||||
year=current_year,
|
||||
month=current_month,
|
||||
day=current_day,
|
||||
user, "artist", year=year, month=month, day=day,
|
||||
)
|
||||
),
|
||||
"week": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"artist",
|
||||
year=current_year,
|
||||
week=current_week,
|
||||
user, "artist", year=year, week=week,
|
||||
)
|
||||
),
|
||||
"month": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"artist",
|
||||
year=current_year,
|
||||
month=current_month,
|
||||
user, "artist", year=year, month=month,
|
||||
)
|
||||
),
|
||||
"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")),
|
||||
},
|
||||
"album": {
|
||||
"today": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"album",
|
||||
year=current_year,
|
||||
month=current_month,
|
||||
day=current_day,
|
||||
user, "album", year=year, month=month, day=day,
|
||||
)
|
||||
),
|
||||
"week": list(
|
||||
self.get_charts_for_period(
|
||||
user, "album", year=current_year, week=current_week
|
||||
user, "album", year=year, week=week,
|
||||
)
|
||||
),
|
||||
"month": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"album",
|
||||
year=current_year,
|
||||
month=current_month,
|
||||
user, "album", year=year, month=month,
|
||||
)
|
||||
),
|
||||
"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")),
|
||||
},
|
||||
"tv_series": {
|
||||
"today": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"tv_series",
|
||||
year=current_year,
|
||||
month=current_month,
|
||||
day=current_day,
|
||||
user, "tv_series", year=year, month=month, day=day,
|
||||
)
|
||||
),
|
||||
"week": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"tv_series",
|
||||
year=current_year,
|
||||
week=current_week,
|
||||
user, "tv_series", year=year, week=week,
|
||||
)
|
||||
),
|
||||
"month": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"tv_series",
|
||||
year=current_year,
|
||||
month=current_month,
|
||||
user, "tv_series", year=year, month=month,
|
||||
)
|
||||
),
|
||||
"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")),
|
||||
},
|
||||
}
|
||||
|
||||
# List-group tables default to week-level when no date param (matches active tab)
|
||||
if not date_param:
|
||||
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["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
|
||||
)
|
||||
),
|
||||
}
|
||||
list_year = current_year
|
||||
list_month = None
|
||||
list_week = current_week
|
||||
list_day = None
|
||||
else:
|
||||
parts = date_param.split("-")
|
||||
year = int(parts[0])
|
||||
list_year = year
|
||||
list_month = month
|
||||
list_week = week
|
||||
list_day = day
|
||||
|
||||
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
|
||||
|
||||
context["charts"] = {
|
||||
"artist": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"artist",
|
||||
year=year,
|
||||
month=month,
|
||||
week=week,
|
||||
day=day,
|
||||
)
|
||||
),
|
||||
"album": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"album",
|
||||
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,
|
||||
)
|
||||
),
|
||||
}
|
||||
context["charts"] = {
|
||||
"artist": list(
|
||||
self.get_charts_for_period(
|
||||
user, "artist", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
|
||||
)
|
||||
),
|
||||
"album": list(
|
||||
self.get_charts_for_period(
|
||||
user, "album", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
|
||||
)
|
||||
),
|
||||
"track": list(
|
||||
self.get_charts_for_period(
|
||||
user, "track", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
|
||||
)
|
||||
),
|
||||
"tv_series": list(
|
||||
self.get_charts_for_period(
|
||||
user, "tv_series", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
|
||||
)
|
||||
),
|
||||
"video": list(
|
||||
self.get_charts_for_period(
|
||||
user, "video", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
|
||||
)
|
||||
),
|
||||
"board_game": list(
|
||||
self.get_charts_for_period(
|
||||
user, "board_game", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
|
||||
)
|
||||
),
|
||||
"book": list(
|
||||
self.get_charts_for_period(
|
||||
user, "book", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
|
||||
)
|
||||
),
|
||||
"food": list(
|
||||
self.get_charts_for_period(
|
||||
user, "food", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
|
||||
)
|
||||
),
|
||||
"podcast": list(
|
||||
self.get_charts_for_period(
|
||||
user, "podcast", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
|
||||
)
|
||||
),
|
||||
"trail": list(
|
||||
self.get_charts_for_period(
|
||||
user, "trail", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
bird_data = self.get_bird_chart_data(
|
||||
user,
|
||||
|
||||
@ -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
|
||||
@ -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:
|
||||
|
||||
@ -0,0 +1,65 @@
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
{% if data.total and data.total > 0 %}
|
||||
<h5>Overall</h5>
|
||||
<div class="table-responsive mb-4">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Category</th>
|
||||
<th class="text-end">Scrobbles</th>
|
||||
<th class="text-end">%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for slug, info in data.categories.items %}
|
||||
<tr>
|
||||
<td>{{ info.label }}</td>
|
||||
<td class="text-end">{{ info.count }}</td>
|
||||
<td class="text-end">{{ info.pct }}%</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr class="table-secondary">
|
||||
<td><strong>Total</strong></td>
|
||||
<td class="text-end"><strong>{{ data.total }}</strong></td>
|
||||
<td class="text-end"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h5>By Media Type</h5>
|
||||
{% for mt, mt_data in data.by_media_type.items %}
|
||||
<h6 class="mt-3">{{ mt }}</h6>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Category</th>
|
||||
<th class="text-end">Scrobbles</th>
|
||||
<th class="text-end">%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for slug, info in mt_data.categories.items %}
|
||||
<tr>
|
||||
<td>{{ info.label }}</td>
|
||||
<td class="text-end">{{ info.count }}</td>
|
||||
<td class="text-end">{{ info.pct }}%</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr class="table-secondary">
|
||||
<td><strong>Total</strong></td>
|
||||
<td class="text-end"><strong>{{ mt_data.total }}</strong></td>
|
||||
<td class="text-end"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% else %}
|
||||
<p class="text-muted">No data found for Books, Trails, Birding Locations, or Board Games in this period.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@ -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>
|
||||
@ -55,6 +67,9 @@
|
||||
{% elif trend.slug == "reading-pace-vs-activity" %}
|
||||
{% include "trends/_reading_pace.html" %}
|
||||
|
||||
{% elif trend.slug == "time-of-day-categories" %}
|
||||
{% include "trends/_time_of_day_categories.html" %}
|
||||
|
||||
{% elif trend.slug == "trending-up" %}
|
||||
{% include "trends/_trending_up.html" %}
|
||||
|
||||
@ -82,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>
|
||||
|
||||
@ -15,6 +15,7 @@ from trends.trends.mood import (
|
||||
compute_mood_weather,
|
||||
)
|
||||
from trends.trends.reading import compute_reading_pace_vs_activity
|
||||
from trends.trends.time_of_day import compute_time_of_day_categories
|
||||
from trends.trends.trending import compute_trending_up
|
||||
|
||||
TREND_REGISTRY = {}
|
||||
@ -44,5 +45,8 @@ compute_peak_hours = register("peak-hours")(compute_peak_hours)
|
||||
compute_reading_pace_vs_activity = register("reading-pace-vs-activity")(
|
||||
compute_reading_pace_vs_activity
|
||||
)
|
||||
compute_time_of_day_categories = register("time-of-day-categories")(
|
||||
compute_time_of_day_categories
|
||||
)
|
||||
compute_trending_up = register("trending-up")(compute_trending_up)
|
||||
compute_weekly_rhythm = register("weekly-rhythm")(compute_weekly_rhythm)
|
||||
|
||||
89
vrobbler/apps/trends/trends/time_of_day.py
Normal file
89
vrobbler/apps/trends/trends/time_of_day.py
Normal file
@ -0,0 +1,89 @@
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.db.models import Count, Q
|
||||
from django.db.models.functions import Extract
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
|
||||
TARGET_MEDIA_TYPES = ["Book", "Trail", "BirdingLocation", "BoardGame"]
|
||||
|
||||
CATEGORIES = OrderedDict(
|
||||
[
|
||||
("early_bird", {"label": "Early Bird", "hours": {5, 6, 7, 8, 9, 10}}),
|
||||
("day_jay", {"label": "Day Jay", "hours": {11, 12, 13, 14, 15, 16, 17, 18}}),
|
||||
("night_owl", {"label": "Night Owl", "hours": {19, 20, 21, 22, 23, 0, 1, 2, 3, 4}}),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _categorize_hour(hour):
|
||||
for slug, cat in CATEGORIES.items():
|
||||
if hour in cat["hours"]:
|
||||
return slug
|
||||
return None
|
||||
|
||||
|
||||
def compute_time_of_day_categories(user, period="last_30"):
|
||||
from trends.utils import get_date_range
|
||||
|
||||
start, end = get_date_range(period)
|
||||
filters = Q(user=user, media_type__in=TARGET_MEDIA_TYPES, timestamp__isnull=False)
|
||||
if start:
|
||||
filters &= Q(timestamp__gte=start)
|
||||
if end:
|
||||
filters &= Q(timestamp__lte=end)
|
||||
|
||||
qs = (
|
||||
Scrobble.objects.filter(filters)
|
||||
.annotate(hour=Extract("timestamp", "hour"))
|
||||
.values("media_type", "hour")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("media_type", "hour")
|
||||
)
|
||||
|
||||
raw = {}
|
||||
for row in qs:
|
||||
mt = row["media_type"]
|
||||
raw.setdefault(mt, {})[row["hour"]] = row["count"]
|
||||
|
||||
by_media_type = {}
|
||||
grand_totals = {"early_bird": 0, "day_jay": 0, "night_owl": 0}
|
||||
grand_total = 0
|
||||
|
||||
for mt in TARGET_MEDIA_TYPES:
|
||||
mt_data = raw.get(mt, {})
|
||||
cat_counts = {"early_bird": 0, "day_jay": 0, "night_owl": 0}
|
||||
mt_total = 0
|
||||
for hour, count in mt_data.items():
|
||||
slug = _categorize_hour(hour)
|
||||
if slug:
|
||||
cat_counts[slug] += count
|
||||
mt_total += count
|
||||
by_media_type[mt] = {
|
||||
"total": mt_total,
|
||||
"categories": {},
|
||||
}
|
||||
for slug in CATEGORIES:
|
||||
c = cat_counts[slug]
|
||||
by_media_type[mt]["categories"][slug] = {
|
||||
"count": c,
|
||||
"pct": round((c / mt_total * 100), 1) if mt_total else 0,
|
||||
"label": CATEGORIES[slug]["label"],
|
||||
}
|
||||
grand_totals[slug] += c
|
||||
grand_total += mt_total
|
||||
|
||||
categories = {}
|
||||
for slug in CATEGORIES:
|
||||
c = grand_totals[slug]
|
||||
categories[slug] = {
|
||||
"count": c,
|
||||
"pct": round((c / grand_total * 100), 1) if grand_total else 0,
|
||||
"label": CATEGORIES[slug]["label"],
|
||||
}
|
||||
|
||||
return {
|
||||
"categories": categories,
|
||||
"total": grand_total,
|
||||
"by_media_type": by_media_type,
|
||||
}
|
||||
@ -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__)
|
||||
|
||||
@ -25,6 +26,7 @@ TIME_BOUND_TRENDS = {
|
||||
"mood-weather",
|
||||
"peak-hours",
|
||||
"reading-pace-vs-activity",
|
||||
"time-of-day-categories",
|
||||
"trending-up",
|
||||
"weekly-rhythm",
|
||||
}
|
||||
@ -34,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": {
|
||||
@ -55,6 +58,11 @@ TREND_METADATA = {
|
||||
"description": "Compare how long you read per session with and without concurrent music.",
|
||||
"icon": "📊",
|
||||
},
|
||||
"time-of-day-categories": {
|
||||
"title": "Time of Day Categories",
|
||||
"description": "Are you an early bird, day jay, or night owl? Categorized by Books, Trails, Birding Locations, and Board Games.",
|
||||
"icon": "🦉",
|
||||
},
|
||||
"trending-up": {
|
||||
"title": "Trending Media Types",
|
||||
"description": "Which media types have you been consuming more or less of recently?",
|
||||
@ -73,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")
|
||||
@ -84,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,
|
||||
@ -94,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
|
||||
@ -143,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"))
|
||||
)
|
||||
|
||||
@ -17,8 +17,8 @@ MISSING_ALL = [
|
||||
]
|
||||
|
||||
MISSING_GROUPS = {
|
||||
"cover": lambda g: not bool(g.cover),
|
||||
"screenshot": lambda g: not bool(g.screenshot),
|
||||
"cover": lambda g: _image_missing_or_broken(g, "cover"),
|
||||
"screenshot": lambda g: _image_missing_or_broken(g, "screenshot"),
|
||||
"summary": lambda g: not g.summary,
|
||||
"rating": lambda g: g.rating 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):
|
||||
if not flags:
|
||||
return False
|
||||
@ -103,6 +113,7 @@ class Command(BaseCommand):
|
||||
|
||||
if all_missing:
|
||||
flags = MISSING_ALL
|
||||
fix_broken_images = True
|
||||
|
||||
if not flags and not game_id and not force and not fix_broken_images:
|
||||
self.stdout.write(
|
||||
|
||||
@ -30,6 +30,19 @@ class Command(BaseCommand):
|
||||
action="store_true",
|
||||
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):
|
||||
from videos.models import Channel
|
||||
@ -38,6 +51,7 @@ class Command(BaseCommand):
|
||||
dry_run = options["dry_run"]
|
||||
youtube_only = options["youtube_only"]
|
||||
twitch_only = options["twitch_only"]
|
||||
needs_metadata = options["needs_metadata"]
|
||||
|
||||
qs = Channel.objects.all()
|
||||
|
||||
@ -45,29 +59,61 @@ class Command(BaseCommand):
|
||||
qs = qs.exclude(youtube_id__isnull=True).exclude(youtube_id="")
|
||||
elif twitch_only:
|
||||
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:
|
||||
qs = qs.filter(
|
||||
models.Q(youtube_id__isnull=False) | models.Q(twitch_id__isnull=False)
|
||||
).exclude(youtube_id="", twitch_id="")
|
||||
|
||||
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:
|
||||
for channel in qs.iterator():
|
||||
for channel in all_channels:
|
||||
source = "youtube" if channel.youtube_id else "twitch"
|
||||
identifier = channel.youtube_id or channel.twitch_id
|
||||
status = f"({source}: {identifier})"
|
||||
if self._has_broken_image(channel):
|
||||
status += " [image BROKEN]"
|
||||
self.stdout.write(
|
||||
f" [DRY RUN] Would fix {channel.name} ({source}: {identifier})"
|
||||
f" [DRY RUN] Would fix {channel.name} {status}"
|
||||
)
|
||||
return
|
||||
|
||||
updated = 0
|
||||
errors = 0
|
||||
for channel in qs.iterator():
|
||||
for channel in all_channels:
|
||||
try:
|
||||
with transaction.atomic():
|
||||
channel.fix_metadata(force=force)
|
||||
channel.fix_metadata(force=force or self._has_broken_image(channel))
|
||||
updated += 1
|
||||
source = "youtube" if channel.youtube_id else "twitch"
|
||||
self.stdout.write(f" [{updated}/{total}] {channel.name} ({source})")
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import models, transaction
|
||||
@ -28,9 +29,18 @@ class Command(BaseCommand):
|
||||
parser.add_argument(
|
||||
"--needs-metadata",
|
||||
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):
|
||||
from videos.models import Series
|
||||
|
||||
@ -51,25 +61,50 @@ class Command(BaseCommand):
|
||||
)
|
||||
|
||||
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:
|
||||
for series in qs.iterator():
|
||||
for series in all_series:
|
||||
has_imdb = bool(series.imdb_id)
|
||||
has_image = bool(series.cover_image)
|
||||
self.stdout.write(
|
||||
f" [DRY RUN] Would fix {series.name}"
|
||||
f" (imdb_id={'✓' if has_imdb else '✗'}"
|
||||
f", image={'✓' if has_image else '✗'})"
|
||||
)
|
||||
image_broken = self._has_broken_image(series)
|
||||
status = f"imdb_id={'✓' if has_imdb else '✗'}"
|
||||
if image_broken:
|
||||
status += ", image=BROKEN"
|
||||
elif has_image:
|
||||
status += ", image=✓"
|
||||
else:
|
||||
status += ", image=✗"
|
||||
self.stdout.write(f" [DRY RUN] Would fix {series.name} ({status})")
|
||||
return
|
||||
|
||||
updated = 0
|
||||
errors = 0
|
||||
for series in qs.iterator():
|
||||
for series in all_series:
|
||||
try:
|
||||
with transaction.atomic():
|
||||
series.fix_metadata(force_update=force)
|
||||
series.fix_metadata(force_update=force or self._has_broken_image(series))
|
||||
updated += 1
|
||||
self.stdout.write(f" [{updated}/{total}] {series.name}")
|
||||
except Exception as e:
|
||||
|
||||
@ -45,6 +45,37 @@ class Domain(TimeStampedModel):
|
||||
)
|
||||
|
||||
|
||||
def _fetch_url_raw(url: str) -> Optional[str]:
|
||||
"""Fetch raw HTML for a URL.
|
||||
|
||||
Tries two strategies in order:
|
||||
1. trafilatura (standard, works for most sites)
|
||||
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:
|
||||
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):
|
||||
COMPLETION_PERCENT = getattr(settings, "WEBSITE_COMPLETION_PERCENT", 100)
|
||||
|
||||
@ -80,7 +111,7 @@ class WebPage(ScrobblableMixin):
|
||||
|
||||
def _update_extract_from_web(self, raw_text: str = "", force=True):
|
||||
if not raw_text:
|
||||
raw_text = requests.get(self.url, headers=headers).text
|
||||
raw_text = _fetch_url_raw(self.url)
|
||||
if not self.extract or force:
|
||||
self.extract = trafilatura.extract(raw_text)
|
||||
self.save(update_fields=["extract"])
|
||||
@ -259,7 +290,7 @@ class WebPage(ScrobblableMixin):
|
||||
return False
|
||||
|
||||
def fetch_data_from_web(self, save=True, force=True):
|
||||
raw_text = trafilatura.fetch_url(self.url)
|
||||
raw_text = _fetch_url_raw(self.url)
|
||||
if not self.extract or force:
|
||||
self.extract = trafilatura.extract(
|
||||
raw_text,
|
||||
|
||||
@ -122,7 +122,61 @@
|
||||
|
||||
{% 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 »</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.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.track %}
|
||||
<div class="col-md-6 col-lg-4 chart-section">
|
||||
<h3>🎵 Top Tracks</h3>
|
||||
|
||||
@ -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 }}
|
||||
|
||||
@ -49,11 +49,12 @@
|
||||
</div>
|
||||
<div style="float:left; width:300px;">
|
||||
<div style="display:flex; flex-wrap: wrap;">
|
||||
{% for i in "67891011121314" %}
|
||||
{% with artists|get_item:forloop.counter|add:5 as artist %}
|
||||
{% for i in "123456789" %}
|
||||
{% with forloop.counter|add:4 as idx %}
|
||||
{% with artists|get_item:idx as artist %}
|
||||
{% if artist %}
|
||||
<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 %}
|
||||
<a href="{{artist.artist.get_absolute_url}}"><img src="{{artist.artist.thumbnail_medium.url}}" width="100px"></a>
|
||||
{% else %}
|
||||
@ -62,6 +63,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
@ -95,7 +97,7 @@
|
||||
<div style="display:block">
|
||||
<div style="float:left;">
|
||||
<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 %}
|
||||
<a href="{{albums.0.album.get_absolute_url}}"><img src="{{albums.0.album.cover_image_medium.url}}" width="300px"></a>
|
||||
{% else %}
|
||||
@ -109,7 +111,7 @@
|
||||
{% 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}} {{album.album.title}}</div>
|
||||
<div class="caption-medium">#{{forloop.counter|add:1}} {{album.album.name}}</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 %}
|
||||
@ -123,11 +125,12 @@
|
||||
</div>
|
||||
<div style="float:left; width:300px;">
|
||||
<div style="display:flex; flex-wrap: wrap;">
|
||||
{% for i in "67891011121314" %}
|
||||
{% with albums|get_item:forloop.counter|add:5 as album %}
|
||||
{% for i in "123456789" %}
|
||||
{% with forloop.counter|add:4 as idx %}
|
||||
{% with albums|get_item:idx as album %}
|
||||
{% if album %}
|
||||
<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 %}
|
||||
<a href="{{album.album.get_absolute_url}}"><img src="{{album.album.cover_image_medium.url}}" width="100px"></a>
|
||||
{% else %}
|
||||
@ -136,6 +139,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
@ -197,11 +201,12 @@
|
||||
</div>
|
||||
<div style="float:left; width:300px;">
|
||||
<div style="display:flex; flex-wrap: wrap;">
|
||||
{% for i in "67891011121314" %}
|
||||
{% with shows|get_item:forloop.counter|add:5 as show %}
|
||||
{% for i in "123456789" %}
|
||||
{% with forloop.counter|add:4 as idx %}
|
||||
{% with shows|get_item:idx as show %}
|
||||
{% if show %}
|
||||
<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 %}
|
||||
<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 %}
|
||||
@ -210,6 +215,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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