Compare commits

...

38 Commits
52.2 ... 55.2

Author SHA1 Message Date
68ff230f13 [release] Bump to version 55.2
All checks were successful
build / test (push) Successful in 1m57s
deploy / test (push) Successful in 2m0s
deploy / build-and-deploy (push) Successful in 30s
- Fix bug in scrobble id in calendar view
- Video game cleanup script should clear out broken images
2026-06-18 15:27:29 -04:00
57a952a6d1 [templates] Fix bug in calendar view
Some checks failed
build / test (push) Has been cancelled
2026-06-18 15:27:09 -04:00
718fcf7392 [videogames] Fix broken images in cleanup
All checks were successful
build / test (push) Successful in 2m0s
2026-06-18 15:24:47 -04:00
52adcf83c7 [release] Bump to version 55.1
All checks were successful
build / test (push) Successful in 1m57s
deploy / test (push) Successful in 1m57s
deploy / build-and-deploy (push) Successful in 30s
- Clean up metadata scrapping for video games
2026-06-18 15:08:46 -04:00
0061623f7e [videogames] Fix metadata scrapping for video games
Some checks failed
build / test (push) Has been cancelled
2026-06-18 15:08:23 -04:00
ec73e5151e [release] Bump to version 55.0
All checks were successful
build / test (push) Successful in 1m58s
deploy / test (push) Successful in 1m57s
deploy / build-and-deploy (push) Successful in 37s
- Use pk ID for scrobble detail view, not uuid
- Display videogame screenshots on scrobble detail if they exist
- Add autotagging to webpages based on domain, title
2026-06-18 12:15:16 -04:00
2c90dd38b5 [project] Add todos 2026-06-18 12:14:58 -04:00
c6b1e42d7a [scrobbles] Use IDs not UUIDs in URLs
All checks were successful
build / test (push) Successful in 1m59s
2026-06-18 11:25:57 -04:00
fcf86d5b3f [scrobbles] Add screenshots to templates
All checks were successful
build / test (push) Successful in 2m6s
2026-06-18 10:54:28 -04:00
6fde9ec8d2 [webpages] Add autotagging to webpages
All checks were successful
build / test (push) Successful in 1m59s
2026-06-18 10:43:21 -04:00
0f1882b21f [release] Bump to version 54.5
All checks were successful
build / test (push) Successful in 2m7s
deploy / build-and-deploy (push) Has been skipped
deploy / test (push) Successful in 1m58s
- Fix bug in generating mood trends
2026-06-17 21:46:21 -04:00
e819a2db0d [trends] Fix bug in mood trend generation 2026-06-17 21:45:45 -04:00
e03cf6c9b1 [release] Bump to version 54.4
All checks were successful
build / test (push) Successful in 2m0s
deploy / test (push) Successful in 1m58s
deploy / build-and-deploy (push) Successful in 29s
- Remove all-time trends
- Add a trend around moods
2026-06-17 17:09:30 -04:00
471e70ff7f [trends] Remove all time trends
Some checks failed
build / test (push) Has been cancelled
2026-06-17 17:09:11 -04:00
255e335d7a [trends] Add some mood related trends 2026-06-17 17:07:16 -04:00
c8cf80b513 [release] Bump to version 54.3
All checks were successful
deploy / test (push) Successful in 2m1s
build / test (push) Successful in 1m52s
deploy / build-and-deploy (push) Successful in 29s
- Fix bug in series metadata cleanup script
2026-06-17 12:05:50 -04:00
b4180afbed [videos] Fix metadata for series script
Some checks failed
build / test (push) Has been cancelled
2026-06-17 12:05:22 -04:00
37112babbb [release] Bump to version 54.2
All checks were successful
build / test (push) Successful in 1m59s
deploy / test (push) Successful in 1m56s
deploy / build-and-deploy (push) Successful in 30s
- Add script to clean up TV series metadata
- Update youtube video detail pages with links to channel
- Concurrent reading trend does not consolidate on single book
- Trends dont seem to look very far back
2026-06-17 11:06:11 -04:00
fb775f2f58 [videos] Add cmd to cleanup series metadata
Some checks failed
build / test (push) Has been cancelled
2026-06-17 11:05:38 -04:00
b26470c279 [videos] Fix channel templates
All checks were successful
build / test (push) Successful in 2m0s
2026-06-17 10:59:42 -04:00
d3b9ec815b [trends] Fix concurrent reading trend
All checks were successful
build / test (push) Successful in 2m14s
2026-06-17 10:50:59 -04:00
19f2b5e801 [trends] Add time periods 2026-06-17 10:50:16 -04:00
9e3288a5ff [release] Bump to version 54.1
All checks were successful
build / test (push) Successful in 2m0s
deploy / test (push) Successful in 1m58s
deploy / build-and-deploy (push) Successful in 35s
- Concurrent listening trend is inefficient and should be disabled
2026-06-17 09:21:20 -04:00
06465919dd [trends] Disable concurrent listening
Some checks failed
build / test (push) Has been cancelled
2026-06-17 09:20:29 -04:00
253e58eb48 [release] Bump to version 54.0
All checks were successful
build / test (push) Successful in 1m50s
deploy / test (push) Successful in 1m57s
deploy / build-and-deploy (push) Successful in 49s
- Add peak hour, weekly rhythm and activity dist trends
- Implement YouTube channel info scraping
- Fix Amazon book scraper
2026-06-16 16:42:44 -04:00
5393996e47 [trends] Add peak hours, weekly rhtyhms and activity dist trends
Some checks failed
build / test (push) Has been cancelled
2026-06-16 16:42:14 -04:00
1624f01e11 [videos] Increase metdata for Youtube
All checks were successful
build / test (push) Successful in 2m1s
2026-06-16 15:54:16 -04:00
535dead7e8 [videos] Clean up channel metadata
All checks were successful
build / test (push) Successful in 1m54s
2026-06-16 13:49:32 -04:00
3b97d49227 [books] Fix amazon scrapper to use actual AWS endpoints
All checks were successful
build / test (push) Successful in 2m2s
2026-06-16 13:18:20 -04:00
ea7b0946bb [release] Bump to version 53.1
All checks were successful
deploy / test (push) Successful in 1m58s
build / test (push) Successful in 1m56s
deploy / build-and-deploy (push) Successful in 34s
- Error with loading logdict
2026-06-16 11:31:14 -04:00
b8384166de [music] Fix logdata lookup for music
Some checks failed
build / test (push) Has been cancelled
2026-06-16 11:30:54 -04:00
d2705758c6 [release] Bump to version 53.0
All checks were successful
build / test (push) Successful in 1m57s
deploy / test (push) Successful in 1m59s
deploy / build-and-deploy (push) Successful in 52s
- Add a /trends/ page that shows trends based on scrobble data
- Notify users when Last.fm import completes
- Cleaner =GeoLocationLogData= deserialization
- Webpage scrobbles should diff existing webpages content
- Make ArchiveBox push asynchronous
2026-06-16 11:13:07 -04:00
f4368c31f3 [project] Update todos
Some checks failed
build / test (push) Has been cancelled
2026-06-16 11:12:50 -04:00
57f273b0cc [trends] Initial trends app
Some checks failed
build / test (push) Has been cancelled
2026-06-16 11:12:13 -04:00
ac82292200 [importers] Add last.fm import notifications 2026-06-16 10:26:08 -04:00
6a8432c08f [locations] Handle log data cleaner
All checks were successful
build / test (push) Successful in 1m54s
2026-06-16 10:12:58 -04:00
5a2c41155c [webpages] Add historical extract stashing
All checks were successful
build / test (push) Successful in 1m50s
2026-06-16 09:52:56 -04:00
83a046111b [webpages] Async pushing to archivebox
All checks were successful
build / test (push) Successful in 1m52s
2026-06-16 09:27:40 -04:00
95 changed files with 4073 additions and 336 deletions

View File

@ -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

View File

@ -88,7 +88,7 @@ fetching and simple saving.
*** Metadata sources
**** Scraper
* Backlog [0/20] :vrobbler:project:personal:
* Backlog [0/21] :vrobbler:project:personal:
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles:
:PROPERTIES:
:ID: 702462cf-d54b-48c6-8a7c-78b8de751deb
@ -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,223 @@ 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
** TODO [#B] Is there way to create unique slugs for media instances :media_types:
* Version 55.2 [2/2]
** DONE [#A] Fix bug in scrobble id in calendar view :templates:
:PROPERTIES:
:ID: 8cb34852-b18f-e794-cd9b-fb1ecad70a0d
:END:
** DONE [#A] Video game cleanup script should clear out broken images :metadata:videogames:
:PROPERTIES:
:ID: ca1f1ea9-0f79-082c-5ff7-867671faff4b
:END:
* Version 55.1 [1/1]
** DONE [#A] Clean up metadata scrapping for video games :metadata:videogames:
:PROPERTIES:
:ID: fbc421b5-21a3-4aed-9062-c59192ead065
:END:
* Version 55.0 [3/3]
** DONE [#B] Use pk ID for scrobble detail view, not uuid :scrobbles:
:PROPERTIES:
:ID: 9cc3b285-e478-041e-394b-3d550aefbe1d
:END:
** DONE [#B] Display videogame screenshots on scrobble detail if they exist :videogames:templates:
:PROPERTIES:
:ID: 0406d082-20f6-0d12-76e2-f281c4801468
:END:
** DONE [#B] Add autotagging to webpages based on domain, title :webpages:metadata:
:PROPERTIES:
:ID: f658435b-f7a0-42e6-b9f6-226678a77a55
:END:
*** Description
For easier filtering, like we do with tasks, we should auto tag WebPage instances
based on the domain name split part by periods (so news.ycombinator.com tags: news, ycombinator, com)
And also based on the nouns in the title.
* Version 54.5 [1/1]
** DONE Fix bug in generating mood trends :trends:
:PROPERTIES:
:ID: 8e75abfa-8e70-d85b-00a4-a4813bbce879
:END:
* Version 54.4 [2/2]
** DONE [#A] Remove all-time trends :trends:
:PROPERTIES:
:ID: 53b231d1-7677-8cd3-1d88-dae110aba1e6
:END:
*** Description
All time trends take forever to calculate and don't provide too much data
** DONE [#B] Add a trend around moods :moods:trends:
:PROPERTIES:
:ID: fba3f4ae-8f97-ee0b-e762-31630884518a
:END:
* Version 54.3 [1/1]
** DONE [#B] Fix bug in series metadata cleanup script :videos:metadta:
:PROPERTIES:
:ID: 85448702-907c-5d63-f5af-7795661d7c46
:END:
* 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:

25
poetry.lock generated
View File

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

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "vrobbler"
version = "52.2"
version = "55.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

View File

@ -519,7 +519,7 @@ def test_scrobble_detail_view_with_notes_as_flat_list(client):
"description": "Test description",
},
)
url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid})
url = reverse("scrobbles:detail", kwargs={"pk": scrobble.id})
response = client.get(url)
assert response.status_code == 200
assert "First note" in response.content.decode()
@ -545,7 +545,7 @@ def test_scrobble_detail_view_with_notes_as_dict_timestamps(client):
"description": "Test description",
},
)
url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid})
url = reverse("scrobbles:detail", kwargs={"pk": scrobble.id})
response = client.get(url)
assert response.status_code == 200
content = response.content.decode()
@ -574,7 +574,7 @@ def test_scrobble_detail_view_with_notes_and_labels(client):
"description": "Test description",
},
)
url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid})
url = reverse("scrobbles:detail", kwargs={"pk": scrobble.id})
response = client.get(url)
assert response.status_code == 200
content = response.content.decode()
@ -597,7 +597,7 @@ def test_scrobble_detail_view_post_updates_log(client):
"description": "Original description",
},
)
url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid})
url = reverse("scrobbles:detail", kwargs={"pk": scrobble.id})
client.force_login(user)
response = client.post(
@ -896,7 +896,7 @@ def test_change_visibility_owner_can_change(client):
)
client.force_login(user)
url = reverse("scrobbles:change-visibility", kwargs={"uuid": scrobble.uuid})
url = reverse("scrobbles:change-visibility", kwargs={"pk": scrobble.id})
response = client.post(url, {"visibility": "shared"})
assert response.status_code == 302
@ -919,7 +919,7 @@ def test_change_visibility_non_owner_gets_404(client):
)
client.force_login(other)
url = reverse("scrobbles:change-visibility", kwargs={"uuid": scrobble.uuid})
url = reverse("scrobbles:change-visibility", kwargs={"pk": scrobble.id})
response = client.post(url, {"visibility": "shared"})
assert response.status_code == 404
@ -937,7 +937,7 @@ def test_change_visibility_anonymous_redirects_to_login(client):
task=task, media_type="Task", user=user, visibility="private",
timestamp=timezone.now(),
)
url = reverse("scrobbles:change-visibility", kwargs={"uuid": scrobble.uuid})
url = reverse("scrobbles:change-visibility", kwargs={"pk": scrobble.id})
response = client.post(url, {"visibility": "shared"})
assert response.status_code == 302
assert "/login/" in response.url
@ -956,7 +956,7 @@ def test_regenerate_share_token_invalidates_old_sqid(client):
old_sqid = encode_scrobble_share(scrobble.id, scrobble.share_token_version)
client.force_login(user)
url = reverse("scrobbles:regenerate-share-token", kwargs={"uuid": scrobble.uuid})
url = reverse("scrobbles:regenerate-share-token", kwargs={"pk": scrobble.id})
response = client.post(url)
assert response.status_code == 302
@ -985,7 +985,7 @@ def test_share_analytics_owner_can_view(client):
)
client.force_login(user)
url = reverse("scrobbles:share-analytics", kwargs={"uuid": scrobble.uuid})
url = reverse("scrobbles:share-analytics", kwargs={"pk": scrobble.id})
response = client.get(url)
assert response.status_code == 200
@ -1005,7 +1005,7 @@ def test_share_analytics_non_owner_gets_404(client):
)
client.force_login(other)
url = reverse("scrobbles:share-analytics", kwargs={"uuid": scrobble.uuid})
url = reverse("scrobbles:share-analytics", kwargs={"pk": scrobble.id})
response = client.get(url)
assert response.status_code == 404
@ -1027,7 +1027,7 @@ def test_share_analytics_shows_view_logs(client):
client.get(share_url)
client.force_login(user)
url = reverse("scrobbles:share-analytics", kwargs={"uuid": scrobble.uuid})
url = reverse("scrobbles:share-analytics", kwargs={"pk": scrobble.id})
response = client.get(url)
assert response.status_code == 200
content = response.content.decode()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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):

