Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d2705758c6 | |||
| f4368c31f3 | |||
| 57f273b0cc | |||
| ac82292200 | |||
| 6a8432c08f | |||
| 5a2c41155c | |||
| 83a046111b |
@ -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
|
Be sure to check pyproject.toml for project defaults. Specifically for black and
|
||||||
isort expectations.
|
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.
|
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
|
In local development, environment variables for various sensitive values live in a .envrc file
|
||||||
|
|||||||
90
PROJECT.org
90
PROJECT.org
@ -88,7 +88,7 @@ fetching and simple saving.
|
|||||||
*** Metadata sources
|
*** Metadata sources
|
||||||
**** Scraper
|
**** Scraper
|
||||||
|
|
||||||
* Backlog [0/20] :vrobbler:project:personal:
|
* Backlog [0/19] :vrobbler:project:personal:
|
||||||
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles:
|
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: 702462cf-d54b-48c6-8a7c-78b8de751deb
|
: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
|
so we can probably use backtrace=True and diagnose=True to help us root cause
|
||||||
bugs faster.
|
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:
|
** TODO [#B] Scrape ComicBookRoundUp ratings for comic book metadata :books:feature:comicbook:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: b3cc57ca-3d2c-468d-ab7c-c47f1120309b
|
:ID: b3cc57ca-3d2c-468d-ab7c-c47f1120309b
|
||||||
@ -564,6 +547,7 @@ File: ~vrobbler/apps/podcasts/utils.py~ (line 13)
|
|||||||
:END:
|
:END:
|
||||||
|
|
||||||
*** Description
|
*** Description
|
||||||
|
|
||||||
The zombie scrobble cleanup query lives in a utility function. Should be a
|
The zombie scrobble cleanup query lives in a utility function. Should be a
|
||||||
custom model manager method (e.g. =Scrobble.objects.zombies()=).
|
custom model manager method (e.g. =Scrobble.objects.zombies()=).
|
||||||
|
|
||||||
@ -594,6 +578,76 @@ named constants for maintainability.
|
|||||||
- ~vrobbler/apps/webpages/models.py~ (line 290) -- ="url"=
|
- ~vrobbler/apps/webpages/models.py~ (line 290) -- ="url"=
|
||||||
- ~vrobbler/apps/scrobbles/importers/tsv.py~ (line 55) -- ="S"= completion status
|
- ~vrobbler/apps/scrobbles/importers/tsv.py~ (line 55) -- ="S"= completion status
|
||||||
|
|
||||||
|
|
||||||
|
* 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]
|
* Version 52.2 [1/1]
|
||||||
** DONE [#A] Fix bug in recomputing long play seconds taking forever :bug:longplay:commands:
|
** DONE [#A] Fix bug in recomputing long play seconds taking forever :bug:longplay:commands:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "vrobbler"
|
name = "vrobbler"
|
||||||
version = "52.2"
|
version = "53.0"
|
||||||
description = ""
|
description = ""
|
||||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||||
|
|
||||||
|
|||||||
@ -28,6 +28,14 @@ class GeoLocationLogData(BaseLogData, WithPeopleLogData):
|
|||||||
activity: str = ""
|
activity: str = ""
|
||||||
detected_at: 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):
|
class GeoLocation(ScrobblableMixin):
|
||||||
COMPLETION_PERCENT = getattr(settings, "LOCATION_COMPLETION_PERCENT", 100)
|
COMPLETION_PERCENT = getattr(settings, "LOCATION_COMPLETION_PERCENT", 100)
|
||||||
|
|||||||
@ -50,6 +50,18 @@ class BaseLogData(JSONDataclass):
|
|||||||
def override_fields(cls) -> dict:
|
def override_fields(cls) -> dict:
|
||||||
return {}
|
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:
|
def notes_as_str(self, separator: str = " | ") -> str:
|
||||||
import html
|
import html
|
||||||
import re
|
import re
|
||||||
|
|||||||
@ -93,7 +93,6 @@ class LastFM:
|
|||||||
new_scrobbles.append(new_scrobble)
|
new_scrobbles.append(new_scrobble)
|
||||||
|
|
||||||
created = Scrobble.objects.bulk_create(new_scrobbles)
|
created = Scrobble.objects.bulk_create(new_scrobbles)
|
||||||
# TODO Add a notification for users that their import is complete
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Last.fm import fnished",
|
f"Last.fm import fnished",
|
||||||
extra={
|
extra={
|
||||||
|
|||||||
@ -51,7 +51,10 @@ from scrobbles.constants import (
|
|||||||
MEDIA_END_PADDING_SECONDS,
|
MEDIA_END_PADDING_SECONDS,
|
||||||
)
|
)
|
||||||
from scrobbles.importers.lastfm import LastFM
|
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 scrobbles.utils import get_file_md5_hash, media_class_to_foreign_key
|
||||||
from sports.models import SportEvent
|
from sports.models import SportEvent
|
||||||
from taggit.managers import TaggableManager
|
from taggit.managers import TaggableManager
|
||||||
@ -428,6 +431,8 @@ class LastFmImport(BaseFileImportMixin):
|
|||||||
try:
|
try:
|
||||||
scrobbles = lastfm.import_from_lastfm(last_processed, time_to=time_to)
|
scrobbles = lastfm.import_from_lastfm(last_processed, time_to=time_to)
|
||||||
self.record_log(scrobbles)
|
self.record_log(scrobbles)
|
||||||
|
if scrobbles:
|
||||||
|
LastFmImportNtfyNotification(self, len(scrobbles)).send()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.record_error(f"Import failed: {e}")
|
self.record_error(f"Import failed: {e}")
|
||||||
logger.exception(f"Import failed for {self}")
|
logger.exception(f"Import failed for {self}")
|
||||||
@ -952,27 +957,6 @@ class Scrobble(TimeStampedModel):
|
|||||||
self.share_token_version += 1
|
self.share_token_version += 1
|
||||||
self.save(update_fields=["share_token_version"])
|
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
|
@property
|
||||||
def logdata(self) -> Optional[logdata.BaseLogData]:
|
def logdata(self) -> Optional[logdata.BaseLogData]:
|
||||||
if self.media_obj:
|
if self.media_obj:
|
||||||
@ -994,24 +978,8 @@ class Scrobble(TimeStampedModel):
|
|||||||
if not log_dict:
|
if not log_dict:
|
||||||
log_dict = {}
|
log_dict = {}
|
||||||
|
|
||||||
# Special handling for GeoLocationLogData - data is nested under 'movement_detection'
|
# Use LogData's from_log_dict to handle any custom nesting/structure
|
||||||
# TODO there's a better way to fix this this at the LogData level
|
logdata_kwargs = logdata_cls.from_log_dict(log_dict)
|
||||||
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__
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return logdata_cls(**logdata_kwargs)
|
return logdata_cls(**logdata_kwargs)
|
||||||
|
|||||||
@ -79,6 +79,27 @@ class ScrobbleNtfyNotification(ScrobbleNotification):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LastFmImportNtfyNotification(BasicNtfyNotification):
|
||||||
|
def __init__(self, lfm_import, scrobble_count):
|
||||||
|
super().__init__(lfm_import.user.profile)
|
||||||
|
self.ntfy_str = f"Imported {scrobble_count} scrobble(s) from Last.fm"
|
||||||
|
self.click_url = lfm_import.get_absolute_url()
|
||||||
|
self.title = "Last.fm Import Complete"
|
||||||
|
|
||||||
|
def send(self):
|
||||||
|
if self.profile and self.profile.ntfy_enabled and self.profile.ntfy_url:
|
||||||
|
requests.post(
|
||||||
|
self.profile.ntfy_url,
|
||||||
|
data=self.ntfy_str.encode(encoding="utf-8"),
|
||||||
|
headers={
|
||||||
|
"Title": self.title,
|
||||||
|
"Priority": "default",
|
||||||
|
"Tags": "musical_note",
|
||||||
|
"Click": self.click_url,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class MoodNtfyNotification(BasicNtfyNotification):
|
class MoodNtfyNotification(BasicNtfyNotification):
|
||||||
def __init__(self, profile, **kwargs):
|
def __init__(self, profile, **kwargs):
|
||||||
super().__init__(profile)
|
super().__init__(profile)
|
||||||
|
|||||||
@ -32,6 +32,7 @@ from scrobbles.constants import (
|
|||||||
)
|
)
|
||||||
from scrobbles.models import Scrobble
|
from scrobbles.models import Scrobble
|
||||||
from scrobbles.notifications import ScrobbleNtfyNotification
|
from scrobbles.notifications import ScrobbleNtfyNotification
|
||||||
|
from scrobbles.tasks import push_scrobble_to_archivebox
|
||||||
from scrobbles.utils import (
|
from scrobbles.utils import (
|
||||||
convert_to_seconds,
|
convert_to_seconds,
|
||||||
extract_domain,
|
extract_domain,
|
||||||
@ -1028,8 +1029,7 @@ def manual_scrobble_webpage(
|
|||||||
if action == "stop":
|
if action == "stop":
|
||||||
scrobble.stop(force_finish=True)
|
scrobble.stop(force_finish=True)
|
||||||
else:
|
else:
|
||||||
# possibly async this?
|
push_scrobble_to_archivebox.delay(scrobble.id)
|
||||||
scrobble.push_to_archivebox()
|
|
||||||
|
|
||||||
return scrobble
|
return scrobble
|
||||||
|
|
||||||
|
|||||||
@ -252,6 +252,25 @@ def update_charts_for_timestamp(user_id, year, month, day, week):
|
|||||||
logger.error(f"[charts] Failed to update charts: {e}")
|
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 ──────────────────────────────────────────────────────
|
# ── Crontab replacements ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
0
vrobbler/apps/trends/__init__.py
Normal file
0
vrobbler/apps/trends/__init__.py
Normal file
10
vrobbler/apps/trends/admin.py
Normal file
10
vrobbler/apps/trends/admin.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from trends.models import TrendResult
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(TrendResult)
|
||||||
|
class TrendResultAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("user", "trend_slug", "computed_at", "created")
|
||||||
|
list_filter = ("user", "trend_slug")
|
||||||
|
ordering = ("-computed_at",)
|
||||||
6
vrobbler/apps/trends/apps.py
Normal file
6
vrobbler/apps/trends/apps.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class TrendsConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "trends"
|
||||||
0
vrobbler/apps/trends/management/__init__.py
Normal file
0
vrobbler/apps/trends/management/__init__.py
Normal file
40
vrobbler/apps/trends/management/commands/compute_trends.py
Normal file
40
vrobbler/apps/trends/management/commands/compute_trends.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from trends.tasks import compute_user_trends
|
||||||
|
|
||||||
|
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]
|
||||||
|
self.stdout.write(f"Computing trends for user: {user}")
|
||||||
|
else:
|
||||||
|
users = User.objects.filter(is_active=True)
|
||||||
|
self.stdout.write(f"Computing trends for {users.count()} users...")
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
try:
|
||||||
|
compute_user_trends(user.id)
|
||||||
|
self.stdout.write(self.style.SUCCESS(f" OK {user}"))
|
||||||
|
except Exception as e:
|
||||||
|
self.stderr.write(self.style.ERROR(f" FAILED {user}: {e}"))
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS("Done!"))
|
||||||
57
vrobbler/apps/trends/migrations/0001_initial.py
Normal file
57
vrobbler/apps/trends/migrations/0001_initial.py
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
# Generated by Django 4.2.29 on 2026-06-16 14:52
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django_extensions.db.fields
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="TrendResult",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"created",
|
||||||
|
django_extensions.db.fields.CreationDateTimeField(
|
||||||
|
auto_now_add=True, verbose_name="created"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"modified",
|
||||||
|
django_extensions.db.fields.ModificationDateTimeField(
|
||||||
|
auto_now=True, verbose_name="modified"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("trend_slug", models.CharField(db_index=True, max_length=100)),
|
||||||
|
("computed_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("data", models.JSONField(default=dict)),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"unique_together": {("user", "trend_slug")},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
vrobbler/apps/trends/migrations/__init__.py
Normal file
0
vrobbler/apps/trends/migrations/__init__.py
Normal file
18
vrobbler/apps/trends/models.py
Normal file
18
vrobbler/apps/trends/models.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db import models
|
||||||
|
from django_extensions.db.models import TimeStampedModel
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class TrendResult(TimeStampedModel):
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
|
trend_slug = models.CharField(max_length=100, db_index=True)
|
||||||
|
computed_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
data = models.JSONField(default=dict)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ["user", "trend_slug"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user} - {self.trend_slug} ({self.computed_at})"
|
||||||
39
vrobbler/apps/trends/tasks.py
Normal file
39
vrobbler/apps/trends/tasks.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from celery import shared_task
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from trends.models import TrendResult
|
||||||
|
from trends.trends import TREND_REGISTRY
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def compute_all_trends():
|
||||||
|
for user in User.objects.filter(is_active=True):
|
||||||
|
compute_user_trends.delay(user.id)
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
for slug, fn in TREND_REGISTRY.items():
|
||||||
|
try:
|
||||||
|
data = fn(user)
|
||||||
|
TrendResult.objects.update_or_create(
|
||||||
|
user=user,
|
||||||
|
trend_slug=slug,
|
||||||
|
defaults={"data": data, "computed_at": timezone.now()},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to compute trend '%s' for user %s", slug, user_id
|
||||||
|
)
|
||||||
@ -0,0 +1,89 @@
|
|||||||
|
<div class="row">
|
||||||
|
{% if data.trails %}
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<h4>🥾 While on Trails</h4>
|
||||||
|
{% for trail in data.trails %}
|
||||||
|
<div class="card mb-2">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<h6 class="card-title mb-1">
|
||||||
|
{% if trail.uuid %}
|
||||||
|
<a href="{% url 'trails:trail_detail' trail.uuid %}">{{ trail.name }}</a>
|
||||||
|
{% else %}
|
||||||
|
{{ trail.name }}
|
||||||
|
{% endif %}
|
||||||
|
<small class="text-muted">({{ trail.total_sessions }} sessions)</small>
|
||||||
|
</h6>
|
||||||
|
{% if trail.tracks %}
|
||||||
|
<table class="table table-sm table-borderless mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Track</th>
|
||||||
|
<th>Artist</th>
|
||||||
|
<th class="text-end">Plays</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for t in trail.tracks %}
|
||||||
|
<tr>
|
||||||
|
<td>{% if t.track_uuid %}<a href="{% url 'music:track_detail' t.track_uuid %}">{{ t.track_name }}</a>{% else %}{{ t.track_name }}{% endif %}</td>
|
||||||
|
<td>{{ t.artist_name }}</td>
|
||||||
|
<td class="text-end">{{ t.count }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<p class="text-muted">No concurrent listening data for trails.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if data.locations %}
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<h4>📍 While at Locations</h4>
|
||||||
|
{% for loc in data.locations %}
|
||||||
|
<div class="card mb-2">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<h6 class="card-title mb-1">
|
||||||
|
{% if loc.uuid %}
|
||||||
|
<a href="{% url 'locations:geolocation_detail' loc.uuid %}">{{ loc.name }}</a>
|
||||||
|
{% else %}
|
||||||
|
{{ loc.name }}
|
||||||
|
{% endif %}
|
||||||
|
<small class="text-muted">({{ loc.total_sessions }} sessions)</small>
|
||||||
|
</h6>
|
||||||
|
{% if loc.tracks %}
|
||||||
|
<table class="table table-sm table-borderless mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Track</th>
|
||||||
|
<th>Artist</th>
|
||||||
|
<th class="text-end">Plays</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for t in loc.tracks %}
|
||||||
|
<tr>
|
||||||
|
<td>{% if t.track_uuid %}<a href="{% url 'music:track_detail' t.track_uuid %}">{{ t.track_name }}</a>{% else %}{{ t.track_name }}{% endif %}</td>
|
||||||
|
<td>{{ t.artist_name }}</td>
|
||||||
|
<td class="text-end">{{ t.count }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<p class="text-muted">No concurrent listening data for locations.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not data.trails and not data.locations %}
|
||||||
|
<p class="text-muted">No concurrent listening data found.</p>
|
||||||
|
{% endif %}
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
<div class="row">
|
||||||
|
{% if data.books %}
|
||||||
|
{% for book in data.books %}
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<h6 class="card-title mb-1">
|
||||||
|
{% if book.book_uuid %}
|
||||||
|
<a href="{% url 'books:book_detail' book.book_uuid %}">{{ book.book_title }}</a>
|
||||||
|
{% else %}
|
||||||
|
{{ book.book_title }}
|
||||||
|
{% endif %}
|
||||||
|
<small class="text-muted">({{ book.total_sessions }} listening sessions)</small>
|
||||||
|
</h6>
|
||||||
|
{% if book.tracks %}
|
||||||
|
<table class="table table-sm table-borderless mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Track</th>
|
||||||
|
<th>Artist</th>
|
||||||
|
<th class="text-end">Plays</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for t in book.tracks %}
|
||||||
|
<tr>
|
||||||
|
<td>{% if t.track_uuid %}<a href="{% url 'music:track_detail' t.track_uuid %}">{{ t.track_name }}</a>{% else %}{{ t.track_name }}{% endif %}</td>
|
||||||
|
<td>{{ t.artist_name }}</td>
|
||||||
|
<td class="text-end">{{ t.count }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted">No concurrent reading data found.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
52
vrobbler/apps/trends/templates/trends/_reading_pace.html
Normal file
52
vrobbler/apps/trends/templates/trends/_reading_pace.html
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">🎵 Reading with Music</h5>
|
||||||
|
{% if data.with_music %}
|
||||||
|
<table class="table table-sm mb-0">
|
||||||
|
<tr>
|
||||||
|
<th>Avg session duration</th>
|
||||||
|
<td>{{ data.with_music.avg_seconds }} seconds</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Total reading time</th>
|
||||||
|
<td>{{ data.with_music.total_seconds }} seconds</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Reading sessions</th>
|
||||||
|
<td>{{ data.with_music.sessions_count }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted mb-0">No data.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">🔇 Reading without Music</h5>
|
||||||
|
{% if data.without_music %}
|
||||||
|
<table class="table table-sm mb-0">
|
||||||
|
<tr>
|
||||||
|
<th>Avg session duration</th>
|
||||||
|
<td>{{ data.without_music.avg_seconds }} seconds</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Total reading time</th>
|
||||||
|
<td>{{ data.without_music.total_seconds }} seconds</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Reading sessions</th>
|
||||||
|
<td>{{ data.without_music.sessions_count }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted mb-0">No data.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
38
vrobbler/apps/trends/templates/trends/_trending_up.html
Normal file
38
vrobbler/apps/trends/templates/trends/_trending_up.html
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
{% if data %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Media Type</th>
|
||||||
|
<th class="text-end">Recent (30 days)</th>
|
||||||
|
<th class="text-end">Previous (30 days)</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>
|
||||||
38
vrobbler/apps/trends/templates/trends/trend_detail.html
Normal file
38
vrobbler/apps/trends/templates/trends/trend_detail.html
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
{% extends "base_list.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ trend.title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block lists %}
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<a href="{% url 'trends:trends-home' %}" class="btn btn-sm btn-outline-secondary mb-2">← All Trends</a>
|
||||||
|
<h2>{{ trend.icon }} {{ trend.title }}</h2>
|
||||||
|
<p class="text-muted">{{ trend.description }}</p>
|
||||||
|
{% if 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. 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" %}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
39
vrobbler/apps/trends/templates/trends/trend_list.html
Normal file
39
vrobbler/apps/trends/templates/trends/trend_list.html
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
{% extends "base_list.html" %}
|
||||||
|
|
||||||
|
{% block title %}Trends{% endblock %}
|
||||||
|
|
||||||
|
{% block lists %}
|
||||||
|
<div class="row">
|
||||||
|
{% if not user.is_authenticated %}
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="alert alert-info">Log in to see your trends.</div>
|
||||||
|
</div>
|
||||||
|
{% elif not trends %}
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="alert alert-info">
|
||||||
|
No trends computed yet. Trends are computed once daily, check back later.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{% for trend in trends %}
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">
|
||||||
|
<a href="{% url 'trends:trend-detail' trend.slug %}" class="stretched-link text-decoration-none">
|
||||||
|
{{ trend.icon }} {{ trend.title }}
|
||||||
|
</a>
|
||||||
|
</h5>
|
||||||
|
<p class="card-text text-muted">{{ trend.description }}</p>
|
||||||
|
{% if trend.computed_at %}
|
||||||
|
<small class="text-muted">Last computed: {{ trend.computed_at|date:"M j, Y H:i" }}</small>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-warning text-dark">Pending</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
0
vrobbler/apps/trends/templatetags/__init__.py
Normal file
0
vrobbler/apps/trends/templatetags/__init__.py
Normal file
25
vrobbler/apps/trends/trends/__init__.py
Normal file
25
vrobbler/apps/trends/trends/__init__.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
from trends.trends.concurrent import compute_concurrent_listening, compute_concurrent_reading
|
||||||
|
from trends.trends.reading import compute_reading_pace_vs_activity
|
||||||
|
from trends.trends.trending import compute_trending_up
|
||||||
|
|
||||||
|
TREND_REGISTRY = {}
|
||||||
|
|
||||||
|
def register(slug):
|
||||||
|
def decorator(fn):
|
||||||
|
TREND_REGISTRY[slug] = fn
|
||||||
|
return fn
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
compute_concurrent_listening = register("concurrent-listening")(
|
||||||
|
compute_concurrent_listening
|
||||||
|
)
|
||||||
|
compute_concurrent_reading = register("concurrent-reading")(
|
||||||
|
compute_concurrent_reading
|
||||||
|
)
|
||||||
|
compute_reading_pace_vs_activity = register("reading-pace-vs-activity")(
|
||||||
|
compute_reading_pace_vs_activity
|
||||||
|
)
|
||||||
|
compute_trending_up = register("trending-up")(
|
||||||
|
compute_trending_up
|
||||||
|
)
|
||||||
222
vrobbler/apps/trends/trends/concurrent.py
Normal file
222
vrobbler/apps/trends/trends/concurrent.py
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
import datetime
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
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):
|
||||||
|
"""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.
|
||||||
|
"""
|
||||||
|
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(
|
||||||
|
user=user,
|
||||||
|
timestamp__isnull=False,
|
||||||
|
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(
|
||||||
|
user=user,
|
||||||
|
media_type="Track",
|
||||||
|
timestamp__isnull=False,
|
||||||
|
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):
|
||||||
|
"""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.
|
||||||
|
"""
|
||||||
|
anchor_scrobbles = list(
|
||||||
|
Scrobble.objects.filter(
|
||||||
|
user=user,
|
||||||
|
media_type="Book",
|
||||||
|
timestamp__isnull=False,
|
||||||
|
stop_timestamp__isnull=False,
|
||||||
|
played_to_completion=True,
|
||||||
|
)
|
||||||
|
.select_related("book")
|
||||||
|
.order_by("-timestamp")
|
||||||
|
)
|
||||||
|
|
||||||
|
paired_scrobbles = list(
|
||||||
|
Scrobble.objects.filter(
|
||||||
|
user=user,
|
||||||
|
media_type="Track",
|
||||||
|
timestamp__isnull=False,
|
||||||
|
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 = []
|
||||||
|
|
||||||
|
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 "",
|
||||||
|
}
|
||||||
|
|
||||||
|
book = anchor.book
|
||||||
|
books.append({
|
||||||
|
"book_title": str(book) if book else "Unknown",
|
||||||
|
"book_uuid": str(book.uuid) if book and book.uuid else "",
|
||||||
|
"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],
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"books": sorted(books, key=lambda x: x["total_sessions"], reverse=True)[:20],
|
||||||
|
}
|
||||||
86
vrobbler/apps/trends/trends/reading.py
Normal file
86
vrobbler/apps/trends/trends/reading.py
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import datetime
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
|
|
||||||
|
def compute_reading_pace_vs_activity(user):
|
||||||
|
"""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.
|
||||||
|
"""
|
||||||
|
book_scrobbles = list(
|
||||||
|
Scrobble.objects.filter(
|
||||||
|
user=user,
|
||||||
|
media_type="Book",
|
||||||
|
timestamp__isnull=False,
|
||||||
|
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(
|
||||||
|
user=user,
|
||||||
|
media_type="Track",
|
||||||
|
timestamp__isnull=False,
|
||||||
|
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),
|
||||||
|
}
|
||||||
64
vrobbler/apps/trends/trends/trending.py
Normal file
64
vrobbler/apps/trends/trends/trending.py
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
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, days=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.
|
||||||
|
|
||||||
|
Returns a dict keyed by media_type with count and change info.
|
||||||
|
"""
|
||||||
|
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
|
||||||
10
vrobbler/apps/trends/urls.py
Normal file
10
vrobbler/apps/trends/urls.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
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"),
|
||||||
|
]
|
||||||
89
vrobbler/apps/trends/views.py
Normal file
89
vrobbler/apps/trends/views.py
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from django.views.generic import TemplateView
|
||||||
|
|
||||||
|
from trends.models import TrendResult
|
||||||
|
from trends.trends import TREND_REGISTRY
|
||||||
|
|
||||||
|
TREND_METADATA = {
|
||||||
|
"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": "📖",
|
||||||
|
},
|
||||||
|
"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": "📈",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TrendListView(LoginRequiredMixin, TemplateView):
|
||||||
|
template_name = "trends/trend_list.html"
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
ctx = super().get_context_data(**kwargs)
|
||||||
|
results = {
|
||||||
|
r.trend_slug: r
|
||||||
|
for r in TrendResult.objects.filter(
|
||||||
|
user=self.request.user
|
||||||
|
)
|
||||||
|
}
|
||||||
|
trends = []
|
||||||
|
for slug in TREND_REGISTRY:
|
||||||
|
meta = TREND_METADATA.get(slug, {})
|
||||||
|
result = results.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
|
||||||
|
|
||||||
|
meta = TREND_METADATA.get(slug, {})
|
||||||
|
ctx["trend"] = {
|
||||||
|
"slug": slug,
|
||||||
|
"title": meta.get("title", slug),
|
||||||
|
"description": meta.get("description", ""),
|
||||||
|
"icon": meta.get("icon", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
result = TrendResult.objects.filter(
|
||||||
|
user=self.request.user,
|
||||||
|
trend_slug=slug,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if result:
|
||||||
|
ctx["computed_at"] = result.computed_at
|
||||||
|
ctx["data"] = result.data
|
||||||
|
else:
|
||||||
|
ctx["computed_at"] = None
|
||||||
|
ctx["data"] = None
|
||||||
|
|
||||||
|
return ctx
|
||||||
@ -1,6 +1,6 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from webpages.models import Domain, WebPage
|
from webpages.models import Domain, HistoricalWebPage, WebPage
|
||||||
|
|
||||||
from scrobbles.admin import ScrobbleInline
|
from scrobbles.admin import ScrobbleInline
|
||||||
|
|
||||||
@ -20,6 +20,12 @@ class DomainAdmin(admin.ModelAdmin):
|
|||||||
inlines = [WebPageInline]
|
inlines = [WebPageInline]
|
||||||
|
|
||||||
|
|
||||||
|
class HistoricalWebPageInline(admin.TabularInline):
|
||||||
|
model = HistoricalWebPage
|
||||||
|
extra = 0
|
||||||
|
readonly_fields = ("date", "domain", "extract", "created")
|
||||||
|
|
||||||
|
|
||||||
@admin.register(WebPage)
|
@admin.register(WebPage)
|
||||||
class WebPageAdmin(admin.ModelAdmin):
|
class WebPageAdmin(admin.ModelAdmin):
|
||||||
date_hierarchy = "created"
|
date_hierarchy = "created"
|
||||||
@ -33,4 +39,20 @@ class WebPageAdmin(admin.ModelAdmin):
|
|||||||
search_fields = ("title",)
|
search_fields = ("title",)
|
||||||
inlines = [
|
inlines = [
|
||||||
ScrobbleInline,
|
ScrobbleInline,
|
||||||
|
HistoricalWebPageInline,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(HistoricalWebPage)
|
||||||
|
class HistoricalWebPageAdmin(admin.ModelAdmin):
|
||||||
|
date_hierarchy = "created"
|
||||||
|
list_display = (
|
||||||
|
"uuid",
|
||||||
|
"webpage",
|
||||||
|
"date",
|
||||||
|
"domain",
|
||||||
|
"created",
|
||||||
|
)
|
||||||
|
raw_id_fields = ("webpage", "domain")
|
||||||
|
ordering = ("-created",)
|
||||||
|
search_fields = ("webpage__title",)
|
||||||
|
|||||||
71
vrobbler/apps/webpages/migrations/0010_historicalwebpage.py
Normal file
71
vrobbler/apps/webpages/migrations/0010_historicalwebpage.py
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
# Generated by Django 4.2.29 on 2026-06-16 13:39
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django_extensions.db.fields
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("webpages", "0009_alter_webpage_genre"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="HistoricalWebPage",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"created",
|
||||||
|
django_extensions.db.fields.CreationDateTimeField(
|
||||||
|
auto_now_add=True, verbose_name="created"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"modified",
|
||||||
|
django_extensions.db.fields.ModificationDateTimeField(
|
||||||
|
auto_now=True, verbose_name="modified"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"uuid",
|
||||||
|
models.UUIDField(
|
||||||
|
blank=True, default=uuid.uuid4, editable=False, null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("date", models.DateField(blank=True, null=True)),
|
||||||
|
("extract", models.TextField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"domain",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||||
|
to="webpages.domain",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"webpage",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="historical_webpages",
|
||||||
|
to="webpages.webpage",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"get_latest_by": "modified",
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -17,6 +17,7 @@ from htmldate import find_date
|
|||||||
from imagekit.models import ImageSpecField
|
from imagekit.models import ImageSpecField
|
||||||
from imagekit.processors import ResizeToFit
|
from imagekit.processors import ResizeToFit
|
||||||
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
||||||
|
from scrobbles.tasks import push_scrobble_to_archivebox
|
||||||
from taggit.managers import TaggableManager
|
from taggit.managers import TaggableManager
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -130,8 +131,7 @@ class WebPage(ScrobblableMixin):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
scrobble = Scrobble.create_or_update(self, user_id, scrobble_data)
|
scrobble = Scrobble.create_or_update(self, user_id, scrobble_data)
|
||||||
# TODO Possibly make this async?
|
push_scrobble_to_archivebox.delay(scrobble.id)
|
||||||
scrobble.push_to_archivebox()
|
|
||||||
return scrobble
|
return scrobble
|
||||||
|
|
||||||
def scrobbles(self, user):
|
def scrobbles(self, user):
|
||||||
@ -183,7 +183,13 @@ class WebPage(ScrobblableMixin):
|
|||||||
if save:
|
if save:
|
||||||
self.save(update_fields=["date"])
|
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/")
|
login_url = requests.compat.urljoin(url, "admin/login/")
|
||||||
session = requests.Session()
|
session = requests.Session()
|
||||||
response = session.get(login_url)
|
response = session.get(login_url)
|
||||||
@ -297,4 +303,41 @@ class WebPage(ScrobblableMixin):
|
|||||||
if not webpage:
|
if not webpage:
|
||||||
webpage = cls(url=data_dict.get("url"))
|
webpage = cls(url=data_dict.get("url"))
|
||||||
webpage.fetch_data_from_web(save=True)
|
webpage.fetch_data_from_web(save=True)
|
||||||
|
else:
|
||||||
|
webpage._archive_and_refetch()
|
||||||
return webpage
|
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)
|
||||||
|
|||||||
@ -134,6 +134,10 @@ CELERY_BEAT_SCHEDULE = {
|
|||||||
"task": "scrobbles.tasks.rebuild_yearly_charts",
|
"task": "scrobbles.tasks.rebuild_yearly_charts",
|
||||||
"schedule": crontab(hour=0, minute=30, day_of_month=1, month_of_year=1),
|
"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 ─────────────────────────────────────────────
|
# ── Crontab replacements ─────────────────────────────────────────────
|
||||||
"database-backup": {
|
"database-backup": {
|
||||||
"task": "scrobbles.tasks.backup_database",
|
"task": "scrobbles.tasks.backup_database",
|
||||||
@ -192,6 +196,7 @@ INSTALLED_APPS = [
|
|||||||
"scrobbles",
|
"scrobbles",
|
||||||
"people",
|
"people",
|
||||||
"charts",
|
"charts",
|
||||||
|
"trends",
|
||||||
"videos",
|
"videos",
|
||||||
"music",
|
"music",
|
||||||
"podcasts",
|
"podcasts",
|
||||||
|
|||||||
@ -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.people import urls as people_urls
|
||||||
from vrobbler.apps.charts import urls as charts_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
|
# from vrobbler.apps.modern_ui import urls as modern_ui_urls
|
||||||
|
|
||||||
@ -182,6 +183,7 @@ urlpatterns = [
|
|||||||
path("", include(profiles_urls, namespace="profiles")),
|
path("", include(profiles_urls, namespace="profiles")),
|
||||||
path("", include(people_urls, namespace="people")),
|
path("", include(people_urls, namespace="people")),
|
||||||
path("", include(charts_urls, namespace="charts")),
|
path("", include(charts_urls, namespace="charts")),
|
||||||
|
path("", include(trends_urls, namespace="trends")),
|
||||||
path("", scrobbles_views.RecentScrobbleList.as_view(), name="vrobbler-home"),
|
path("", scrobbles_views.RecentScrobbleList.as_view(), name="vrobbler-home"),
|
||||||
]
|
]
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
|
|||||||
Reference in New Issue
Block a user