Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 37112babbb | |||
| fb775f2f58 | |||
| b26470c279 | |||
| d3b9ec815b | |||
| 19f2b5e801 | |||
| 9e3288a5ff | |||
| 06465919dd | |||
| 253e58eb48 | |||
| 5393996e47 | |||
| 1624f01e11 | |||
| 535dead7e8 | |||
| 3b97d49227 | |||
| ea7b0946bb | |||
| b8384166de | |||
| d2705758c6 | |||
| f4368c31f3 | |||
| 57f273b0cc | |||
| ac82292200 | |||
| 6a8432c08f | |||
| 5a2c41155c | |||
| 83a046111b | |||
| ab10758f40 | |||
| 88f16f0aaa |
@ -15,6 +15,8 @@ ro class method should call the utility function.
|
||||
Be sure to check pyproject.toml for project defaults. Specifically for black and
|
||||
isort expectations.
|
||||
|
||||
Imports in python files should always be top level if possible.
|
||||
|
||||
All tasks live in the PROJECT.org file and include an org ID that is a uuid to make them unique.
|
||||
|
||||
In local development, environment variables for various sensitive values live in a .envrc file
|
||||
|
||||
175
PROJECT.org
175
PROJECT.org
@ -522,23 +522,6 @@ easily. And our exposure to PII is really low at this point in the project,
|
||||
so we can probably use backtrace=True and diagnose=True to help us root cause
|
||||
bugs faster.
|
||||
|
||||
** TODO [#B] Add a /trends/ page that shows trends based on scrobble data :feature:trends:scrobbles:
|
||||
|
||||
*** Description
|
||||
|
||||
This project is a bit invovled. But we should add a top level URL `trends` that shows
|
||||
various trends as defined either in a static settings file, or dynamically via a database table.
|
||||
|
||||
Examples of trends:
|
||||
|
||||
- How often does the user:
|
||||
+ watch sports while doing a task?
|
||||
+ do a task while watching a video?
|
||||
* how often do I do
|
||||
|
||||
- trail_scrobble__average_heartrate per trail
|
||||
- ...
|
||||
|
||||
** TODO [#B] Scrape ComicBookRoundUp ratings for comic book metadata :books:feature:comicbook:
|
||||
:PROPERTIES:
|
||||
:ID: b3cc57ca-3d2c-468d-ab7c-c47f1120309b
|
||||
@ -564,6 +547,7 @@ File: ~vrobbler/apps/podcasts/utils.py~ (line 13)
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
The zombie scrobble cleanup query lives in a utility function. Should be a
|
||||
custom model manager method (e.g. =Scrobble.objects.zombies()=).
|
||||
|
||||
@ -594,6 +578,163 @@ 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.2 [4/4]
|
||||
** DONE [#B] Add script to clean up TV series metadata :videos:metadata:
|
||||
:PROPERTIES:
|
||||
:ID: a468b328-59d9-f84b-9ddb-087216783453
|
||||
:END:
|
||||
** DONE [#A] Update youtube video detail pages with links to channel :videos:templates:
|
||||
:PROPERTIES:
|
||||
:ID: 8b87cb42-09e5-a3f5-136f-182f967fa81f
|
||||
:END:
|
||||
** DONE [#A] Concurrent reading trend does not consolidate on single book :trends:reading:
|
||||
:PROPERTIES:
|
||||
:ID: fe220f55-7e0d-2a17-2477-a5aa7c4a1f2c
|
||||
:END:
|
||||
** DONE [#B] Trends dont seem to look very far back :trends:
|
||||
:PROPERTIES:
|
||||
:ID: ffcfba3f-5a93-9ee0-9680-666e6eccd684
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Specificially, looking at reading-pace when run on prod, it claims that I've
|
||||
only had one reading session without music. Which may be true, but perhaps we
|
||||
need to indicate what the time frame we're looking at is (month, week, year)
|
||||
and provide a way to jump back and forward through time, same as charts.
|
||||
|
||||
|
||||
* Version 54.1 [1/1]
|
||||
** DONE [#A] Concurrent listening trend is inefficient and should be disabled :trends:scrobbles:
|
||||
:PROPERTIES:
|
||||
:ID: 4aa3b719-6b22-cae9-85f0-fac67b4fc753
|
||||
:END:
|
||||
|
||||
* 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:
|
||||
:ID: 92d4fa16-4b90-47e0-95ae-472bdca582ce
|
||||
:END:
|
||||
|
||||
|
||||
* Version 53.0 [5/5]
|
||||
** DONE [#B] Add a /trends/ page that shows trends based on scrobble data :feature:trends:scrobbles:
|
||||
:PROPERTIES:
|
||||
:ID: 03e9fe30-2bc6-4062-bb24-e95b98daf05b
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
This project is a bit invovled. But we should add a top level URL `trends` that shows
|
||||
various trends as defined either in a static settings file, or dynamically via a database table.
|
||||
|
||||
Trends could be things like doing multiple things at the same time, like while driving, what
|
||||
did we listen to this week, or while running, what were listening to this week?
|
||||
|
||||
Or more complicated trends like, how time per page changes based on the book I was reading, or if I was doing something else (music or sport event) while reading.
|
||||
|
||||
** DONE [#B] Notify users when Last.fm import completes :importers:notifications:
|
||||
:PROPERTIES:
|
||||
:ID: 92846b36-54c5-4b78-9c57-bdc401045fbe
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
After a bulk import from Last.fm, users receive no confirmation. Should add a notification (in-app, email, or similar).
|
||||
|
||||
File: ~vrobbler/apps/scrobbles/importers/lastfm.py~ (line 96)
|
||||
|
||||
** DONE [#C] Cleaner =GeoLocationLogData= deserialization :models:refactoring:
|
||||
:PROPERTIES:
|
||||
:ID: 85465dbf-69b3-48cb-9df0-cd076c4470ab
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
Currently special-cases =GeoLocationLogData= by reaching into a nested ="movement_detection"= key. Should be handled at the LogData dataclass level.
|
||||
|
||||
File: ~vrobbler/apps/scrobbles/models.py~ (line 977)
|
||||
|
||||
** DONE [#B] Webpage scrobbles should diff existing webpages content :webpages:metadata:
|
||||
:PROPERTIES:
|
||||
:ID: 25576197-258f-48d6-bfe9-e4172a0a1898
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Webpages change content between scrobbles. The current model stores the webpage content once, the
|
||||
first time it's scrobbled. When a page has been seen before, we should move the existing content
|
||||
to a new model HistoricalWebPage with the following fields:
|
||||
|
||||
webpage_id -> FK to WebPage
|
||||
date -> date from existing WebPage content
|
||||
domain -> same as existing WebPage content
|
||||
extract -> copy of existing WebPage content
|
||||
|
||||
Once the HistoricalWebPage instance is successfully created, the new extract data
|
||||
should be saved into the WebPage instance.
|
||||
|
||||
|
||||
** DONE [#B] Make ArchiveBox push asynchronous :archivebox:async:
|
||||
:PROPERTIES:
|
||||
:ID: 17c116a7-5952-db37-e56c-2987c2fc456b
|
||||
:END:
|
||||
*** Description
|
||||
|
||||
=push_to_archivebox()= runs synchronously during the request. Should be moved to a
|
||||
Celery task or similar background worker.
|
||||
|
||||
File: ~vrobbler/apps/webpages/models.py~ (line 133)
|
||||
|
||||
|
||||
* Version 52.2 [1/1]
|
||||
** DONE [#A] Fix bug in recomputing long play seconds taking forever :bug:longplay:commands:
|
||||
:PROPERTIES:
|
||||
:ID: 0a813cf9-17fb-dbd7-b5a7-7410d9bd4d8c
|
||||
:END:
|
||||
|
||||
* Version 52.1 [1/1]
|
||||
** DONE [#C] Show time per scrobble in long play lists and total time playing :templates:longplay:scrobbles:
|
||||
: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 = "52.1"
|
||||
version = "54.2"
|
||||
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
|
||||
@ -28,6 +28,14 @@ class GeoLocationLogData(BaseLogData, WithPeopleLogData):
|
||||
activity: str = ""
|
||||
detected_at: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_log_dict(cls, log_dict: dict) -> dict:
|
||||
instance_data = log_dict.get("movement_detection", {}).copy()
|
||||
for field_name in ["description", "notes", "with_people_ids"]:
|
||||
if field_name in log_dict:
|
||||
instance_data[field_name] = log_dict[field_name]
|
||||
return instance_data
|
||||
|
||||
|
||||
class GeoLocation(ScrobblableMixin):
|
||||
COMPLETION_PERCENT = getattr(settings, "LOCATION_COMPLETION_PERCENT", 100)
|
||||
|
||||
@ -600,8 +600,9 @@ class Track(ScrobblableMixin):
|
||||
def __str__(self):
|
||||
return f"{self.title} by {self.artist}"
|
||||
|
||||
@property
|
||||
def logdata_cls(self):
|
||||
return TrackLogData()
|
||||
return TrackLogData
|
||||
|
||||
@property
|
||||
def primary_album(self):
|
||||
|
||||
@ -50,6 +50,18 @@ class BaseLogData(JSONDataclass):
|
||||
def override_fields(cls) -> dict:
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def from_log_dict(cls, log_dict: dict) -> dict:
|
||||
"""Extract LogData keyword arguments from a stored log dict.
|
||||
|
||||
Override in subclasses to handle custom nesting/structure.
|
||||
"""
|
||||
return {
|
||||
k: v
|
||||
for k, v in log_dict.items()
|
||||
if k in cls.__dataclass_fields__
|
||||
}
|
||||
|
||||
def notes_as_str(self, separator: str = " | ") -> str:
|
||||
import html
|
||||
import re
|
||||
|
||||
@ -93,7 +93,6 @@ class LastFM:
|
||||
new_scrobbles.append(new_scrobble)
|
||||
|
||||
created = Scrobble.objects.bulk_create(new_scrobbles)
|
||||
# TODO Add a notification for users that their import is complete
|
||||
logger.info(
|
||||
f"Last.fm import fnished",
|
||||
extra={
|
||||
|
||||
@ -4,11 +4,14 @@ from django.db import connection
|
||||
from scrobbles.constants import LONG_PLAY_MEDIA
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
BATCH_SIZE = 1000
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = (
|
||||
"Backfill long_play_last_scrobble FK chains, then recompute "
|
||||
"long_play_seconds by walking backward through the chain."
|
||||
"long_play_seconds by walking forward through scrobbles in "
|
||||
"timestamp order with a running accumulator."
|
||||
)
|
||||
|
||||
def add_arguments(self, parser):
|
||||
@ -61,49 +64,74 @@ class Command(BaseCommand):
|
||||
|
||||
# Step 2: recompute long_play_seconds
|
||||
self.stdout.write(
|
||||
"\nStep 2: Recomputing long_play_seconds via FK chain..."
|
||||
"\nStep 2: Recomputing long_play_seconds in timestamp order..."
|
||||
)
|
||||
|
||||
scrobbles = Scrobble.objects.filter(
|
||||
media_type__in=media_types,
|
||||
playback_position_seconds__isnull=False,
|
||||
).order_by("-timestamp")
|
||||
|
||||
total = scrobbles.count()
|
||||
self.stdout.write(f" Found {total} long play scrobbles to process")
|
||||
|
||||
to_update = []
|
||||
for scrobble in scrobbles.iterator():
|
||||
accumulated = scrobble.playback_position_seconds or 0
|
||||
current = scrobble.long_play_last_scrobble
|
||||
while current and not current.long_play_complete:
|
||||
accumulated += current.playback_position_seconds or 0
|
||||
current = current.long_play_last_scrobble
|
||||
|
||||
if scrobble.long_play_seconds != accumulated:
|
||||
self.stdout.write(
|
||||
f" Scrobble {scrobble.id} ({scrobble.media_type}): "
|
||||
f"{scrobble.long_play_seconds or 0} -> {accumulated}"
|
||||
)
|
||||
if not dry_run:
|
||||
scrobble.long_play_seconds = accumulated
|
||||
to_update.append(scrobble)
|
||||
|
||||
if to_update:
|
||||
Scrobble.objects.bulk_update(to_update, ["long_play_seconds"])
|
||||
total_updated = 0
|
||||
for mt in media_types:
|
||||
n = self._recompute_for_media_type(mt, dry_run)
|
||||
total_updated += n
|
||||
self.stdout.write(f" {mt}: {n} scrobbles updated")
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f"Dry run: would update {len(to_update)} scrobbles. "
|
||||
f"Dry run: would update {total_updated} scrobbles total. "
|
||||
"Use without --dry-run to apply."
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"Updated {len(to_update)} scrobbles")
|
||||
self.style.SUCCESS(f"Updated {total_updated} scrobbles")
|
||||
)
|
||||
|
||||
def _recompute_for_media_type(self, media_type: str, dry_run: bool) -> int:
|
||||
"""Process scrobbles for a single media type in timestamp order with a
|
||||
running accumulator, avoiding O(n2) FK chain walks."""
|
||||
fk = _media_type_to_fk(media_type)
|
||||
fk_id = f"{fk}_id"
|
||||
|
||||
scrobbles = Scrobble.objects.filter(
|
||||
media_type=media_type,
|
||||
**{f"{fk}__isnull": False},
|
||||
playback_position_seconds__isnull=False,
|
||||
).order_by(fk_id, "user_id", "timestamp")
|
||||
|
||||
total = scrobbles.count()
|
||||
if not total:
|
||||
return 0
|
||||
|
||||
updated = 0
|
||||
batch = []
|
||||
last_key = None
|
||||
running_total = 0
|
||||
|
||||
for scrobble in scrobbles.iterator():
|
||||
key = (getattr(scrobble, fk_id), scrobble.user_id)
|
||||
|
||||
if key != last_key:
|
||||
running_total = 0
|
||||
last_key = key
|
||||
|
||||
running_total += scrobble.playback_position_seconds or 0
|
||||
|
||||
if scrobble.long_play_seconds != running_total:
|
||||
updated += 1
|
||||
if not dry_run:
|
||||
scrobble.long_play_seconds = running_total
|
||||
batch.append(scrobble)
|
||||
if len(batch) >= BATCH_SIZE:
|
||||
Scrobble.objects.bulk_update(batch, ["long_play_seconds"])
|
||||
batch = []
|
||||
|
||||
if scrobble.long_play_complete:
|
||||
running_total = 0
|
||||
|
||||
if batch:
|
||||
Scrobble.objects.bulk_update(batch, ["long_play_seconds"])
|
||||
|
||||
return updated
|
||||
|
||||
def _backfill_chain(self, media_type: str, dry_run: bool) -> int:
|
||||
"""Set long_play_last_scrobble on each scrobble to the previous
|
||||
scrobble for the same media+user using a single UPDATE with a
|
||||
|
||||
@ -51,7 +51,10 @@ from scrobbles.constants import (
|
||||
MEDIA_END_PADDING_SECONDS,
|
||||
)
|
||||
from scrobbles.importers.lastfm import LastFM
|
||||
from scrobbles.notifications import ScrobbleNtfyNotification
|
||||
from scrobbles.notifications import (
|
||||
LastFmImportNtfyNotification,
|
||||
ScrobbleNtfyNotification,
|
||||
)
|
||||
from scrobbles.utils import get_file_md5_hash, media_class_to_foreign_key
|
||||
from sports.models import SportEvent
|
||||
from taggit.managers import TaggableManager
|
||||
@ -428,6 +431,8 @@ class LastFmImport(BaseFileImportMixin):
|
||||
try:
|
||||
scrobbles = lastfm.import_from_lastfm(last_processed, time_to=time_to)
|
||||
self.record_log(scrobbles)
|
||||
if scrobbles:
|
||||
LastFmImportNtfyNotification(self, len(scrobbles)).send()
|
||||
except Exception as e:
|
||||
self.record_error(f"Import failed: {e}")
|
||||
logger.exception(f"Import failed for {self}")
|
||||
@ -952,27 +957,6 @@ class Scrobble(TimeStampedModel):
|
||||
self.share_token_version += 1
|
||||
self.save(update_fields=["share_token_version"])
|
||||
|
||||
def push_to_archivebox(self):
|
||||
pushable_media = hasattr(self.media_obj, "push_to_archivebox") and callable(
|
||||
self.media_obj.push_to_archivebox
|
||||
)
|
||||
|
||||
if pushable_media and self.user.profile.archivebox_url:
|
||||
try:
|
||||
self.media_obj.push_to_archivebox(
|
||||
url=self.user.profile.archivebox_url,
|
||||
username=self.user.profile.archivebox_username,
|
||||
password=self.user.profile.archivebox_password,
|
||||
)
|
||||
except Exception:
|
||||
logger.info(
|
||||
"Failed to push URL to archivebox",
|
||||
extra={
|
||||
"archivebox_url": self.user.profile.archivebox_url,
|
||||
"archivebox_username": self.user.profile.archivebox_username,
|
||||
},
|
||||
)
|
||||
|
||||
@property
|
||||
def logdata(self) -> Optional[logdata.BaseLogData]:
|
||||
if self.media_obj:
|
||||
@ -994,24 +978,8 @@ class Scrobble(TimeStampedModel):
|
||||
if not log_dict:
|
||||
log_dict = {}
|
||||
|
||||
# Special handling for GeoLocationLogData - data is nested under 'movement_detection'
|
||||
# TODO there's a better way to fix this this at the LogData level
|
||||
if logdata_cls.__name__ == "GeoLocationLogData":
|
||||
instance_data = log_dict.get("movement_detection", {}).copy()
|
||||
# Add top-level fields that GeoLocationLogData expects from BaseLogData/WithPeopleLogData
|
||||
for field_name in ["description", "notes", "with_people_ids"]:
|
||||
if field_name in log_dict:
|
||||
instance_data[field_name] = log_dict[field_name]
|
||||
try:
|
||||
return logdata_cls(**instance_data)
|
||||
except Exception as e:
|
||||
logger.warning("Log data could not be loaded", e)
|
||||
return logdata_cls()
|
||||
|
||||
# Strip log-only keys (stored in JSONField but not part of LogData dataclass)
|
||||
logdata_kwargs = {
|
||||
k: v for k, v in log_dict.items() if k in logdata_cls().__dataclass_fields__
|
||||
}
|
||||
# Use LogData's from_log_dict to handle any custom nesting/structure
|
||||
logdata_kwargs = logdata_cls.from_log_dict(log_dict)
|
||||
|
||||
try:
|
||||
return logdata_cls(**logdata_kwargs)
|
||||
|
||||
@ -79,6 +79,27 @@ class ScrobbleNtfyNotification(ScrobbleNotification):
|
||||
)
|
||||
|
||||
|
||||
class LastFmImportNtfyNotification(BasicNtfyNotification):
|
||||
def __init__(self, lfm_import, scrobble_count):
|
||||
super().__init__(lfm_import.user.profile)
|
||||
self.ntfy_str = f"Imported {scrobble_count} scrobble(s) from Last.fm"
|
||||
self.click_url = lfm_import.get_absolute_url()
|
||||
self.title = "Last.fm Import Complete"
|
||||
|
||||
def send(self):
|
||||
if self.profile and self.profile.ntfy_enabled and self.profile.ntfy_url:
|
||||
requests.post(
|
||||
self.profile.ntfy_url,
|
||||
data=self.ntfy_str.encode(encoding="utf-8"),
|
||||
headers={
|
||||
"Title": self.title,
|
||||
"Priority": "default",
|
||||
"Tags": "musical_note",
|
||||
"Click": self.click_url,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class MoodNtfyNotification(BasicNtfyNotification):
|
||||
def __init__(self, profile, **kwargs):
|
||||
super().__init__(profile)
|
||||
|
||||
@ -32,6 +32,7 @@ from scrobbles.constants import (
|
||||
)
|
||||
from scrobbles.models import Scrobble
|
||||
from scrobbles.notifications import ScrobbleNtfyNotification
|
||||
from scrobbles.tasks import push_scrobble_to_archivebox
|
||||
from scrobbles.utils import (
|
||||
convert_to_seconds,
|
||||
extract_domain,
|
||||
@ -1028,8 +1029,7 @@ def manual_scrobble_webpage(
|
||||
if action == "stop":
|
||||
scrobble.stop(force_finish=True)
|
||||
else:
|
||||
# possibly async this?
|
||||
scrobble.push_to_archivebox()
|
||||
push_scrobble_to_archivebox.delay(scrobble.id)
|
||||
|
||||
return scrobble
|
||||
|
||||
|
||||
@ -252,6 +252,25 @@ def update_charts_for_timestamp(user_id, year, month, day, week):
|
||||
logger.error(f"[charts] Failed to update charts: {e}")
|
||||
|
||||
|
||||
@shared_task
|
||||
def push_scrobble_to_archivebox(scrobble_id):
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
scrobble = Scrobble.objects.filter(id=scrobble_id).first()
|
||||
if not scrobble:
|
||||
logger.warning(
|
||||
"Scrobble %s not found for archivebox push", scrobble_id
|
||||
)
|
||||
return
|
||||
webpage = scrobble.web_page
|
||||
if not webpage:
|
||||
logger.warning(
|
||||
"Scrobble %s has no web_page for archivebox push", scrobble_id
|
||||
)
|
||||
return
|
||||
webpage.push_to_archivebox(scrobble.user)
|
||||
|
||||
|
||||
# ── Crontab replacements ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
0
vrobbler/apps/trends/__init__.py
Normal file
0
vrobbler/apps/trends/__init__.py
Normal file
9
vrobbler/apps/trends/admin.py
Normal file
9
vrobbler/apps/trends/admin.py
Normal file
@ -0,0 +1,9 @@
|
||||
from django.contrib import admin
|
||||
from trends.models import TrendResult
|
||||
|
||||
|
||||
@admin.register(TrendResult)
|
||||
class TrendResultAdmin(admin.ModelAdmin):
|
||||
list_display = ("user", "trend_slug", "computed_at", "created")
|
||||
list_filter = ("user", "trend_slug")
|
||||
ordering = ("-computed_at",)
|
||||
6
vrobbler/apps/trends/apps.py
Normal file
6
vrobbler/apps/trends/apps.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class TrendsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "trends"
|
||||
0
vrobbler/apps/trends/management/__init__.py
Normal file
0
vrobbler/apps/trends/management/__init__.py
Normal file
82
vrobbler/apps/trends/management/commands/compute_trends.py
Normal file
82
vrobbler/apps/trends/management/commands/compute_trends.py
Normal file
@ -0,0 +1,82 @@
|
||||
import logging
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
from trends.trends import TREND_REGISTRY
|
||||
from trends.utils import compute_and_save_trend, get_supported_periods
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Compute trends for all users (or a specific user)"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--user-id",
|
||||
type=int,
|
||||
help="Compute trends for a specific user only",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if options["user_id"]:
|
||||
user = User.objects.filter(id=options["user_id"]).first()
|
||||
if not user:
|
||||
self.stderr.write(
|
||||
self.style.ERROR(f"User with id {options['user_id']} not found")
|
||||
)
|
||||
return
|
||||
users = [user]
|
||||
else:
|
||||
users = User.objects.filter(is_active=True)
|
||||
|
||||
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:
|
||||
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
|
||||
|
||||
for idx, (slug, _) in enumerate(TREND_REGISTRY.items(), start=1):
|
||||
periods = get_supported_periods(slug)
|
||||
self.stdout.write(f" [{idx}/{total_trends}] {slug}...\n")
|
||||
for period in periods:
|
||||
trend_start = timezone.now()
|
||||
self.stdout.write(f" {period}... ", ending="")
|
||||
try:
|
||||
elapsed = compute_and_save_trend(user, slug, period)
|
||||
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))"
|
||||
)
|
||||
)
|
||||
57
vrobbler/apps/trends/migrations/0001_initial.py
Normal file
57
vrobbler/apps/trends/migrations/0001_initial.py
Normal file
@ -0,0 +1,57 @@
|
||||
# Generated by Django 4.2.29 on 2026-06-16 14:52
|
||||
|
||||
import django.db.models.deletion
|
||||
import django_extensions.db.fields
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="TrendResult",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created",
|
||||
django_extensions.db.fields.CreationDateTimeField(
|
||||
auto_now_add=True, verbose_name="created"
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
django_extensions.db.fields.ModificationDateTimeField(
|
||||
auto_now=True, verbose_name="modified"
|
||||
),
|
||||
),
|
||||
("trend_slug", models.CharField(db_index=True, max_length=100)),
|
||||
("computed_at", models.DateTimeField(auto_now_add=True)),
|
||||
("data", models.JSONField(default=dict)),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"unique_together": {("user", "trend_slug")},
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,37 @@
|
||||
# Generated by Django 4.2.29 on 2026-06-17 14:32
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("trends", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterUniqueTogether(
|
||||
name="trendresult",
|
||||
unique_together=set(),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="trendresult",
|
||||
name="period",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("last_30", "Last 30 days"),
|
||||
("last_90", "Last 90 days"),
|
||||
("last_year", "Last year"),
|
||||
("all_time", "All time"),
|
||||
],
|
||||
default="all_time",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="trendresult",
|
||||
unique_together={("user", "trend_slug", "period")},
|
||||
),
|
||||
]
|
||||
0
vrobbler/apps/trends/migrations/__init__.py
Normal file
0
vrobbler/apps/trends/migrations/__init__.py
Normal file
30
vrobbler/apps/trends/models.py
Normal file
30
vrobbler/apps/trends/models.py
Normal file
@ -0,0 +1,30 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
PERIOD_CHOICES = [
|
||||
("last_30", "Last 30 days"),
|
||||
("last_90", "Last 90 days"),
|
||||
("last_year", "Last year"),
|
||||
("all_time", "All time"),
|
||||
]
|
||||
|
||||
|
||||
class TrendResult(TimeStampedModel):
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
trend_slug = models.CharField(max_length=100, db_index=True)
|
||||
period = models.CharField(
|
||||
max_length=20,
|
||||
choices=PERIOD_CHOICES,
|
||||
default="all_time",
|
||||
)
|
||||
computed_at = models.DateTimeField(auto_now_add=True)
|
||||
data = models.JSONField(default=dict)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["user", "trend_slug", "period"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user} - {self.trend_slug} ({self.period})"
|
||||
69
vrobbler/apps/trends/tasks.py
Normal file
69
vrobbler/apps/trends/tasks.py
Normal file
@ -0,0 +1,69 @@
|
||||
import logging
|
||||
|
||||
from celery import shared_task
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
from trends.trends import TREND_REGISTRY
|
||||
from trends.utils import compute_and_save_trend, get_supported_periods
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@shared_task
|
||||
def compute_all_trends():
|
||||
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
|
||||
def compute_user_trends(user_id):
|
||||
try:
|
||||
user = User.objects.get(id=user_id)
|
||||
except User.DoesNotExist:
|
||||
logger.warning("User %s not found, skipping trends", user_id)
|
||||
return
|
||||
|
||||
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
|
||||
|
||||
periods = get_supported_periods(slug)
|
||||
|
||||
for period in periods:
|
||||
logger.info("[%s/%s] Computing for user %d...", slug, period, user_id)
|
||||
try:
|
||||
elapsed = compute_and_save_trend(user, slug, period)
|
||||
logger.info(
|
||||
"[%s/%s] Completed for user %d in %.1fs",
|
||||
slug,
|
||||
period,
|
||||
user_id,
|
||||
elapsed,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("[%s/%s] Failed for user %d", slug, period, user_id)
|
||||
@ -0,0 +1,47 @@
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
{% if data.distribution %}
|
||||
<p class="text-muted mb-3">
|
||||
Total scrobbles{% if current_period_label %} ({{ current_period_label }}){% endif %}: <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>
|
||||
@ -0,0 +1,89 @@
|
||||
<div class="row">
|
||||
{% if data.trails %}
|
||||
<div class="col-md-6 mb-3">
|
||||
<h4>🥾 While on Trails</h4>
|
||||
{% for trail in data.trails %}
|
||||
<div class="card mb-2">
|
||||
<div class="card-body py-2">
|
||||
<h6 class="card-title mb-1">
|
||||
{% if trail.uuid %}
|
||||
<a href="{% url 'trails:trail_detail' trail.uuid %}">{{ trail.name }}</a>
|
||||
{% else %}
|
||||
{{ trail.name }}
|
||||
{% endif %}
|
||||
<small class="text-muted">({{ trail.total_sessions }} sessions)</small>
|
||||
</h6>
|
||||
{% if trail.tracks %}
|
||||
<table class="table table-sm table-borderless mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Track</th>
|
||||
<th>Artist</th>
|
||||
<th class="text-end">Plays</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for t in trail.tracks %}
|
||||
<tr>
|
||||
<td>{% if t.track_uuid %}<a href="{% url 'music:track_detail' t.track_uuid %}">{{ t.track_name }}</a>{% else %}{{ t.track_name }}{% endif %}</td>
|
||||
<td>{{ t.artist_name }}</td>
|
||||
<td class="text-end">{{ t.count }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p class="text-muted">No concurrent listening data for trails.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if data.locations %}
|
||||
<div class="col-md-6 mb-3">
|
||||
<h4>📍 While at Locations</h4>
|
||||
{% for loc in data.locations %}
|
||||
<div class="card mb-2">
|
||||
<div class="card-body py-2">
|
||||
<h6 class="card-title mb-1">
|
||||
{% if loc.uuid %}
|
||||
<a href="{% url 'locations:geolocation_detail' loc.uuid %}">{{ loc.name }}</a>
|
||||
{% else %}
|
||||
{{ loc.name }}
|
||||
{% endif %}
|
||||
<small class="text-muted">({{ loc.total_sessions }} sessions)</small>
|
||||
</h6>
|
||||
{% if loc.tracks %}
|
||||
<table class="table table-sm table-borderless mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Track</th>
|
||||
<th>Artist</th>
|
||||
<th class="text-end">Plays</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for t in loc.tracks %}
|
||||
<tr>
|
||||
<td>{% if t.track_uuid %}<a href="{% url 'music:track_detail' t.track_uuid %}">{{ t.track_name }}</a>{% else %}{{ t.track_name }}{% endif %}</td>
|
||||
<td>{{ t.artist_name }}</td>
|
||||
<td class="text-end">{{ t.count }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p class="text-muted">No concurrent listening data for locations.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if not data.trails and not data.locations %}
|
||||
<p class="text-muted">No concurrent listening data found.</p>
|
||||
{% endif %}
|
||||
@ -0,0 +1,42 @@
|
||||
<div class="row">
|
||||
{% if data.books %}
|
||||
{% for book in data.books %}
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="card">
|
||||
<div class="card-body py-2">
|
||||
<h6 class="card-title mb-1">
|
||||
{% if book.book_uuid %}
|
||||
<a href="{% url 'books:book_detail' book.book_uuid %}">{{ book.book_title }}</a>
|
||||
{% else %}
|
||||
{{ book.book_title }}
|
||||
{% endif %}
|
||||
<small class="text-muted">({{ book.total_sessions }} listening sessions)</small>
|
||||
</h6>
|
||||
{% if book.tracks %}
|
||||
<table class="table table-sm table-borderless mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Track</th>
|
||||
<th>Artist</th>
|
||||
<th class="text-end">Plays</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for t in book.tracks %}
|
||||
<tr>
|
||||
<td>{% if t.track_uuid %}<a href="{% url 'music:track_detail' t.track_uuid %}">{{ t.track_name }}</a>{% else %}{{ t.track_name }}{% endif %}</td>
|
||||
<td>{{ t.artist_name }}</td>
|
||||
<td class="text-end">{{ t.count }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="text-muted">No concurrent reading data found.</p>
|
||||
{% endif %}
|
||||
</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>
|
||||
57
vrobbler/apps/trends/templates/trends/_reading_pace.html
Normal file
57
vrobbler/apps/trends/templates/trends/_reading_pace.html
Normal file
@ -0,0 +1,57 @@
|
||||
<div class="row">
|
||||
{% if current_period_label %}
|
||||
<div class="col-12 mb-2">
|
||||
<small class="text-muted">Period: {{ current_period_label }}</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">🎵 Reading with Music</h5>
|
||||
{% if data.with_music %}
|
||||
<table class="table table-sm mb-0">
|
||||
<tr>
|
||||
<th>Avg session duration</th>
|
||||
<td>{{ data.with_music.avg_seconds }} seconds</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Total reading time</th>
|
||||
<td>{{ data.with_music.total_seconds }} seconds</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Reading sessions</th>
|
||||
<td>{{ data.with_music.sessions_count }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">No data.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">🔇 Reading without Music</h5>
|
||||
{% if data.without_music %}
|
||||
<table class="table table-sm mb-0">
|
||||
<tr>
|
||||
<th>Avg session duration</th>
|
||||
<td>{{ data.without_music.avg_seconds }} seconds</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Total reading time</th>
|
||||
<td>{{ data.without_music.total_seconds }} seconds</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Reading sessions</th>
|
||||
<td>{{ data.without_music.sessions_count }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">No data.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
38
vrobbler/apps/trends/templates/trends/_trending_up.html
Normal file
38
vrobbler/apps/trends/templates/trends/_trending_up.html
Normal file
@ -0,0 +1,38 @@
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
{% if data %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Media Type</th>
|
||||
<th class="text-end">Recent ({{ current_period_label }})</th>
|
||||
<th class="text-end">Previous ({{ current_period_label }})</th>
|
||||
<th class="text-end">Change</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for mt, info in data.items %}
|
||||
<tr>
|
||||
<td>{{ mt }}</td>
|
||||
<td class="text-end">{{ info.recent }}</td>
|
||||
<td class="text-end">{{ info.previous }}</td>
|
||||
<td class="text-end">
|
||||
{% if info.change_pct > 0 %}
|
||||
<span class="text-success">+{{ info.change_pct }}%</span>
|
||||
{% elif info.change_pct < 0 %}
|
||||
<span class="text-danger">{{ info.change_pct }}%</span>
|
||||
{% else %}
|
||||
<span class="text-muted">0%</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted">No trending 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>
|
||||
71
vrobbler/apps/trends/templates/trends/trend_detail.html
Normal file
71
vrobbler/apps/trends/templates/trends/trend_detail.html
Normal file
@ -0,0 +1,71 @@
|
||||
{% extends "base_list.html" %}
|
||||
|
||||
{% block title %}{{ trend.title }}{% endblock %}
|
||||
|
||||
{% block lists %}
|
||||
<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>
|
||||
<h2>{{ trend.icon }} {{ trend.title }}</h2>
|
||||
<p class="text-muted">{{ trend.description }}</p>
|
||||
|
||||
{% if supported_periods|length > 1 %}
|
||||
<div class="d-flex align-items-center gap-2 mb-2 flex-wrap">
|
||||
<nav class="btn-group btn-group-sm" role="group">
|
||||
{% for period_slug, period_label in supported_periods.items %}
|
||||
<a href="?period={{ period_slug }}"
|
||||
class="btn btn-sm {% if period_slug == current_period %}btn-primary{% else %}btn-outline-secondary{% endif %}">
|
||||
{{ period_label }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
{% if prev_period or next_period %}
|
||||
<div class="btn-group btn-group-sm">
|
||||
{% if prev_period %}
|
||||
<a href="?period={{ prev_period }}" class="btn btn-outline-secondary">« Prev</a>
|
||||
{% endif %}
|
||||
{% if next_period %}
|
||||
<a href="?period={{ next_period }}" class="btn btn-outline-secondary">Next »</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if computed_at %}
|
||||
<small class="text-muted">Last computed: {{ computed_at|date:"F j, Y H:i" }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if trend_not_found %}
|
||||
<div class="alert alert-warning">Trend not found.</div>
|
||||
|
||||
{% elif data is None %}
|
||||
<div class="alert alert-info">
|
||||
No data computed yet for this period. Trends are updated once daily, check back later.
|
||||
</div>
|
||||
|
||||
{% elif trend.slug == "concurrent-listening" %}
|
||||
{% include "trends/_concurrent_listening.html" %}
|
||||
|
||||
{% elif trend.slug == "concurrent-reading" %}
|
||||
{% include "trends/_concurrent_reading.html" %}
|
||||
|
||||
{% elif trend.slug == "reading-pace-vs-activity" %}
|
||||
{% include "trends/_reading_pace.html" %}
|
||||
|
||||
{% 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 %}
|
||||
39
vrobbler/apps/trends/templates/trends/trend_list.html
Normal file
39
vrobbler/apps/trends/templates/trends/trend_list.html
Normal file
@ -0,0 +1,39 @@
|
||||
{% extends "base_list.html" %}
|
||||
|
||||
{% block title %}Trends{% endblock %}
|
||||
|
||||
{% block lists %}
|
||||
<div class="row">
|
||||
{% if not user.is_authenticated %}
|
||||
<div class="col-12">
|
||||
<div class="alert alert-info">Log in to see your trends.</div>
|
||||
</div>
|
||||
{% elif not trends %}
|
||||
<div class="col-12">
|
||||
<div class="alert alert-info">
|
||||
No trends computed yet. Trends are computed once daily, check back later.
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
{% for trend in trends %}
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">
|
||||
<a href="{% url 'trends:trend-detail' trend.slug %}" class="stretched-link text-decoration-none">
|
||||
{{ trend.icon }} {{ trend.title }}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
0
vrobbler/apps/trends/templatetags/__init__.py
Normal file
0
vrobbler/apps/trends/templatetags/__init__.py
Normal file
36
vrobbler/apps/trends/trends/__init__.py
Normal file
36
vrobbler/apps/trends/trends/__init__.py
Normal file
@ -0,0 +1,36 @@
|
||||
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
|
||||
|
||||
TREND_REGISTRY = {}
|
||||
|
||||
|
||||
def register(slug):
|
||||
def decorator(fn):
|
||||
TREND_REGISTRY[slug] = fn
|
||||
return fn
|
||||
|
||||
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)
|
||||
113
vrobbler/apps/trends/trends/activity.py
Normal file
113
vrobbler/apps/trends/trends/activity.py
Normal file
@ -0,0 +1,113 @@
|
||||
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, period="all_time"):
|
||||
"""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, period="all_time"):
|
||||
"""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, period="all_time"):
|
||||
"""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".
|
||||
"""
|
||||
from trends.utils import get_date_range
|
||||
|
||||
start, end = get_date_range(period)
|
||||
filters = Q(user=user)
|
||||
if start:
|
||||
filters &= Q(timestamp__gte=start)
|
||||
if end:
|
||||
filters &= Q(timestamp__lte=end)
|
||||
|
||||
dist_qs = (
|
||||
Scrobble.objects.filter(filters)
|
||||
.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),
|
||||
}
|
||||
275
vrobbler/apps/trends/trends/concurrent.py
Normal file
275
vrobbler/apps/trends/trends/concurrent.py
Normal file
@ -0,0 +1,275 @@
|
||||
import datetime
|
||||
from collections import defaultdict
|
||||
|
||||
from django.db.models import Q
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
|
||||
def _range_for(scrobble):
|
||||
start = scrobble.timestamp
|
||||
end = scrobble.stop_timestamp
|
||||
if end is None:
|
||||
try:
|
||||
end = start + datetime.timedelta(hours=12)
|
||||
except AttributeError:
|
||||
end = start
|
||||
return start, end
|
||||
|
||||
|
||||
def _find_concurrent(anchor_scrobbles, paired_scrobbles):
|
||||
"""Find paired scrobbles that overlap in time with anchor scrobbles.
|
||||
|
||||
Returns a dict mapping each anchor scrobble PK to a list of
|
||||
paired scrobble PKs that overlap with it.
|
||||
"""
|
||||
anchor_ranges = {s.pk: _range_for(s) for s in anchor_scrobbles}
|
||||
paired_ranges = {s.pk: _range_for(s) for s in paired_scrobbles}
|
||||
|
||||
anchor_to_paired = defaultdict(list)
|
||||
|
||||
for a_pk, (a_start, a_end) in anchor_ranges.items():
|
||||
for p_pk, (p_start, p_end) in paired_ranges.items():
|
||||
if a_start <= p_end and p_start <= a_end:
|
||||
anchor_to_paired[a_pk].append(p_pk)
|
||||
|
||||
return anchor_to_paired
|
||||
|
||||
|
||||
def _get_media_name(scrobble):
|
||||
"""Return the name of the media object associated with a scrobble."""
|
||||
for attr in [
|
||||
"trail",
|
||||
"geo_location",
|
||||
"book",
|
||||
"track",
|
||||
]:
|
||||
obj = getattr(scrobble, attr, None)
|
||||
if obj is not None:
|
||||
return str(obj)
|
||||
return "Unknown"
|
||||
|
||||
|
||||
def compute_concurrent_listening(user, period="all_time"):
|
||||
"""Find what music was listened to while on trails or at locations.
|
||||
|
||||
Returns a dict with two keys: 'trails' and 'locations', each containing
|
||||
a list of entries with the trail/location name and the tracks listened to.
|
||||
"""
|
||||
from trends.utils import get_date_range
|
||||
|
||||
start, end = get_date_range(period)
|
||||
base_filters = Q(user=user, timestamp__isnull=False)
|
||||
if start:
|
||||
base_filters &= Q(timestamp__gte=start)
|
||||
if end:
|
||||
base_filters &= Q(timestamp__lte=end)
|
||||
|
||||
media_types_to_exclude_from_anchor = (
|
||||
"Track",
|
||||
"Book",
|
||||
"Video",
|
||||
"PodcastEpisode",
|
||||
"VideoGame",
|
||||
"BoardGame",
|
||||
"Puzzle",
|
||||
"Food",
|
||||
"Beer",
|
||||
"Task",
|
||||
"WebPage",
|
||||
"LifeEvent",
|
||||
"Mood",
|
||||
"BrickSet",
|
||||
"Channel",
|
||||
"BirdingLocation",
|
||||
"Paper",
|
||||
"SportEvent",
|
||||
)
|
||||
|
||||
anchor_scrobbles = list(
|
||||
Scrobble.objects.filter(
|
||||
base_filters,
|
||||
played_to_completion=True,
|
||||
)
|
||||
.exclude(media_type__in=media_types_to_exclude_from_anchor)
|
||||
.select_related("trail", "geo_location")
|
||||
.order_by("-timestamp")
|
||||
)
|
||||
|
||||
paired_scrobbles = list(
|
||||
Scrobble.objects.filter(
|
||||
base_filters,
|
||||
media_type="Track",
|
||||
stop_timestamp__isnull=False,
|
||||
played_to_completion=True,
|
||||
)
|
||||
.select_related("track")
|
||||
.order_by("-timestamp")
|
||||
)
|
||||
|
||||
if not anchor_scrobbles or not paired_scrobbles:
|
||||
return {"trails": [], "locations": []}
|
||||
|
||||
anchor_to_paired = _find_concurrent(anchor_scrobbles, paired_scrobbles)
|
||||
|
||||
paired_by_pk = {s.pk: s for s in paired_scrobbles}
|
||||
|
||||
trails = []
|
||||
locations = []
|
||||
|
||||
for anchor in anchor_scrobbles:
|
||||
paired_pks = anchor_to_paired.get(anchor.pk, [])
|
||||
if not paired_pks:
|
||||
continue
|
||||
|
||||
tracks_by_name = defaultdict(int)
|
||||
track_details = {}
|
||||
for p_pk in paired_pks:
|
||||
ps = paired_by_pk[p_pk]
|
||||
track = ps.track
|
||||
if track is None:
|
||||
continue
|
||||
name = str(track)
|
||||
tracks_by_name[name] += 1
|
||||
if name not in track_details:
|
||||
track_details[name] = {
|
||||
"track_name": name,
|
||||
"track_uuid": str(track.uuid) if track.uuid else "",
|
||||
"artist_name": str(track.artist) if track.artist else "",
|
||||
}
|
||||
|
||||
anchor_name = _get_media_name(anchor)
|
||||
entry = {
|
||||
"name": anchor_name,
|
||||
"uuid": "",
|
||||
"total_sessions": len(paired_pks),
|
||||
"tracks": sorted(
|
||||
[
|
||||
{**track_details[name], "count": count}
|
||||
for name, count in tracks_by_name.items()
|
||||
],
|
||||
key=lambda x: x["count"],
|
||||
reverse=True,
|
||||
)[:20],
|
||||
}
|
||||
|
||||
if anchor.media_type == "Trail":
|
||||
entry["uuid"] = (
|
||||
str(anchor.trail.uuid) if anchor.trail and anchor.trail.uuid else ""
|
||||
)
|
||||
trails.append(entry)
|
||||
else:
|
||||
entry["uuid"] = (
|
||||
str(anchor.geo_location.uuid)
|
||||
if anchor.geo_location and anchor.geo_location.uuid
|
||||
else ""
|
||||
)
|
||||
locations.append(entry)
|
||||
|
||||
return {
|
||||
"trails": sorted(trails, key=lambda x: x["total_sessions"], reverse=True)[:20],
|
||||
"locations": sorted(locations, key=lambda x: x["total_sessions"], reverse=True)[
|
||||
:20
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def compute_concurrent_reading(user, period="all_time"):
|
||||
"""Find what music was listened to while reading books.
|
||||
|
||||
Returns a dict with key 'books' containing a list of entries with the
|
||||
book title and the tracks listened to while reading.
|
||||
"""
|
||||
from trends.utils import get_date_range
|
||||
|
||||
start, end = get_date_range(period)
|
||||
base_filters = Q(user=user, timestamp__isnull=False)
|
||||
if start:
|
||||
base_filters &= Q(timestamp__gte=start)
|
||||
if end:
|
||||
base_filters &= Q(timestamp__lte=end)
|
||||
|
||||
anchor_scrobbles = list(
|
||||
Scrobble.objects.filter(
|
||||
base_filters,
|
||||
media_type="Book",
|
||||
stop_timestamp__isnull=False,
|
||||
played_to_completion=True,
|
||||
)
|
||||
.select_related("book")
|
||||
.order_by("-timestamp")
|
||||
)
|
||||
|
||||
paired_scrobbles = list(
|
||||
Scrobble.objects.filter(
|
||||
base_filters,
|
||||
media_type="Track",
|
||||
stop_timestamp__isnull=False,
|
||||
played_to_completion=True,
|
||||
)
|
||||
.select_related("track")
|
||||
.order_by("-timestamp")
|
||||
)
|
||||
|
||||
if not anchor_scrobbles or not paired_scrobbles:
|
||||
return {"books": []}
|
||||
|
||||
anchor_to_paired = _find_concurrent(anchor_scrobbles, paired_scrobbles)
|
||||
paired_by_pk = {s.pk: s for s in paired_scrobbles}
|
||||
|
||||
books_by_uuid = {}
|
||||
|
||||
for anchor in anchor_scrobbles:
|
||||
paired_pks = anchor_to_paired.get(anchor.pk, [])
|
||||
if not paired_pks:
|
||||
continue
|
||||
|
||||
book = anchor.book
|
||||
book_uuid = str(book.uuid) if book and book.uuid else ""
|
||||
book_key = book_uuid or str(book) if book else "Unknown"
|
||||
|
||||
if book_key not in books_by_uuid:
|
||||
books_by_uuid[book_key] = {
|
||||
"book_title": str(book) if book else "Unknown",
|
||||
"book_uuid": book_uuid,
|
||||
"total_sessions": 0,
|
||||
"tracks_by_name": defaultdict(int),
|
||||
"track_details": {},
|
||||
}
|
||||
|
||||
books_by_uuid[book_key]["total_sessions"] += len(paired_pks)
|
||||
|
||||
for p_pk in paired_pks:
|
||||
ps = paired_by_pk[p_pk]
|
||||
track = ps.track
|
||||
if track is None:
|
||||
continue
|
||||
name = str(track)
|
||||
books_by_uuid[book_key]["tracks_by_name"][name] += 1
|
||||
if name not in books_by_uuid[book_key]["track_details"]:
|
||||
books_by_uuid[book_key]["track_details"][name] = {
|
||||
"track_name": name,
|
||||
"track_uuid": str(track.uuid) if track.uuid else "",
|
||||
"artist_name": str(track.artist) if track.artist else "",
|
||||
}
|
||||
|
||||
books = []
|
||||
for bd in books_by_uuid.values():
|
||||
books.append(
|
||||
{
|
||||
"book_title": bd["book_title"],
|
||||
"book_uuid": bd["book_uuid"],
|
||||
"total_sessions": bd["total_sessions"],
|
||||
"tracks": sorted(
|
||||
[
|
||||
{**bd["track_details"][name], "count": count}
|
||||
for name, count in bd["tracks_by_name"].items()
|
||||
],
|
||||
key=lambda x: x["count"],
|
||||
reverse=True,
|
||||
)[:5],
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"books": sorted(books, key=lambda x: x["total_sessions"], reverse=True)[:20],
|
||||
}
|
||||
93
vrobbler/apps/trends/trends/reading.py
Normal file
93
vrobbler/apps/trends/trends/reading.py
Normal file
@ -0,0 +1,93 @@
|
||||
import datetime
|
||||
from collections import defaultdict
|
||||
|
||||
from django.db.models import Q
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
|
||||
def compute_reading_pace_vs_activity(user, period="all_time"):
|
||||
"""Compare reading pace (seconds per session) when music is playing vs. not.
|
||||
|
||||
For each Book scrobble with a playback_position_seconds value, checks
|
||||
whether there is an overlapping Track scrobble and groups the data.
|
||||
Returns average session duration for both groups.
|
||||
"""
|
||||
from trends.utils import get_date_range
|
||||
|
||||
start, end = get_date_range(period)
|
||||
base_filters = Q(user=user, timestamp__isnull=False)
|
||||
if start:
|
||||
base_filters &= Q(timestamp__gte=start)
|
||||
if end:
|
||||
base_filters &= Q(timestamp__lte=end)
|
||||
|
||||
book_scrobbles = list(
|
||||
Scrobble.objects.filter(
|
||||
base_filters,
|
||||
media_type="Book",
|
||||
playback_position_seconds__isnull=False,
|
||||
played_to_completion=True,
|
||||
)
|
||||
.select_related("book")
|
||||
.order_by("-timestamp")
|
||||
)
|
||||
|
||||
if not book_scrobbles:
|
||||
return {"with_music": None, "without_music": None}
|
||||
|
||||
track_scrobbles = list(
|
||||
Scrobble.objects.filter(
|
||||
base_filters,
|
||||
media_type="Track",
|
||||
played_to_completion=True,
|
||||
).order_by("-timestamp")
|
||||
)
|
||||
|
||||
track_ranges = []
|
||||
for ts in track_scrobbles:
|
||||
p_start = ts.timestamp
|
||||
p_end = ts.stop_timestamp
|
||||
if p_end is None:
|
||||
try:
|
||||
p_end = p_start + datetime.timedelta(hours=12)
|
||||
except AttributeError:
|
||||
p_end = p_start
|
||||
track_ranges.append((p_start, p_end))
|
||||
|
||||
with_music_durations = []
|
||||
without_music_durations = []
|
||||
|
||||
for bs in book_scrobbles:
|
||||
b_start = bs.timestamp
|
||||
b_end = bs.stop_timestamp
|
||||
if b_end is None:
|
||||
try:
|
||||
b_end = b_start + datetime.timedelta(hours=12)
|
||||
except AttributeError:
|
||||
b_end = b_start
|
||||
|
||||
has_overlap = False
|
||||
for p_start, p_end in track_ranges:
|
||||
if b_start <= p_end and p_start <= b_end:
|
||||
has_overlap = True
|
||||
break
|
||||
|
||||
duration = bs.playback_position_seconds
|
||||
if has_overlap:
|
||||
with_music_durations.append(duration)
|
||||
else:
|
||||
without_music_durations.append(duration)
|
||||
|
||||
def _stats(durations):
|
||||
if not durations:
|
||||
return None
|
||||
return {
|
||||
"avg_seconds": int(sum(durations) / len(durations)),
|
||||
"sessions_count": len(durations),
|
||||
"total_seconds": sum(durations),
|
||||
}
|
||||
|
||||
return {
|
||||
"with_music": _stats(with_music_durations),
|
||||
"without_music": _stats(without_music_durations),
|
||||
}
|
||||
67
vrobbler/apps/trends/trends/trending.py
Normal file
67
vrobbler/apps/trends/trends/trending.py
Normal file
@ -0,0 +1,67 @@
|
||||
from collections import defaultdict
|
||||
|
||||
from django.db.models import Count
|
||||
from django.utils import timezone
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
|
||||
def compute_trending_up(user, period="last_30"):
|
||||
"""Compare scrobble counts per media type between two periods.
|
||||
|
||||
Compares the most recent N days against the N days before that,
|
||||
returning the count for each period and the percentage change.
|
||||
The period controls the window size (e.g. 30, 90, 365 days).
|
||||
|
||||
Returns a dict keyed by media_type with count and change info.
|
||||
"""
|
||||
from trends.utils import get_period_days
|
||||
|
||||
days = get_period_days(period) or 30
|
||||
now = timezone.now()
|
||||
recent_start = now - timezone.timedelta(days=days)
|
||||
previous_start = recent_start - timezone.timedelta(days=days)
|
||||
|
||||
recent_counts = defaultdict(int)
|
||||
for row in (
|
||||
Scrobble.objects.filter(
|
||||
user=user,
|
||||
timestamp__gte=recent_start,
|
||||
timestamp__lte=now,
|
||||
played_to_completion=True,
|
||||
)
|
||||
.values("media_type")
|
||||
.annotate(count=Count("id"))
|
||||
):
|
||||
recent_counts[row["media_type"]] = row["count"]
|
||||
|
||||
previous_counts = defaultdict(int)
|
||||
for row in (
|
||||
Scrobble.objects.filter(
|
||||
user=user,
|
||||
timestamp__gte=previous_start,
|
||||
timestamp__lt=recent_start,
|
||||
played_to_completion=True,
|
||||
)
|
||||
.values("media_type")
|
||||
.annotate(count=Count("id"))
|
||||
):
|
||||
previous_counts[row["media_type"]] = row["count"]
|
||||
|
||||
all_types = set(list(recent_counts.keys()) + list(previous_counts.keys()))
|
||||
changes = {}
|
||||
for mt in sorted(all_types):
|
||||
rc = recent_counts.get(mt, 0)
|
||||
pc = previous_counts.get(mt, 0)
|
||||
if pc > 0:
|
||||
change_pct = round(((rc - pc) / pc) * 100, 1)
|
||||
elif rc > 0:
|
||||
change_pct = 100.0
|
||||
else:
|
||||
change_pct = 0.0
|
||||
changes[mt] = {
|
||||
"recent": rc,
|
||||
"previous": pc,
|
||||
"change_pct": change_pct,
|
||||
}
|
||||
|
||||
return changes
|
||||
9
vrobbler/apps/trends/urls.py
Normal file
9
vrobbler/apps/trends/urls.py
Normal file
@ -0,0 +1,9 @@
|
||||
from django.urls import path
|
||||
from trends.views import 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"),
|
||||
]
|
||||
80
vrobbler/apps/trends/utils.py
Normal file
80
vrobbler/apps/trends/utils.py
Normal file
@ -0,0 +1,80 @@
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
from trends.models import PERIOD_CHOICES, TrendResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PERIOD_DAYS = {
|
||||
"last_30": 30,
|
||||
"last_90": 90,
|
||||
"last_year": 365,
|
||||
"all_time": None,
|
||||
}
|
||||
|
||||
PERIOD_LABELS = dict(PERIOD_CHOICES)
|
||||
|
||||
TIME_BOUND_TRENDS = {
|
||||
"activity-distribution",
|
||||
"concurrent-reading",
|
||||
"concurrent-listening",
|
||||
"reading-pace-vs-activity",
|
||||
"trending-up",
|
||||
}
|
||||
|
||||
TREND_PERIOD_OVERRIDES = {
|
||||
"trending-up": ["last_30", "last_90", "last_year"],
|
||||
}
|
||||
|
||||
|
||||
def get_supported_periods(trend_slug):
|
||||
if trend_slug in TREND_PERIOD_OVERRIDES:
|
||||
slugs = TREND_PERIOD_OVERRIDES[trend_slug]
|
||||
return {s: PERIOD_LABELS[s] for s in slugs}
|
||||
if trend_slug in TIME_BOUND_TRENDS:
|
||||
return dict(PERIOD_LABELS)
|
||||
return {"all_time": PERIOD_LABELS["all_time"]}
|
||||
|
||||
|
||||
def get_period_days(period):
|
||||
return PERIOD_DAYS.get(period)
|
||||
|
||||
|
||||
def get_date_range(period):
|
||||
days = get_period_days(period)
|
||||
if days is None:
|
||||
return None, None
|
||||
now = timezone.now()
|
||||
return now - timedelta(days=days), now
|
||||
|
||||
|
||||
def get_period_nav(current_period, trend_slug):
|
||||
supported = get_supported_periods(trend_slug)
|
||||
keys = list(supported.keys())
|
||||
try:
|
||||
idx = keys.index(current_period)
|
||||
except ValueError:
|
||||
return None, None
|
||||
prev_period = keys[idx - 1] if idx > 0 else None
|
||||
next_period = keys[idx + 1] if idx < len(keys) - 1 else None
|
||||
return prev_period, next_period
|
||||
|
||||
|
||||
def compute_and_save_trend(user, slug, period="all_time"):
|
||||
"""Compute a single trend for a given period and persist the result.
|
||||
|
||||
Returns elapsed seconds on success, raises on failure.
|
||||
"""
|
||||
from trends.trends import TREND_REGISTRY
|
||||
|
||||
fn = TREND_REGISTRY[slug]
|
||||
start = timezone.now()
|
||||
data = fn(user, period=period)
|
||||
TrendResult.objects.update_or_create(
|
||||
user=user,
|
||||
trend_slug=slug,
|
||||
period=period,
|
||||
defaults={"data": data, "computed_at": timezone.now()},
|
||||
)
|
||||
return (timezone.now() - start).total_seconds()
|
||||
121
vrobbler/apps/trends/views.py
Normal file
121
vrobbler/apps/trends/views.py
Normal file
@ -0,0 +1,121 @@
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.views.generic import TemplateView
|
||||
from trends.models import TrendResult
|
||||
from trends.trends import TREND_REGISTRY
|
||||
from trends.utils import get_period_nav, get_supported_periods
|
||||
|
||||
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?",
|
||||
"icon": "🎧",
|
||||
},
|
||||
"concurrent-reading": {
|
||||
"title": "Concurrent Reading",
|
||||
"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.",
|
||||
"icon": "📊",
|
||||
},
|
||||
"trending-up": {
|
||||
"title": "Trending Media Types",
|
||||
"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": "📅",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TrendListView(LoginRequiredMixin, TemplateView):
|
||||
template_name = "trends/trend_list.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
results = TrendResult.objects.filter(
|
||||
user=self.request.user,
|
||||
).order_by("trend_slug", "-computed_at")
|
||||
|
||||
latest_by_slug = {}
|
||||
for r in results:
|
||||
if r.trend_slug not in latest_by_slug:
|
||||
latest_by_slug[r.trend_slug] = r
|
||||
|
||||
trends = []
|
||||
for slug in TREND_REGISTRY:
|
||||
meta = TREND_METADATA.get(slug, {})
|
||||
result = latest_by_slug.get(slug)
|
||||
trends.append(
|
||||
{
|
||||
"slug": slug,
|
||||
"title": meta.get("title", slug),
|
||||
"description": meta.get("description", ""),
|
||||
"icon": meta.get("icon", ""),
|
||||
"computed_at": result.computed_at if result else None,
|
||||
"has_data": result is not None,
|
||||
}
|
||||
)
|
||||
ctx["trends"] = trends
|
||||
return ctx
|
||||
|
||||
|
||||
class TrendDetailView(LoginRequiredMixin, TemplateView):
|
||||
template_name = "trends/trend_detail.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
slug = kwargs["trend_slug"]
|
||||
|
||||
if slug not in TREND_REGISTRY:
|
||||
ctx["trend_not_found"] = True
|
||||
return ctx
|
||||
|
||||
period = self.request.GET.get("period", "all_time")
|
||||
|
||||
meta = TREND_METADATA.get(slug, {})
|
||||
ctx["trend"] = {
|
||||
"slug": slug,
|
||||
"title": meta.get("title", slug),
|
||||
"description": meta.get("description", ""),
|
||||
"icon": meta.get("icon", ""),
|
||||
}
|
||||
|
||||
supported = get_supported_periods(slug)
|
||||
ctx["supported_periods"] = supported
|
||||
ctx["current_period"] = period
|
||||
ctx["current_period_label"] = supported.get(period, "")
|
||||
|
||||
prev_period, next_period = get_period_nav(period, slug)
|
||||
ctx["prev_period"] = prev_period
|
||||
ctx["next_period"] = next_period
|
||||
|
||||
result = TrendResult.objects.filter(
|
||||
user=self.request.user,
|
||||
trend_slug=slug,
|
||||
period=period,
|
||||
).first()
|
||||
|
||||
if result:
|
||||
ctx["computed_at"] = result.computed_at
|
||||
ctx["data"] = result.data
|
||||
else:
|
||||
ctx["computed_at"] = None
|
||||
ctx["data"] = None
|
||||
|
||||
return ctx
|
||||
@ -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,68 @@
|
||||
import logging
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Enrich TV series metadata from TMDB/OMDB APIs"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Overwrite existing cover image",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Show what would be done without making changes",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--imdb-id",
|
||||
type=str,
|
||||
help="Only process series with this imdb_id",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from videos.models import Series
|
||||
|
||||
force = options["force"]
|
||||
dry_run = options["dry_run"]
|
||||
imdb_id = options["imdb_id"]
|
||||
|
||||
qs = Series.objects.all()
|
||||
if imdb_id:
|
||||
qs = qs.filter(imdb_id=imdb_id)
|
||||
|
||||
total = qs.count()
|
||||
self.stdout.write(f"Processing {total} series")
|
||||
|
||||
if dry_run:
|
||||
for series in qs.iterator():
|
||||
self.stdout.write(
|
||||
f" [DRY RUN] Would fix {series.name} (imdb_id={series.imdb_id})"
|
||||
)
|
||||
return
|
||||
|
||||
updated = 0
|
||||
errors = 0
|
||||
for series in qs.iterator():
|
||||
try:
|
||||
with transaction.atomic():
|
||||
series.fix_metadata(force_update=force)
|
||||
updated += 1
|
||||
self.stdout.write(f" [{updated}/{total}] {series.name}")
|
||||
except Exception as e:
|
||||
errors += 1
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
f" Error updating series {series.name} (imdb_id={series.imdb_id}): {e}"
|
||||
)
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"\nDone! {updated} series 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:
|
||||
@ -77,6 +80,28 @@ class Channel(ScrobblableMixin):
|
||||
def title(self):
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def safe_cover_image_url(self) -> str:
|
||||
if self.cover_image:
|
||||
try:
|
||||
if self.cover_image.storage.exists(self.cover_image.name):
|
||||
return self.cover_medium.url
|
||||
except Exception:
|
||||
pass
|
||||
return "/static/images/not-found.jpg"
|
||||
|
||||
@property
|
||||
def youtube_url(self) -> str:
|
||||
if self.youtube_id:
|
||||
return YOUTUBE_CHANNEL_URL + self.youtube_id
|
||||
return ""
|
||||
|
||||
@property
|
||||
def twitch_url(self) -> str:
|
||||
if self.twitch_id:
|
||||
return f"https://www.twitch.tv/{self.twitch_id}"
|
||||
return ""
|
||||
|
||||
def save_image_from_url(self, url: str, force_update: bool = False):
|
||||
if not self.cover_image or (force_update and url):
|
||||
r = requests.get(url)
|
||||
@ -92,7 +117,7 @@ class Channel(ScrobblableMixin):
|
||||
played_query = models.Q()
|
||||
return Scrobble.objects.filter(
|
||||
played_query,
|
||||
channel=self,
|
||||
models.Q(channel=self) | models.Q(video__channel=self),
|
||||
user=user_id,
|
||||
).order_by("-timestamp")
|
||||
|
||||
@ -138,8 +163,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
|
||||
|
||||
|
||||
@ -239,16 +330,18 @@ class Series(TimeStampedModel):
|
||||
logger.warning(f"No imdb data for {self}")
|
||||
return
|
||||
|
||||
cover_url = imdb_dict.get("cover_url")
|
||||
|
||||
if (not self.cover_image or force_update) and cover_url:
|
||||
r = requests.get(cover_url)
|
||||
if video_metadata.cover_url and (not self.cover_image or force_update):
|
||||
r = requests.get(video_metadata.cover_url)
|
||||
if r.status_code == 200:
|
||||
fname = f"{self.name}_{self.uuid}.jpg"
|
||||
self.cover_image.save(fname, ContentFile(r.content), save=True)
|
||||
|
||||
if genres := imdb_dict.get("genres"):
|
||||
self.genre.add(*genres)
|
||||
self.plot = video_metadata.plot
|
||||
self.imdb_rating = video_metadata.imdb_rating
|
||||
self.save()
|
||||
|
||||
if video_metadata.genres:
|
||||
self.genre.add(*video_metadata.genres)
|
||||
|
||||
@classmethod
|
||||
def find_or_create(cls, imdb_id: str, overwrite: bool = True):
|
||||
@ -452,9 +545,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 +567,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")
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import datetime
|
||||
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
|
||||
from django.utils import timezone
|
||||
from django.views import generic
|
||||
from scrobbles.models import Scrobble
|
||||
@ -44,15 +46,31 @@ class SeriesDetailView(LoginRequiredMixin, ChartContextMixin, generic.DetailView
|
||||
return context_data
|
||||
|
||||
|
||||
class ChannelDetailView(LoginRequiredMixin, generic.DetailView):
|
||||
class ChannelDetailView(LoginRequiredMixin, ChartContextMixin, generic.DetailView):
|
||||
model = Channel
|
||||
slug_field = "uuid"
|
||||
template_name = "videos/channel_detail.html"
|
||||
paginate_by = 50
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
user_id = self.request.user.id
|
||||
context_data = super().get_context_data(**kwargs)
|
||||
context_data["scrobbles"] = self.object.scrobbles_for_user(user_id)
|
||||
|
||||
scrobbles = self.object.scrobbles_for_user(user_id)
|
||||
paginator = Paginator(scrobbles, self.paginate_by)
|
||||
page_number = self.request.GET.get("page")
|
||||
|
||||
try:
|
||||
page_obj = paginator.page(page_number)
|
||||
except PageNotAnInteger:
|
||||
page_obj = paginator.page(1)
|
||||
except EmptyPage:
|
||||
page_obj = paginator.page(paginator.num_pages)
|
||||
|
||||
context_data["page_obj"] = page_obj
|
||||
context_data["scrobbles"] = page_obj.object_list
|
||||
context_data["is_paginated"] = paginator.num_pages > 1
|
||||
|
||||
return context_data
|
||||
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from webpages.models import Domain, WebPage
|
||||
from webpages.models import Domain, HistoricalWebPage, WebPage
|
||||
|
||||
from scrobbles.admin import ScrobbleInline
|
||||
|
||||
@ -20,6 +20,12 @@ class DomainAdmin(admin.ModelAdmin):
|
||||
inlines = [WebPageInline]
|
||||
|
||||
|
||||
class HistoricalWebPageInline(admin.TabularInline):
|
||||
model = HistoricalWebPage
|
||||
extra = 0
|
||||
readonly_fields = ("date", "domain", "extract", "created")
|
||||
|
||||
|
||||
@admin.register(WebPage)
|
||||
class WebPageAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
@ -33,4 +39,20 @@ class WebPageAdmin(admin.ModelAdmin):
|
||||
search_fields = ("title",)
|
||||
inlines = [
|
||||
ScrobbleInline,
|
||||
HistoricalWebPageInline,
|
||||
]
|
||||
|
||||
|
||||
@admin.register(HistoricalWebPage)
|
||||
class HistoricalWebPageAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = (
|
||||
"uuid",
|
||||
"webpage",
|
||||
"date",
|
||||
"domain",
|
||||
"created",
|
||||
)
|
||||
raw_id_fields = ("webpage", "domain")
|
||||
ordering = ("-created",)
|
||||
search_fields = ("webpage__title",)
|
||||
|
||||
71
vrobbler/apps/webpages/migrations/0010_historicalwebpage.py
Normal file
71
vrobbler/apps/webpages/migrations/0010_historicalwebpage.py
Normal file
@ -0,0 +1,71 @@
|
||||
# Generated by Django 4.2.29 on 2026-06-16 13:39
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django_extensions.db.fields
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("webpages", "0009_alter_webpage_genre"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="HistoricalWebPage",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created",
|
||||
django_extensions.db.fields.CreationDateTimeField(
|
||||
auto_now_add=True, verbose_name="created"
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
django_extensions.db.fields.ModificationDateTimeField(
|
||||
auto_now=True, verbose_name="modified"
|
||||
),
|
||||
),
|
||||
(
|
||||
"uuid",
|
||||
models.UUIDField(
|
||||
blank=True, default=uuid.uuid4, editable=False, null=True
|
||||
),
|
||||
),
|
||||
("date", models.DateField(blank=True, null=True)),
|
||||
("extract", models.TextField(blank=True, null=True)),
|
||||
(
|
||||
"domain",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to="webpages.domain",
|
||||
),
|
||||
),
|
||||
(
|
||||
"webpage",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="historical_webpages",
|
||||
to="webpages.webpage",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"get_latest_by": "modified",
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -17,6 +17,7 @@ from htmldate import find_date
|
||||
from imagekit.models import ImageSpecField
|
||||
from imagekit.processors import ResizeToFit
|
||||
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
||||
from scrobbles.tasks import push_scrobble_to_archivebox
|
||||
from taggit.managers import TaggableManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -130,8 +131,7 @@ class WebPage(ScrobblableMixin):
|
||||
},
|
||||
)
|
||||
scrobble = Scrobble.create_or_update(self, user_id, scrobble_data)
|
||||
# TODO Possibly make this async?
|
||||
scrobble.push_to_archivebox()
|
||||
push_scrobble_to_archivebox.delay(scrobble.id)
|
||||
return scrobble
|
||||
|
||||
def scrobbles(self, user):
|
||||
@ -183,7 +183,13 @@ class WebPage(ScrobblableMixin):
|
||||
if save:
|
||||
self.save(update_fields=["date"])
|
||||
|
||||
def push_to_archivebox(self, url: str, username: str, password: str):
|
||||
def push_to_archivebox(self, user):
|
||||
profile = user.profile
|
||||
url = profile.archivebox_url
|
||||
if not url:
|
||||
return
|
||||
username = profile.archivebox_username
|
||||
password = profile.archivebox_password
|
||||
login_url = requests.compat.urljoin(url, "admin/login/")
|
||||
session = requests.Session()
|
||||
response = session.get(login_url)
|
||||
@ -297,4 +303,41 @@ class WebPage(ScrobblableMixin):
|
||||
if not webpage:
|
||||
webpage = cls(url=data_dict.get("url"))
|
||||
webpage.fetch_data_from_web(save=True)
|
||||
else:
|
||||
webpage._archive_and_refetch()
|
||||
return webpage
|
||||
|
||||
def _archive_and_refetch(self):
|
||||
"""Archive current content to HistoricalWebPage and re-fetch from web."""
|
||||
if self.extract or self.date or self.domain:
|
||||
HistoricalWebPage.objects.create(
|
||||
webpage=self,
|
||||
date=self.date,
|
||||
domain=self.domain,
|
||||
extract=self.extract,
|
||||
)
|
||||
|
||||
self.extract = None
|
||||
self.date = None
|
||||
self.domain = None
|
||||
self.title = None
|
||||
self.base_run_time_seconds = None
|
||||
self.image = None
|
||||
self.fetch_data_from_web(save=True, force=True)
|
||||
|
||||
|
||||
class HistoricalWebPage(TimeStampedModel):
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
webpage = models.ForeignKey(
|
||||
WebPage, on_delete=models.CASCADE, related_name="historical_webpages"
|
||||
)
|
||||
date = models.DateField(**BNULL)
|
||||
domain = models.ForeignKey(Domain, on_delete=models.DO_NOTHING, **BNULL)
|
||||
extract = models.TextField(**BNULL)
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.webpage.title:
|
||||
return "{} ({}) - {}".format(
|
||||
self.webpage.title, self.webpage.domain, self.created
|
||||
)
|
||||
return "{} - {}".format(self.webpage.url, self.created)
|
||||
|
||||
@ -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",
|
||||
@ -134,6 +139,10 @@ CELERY_BEAT_SCHEDULE = {
|
||||
"task": "scrobbles.tasks.rebuild_yearly_charts",
|
||||
"schedule": crontab(hour=0, minute=30, day_of_month=1, month_of_year=1),
|
||||
},
|
||||
"compute-daily-trends": {
|
||||
"task": "trends.tasks.compute_all_trends",
|
||||
"schedule": crontab(hour=0, minute=10),
|
||||
},
|
||||
# ── Crontab replacements ─────────────────────────────────────────────
|
||||
"database-backup": {
|
||||
"task": "scrobbles.tasks.backup_database",
|
||||
@ -192,6 +201,7 @@ INSTALLED_APPS = [
|
||||
"scrobbles",
|
||||
"people",
|
||||
"charts",
|
||||
"trends",
|
||||
"videos",
|
||||
"music",
|
||||
"podcasts",
|
||||
|
||||
@ -19,6 +19,26 @@
|
||||
color:white;
|
||||
background:rgba(0,0,0,0.4);
|
||||
}
|
||||
dl {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
padding-right:20px;
|
||||
border:none;
|
||||
}
|
||||
dt {
|
||||
flex-basis: 20%;
|
||||
padding: 5px;
|
||||
background: #3cf;
|
||||
text-align: right;
|
||||
color: #fff;
|
||||
}
|
||||
dd {
|
||||
flex-basis: 70%;
|
||||
flex-grow: 1;
|
||||
margin: 0;
|
||||
padding: 5px;
|
||||
border:none;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@ -29,9 +49,30 @@
|
||||
<img src="{{ object.safe_cover_image_url }}" width="400px" />
|
||||
</div>
|
||||
<div class="summary">
|
||||
{% if object.youtube_id %}<p><a href="{{object.youtube_url}}" target="_blank">View on YouTube</a></p>{% endif %}
|
||||
{% if object.description %}<p><em>{{object.description}}</em></p>{% endif %}
|
||||
{% if object.genre.all %}
|
||||
<p>Genres: {% for tag in object.genre.all %}<span class="badge bg-secondary">{{tag.name}}</span> {% endfor %}</p>
|
||||
{% endif %}
|
||||
<hr />
|
||||
{% if object.youtube_id %}
|
||||
<p style="float:right;">
|
||||
<a href="{{object.youtube_url}}" target="_blank"><img src="{% static "images/youtube_logo.png" %}" width=35></a>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if object.twitch_id %}
|
||||
<p style="float:right;">
|
||||
<a href="{{object.twitch_url}}" target="_blank">View on Twitch</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if charts %}
|
||||
<div class="row">
|
||||
<div class="col-md">
|
||||
{% include "scrobbles/_chart_links.html" %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="row">
|
||||
<div class="col-md">
|
||||
<h3>Last scrobbles</h3>
|
||||
@ -41,6 +82,8 @@
|
||||
<tr>
|
||||
<th scope="col">Date</th>
|
||||
<th scope="col">Title</th>
|
||||
<th scope="col">With</th>
|
||||
<th scope="col">Rated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -48,11 +91,26 @@
|
||||
<tr>
|
||||
<td><a href={{scrobble.get_absolute_url}}>{{scrobble.local_timestamp}}</a></td>
|
||||
<td><a href="{{scrobble.media_obj.get_absolute_url}}">{{scrobble.media_obj.title}}</a></td>
|
||||
<td>{% firstof scrobble.logdata.with_people|join:", " "Solo" %}</td>
|
||||
<td>{% firstof scrobble.logdata.rating "Unrated" %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if is_paginated %}
|
||||
<nav>
|
||||
<ul class="pagination">
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item"><a class="page-link" href="?page={{ page_obj.previous_page_number }}">Previous</a></li>
|
||||
{% endif %}
|
||||
<li class="page-item disabled"><span class="page-link">Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span></li>
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item"><a class="page-link" href="?page={{ page_obj.next_page_number }}">Next</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@ -63,6 +63,7 @@ dd {
|
||||
</div>
|
||||
<div class="summary">
|
||||
{% if object.tv_series %}<h4><a href="{{object.tv_series.get_absolute_url}}">{{object.tv_series}}</a> - S{{object.season_number}}E{{object.episode_number}}</h4>{% endif %}
|
||||
{% if object.channel %}<h5><a href="{{object.channel.get_absolute_url}}">{{object.channel.name}}</a></h5>{% endif %}
|
||||
{% if object.overview %}<p><em>{{object.overview}}</em></p>{% endif %}
|
||||
{% if object.plot%}<p>{{object.plot|safe|linebreaks|truncatewords:160}}</p>{% endif %}
|
||||
<hr />
|
||||
|
||||
@ -105,6 +105,7 @@ from vrobbler.apps.webpages.api.views import DomainViewSet, WebPageViewSet
|
||||
|
||||
from vrobbler.apps.people import urls as people_urls
|
||||
from vrobbler.apps.charts import urls as charts_urls
|
||||
from vrobbler.apps.trends import urls as trends_urls
|
||||
|
||||
# from vrobbler.apps.modern_ui import urls as modern_ui_urls
|
||||
|
||||
@ -182,6 +183,7 @@ urlpatterns = [
|
||||
path("", include(profiles_urls, namespace="profiles")),
|
||||
path("", include(people_urls, namespace="people")),
|
||||
path("", include(charts_urls, namespace="charts")),
|
||||
path("", include(trends_urls, namespace="trends")),
|
||||
path("", scrobbles_views.RecentScrobbleList.as_view(), name="vrobbler-home"),
|
||||
]
|
||||
if settings.DEBUG:
|
||||
|
||||
Reference in New Issue
Block a user