View File

@ -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

View File

@ -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={

View File

@ -114,7 +114,7 @@ class ScrobblableMixin(TimeStampedModel):
@property
def start_url(self):
return reverse("scrobbles:start", kwargs={"uuid": self.uuid})
return reverse("scrobbles:start", kwargs={"media_uuid": self.uuid})
@property
def strings(self) -> ScrobblableConstants:
@ -162,7 +162,7 @@ class LongPlayScrobblableMixin(ScrobblableMixin):
return False
def get_longplay_finish_url(self):
return reverse("scrobbles:longplay-finish", kwargs={"uuid": self.uuid})
return reverse("scrobbles:longplay-finish", kwargs={"media_uuid": self.uuid})
def first_long_play_scrobble_for_user(self, user) -> Optional["Scrobble"]:
last = self.last_long_play_scrobble_for_user(user)

View File

@ -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}")
@ -793,6 +798,12 @@ class Scrobble(TimeStampedModel):
format="JPEG",
options={"quality": 75},
)
screenshot_large = ImageSpecField(
source="screenshot",
processors=[ResizeToFit(800, 800)],
format="JPEG",
options={"quality": 85},
)
long_play_seconds = models.BigIntegerField(**BNULL)
long_play_complete = models.BooleanField(**BNULL)
long_play_last_scrobble = models.ForeignKey(
@ -895,7 +906,7 @@ class Scrobble(TimeStampedModel):
@property
def finish_url(self) -> str:
return reverse("scrobbles:finish", kwargs={"uuid": self.uuid})
return reverse("scrobbles:finish", kwargs={"pk": self.pk})
def save(self, *args, **kwargs):
class_name = self.media_obj.__class__.__name__
@ -937,10 +948,7 @@ class Scrobble(TimeStampedModel):
return super(Scrobble, self).save(*args, **kwargs)
def get_absolute_url(self):
if not self.uuid:
self.uuid = uuid4()
self.save(update_fields=["uuid"])
return reverse("scrobbles:detail", kwargs={"uuid": self.uuid})
return reverse("scrobbles:detail", kwargs={"pk": self.pk})
def get_share_url(self):
if self.visibility == Visibility.PRIVATE:
@ -952,27 +960,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 +981,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)

View File

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

View File

@ -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

View File

@ -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 ──────────────────────────────────────────────────────

View File

@ -44,7 +44,7 @@ urlpatterns = [
name="lookup-manual-scrobble",
),
path(
"long-play-finish/<slug:uuid>/",
"long-play-finish/<slug:media_uuid>/",
views.scrobble_longplay_finish,
name="longplay-finish",
),
@ -160,38 +160,38 @@ urlpatterns = [
name="shared-detail",
),
path(
"scrobbles/<slug:uuid>/",
"scrobbles/<int:pk>/",
views.ScrobbleDetailView.as_view(),
name="detail",
),
path(
"scrobbles/<slug:uuid>/regenerate-share-token/",
"scrobbles/<int:pk>/regenerate-share-token/",
views.RegenerateShareTokenView.as_view(),
name="regenerate-share-token",
),
path(
"scrobbles/<slug:uuid>/change-visibility/",
"scrobbles/<int:pk>/change-visibility/",
views.ChangeVisibilityView.as_view(),
name="change-visibility",
),
path(
"scrobbles/<slug:uuid>/share-analytics/",
"scrobbles/<int:pk>/share-analytics/",
views.ScrobbleShareAnalyticsView.as_view(),
name="share-analytics",
),
path(
"scrobbles/<slug:uuid>/add-to-mopidy-queue/",
"scrobbles/<int:pk>/add-to-mopidy-queue/",
views.add_to_mopidy_queue,
name="add-to-mopidy-queue",
),
path(
"scrobbles/<slug:uuid>/add-to-mopidy-monthly-playlist/",
"scrobbles/<int:pk>/add-to-mopidy-monthly-playlist/",
views.add_to_mopidy_monthly_playlist,
name="add-to-mopidy-monthly-playlist",
),
path("scrobbles/<slug:uuid>/start/", views.scrobble_start, name="start"),
path("scrobbles/<slug:uuid>/finish/", views.scrobble_finish, name="finish"),
path("scrobbles/<slug:uuid>/cancel/", views.scrobble_cancel, name="cancel"),
path("scrobbles/<slug:media_uuid>/start/", views.scrobble_start, name="start"),
path("scrobbles/<int:pk>/finish/", views.scrobble_finish, name="finish"),
path("scrobbles/<int:pk>/cancel/", views.scrobble_cancel, name="cancel"),
path(
"favorite/<str:media_type>/<int:object_id>/toggle/",
views.toggle_favorite,

View File

@ -1,4 +1,5 @@
import hashlib
import html
import logging
import requests
import re
@ -795,6 +796,7 @@ def tokenize_title_to_tags(title: str) -> list[str]:
if not title:
return []
title = html.unescape(title)
cleaned = re.sub(r"[\(\)\[\]\{\}]", "", title)
cleaned = re.sub(r"[^\w\s]", "", cleaned)

View File

@ -839,10 +839,10 @@ def import_audioscrobbler_file(request):
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def scrobble_start(request, uuid):
def scrobble_start(request, media_uuid):
logger.info(
"[scrobble_start] called",
extra={"request": request, "uuid": uuid},
extra={"request": request, "media_uuid": media_uuid},
)
user = request.user
success_url = request.META.get("HTTP_REFERER")
@ -853,14 +853,14 @@ def scrobble_start(request, uuid):
media_obj = None
for app, model in PLAY_AGAIN_MEDIA.items():
media_model = apps.get_model(app_label=app, model_name=model)
media_obj = media_model.objects.filter(uuid=uuid).first()
media_obj = media_model.objects.filter(uuid=media_uuid).first()
if media_obj:
break
if not media_obj:
logger.info(
"[scrobble_start] media object not found",
extra={"uuid": uuid, "user_id": user.id},
extra={"media_uuid": media_uuid, "user_id": user.id},
)
raise Exception("No media object provided to scrobble")
@ -897,7 +897,7 @@ def scrobble_start(request, uuid):
)
else:
messages.add_message(
request, messages.ERROR, f"Media with uuid {uuid} not found."
request, messages.ERROR, f"Media with uuid {media_uuid} not found."
)
if (
@ -915,7 +915,7 @@ def scrobble_start(request, uuid):
@api_view(["GET"])
def scrobble_longplay_finish(request, uuid):
def scrobble_longplay_finish(request, media_uuid):
user = request.user
success_url = request.META.get("HTTP_REFERER")
@ -923,7 +923,7 @@ def scrobble_longplay_finish(request, uuid):
return HttpResponseRedirect(success_url)
# Try scrobble UUID first
scrobble = Scrobble.objects.filter(uuid=uuid, user=user).first()
scrobble = Scrobble.objects.filter(uuid=media_uuid, user=user).first()
if scrobble:
if scrobble.long_play_complete == True:
scrobble.long_play_complete = None
@ -947,13 +947,13 @@ def scrobble_longplay_finish(request, uuid):
media_obj = None
for app, model in LONG_PLAY_MEDIA.items():
media_model = apps.get_model(app_label=app, model_name=model)
media_obj = media_model.objects.filter(uuid=uuid).first()
media_obj = media_model.objects.filter(uuid=media_uuid).first()
if media_obj:
break
if not media_obj:
messages.add_message(
request, messages.ERROR, f"Media with uuid {uuid} not found."
request, messages.ERROR, f"Media with uuid {media_uuid} not found."
)
return HttpResponseRedirect(success_url)
@ -976,14 +976,14 @@ def scrobble_longplay_finish(request, uuid):
)
else:
messages.add_message(
request, messages.ERROR, f"Media with uuid {uuid} not found."
request, messages.ERROR, f"Media with uuid {media_uuid} not found."
)
return HttpResponseRedirect(success_url)
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def scrobble_finish(request, uuid):
def scrobble_finish(request, pk):
user = request.user
success_url = request.META.get("HTTP_REFERER")
if not success_url:
@ -992,7 +992,7 @@ def scrobble_finish(request, uuid):
if not user.is_authenticated:
return HttpResponseRedirect(success_url)
scrobble = Scrobble.objects.filter(user=user, uuid=uuid).first()
scrobble = Scrobble.objects.filter(user=user, pk=pk).first()
if scrobble:
scrobble.stop(force_finish=True)
messages.add_message(
@ -1007,14 +1007,14 @@ def scrobble_finish(request, uuid):
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def scrobble_cancel(request, uuid):
def scrobble_cancel(request, pk):
user = request.user
success_url = reverse_lazy("vrobbler-home")
if not user.is_authenticated:
return HttpResponseRedirect(success_url)
scrobble = Scrobble.objects.filter(user=user, uuid=uuid).first()
scrobble = Scrobble.objects.filter(user=user, pk=pk).first()
if scrobble:
scrobble.cancel()
messages.add_message(
@ -1028,11 +1028,11 @@ def scrobble_cancel(request, uuid):
@require_POST
def add_to_mopidy_queue(request, uuid):
def add_to_mopidy_queue(request, pk):
if not request.user.is_authenticated:
return redirect("scrobbles:detail", uuid=uuid)
return redirect("scrobbles:detail", pk=pk)
scrobble = get_object_or_404(Scrobble, uuid=uuid, user=request.user)
scrobble = get_object_or_404(Scrobble, pk=pk, user=request.user)
mopidy_url = request.user.profile.mopidy_api_url
if not mopidy_url:
@ -1041,22 +1041,22 @@ def add_to_mopidy_queue(request, uuid):
messages.ERROR,
"Mopidy API URL not configured in your profile settings.",
)
return redirect("scrobbles:detail", uuid=uuid)
return redirect("scrobbles:detail", pk=pk)
from scrobbles.tasks import add_scrobble_to_mopidy_queue as task
task.delay(scrobble.id)
msg = f'Adding "{scrobble.media_obj}" to Mopidy queue.'
messages.add_message(request, messages.SUCCESS, msg)
return redirect("scrobbles:detail", uuid=uuid)
return redirect("scrobbles:detail", pk=pk)
@require_POST
def add_to_mopidy_monthly_playlist(request, uuid):
def add_to_mopidy_monthly_playlist(request, pk):
if not request.user.is_authenticated:
return redirect("scrobbles:detail", uuid=uuid)
return redirect("scrobbles:detail", pk=pk)
scrobble = get_object_or_404(Scrobble, uuid=uuid, user=request.user)
scrobble = get_object_or_404(Scrobble, pk=pk, user=request.user)
profile = request.user.profile
pattern = profile.monthly_mopidy_playlist_pattern
@ -1066,7 +1066,7 @@ def add_to_mopidy_monthly_playlist(request, uuid):
messages.ERROR,
"Monthly playlist pattern or Mopidy API URL not configured in your profile.",
)
return redirect("scrobbles:detail", uuid=uuid)
return redirect("scrobbles:detail", pk=pk)
now = now_user_timezone(profile)
playlist_name = DateFormat(now).format(pattern)
@ -1079,7 +1079,7 @@ def add_to_mopidy_monthly_playlist(request, uuid):
messages.SUCCESS,
f'Adding "{scrobble.media_obj}" to monthly playlist "{playlist_name}".',
)
return redirect("scrobbles:detail", uuid=uuid)
return redirect("scrobbles:detail", pk=pk)
@require_POST
@ -1184,8 +1184,6 @@ class ScrobbleStatusView(LoginRequiredMixin, TemplateView):
class ScrobbleDetailView(DetailView):
model = Scrobble
slug_field = "uuid"
slug_url_kwarg = "uuid"
paginate_by = 100
def get_object(self, queryset=None):
@ -1385,15 +1383,15 @@ class ScrobbleExploreView(ListView):
class RegenerateShareTokenView(LoginRequiredMixin, View):
def post(self, request, uuid):
scrobble = get_object_or_404(Scrobble, uuid=uuid, user=request.user)
def post(self, request, pk):
scrobble = get_object_or_404(Scrobble, pk=pk, user=request.user)
scrobble.regenerate_share_token()
return redirect(scrobble.get_absolute_url())
class ChangeVisibilityView(LoginRequiredMixin, View):
def post(self, request, uuid):
scrobble = get_object_or_404(Scrobble, uuid=uuid, user=request.user)
def post(self, request, pk):
scrobble = get_object_or_404(Scrobble, pk=pk, user=request.user)
visibility = request.POST.get("visibility")
if visibility not in (Visibility.PUBLIC, Visibility.SHARED, Visibility.PRIVATE):
return redirect(scrobble.get_absolute_url())
@ -1404,8 +1402,6 @@ class ChangeVisibilityView(LoginRequiredMixin, View):
class ScrobbleShareAnalyticsView(LoginRequiredMixin, DetailView):
model = Scrobble
slug_field = "uuid"
slug_url_kwarg = "uuid"
template_name = "scrobbles/scrobble_share_analytics.html"
def get_queryset(self):
@ -1721,6 +1717,7 @@ class ScrobbleCalendarView(LoginRequiredMixin, TemplateView):
for scrobble in day_map[day_num]:
day_scrobbles.append(
{
"id": scrobble.pk,
"uuid": scrobble.uuid,
"emoji": self.MEDIA_EMOJI.get(scrobble.media_type, "📌"),
"title": (

View File

View 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",)

View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class TrendsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "trends"

View 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))"
)
)

