Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a28d0675b | |||
| 5f6e75b14e | |||
| a96a42cdbf | |||
| c7f5d7d384 |
60
PROJECT.org
60
PROJECT.org
@ -88,7 +88,7 @@ fetching and simple saving.
|
||||
*** Metadata sources
|
||||
**** Scraper
|
||||
|
||||
* Backlog [0/14] :vrobbler:project:personal:
|
||||
* Backlog [0/16] :vrobbler:project:personal:
|
||||
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :vrobbler:personal:bug:music:scrobbles:
|
||||
:PROPERTIES:
|
||||
:ID: 702462cf-d54b-48c6-8a7c-78b8de751deb
|
||||
@ -518,6 +518,64 @@ favorited media objects.
|
||||
:PROPERTIES:
|
||||
:ID: 79f867c3-1288-4143-b6bf-2a452983ee9f
|
||||
:END:
|
||||
** TODO [#C] Implement loguru into project :feature:loguru:logging:
|
||||
:PROPERTIES:
|
||||
:ID: efcd0c0a-db81-4518-9c23-5505d59e8ef5
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Would be great to formalize how we log so we can search for errors and such more
|
||||
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
|
||||
- ...
|
||||
|
||||
* Version 51.4 [1/1]
|
||||
** DONE [#A] Clean up metadata comicbook enrichment :bug:comics:books:metadata:
|
||||
:PROPERTIES:
|
||||
:ID: cd875450-7117-78ca-8be4-9c8b73037dba
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Still getting wonky results with some comicbooks. Would be nice to be able to
|
||||
tag a Book as a comicbook, and also gather volume information. I also noticed
|
||||
that some books that are found in OL never get their comicvine_id populated. We
|
||||
should make sure we always have comicvine_ids if available.
|
||||
|
||||
|
||||
* Version 51.3 [1/1]
|
||||
** DONE [#A] Improve speed of index and chart pages :bug:scrobbles:perf:
|
||||
:PROPERTIES:
|
||||
:ID: 031a23f8-7c02-4926-9884-6654ceca16c2
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Over the last few releases, the home page and charts pages have gotten really
|
||||
slow.
|
||||
|
||||
We should look into what's causing the slowness and maybe do more agressive
|
||||
query optimization or caching.
|
||||
|
||||
|
||||
* Version 51.2 [2/2]
|
||||
** DONE [#A] Fix bug where last page of book gets separate scrobble :bug:books:importers:koreader:
|
||||
:PROPERTIES:
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "vrobbler"
|
||||
version = "51.2"
|
||||
version = "51.4"
|
||||
description = ""
|
||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||
|
||||
|
||||
@ -160,7 +160,10 @@ class Command(BaseCommand):
|
||||
)
|
||||
|
||||
def _enrich_book(self, book, sleep_secs):
|
||||
from books.sources.comicvine import lookup_comic_from_comicvine
|
||||
from books.sources.comicvine import (
|
||||
lookup_comic_from_comicvine,
|
||||
lookup_issue_by_comicvine_id,
|
||||
)
|
||||
from books.sources.google import lookup_book_from_google
|
||||
from books.sources.openlibrary import lookup_book_from_openlibrary as lookup_book_from_ol
|
||||
|
||||
@ -168,13 +171,13 @@ class Command(BaseCommand):
|
||||
author_name = book.author.name if book.author else None
|
||||
book_dict = {}
|
||||
|
||||
is_comic = bool(book.readcomics_url) or (
|
||||
book.issue_number is not None or book.volume_number is not None
|
||||
)
|
||||
if is_comic and READCOMICSONLINE_URL in (book.readcomics_url or ""):
|
||||
cv_data = None
|
||||
if book.comicvine_id:
|
||||
cv_data = lookup_issue_by_comicvine_id(str(book.comicvine_id))
|
||||
if not cv_data:
|
||||
cv_data = lookup_comic_from_comicvine(title)
|
||||
if cv_data:
|
||||
book_dict.update(cv_data)
|
||||
if cv_data:
|
||||
book_dict.update(cv_data)
|
||||
|
||||
ol_data = lookup_book_from_ol(title, author=author_name)
|
||||
time.sleep(sleep_secs)
|
||||
@ -261,6 +264,14 @@ class Command(BaseCommand):
|
||||
book.volume_number = data["volume_number"]
|
||||
update_fields.append("volume_number")
|
||||
|
||||
if data.get("volume") and not book.volume:
|
||||
book.volume = data["volume"]
|
||||
update_fields.append("volume")
|
||||
|
||||
if data.get("volume_comicvine_id") and not book.volume_comicvine_id:
|
||||
book.volume_comicvine_id = data["volume_comicvine_id"]
|
||||
update_fields.append("volume_comicvine_id")
|
||||
|
||||
if update_fields:
|
||||
book.save(update_fields=update_fields)
|
||||
self.stdout.write(f" [ENRICHED] {book} — {', '.join(update_fields)}")
|
||||
@ -279,4 +290,12 @@ class Command(BaseCommand):
|
||||
book.genre.add(*new_genres)
|
||||
self.stdout.write(f" [GENRES] {book} — added {len(new_genres)} genres")
|
||||
|
||||
tags = data.pop("tags", [])
|
||||
if tags:
|
||||
existing_tags = set(book.tags.names())
|
||||
new_tags = [t for t in tags if t not in existing_tags]
|
||||
if new_tags:
|
||||
book.tags.add(*new_tags)
|
||||
self.stdout.write(f" [TAGS] {book} — added {', '.join(new_tags)}")
|
||||
|
||||
return changed if any(changed.values()) else None
|
||||
|
||||
@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.2.29 on 2026-06-15 16:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("books", "0036_alter_book_genre_alter_paper_genre"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="book",
|
||||
name="volume",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="book",
|
||||
name="volume_comicvine_id",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
]
|
||||
@ -20,6 +20,7 @@ from books.openlibrary import (
|
||||
from books.sources.comicvine import (
|
||||
ComicVineClient,
|
||||
lookup_comic_from_comicvine,
|
||||
lookup_issue_by_comicvine_id,
|
||||
)
|
||||
from books.sources.google import lookup_book_from_google
|
||||
from books.sources.openlibrary import (
|
||||
@ -150,6 +151,8 @@ class Book(LongPlayScrobblableMixin):
|
||||
first_sentence = models.TextField(**BNULL)
|
||||
# ComicVine
|
||||
comicvine_id = models.CharField(max_length=255, **BNULL)
|
||||
volume = models.CharField(max_length=255, **BNULL)
|
||||
volume_comicvine_id = models.CharField(max_length=255, **BNULL)
|
||||
readcomics_url = models.CharField(max_length=255, **BNULL)
|
||||
next_readcomics_url = models.CharField(max_length=255, **BNULL)
|
||||
issue_number = models.IntegerField(**BNULL)
|
||||
@ -236,13 +239,17 @@ class Book(LongPlayScrobblableMixin):
|
||||
if not book_dict:
|
||||
return book
|
||||
|
||||
tags = book_dict.pop("tags", [])
|
||||
genres = book_dict.pop("genres", book_dict.pop("generes", []))
|
||||
|
||||
for k, v in book_dict.items():
|
||||
setattr(book, k, v)
|
||||
book.save()
|
||||
|
||||
genres = book_dict.get("genres", [])
|
||||
if genres:
|
||||
book.genre.add(*genres)
|
||||
if tags:
|
||||
book.tags.add(*tags)
|
||||
return book
|
||||
|
||||
@classmethod
|
||||
@ -276,8 +283,10 @@ class Book(LongPlayScrobblableMixin):
|
||||
|
||||
book_dict = None
|
||||
source_tag = None
|
||||
tried_comicvine = False
|
||||
if READCOMICSONLINE_URL in url:
|
||||
book_dict = lookup_comic_from_comicvine(title)
|
||||
tried_comicvine = True
|
||||
if book_dict:
|
||||
source_tag = MediaSourceTag.COMICVINE
|
||||
book_dict["readcomics_url"] = get_comic_issue_url(url)
|
||||
@ -302,6 +311,16 @@ class Book(LongPlayScrobblableMixin):
|
||||
if ol_data and ol_data.get("cover_url"):
|
||||
book_dict["cover_url"] = ol_data["cover_url"]
|
||||
|
||||
# Always try ComicVine as a fallback — it may recognize books that
|
||||
# OL/Google don't flag as comics
|
||||
if not tried_comicvine:
|
||||
cv_data = lookup_comic_from_comicvine(title)
|
||||
if cv_data:
|
||||
for k, v in cv_data.items():
|
||||
if v:
|
||||
book_dict.setdefault(k, v)
|
||||
source_tag = MediaSourceTag.COMICVINE
|
||||
|
||||
if not book_dict:
|
||||
logger.warning(
|
||||
"No book found in any source, using data as is",
|
||||
@ -312,6 +331,7 @@ class Book(LongPlayScrobblableMixin):
|
||||
authors = book_dict.pop("authors", [])
|
||||
cover_url = book_dict.pop("cover_url", "")
|
||||
genres = book_dict.pop("genres", book_dict.pop("generes", []))
|
||||
tags = book_dict.pop("tags", [])
|
||||
|
||||
if authors:
|
||||
for author_str in authors:
|
||||
@ -331,6 +351,8 @@ class Book(LongPlayScrobblableMixin):
|
||||
book.save_image_from_url(cover_url)
|
||||
if genres:
|
||||
book.genre.add(*genres)
|
||||
if tags:
|
||||
book.tags.add(*tags)
|
||||
book.authors.add(*author_list)
|
||||
if source_tag:
|
||||
book.tags.add(source_tag.value)
|
||||
@ -368,8 +390,14 @@ class Book(LongPlayScrobblableMixin):
|
||||
data = lookup_comic_from_locg(str(self.title))
|
||||
|
||||
if not data and COMICVINE_API_KEY:
|
||||
logger.warn(f"Checking ComicVine for {self.title}")
|
||||
data = lookup_comic_from_comicvine(str(self.title))
|
||||
if self.comicvine_id:
|
||||
logger.warn(
|
||||
f"Checking ComicVine by ID for {self.title}"
|
||||
)
|
||||
data = lookup_issue_by_comicvine_id(str(self.comicvine_id))
|
||||
if not data:
|
||||
logger.warn(f"Checking ComicVine for {self.title}")
|
||||
data = lookup_comic_from_comicvine(str(self.title))
|
||||
|
||||
if not data:
|
||||
logger.warn(f"Book not found in any sources: {self.title}")
|
||||
@ -407,10 +435,10 @@ class Book(LongPlayScrobblableMixin):
|
||||
)
|
||||
data.pop("pages")
|
||||
|
||||
# Pop this, so we can look it up later
|
||||
# Pop these so they don't get passed to update()
|
||||
cover_url = data.pop("cover_url", "")
|
||||
|
||||
subject_key_list = data.pop("subject_key_list", [])
|
||||
tags = data.pop("tags", [])
|
||||
|
||||
# Fun trick for updating all fields at once
|
||||
Book.objects.filter(pk=self.id).update(**data)
|
||||
@ -418,6 +446,8 @@ class Book(LongPlayScrobblableMixin):
|
||||
|
||||
if subject_key_list:
|
||||
self.genre.add(*subject_key_list)
|
||||
if tags:
|
||||
self.tags.add(*tags)
|
||||
|
||||
if cover_url:
|
||||
r = requests.get(cover_url)
|
||||
|
||||
@ -17,8 +17,10 @@ class ComicVineClient(object):
|
||||
account on https://comicvine.gamespot.com/ in order to obtain an API key.
|
||||
"""
|
||||
|
||||
# All API requests made by this client will be made to this URL.
|
||||
# All API requests made by this client will be made to these URLs.
|
||||
API_URL = "https://comicvine.gamespot.com/api/search/"
|
||||
ISSUE_API_URL = "https://comicvine.gamespot.com/api/issue/4000-{issue_id}/"
|
||||
VOLUME_API_URL = "https://comicvine.gamespot.com/api/volume/4050-{volume_id}/"
|
||||
|
||||
# A valid User-Agent header must be set in order for our API requests to
|
||||
# be accepted, otherwise our request will be rejected with a
|
||||
@ -197,6 +199,74 @@ class ComicVineClient(object):
|
||||
|
||||
raise exception(message)
|
||||
|
||||
def get_issue(self, issue_id: str) -> dict:
|
||||
"""
|
||||
Fetch a single issue by its ComicVine ID directly from the issue detail
|
||||
endpoint, which returns richer data than the search endpoint.
|
||||
|
||||
:param issue_id: The ComicVine numeric ID for the issue (e.g. "538480")
|
||||
:type issue_id: str
|
||||
|
||||
:return: The full JSON response for the issue, or empty dict on failure.
|
||||
:rtype: dict
|
||||
"""
|
||||
params = {
|
||||
"api_key": self.api_key,
|
||||
"format": "json",
|
||||
}
|
||||
url = self.ISSUE_API_URL.format(issue_id=issue_id)
|
||||
response = requests.get(url, headers=self.HEADERS, params=params)
|
||||
|
||||
if not response.ok:
|
||||
self._handle_http_error(response)
|
||||
|
||||
json_data = response.json()
|
||||
|
||||
if json_data.get("status_code") != 1:
|
||||
error_msg = json_data.get("error", "Unknown ComicVine API error")
|
||||
logger.error(
|
||||
"ComicVine API returned status_code %s: %s",
|
||||
json_data.get("status_code"),
|
||||
error_msg,
|
||||
)
|
||||
return {}
|
||||
|
||||
return json_data.get("results", {})
|
||||
|
||||
def get_volume(self, volume_id: str) -> dict:
|
||||
"""
|
||||
Fetch a single volume by its ComicVine ID from the volume detail
|
||||
endpoint. Used to get publisher info and other volume-level metadata.
|
||||
|
||||
:param volume_id: The ComicVine numeric ID for the volume (e.g. "91273")
|
||||
:type volume_id: str
|
||||
|
||||
:return: The full JSON response for the volume, or empty dict on failure.
|
||||
:rtype: dict
|
||||
"""
|
||||
params = {
|
||||
"api_key": self.api_key,
|
||||
"format": "json",
|
||||
}
|
||||
url = self.VOLUME_API_URL.format(volume_id=volume_id)
|
||||
response = requests.get(url, headers=self.HEADERS, params=params)
|
||||
|
||||
if not response.ok:
|
||||
self._handle_http_error(response)
|
||||
|
||||
json_data = response.json()
|
||||
|
||||
if json_data.get("status_code") != 1:
|
||||
error_msg = json_data.get("error", "Unknown ComicVine API error")
|
||||
logger.error(
|
||||
"ComicVine API returned status_code %s: %s",
|
||||
json_data.get("status_code"),
|
||||
error_msg,
|
||||
)
|
||||
return {}
|
||||
|
||||
return json_data.get("results", {})
|
||||
|
||||
|
||||
def lookup_comic_from_comicvine(title: str) -> dict:
|
||||
original_title = title
|
||||
@ -238,26 +308,113 @@ def lookup_comic_from_comicvine(title: str) -> dict:
|
||||
if not found_result:
|
||||
found_result = results[0]
|
||||
|
||||
title = found_result.get("name")
|
||||
data_dict = _build_data_dict_from_issue(found_result, original_title)
|
||||
_enrich_with_volume_data(client, data_dict)
|
||||
return data_dict
|
||||
|
||||
if found_result.get("volume"):
|
||||
title = found_result.get("volume").get("name")
|
||||
|
||||
def lookup_issue_by_comicvine_id(comicvine_id: str) -> dict:
|
||||
"""
|
||||
Look up an issue directly by its ComicVine ID using the issue detail
|
||||
endpoint. Returns richer data than the search-based lookup.
|
||||
|
||||
:param comicvine_id: The ComicVine numeric ID for the issue (e.g. "538480")
|
||||
:type comicvine_id: str
|
||||
|
||||
:return: A dict of extracted book metadata, or empty dict on failure.
|
||||
:rtype: dict
|
||||
"""
|
||||
if not comicvine_id:
|
||||
return {}
|
||||
|
||||
api_key = getattr(settings, "COMICVINE_API_KEY", "")
|
||||
if not api_key:
|
||||
logger.warning("No ComicVine API key configured, not looking anything up")
|
||||
return {}
|
||||
|
||||
client = ComicVineClient(api_key=api_key)
|
||||
issue_data = client.get_issue(comicvine_id)
|
||||
if not issue_data:
|
||||
logger.warning("No issue found on ComicVine for ID %s", comicvine_id)
|
||||
return {}
|
||||
|
||||
data_dict = _build_data_dict_from_issue(issue_data, issue_data.get("name", ""))
|
||||
_enrich_with_volume_data(client, data_dict)
|
||||
return data_dict
|
||||
|
||||
|
||||
def _build_data_dict_from_issue(issue_data: dict, original_title: str = "") -> dict:
|
||||
"""
|
||||
Build a book metadata dict from a ComicVine issue resource (either from
|
||||
search results or issue detail endpoint). Both return the same shape of
|
||||
issue data.
|
||||
|
||||
:param issue_data: The issue resource dict from ComicVine.
|
||||
:param original_title: The original search term, if any.
|
||||
|
||||
:return: A dict of extracted book metadata.
|
||||
:rtype: dict
|
||||
"""
|
||||
title = issue_data.get("name")
|
||||
if issue_data.get("volume"):
|
||||
title = issue_data.get("volume").get("name")
|
||||
|
||||
cover_url = None
|
||||
if found_result.get("image"):
|
||||
cover_url = found_result["image"].get("original_url")
|
||||
if issue_data.get("image"):
|
||||
cover_url = issue_data["image"].get("original_url")
|
||||
|
||||
volume_name = None
|
||||
volume_cv_id = None
|
||||
publisher_name = None
|
||||
volume_data = issue_data.get("volume")
|
||||
if volume_data:
|
||||
volume_name = volume_data.get("name")
|
||||
volume_cv_id = volume_data.get("id")
|
||||
publisher_data = volume_data.get("publisher")
|
||||
if publisher_data:
|
||||
publisher_name = publisher_data.get("name")
|
||||
|
||||
data_dict = {
|
||||
"title": title,
|
||||
"original_title": original_title,
|
||||
"issue_number": found_result.get("issue_number"),
|
||||
"volume_number": found_result.get("volume_number"),
|
||||
"issue_number": issue_data.get("issue_number"),
|
||||
"volume_number": issue_data.get("volume_number"),
|
||||
"volume": volume_name,
|
||||
"volume_comicvine_id": volume_cv_id,
|
||||
"publisher": publisher_name,
|
||||
"cover_url": cover_url,
|
||||
"comicvine_id": found_result.get("id"),
|
||||
"comicvine_data": found_result,
|
||||
"summary": found_result.get("description"),
|
||||
"publish_date": found_result.get("cover_date"),
|
||||
"first_publish_year": (found_result.get("cover_date") or "")[:4],
|
||||
"comicvine_id": issue_data.get("id"),
|
||||
"summary": issue_data.get("description"),
|
||||
"publish_date": issue_data.get("cover_date"),
|
||||
"first_publish_year": (issue_data.get("cover_date") or "")[:4],
|
||||
"tags": ["comicbook"],
|
||||
}
|
||||
|
||||
return data_dict
|
||||
|
||||
|
||||
def _enrich_with_volume_data(client: ComicVineClient, data_dict: dict) -> None:
|
||||
"""
|
||||
Follow-up a successful issue lookup by fetching the volume detail and
|
||||
filling in publisher and other volume-level metadata that the issue
|
||||
endpoint doesn't provide.
|
||||
|
||||
:param client: An initialised ComicVineClient instance.
|
||||
:param data_dict: The data dict from an issue lookup (mutated in place).
|
||||
"""
|
||||
volume_cv_id = data_dict.get("volume_comicvine_id")
|
||||
if not volume_cv_id:
|
||||
return
|
||||
|
||||
volume_data = client.get_volume(str(volume_cv_id))
|
||||
if not volume_data:
|
||||
return
|
||||
|
||||
publisher_data = volume_data.get("publisher")
|
||||
if publisher_data:
|
||||
publisher_name = publisher_data.get("name")
|
||||
if publisher_name and not data_dict.get("publisher"):
|
||||
data_dict["publisher"] = publisher_name
|
||||
|
||||
if not data_dict.get("volume"):
|
||||
data_dict["volume"] = volume_data.get("name")
|
||||
|
||||
@ -0,0 +1,146 @@
|
||||
# Generated by Django 4.2.29 on 2026-06-12 16:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("charts", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "month", "album", "rank"],
|
||||
name="charts_char_user_id_1adcde_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "month", "track", "rank"],
|
||||
name="charts_char_user_id_d18aab_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "month", "video", "rank"],
|
||||
name="charts_char_user_id_de9f0a_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "month", "board_game", "rank"],
|
||||
name="charts_char_user_id_d5d58f_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "month", "book", "rank"],
|
||||
name="charts_char_user_id_e877cf_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "month", "food", "rank"],
|
||||
name="charts_char_user_id_a0ad71_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "month", "podcast", "rank"],
|
||||
name="charts_char_user_id_846b80_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "month", "trail", "rank"],
|
||||
name="charts_char_user_id_54feba_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "week", "album", "rank"],
|
||||
name="charts_char_user_id_a3dc49_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "week", "track", "rank"],
|
||||
name="charts_char_user_id_4b01ab_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "week", "video", "rank"],
|
||||
name="charts_char_user_id_2ac9d2_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "week", "board_game", "rank"],
|
||||
name="charts_char_user_id_ba968a_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "week", "book", "rank"],
|
||||
name="charts_char_user_id_e66751_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "week", "food", "rank"],
|
||||
name="charts_char_user_id_d23f06_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "week", "podcast", "rank"],
|
||||
name="charts_char_user_id_be8122_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "week", "trail", "rank"],
|
||||
name="charts_char_user_id_b94ea9_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "month", "day", "artist", "rank"],
|
||||
name="charts_char_user_id_406e0e_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "month", "day", "album", "rank"],
|
||||
name="charts_char_user_id_322b0d_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "month", "day", "tv_series", "rank"],
|
||||
name="charts_char_user_id_aa44b7_idx",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -60,10 +60,29 @@ class ChartRecord(TimeStampedModel):
|
||||
models.Index(fields=["user", "year", "geo_location", "rank"]),
|
||||
models.Index(fields=["user", "year", "food", "rank"]),
|
||||
models.Index(fields=["user", "year", "book", "rank"]),
|
||||
models.Index(fields=["user", "year", "week", "artist", "rank"]),
|
||||
models.Index(fields=["user", "year", "week", "tv_series", "rank"]),
|
||||
models.Index(fields=["user", "year", "month", "artist", "rank"]),
|
||||
models.Index(fields=["user", "year", "month", "album", "rank"]),
|
||||
models.Index(fields=["user", "year", "month", "track", "rank"]),
|
||||
models.Index(fields=["user", "year", "month", "tv_series", "rank"]),
|
||||
models.Index(fields=["user", "year", "month", "video", "rank"]),
|
||||
models.Index(fields=["user", "year", "month", "board_game", "rank"]),
|
||||
models.Index(fields=["user", "year", "month", "book", "rank"]),
|
||||
models.Index(fields=["user", "year", "month", "food", "rank"]),
|
||||
models.Index(fields=["user", "year", "month", "podcast", "rank"]),
|
||||
models.Index(fields=["user", "year", "month", "trail", "rank"]),
|
||||
models.Index(fields=["user", "year", "week", "artist", "rank"]),
|
||||
models.Index(fields=["user", "year", "week", "album", "rank"]),
|
||||
models.Index(fields=["user", "year", "week", "track", "rank"]),
|
||||
models.Index(fields=["user", "year", "week", "tv_series", "rank"]),
|
||||
models.Index(fields=["user", "year", "week", "video", "rank"]),
|
||||
models.Index(fields=["user", "year", "week", "board_game", "rank"]),
|
||||
models.Index(fields=["user", "year", "week", "book", "rank"]),
|
||||
models.Index(fields=["user", "year", "week", "food", "rank"]),
|
||||
models.Index(fields=["user", "year", "week", "podcast", "rank"]),
|
||||
models.Index(fields=["user", "year", "week", "trail", "rank"]),
|
||||
models.Index(fields=["user", "year", "month", "day", "artist", "rank"]),
|
||||
models.Index(fields=["user", "year", "month", "day", "album", "rank"]),
|
||||
models.Index(fields=["user", "year", "month", "day", "tv_series", "rank"]),
|
||||
]
|
||||
|
||||
@property
|
||||
|
||||
@ -438,12 +438,14 @@ class ChartRecordView(TemplateView):
|
||||
return context
|
||||
|
||||
def get_available_years(self, user):
|
||||
return list(
|
||||
ChartRecord.objects.filter(user=user)
|
||||
.values_list("year", flat=True)
|
||||
.distinct()
|
||||
.order_by("-year")
|
||||
)
|
||||
if not hasattr(self, "_available_years"):
|
||||
self._available_years = list(
|
||||
ChartRecord.objects.filter(user=user)
|
||||
.values_list("year", flat=True)
|
||||
.distinct()
|
||||
.order_by("-year")
|
||||
)
|
||||
return self._available_years
|
||||
|
||||
def get_period_type(self):
|
||||
date_param = self.request.GET.get("date")
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
# Generated by Django 4.2.29 on 2026-06-12 16:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("scrobbles", "0096_convert_book_page_data_to_dict"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name="scrobble",
|
||||
index=models.Index(
|
||||
fields=["user", "-timestamp"], name="scrobbles_s_user_id_d367a7_idx"
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -612,6 +612,30 @@ class EBirdCSVImport(BaseFileImportMixin):
|
||||
self.mark_finished()
|
||||
|
||||
|
||||
TYPE_FK_PREFETCHES: dict[str, tuple[str, ...]] = {
|
||||
"Video": ("video",),
|
||||
"Track": ("track", "track__artist_fk"),
|
||||
"PodcastEpisode": ("podcast_episode", "podcast_episode__podcast"),
|
||||
"SportEvent": ("sport_event",),
|
||||
"Book": ("book",),
|
||||
"Paper": ("paper",),
|
||||
"VideoGame": ("video_game",),
|
||||
"BoardGame": ("board_game",),
|
||||
"GeoLocation": ("geo_location",),
|
||||
"Trail": ("trail",),
|
||||
"Beer": ("beer",),
|
||||
"Puzzle": ("puzzle",),
|
||||
"Food": ("food",),
|
||||
"Task": ("task",),
|
||||
"WebPage": ("web_page",),
|
||||
"LifeEvent": ("life_event",),
|
||||
"Mood": ("mood",),
|
||||
"BrickSet": ("brick_set",),
|
||||
"Channel": ("channel",),
|
||||
"BirdingLocation": ("birding_location",),
|
||||
}
|
||||
|
||||
|
||||
class ScrobbleQuerySet(models.QuerySet):
|
||||
def with_related(self):
|
||||
return self.select_related("user").prefetch_related(
|
||||
@ -638,6 +662,13 @@ class ScrobbleQuerySet(models.QuerySet):
|
||||
"birding_location",
|
||||
)
|
||||
|
||||
def with_related_for_types(self, media_types: list[str]):
|
||||
prefetches = []
|
||||
for t in media_types:
|
||||
if t in TYPE_FK_PREFETCHES:
|
||||
prefetches.extend(TYPE_FK_PREFETCHES[t])
|
||||
return self.select_related("user").prefetch_related(*prefetches)
|
||||
|
||||
|
||||
class ShareViewLog(TimeStampedModel):
|
||||
scrobble = models.ForeignKey(
|
||||
@ -778,6 +809,7 @@ class Scrobble(TimeStampedModel):
|
||||
"is_paused",
|
||||
]
|
||||
),
|
||||
models.Index(fields=["user", "-timestamp"]),
|
||||
]
|
||||
|
||||
@classmethod
|
||||
@ -810,11 +842,14 @@ class Scrobble(TimeStampedModel):
|
||||
@classmethod
|
||||
def as_dict_by_type(cls, scrobble_qs: models.QuerySet) -> dict:
|
||||
scrobbles_by_type = defaultdict(list)
|
||||
scrobbles = (
|
||||
scrobble_qs.with_related()
|
||||
if hasattr(scrobble_qs, "with_related")
|
||||
else scrobble_qs
|
||||
)
|
||||
|
||||
if hasattr(scrobble_qs, "with_related"):
|
||||
media_types_present = list(
|
||||
scrobble_qs.values_list("media_type", flat=True).distinct()
|
||||
)
|
||||
scrobbles = scrobble_qs.with_related_for_types(media_types_present)
|
||||
else:
|
||||
scrobbles = scrobble_qs
|
||||
|
||||
for scrobble in scrobbles:
|
||||
scrobbles_by_type[scrobble.media_type].append(scrobble)
|
||||
@ -829,7 +864,7 @@ class Scrobble(TimeStampedModel):
|
||||
|
||||
# Remove any locations without titles
|
||||
if "GeoLocation" in scrobbles_by_type.keys():
|
||||
for loc_scrobble in scrobbles_by_type["GeoLocation"]:
|
||||
for loc_scrobble in list(scrobbles_by_type["GeoLocation"]):
|
||||
if not loc_scrobble.media_obj.title:
|
||||
scrobbles_by_type["GeoLocation"].remove(loc_scrobble)
|
||||
scrobbles_by_type["GeoLocation_count"] -= 1
|
||||
|
||||
Reference in New Issue
Block a user