Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 407d570c82 | |||
| 033239260f | |||
| 9f854dc735 | |||
| f29272a853 | |||
| 4e56d9420a | |||
| 852a257159 | |||
| 68ff230f13 | |||
| 57a952a6d1 | |||
| 718fcf7392 |
63
PROJECT.org
63
PROJECT.org
@ -88,7 +88,7 @@ fetching and simple saving.
|
|||||||
*** Metadata sources
|
*** Metadata sources
|
||||||
**** Scraper
|
**** Scraper
|
||||||
|
|
||||||
* Backlog [0/21] :vrobbler:project:personal:
|
* Backlog [0/22] :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
|
||||||
@ -579,6 +579,18 @@ named constants for maintainability.
|
|||||||
- ~vrobbler/apps/scrobbles/importers/tsv.py~ (line 55) -- ="S"= completion status
|
- ~vrobbler/apps/scrobbles/importers/tsv.py~ (line 55) -- ="S"= completion status
|
||||||
|
|
||||||
|
|
||||||
|
** TODO [#A] Deduplicate BGG plays before posting :boardgames:bgg:duplication:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: e9b842bf-0049-42e7-a060-f3ebd0067d2f
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
No check for existing BGG plays before posting, which can create duplicates.
|
||||||
|
Should look up past plays by =bggeek_id= first.
|
||||||
|
|
||||||
|
File: ~vrobbler/apps/boardgames/bgg.py~ (line 117)
|
||||||
|
|
||||||
** TODO [#C] Clean up naming of =bgsplay= parsing :importers:refactoring:
|
** TODO [#C] Clean up naming of =bgsplay= parsing :importers:refactoring:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: c751dbbc-464a-4e63-9fe3-e034303f7b54
|
:ID: c751dbbc-464a-4e63-9fe3-e034303f7b54
|
||||||
@ -591,6 +603,55 @@ a helper method to create board game scrobbles given a json blob. It's
|
|||||||
independent of the email flow it was originally creatdd for
|
independent of the email flow it was originally creatdd for
|
||||||
|
|
||||||
** TODO [#B] Is there way to create unique slugs for media instances :media_types:
|
** TODO [#B] Is there way to create unique slugs for media instances :media_types:
|
||||||
|
|
||||||
|
* Version 55.4 [1/1]
|
||||||
|
** DONE [#A] Tighten up the speed of startup and first request :perf:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 9ee8834c-6be2-d04b-df6d-56375504083f
|
||||||
|
:END:
|
||||||
|
|
||||||
|
|
||||||
|
* Version 55.3 [3/3]
|
||||||
|
** DONE [#C] =alt_names= feature for artists (commented out / dead code) :music:dead-code:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: e22060a2-5f7a-4f33-9056-309ecd27159c
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
File: ~vrobbler/apps/music/models.py~ (line 236)
|
||||||
|
|
||||||
|
An entire block of code for tracking alternate artist names is commented
|
||||||
|
out. The TODO questions whether it even works. Review: either implement
|
||||||
|
properly or remove the dead code.
|
||||||
|
|
||||||
|
** DONE [#A] Put chart rebuilds in a lower priority task queue :charts:tasks:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 43c90de0-fc1c-1139-dac7-9b7c82006b2e
|
||||||
|
:END:
|
||||||
|
** DONE [#A] Check for existing book scrobble and update page count :books:scrobbling:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 1a0609bc-6b16-4da4-96c1-59588229e4b4
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
File: ~vrobbler/apps/scrobbles/scrobblers.py~ (line 330)
|
||||||
|
|
||||||
|
When scrobbling a book (comic), the code doesn't check for prior scrobbles to
|
||||||
|
update reading progress. Needed for proper page-count tracking.
|
||||||
|
|
||||||
|
|
||||||
|
* Version 55.2 [2/2]
|
||||||
|
** DONE [#A] Fix bug in scrobble id in calendar view :templates:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 8cb34852-b18f-e794-cd9b-fb1ecad70a0d
|
||||||
|
:END:
|
||||||
|
** DONE [#A] Video game cleanup script should clear out broken images :metadata:videogames:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: ca1f1ea9-0f79-082c-5ff7-867671faff4b
|
||||||
|
:END:
|
||||||
|
|
||||||
* Version 55.1 [1/1]
|
* Version 55.1 [1/1]
|
||||||
** DONE [#A] Clean up metadata scrapping for video games :metadata:videogames:
|
** DONE [#A] Clean up metadata scrapping for video games :metadata:videogames:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
|
|||||||
2
Procfile
2
Procfile
@ -1,2 +1,2 @@
|
|||||||
web: python manage.py runserver 0.0.0.0:8014
|
web: python manage.py runserver 0.0.0.0:8014
|
||||||
worker: celery -A vrobbler worker -l DEBUG
|
worker: celery -A vrobbler worker -Q default,charts -l DEBUG
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "vrobbler"
|
name = "vrobbler"
|
||||||
version = "55.1"
|
version = "55.4"
|
||||||
description = ""
|
description = ""
|
||||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||||
|
|
||||||
|
|||||||
@ -4,9 +4,15 @@ from unittest.mock import MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from vrobbler import context_processors
|
||||||
from vrobbler.context_processors import version_info
|
from vrobbler.context_processors import version_info
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_git_cache():
|
||||||
|
context_processors._GIT_COMMIT = None
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_request():
|
def mock_request():
|
||||||
return MagicMock()
|
return MagicMock()
|
||||||
|
|||||||
@ -0,0 +1,109 @@
|
|||||||
|
# Generated by Django 4.2.29 on 2026-06-19 16:25
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("charts", "0002_chartrecord_charts_char_user_id_1adcde_idx_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="chartrecord",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(("artist__isnull", False)),
|
||||||
|
fields=("user", "year", "month", "week", "day", "artist"),
|
||||||
|
name="unique_chart_artist_period",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="chartrecord",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(("album__isnull", False)),
|
||||||
|
fields=("user", "year", "month", "week", "day", "album"),
|
||||||
|
name="unique_chart_album_period",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="chartrecord",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(("track__isnull", False)),
|
||||||
|
fields=("user", "year", "month", "week", "day", "track"),
|
||||||
|
name="unique_chart_track_period",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="chartrecord",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(("tv_series__isnull", False)),
|
||||||
|
fields=("user", "year", "month", "week", "day", "tv_series"),
|
||||||
|
name="unique_chart_tv_series_period",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="chartrecord",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(("video__isnull", False)),
|
||||||
|
fields=("user", "year", "month", "week", "day", "video"),
|
||||||
|
name="unique_chart_video_period",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="chartrecord",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(("podcast__isnull", False)),
|
||||||
|
fields=("user", "year", "month", "week", "day", "podcast"),
|
||||||
|
name="unique_chart_podcast_period",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="chartrecord",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(("podcast_episode__isnull", False)),
|
||||||
|
fields=("user", "year", "month", "week", "day", "podcast_episode"),
|
||||||
|
name="unique_chart_podcast_episode_period",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="chartrecord",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(("board_game__isnull", False)),
|
||||||
|
fields=("user", "year", "month", "week", "day", "board_game"),
|
||||||
|
name="unique_chart_board_game_period",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="chartrecord",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(("trail__isnull", False)),
|
||||||
|
fields=("user", "year", "month", "week", "day", "trail"),
|
||||||
|
name="unique_chart_trail_period",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="chartrecord",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(("geo_location__isnull", False)),
|
||||||
|
fields=("user", "year", "month", "week", "day", "geo_location"),
|
||||||
|
name="unique_chart_geo_location_period",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="chartrecord",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(("food__isnull", False)),
|
||||||
|
fields=("user", "year", "month", "week", "day", "food"),
|
||||||
|
name="unique_chart_food_period",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="chartrecord",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(("book__isnull", False)),
|
||||||
|
fields=("user", "year", "month", "week", "day", "book"),
|
||||||
|
name="unique_chart_book_period",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -2,6 +2,7 @@ import calendar
|
|||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db.models import Q
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django_extensions.db.models import TimeStampedModel
|
from django_extensions.db.models import TimeStampedModel
|
||||||
|
|
||||||
@ -84,6 +85,68 @@ class ChartRecord(TimeStampedModel):
|
|||||||
models.Index(fields=["user", "year", "month", "day", "album", "rank"]),
|
models.Index(fields=["user", "year", "month", "day", "album", "rank"]),
|
||||||
models.Index(fields=["user", "year", "month", "day", "tv_series", "rank"]),
|
models.Index(fields=["user", "year", "month", "day", "tv_series", "rank"]),
|
||||||
]
|
]
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "year", "month", "week", "day", "artist"],
|
||||||
|
condition=Q(artist__isnull=False),
|
||||||
|
name="unique_chart_artist_period",
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "year", "month", "week", "day", "album"],
|
||||||
|
condition=Q(album__isnull=False),
|
||||||
|
name="unique_chart_album_period",
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "year", "month", "week", "day", "track"],
|
||||||
|
condition=Q(track__isnull=False),
|
||||||
|
name="unique_chart_track_period",
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "year", "month", "week", "day", "tv_series"],
|
||||||
|
condition=Q(tv_series__isnull=False),
|
||||||
|
name="unique_chart_tv_series_period",
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "year", "month", "week", "day", "video"],
|
||||||
|
condition=Q(video__isnull=False),
|
||||||
|
name="unique_chart_video_period",
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "year", "month", "week", "day", "podcast"],
|
||||||
|
condition=Q(podcast__isnull=False),
|
||||||
|
name="unique_chart_podcast_period",
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "year", "month", "week", "day", "podcast_episode"],
|
||||||
|
condition=Q(podcast_episode__isnull=False),
|
||||||
|
name="unique_chart_podcast_episode_period",
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "year", "month", "week", "day", "board_game"],
|
||||||
|
condition=Q(board_game__isnull=False),
|
||||||
|
name="unique_chart_board_game_period",
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "year", "month", "week", "day", "trail"],
|
||||||
|
condition=Q(trail__isnull=False),
|
||||||
|
name="unique_chart_trail_period",
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "year", "month", "week", "day", "geo_location"],
|
||||||
|
condition=Q(geo_location__isnull=False),
|
||||||
|
name="unique_chart_geo_location_period",
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "year", "month", "week", "day", "food"],
|
||||||
|
condition=Q(food__isnull=False),
|
||||||
|
name="unique_chart_food_period",
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "year", "month", "week", "day", "book"],
|
||||||
|
condition=Q(book__isnull=False),
|
||||||
|
name="unique_chart_book_period",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def media_obj(self):
|
def media_obj(self):
|
||||||
|
|||||||
@ -6,6 +6,7 @@ from typing import Optional
|
|||||||
import pytz
|
import pytz
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.db import transaction
|
||||||
from django.db.models import Count, Q
|
from django.db.models import Count, Q
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
@ -186,60 +187,64 @@ def build_charts(
|
|||||||
ranks = {count: rank for rank, count in enumerate(unique_counts, start=1)}
|
ranks = {count: rank for rank, count in enumerate(unique_counts, start=1)}
|
||||||
|
|
||||||
media_field = f"{media_type}_id"
|
media_field = f"{media_type}_id"
|
||||||
records_to_create = []
|
|
||||||
records_to_update = []
|
|
||||||
|
|
||||||
existing = ChartRecord.objects.filter(
|
with transaction.atomic():
|
||||||
period_filter, user=user, **{media_field + "__isnull": False}
|
records_to_create = []
|
||||||
)
|
records_to_update = []
|
||||||
existing_by_media_id = {getattr(r, media_field): r for r in existing}
|
|
||||||
found_media_ids = set()
|
|
||||||
|
|
||||||
for result in results:
|
existing = ChartRecord.objects.select_for_update().filter(
|
||||||
media_id = result[config["values"]]
|
period_filter, user=user, **{media_field + "__isnull": False}
|
||||||
if media_id is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
found_media_ids.add(media_id)
|
|
||||||
|
|
||||||
chart_record_data = {
|
|
||||||
"user_id": user.id,
|
|
||||||
"year": year,
|
|
||||||
"month": month,
|
|
||||||
"week": week,
|
|
||||||
"day": day,
|
|
||||||
"rank": ranks[result["scrobble_count"]],
|
|
||||||
"count": result["scrobble_count"],
|
|
||||||
}
|
|
||||||
chart_record_data[media_field] = media_id
|
|
||||||
|
|
||||||
if media_id in existing_by_media_id:
|
|
||||||
existing_record = existing_by_media_id[media_id]
|
|
||||||
existing_record.rank = chart_record_data["rank"]
|
|
||||||
existing_record.count = chart_record_data["count"]
|
|
||||||
records_to_update.append(existing_record)
|
|
||||||
else:
|
|
||||||
records_to_create.append(ChartRecord(**chart_record_data))
|
|
||||||
|
|
||||||
ids_to_delete = [
|
|
||||||
r.id for r in existing if getattr(r, media_field) not in found_media_ids
|
|
||||||
]
|
|
||||||
if ids_to_delete:
|
|
||||||
ChartRecord.objects.filter(id__in=ids_to_delete).delete()
|
|
||||||
|
|
||||||
if records_to_update:
|
|
||||||
ChartRecord.objects.bulk_update(
|
|
||||||
records_to_update, ["rank", "count"], batch_size=500
|
|
||||||
)
|
)
|
||||||
|
existing_by_media_id = {getattr(r, media_field): r for r in existing}
|
||||||
|
found_media_ids = set()
|
||||||
|
|
||||||
if records_to_create:
|
for result in results:
|
||||||
ChartRecord.objects.bulk_create(records_to_create, batch_size=500)
|
media_id = result[config["values"]]
|
||||||
|
if media_id is None:
|
||||||
|
continue
|
||||||
|
|
||||||
logger.info(
|
found_media_ids.add(media_id)
|
||||||
f"Built {len(records_to_create)} new, {len(records_to_update)} updated "
|
|
||||||
f"chart records for {media_type}, period "
|
chart_record_data = {
|
||||||
f"{year}-{month or ''}-{week or ''}-{day or ''} for {user}"
|
"user_id": user.id,
|
||||||
)
|
"year": year,
|
||||||
|
"month": month,
|
||||||
|
"week": week,
|
||||||
|
"day": day,
|
||||||
|
"rank": ranks[result["scrobble_count"]],
|
||||||
|
"count": result["scrobble_count"],
|
||||||
|
}
|
||||||
|
chart_record_data[media_field] = media_id
|
||||||
|
|
||||||
|
if media_id in existing_by_media_id:
|
||||||
|
existing_record = existing_by_media_id[media_id]
|
||||||
|
existing_record.rank = chart_record_data["rank"]
|
||||||
|
existing_record.count = chart_record_data["count"]
|
||||||
|
records_to_update.append(existing_record)
|
||||||
|
else:
|
||||||
|
records_to_create.append(ChartRecord(**chart_record_data))
|
||||||
|
|
||||||
|
ids_to_delete = [
|
||||||
|
r.id
|
||||||
|
for r in existing
|
||||||
|
if getattr(r, media_field) not in found_media_ids
|
||||||
|
]
|
||||||
|
if ids_to_delete:
|
||||||
|
ChartRecord.objects.filter(id__in=ids_to_delete).delete()
|
||||||
|
|
||||||
|
if records_to_update:
|
||||||
|
ChartRecord.objects.bulk_update(
|
||||||
|
records_to_update, ["rank", "count"], batch_size=500
|
||||||
|
)
|
||||||
|
|
||||||
|
if records_to_create:
|
||||||
|
ChartRecord.objects.bulk_create(records_to_create, batch_size=500)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Built {len(records_to_create)} new, {len(records_to_update)} updated "
|
||||||
|
f"chart records for {media_type}, period "
|
||||||
|
f"{year}-{month or ''}-{week or ''}-{day or ''} for {user}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_yesterdays_charts(user, media_types: Optional[list] = None) -> None:
|
def build_yesterdays_charts(user, media_types: Optional[list] = None) -> None:
|
||||||
|
|||||||
@ -1,8 +1,22 @@
|
|||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
from music.models import Artist, Album
|
from music.models import Artist, Album
|
||||||
|
|
||||||
|
CACHE_TTL = 300
|
||||||
|
|
||||||
|
|
||||||
def music_lists(request):
|
def music_lists(request):
|
||||||
|
artist_list = cache.get("music_lists_artist_list")
|
||||||
|
if artist_list is None:
|
||||||
|
artist_list = list(Artist.objects.all().only("id", "name"))
|
||||||
|
cache.set("music_lists_artist_list", artist_list, CACHE_TTL)
|
||||||
|
|
||||||
|
album_list = cache.get("music_lists_album_list")
|
||||||
|
if album_list is None:
|
||||||
|
album_list = list(Album.objects.all().only("id", "name"))
|
||||||
|
cache.set("music_lists_album_list", album_list, CACHE_TTL)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"artist_list": Artist.objects.all(),
|
"artist_list": artist_list,
|
||||||
"album_list": Album.objects.all(),
|
"album_list": album_list,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -236,19 +236,6 @@ class Artist(TimeStampedModel):
|
|||||||
)
|
)
|
||||||
artist.fix_metadata()
|
artist.fix_metadata()
|
||||||
|
|
||||||
# TODO: See if this alt_names stuff actually works or causes hard to debug problems
|
|
||||||
# If we did find our artist, but the found name is slightly differnt, record that
|
|
||||||
# if artist and alt_name:
|
|
||||||
# if not artist.alt_names:
|
|
||||||
# artist.alt_names = alt_name
|
|
||||||
# else:
|
|
||||||
# artist.alt_names += f"\\{alt_name}"
|
|
||||||
# logger.info(
|
|
||||||
# f"Add alt_name {alt_name} to artist {artist}",
|
|
||||||
# extra={"alt_name": alt_name, "artist_id": artist.id},
|
|
||||||
# )
|
|
||||||
# artist.save(update_fields=["alt_names"])
|
|
||||||
|
|
||||||
return artist
|
return artist
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import pytz
|
import pytz
|
||||||
|
from django.core.cache import cache
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from scrobbles.constants import EXCLUDE_FROM_NOW_PLAYING
|
from scrobbles.constants import EXCLUDE_FROM_NOW_PLAYING
|
||||||
@ -19,6 +20,8 @@ MONTH_COLORS = [
|
|||||||
"#db7a7a", # Dec
|
"#db7a7a", # Dec
|
||||||
]
|
]
|
||||||
|
|
||||||
|
CACHE_TTL = 60
|
||||||
|
|
||||||
|
|
||||||
def month_color(request):
|
def month_color(request):
|
||||||
from datetime import date
|
from datetime import date
|
||||||
@ -27,15 +30,25 @@ def month_color(request):
|
|||||||
|
|
||||||
def now_playing(request):
|
def now_playing(request):
|
||||||
user = request.user
|
user = request.user
|
||||||
now = timezone.now()
|
|
||||||
if not user.is_authenticated:
|
if not user.is_authenticated:
|
||||||
return {}
|
return {}
|
||||||
return {
|
|
||||||
"now_playing_list": Scrobble.objects.filter(
|
cache_key = f"now_playing_list_{user.id}"
|
||||||
in_progress=True,
|
now_playing_list = cache.get(cache_key)
|
||||||
is_paused=False,
|
if now_playing_list is None:
|
||||||
user=user,
|
now_playing_list = list(
|
||||||
).exclude(
|
Scrobble.objects.filter(
|
||||||
media_type__in=EXCLUDE_FROM_NOW_PLAYING,
|
in_progress=True,
|
||||||
|
is_paused=False,
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
.exclude(
|
||||||
|
media_type__in=EXCLUDE_FROM_NOW_PLAYING,
|
||||||
|
)
|
||||||
|
.select_related("track", "video", "podcast_episode")
|
||||||
)
|
)
|
||||||
|
cache.set(cache_key, now_playing_list, CACHE_TTL)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"now_playing_list": now_playing_list,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -330,8 +330,6 @@ def manual_scrobble_book(
|
|||||||
|
|
||||||
source = READCOMICSONLINE_URL.replace("https://", "")
|
source = READCOMICSONLINE_URL.replace("https://", "")
|
||||||
|
|
||||||
# TODO: Check for scrobble of this book already and if so, update the page count
|
|
||||||
|
|
||||||
book = Book.find_or_create(title, url=url, enrich=True)
|
book = Book.find_or_create(title, url=url, enrich=True)
|
||||||
|
|
||||||
scrobble_dict = {
|
scrobble_dict = {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from django.core.cache import cache
|
||||||
from django.db.models.signals import post_delete, post_save
|
from django.db.models.signals import post_delete, post_save
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@ -52,6 +53,11 @@ def _update_charts_for_timestamp(user, ts):
|
|||||||
if ts is None:
|
if ts is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
lock_key = f"chart_update_{user.id}"
|
||||||
|
if not cache.add(lock_key, "locked", timeout=30):
|
||||||
|
logger.info(f"Chart update already queued for user {user.id}, skipping")
|
||||||
|
return
|
||||||
|
|
||||||
if timezone.is_naive(ts):
|
if timezone.is_naive(ts):
|
||||||
ts = timezone.make_aware(ts)
|
ts = timezone.make_aware(ts)
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,7 @@ from charts.utils import (
|
|||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.cache import cache
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
@ -241,6 +242,11 @@ def update_charts_for_timestamp(user_id, year, month, day, week):
|
|||||||
logger.error(f"User with id {user_id} not found")
|
logger.error(f"User with id {user_id} not found")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
lock_key = f"chart_update_running_{user_id}"
|
||||||
|
if not cache.add(lock_key, "locked", timeout=300):
|
||||||
|
logger.info(f"Chart update already running for user {user_id}, skipping")
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
build_daily_charts(user, year, month, day, CHARTABLE_MEDIA_TYPES)
|
build_daily_charts(user, year, month, day, CHARTABLE_MEDIA_TYPES)
|
||||||
build_weekly_charts(user, year, week, CHARTABLE_MEDIA_TYPES)
|
build_weekly_charts(user, year, week, CHARTABLE_MEDIA_TYPES)
|
||||||
@ -250,6 +256,8 @@ def update_charts_for_timestamp(user_id, year, month, day, week):
|
|||||||
logger.info(f"[charts] Updated charts for {user} on {date_str}")
|
logger.info(f"[charts] Updated charts for {user} on {date_str}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[charts] Failed to update charts: {e}")
|
logger.error(f"[charts] Failed to update charts: {e}")
|
||||||
|
finally:
|
||||||
|
cache.delete(lock_key)
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
|
|||||||
@ -1717,6 +1717,7 @@ class ScrobbleCalendarView(LoginRequiredMixin, TemplateView):
|
|||||||
for scrobble in day_map[day_num]:
|
for scrobble in day_map[day_num]:
|
||||||
day_scrobbles.append(
|
day_scrobbles.append(
|
||||||
{
|
{
|
||||||
|
"id": scrobble.pk,
|
||||||
"uuid": scrobble.uuid,
|
"uuid": scrobble.uuid,
|
||||||
"emoji": self.MEDIA_EMOJI.get(scrobble.media_type, "📌"),
|
"emoji": self.MEDIA_EMOJI.get(scrobble.media_type, "📌"),
|
||||||
"title": (
|
"title": (
|
||||||
|
|||||||
@ -69,6 +69,11 @@ class Command(BaseCommand):
|
|||||||
type=int,
|
type=int,
|
||||||
help="Only process a specific game by ID",
|
help="Only process a specific game by ID",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--fix-broken-images",
|
||||||
|
action="store_true",
|
||||||
|
help="Check and refetch broken/deleted game images (cover, screenshot, hltb_cover)",
|
||||||
|
)
|
||||||
for flag in MISSING_ALL:
|
for flag in MISSING_ALL:
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
f"--missing-{flag}",
|
f"--missing-{flag}",
|
||||||
@ -92,15 +97,16 @@ class Command(BaseCommand):
|
|||||||
sleep_secs = options["sleep"]
|
sleep_secs = options["sleep"]
|
||||||
force = options["force"]
|
force = options["force"]
|
||||||
game_id = options["game_id"]
|
game_id = options["game_id"]
|
||||||
|
fix_broken_images = options.get("fix_broken_images", False)
|
||||||
flags = options.get("missing_flags") or []
|
flags = options.get("missing_flags") or []
|
||||||
all_missing = options["all_missing"]
|
all_missing = options["all_missing"]
|
||||||
|
|
||||||
if all_missing:
|
if all_missing:
|
||||||
flags = MISSING_ALL
|
flags = MISSING_ALL
|
||||||
|
|
||||||
if not flags and not game_id and not force:
|
if not flags and not game_id and not force and not fix_broken_images:
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
"No filters specified. Use --all, --missing-*, --game-id, or --force."
|
"No filters specified. Use --all, --missing-*, --game-id, --force, or --fix-broken-images."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -135,25 +141,33 @@ class Command(BaseCommand):
|
|||||||
"release_year_fixed": 0,
|
"release_year_fixed": 0,
|
||||||
"igdb_id_found": 0,
|
"igdb_id_found": 0,
|
||||||
"hltb_id_found": 0,
|
"hltb_id_found": 0,
|
||||||
|
"images_fixed": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
for batch_num, offset in enumerate(range(0, len(qs), batch_size)):
|
enriched_any = bool(flags or game_id or force)
|
||||||
batch = qs[offset : offset + batch_size]
|
|
||||||
for game in batch:
|
|
||||||
result = self._enrich_game(game, sleep_secs, force)
|
|
||||||
self._check_retroarch_name(game, title_mismatches)
|
|
||||||
if result:
|
|
||||||
enriched += 1
|
|
||||||
for key in stats:
|
|
||||||
if result.get(key):
|
|
||||||
stats[key] += 1
|
|
||||||
else:
|
|
||||||
skipped += 1
|
|
||||||
|
|
||||||
self.stdout.write(
|
if enriched_any:
|
||||||
f" Batch {batch_num + 1}: {offset + len(batch)}/{total} — "
|
for batch_num, offset in enumerate(range(0, len(qs), batch_size)):
|
||||||
f"enriched: {enriched}, skipped: {skipped}"
|
batch = qs[offset : offset + batch_size]
|
||||||
)
|
for game in batch:
|
||||||
|
result = self._enrich_game(game, sleep_secs, force)
|
||||||
|
self._check_retroarch_name(game, title_mismatches)
|
||||||
|
if result:
|
||||||
|
enriched += 1
|
||||||
|
for key in stats:
|
||||||
|
if result.get(key):
|
||||||
|
stats[key] += 1
|
||||||
|
else:
|
||||||
|
skipped += 1
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
f" Batch {batch_num + 1}: {offset + len(batch)}/{total} — "
|
||||||
|
f"enriched: {enriched}, skipped: {skipped}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if fix_broken_images:
|
||||||
|
broken_stats = self._fix_broken_images(qs, sleep_secs)
|
||||||
|
stats["images_fixed"] = broken_stats["images_fixed"]
|
||||||
|
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
f"\nResults (commit={commit}):\n"
|
f"\nResults (commit={commit}):\n"
|
||||||
@ -168,6 +182,8 @@ class Command(BaseCommand):
|
|||||||
f" IGDB IDs found: {stats['igdb_id_found']}\n"
|
f" IGDB IDs found: {stats['igdb_id_found']}\n"
|
||||||
f" HLtB IDs found: {stats['hltb_id_found']}"
|
f" HLtB IDs found: {stats['hltb_id_found']}"
|
||||||
)
|
)
|
||||||
|
if fix_broken_images:
|
||||||
|
self.stdout.write(f" Broken images fixed: {stats['images_fixed']}")
|
||||||
|
|
||||||
if title_mismatches:
|
if title_mismatches:
|
||||||
self.stdout.write("\nTitle vs retroarch_name mismatches (not auto-fixed):")
|
self.stdout.write("\nTitle vs retroarch_name mismatches (not auto-fixed):")
|
||||||
@ -450,3 +466,64 @@ class Command(BaseCommand):
|
|||||||
self.stdout.write(f" [TAG] {game} — tagged hltb-enriched")
|
self.stdout.write(f" [TAG] {game} — tagged hltb-enriched")
|
||||||
|
|
||||||
return changed if any(changed.values()) else None
|
return changed if any(changed.values()) else None
|
||||||
|
|
||||||
|
def _fix_broken_images(self, games, sleep_secs):
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from videogames.igdb import lookup_game_from_igdb
|
||||||
|
from videogames.howlongtobeat import lookup_game_from_hltb
|
||||||
|
|
||||||
|
stats = {"cover_fixed": 0, "screenshot_fixed": 0, "images_fixed": 0}
|
||||||
|
|
||||||
|
for game in games:
|
||||||
|
for field_name, source in [
|
||||||
|
("cover", "igdb"),
|
||||||
|
("screenshot", "igdb"),
|
||||||
|
("hltb_cover", "hltb"),
|
||||||
|
]:
|
||||||
|
field = getattr(game, field_name)
|
||||||
|
if not field.name:
|
||||||
|
continue
|
||||||
|
if field.storage.exists(field.name):
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
f" [IMAGE] {game} — {field_name} is broken (file missing), refetching…"
|
||||||
|
)
|
||||||
|
|
||||||
|
if source == "igdb" and game.igdb_id:
|
||||||
|
data = lookup_game_from_igdb(str(game.igdb_id))
|
||||||
|
time.sleep(sleep_secs)
|
||||||
|
if not data:
|
||||||
|
continue
|
||||||
|
url = data.get("cover_url" if field_name == "cover" else "screenshot_url")
|
||||||
|
if not url:
|
||||||
|
continue
|
||||||
|
r = requests.get(url)
|
||||||
|
if r.status_code != 200:
|
||||||
|
continue
|
||||||
|
fname = f"{game.title}_{game.uuid}.jpg"
|
||||||
|
getattr(game, field_name).save(fname, ContentFile(r.content), save=True)
|
||||||
|
stats["images_fixed"] += 1
|
||||||
|
self.stdout.write(f" [IMAGE] {game} — {field_name} refetched from IGDB")
|
||||||
|
|
||||||
|
elif source == "hltb" and game.hltb_id:
|
||||||
|
data = lookup_game_from_hltb(str(game.hltb_id))
|
||||||
|
time.sleep(sleep_secs)
|
||||||
|
if not data:
|
||||||
|
continue
|
||||||
|
url = data.get("cover_url")
|
||||||
|
if not url:
|
||||||
|
continue
|
||||||
|
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||||
|
r = requests.get(url, headers=headers)
|
||||||
|
if r.status_code != 200:
|
||||||
|
continue
|
||||||
|
fname = f"{game.title}_cover_{game.uuid}.jpg"
|
||||||
|
game.hltb_cover.save(fname, ContentFile(r.content), save=True)
|
||||||
|
stats["images_fixed"] += 1
|
||||||
|
self.stdout.write(f" [IMAGE] {game} — hltb_cover refetched from HLtB")
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|||||||
@ -1,8 +1,24 @@
|
|||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
from videos.models import Video, Series
|
from videos.models import Video, Series
|
||||||
|
|
||||||
|
CACHE_TTL = 300
|
||||||
|
|
||||||
|
|
||||||
def video_lists(request):
|
def video_lists(request):
|
||||||
|
movie_list = cache.get("video_lists_movie_list")
|
||||||
|
if movie_list is None:
|
||||||
|
movie_list = list(
|
||||||
|
Video.objects.filter(video_type=Video.VideoType.MOVIE).only("id", "title")
|
||||||
|
)
|
||||||
|
cache.set("video_lists_movie_list", movie_list, CACHE_TTL)
|
||||||
|
|
||||||
|
series_list = cache.get("video_lists_series_list")
|
||||||
|
if series_list is None:
|
||||||
|
series_list = list(Series.objects.all().only("id", "name"))
|
||||||
|
cache.set("video_lists_series_list", series_list, CACHE_TTL)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"movie_list": Video.objects.filter(video_type=Video.VideoType.MOVIE),
|
"movie_list": movie_list,
|
||||||
"series_list": Series.objects.all(),
|
"series_list": series_list,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,46 +4,55 @@ from importlib.metadata import version as get_version
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
_GIT_COMMIT = None
|
||||||
|
|
||||||
|
|
||||||
def version_info(request):
|
def version_info(request):
|
||||||
|
global _GIT_COMMIT
|
||||||
|
|
||||||
try:
|
try:
|
||||||
app_version = get_version("vrobbler")
|
app_version = get_version("vrobbler")
|
||||||
except Exception:
|
except Exception:
|
||||||
app_version = "unknown"
|
app_version = "unknown"
|
||||||
|
|
||||||
commit = os.environ.get("VROBBLER_COMMIT")
|
commit = os.environ.get("VROBBLER_COMMIT")
|
||||||
if not commit:
|
if commit:
|
||||||
# Try to import from _commit.py module first
|
return {"app_version": app_version, "git_commit": commit}
|
||||||
|
|
||||||
|
if _GIT_COMMIT is not None:
|
||||||
|
return {"app_version": app_version, "git_commit": _GIT_COMMIT}
|
||||||
|
|
||||||
|
try:
|
||||||
|
from vrobbler._commit import commit as _commit
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
if _commit and _commit != "unknown":
|
||||||
|
_GIT_COMMIT = _commit
|
||||||
|
return {"app_version": app_version, "git_commit": _GIT_COMMIT}
|
||||||
|
|
||||||
|
commit_file = Path("/var/lib/vrobbler/commit.txt")
|
||||||
|
if commit_file.exists():
|
||||||
try:
|
try:
|
||||||
from vrobbler._commit import commit as _commit
|
commit = commit_file.read_text().strip()
|
||||||
except ImportError:
|
if commit:
|
||||||
|
_GIT_COMMIT = commit
|
||||||
|
return {"app_version": app_version, "git_commit": _GIT_COMMIT}
|
||||||
|
except OSError:
|
||||||
pass
|
pass
|
||||||
else:
|
|
||||||
if _commit and _commit != "unknown":
|
|
||||||
return {"app_version": app_version, "git_commit": _commit}
|
|
||||||
|
|
||||||
# Try to read from commit file (written during deploy)
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
commit_file = Path("/var/lib/vrobbler/commit.txt")
|
try:
|
||||||
if commit_file.exists():
|
_GIT_COMMIT = (
|
||||||
try:
|
subprocess.check_output(
|
||||||
commit = commit_file.read_text().strip()
|
["git", "rev-parse", "--short", "HEAD"],
|
||||||
if commit:
|
cwd=PROJECT_ROOT,
|
||||||
return {"app_version": app_version, "git_commit": commit}
|
stderr=subprocess.DEVNULL,
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Fall back to git command
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
||||||
try:
|
|
||||||
commit = (
|
|
||||||
subprocess.check_output(
|
|
||||||
["git", "rev-parse", "--short", "HEAD"],
|
|
||||||
cwd=PROJECT_ROOT,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
)
|
|
||||||
.decode("utf-8")
|
|
||||||
.strip()
|
|
||||||
)
|
)
|
||||||
except (subprocess.SubprocessError, FileNotFoundError):
|
.decode("utf-8")
|
||||||
commit = "unknown"
|
.strip()
|
||||||
|
)
|
||||||
|
except (subprocess.SubprocessError, FileNotFoundError):
|
||||||
|
_GIT_COMMIT = "unknown"
|
||||||
|
|
||||||
return {"app_version": app_version, "git_commit": commit}
|
return {"app_version": app_version, "git_commit": _GIT_COMMIT}
|
||||||
|
|||||||
@ -122,6 +122,15 @@ CELERY_ACCEPT_CONTENT = ["json"]
|
|||||||
CELERY_RESULT_EXTENDED = True
|
CELERY_RESULT_EXTENDED = True
|
||||||
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True
|
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True
|
||||||
|
|
||||||
|
CELERY_TASK_CREATE_MISSING_QUEUES = True
|
||||||
|
CELERY_TASK_ROUTES = {
|
||||||
|
"scrobbles.tasks.update_charts_for_timestamp": {"queue": "charts"},
|
||||||
|
"scrobbles.tasks.create_yesterdays_charts": {"queue": "charts"},
|
||||||
|
"scrobbles.tasks.rebuild_weekly_charts": {"queue": "charts"},
|
||||||
|
"scrobbles.tasks.rebuild_monthly_charts": {"queue": "charts"},
|
||||||
|
"scrobbles.tasks.rebuild_yearly_charts": {"queue": "charts"},
|
||||||
|
}
|
||||||
|
|
||||||
CELERY_BEAT_SCHEDULE = {
|
CELERY_BEAT_SCHEDULE = {
|
||||||
"build-yesterdays-charts": {
|
"build-yesterdays-charts": {
|
||||||
"task": "scrobbles.tasks.create_yesterdays_charts",
|
"task": "scrobbles.tasks.create_yesterdays_charts",
|
||||||
@ -352,11 +361,11 @@ USE_TZ = True
|
|||||||
# Static files (CSS, JavaScript, Images)
|
# Static files (CSS, JavaScript, Images)
|
||||||
# https://docs.djangoproject.com/en/3.1/howto/static-files/
|
# https://docs.djangoproject.com/en/3.1/howto/static-files/
|
||||||
#
|
#
|
||||||
from storages.backends import s3boto3
|
|
||||||
|
|
||||||
USE_S3_STORAGE = os.getenv("VROBBLER_USE_S3", "False").lower() in TRUTHY
|
USE_S3_STORAGE = os.getenv("VROBBLER_USE_S3", "False").lower() in TRUTHY
|
||||||
|
|
||||||
if USE_S3_STORAGE:
|
if USE_S3_STORAGE:
|
||||||
|
from storages.backends import s3boto3
|
||||||
|
|
||||||
AWS_S3_ENDPOINT_URL = os.getenv("AWS_S3_ENDPOINT_URL", "")
|
AWS_S3_ENDPOINT_URL = os.getenv("AWS_S3_ENDPOINT_URL", "")
|
||||||
AWS_STORAGE_BUCKET_NAME = os.getenv("AWS_STORAGE_BUCKET_NAME", "")
|
AWS_STORAGE_BUCKET_NAME = os.getenv("AWS_STORAGE_BUCKET_NAME", "")
|
||||||
AWS_S3_ACCESS_KEY_ID = os.getenv("AWS_S3_ACCESS_KEY_ID")
|
AWS_S3_ACCESS_KEY_ID = os.getenv("AWS_S3_ACCESS_KEY_ID")
|
||||||
|
|||||||
Reference in New Issue
Block a user