View 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")},
},
),
]

View File

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

View File

@ -0,0 +1,29 @@
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"),
]
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="last_30",
)
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})"

View 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)

View File

@ -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>

View File

@ -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 %}

View File

@ -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>

View File

@ -0,0 +1,78 @@
<div class="row">
<div class="col-md-6">
<h5 class="mb-3">By Hour of Day</h5>
{% if data.hours %}
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th>Hour</th>
<th class="text-end">Avg Quality</th>
<th class="text-end">Check-ins</th>
</tr>
</thead>
<tbody>
{% for entry in data.hours %}
{% if entry.count > 0 %}
<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">
<span class="{% if entry.avg_quality >= 5 %}text-success{% elif entry.avg_quality >= 4 %}text-info{% elif entry.avg_quality >= 3 %}text-warning{% else %}text-danger{% endif %}">
{{ entry.avg_quality }}
</span>
</td>
<td class="text-end">{{ entry.count }}</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted">No hourly data.</p>
{% endif %}
</div>
<div class="col-md-6">
<h5 class="mb-3">By Day of Week</h5>
{% if data.days %}
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th>Day</th>
<th class="text-end">Avg Quality</th>
<th class="text-end">Check-ins</th>
</tr>
</thead>
<tbody>
{% for entry in data.days %}
{% if entry.count > 0 %}
<tr>
<td>{{ entry.day_name }}</td>
<td class="text-end">
<span class="{% if entry.avg_quality >= 5 %}text-success{% elif entry.avg_quality >= 4 %}text-info{% elif entry.avg_quality >= 3 %}text-warning{% else %}text-danger{% endif %}">
{{ entry.avg_quality }}
</span>
</td>
<td class="text-end">{{ entry.count }}</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted">No daily data.</p>
{% endif %}
</div>
</div>

View File

@ -0,0 +1,45 @@
<div class="row">
<div class="col-12">
{% if data.moods %}
<p class="text-muted mb-3">
Total mood check-ins{% if current_period_label %} ({{ current_period_label }}){% endif %}: <strong>{{ data.total }}</strong>
&middot; Positive: <strong>{{ data.positive_count }}</strong>
&middot; Negative: <strong>{{ data.negative_count }}</strong>
</p>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th>Mood</th>
<th class="text-end">Count</th>
<th>Distribution</th>
</tr>
</thead>
<tbody>
{% with max=data.moods.0.count %}
{% for entry in data.moods %}
<tr>
<td>{{ entry.mood }}</td>
<td class="text-end">{{ entry.count }}</td>
<td style="width: 40%;">
{% if max > 0 %}
<div class="progress" style="height: 12px;">
<div class="progress-bar" role="progressbar"
style="width: {% widthratio entry.count max 100 %}%;"
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 mood distribution data found.</p>
{% endif %}
</div>
</div>

View File

