Compare commits

..

5 Commits
53.1 ... 54.0

Author SHA1 Message Date
253e58eb48 [release] Bump to version 54.0
All checks were successful
build / test (push) Successful in 1m50s
deploy / test (push) Successful in 1m57s
deploy / build-and-deploy (push) Successful in 49s
- Add peak hour, weekly rhythm and activity dist trends
- Implement YouTube channel info scraping
- Fix Amazon book scraper
2026-06-16 16:42:44 -04:00
5393996e47 [trends] Add peak hours, weekly rhtyhms and activity dist trends
Some checks failed
build / test (push) Has been cancelled
2026-06-16 16:42:14 -04:00
1624f01e11 [videos] Increase metdata for Youtube
All checks were successful
build / test (push) Successful in 2m1s
2026-06-16 15:54:16 -04:00
535dead7e8 [videos] Clean up channel metadata
All checks were successful
build / test (push) Successful in 1m54s
2026-06-16 13:49:32 -04:00
3b97d49227 [books] Fix amazon scrapper to use actual AWS endpoints
All checks were successful
build / test (push) Successful in 2m2s
2026-06-16 13:18:20 -04:00
21 changed files with 786 additions and 164 deletions

View File

@ -88,7 +88,7 @@ fetching and simple saving.
*** Metadata sources
**** Scraper
* Backlog [0/19] :vrobbler:project:personal:
* Backlog [0/20] :vrobbler:project:personal:
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles:
:PROPERTIES:
:ID: 702462cf-d54b-48c6-8a7c-78b8de751deb
@ -578,6 +578,49 @@ named constants for maintainability.
- ~vrobbler/apps/webpages/models.py~ (line 290) -- ="url"=
- ~vrobbler/apps/scrobbles/importers/tsv.py~ (line 55) -- ="S"= completion status
** TODO [#C] Clean up naming of =bgsplay= parsing :importers:refactoring:
:PROPERTIES:
:ID: c751dbbc-464a-4e63-9fe3-e034303f7b54
:END:
*** Description
We should rename `email_scrobble_board_game` to reflect the fact that it's just
a helper method to create board game scrobbles given a json blob. It's
independent of the email flow it was originally creatdd for
* Version 54.0 [3/3]
** DONE [#B] Add peak hour, weekly rhythm and activity dist trends :trends:scrobbles:
:PROPERTIES:
:ID: 5fa52fac-d5f0-4369-bcaa-589c886b07d3
:END:
** DONE [#A] Implement YouTube channel info scraping :videos:youtube:stub:
:PROPERTIES:
:ID: 1d3beafd-62cb-4735-a465-edb37bf885db
:END:
*** Description
File: ~vrobbler/apps/videos/models.py~ (line 140)
=Video.fix_metadata()= is a stub that logs "Not implemented yet" and returns.
Needs actual implementation to scrape channel metadata from YouTube.
** DONE [#A] Fix Amazon book scraper :amazon:scraper:broken:
:PROPERTIES:
:ID: c38aba25-0171-49ab-a9f3-acf2003da429
:END:
*** Description
File: ~vrobbler/apps/books/amazon.py~ (line 56)
The =scrape_data_from_amazon()= function is likely broken due to Amazon blocking
scrapers and changing HTML structure. Needs rewrite or replacement with a proper
API.
* Version 53.1 [1/1]
** DONE [#A] Error with loading logdict :scrobbles:bug:logdata:
:PROPERTIES:

25
poetry.lock generated
View File

@ -4270,6 +4270,29 @@ six = "*"
[package.extras]
testing = ["filelock"]
[[package]]
name = "python-amazon-paapi"
version = "6.3.0"
description = "Amazon Product Advertising API 5.0 wrapper for Python"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "python_amazon_paapi-6.3.0-py3-none-any.whl", hash = "sha256:b7cd852084a49d53c3ba2195531fccbc8c7f4124b2e82e2fda02b53d3b8de521"},
{file = "python_amazon_paapi-6.3.0.tar.gz", hash = "sha256:e525d69efcbe4f9566ec2b9b43fa3183c484d166d3852edb38b4df9c0b19cf1f"},
]
[package.dependencies]
certifi = ">=2023.0.0"
pydantic = ">=2.0.0"
python-dateutil = ">=2.8.0"
requests = ">=2.28.0"
six = ">=1.16.0"
urllib3 = ">=1.26.0,<3"
[package.extras]
async = ["httpx (>=0.27.0)", "typing-extensions (>=4.15.0)"]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
@ -6032,4 +6055,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 = "cc5b3b44071d6b0ab4f05189580232cc129b4ed694ab3f0673c3d838c3af0f8a"
content-hash = "aafab54d3c3d674b917782bf449b7d6324ca2259fb58bff13a08caabe110c342"

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "vrobbler"
version = "53.1"
version = "54.0"
description = ""
authors = ["Colin Powell <colin@unbl.ink>"]
@ -64,6 +64,7 @@ fitparse = "^1.2.0"
lxml = ">=5.5.0"
vaderSentiment = "^3.3.2"
sqids = "^0.5.2"
python-amazon-paapi = "^6.3.0"
[tool.poetry.group.test]
optional = true

View File

@ -1,128 +0,0 @@
from enum import Enum
from typing import Optional
from bs4 import BeautifulSoup
import requests
import logging
logger = logging.getLogger(__name__)
USER_AGENT = "Mozilla/5.0 (Android 4.4; Mobile; rv:41.0) Gecko/41.0 Firefox/41.0"
AMAZON_SEARCH_URL = "https://www.amazon.com/s?k={amazon_id}"
class AmazonAttribute(Enum):
SERIES = 0
PAGES = 1
LANGUAGE = 2
PUBLISHER = 3
PUB_DATE = 4
DIMENSIONS = 5
ISBN_10 = 6
ISBN_13 = 7
def strip_and_clean(text):
return text.strip("\n").rstrip().lstrip()
def get_rating_from_soup(soup) -> Optional[int]:
rating = None
try:
potential_rating = soup.find("div", class_="allmusic-rating")
if potential_rating:
rating = int(strip_and_clean(potential_rating.get_text()))
except ValueError:
pass
return rating
def get_review_from_soup(soup) -> str:
review = ""
try:
potential_text = soup.find("div", class_="text")
if potential_text:
review = strip_and_clean(potential_text.get_text())
except ValueError:
pass
return review
def scrape_data_from_amazon(url) -> dict:
data_dict = {}
headers = {"User-Agent": USER_AGENT}
r = requests.get(url, headers=headers)
if r.status_code == 200:
soup = BeautifulSoup(r.text, "html.parser")
# TODO Fix this scraper
data_dict["rating"] = get_rating_from_soup(soup)
data_dict["review"] = get_review_from_soup(soup)
return data_dict
def get_amazon_product_dict(amazon_id: str) -> dict:
data_dict = {}
url = ""
search_url = AMAZON_SEARCH_URL.format(amazon_id=amazon_id)
headers = {
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36",
"accept-language": "en-GB,en;q=0.9",
}
response = requests.get(search_url, headers=headers)
if response.status_code != 200:
logger.info(f"Bad http response from Amazon {response}")
return data_dict
soup = BeautifulSoup(response.text, "html.parser")
results = soup.find("a", class_="a-link-normal")
if not results:
logger.info(f"No search results for {amazon_id}")
return data_dict
product_url = "https://www.amazon.com" + str(results.get("href", ""))
data_dict = {}
response = requests.get(product_url, headers=headers)
if response.status_code != 200:
logger.info(f"Bad http response from Amazon {response}")
return data_dict
soup = BeautifulSoup(response.text, "html.parser")
try:
data_dict["title"] = soup.findAll("span", class_="celwidget")[1].text.strip()
data_dict["cover_url"] = soup.find("img", class_="frontImage").get("src")
data_dict["summary"] = soup.findAll("div", class_="a-expander-content")[1].text
meta = soup.findAll("div", class_="rpi-attribute-value")
data_dict["isbn"] = meta[AmazonAttribute.ISBN_10.value].text.strip()
pages = meta[AmazonAttribute.PAGES.value].text
if "pages" in pages:
data_dict["pages"] = (
meta[AmazonAttribute.PAGES.value].text.split("pages")[0].strip()
)
except IndexError as e:
logger.error(f"Amazon lookup is failing for this product {amazon_id}: {e}")
except AttributeError as e:
logger.error(f"Amazon lookup is failing for this product {amazon_id}: {e}")
return data_dict
def lookup_book_from_amazon(amazon_id: str) -> dict:
top = {}
return {
"title": top.get("title"),
"isbn": isbn,
"openlibrary_id": ol_id,
"goodreads_id": get_first("id_goodreads", top),
"first_publish_year": top.get("first_publish_year"),
"first_sentence": first_sentence,
"pages": top.get("number_of_pages_median", None),
"cover_url": COVER_URL.format(id=ol_id),
"ol_author_id": ol_author_id,
"subject_key_list": top.get("subject_key", []),
}

View File

@ -17,6 +17,7 @@ class MediaSourceTag(str, Enum):
LOCG = "source_locg"
KOREADER = "source_koreader"
SEMANTIC_SCHOLAR = "source_semantic_scholar"
AMAZON = "source_amazon"
@classmethod
def choices(cls):

View File

@ -23,6 +23,7 @@ from books.sources.comicvine import (
lookup_issue_by_comicvine_id,
)
from books.sources.google import lookup_book_from_google
from books.sources.amazon import lookup_book_from_amazon
from books.sources.openlibrary import (
lookup_book_from_openlibrary as lookup_book_from_ol,
)
@ -260,6 +261,7 @@ class Book(LongPlayScrobblableMixin):
url: str = "",
enrich: bool = True,
commit: bool = True,
amazon_id: str | None = None,
):
"""Given a title, get a Book instance.
@ -321,6 +323,13 @@ class Book(LongPlayScrobblableMixin):
book_dict.setdefault(k, v)
source_tag = MediaSourceTag.COMICVINE
# Try Amazon PAAPI as a fallback when given an ASIN
if amazon_id and not book_dict:
amazon_data = lookup_book_from_amazon(amazon_id)
if amazon_data:
book_dict.update(amazon_data)
source_tag = MediaSourceTag.AMAZON
if not book_dict:
logger.warning(
"No book found in any source, using data as is",

View File

@ -0,0 +1,123 @@
import logging
import warnings
from django.conf import settings
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
from amazon_paapi import AmazonApi
logger = logging.getLogger(__name__)
_amazon_client = None
def _get_client() -> AmazonApi | None:
global _amazon_client
if _amazon_client is not None:
return _amazon_client
key = settings.AMAZON_PAAPI_ACCESS_KEY
secret = settings.AMAZON_PAAPI_SECRET_KEY
tag = settings.AMAZON_PAAPI_ASSOCIATE_TAG
country = settings.AMAZON_PAAPI_COUNTRY
if not all([key, secret, tag]):
logger.warning("Amazon PAAPI credentials not configured")
return None
_amazon_client = AmazonApi(key, secret, tag, country)
return _amazon_client
def lookup_book_from_amazon(asin: str) -> dict:
book_dict: dict = {}
client = _get_client()
if not client:
return book_dict
try:
items = client.get_items(
items=[asin],
Condition="New",
LanguagesOfPreference=["en_US"],
)
except Exception as e:
logger.warning(f"Amazon PAAPI lookup failed for {asin}: {e}")
return book_dict
if not items:
logger.info(f"No Amazon item found for {asin}")
return book_dict
item = items[0]
raw = item.to_dict()
item_info = raw.get("item_info", {}) or {}
book_dict["title"] = _get_nested(item_info, "title", "display_value")
if not book_dict.get("title"):
book_dict["title"] = _get_nested(item_info, "title", "value")
contributors = _get_nested(item_info, "by_line_info", "contributors") or []
authors = [
c["name"]
for c in contributors
if c.get("role", "").lower() in ("author", "artist", "writer")
]
if authors:
book_dict["authors"] = authors
publisher = _get_nested(item_info, "by_line_info", "manufacturer")
if publisher:
book_dict["publisher"] = publisher
isb_ns = _get_nested(item_info, "external_ids", "isb_ns")
if isb_ns and isinstance(isb_ns, list):
for isb in isb_ns:
if isinstance(isb, dict):
if isb.get("type") == "ISBN_13":
book_dict["isbn_13"] = isb.get("value")
elif isb.get("type") == "ISBN_10":
book_dict["isbn_10"] = isb.get("value")
pages_count = _get_nested(item_info, "content_info", "pages_count")
if pages_count and isinstance(pages_count, dict):
book_dict["pages"] = pages_count.get("value") or pages_count.get("display_value")
languages = _get_nested(item_info, "content_info", "languages") or []
if languages and isinstance(languages, list):
lang = languages[0]
if isinstance(lang, dict):
book_dict["language"] = lang.get("display_value") or lang.get("value")
pub_date = _get_nested(item_info, "content_info", "publication_date")
if not pub_date:
pub_date = _get_nested(item_info, "product_info", "release_date")
if pub_date and isinstance(pub_date, dict):
book_dict["publish_date"] = pub_date.get("display_value") or pub_date.get("value")
features = item_info.get("features") or []
if features and isinstance(features, list):
book_dict["summary"] = " ".join(features[:5])
images = raw.get("images", {}) or {}
primary = images.get("primary", {}) or {}
for size in ("large", "hi_res", "medium"):
candidate = primary.get(size, {}) or {}
url = candidate.get("url")
if url:
book_dict["cover_url"] = url
break
book_dict["detail_page_url"] = raw.get("detail_page_url")
return book_dict
def _get_nested(d: dict, *keys):
for key in keys:
if not isinstance(d, dict):
return None
d = d.get(key)
return d

View File

@ -1,8 +1,13 @@
import logging
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand
from django.utils import timezone
from trends.tasks import compute_user_trends
from trends.tasks import _compute_and_save_trend
from trends.trends import TREND_REGISTRY
logger = logging.getLogger(__name__)
User = get_user_model()
@ -25,16 +30,57 @@ class Command(BaseCommand):
)
return
users = [user]
self.stdout.write(f"Computing trends for user: {user}")
else:
users = User.objects.filter(is_active=True)
self.stdout.write(f"Computing trends for {users.count()} users...")
total_users = len(users)
self.stdout.write(f"Computing trends for {total_users} user(s)...")
overall_start = timezone.now()
ok_count = 0
fail_count = 0
for user in users:
try:
compute_user_trends(user.id)
self.stdout.write(self.style.SUCCESS(f" OK {user}"))
except Exception as e:
self.stderr.write(self.style.ERROR(f" FAILED {user}: {e}"))
total_trends = len(TREND_REGISTRY)
self.stdout.write(f" {user} ({user.id}): {total_trends} trends...")
user_start = timezone.now()
user_ok = 0
user_fail = 0
self.stdout.write(self.style.SUCCESS("Done!"))
for idx, (slug, _) in enumerate(TREND_REGISTRY.items(), start=1):
trend_start = timezone.now()
self.stdout.write(
f" [{idx}/{total_trends}] {slug}... ", ending=""
)
try:
elapsed = _compute_and_save_trend(user, slug)
self.stdout.write(
self.style.SUCCESS(f"OK ({elapsed:.1f}s)")
)
user_ok += 1
except Exception as e:
elapsed = (timezone.now() - trend_start).total_seconds()
self.stdout.write(
self.style.ERROR(
f"FAILED after {elapsed:.1f}s: {e}"
)
)
user_fail += 1
user_elapsed = (timezone.now() - user_start).total_seconds()
self.stdout.write(
self.style.SUCCESS(
f" {user}: {user_ok} OK, {user_fail} failed "
f"({user_elapsed:.1f}s total)"
)
)
ok_count += user_ok
fail_count += user_fail
overall_elapsed = (timezone.now() - overall_start).total_seconds()
self.stdout.write(
self.style.SUCCESS(
f"Done! {ok_count} OK, {fail_count} failed "
f"({overall_elapsed:.1f}s across {total_users} user(s))"
)
)

View File

@ -11,10 +11,30 @@ logger = logging.getLogger(__name__)
User = get_user_model()
def _compute_and_save_trend(user, slug):
"""Compute a single trend and persist the result.
Returns elapsed seconds on success, raises on failure.
"""
fn = TREND_REGISTRY[slug]
start = timezone.now()
data = fn(user)
TrendResult.objects.update_or_create(
user=user,
trend_slug=slug,
defaults={"data": data, "computed_at": timezone.now()},
)
return (timezone.now() - start).total_seconds()
@shared_task
def compute_all_trends():
for user in User.objects.filter(is_active=True):
compute_user_trends.delay(user.id)
user_ids = list(
User.objects.filter(is_active=True).values_list("id", flat=True)
)
logger.info("Dispatching trend computation for %d users", len(user_ids))
for uid in user_ids:
compute_user_trends.delay(uid)
@shared_task
@ -25,15 +45,38 @@ def compute_user_trends(user_id):
logger.warning("User %s not found, skipping trends", user_id)
return
for slug, fn in TREND_REGISTRY.items():
try:
data = fn(user)
TrendResult.objects.update_or_create(
user=user,
trend_slug=slug,
defaults={"data": data, "computed_at": timezone.now()},
)
except Exception:
logger.exception(
"Failed to compute trend '%s' for user %s", slug, user_id
)
total = len(TREND_REGISTRY)
logger.info(
"Computing %d trends for user %s (%d)",
total, user, user_id,
)
for idx, (slug, _) in enumerate(TREND_REGISTRY.items(), start=1):
compute_single_trend.delay(user_id, slug)
logger.info("Dispatched all %d trends for user %s (%d)", total, user, user_id)
@shared_task
def compute_single_trend(user_id, slug):
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist:
logger.warning(
"User %d not found for trend '%s', skipping", user_id, slug
)
return
if slug not in TREND_REGISTRY:
logger.warning("Unknown trend slug '%s' for user %d", slug, user_id)
return
logger.info("[%s] Computing for user %d...", slug, user_id)
try:
elapsed = _compute_and_save_trend(user, slug)
logger.info(
"[%s] Completed for user %d in %.1fs",
slug, user_id, elapsed,
)
except Exception:
logger.exception("[%s] Failed for user %d", slug, user_id)

View File

@ -0,0 +1,47 @@
<div class="row">
<div class="col-12">
{% if data.distribution %}
<p class="text-muted mb-3">
Total scrobbles: <strong>{{ data.total_count }}</strong>
</p>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th>Media Type</th>
<th class="text-end">Total</th>
<th class="text-end">Completed</th>
<th class="text-end">%</th>
<th>Distribution</th>
</tr>
</thead>
<tbody>
{% with max=data.distribution.0.count %}
{% for entry in data.distribution %}
<tr>
<td>{{ entry.media_type }}</td>
<td class="text-end">{{ entry.count }}</td>
<td class="text-end">{{ entry.completed }}</td>
<td class="text-end">{{ entry.pct }}%</td>
<td style="width: 30%;">
{% if max > 0 %}
<div class="progress" style="height: 12px;">
<div class="progress-bar" role="progressbar"
style="width: {{ entry.pct }}%;"
aria-valuenow="{{ entry.count }}"
aria-valuemin="0" aria-valuemax="{{ max }}">
</div>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
{% endwith %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted">No activity data found.</p>
{% endif %}
</div>
</div>

View File

@ -0,0 +1,52 @@
<div class="row">
<div class="col-12">
{% if data.hours %}
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th>Hour</th>
<th class="text-end">Scrobbles</th>
<th>Distribution</th>
</tr>
</thead>
<tbody>
{% with total=data.hours|dictsortreversed:"count"|first %}
{% with max_count=total.count %}
{% for entry in data.hours %}
<tr>
<td>
{% if entry.hour == 0 %}
12 AM
{% elif entry.hour < 12 %}
{{ entry.hour }} AM
{% elif entry.hour == 12 %}
12 PM
{% else %}
{{ entry.hour|add:"-12" }} PM
{% endif %}
</td>
<td class="text-end">{{ entry.count }}</td>
<td style="width: 40%;">
{% if max_count > 0 %}
<div class="progress" style="height: 12px;">
<div class="progress-bar" role="progressbar"
style="width: {{ entry.count|floatformat:0 }}%;"
aria-valuenow="{{ entry.count }}"
aria-valuemin="0" aria-valuemax="{{ max_count }}">
</div>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
{% endwith %}
{% endwith %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted">No activity data found.</p>
{% endif %}
</div>
</div>

View File

@ -0,0 +1,42 @@
<div class="row">
<div class="col-12">
{% if data.days %}
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th>Day</th>
<th class="text-end">Scrobbles</th>
<th>Distribution</th>
</tr>
</thead>
<tbody>
{% with total=data.days|dictsortreversed:"count"|first %}
{% with max_count=total.count %}
{% for entry in data.days %}
<tr>
<td>{{ entry.day_name }}</td>
<td class="text-end">{{ entry.count }}</td>
<td style="width: 40%;">
{% if max_count > 0 %}
<div class="progress" style="height: 12px;">
<div class="progress-bar" role="progressbar"
style="width: {{ entry.count|floatformat:0 }}%;"
aria-valuenow="{{ entry.count }}"
aria-valuemin="0" aria-valuemax="{{ max_count }}">
</div>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
{% endwith %}
{% endwith %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted">No weekly data found.</p>
{% endif %}
</div>
</div>

View File

@ -34,5 +34,14 @@
{% elif trend.slug == "trending-up" %}
{% include "trends/_trending_up.html" %}
{% elif trend.slug == "peak-hours" %}
{% include "trends/_peak_hours.html" %}
{% elif trend.slug == "weekly-rhythm" %}
{% include "trends/_weekly_rhythm.html" %}
{% elif trend.slug == "activity-distribution" %}
{% include "trends/_activity_distribution.html" %}
{% endif %}
{% endblock %}

View File

@ -1,3 +1,8 @@
from trends.trends.activity import (
compute_activity_distribution,
compute_peak_hours,
compute_weekly_rhythm,
)
from trends.trends.concurrent import compute_concurrent_listening, compute_concurrent_reading
from trends.trends.reading import compute_reading_pace_vs_activity
from trends.trends.trending import compute_trending_up
@ -11,15 +16,24 @@ def register(slug):
return decorator
compute_activity_distribution = register("activity-distribution")(
compute_activity_distribution
)
compute_concurrent_listening = register("concurrent-listening")(
compute_concurrent_listening
)
compute_concurrent_reading = register("concurrent-reading")(
compute_concurrent_reading
)
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_trending_up = register("trending-up")(
compute_trending_up
)
compute_weekly_rhythm = register("weekly-rhythm")(
compute_weekly_rhythm
)

View File

@ -0,0 +1,99 @@
from collections import OrderedDict, defaultdict
from django.db.models import Count, Q
from django.db.models.functions import Extract
from django.utils import timezone
from scrobbles.models import Scrobble
def compute_peak_hours(user):
"""Group scrobbles by hour of day (0-23) and count them.
Returns dict: {"hours": [{"hour": N, "count": N}, ...]} sorted by hour.
"""
hours_qs = (
Scrobble.objects.filter(user=user, timestamp__isnull=False)
.annotate(hour=Extract("timestamp", "hour"))
.values("hour")
.annotate(count=Count("id"))
.order_by("hour")
)
hours = []
raw = {row["hour"]: row["count"] for row in hours_qs}
for h in range(24):
hours.append({"hour": h, "count": raw.get(h, 0)})
return {"hours": hours}
def compute_weekly_rhythm(user):
"""Group scrobble counts by day of the week.
Uses iso_week_day (1=Monday, 7=Sunday). Returns dict sorted by day index
with human-readable day names.
"""
DAY_NAMES = OrderedDict([
(1, "Monday"),
(2, "Tuesday"),
(3, "Wednesday"),
(4, "Thursday"),
(5, "Friday"),
(6, "Saturday"),
(7, "Sunday"),
])
days_qs = (
Scrobble.objects.filter(user=user, timestamp__isnull=False)
.annotate(day=Extract("timestamp", "iso_week_day"))
.values("day")
.annotate(count=Count("id"))
.order_by("day")
)
raw = {row["day"]: row["count"] for row in days_qs}
days = []
for idx, name in DAY_NAMES.items():
days.append({
"day_index": idx,
"day_name": name,
"count": raw.get(idx, 0),
})
return {"days": days}
def compute_activity_distribution(user):
"""Proportion of total scrobbles per media type.
Returns dict: {"distribution": [{"media_type": "...", "count": N,
"completed": N, "pct": float}, ...]} sorted by count desc, plus
"total_count".
"""
dist_qs = (
Scrobble.objects.filter(user=user)
.values("media_type")
.annotate(
count=Count("id"),
completed=Count("id", filter=Q(played_to_completion=True)),
)
.order_by("-count")
)
rows = list(dist_qs)
total = sum(r["count"] for r in rows) or 1
distribution = []
for row in rows:
distribution.append({
"media_type": row["media_type"],
"count": row["count"],
"completed": row["completed"],
"pct": round((row["count"] / total) * 100, 1),
})
return {
"distribution": distribution,
"total_count": sum(r["count"] for r in rows),
}

View File

@ -5,6 +5,11 @@ from trends.models import TrendResult
from trends.trends import TREND_REGISTRY
TREND_METADATA = {
"activity-distribution": {
"title": "Activity Distribution",
"description": "How your scrobbles are divided across media types.",
"icon": "📊",
},
"concurrent-listening": {
"title": "Concurrent Listening",
"description": "What music were you listening to while on trails or at locations?",
@ -15,6 +20,11 @@ TREND_METADATA = {
"description": "What music did you listen to while reading books?",
"icon": "📖",
},
"peak-hours": {
"title": "Peak Activity Hours",
"description": "What time of day are you most active?",
"icon": "🕐",
},
"reading-pace-vs-activity": {
"title": "Reading Pace vs Music",
"description": "Compare how long you read per session with and without concurrent music.",
@ -25,6 +35,11 @@ TREND_METADATA = {
"description": "Which media types have you been consuming more or less of recently?",
"icon": "📈",
},
"weekly-rhythm": {
"title": "Weekly Rhythm",
"description": "Which days of the week see the most scrobble activity?",
"icon": "📅",
},
}

View File

@ -0,0 +1,82 @@
import logging
from django.core.management.base import BaseCommand
from django.db import models, transaction
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "Enrich YouTube and Twitch channel metadata from upstream APIs"
def add_arguments(self, parser):
parser.add_argument(
"--force",
action="store_true",
help="Overwrite existing channel name and cover image",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be done without making changes",
)
parser.add_argument(
"--youtube-only",
action="store_true",
help="Only process channels with a youtube_id",
)
parser.add_argument(
"--twitch-only",
action="store_true",
help="Only process channels with a twitch_id",
)
def handle(self, *args, **options):
from videos.models import Channel
force = options["force"]
dry_run = options["dry_run"]
youtube_only = options["youtube_only"]
twitch_only = options["twitch_only"]
qs = Channel.objects.all()
if youtube_only:
qs = qs.exclude(youtube_id__isnull=True).exclude(youtube_id="")
elif twitch_only:
qs = qs.exclude(twitch_id__isnull=True).exclude(twitch_id="")
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")
if dry_run:
for channel in qs.iterator():
source = "youtube" if channel.youtube_id else "twitch"
identifier = channel.youtube_id or channel.twitch_id
self.stdout.write(
f" [DRY RUN] Would fix {channel.name} ({source}: {identifier})"
)
return
updated = 0
errors = 0
for channel in qs.iterator():
try:
with transaction.atomic():
channel.fix_metadata(force=force)
updated += 1
source = "youtube" if channel.youtube_id else "twitch"
self.stdout.write(f" [{updated}/{total}] {channel.name} ({source})")
except Exception as e:
errors += 1
self.stdout.write(
self.style.ERROR(f" Error updating channel {channel.name}: {e}")
)
self.stdout.write(
self.style.SUCCESS(f"\nDone! {updated} channels updated, {errors} errors")
)

View File

@ -0,0 +1,23 @@
# Generated by Django 4.2.29 on 2026-06-16 19:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("videos", "0030_alter_channel_genre_alter_series_genre_and_more"),
]
operations = [
migrations.AddField(
model_name="channel",
name="custom_url",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name="channel",
name="description",
field=models.TextField(blank=True, null=True),
),
]

View File

@ -1,3 +1,4 @@
import json
import logging
import re
from dataclasses import dataclass
@ -58,6 +59,8 @@ class Channel(ScrobblableMixin):
)
youtube_id = models.CharField(max_length=255, **BNULL)
twitch_id = models.CharField(max_length=255, **BNULL)
description = models.TextField(**BNULL)
custom_url = models.CharField(max_length=255, **BNULL)
genre = TaggableManager(through=ObjectWithGenres, blank=True, verbose_name="Genre")
class Meta:
@ -138,8 +141,74 @@ class Channel(ScrobblableMixin):
)
def fix_metadata(self, force: bool = False):
# TODO Scrape channel info from Youtube
logger.warning("Not implemented yet")
if self.youtube_id:
GOOGLE_CHANNELS_URL = "https://www.googleapis.com/youtube/v3/channels?part=snippet,topicDetails&id={channel_id}&key={key}"
url = GOOGLE_CHANNELS_URL.format(
channel_id=self.youtube_id,
key=settings.GOOGLE_API_KEY,
)
headers = {"User-Agent": "Vrobbler 0.11.12"}
response = requests.get(url, headers=headers)
if response.status_code != 200:
logger.warning(
"Bad response from Google for channel",
extra={"response": response},
)
return
items = json.loads(response.content).get("items", [])
if not items:
logger.warning(f"No YouTube channel data for {self.youtube_id}")
return
snippet = items[0].get("snippet", {})
channel_name = snippet.get("title", "")
if channel_name and (not self.name or force):
self.name = channel_name
channel_description = snippet.get("description", "")
if channel_description and (not self.description or force):
self.description = channel_description
custom_url = snippet.get("customUrl", "")
if custom_url and (not self.custom_url or force):
self.custom_url = custom_url
thumbnails = snippet.get("thumbnails", {})
cover_url = (
thumbnails.get("high", {}).get("url")
or thumbnails.get("medium", {}).get("url")
or thumbnails.get("default", {}).get("url")
)
if cover_url:
self.save_image_from_url(cover_url, force_update=force)
topic_details = items[0].get("topicDetails", {})
topic_categories = topic_details.get("topicCategories", [])
if topic_categories:
if force:
self.genre.clear()
for category_url in topic_categories:
topic_name = category_url.rstrip("/").split("/")[-1]
topic_name = topic_name.replace("_", " ").replace("-", " ")
self.genre.add(topic_name)
self.save()
return
if self.twitch_id:
from videos.sources.twitch import lookup_channel_from_twitch
metadata = lookup_channel_from_twitch(self.twitch_id)
if metadata.name and (not self.name or force):
self.name = metadata.name
if metadata.profile_image_url:
self.save_image_from_url(metadata.profile_image_url, force_update=force)
self.save()
return
logger.warning(f"No youtube_id or twitch_id set for channel {self}")
return
@ -452,9 +521,7 @@ class Video(ScrobblableMixin):
if metadata.channel_id:
from videos.models import Channel
self.channel = Channel.objects.filter(
id=metadata.channel_id
).first()
self.channel = Channel.objects.filter(id=metadata.channel_id).first()
self.save()
@ -476,9 +543,7 @@ class Video(ScrobblableMixin):
logger.warning(f"No metadata found for {self} from TMDB or OMDB")
return
vdict, series_id, cover, genres = (
metadata.as_dict_with_cover_and_genres()
)
vdict, series_id, cover, genres = metadata.as_dict_with_cover_and_genres()
for k, v in vdict.items():
setattr(self, k, v)

View File

@ -4,7 +4,6 @@ import logging
import pendulum
import requests
from django.conf import settings
from videos.metadata import VideoMetadata, VideoType
logger = logging.getLogger(__name__)
@ -13,7 +12,7 @@ YOUTUBE_VIDEO_URL = "https://www.youtube.com/watch?v="
YOUTUBE_CHANNEL_URL = "https://www.youtube.com/channel/"
API_KEY = settings.GOOGLE_API_KEY
GOOGLE_VIDEOS_URL = "https://www.googleapis.com/youtube/v3/videos?part=snippet,contentDetails&id={youtube_id}&key={key}"
GOOGLE_VIDEOS_URL = "https://www.googleapis.com/youtube/v3/videos?part=snippet,contentDetails,topicDetails&id={youtube_id}&key={key}"
def lookup_video_from_youtube(youtube_id: str) -> VideoMetadata:
@ -68,6 +67,15 @@ def lookup_video_from_youtube(youtube_id: str) -> VideoMetadata:
yt_metadata.get("thumbnails", {}).get("high", {}).get("url", {})
)
video_metadata.genres = yt_metadata.get("tags", [])
topic_details = (
json.loads(response.content).get("items", [None])[0].get("topicDetails", {})
)
topic_categories = topic_details.get("topicCategories", [])
for category_url in topic_categories:
topic_name = category_url.rstrip("/").split("/")[-1]
topic_name = topic_name.replace("_", " ").replace("-", " ")
if topic_name not in video_metadata.genres:
video_metadata.genres.append(topic_name)
video_metadata.overview = yt_metadata.get("description", "")
date_str = yt_metadata.get("publishedAt")

View File

@ -82,6 +82,11 @@ TODOIST_CLIENT_SECRET = os.getenv("VROBBLER_TODOIST_CLIENT_SECRET", "")
GOOGLE_API_KEY = os.getenv("VROBBLER_GOOGLE_API_KEY", "")
LICHESS_API_KEY = os.getenv("VROBBLER_LICHESS_API_KEY", "")
AMAZON_PAAPI_ACCESS_KEY = os.getenv("VROBBLER_AMAZON_PAAPI_ACCESS_KEY", "")
AMAZON_PAAPI_SECRET_KEY = os.getenv("VROBBLER_AMAZON_PAAPI_SECRET_KEY", "")
AMAZON_PAAPI_ASSOCIATE_TAG = os.getenv("VROBBLER_AMAZON_PAAPI_ASSOCIATE_TAG", "")
AMAZON_PAAPI_COUNTRY = os.getenv("VROBBLER_AMAZON_PAAPI_COUNTRY", "US")
DEFAULT_TASK_CONTEXT_TAGS = [
"Dev",
"Home",