Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 253e58eb48 | |||
| 5393996e47 | |||
| 1624f01e11 | |||
| 535dead7e8 | |||
| 3b97d49227 |
45
PROJECT.org
45
PROJECT.org
@ -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
25
poetry.lock
generated
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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", []),
|
||||
}
|
||||
@ -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):
|
||||
|
||||
@ -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",
|
||||
|
||||
123
vrobbler/apps/books/sources/amazon.py
Normal file
123
vrobbler/apps/books/sources/amazon.py
Normal 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
|
||||
@ -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))"
|
||||
)
|
||||
)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
52
vrobbler/apps/trends/templates/trends/_peak_hours.html
Normal file
52
vrobbler/apps/trends/templates/trends/_peak_hours.html
Normal 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>
|
||||
42
vrobbler/apps/trends/templates/trends/_weekly_rhythm.html
Normal file
42
vrobbler/apps/trends/templates/trends/_weekly_rhythm.html
Normal 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>
|
||||
@ -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 %}
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
99
vrobbler/apps/trends/trends/activity.py
Normal file
99
vrobbler/apps/trends/trends/activity.py
Normal 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),
|
||||
}
|
||||
@ -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": "📅",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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")
|
||||
)
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
@ -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)
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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",
|
||||
|
||||
Reference in New Issue
Block a user