@ -0,0 +1,47 @@
<div class="row">
<div class="col-12">
{% if data.current_streak %}
<div class="alert alert-info">
<strong>Current streak:</strong>
{{ data.current_streak.length }} consecutive
<span class="{% if data.current_streak.mood_type == 'positive' %}text-success{% else %}text-danger{% endif %}">
{{ data.current_streak.mood_type }}
</span>
check-ins since {{ data.current_streak.start_date }}.
</div>
{% endif %}
{% if data.streaks %}
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th>#</th>
<th>Mood Type</th>
<th class="text-end">Length</th>
<th>Start</th>
<th>End</th>
</tr>
</thead>
<tbody>
{% for streak in data.streaks %}
<tr>
<td>{{ forloop.counter }}</td>
<td>
<span class="{% if streak.mood_type == 'positive' %}text-success{% elif streak.mood_type == 'negative' %}text-danger{% endif %}">
{{ streak.mood_type|title }}
</span>
</td>
<td class="text-end">{{ streak.length }}</td>
<td>{{ streak.start_date }}</td>
<td>{{ streak.end_date }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted">No streak data found.</p>
{% endif %}
</div>
</div>

View File

@ -0,0 +1,39 @@
<div class="row">
<div class="col-12">
{% if data.trajectory %}
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th>Date</th>
<th class="text-end">Avg Quality</th>
<th class="text-end">Check-ins</th>
<th>Mood Bar</th>
</tr>
</thead>
<tbody>
{% for entry in data.trajectory %}
<tr>
<td>{{ entry.date }}</td>
<td class="text-end">{{ entry.avg_quality }}</td>
<td class="text-end">{{ entry.count }}</td>
<td style="width: 40%;">
<div class="progress" style="height: 16px;">
<div class="progress-bar {% if entry.avg_quality >= 5 %}bg-success{% elif entry.avg_quality >= 4 %}bg-info{% elif entry.avg_quality >= 3 %}bg-warning{% else %}bg-danger{% endif %}"
role="progressbar"
style="width: {% widthratio entry.avg_quality 7 100 %}%;"
aria-valuenow="{{ entry.avg_quality }}"
aria-valuemin="1" aria-valuemax="7">
</div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted">No mood check-in data found.</p>
{% endif %}
</div>
</div>

View File

@ -0,0 +1,64 @@
<div class="row">
<div class="col-md-6">
<h5 class="mb-3">By Weather Condition</h5>
{% if data.conditions %}
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th>Condition</th>
<th class="text-end">Avg Quality</th>
<th class="text-end">Check-ins</th>
</tr>
</thead>
<tbody>
{% for entry in data.conditions %}
<tr>
<td>{{ entry.condition }}</td>
<td class="text-end">
<span class="{% if entry.avg_quality >= 5 %}text-success{% elif entry.avg_quality >= 4 %}text-info{% elif entry.avg_quality >= 3 %}text-warning{% else %}text-danger{% endif %}">
{{ entry.avg_quality }}
</span>
</td>
<td class="text-end">{{ entry.count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted">No weather-linked mood data found.</p>
{% endif %}
</div>
<div class="col-md-6">
<h5 class="mb-3">By Temperature Range</h5>
{% if data.temp_ranges %}
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th>Temp Range</th>
<th class="text-end">Avg Quality</th>
<th class="text-end">Check-ins</th>
</tr>
</thead>
<tbody>
{% for entry in data.temp_ranges %}
<tr>
<td>{{ entry.range }}</td>
<td class="text-end">
<span class="{% if entry.avg_quality >= 5 %}text-success{% elif entry.avg_quality >= 4 %}text-info{% elif entry.avg_quality >= 3 %}text-warning{% else %}text-danger{% endif %}">
{{ entry.avg_quality }}
</span>
</td>
<td class="text-end">{{ entry.count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted">No temperature-linked mood data found.</p>
{% endif %}
</div>
</div>

View File

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

View File

@ -0,0 +1,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>

View 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>

View File

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

View File

@ -0,0 +1,86 @@
{% 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">&larr; 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">&laquo; Prev</a>
{% endif %}
{% if next_period %}
<a href="?period={{ next_period }}" class="btn btn-outline-secondary">Next &raquo;</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" %}
{% elif trend.slug == "mood-trajectory" %}
{% include "trends/_mood_trajectory.html" %}
{% elif trend.slug == "mood-by-time" %}
{% include "trends/_mood_by_time.html" %}
{% elif trend.slug == "mood-distribution" %}
{% include "trends/_mood_distribution.html" %}
{% elif trend.slug == "mood-streaks" %}
{% include "trends/_mood_streaks.html" %}
{% elif trend.slug == "mood-weather" %}
{% include "trends/_mood_weather.html" %}
{% endif %}
{% endblock %}

View 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 %}

View File

@ -0,0 +1,48 @@
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.mood import (
compute_mood_by_time,
compute_mood_distribution,
compute_mood_streaks,
compute_mood_trajectory,
compute_mood_weather,
)
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_mood_by_time = register("mood-by-time")(compute_mood_by_time)
compute_mood_distribution = register("mood-distribution")(compute_mood_distribution)
compute_mood_streaks = register("mood-streaks")(compute_mood_streaks)
compute_mood_trajectory = register("mood-trajectory")(compute_mood_trajectory)
compute_mood_weather = register("mood-weather")(compute_mood_weather)
compute_peak_hours = register("peak-hours")(compute_peak_hours)
compute_reading_pace_vs_activity = register("reading-pace-vs-activity")(
compute_reading_pace_vs_activity
)
compute_trending_up = register("trending-up")(compute_trending_up)
compute_weekly_rhythm = register("weekly-rhythm")(compute_weekly_rhythm)

View File

@ -0,0 +1,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),
}

View 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],
}

View File

@ -0,0 +1,208 @@
from collections import Counter, defaultdict
from django.db.models import Q
from scrobbles.models import Scrobble
def _mood_scrobbles(user, period="last_30"):
from trends.utils import get_date_range
start, end = get_date_range(period)
filters = Q(user=user, media_type=Scrobble.MediaType.MOOD)
if start:
filters &= Q(timestamp__gte=start)
if end:
filters &= Q(timestamp__lte=end)
return Scrobble.objects.filter(filters).select_related("mood")
def _parse_quality(raw):
try:
return int(raw)
except (TypeError, ValueError):
return None
def _avg_quality(values):
nums = [v for v in values if v is not None]
if not nums:
return 0.0
return round(sum(nums) / len(nums), 2)
def compute_mood_trajectory(user, period="last_30"):
scrobbles = _mood_scrobbles(user, period).order_by("timestamp")
by_date = defaultdict(list)
for s in scrobbles:
quality = _parse_quality(s.log.get("mood_quality"))
if quality is not None:
day_key = s.timestamp.strftime("%Y-%m-%d")
by_date[day_key].append(quality)
trajectory = []
for date_key in sorted(by_date):
values = by_date[date_key]
trajectory.append(
{
"date": date_key,
"avg_quality": _avg_quality(values),
"count": len(values),
}
)
return {"trajectory": trajectory}
def compute_mood_by_time(user, period="last_30"):
scrobbles = _mood_scrobbles(user, period)
by_hour = defaultdict(list)
by_day = defaultdict(list)
for s in scrobbles:
quality = _parse_quality(s.log.get("mood_quality"))
if quality is not None and s.timestamp:
by_hour[s.timestamp.hour].append(quality)
by_day[s.timestamp.isoweekday()].append(quality)
hours = []
for h in range(24):
vals = by_hour.get(h, [])
hours.append(
{
"hour": h,
"avg_quality": _avg_quality(vals),
"count": len(vals),
}
)
DAY_NAMES = {
1: "Monday",
2: "Tuesday",
3: "Wednesday",
4: "Thursday",
5: "Friday",
6: "Saturday",
7: "Sunday",
}
days = []
for d in range(1, 8):
vals = by_day.get(d, [])
days.append(
{
"day_index": d,
"day_name": DAY_NAMES[d],
"avg_quality": _avg_quality(vals),
"count": len(vals),
}
)
return {"hours": hours, "days": days}
def compute_mood_distribution(user, period="last_30"):
scrobbles = _mood_scrobbles(user, period)
mood_counts = Counter()
type_counts = Counter()
for s in scrobbles:
if s.mood and s.mood.title:
mood_counts[s.mood.title] += 1
mood_type = s.log.get("mood_type")
if mood_type:
type_counts[mood_type] += 1
moods = [
{"mood": mood, "count": count}
for mood, count in mood_counts.most_common()
]
total = sum(mood_counts.values())
return {
"moods": moods,
"total": total,
"positive_count": type_counts.get("positive", 0),
"negative_count": type_counts.get("negative", 0),
}
def compute_mood_streaks(user, period="last_30"):
scrobbles = list(
_mood_scrobbles(user, period).order_by("timestamp")
)
if not scrobbles:
return {"streaks": [], "current_streak": None}
streaks = []
current_start = scrobbles[0].timestamp.date()
current_type = scrobbles[0].log.get("mood_type") or "unknown"
current_length = 1
for s in scrobbles[1:]:
mood_type = s.log.get("mood_type") or "unknown"
if mood_type == current_type:
current_length += 1
else:
streaks.append(
{
"start_date": current_start.isoformat(),
"end_date": scrobbles[scrobbles.index(s) - 1].timestamp.date().isoformat(),
"mood_type": current_type,
"length": current_length,
}
)
current_start = s.timestamp.date()
current_type = mood_type
current_length = 1
streaks.append(
{
"start_date": current_start.isoformat(),
"end_date": scrobbles[-1].timestamp.date().isoformat(),
"mood_type": current_type,
"length": current_length,
}
)
streaks.sort(key=lambda x: x["length"], reverse=True)
current_streak = {
"mood_type": current_type,
"length": current_length,
"start_date": current_start.isoformat(),
}
return {"streaks": streaks[:10], "current_streak": current_streak}
def compute_mood_weather(user, period="last_30"):
scrobbles = _mood_scrobbles(user, period)
by_condition = defaultdict(list)
by_temp_range = defaultdict(list)
for s in scrobbles:
quality = _parse_quality(s.log.get("mood_quality"))
if quality is None:
continue
desc = s.log.get("weather_description")
temp = s.log.get("weather_temp")
if desc:
by_condition[desc].append(quality)
if temp is not None:
try:
temp_f = float(temp)
except (TypeError, ValueError):
continue
bucket = f"{(int(temp_f) // 10) * 10}-{(int(temp_f) // 10) * 10 + 9}F"
by_temp_range[bucket].append(quality)
conditions = [
{"condition": cond, "avg_quality": _avg_quality(vals), "count": len(vals)}
for cond, vals in sorted(by_condition.items(), key=lambda x: len(x[1]), reverse=True)
]
temp_ranges = [
{"range": rng, "avg_quality": _avg_quality(vals), "count": len(vals)}
for rng, vals in sorted(by_temp_range.items())
]
return {"conditions": conditions, "temp_ranges": temp_ranges}

View 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),
}

View 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

View 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"),
]

View File

@ -0,0 +1,84 @@
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,
}
PERIOD_LABELS = dict(PERIOD_CHOICES)
TIME_BOUND_TRENDS = {
"activity-distribution",
"concurrent-reading",
"concurrent-listening",
"mood-by-time",
"mood-distribution",
"mood-streaks",
"mood-trajectory",
"mood-weather",
"peak-hours",
"reading-pace-vs-activity",
"trending-up",
"weekly-rhythm",
}
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}
return dict(PERIOD_LABELS)
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="last_30"):
"""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()

View File

@ -0,0 +1,146 @@
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": "📖",
},
"mood-trajectory": {
"title": "Mood Trajectory",
"description": "How your mood quality has changed over time.",
"icon": "📈",
},
"mood-by-time": {
"title": "Mood by Time",
"description": "How your mood varies by hour of day and day of week.",
"icon": "🕐",
},
"mood-distribution": {
"title": "Mood Distribution",
"description": "Which moods you feel most often.",
"icon": "🎭",
},
"mood-streaks": {
"title": "Mood Streaks",
"description": "Your longest runs of positive and negative moods.",
"icon": "🔥",
},
"mood-weather": {
"title": "Mood & Weather",
"description": "How weather conditions correlate with your mood.",
"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", "last_30")
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

View File

@ -10,26 +10,27 @@ def hrs_to_secs(hrs: float) -> int:
return int(hrs * 60 * 60)
def lookup_game_from_hltb(name_or_id: str) -> Optional[dict]:
def lookup_game_from_hltb(name_or_id: str, search_by_title: bool = False) -> Optional[dict]:
"""Lookup game on HowLongToBeat.com via HLtB ID or a name string and return
the data in a dictonary mapped to our internal game fields
"""
hltb_game = {}
try:
hltb_id = int(name_or_id)
except ValueError:
hltb_id = None
if not search_by_title:
try:
hltb_id = int(name_or_id)
except ValueError:
hltb_id = None
if hltb_id:
hltb_game = HowLongToBeat().search_from_id(hltb_id)
logger.info(f"Found game on HLtB for ID {hltb_id}")
if hltb_id:
hltb_game = HowLongToBeat().search_from_id(hltb_id)
logger.info(f"Found game on HLtB for ID {hltb_id}")
if not hltb_game:
results = HowLongToBeat().search(name_or_id)
if not results:
logger.warn(f"Lookup of game on HLtB failed for ID {name_or_id}")
logger.warn(f"Lookup of game on HLtB failed via search {name_or_id!r}")
return
hltb_game = results[0]

View File

@ -19,6 +19,7 @@ GAMES_URL = "https://api.igdb.com/v4/games"
ALT_NAMES_URL = "https://api.igdb.com/v4/alternative_names"
SCREENSHOT_URL = "https://api.igdb.com/v4/screenshots"
COVER_URL = "https://api.igdb.com/v4/covers"
PLATFORMS_URL = "https://api.igdb.com/v4/platforms"
IGDB_CLIENT_ID = getattr(settings, "IGDB_CLIENT_ID")
IGDB_CLIENT_SECRET = getattr(settings, "IGDB_CLIENT_SECRET")
@ -35,6 +36,20 @@ def get_igdb_token() -> str:
return results.get("access_token")
def lookup_platform_names(platform_ids: list, headers: dict) -> list:
"""Resolve IGDB platform IDs to platform names"""
if not platform_ids:
return []
ids_str = ",".join(str(pid) for pid in platform_ids)
body = f"fields name; where id = ({ids_str});"
resp = requests.post(PLATFORMS_URL, data=body, headers=headers)
if resp.status_code != 200:
logger.warn(f"Failed to resolve platform IDs {platform_ids}")
return []
results = json.loads(resp.content)
return [p["name"] for p in results if "name" in p]
def lookup_game_id_from_gdb(name: str) -> str:
headers = {
@ -62,9 +77,10 @@ def lookup_game_id_from_gdb(name: str) -> str:
"details": results.get("details"),
},
)
# Sort our result by IDs so we always get the lowest ID, which is likely to be the least esoteric game
results = sorted(results, key=lambda k: k.get("game", 250000))
return results[0].get("game", "")
# Sort results by release date (oldest first) to prefer the original game
results = [r for r in results if r.get("game")]
results = sorted(results, key=lambda k: k.get("published_at") or 9999999999)
return results[0].get("game", "") if results else ""
def lookup_game_from_igdb(name_or_igdb_id: str) -> Dict:
@ -118,6 +134,16 @@ def lookup_game_from_igdb(name_or_igdb_id: str) -> Dict:
for genre in game.get("genres"):
genres.append(genre["name"])
platforms = []
if "release_dates" in game.keys():
platform_ids = set()
for rd in game["release_dates"]:
pid = rd.get("platform")
if pid is not None:
platform_ids.add(pid)
if platform_ids:
platforms = lookup_platform_names(list(platform_ids), headers)
game_dict = {
"igdb_id": game.get("id"),
"title": game.get("name"),
@ -129,6 +155,7 @@ def lookup_game_from_igdb(name_or_igdb_id: str) -> Dict:
"release_date": release_date,
"summary": game.get("summary"),
"genres": genres,
"platforms": platforms,
}
return game_dict

View File

@ -0,0 +1,529 @@
import logging
import time
from django.core.management.base import BaseCommand
logger = logging.getLogger(__name__)
MISSING_ALL = [
"cover",
"screenshot",
"summary",
"rating",
"release_date",
"release_year",
"igdb_id",
"hltb_id",
]
MISSING_GROUPS = {
"cover": lambda g: not bool(g.cover),
"screenshot": lambda g: not bool(g.screenshot),
"summary": lambda g: not g.summary,
"rating": lambda g: g.rating is None,
"release_date": lambda g: g.release_date is None,
"release_year": lambda g: g.release_year is None,
"igdb_id": lambda g: g.igdb_id is None,
"hltb_id": lambda g: g.hltb_id is None,
}
def _game_matches(game, flags):
if not flags:
return False
for flag in flags:
fn = MISSING_GROUPS.get(flag)
if fn and fn(game):
return True
return False
class Command(BaseCommand):
help = "Backfill missing metadata on video games from IGDB and HowLongToBeat"
def add_arguments(self, parser):
parser.add_argument(
"--commit",
action="store_true",
help="Commit changes to the database",
)
parser.add_argument(
"--batch-size",
type=int,
default=100,
help="Number of games to process per batch (default: 100)",
)
parser.add_argument(
"--sleep",
type=float,
default=0.5,
help="Seconds to sleep between API calls (default: 0.5)",
)
parser.add_argument(
"--force",
action="store_true",
help="Re-fetch metadata even if data already exists",
)
parser.add_argument(
"--game-id",
type=int,
help="Only process a specific game by ID",
)
parser.add_argument(
"--fix-broken-images",
action="store_true",
help="Check and refetch broken/deleted game images (cover, screenshot, hltb_cover)",
)
for flag in MISSING_ALL:
parser.add_argument(
f"--missing-{flag}",
dest="missing_flags",
action="append_const",
const=flag,
help=f"Process games missing {flag}",
)
parser.add_argument(
"--all",
action="store_true",
dest="all_missing",
help="Process games missing any metadata field",
)
def handle(self, *args, **options):
from videogames.models import VideoGame
commit = options["commit"]
batch_size = options["batch_size"]
sleep_secs = options["sleep"]
force = options["force"]
game_id = options["game_id"]
fix_broken_images = options.get("fix_broken_images", False)
flags = options.get("missing_flags") or []
all_missing = options["all_missing"]
if all_missing:
flags = MISSING_ALL
if not flags and not game_id and not force and not fix_broken_images:
self.stdout.write(
"No filters specified. Use --all, --missing-*, --game-id, --force, or --fix-broken-images."
)
return
if game_id:
qs = VideoGame.objects.filter(id=game_id)
else:
qs = VideoGame.objects.all()
if flags:
qs = [g for g in qs.iterator() if _game_matches(g, flags)]
else:
qs = list(qs)
total = len(qs)
self.stdout.write(f"Found {total} games to process")
if not commit:
self.stdout.write(
"Dry run — no API calls will be made. Use --commit to run lookups."
)
return
title_mismatches = []
enriched = 0
skipped = 0
stats = {
"cover_fixed": 0,
"screenshot_fixed": 0,
"summary_fixed": 0,
"rating_fixed": 0,
"release_date_fixed": 0,
"release_year_fixed": 0,
"igdb_id_found": 0,
"hltb_id_found": 0,
"images_fixed": 0,
}
enriched_any = bool(flags or game_id or force)
if enriched_any:
for batch_num, offset in enumerate(range(0, len(qs), batch_size)):
batch = qs[offset : offset + batch_size]
for game in batch:
result = self._enrich_game(game, sleep_secs, force)
self._check_retroarch_name(game, title_mismatches)
if result:
enriched += 1
for key in stats:
if result.get(key):
stats[key] += 1
else:
skipped += 1
self.stdout.write(
f" Batch {batch_num + 1}: {offset + len(batch)}/{total}"
f"enriched: {enriched}, skipped: {skipped}"
)
if fix_broken_images:
broken_stats = self._fix_broken_images(qs, sleep_secs)
stats["images_fixed"] = broken_stats["images_fixed"]
self.stdout.write(
f"\nResults (commit={commit}):\n"
f" Games enriched: {enriched}\n"
f" Games skipped: {skipped}\n"
f" Covers fixed: {stats['cover_fixed']}\n"
f" Screenshots fixed: {stats['screenshot_fixed']}\n"
f" Summaries fixed: {stats['summary_fixed']}\n"
f" Ratings fixed: {stats['rating_fixed']}\n"
f" Release dates fixed: {stats['release_date_fixed']}\n"
f" Release years fixed: {stats['release_year_fixed']}\n"
f" IGDB IDs found: {stats['igdb_id_found']}\n"
f" HLtB IDs found: {stats['hltb_id_found']}"
)
if fix_broken_images:
self.stdout.write(f" Broken images fixed: {stats['images_fixed']}")
if title_mismatches:
self.stdout.write("\nTitle vs retroarch_name mismatches (not auto-fixed):")
for retroarch_name, title, game_id in title_mismatches:
self.stdout.write(
f" Game #{game_id}: retroarch_name={retroarch_name!r} vs title={title!r}"
)
def _clean_retroarch_name(self, name):
if not name:
return ""
name = name.strip()
if "(" in name:
name = name.split("(")[0].strip()
return name
def _check_retroarch_name(self, game, mismatches):
if not game.retroarch_name:
return
cleaned = self._clean_retroarch_name(game.retroarch_name)
if cleaned.lower() != game.title.lower():
mismatches.append((game.retroarch_name, game.title, game.id))
if "retroarch-mismatch" not in game.tags.names():
game.tags.add("retroarch-mismatch")
self.stdout.write(
f" [TAG] {game} — tagged as retroarch-mismatch"
)
def _enrich_game(self, game, sleep_secs, force):
from videogames.igdb import lookup_game_id_from_gdb, lookup_game_from_igdb
from videogames.howlongtobeat import lookup_game_from_hltb
search_name = self._clean_retroarch_name(game.retroarch_name) or game.title
changed = {}
if not game.hltb_id:
hltb_data = None
if search_name:
hltb_data = lookup_game_from_hltb(search_name, search_by_title=True)
time.sleep(sleep_secs)
if not hltb_data and game.title and game.title != search_name:
hltb_data = lookup_game_from_hltb(game.title, search_by_title=True)
time.sleep(sleep_secs)
if hltb_data:
result = self._apply_hltb_data(game, hltb_data, force)
if result:
changed.update(result)
igdb_data = None
if not game.igdb_id and search_name:
igdb_id = lookup_game_id_from_gdb(search_name)
time.sleep(sleep_secs)
if igdb_id:
igdb_data = lookup_game_from_igdb(str(igdb_id))
time.sleep(sleep_secs)
elif game.igdb_id:
igdb_data = lookup_game_from_igdb(str(game.igdb_id))
time.sleep(sleep_secs)
if igdb_data:
igdb_title = igdb_data.get("title", "")
igdb_title_clean = self._clean_retroarch_name(igdb_title)
if igdb_title_clean.lower() == search_name.lower():
if game.igdb_id is None and igdb_data.get("igdb_id"):
game.igdb_id = int(igdb_data["igdb_id"])
game.save(update_fields=["igdb_id"])
changed["igdb_id_found"] = True
self.stdout.write(f" [IGDB_ID] {game} — found IGDB ID {game.igdb_id}")
result = self._apply_igdb_data(game, igdb_data, force)
if result:
changed.update(result)
else:
self.stdout.write(
f" [IGDB] {game} — title mismatch (IGDB: {igdb_title!r} vs expected: {search_name!r}), re-searching…"
)
resolved = False
for candidate in (search_name, game.title if game.title != search_name else None):
if not candidate:
continue
new_id = lookup_game_id_from_gdb(candidate)
time.sleep(sleep_secs)
if not new_id:
continue
new_data = lookup_game_from_igdb(str(new_id))
time.sleep(sleep_secs)
if not new_data:
continue
new_title = new_data.get("title", "")
new_title_clean = self._clean_retroarch_name(new_title)
if new_title_clean.lower() == candidate.lower():
game.igdb_id = int(new_id)
if new_title and new_title != game.title:
game.title = new_title
changed["title_updated"] = True
self.stdout.write(f" [TITLE] {game} — updated title to {new_title!r} from IGDB")
game.save(update_fields=["igdb_id"] + (["title"] if changed.get("title_updated") else []))
changed["igdb_id_found"] = True
self.stdout.write(f" [IGDB_ID] {game} — re-found IGDB ID {game.igdb_id}")
if "igdb-mismatch" in game.tags.names():
game.tags.remove("igdb-mismatch")
self.stdout.write(f" [TAG] {game} — removed igdb-mismatch tag")
result = self._apply_igdb_data(game, new_data, force)
if result:
changed.update(result)
resolved = True
break
if not resolved and "igdb-mismatch" not in game.tags.names():
game.tags.add("igdb-mismatch")
self.stdout.write(f" [TAG] {game} — tagged igdb-mismatch")
# If retroarch-mismatch tag exists but no longer applies, remove it
if "retroarch-mismatch" in game.tags.names():
cleaned = self._clean_retroarch_name(game.retroarch_name or "")
if cleaned.lower() == game.title.lower():
game.tags.remove("retroarch-mismatch")
self.stdout.write(f" [TAG] {game} — removed retroarch-mismatch tag (title now matches)")
return changed if changed else None
def _apply_igdb_data(self, game, data, force):
from django.core.files.base import ContentFile
import requests
changed = {
"cover_fixed": False,
"screenshot_fixed": False,
"summary_fixed": False,
"rating_fixed": False,
"release_date_fixed": False,
}
update_fields = []
if data.get("alternative_name") and not game.alternative_name:
game.alternative_name = data["alternative_name"]
update_fields.append("alternative_name")
if data.get("summary") and (not game.summary or force):
game.summary = data["summary"]
update_fields.append("summary")
changed["summary_fixed"] = True
if data.get("rating") is not None and (game.rating is None or force):
game.rating = data["rating"]
update_fields.append("rating")
changed["rating_fixed"] = True
if data.get("rating_count") is not None and (game.rating_count is None or force):
game.rating_count = data["rating_count"]
update_fields.append("rating_count")
if data.get("release_date") and (game.release_date is None or force):
game.release_date = data["release_date"]
update_fields.append("release_date")
changed["release_date_fixed"] = True
if update_fields:
game.save(update_fields=update_fields)
self.stdout.write(f" [IGDB] {game}{', '.join(update_fields)}")
cover_url = data.get("cover_url")
if cover_url:
r = requests.get(cover_url)
if r.status_code == 200:
fname = f"{game.title}_{game.uuid}.jpg"
game.cover.save(fname, ContentFile(r.content), save=True)
changed["cover_fixed"] = True
self.stdout.write(f" [COVER] {game} — cover saved from IGDB")
screenshot_url = data.get("screenshot_url")
if screenshot_url:
r = requests.get(screenshot_url)
if r.status_code == 200:
fname = f"{game.title}_{game.uuid}.jpg"
game.screenshot.save(fname, ContentFile(r.content), save=True)
changed["screenshot_fixed"] = True
self.stdout.write(f" [SCREENSHOT] {game} — screenshot saved from IGDB")
genres = data.get("genres", [])
if genres:
existing = set(game.genre.names())
new_genres = [g for g in genres if g not in existing]
if new_genres:
game.genre.add(*new_genres)
self.stdout.write(f" [GENRES] {game} — added {len(new_genres)} genres")
platforms = data.get("platforms", [])
if platforms:
existing = set(game.platforms.values_list("name", flat=True))
new_platforms = [p for p in platforms if p not in existing]
if new_platforms:
from videogames.models import VideoGamePlatform
for name in new_platforms:
p, _ = VideoGamePlatform.objects.get_or_create(name=name)
game.platforms.add(p)
self.stdout.write(f" [PLATFORMS] {game} — added {len(new_platforms)} platforms")
if "igdb-enriched" not in game.tags.names():
game.tags.add("igdb-enriched")
self.stdout.write(f" [TAG] {game} — tagged igdb-enriched")
return changed if any(changed.values()) else None
def _apply_hltb_data(self, game, data, force):
from django.core.files.base import ContentFile
import requests
changed = {
"hltb_id_found": False,
"release_year_fixed": False,
}
update_fields = []
hltb_title = data.get("title", "")
if hltb_title and hltb_title != game.title:
game.title = hltb_title
update_fields.append("title")
self.stdout.write(f" [TITLE] {game} — updated title to {hltb_title!r}")
if data.get("hltb_id") and (game.hltb_id is None or force):
game.hltb_id = data["hltb_id"]
update_fields.append("hltb_id")
changed["hltb_id_found"] = True
self.stdout.write(f" [HLTB_ID] {game} — found HLtB ID {data['hltb_id']}")
if data.get("release_year") and (game.release_year is None or force):
game.release_year = data["release_year"]
update_fields.append("release_year")
changed["release_year_fixed"] = True
if data.get("main_story_time") and (game.main_story_time is None or force):
game.main_story_time = data["main_story_time"]
update_fields.append("main_story_time")
if data.get("main_extra_time") and (game.main_extra_time is None or force):
game.main_extra_time = data["main_extra_time"]
update_fields.append("main_extra_time")
if data.get("completionist_time") and (game.completionist_time is None or force):
game.completionist_time = data["completionist_time"]
update_fields.append("completionist_time")
if data.get("hltb_score") is not None and (game.hltb_score is None or force):
game.hltb_score = data["hltb_score"]
update_fields.append("hltb_score")
if update_fields:
game.save(update_fields=update_fields)
self.stdout.write(f" [HLTB] {game}{', '.join(update_fields)}")
cover_url = data.get("cover_url")
if cover_url:
headers = {"User-Agent": "Vrobbler 0.11.12"}
r = requests.get(cover_url, headers=headers)
if r.status_code == 200:
fname = f"{game.title}_cover_{game.uuid}.jpg"
game.hltb_cover.save(fname, ContentFile(r.content), save=True)
self.stdout.write(f" [HLTB_COVER] {game} — cover saved from HLtB")
platforms = data.get("platforms", [])
if platforms:
existing = set(game.platforms.values_list("name", flat=True))
new_platforms = [p for p in platforms if p not in existing]
if new_platforms:
from videogames.models import VideoGamePlatform
for name in new_platforms:
p, _ = VideoGamePlatform.objects.get_or_create(name=name)
game.platforms.add(p)
self.stdout.write(f" [PLATFORMS] {game} — added {len(new_platforms)} platforms")
if "hltb-enriched" not in game.tags.names():
game.tags.add("hltb-enriched")
self.stdout.write(f" [TAG] {game} — tagged hltb-enriched")
return changed if any(changed.values()) else None
def _fix_broken_images(self, games, sleep_secs):
from django.core.files.base import ContentFile
import requests
from videogames.igdb import lookup_game_from_igdb
from videogames.howlongtobeat import lookup_game_from_hltb
stats = {"cover_fixed": 0, "screenshot_fixed": 0, "images_fixed": 0}
for game in games:
for field_name, source in [
("cover", "igdb"),
("screenshot", "igdb"),
("hltb_cover", "hltb"),
]:
field = getattr(game, field_name)
if not field.name:
continue
if field.storage.exists(field.name):
continue
self.stdout.write(
f" [IMAGE] {game}{field_name} is broken (file missing), refetching…"
)
if source == "igdb" and game.igdb_id:
data = lookup_game_from_igdb(str(game.igdb_id))
time.sleep(sleep_secs)
if not data:
continue
url = data.get("cover_url" if field_name == "cover" else "screenshot_url")
if not url:
continue
r = requests.get(url)
if r.status_code != 200:
continue
fname = f"{game.title}_{game.uuid}.jpg"
getattr(game, field_name).save(fname, ContentFile(r.content), save=True)
stats["images_fixed"] += 1
self.stdout.write(f" [IMAGE] {game}{field_name} refetched from IGDB")
elif source == "hltb" and game.hltb_id:
data = lookup_game_from_hltb(str(game.hltb_id))
time.sleep(sleep_secs)
if not data:
continue
url = data.get("cover_url")
if not url:
continue
headers = {"User-Agent": "Vrobbler 0.11.12"}
r = requests.get(url, headers=headers)
if r.status_code != 200:
continue
fname = f"{game.title}_cover_{game.uuid}.jpg"
game.hltb_cover.save(fname, ContentFile(r.content), save=True)
stats["images_fixed"] += 1
self.stdout.write(f" [IMAGE] {game} — hltb_cover refetched from HLtB")
return stats

View File

@ -215,12 +215,16 @@ class VideoGame(LongPlayScrobblableMixin):
def fix_metadata(self, force_update: bool = False):
from videogames.utils import (
get_or_create_videogame,
load_game_data_from_hltb,
load_game_data_from_igdb,
)
if self.hltb_id and force_update:
get_or_create_videogame(str(self.hltb_id), force_update)
if not self.hltb_id:
load_game_data_from_hltb(self.id)
if not self.igdb_id:
# This almost never works without intervention
self.igdb_id = lookup_game_id_from_gdb(self.title)

View File

@ -153,6 +153,13 @@ def import_retroarch_lrtl_files(playlog_path: str, user_id: int) -> List[dict]:
continue
logger.info(f"Queued scrobble for game {found_game.id}")
log_data = {"emulated": True}
if last_scrobble and last_scrobble.log:
prev = last_scrobble.log
if prev.get("emulator"):
log_data["emulator"] = prev["emulator"]
new_scrobbles.append(
Scrobble(
video_game_id=found_game.id,
@ -168,6 +175,7 @@ def import_retroarch_lrtl_files(playlog_path: str, user_id: int) -> List[dict]:
user_id=user_id,
source="Retroarch",
media_type=Scrobble.MediaType.VIDEO_GAME,
log=log_data,
)
)
created_scrobbles = Scrobble.objects.bulk_create(new_scrobbles)

View File

@ -7,8 +7,6 @@ from videogames.howlongtobeat import lookup_game_from_hltb
from videogames.igdb import lookup_game_from_igdb
from videogames.models import VideoGame, VideoGamePlatform
from vrobbler.apps.videogames.exceptions import GameNotFound
logger = logging.getLogger(__name__)
@ -16,22 +14,33 @@ def get_or_create_videogame(
name_or_id: str,
force_update: bool = False,
) -> Optional[VideoGame]:
"""Look up game by name or ID from HowLongToBeat"""
"""Look up game by name or ID from HowLongToBeat, then enrich with IGDB"""
game_dict = lookup_game_from_hltb(name_or_id)
hltb_data = lookup_game_from_hltb(name_or_id)
if not game_dict:
game_dict = lookup_game_from_igdb(name_or_id)
if hltb_data:
game = _create_update_from_dict(hltb_data, force_update)
else:
igdb_data = lookup_game_from_igdb(name_or_id)
if igdb_data:
game = _create_update_from_dict(igdb_data, force_update)
else:
return None
if not game_dict:
return
if game:
game.fix_metadata()
return game
def _create_update_from_dict(
game_dict: dict, force_update: bool = False
) -> Optional[VideoGame]:
# Create missing platforms and prep for loading after create
platform_ids = []
if "platforms" in game_dict.keys():
platforms = game_dict.get("platforms", [])
if platforms:
for platform in game_dict.get("platforms", []):
for platform in platforms:
p, _created = VideoGamePlatform.objects.get_or_create(name=platform)
platform_ids.append(p.id)
game_dict.pop("platforms")
@ -48,7 +57,7 @@ def get_or_create_videogame(
title = game_dict.get("title")
if not title:
raise GameNotFound(name_or_id)
return None
hltb_id = game_dict.get("hltb_id")
igdb_id = game_dict.get("igdb_id")
@ -69,21 +78,19 @@ def get_or_create_videogame(
VideoGame.objects.filter(pk=game.id).update(**game_dict)
game.refresh_from_db()
# Associate plaforms
if platform_ids:
game.platforms.add(*platform_ids)
if genres:
game.genre.add(*genres)
if not game.screenshot and screenshot_url:
if screenshot_url:
r = requests.get(screenshot_url)
if r.status_code == 200:
fname = f"{game.title}_{game.uuid}.jpg"
game.screenshot.save(fname, ContentFile(r.content), save=True)
# Go get cover image if the URL is present
if cover_url and not game.hltb_cover:
if cover_url:
headers = {"User-Agent": "Vrobbler 0.11.12"}
r = requests.get(cover_url, headers=headers)
logger.debug(r.status_code)
@ -91,12 +98,89 @@ def get_or_create_videogame(
fname = f"{game.title}_cover_{game.uuid}.jpg"
game.hltb_cover.save(fname, ContentFile(r.content), save=True)
logger.debug("Loaded cover image from HLtB")
game.fix_metadata()
tag = "hltb-enriched" if hltb_id else "igdb-enriched"
if tag not in game.tags.names():
game.tags.add(tag)
logger.info(f"Game {game} tagged {tag}")
return game
def load_game_data_from_igdb(game_id: int, igdb_id: str = "") -> Optional[VideoGame]:
def load_game_data_from_hltb(
game_id: int, expected_title: str = ""
) -> Optional[VideoGame]:
"""Look up HLtB data for an existing game and apply it"""
game = VideoGame.objects.filter(id=game_id).first()
if not game:
logger.warn(f"Video game with ID {game_id} not found")
return
logger.info(f"Looking up HLtB data for {game}")
hltb_data = lookup_game_from_hltb(game.title, search_by_title=True)
if not hltb_data:
logger.warn(f"No HLtB data found for {game}")
return
update_fields = []
hltb_title = hltb_data.get("title", "")
if hltb_title and hltb_title != game.title:
game.title = hltb_title
update_fields.append("title")
logger.info(f"Game {game.id} title updated to {hltb_title!r}")
if hltb_data.get("hltb_id") and (game.hltb_id is None):
game.hltb_id = hltb_data["hltb_id"]
update_fields.append("hltb_id")
if hltb_data.get("release_year") and (game.release_year is None):
game.release_year = hltb_data["release_year"]
update_fields.append("release_year")
if hltb_data.get("main_story_time") and (game.main_story_time is None):
game.main_story_time = hltb_data["main_story_time"]
update_fields.append("main_story_time")
if hltb_data.get("main_extra_time") and (game.main_extra_time is None):
game.main_extra_time = hltb_data["main_extra_time"]
update_fields.append("main_extra_time")
if hltb_data.get("completionist_time") and (game.completionist_time is None):
game.completionist_time = hltb_data["completionist_time"]
update_fields.append("completionist_time")
if hltb_data.get("hltb_score") is not None and (game.hltb_score is None):
game.hltb_score = hltb_data["hltb_score"]
update_fields.append("hltb_score")
if update_fields:
game.save(update_fields=update_fields)
platforms = hltb_data.get("platforms", [])
if platforms:
for name in platforms:
p, _ = VideoGamePlatform.objects.get_or_create(name=name)
game.platforms.add(p)
cover_url = hltb_data.get("cover_url")
if cover_url:
headers = {"User-Agent": "Vrobbler 0.11.12"}
r = requests.get(cover_url, headers=headers)
if r.status_code == 200:
fname = f"{game.title}_cover_{game.uuid}.jpg"
game.hltb_cover.save(fname, ContentFile(r.content), save=True)
if "hltb-enriched" not in game.tags.names():
game.tags.add("hltb-enriched")
logger.info(f"Game {game} tagged hltb-enriched")
return game
def load_game_data_from_igdb(
game_id: int, igdb_id: str = "", expected_title: str = ""
) -> Optional[VideoGame]:
"""Look up game, if it doesn't exist, lookup data from igdb"""
game = VideoGame.objects.filter(id=game_id).first()
if not game:
@ -116,25 +200,68 @@ def load_game_data_from_igdb(game_id: int, igdb_id: str = "") -> Optional[VideoG
logger.warn(f"No game data found on IGDB for ID {igdb_id}")
return
igdb_title = game_dict.get("title", "")
igdb_title_clean = igdb_title.split(" (")[0].strip() if " (" in igdb_title else igdb_title
expected = expected_title or game.title
if igdb_title_clean.lower() != expected.lower():
logger.info(
f"IGDB title {igdb_title!r} doesn't match expected {expected!r} for {game} — re-searching…"
)
from videogames.igdb import lookup_game_id_from_gdb
new_id = lookup_game_id_from_gdb(expected)
if new_id:
new_data = lookup_game_from_igdb(str(new_id))
if new_data:
new_title = new_data.get("title", "")
new_title_clean = new_title.split(" (")[0].strip() if " (" in new_title else new_title
if new_title_clean.lower() == expected.lower():
game_dict = new_data
igdb_id = int(new_id)
if game.igdb_id != igdb_id:
game.igdb_id = igdb_id
game.save(update_fields=["igdb_id"])
logger.info(f"Game {game} IGDB ID updated to {igdb_id}")
igdb_title = game_dict.get("title", "")
igdb_title_clean = igdb_title.split(" (")[0].strip() if " (" in igdb_title else igdb_title
if igdb_title_clean.lower() != expected.lower():
if "igdb-mismatch" not in game.tags.names():
game.tags.add("igdb-mismatch")
logger.info(
f"Game {game} tagged igdb-mismatch (IGDB: {igdb_title!r} vs expected: {expected!r})"
)
return
screenshot_url = game_dict.pop("screenshot_url")
cover_url = game_dict.pop("cover_url")
genres = game_dict.pop("genres")
platforms = game_dict.pop("platforms", [])
VideoGame.objects.filter(pk=game.id).update(**game_dict)
game.refresh_from_db()
game.genre.add(*genres)
if not game.screenshot and screenshot_url:
if platforms:
for name in platforms:
p, _ = VideoGamePlatform.objects.get_or_create(name=name)
game.platforms.add(p)
if screenshot_url:
r = requests.get(screenshot_url)
if r.status_code == 200:
fname = f"{game.title}_{game.uuid}.jpg"
game.screenshot.save(fname, ContentFile(r.content), save=True)
if not game.cover and cover_url:
if cover_url:
r = requests.get(cover_url)
if r.status_code == 200:
fname = f"{game.title}_{game.uuid}.jpg"
game.cover.save(fname, ContentFile(r.content), save=True)
if "igdb-enriched" not in game.tags.names():
game.tags.add("igdb-enriched")
logger.info(f"Game {game} tagged igdb-enriched")
return game

View File

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

View File

@ -0,0 +1,85 @@
import logging
from django.core.management.base import BaseCommand
from django.db import models, 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",
)
parser.add_argument(
"--needs-metadata",
action="store_true",
help="Only process series missing imdb_id or cover image",
)
def handle(self, *args, **options):
from videos.models import Series
force = options["force"]
dry_run = options["dry_run"]
imdb_id = options["imdb_id"]
needs_metadata = options["needs_metadata"]
qs = Series.objects.all()
if imdb_id:
qs = qs.filter(imdb_id=imdb_id)
if needs_metadata:
qs = qs.filter(
models.Q(imdb_id__isnull=True)
| models.Q(imdb_id="")
| models.Q(cover_image__isnull=True)
| models.Q(cover_image="")
)
total = qs.count()
self.stdout.write(f"Processing {total} series")
if dry_run:
for series in qs.iterator():
has_imdb = bool(series.imdb_id)
has_image = bool(series.cover_image)
self.stdout.write(
f" [DRY RUN] Would fix {series.name}"
f" (imdb_id={'' if has_imdb else ''}"
f", image={'' if has_image else ''})"
)
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")
)

View File

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

View File

@ -1,3 +1,4 @@
import json
import logging
import re
from dataclasses import dataclass
@ -58,6 +59,8 @@ class Channel(ScrobblableMixin):
)
youtube_id = models.CharField(max_length=255, **BNULL)
twitch_id = models.CharField(max_length=255, **BNULL)
description = models.TextField(**BNULL)
custom_url = models.CharField(max_length=255, **BNULL)
genre = TaggableManager(through=ObjectWithGenres, blank=True, verbose_name="Genre")
class Meta:
@ -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
@ -230,25 +321,41 @@ class Series(TimeStampedModel):
return not last_scrobble.played_to_completion
def fix_metadata(self, force_update=False):
name_or_id = self.name
if self.imdb_id:
name_or_id = self.imdb_id
video_metadata: VideoMetadata = lookup_video_from_tmdb(name_or_id)
from tmdbv3api import TV
if not video_metadata.title:
logger.warning(f"No imdb data for {self}")
if not self.imdb_id:
tv = TV()
results = tv.search(self.name)
if results:
show_id = results[0].id
external_ids = tv.external_ids(show_id)
if external_ids and external_ids.imdb_id:
self.imdb_id = external_ids.imdb_id
self.save(update_fields=["imdb_id"])
else:
logger.warning(f"No IMDB ID found on TMDB for {self}")
return
else:
logger.warning(f"No results on TMDB for {self.name}")
return
video_metadata = lookup_video_from_tmdb(self.imdb_id)
if not video_metadata or not video_metadata.title:
logger.warning(f"No metadata 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 or ""
self.imdb_rating = getattr(video_metadata, "imdb_rating", None)
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 +559,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 +581,7 @@ class Video(ScrobblableMixin):
logger.warning(f"No metadata found for {self} from TMDB or OMDB")
return
vdict, series_id, cover, genres = (
metadata.as_dict_with_cover_and_genres()
)
vdict, series_id, cover, genres = metadata.as_dict_with_cover_and_genres()
for k, v in vdict.items():
setattr(self, k, v)

View File

@ -1,4 +1,5 @@
import logging
import os
import pendulum
from django.conf import settings
@ -8,6 +9,8 @@ from videos.metadata import VideoMetadata, VideoType
TMDB_KEY = getattr(settings, "TMDB_API_KEY", "")
os.environ.setdefault("TMDB_API_KEY", TMDB_KEY)
tmdb = TMDb(key=TMDB_KEY, language="en-US", region="US")
TMDB_IMAGE_URL = "https://image.tmdb.org/t/p/original"
@ -43,6 +46,28 @@ def lookup_video_from_tmdb(name_or_id: str, kind: str = "movie") -> VideoMetadat
) # TODO: enrich this with TMDB url
video_metadata.year = pendulum.parse(media.release_date).year
video_metadata.genres = [g.get("name", "") for g in media.genres]
video_metadata.tmdb_id = media.id
video_metadata.base_run_time_seconds = media.runtime * 60
video_metadata.plot = media.overview
video_metadata.overview = media.overview
video_metadata.tmdb_rating = media.vote_average
if len(tmdb_result.tv_results) > 0:
media = TV().details(tmdb_result.tv_results[0].id)
video_metadata.video_type = VideoType.TV_EPISODE.value
video_metadata.title = media.name
video_metadata.cover_url = (
TMDB_IMAGE_URL + media.poster_path
)
video_metadata.year = pendulum.parse(media.first_air_date).year if media.first_air_date else None
video_metadata.genres = [g.get("name", "") for g in media.genres]
video_metadata.tmdb_id = media.id
video_metadata.base_run_time_seconds = (
media.episode_run_time[0] * 60 if media.episode_run_time else 1800
)
video_metadata.plot = media.overview
video_metadata.overview = media.overview
video_metadata.tmdb_rating = media.vote_average
if len(tmdb_result.tv_episode_results) > 0:
video_metadata.video_type = VideoType.TV_EPISODE.value
@ -63,15 +88,12 @@ def lookup_video_from_tmdb(name_or_id: str, kind: str = "movie") -> VideoMetadat
series.save()
video_metadata.tv_series_id = series.id
video_metadata.tmdb_id = media.id
video_metadata.plot = media.overview
video_metadata.overview = media.overview
if not media:
logger.warning("Video not found on TMDB", extra={"imdb_id": imdb_id})
return video_metadata
video_metadata.tmdb_id = media.id
video_metadata.base_run_time_seconds = media.runtime * 60
video_metadata.plot = media.overview
video_metadata.overview = media.overview
video_metadata.tmdb_rating = media.vote_average
# video_metadata.next_imdb_id = imdb_result.get("next episode", None)
return video_metadata

View File

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

View File

@ -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

View File

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

View File

@ -3,3 +3,6 @@ from django.apps import AppConfig
class WebpagesConfig(AppConfig):
name = "webpages"
def ready(self):
import webpages.signals # noqa

View File

@ -0,0 +1,68 @@
import re
from django.core.management.base import BaseCommand
from scrobbles.utils import tokenize_title_to_tags
from webpages.models import WebPage
def _clean(s: str) -> str:
return re.sub(r"[^\x20-\x7e]", "", s)
class Command(BaseCommand):
help = "Backfill auto tags on webpages from domain and title"
def add_arguments(self, parser):
parser.add_argument(
"--commit",
action="store_true",
help="Actually add tags",
)
def handle(self, *args, **options):
commit = options["commit"]
webpages = WebPage.objects.all()
total = webpages.count()
updated_count = 0
skipped_count = 0
for i, webpage in enumerate(webpages.iterator(), start=1):
new_tags = set()
if webpage.domain:
parts = webpage.domain.root.split(".")
for part in parts:
part = part.strip().lower()
if part and part != "www":
new_tags.add(part)
if webpage.title:
title_tags = tokenize_title_to_tags(webpage.title)
new_tags.update(title_tags)
existing_tags = {
t.name for t in webpage.tags.all()
}
tags_to_add = new_tags - existing_tags
if tags_to_add:
updated_count += 1
if commit:
for tag in tags_to_add:
webpage.tags.add(tag)
self.stdout.write(
f"[{i}/{total}] Added tags to {_clean(str(webpage))}: "
f"{sorted(tags_to_add)}"
)
else:
self.stdout.write(
f"[{i}/{total}] [DRY RUN] Would add tags to "
f"{_clean(str(webpage))}: {sorted(tags_to_add)}"
)
else:
skipped_count += 1
self.stdout.write(f"\nDone. {updated_count} webpages to update, "
f"{skipped_count} already up to date.")

View 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,
},
),
]

View File

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

View File

@ -0,0 +1,29 @@
import logging
from django.db.models.signals import post_save
from django.dispatch import receiver
from scrobbles.utils import tokenize_title_to_tags
from webpages.models import WebPage
logger = logging.getLogger(__name__)
@receiver(post_save, sender=WebPage)
def add_auto_tags_to_webpage(sender, instance, **kwargs):
existing_tags = {t.name for t in instance.tags.all()}
if instance.domain:
domain_parts = instance.domain.root.split(".")
for part in domain_parts:
part = part.strip().lower()
if part and part != "www" and part not in existing_tags:
instance.tags.add(part)
existing_tags.add(part)
if instance.title:
title_tags = tokenize_title_to_tags(instance.title)
for tag in title_tags:
if tag not in existing_tags:
instance.tags.add(tag)
existing_tags.add(tag)

View File

@ -16,8 +16,23 @@ from webpages.models import WebPage
class WebPageListView(ScrobbleableListView):
model = WebPage
def get_queryset(self):
queryset = super().get_queryset()
tag_name = self.request.GET.get("tag")
if tag_name:
from django.contrib.contenttypes.models import ContentType
from taggit.models import TaggedItem
ct = ContentType.objects.get_for_model(WebPage)
webpages = TaggedItem.objects.filter(
content_type=ct,
tag__name__iexact=tag_name,
).values_list("object_id", flat=True)
queryset = queryset.filter(pk__in=webpages)
return queryset
def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs)
context_data["active_tag"] = self.request.GET.get("tag", "")
user = self.request.user
now = timezone.now()
start_day_of_week = now - datetime.timedelta(days=now.weekday())

View File

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

View File

@ -322,8 +322,8 @@
<span class="progress-bar-fill" style="width: {{scrobble.percent_played}}%;"></span>
</div>
<p class="action-buttons">
<a href="{% url "scrobbles:cancel" scrobble.uuid %}">Cancel</a>
<a class="right" href="{% url "scrobbles:finish" scrobble.uuid %}">Finish</a>
<a href="{% url "scrobbles:cancel" scrobble.id %}">Cancel</a>
<a class="right" href="{% url "scrobbles:finish" scrobble.id %}">Finish</a>
</p>
{% if not forloop.last %}<hr/>{% endif %}
</div>

View File

@ -65,7 +65,7 @@
{% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
<tr>
<td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td>
<td>{% if scrobble.long_play_complete == True %}Yes <small><a href="{% url 'scrobbles:longplay-finish' uuid=scrobble.uuid %}">(not complete?)</a></small>{% else %}<a href="{% url 'scrobbles:longplay-finish' uuid=scrobble.uuid %}">No</a>{% endif %}</td>
<td>{% if scrobble.long_play_complete == True %}Yes <small><a href="{% url 'scrobbles:longplay-finish' media_uuid=scrobble.uuid %}">(not complete?)</a></small>{% else %}<a href="{% url 'scrobbles:longplay-finish' media_uuid=scrobble.uuid %}">No</a>{% endif %}</td>
<td>{% if scrobble.in_progress %}Now reading{% else %}{{scrobble.session_pages_read}}{% endif %}</td>
<td>{% for author in scrobble.book.authors.all %}<a href="{{author.get_absolute_url}}">{{author}}</a>{% if not forloop.last %}, {% endif %}{% endfor %}</td>
</tr>

View File

@ -48,7 +48,7 @@
{% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
<tr>
<td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td>
<td>{% if scrobble.long_play_complete == True %}Yes <small><a href="{% url 'scrobbles:longplay-finish' uuid=scrobble.uuid %}">(not complete?)</a></small>{% else %}<a href="{% url 'scrobbles:longplay-finish' uuid=scrobble.uuid %}">No</a>{% endif %}</td>
<td>{% if scrobble.long_play_complete == True %}Yes <small><a href="{% url 'scrobbles:longplay-finish' media_uuid=scrobble.uuid %}">(not complete?)</a></small>{% else %}<a href="{% url 'scrobbles:longplay-finish' media_uuid=scrobble.uuid %}">No</a>{% endif %}</td>
<td>{% if scrobble.in_progress %}Now reading{% else %}{{scrobble.session_pages_read}}{% endif %}</td>
</tr>
{% endfor %}

View File

@ -1,7 +1,7 @@
{% load humanize %}
{% load naturalduration %}
<tr {% if scrobble.in_progress %}class="in-progress"{% endif %}>
<td>{% if scrobble.in_progress %}<a href="{{scrobble.get_absolute_url}}">{{scrobble.media_obj.strings.verb}} now</a> | <a class="right" href="{% url "scrobbles:finish" scrobble.uuid %}">Finish</a>{% else %}<a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp|naturaltime}}</a>{% endif %}</td>
<td>{% if scrobble.in_progress %}<a href="{{scrobble.get_absolute_url}}">{{scrobble.media_obj.strings.verb}} now</a> | <a class="right" href="{% url "scrobbles:finish" scrobble.id %}">Finish</a>{% else %}<a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp|naturaltime}}</a>{% endif %}</td>
<td>
{% if scrobble.media_type in "Task" %}
<p><em><a href="{{scrobble.media_obj.get_absolute_url}}">{{scrobble.media_obj.title|truncatechars_html:45}} - {% if scrobble.logdata %}{% if scrobble.logdata.title %}{{scrobble.logdata.title}}{% endif %}{% endif %}</a></em></p>

View File

@ -205,7 +205,7 @@ header.navbar { display: none !important; }
style="background:{% if not cd.is_today %}{{ cd.color }}{% endif %};">
<div class="day-number"><a href="{% url 'vrobbler-home' %}?date={{ year }}-{{ month|stringformat:'02d' }}-{{ cd.day|stringformat:'02d' }}" style="color:inherit;text-decoration:none;">{{ cd.day }}</a>{% if cd.total_count > 0 %} <span style="font-weight:400;color:#999;font-size:0.75rem;">{{ cd.total_count }}</span>{% endif %}</div>
{% for s in cd.scrobbles %}
<a href="{% url 'scrobbles:detail' uuid=s.uuid %}"
<a href="{% url 'scrobbles:detail' pk=s.id %}"
class="event-card"
title="{{ s.title }} — {{ s.media_type }}">{{ s.emoji }} {{ s.title }}</a>
{% endfor %}

View File

@ -88,7 +88,7 @@
{% endif %}">
{{ object.get_visibility_display }}
</span>
<form method="post" action="{% url 'scrobbles:change-visibility' object.uuid %}" class="d-inline-flex align-items-center gap-1">
<form method="post" action="{% url 'scrobbles:change-visibility' object.id %}" class="d-inline-flex align-items-center gap-1">
{% csrf_token %}
<select name="visibility" class="form-select form-select-sm" style="width:auto;" onchange="this.form.submit()">
<option value="private" {% if object.visibility == 'private' %}selected{% endif %}>Private</option>
@ -100,25 +100,25 @@
<span class="small text-muted">Share link:</span>
<code class="small" id="share-link">{{ request.scheme }}://{{ request.get_host }}{{ object.get_share_url }}</code>
<button class="btn btn-sm btn-outline-secondary" onclick="navigator.clipboard.writeText(document.getElementById('share-link').textContent.trim())">Copy</button>
<form method="post" action="{% url 'scrobbles:regenerate-share-token' object.uuid %}" class="d-inline">
<form method="post" action="{% url 'scrobbles:regenerate-share-token' object.id %}" class="d-inline">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-outline-warning">Regenerate</button>
</form>
{% if object.share_view_count %}
<span class="text-muted small ms-2">{{ object.share_view_count }} view{{ object.share_view_count|pluralize }}</span>
{% endif %}
<a href="{% url 'scrobbles:share-analytics' object.uuid %}" class="btn btn-sm btn-outline-info ms-2">Analytics</a>
<a href="{% url 'scrobbles:share-analytics' object.id %}" class="btn btn-sm btn-outline-info ms-2">Analytics</a>
{% endif %}
</div>
{% endif %}
{% if object.media_type == "Track" and has_mopidy_uri and user.profile.mopidy_api_url %}
<form method="post" action="{% url 'scrobbles:add-to-mopidy-queue' object.uuid %}" class="mb-1">
<form method="post" action="{% url 'scrobbles:add-to-mopidy-queue' object.id %}" class="mb-1">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-outline-secondary">add to mopidy queue</button>
</form>
{% if user.profile.monthly_mopidy_playlist_pattern %}
<form method="post" action="{% url 'scrobbles:add-to-mopidy-monthly-playlist' object.uuid %}" class="mb-1">
<form method="post" action="{% url 'scrobbles:add-to-mopidy-monthly-playlist' object.id %}" class="mb-1">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-outline-secondary">add to monthly playlist</button>
</form>
@ -185,6 +185,12 @@
</div>
{% endif %}
{% if object.screenshot %}
<div class="mb-3">
<img src="{{ object.screenshot_large.url }}" class="img-fluid rounded" style="max-height: 600px;" alt="Screenshot" />
</div>
{% endif %}
<p>
Tags:
{% if object.tags.all %}
@ -282,7 +288,7 @@
<tbody>
{% for scrobble in related_scrobbles %}
<tr{% if scrobble.id == object.id %} class="table-active fw-bold"{% endif %}>
<td>{% if scrobble.id == object.id %}{{ scrobble.timestamp|date:"M d, Y" }}{% else %}<a href="{% url 'scrobbles:detail' scrobble.uuid %}">{{ scrobble.timestamp|date:"M d, Y" }}</a>{% endif %}</td>
<td>{% if scrobble.id == object.id %}{{ scrobble.timestamp|date:"M d, Y" }}{% else %}<a href="{% url 'scrobbles:detail' scrobble.id %}">{{ scrobble.timestamp|date:"M d, Y" }}</a>{% endif %}</td>
<td>
{% if scrobble.media_type == "Task" and scrobble.logdata.title %}{{ scrobble.media_obj.title }}: {{ scrobble.logdata.title }}{% else %}{{ scrobble.media_obj.title|default:scrobble.media_obj }}{% endif %}
</td>

View File

@ -103,7 +103,7 @@
{% for scrobble in scrobbles %}
<div class="result-item">
<div class="result-title">
<a href="{% url 'scrobbles:detail' scrobble.uuid %}">
<a href="{% url 'scrobbles:detail' scrobble.id %}">
{{ scrobble|truncatechars:100 }}
</a>
</div>

View File

@ -117,6 +117,12 @@
</div>
{% endif %}
{% if object.screenshot %}
<div class="mb-3">
<img src="{{ object.screenshot_large.url }}" class="img-fluid rounded" style="max-height: 600px;" alt="Screenshot" />
</div>
{% endif %}
<p>
Tags:
{% if object.tags.all %}

View File

@ -81,7 +81,7 @@
<td><a href="{{scrobble.get_media_source_url}}">{{scrobble.logdata.title}}</a></td>
<td>{{scrobble.logdata.notes_as_str}}</td>
<td>{{scrobble.source}}</td>
<td>{% if scrobble.long_play_complete == True %}Yes <small><a href="{% url 'scrobbles:longplay-finish' uuid=scrobble.uuid %}">(not complete?)</a></small>{% else %}<a href="{% url 'scrobbles:longplay-finish' uuid=scrobble.uuid %}">No</a>{% endif %}</td>
<td>{% if scrobble.long_play_complete == True %}Yes <small><a href="{% url 'scrobbles:longplay-finish' media_uuid=scrobble.uuid %}">(not complete?)</a></small>{% else %}<a href="{% url 'scrobbles:longplay-finish' media_uuid=scrobble.uuid %}">No</a>{% endif %}</td>
</tr>
{% endfor %}
</tbody>

View File

@ -87,7 +87,7 @@
<tr>
<td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td>
<td>{% if scrobble.long_play_complete == True %}Yes <small><a href="{% url 'scrobbles:longplay-finish' uuid=scrobble.uuid %}">(not complete?)</a></small>{% else %}<a href="{% url 'scrobbles:longplay-finish' uuid=scrobble.uuid %}">Not yet</a>{% endif %}</td>
<td>{% if scrobble.long_play_complete == True %}Yes <small><a href="{% url 'scrobbles:longplay-finish' media_uuid=scrobble.uuid %}">(not complete?)</a></small>{% else %}<a href="{% url 'scrobbles:longplay-finish' media_uuid=scrobble.uuid %}">Not yet</a>{% endif %}</td>
<td>{% if scrobble.in_progress %}Now playing{% else %}{{scrobble.playback_position_seconds|natural_duration}}{% endif %}</td>
<td>{% for platform in scrobble.video_game.platforms.all %}<a href="{{platform.get_absolute_url}}">{{platform}}</a>{% if not forloop.last %}, {% endif %}{% endfor %}</td>
<td>{% if scrobble.videogame_save_data %}<a href="{{scrobble.videogame_save_data.url}}">Save data</a>{% else %}Not yet{% endif %}</td>

View File

@ -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 %}

View File

@ -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 />

View File

@ -204,6 +204,13 @@
<p>Source: <a href="{{object.url}}">{{object.domain}}</a></p>
{% if object.date %}<p>Published: <em>{{object.date}}</em></p>{% endif %}
<p>Time to read: {{object.estimated_time_to_read_in_minutes}} minutes</p>
{% if object.tags.all %}
<p>Tags:
{% for tag in object.tags.all %}
<a href="{% url 'webpages:webpage_list' %}?tag={{ tag.name|urlencode }}">{{ tag.name }}</a>{% if not forloop.last %}, {% endif %}
{% endfor %}
</p>
{% endif %}
</div>
{% if object.extract %}
<div class="col">

View File

@ -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: