Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cb01781615 | |||
| 1f5fada8b1 | |||
| 31888a85cb | |||
| 22d8b0787e | |||
| 8cc559752b | |||
| db3f9696fa | |||
| 407d570c82 | |||
| 033239260f | |||
| 9f854dc735 | |||
| f29272a853 | |||
| 4e56d9420a | |||
| 852a257159 | |||
| 68ff230f13 | |||
| 57a952a6d1 | |||
| 718fcf7392 | |||
| 52adcf83c7 | |||
| 0061623f7e | |||
| ec73e5151e | |||
| 2c90dd38b5 | |||
| c6b1e42d7a | |||
| fcf86d5b3f | |||
| 6fde9ec8d2 | |||
| 0f1882b21f | |||
| e819a2db0d | |||
| e03cf6c9b1 | |||
| 471e70ff7f | |||
| 255e335d7a | |||
| c8cf80b513 | |||
| b4180afbed |
173
PROJECT.org
173
PROJECT.org
@ -88,7 +88,7 @@ fetching and simple saving.
|
|||||||
*** Metadata sources
|
*** Metadata sources
|
||||||
**** Scraper
|
**** Scraper
|
||||||
|
|
||||||
* Backlog [0/20] :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
|
||||||
@ -590,6 +602,165 @@ We should rename `email_scrobble_board_game` to reflect the fact that it's just
|
|||||||
a helper method to create board game scrobbles given a json blob. It's
|
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:
|
||||||
|
|
||||||
|
* Version 56.0 [1/1]
|
||||||
|
** DONE [#B] Add DiscGolf as a scrobbleable media :discgolf:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 8cdde5d3-0ae5-7d5a-99d2-200c86afae03
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
I have a csv file fro the uDisc disc golf scoring app that looks like:
|
||||||
|
|
||||||
|
Singles round. Note second row is the par for the course
|
||||||
|
#+begin_src csv
|
||||||
|
PlayerName,CourseName,LayoutName,StartDate,EndDate,Total,+/-,RoundRating,Hole1,Hole2,Hole3,Hole4,Hole5,Hole6,Hole7,Hole8,Hole9
|
||||||
|
Par,DR Front 9,Custom Layout,2026-06-19 1535-0400,2026-06-19 1725-0400,27,,,3,3,3,3,3,3,3,3,3
|
||||||
|
Colin Powell,DR Front 9,Custom Layout,2026-06-19 1535-0400,2026-06-19 1725-0400,30,3,,2,3,4,4,3,4,3,3,4
|
||||||
|
Asa Sewell,DR Front 9,Custom Layout,2026-06-19 1535-0400,2026-06-19 1725-0400,44,17,,5,4,4,8,5,5,4,4,5
|
||||||
|
Emma Sweet,DR Front 9,Custom Layout,2026-06-19 1535-0400,2026-06-19 1725-0400,41,14,,5,4,5,6,3,4,3,5,6
|
||||||
|
Jane Sewell,DR Front 9,Custom Layout,2026-06-19 1535-0400,2026-06-19 1725-0400,44,17,,4,5,5,5,5,5,4,6,5
|
||||||
|
Nabby Sewell,DR Front 9,Custom Layout,2026-06-19 1535-0400,2026-06-19 1725-0400,59,32,,6,6,7,7,6,7,6,6,8
|
||||||
|
Silas Sewell,DR Front 9,Custom Layout,2026-06-19 1535-0400,2026-06-19 1725-0400,41,14,,5,5,4,5,3,5,4,4,6`
|
||||||
|
#+end_src
|
||||||
|
|
||||||
|
Teams of two or more persons. Note second row is the par for the course
|
||||||
|
#+begin_src csv
|
||||||
|
PlayerName,CourseName,LayoutName,StartDate,EndDate,Total,+/-,RoundRating,Hole1,Hole2,Hole3,Hole4,Hole5,Hole6,Hole7,Hole8,Hole9
|
||||||
|
Par,Peninsula Links,Main,2026-06-19 2322-0400,2026-06-19 2323-0400,27,,,3,3,3,3,3,3,3,3,3
|
||||||
|
Colin Powell + Asa Sewell,Peninsula Links,Main,2026-06-19 2322-0400,2026-06-19 2323-0400,29,2,,3,4,2,3,2,3,5,3,4
|
||||||
|
Emma Sweet + Jane Sewell,Peninsula Links,Main,2026-06-19 2322-0400,2026-06-19 2323-0400,28,1,,4,3,4,2,3,4,3,3,2
|
||||||
|
#+end_src
|
||||||
|
|
||||||
|
We should add a new app called discgolf that has the following data models:
|
||||||
|
|
||||||
|
- DiscGolfRound - scrobblable media + course_id, round_type (Singles, Teams)
|
||||||
|
- DiscGolfCourse - name, layoutname, number_of_holes
|
||||||
|
|
||||||
|
And the logdata for a DiscGolfOuting scrobble should have:
|
||||||
|
+ {person: {hole_number: score}, total: int}
|
||||||
|
+ {team: {name: "", people: [person, person], hole_number: score}, total: int}
|
||||||
|
+ weather
|
||||||
|
+ fun_factor (miserable, not great, so-so, good, excellent, party time)
|
||||||
|
|
||||||
|
|
||||||
|
* Version 55.6 [1/1]
|
||||||
|
** DONE [#A] Figure out why historical Lastfm imports don't work :importers:lastfm:music:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 71b18c1b-de96-6d93-20fa-de2ec0df1288
|
||||||
|
:END:
|
||||||
|
|
||||||
|
* Version 55.5 [1/1]
|
||||||
|
** DONE [#B] Fix bug in lastfm import for new users :importers:lastfm:music:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: d034966d-0c7f-e512-4cf8-7329c9026b6f
|
||||||
|
:END:
|
||||||
|
|
||||||
|
* 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]
|
||||||
|
** DONE [#A] Clean up metadata scrapping for video games :metadata:videogames:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: fbc421b5-21a3-4aed-9062-c59192ead065
|
||||||
|
:END:
|
||||||
|
|
||||||
|
* Version 55.0 [3/3]
|
||||||
|
** DONE [#B] Use pk ID for scrobble detail view, not uuid :scrobbles:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 9cc3b285-e478-041e-394b-3d550aefbe1d
|
||||||
|
:END:
|
||||||
|
** DONE [#B] Display videogame screenshots on scrobble detail if they exist :videogames:templates:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 0406d082-20f6-0d12-76e2-f281c4801468
|
||||||
|
:END:
|
||||||
|
** DONE [#B] Add autotagging to webpages based on domain, title :webpages:metadata:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: f658435b-f7a0-42e6-b9f6-226678a77a55
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
For easier filtering, like we do with tasks, we should auto tag WebPage instances
|
||||||
|
based on the domain name split part by periods (so news.ycombinator.com tags: news, ycombinator, com)
|
||||||
|
|
||||||
|
And also based on the nouns in the title.
|
||||||
|
|
||||||
|
|
||||||
|
* Version 54.5 [1/1]
|
||||||
|
** DONE Fix bug in generating mood trends :trends:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 8e75abfa-8e70-d85b-00a4-a4813bbce879
|
||||||
|
:END:
|
||||||
|
|
||||||
|
* Version 54.4 [2/2]
|
||||||
|
** DONE [#A] Remove all-time trends :trends:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 53b231d1-7677-8cd3-1d88-dae110aba1e6
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
All time trends take forever to calculate and don't provide too much data
|
||||||
|
|
||||||
|
** DONE [#B] Add a trend around moods :moods:trends:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: fba3f4ae-8f97-ee0b-e762-31630884518a
|
||||||
|
:END:
|
||||||
|
|
||||||
|
* Version 54.3 [1/1]
|
||||||
|
** DONE [#B] Fix bug in series metadata cleanup script :videos:metadta:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 85448702-907c-5d63-f5af-7795661d7c46
|
||||||
|
:END:
|
||||||
|
|
||||||
* Version 54.2 [4/4]
|
* Version 54.2 [4/4]
|
||||||
** DONE [#B] Add script to clean up TV series metadata :videos:metadata:
|
** DONE [#B] Add script to clean up TV series metadata :videos:metadata:
|
||||||
: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 = "54.2"
|
version = "56.0"
|
||||||
description = ""
|
description = ""
|
||||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||||
|
|
||||||
|
|||||||
@ -519,7 +519,7 @@ def test_scrobble_detail_view_with_notes_as_flat_list(client):
|
|||||||
"description": "Test description",
|
"description": "Test description",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid})
|
url = reverse("scrobbles:detail", kwargs={"pk": scrobble.id})
|
||||||
response = client.get(url)
|
response = client.get(url)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "First note" in response.content.decode()
|
assert "First note" in response.content.decode()
|
||||||
@ -545,7 +545,7 @@ def test_scrobble_detail_view_with_notes_as_dict_timestamps(client):
|
|||||||
"description": "Test description",
|
"description": "Test description",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid})
|
url = reverse("scrobbles:detail", kwargs={"pk": scrobble.id})
|
||||||
response = client.get(url)
|
response = client.get(url)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
content = response.content.decode()
|
content = response.content.decode()
|
||||||
@ -574,7 +574,7 @@ def test_scrobble_detail_view_with_notes_and_labels(client):
|
|||||||
"description": "Test description",
|
"description": "Test description",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid})
|
url = reverse("scrobbles:detail", kwargs={"pk": scrobble.id})
|
||||||
response = client.get(url)
|
response = client.get(url)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
content = response.content.decode()
|
content = response.content.decode()
|
||||||
@ -597,7 +597,7 @@ def test_scrobble_detail_view_post_updates_log(client):
|
|||||||
"description": "Original description",
|
"description": "Original description",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid})
|
url = reverse("scrobbles:detail", kwargs={"pk": scrobble.id})
|
||||||
|
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@ -896,7 +896,7 @@ def test_change_visibility_owner_can_change(client):
|
|||||||
)
|
)
|
||||||
|
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
url = reverse("scrobbles:change-visibility", kwargs={"uuid": scrobble.uuid})
|
url = reverse("scrobbles:change-visibility", kwargs={"pk": scrobble.id})
|
||||||
response = client.post(url, {"visibility": "shared"})
|
response = client.post(url, {"visibility": "shared"})
|
||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
|
|
||||||
@ -919,7 +919,7 @@ def test_change_visibility_non_owner_gets_404(client):
|
|||||||
)
|
)
|
||||||
|
|
||||||
client.force_login(other)
|
client.force_login(other)
|
||||||
url = reverse("scrobbles:change-visibility", kwargs={"uuid": scrobble.uuid})
|
url = reverse("scrobbles:change-visibility", kwargs={"pk": scrobble.id})
|
||||||
response = client.post(url, {"visibility": "shared"})
|
response = client.post(url, {"visibility": "shared"})
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
@ -937,7 +937,7 @@ def test_change_visibility_anonymous_redirects_to_login(client):
|
|||||||
task=task, media_type="Task", user=user, visibility="private",
|
task=task, media_type="Task", user=user, visibility="private",
|
||||||
timestamp=timezone.now(),
|
timestamp=timezone.now(),
|
||||||
)
|
)
|
||||||
url = reverse("scrobbles:change-visibility", kwargs={"uuid": scrobble.uuid})
|
url = reverse("scrobbles:change-visibility", kwargs={"pk": scrobble.id})
|
||||||
response = client.post(url, {"visibility": "shared"})
|
response = client.post(url, {"visibility": "shared"})
|
||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
assert "/login/" in response.url
|
assert "/login/" in response.url
|
||||||
@ -956,7 +956,7 @@ def test_regenerate_share_token_invalidates_old_sqid(client):
|
|||||||
old_sqid = encode_scrobble_share(scrobble.id, scrobble.share_token_version)
|
old_sqid = encode_scrobble_share(scrobble.id, scrobble.share_token_version)
|
||||||
|
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
url = reverse("scrobbles:regenerate-share-token", kwargs={"uuid": scrobble.uuid})
|
url = reverse("scrobbles:regenerate-share-token", kwargs={"pk": scrobble.id})
|
||||||
response = client.post(url)
|
response = client.post(url)
|
||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
|
|
||||||
@ -985,7 +985,7 @@ def test_share_analytics_owner_can_view(client):
|
|||||||
)
|
)
|
||||||
|
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
url = reverse("scrobbles:share-analytics", kwargs={"uuid": scrobble.uuid})
|
url = reverse("scrobbles:share-analytics", kwargs={"pk": scrobble.id})
|
||||||
response = client.get(url)
|
response = client.get(url)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
@ -1005,7 +1005,7 @@ def test_share_analytics_non_owner_gets_404(client):
|
|||||||
)
|
)
|
||||||
|
|
||||||
client.force_login(other)
|
client.force_login(other)
|
||||||
url = reverse("scrobbles:share-analytics", kwargs={"uuid": scrobble.uuid})
|
url = reverse("scrobbles:share-analytics", kwargs={"pk": scrobble.id})
|
||||||
response = client.get(url)
|
response = client.get(url)
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
@ -1027,7 +1027,7 @@ def test_share_analytics_shows_view_logs(client):
|
|||||||
client.get(share_url)
|
client.get(share_url)
|
||||||
|
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
url = reverse("scrobbles:share-analytics", kwargs={"uuid": scrobble.uuid})
|
url = reverse("scrobbles:share-analytics", kwargs={"pk": scrobble.id})
|
||||||
response = client.get(url)
|
response = client.get(url)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
content = response.content.decode()
|
content = response.content.decode()
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -225,7 +225,7 @@ class Book(LongPlayScrobblableMixin):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def resume_start_url(self):
|
def resume_start_url(self):
|
||||||
return reverse("scrobbles:start", kwargs={"uuid": self.uuid}) + "?resume=1"
|
return reverse("scrobbles:start", kwargs={"media_uuid": self.uuid}) + "?resume=1"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_from_comicvine(
|
def get_from_comicvine(
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
0
vrobbler/apps/discgolf/__init__.py
Normal file
0
vrobbler/apps/discgolf/__init__.py
Normal file
14
vrobbler/apps/discgolf/admin.py
Normal file
14
vrobbler/apps/discgolf/admin.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
from discgolf.models import DiscGolfCourse
|
||||||
|
from django.contrib import admin
|
||||||
|
from scrobbles.admin import ScrobbleInline
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(DiscGolfCourse)
|
||||||
|
class DiscGolfCourseAdmin(admin.ModelAdmin):
|
||||||
|
date_hierarchy = "created"
|
||||||
|
list_display = ("title", "layout_name", "number_of_holes", "par_total")
|
||||||
|
raw_id_fields = ("trail",)
|
||||||
|
search_fields = ("title", "layout_name")
|
||||||
|
inlines = [
|
||||||
|
ScrobbleInline,
|
||||||
|
]
|
||||||
0
vrobbler/apps/discgolf/api/__init__.py
Normal file
0
vrobbler/apps/discgolf/api/__init__.py
Normal file
11
vrobbler/apps/discgolf/api/serializers.py
Normal file
11
vrobbler/apps/discgolf/api/serializers.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
from discgolf import models
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
|
||||||
|
class DiscGolfCourseSerializer(serializers.HyperlinkedModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = models.DiscGolfCourse
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
13
vrobbler/apps/discgolf/api/views.py
Normal file
13
vrobbler/apps/discgolf/api/views.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
from rest_framework import permissions, viewsets
|
||||||
|
|
||||||
|
from discgolf.api import serializers
|
||||||
|
from discgolf import models
|
||||||
|
|
||||||
|
|
||||||
|
class DiscGolfCourseViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = models.DiscGolfCourse.objects.all().order_by("-created")
|
||||||
|
serializer_class = serializers.DiscGolfCourseSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
6
vrobbler/apps/discgolf/apps.py
Normal file
6
vrobbler/apps/discgolf/apps.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class DiscgolfConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "discgolf"
|
||||||
0
vrobbler/apps/discgolf/management/__init__.py
Normal file
0
vrobbler/apps/discgolf/management/__init__.py
Normal file
83
vrobbler/apps/discgolf/migrations/0001_initial.py
Normal file
83
vrobbler/apps/discgolf/migrations/0001_initial.py
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
# Generated by Django 4.2.29 on 2026-06-20 04:19
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django_extensions.db.fields
|
||||||
|
import taggit.managers
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("taggit", "0004_alter_taggeditem_content_type_alter_taggeditem_tag"),
|
||||||
|
("scrobbles", "0098_scrobble_long_play_last_scrobble"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="DiscGolfCourse",
|
||||||
|
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
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("title", models.CharField(blank=True, max_length=255, null=True)),
|
||||||
|
("base_run_time_seconds", models.IntegerField(blank=True, null=True)),
|
||||||
|
("description", models.TextField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"layout_name",
|
||||||
|
models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
("number_of_holes", models.IntegerField(blank=True, null=True)),
|
||||||
|
("par_total", models.IntegerField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"genre",
|
||||||
|
taggit.managers.TaggableManager(
|
||||||
|
blank=True,
|
||||||
|
help_text="A comma-separated list of tags.",
|
||||||
|
through="scrobbles.ObjectWithGenres",
|
||||||
|
to="scrobbles.Genre",
|
||||||
|
verbose_name="Genre",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"tags",
|
||||||
|
taggit.managers.TaggableManager(
|
||||||
|
blank=True,
|
||||||
|
help_text="A comma-separated list of tags.",
|
||||||
|
through="taggit.TaggedItem",
|
||||||
|
to="taggit.Tag",
|
||||||
|
verbose_name="Tags",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
# Generated by Django 4.2.29 on 2026-06-20 04:29
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("trails", "0009_trail_route_waypoint"),
|
||||||
|
("discgolf", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="discgolfcourse",
|
||||||
|
name="trail",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||||
|
to="trails.trail",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.29 on 2026-06-20 04:35
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("discgolf", "0002_discgolfcourse_trail"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="discgolfcourse",
|
||||||
|
name="par_per_hole",
|
||||||
|
field=models.JSONField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
vrobbler/apps/discgolf/migrations/__init__.py
Normal file
0
vrobbler/apps/discgolf/migrations/__init__.py
Normal file
76
vrobbler/apps/discgolf/models.py
Normal file
76
vrobbler/apps/discgolf/models.py
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional, TypedDict
|
||||||
|
|
||||||
|
from django.apps import apps
|
||||||
|
from django.db import models
|
||||||
|
from django.urls import reverse
|
||||||
|
from scrobbles.dataclasses import BaseLogData, WithPeopleLogData
|
||||||
|
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
||||||
|
|
||||||
|
BNULL = {"blank": True, "null": True}
|
||||||
|
|
||||||
|
|
||||||
|
class DiscGolfSingleScores(TypedDict, total=False):
|
||||||
|
person_id: int
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class DiscGolfTeamScores(TypedDict, total=False):
|
||||||
|
person_ids: list[int]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DiscGolfLogData(BaseLogData, WithPeopleLogData):
|
||||||
|
scores: Optional[dict[str, DiscGolfSingleScores | DiscGolfTeamScores]] = None
|
||||||
|
weather: Optional[str] = None
|
||||||
|
fun_factor: Optional[str] = None
|
||||||
|
course_name: Optional[str] = None
|
||||||
|
par: Optional[int] = None
|
||||||
|
round_type: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class DiscGolfCourse(ScrobblableMixin):
|
||||||
|
description = models.TextField(**BNULL)
|
||||||
|
layout_name = models.CharField(max_length=255, **BNULL)
|
||||||
|
number_of_holes = models.IntegerField(**BNULL)
|
||||||
|
par_total = models.IntegerField(**BNULL)
|
||||||
|
par_per_hole = models.JSONField(**BNULL)
|
||||||
|
trail = models.ForeignKey(
|
||||||
|
"trails.Trail", on_delete=models.DO_NOTHING, **BNULL
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_absolute_url(self) -> str:
|
||||||
|
return reverse("discgolf:course_detail", kwargs={"slug": self.uuid})
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.title} ({self.layout_name or 'Default'})"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def logdata_cls(self):
|
||||||
|
return DiscGolfLogData
|
||||||
|
|
||||||
|
@property
|
||||||
|
def subtitle(self):
|
||||||
|
return self.layout_name or ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def strings(self) -> ScrobblableConstants:
|
||||||
|
return ScrobblableConstants(verb="Playing", tags="golf")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def primary_image_url(self) -> str:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def find_or_create(cls, name: str, **defaults) -> "DiscGolfCourse":
|
||||||
|
course = cls.objects.filter(title=name).first()
|
||||||
|
if not course:
|
||||||
|
course = cls.objects.create(title=name, **defaults)
|
||||||
|
return course
|
||||||
|
|
||||||
|
def scrobbles(self, user_id):
|
||||||
|
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
||||||
|
return Scrobble.objects.filter(user_id=user_id, disc_golf=self).order_by(
|
||||||
|
"-timestamp"
|
||||||
|
)
|
||||||
14
vrobbler/apps/discgolf/urls.py
Normal file
14
vrobbler/apps/discgolf/urls.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from discgolf import views
|
||||||
|
|
||||||
|
app_name = "discgolf"
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("disc-golf/", views.DiscGolfCourseListView.as_view(), name="course_list"),
|
||||||
|
path(
|
||||||
|
"disc-golf/<slug:slug>/",
|
||||||
|
views.DiscGolfCourseDetailView.as_view(),
|
||||||
|
name="course_detail",
|
||||||
|
),
|
||||||
|
]
|
||||||
126
vrobbler/apps/discgolf/utils.py
Normal file
126
vrobbler/apps/discgolf/utils.py
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import csv
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from dateutil.parser import parse as parse_datetime
|
||||||
|
from django.utils import timezone
|
||||||
|
from people.models import Person
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
from scrobbles.notifications import ScrobbleNtfyNotification
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_udisc_datetime(raw: str) -> datetime:
|
||||||
|
return parse_datetime(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_player(name: str) -> Person:
|
||||||
|
person, _ = Person.objects.get_or_create(name=name.strip())
|
||||||
|
return person
|
||||||
|
|
||||||
|
|
||||||
|
def import_udisc_csv(
|
||||||
|
file_path: str, user_id: int, record_error=None
|
||||||
|
) -> list[Scrobble]:
|
||||||
|
from discgolf.models import DiscGolfCourse
|
||||||
|
|
||||||
|
new_scrobbles = []
|
||||||
|
|
||||||
|
with open(file_path, newline="", encoding="utf-8-sig") as f:
|
||||||
|
reader = csv.DictReader(f)
|
||||||
|
rows = list(reader)
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return []
|
||||||
|
|
||||||
|
par_row = None
|
||||||
|
player_rows = []
|
||||||
|
for row in rows:
|
||||||
|
name = row.get("PlayerName", "").strip()
|
||||||
|
if name.lower() == "par":
|
||||||
|
par_row = row
|
||||||
|
else:
|
||||||
|
player_rows.append(row)
|
||||||
|
|
||||||
|
if not par_row:
|
||||||
|
return []
|
||||||
|
|
||||||
|
course_name = par_row.get("CourseName", "").strip()
|
||||||
|
layout_name = par_row.get("LayoutName", "").strip()
|
||||||
|
start_date_raw = par_row.get("StartDate", "").strip()
|
||||||
|
start_dt = _parse_udisc_datetime(start_date_raw) if start_date_raw else timezone.now()
|
||||||
|
|
||||||
|
number_of_holes = sum(1 for k in par_row if k.startswith("Hole") and k[4:].isdigit())
|
||||||
|
par_total_str = par_row.get("Total", "").strip()
|
||||||
|
par_total = int(par_total_str) if par_total_str.isdigit() else None
|
||||||
|
|
||||||
|
par_per_hole = {}
|
||||||
|
for k, v in par_row.items():
|
||||||
|
if k.startswith("Hole") and k[4:].isdigit() and v:
|
||||||
|
hole_num = int(k[4:])
|
||||||
|
try:
|
||||||
|
par_per_hole[f"hole_{hole_num}"] = int(v)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
course, _ = DiscGolfCourse.objects.get_or_create(
|
||||||
|
title=course_name,
|
||||||
|
defaults={
|
||||||
|
"layout_name": layout_name,
|
||||||
|
"number_of_holes": number_of_holes,
|
||||||
|
"par_total": par_total,
|
||||||
|
"par_per_hole": par_per_hole or None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
is_teams = "+" in player_rows[0].get("PlayerName", "") if player_rows else False
|
||||||
|
round_type = "Teams" if is_teams else "Singles"
|
||||||
|
|
||||||
|
scores = {}
|
||||||
|
for row in player_rows:
|
||||||
|
player_name = row.get("PlayerName", "").strip()
|
||||||
|
hole_scores = {}
|
||||||
|
for k, v in row.items():
|
||||||
|
if k.startswith("Hole") and k[4:].isdigit() and v:
|
||||||
|
hole_num = int(k[4:])
|
||||||
|
try:
|
||||||
|
hole_scores[f"hole_{hole_num}"] = int(v)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
total_str = row.get("Total", "").strip()
|
||||||
|
total = int(total_str) if total_str.isdigit() else None
|
||||||
|
if total is not None:
|
||||||
|
hole_scores["total"] = total
|
||||||
|
|
||||||
|
if is_teams:
|
||||||
|
people = player_name.split("+")
|
||||||
|
person_ids = [_resolve_player(p.strip()).id for p in people]
|
||||||
|
hole_scores["person_ids"] = person_ids
|
||||||
|
else:
|
||||||
|
person = _resolve_player(player_name)
|
||||||
|
hole_scores["person_id"] = person.id
|
||||||
|
|
||||||
|
scores[player_name] = hole_scores
|
||||||
|
|
||||||
|
log = {
|
||||||
|
"scores": scores,
|
||||||
|
"course_name": course_name,
|
||||||
|
"par": par_total,
|
||||||
|
"round_type": round_type,
|
||||||
|
}
|
||||||
|
|
||||||
|
scrobble_dict = {
|
||||||
|
"user_id": user_id,
|
||||||
|
"timestamp": start_dt,
|
||||||
|
"source": "uDisc",
|
||||||
|
"playback_position_seconds": 0,
|
||||||
|
"log": log,
|
||||||
|
}
|
||||||
|
|
||||||
|
scrobble = Scrobble.create_or_update(course, user_id, scrobble_dict)
|
||||||
|
if scrobble:
|
||||||
|
new_scrobbles.append(scrobble)
|
||||||
|
ScrobbleNtfyNotification(scrobble).send()
|
||||||
|
|
||||||
|
return new_scrobbles
|
||||||
15
vrobbler/apps/discgolf/views.py
Normal file
15
vrobbler/apps/discgolf/views.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
from discgolf.models import DiscGolfCourse
|
||||||
|
|
||||||
|
from scrobbles.views import (
|
||||||
|
ScrobbleableListView,
|
||||||
|
ScrobbleableDetailView,
|
||||||
|
ChartContextMixin,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DiscGolfCourseListView(ScrobbleableListView):
|
||||||
|
model = DiscGolfCourse
|
||||||
|
|
||||||
|
|
||||||
|
class DiscGolfCourseDetailView(ScrobbleableDetailView, ChartContextMixin):
|
||||||
|
model = DiscGolfCourse
|
||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,7 @@ from scrobbles.models import (
|
|||||||
Scrobble,
|
Scrobble,
|
||||||
ShareViewLog,
|
ShareViewLog,
|
||||||
TrailGPXImport,
|
TrailGPXImport,
|
||||||
|
UDiscCSVImport,
|
||||||
)
|
)
|
||||||
from scrobbles.mixins import Genre
|
from scrobbles.mixins import Genre
|
||||||
|
|
||||||
@ -73,6 +74,10 @@ class ScaleCSVImportAdmin(ImportBaseAdmin): ...
|
|||||||
class TrailGPXImportAdmin(ImportBaseAdmin): ...
|
class TrailGPXImportAdmin(ImportBaseAdmin): ...
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(UDiscCSVImport)
|
||||||
|
class UDiscCSVImportAdmin(ImportBaseAdmin): ...
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Genre)
|
@admin.register(Genre)
|
||||||
class GenreAdmin(admin.ModelAdmin):
|
class GenreAdmin(admin.ModelAdmin):
|
||||||
list_display = (
|
list_display = (
|
||||||
@ -118,6 +123,7 @@ class ScrobbleAdmin(admin.ModelAdmin):
|
|||||||
"web_page",
|
"web_page",
|
||||||
"life_event",
|
"life_event",
|
||||||
"birding_location",
|
"birding_location",
|
||||||
|
"disc_golf",
|
||||||
"long_play_last_scrobble",
|
"long_play_last_scrobble",
|
||||||
)
|
)
|
||||||
list_filter = (
|
list_filter = (
|
||||||
@ -179,4 +185,5 @@ class FavoriteMediaAdmin(admin.ModelAdmin):
|
|||||||
"web_page",
|
"web_page",
|
||||||
"life_event",
|
"life_event",
|
||||||
"birding_location",
|
"birding_location",
|
||||||
|
"disc_golf",
|
||||||
)
|
)
|
||||||
|
|||||||
@ -36,6 +36,7 @@ PLAY_AGAIN_MEDIA = {
|
|||||||
"locations": "GeoLocation",
|
"locations": "GeoLocation",
|
||||||
"videos": "Video",
|
"videos": "Video",
|
||||||
"birds": "BirdingLocation",
|
"birds": "BirdingLocation",
|
||||||
|
"discgolf": "DiscGolf",
|
||||||
}
|
}
|
||||||
|
|
||||||
MEDIA_END_PADDING_SECONDS = {
|
MEDIA_END_PADDING_SECONDS = {
|
||||||
@ -73,6 +74,7 @@ MANUAL_SCROBBLE_FNS = {
|
|||||||
"-c": "manual_scrobble_book",
|
"-c": "manual_scrobble_book",
|
||||||
"-f": "manual_scrobble_food",
|
"-f": "manual_scrobble_food",
|
||||||
"-h": "manual_scrobble_twitch_channel",
|
"-h": "manual_scrobble_twitch_channel",
|
||||||
|
"-dg": "manual_scrobble_discgolf",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,7 @@ DEFAULT_RETROARCH_PATH = "var/retroarch/"
|
|||||||
DEFAULT_BGSTATS_PATH = "var/bgstats/"
|
DEFAULT_BGSTATS_PATH = "var/bgstats/"
|
||||||
DEFAULT_EBIRD_PATH = "var/ebird/"
|
DEFAULT_EBIRD_PATH = "var/ebird/"
|
||||||
DEFAULT_SCALE_PATH = "var/scale/"
|
DEFAULT_SCALE_PATH = "var/scale/"
|
||||||
|
DEFAULT_UDISC_PATH = "var/udisc/"
|
||||||
|
|
||||||
|
|
||||||
def import_from_webdav_for_all_users(
|
def import_from_webdav_for_all_users(
|
||||||
@ -48,6 +49,7 @@ def import_from_webdav_for_all_users(
|
|||||||
bgstats_count = 0
|
bgstats_count = 0
|
||||||
ebird_count = 0
|
ebird_count = 0
|
||||||
scale_count = 0
|
scale_count = 0
|
||||||
|
udisc_count = 0
|
||||||
|
|
||||||
for user_id in webdav_enabled_user_ids:
|
for user_id in webdav_enabled_user_ids:
|
||||||
client = get_webdav_client(user_id)
|
client = get_webdav_client(user_id)
|
||||||
@ -78,9 +80,13 @@ def import_from_webdav_for_all_users(
|
|||||||
)
|
)
|
||||||
logger.info("Scanning WebDAV scale for user %s", user_id)
|
logger.info("Scanning WebDAV scale for user %s", user_id)
|
||||||
scale_count += scan_webdav_for_scale(client, user_id)
|
scale_count += scan_webdav_for_scale(client, user_id)
|
||||||
|
logger.info("Scanning WebDAV udisc for user %s", user_id)
|
||||||
|
udisc_count += scan_webdav_for_udisc(
|
||||||
|
client, user_id, include_processed=include_processed
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Started %d KOReader, %d Trail GPX, %d Retroarch, %d BGStats, %d eBird, %d Scale WebDAV imports",
|
"Started %d KOReader, %d Trail GPX, %d Retroarch, %d BGStats, %d eBird, %d Scale, %d uDisc WebDAV imports",
|
||||||
ko_count,
|
ko_count,
|
||||||
gpx_count,
|
gpx_count,
|
||||||
retro_count,
|
retro_count,
|
||||||
@ -94,9 +100,10 @@ def import_from_webdav_for_all_users(
|
|||||||
"bgstats": bgstats_count,
|
"bgstats": bgstats_count,
|
||||||
"ebird": ebird_count,
|
"ebird": ebird_count,
|
||||||
"scale": scale_count,
|
"scale": scale_count,
|
||||||
|
"udisc": udisc_count,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return ko_count, gpx_count, retro_count, bgstats_count, ebird_count, scale_count
|
return ko_count, gpx_count, retro_count, bgstats_count, ebird_count, scale_count, udisc_count
|
||||||
|
|
||||||
|
|
||||||
def scan_webdav_for_koreader(
|
def scan_webdav_for_koreader(
|
||||||
@ -811,3 +818,115 @@ def scan_webdav_for_scale(webdav_client, user_id):
|
|||||||
os.unlink(tmp.name)
|
os.unlink(tmp.name)
|
||||||
|
|
||||||
return new_imports
|
return new_imports
|
||||||
|
|
||||||
|
|
||||||
|
def scan_webdav_for_udisc(webdav_client, user_id, include_processed=False):
|
||||||
|
"""Download .csv files from WebDAV var/udisc/ and queue imports for new files.
|
||||||
|
|
||||||
|
After importing, files are moved to var/udisc/processed/ so they are
|
||||||
|
not re-imported on subsequent scans unless *include_processed* is True.
|
||||||
|
"""
|
||||||
|
from scrobbles.models import UDiscCSVImport
|
||||||
|
from scrobbles.tasks import process_udisc_csv_import
|
||||||
|
|
||||||
|
udisc_path = DEFAULT_UDISC_PATH
|
||||||
|
try:
|
||||||
|
webdav_client.info(udisc_path)
|
||||||
|
except:
|
||||||
|
logger.info("No var/udisc/ directory on webdav", extra={"user_id": user_id})
|
||||||
|
return 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
files = webdav_client.list(udisc_path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Could not list var/udisc/",
|
||||||
|
extra={"user_id": user_id, "error": str(e)},
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
processed_dir = f"{udisc_path}processed/"
|
||||||
|
try:
|
||||||
|
webdav_client.mkdir(processed_dir, recursive=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
new_imports = 0
|
||||||
|
already_imported = set(
|
||||||
|
UDiscCSVImport.objects.filter(user_id=user_id).values_list(
|
||||||
|
"original_filename", flat=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for fname in files:
|
||||||
|
fname = os.path.basename(fname)
|
||||||
|
if not fname.lower().endswith(".csv"):
|
||||||
|
continue
|
||||||
|
if fname == "processed":
|
||||||
|
continue
|
||||||
|
if fname in already_imported:
|
||||||
|
logger.debug(f"Skipping already-imported {fname}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=fname)
|
||||||
|
try:
|
||||||
|
webdav_client.download_sync(
|
||||||
|
remote_path=f"{udisc_path}{fname}", local_path=tmp.name
|
||||||
|
)
|
||||||
|
imp = UDiscCSVImport.objects.create(
|
||||||
|
user_id=user_id,
|
||||||
|
original_filename=fname,
|
||||||
|
)
|
||||||
|
with open(tmp.name, "rb") as f:
|
||||||
|
imp.csv_file.save(fname, f, save=True)
|
||||||
|
|
||||||
|
stem, ext = os.path.splitext(fname)
|
||||||
|
ts = datetime.now().strftime("%Y%m%dT%H%M%S")
|
||||||
|
webdav_client.move(
|
||||||
|
f"{udisc_path}{fname}",
|
||||||
|
f"{processed_dir}{stem}_{ts}{ext}",
|
||||||
|
)
|
||||||
|
|
||||||
|
process_udisc_csv_import.delay(imp.id)
|
||||||
|
new_imports += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to import uDisc CSV file {fname}: {e}")
|
||||||
|
finally:
|
||||||
|
os.unlink(tmp.name)
|
||||||
|
|
||||||
|
if include_processed:
|
||||||
|
try:
|
||||||
|
processed_files = webdav_client.list(processed_dir)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Could not list var/udisc/processed/",
|
||||||
|
extra={"user_id": user_id, "error": str(e)},
|
||||||
|
)
|
||||||
|
return new_imports
|
||||||
|
|
||||||
|
for fname in processed_files:
|
||||||
|
fname = os.path.basename(fname)
|
||||||
|
if not fname.lower().endswith(".csv"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=fname)
|
||||||
|
try:
|
||||||
|
webdav_client.download_sync(
|
||||||
|
remote_path=f"{processed_dir}{fname}", local_path=tmp.name
|
||||||
|
)
|
||||||
|
imp = UDiscCSVImport.objects.create(
|
||||||
|
user_id=user_id,
|
||||||
|
original_filename=fname,
|
||||||
|
)
|
||||||
|
with open(tmp.name, "rb") as f:
|
||||||
|
imp.csv_file.save(fname, f, save=True)
|
||||||
|
process_udisc_csv_import.delay(imp.id)
|
||||||
|
new_imports += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to import processed uDisc CSV file {fname}: {e}"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
os.unlink(tmp.name)
|
||||||
|
|
||||||
|
return new_imports
|
||||||
|
|||||||
@ -0,0 +1,156 @@
|
|||||||
|
# Generated by Django 4.2.29 on 2026-06-20 04:19
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django_extensions.db.fields
|
||||||
|
import scrobbles.models
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("discgolf", "0001_initial"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
("scrobbles", "0098_scrobble_long_play_last_scrobble"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="favoritemedia",
|
||||||
|
name="disc_golf",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="discgolf.discgolfcourse",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="scrobble",
|
||||||
|
name="disc_golf",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||||
|
to="discgolf.discgolfcourse",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="favoritemedia",
|
||||||
|
name="media_type",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("Video", "Video"),
|
||||||
|
("Track", "Track"),
|
||||||
|
("PodcastEpisode", "Podcast episode"),
|
||||||
|
("SportEvent", "Sport event"),
|
||||||
|
("Book", "Book"),
|
||||||
|
("Paper", "Paper"),
|
||||||
|
("VideoGame", "Video game"),
|
||||||
|
("BoardGame", "Board game"),
|
||||||
|
("GeoLocation", "GeoLocation"),
|
||||||
|
("Trail", "Trail"),
|
||||||
|
("Beer", "Beer"),
|
||||||
|
("Puzzle", "Puzzle"),
|
||||||
|
("Food", "Food"),
|
||||||
|
("Task", "Task"),
|
||||||
|
("WebPage", "Web Page"),
|
||||||
|
("LifeEvent", "Life event"),
|
||||||
|
("Mood", "Mood"),
|
||||||
|
("BrickSet", "Brick set"),
|
||||||
|
("Channel", "Channel"),
|
||||||
|
("DiscGolf", "Disc golf"),
|
||||||
|
],
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="scrobble",
|
||||||
|
name="media_type",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("Video", "Video"),
|
||||||
|
("Track", "Track"),
|
||||||
|
("PodcastEpisode", "Podcast episode"),
|
||||||
|
("SportEvent", "Sport event"),
|
||||||
|
("Book", "Book"),
|
||||||
|
("Paper", "Paper"),
|
||||||
|
("VideoGame", "Video game"),
|
||||||
|
("BoardGame", "Board game"),
|
||||||
|
("GeoLocation", "GeoLocation"),
|
||||||
|
("Trail", "Trail"),
|
||||||
|
("Beer", "Beer"),
|
||||||
|
("Puzzle", "Puzzle"),
|
||||||
|
("Food", "Food"),
|
||||||
|
("Task", "Task"),
|
||||||
|
("WebPage", "Web Page"),
|
||||||
|
("LifeEvent", "Life event"),
|
||||||
|
("Mood", "Mood"),
|
||||||
|
("BrickSet", "Brick set"),
|
||||||
|
("Channel", "Channel"),
|
||||||
|
("DiscGolf", "Disc golf"),
|
||||||
|
],
|
||||||
|
default="Video",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="UDiscCSVImport",
|
||||||
|
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(default=uuid.uuid4, editable=False)),
|
||||||
|
("processing_started", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("processed_finished", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("process_log", models.TextField(blank=True, null=True)),
|
||||||
|
("process_count", models.IntegerField(blank=True, null=True)),
|
||||||
|
("error_log", models.TextField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"csv_file",
|
||||||
|
models.FileField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
upload_to=scrobbles.models.UDiscCSVImport.get_path,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"original_filename",
|
||||||
|
models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||||
|
related_name="scrobbles_udisccsvimport_set",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "uDisc CSV Import",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -114,7 +114,7 @@ class ScrobblableMixin(TimeStampedModel):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def start_url(self):
|
def start_url(self):
|
||||||
return reverse("scrobbles:start", kwargs={"uuid": self.uuid})
|
return reverse("scrobbles:start", kwargs={"media_uuid": self.uuid})
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def strings(self) -> ScrobblableConstants:
|
def strings(self) -> ScrobblableConstants:
|
||||||
@ -162,7 +162,7 @@ class LongPlayScrobblableMixin(ScrobblableMixin):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def get_longplay_finish_url(self):
|
def get_longplay_finish_url(self):
|
||||||
return reverse("scrobbles:longplay-finish", kwargs={"uuid": self.uuid})
|
return reverse("scrobbles:longplay-finish", kwargs={"media_uuid": self.uuid})
|
||||||
|
|
||||||
def first_long_play_scrobble_for_user(self, user) -> Optional["Scrobble"]:
|
def first_long_play_scrobble_for_user(self, user) -> Optional["Scrobble"]:
|
||||||
last = self.last_long_play_scrobble_for_user(user)
|
last = self.last_long_play_scrobble_for_user(user)
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import pendulum
|
|||||||
import pytz
|
import pytz
|
||||||
from beers.models import Beer
|
from beers.models import Beer
|
||||||
from birds.models import BirdingLocation
|
from birds.models import BirdingLocation
|
||||||
|
from discgolf.models import DiscGolfCourse
|
||||||
from boardgames.models import BoardGame
|
from boardgames.models import BoardGame
|
||||||
from books.koreader import process_koreader_sqlite_file
|
from books.koreader import process_koreader_sqlite_file
|
||||||
from books.models import Book, BookLogData, BookPageLogData, Paper
|
from books.models import Book, BookLogData, BookPageLogData, Paper
|
||||||
@ -617,6 +618,60 @@ class EBirdCSVImport(BaseFileImportMixin):
|
|||||||
self.mark_finished()
|
self.mark_finished()
|
||||||
|
|
||||||
|
|
||||||
|
class UDiscCSVImport(BaseFileImportMixin):
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "uDisc CSV Import"
|
||||||
|
|
||||||
|
user = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
on_delete=models.DO_NOTHING,
|
||||||
|
**BNULL,
|
||||||
|
related_name="scrobbles_udisccsvimport_set",
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def import_type(self) -> str:
|
||||||
|
return "uDisc"
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse("scrobbles:udisc-csv-import-detail", kwargs={"slug": self.uuid})
|
||||||
|
|
||||||
|
def get_path(instance, filename):
|
||||||
|
extension = filename.split(".")[-1]
|
||||||
|
uuid = instance.uuid
|
||||||
|
return f"udisc-csv-uploads/{uuid}.{extension}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def upload_file_path(self):
|
||||||
|
if getattr(settings, "USE_S3_STORAGE"):
|
||||||
|
path = self.csv_file.url
|
||||||
|
else:
|
||||||
|
path = self.csv_file.path
|
||||||
|
return path
|
||||||
|
|
||||||
|
csv_file = models.FileField(upload_to=get_path, **BNULL)
|
||||||
|
original_filename = models.CharField(max_length=255, **BNULL)
|
||||||
|
|
||||||
|
def process(self, force=False):
|
||||||
|
from discgolf.utils import import_udisc_csv
|
||||||
|
|
||||||
|
if self.processed_finished and not force:
|
||||||
|
logger.info(f"{self} already processed on {self.processed_finished}")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.mark_started()
|
||||||
|
try:
|
||||||
|
scrobbles = import_udisc_csv(
|
||||||
|
self.upload_file_path, self.user_id, record_error=self.record_error
|
||||||
|
)
|
||||||
|
self.record_log(scrobbles)
|
||||||
|
except Exception as e:
|
||||||
|
self.record_error(f"Import failed: {e}")
|
||||||
|
logger.exception(f"Import failed for {self}")
|
||||||
|
finally:
|
||||||
|
self.mark_finished()
|
||||||
|
|
||||||
|
|
||||||
TYPE_FK_PREFETCHES: dict[str, tuple[str, ...]] = {
|
TYPE_FK_PREFETCHES: dict[str, tuple[str, ...]] = {
|
||||||
"Video": ("video",),
|
"Video": ("video",),
|
||||||
"Track": ("track", "track__artist_fk"),
|
"Track": ("track", "track__artist_fk"),
|
||||||
@ -638,6 +693,7 @@ TYPE_FK_PREFETCHES: dict[str, tuple[str, ...]] = {
|
|||||||
"BrickSet": ("brick_set",),
|
"BrickSet": ("brick_set",),
|
||||||
"Channel": ("channel",),
|
"Channel": ("channel",),
|
||||||
"BirdingLocation": ("birding_location",),
|
"BirdingLocation": ("birding_location",),
|
||||||
|
"DiscGolf": ("disc_golf",),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -665,6 +721,7 @@ class ScrobbleQuerySet(models.QuerySet):
|
|||||||
"mood",
|
"mood",
|
||||||
"brick_set",
|
"brick_set",
|
||||||
"birding_location",
|
"birding_location",
|
||||||
|
"disc_golf",
|
||||||
)
|
)
|
||||||
|
|
||||||
def with_related_for_types(self, media_types: list[str]):
|
def with_related_for_types(self, media_types: list[str]):
|
||||||
@ -714,7 +771,7 @@ class Scrobble(TimeStampedModel):
|
|||||||
MOOD = "Mood", "Mood"
|
MOOD = "Mood", "Mood"
|
||||||
BRICKSET = "BrickSet", "Brick set"
|
BRICKSET = "BrickSet", "Brick set"
|
||||||
CHANNEL = "Channel", "Channel"
|
CHANNEL = "Channel", "Channel"
|
||||||
BIRDING_LOCATION = "BirdingLocation", "Birding location"
|
DISC_GOLF = "DiscGolf", "Disc golf"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def list(cls):
|
def list(cls):
|
||||||
@ -745,6 +802,9 @@ class Scrobble(TimeStampedModel):
|
|||||||
birding_location = models.ForeignKey(
|
birding_location = models.ForeignKey(
|
||||||
BirdingLocation, on_delete=models.DO_NOTHING, **BNULL
|
BirdingLocation, on_delete=models.DO_NOTHING, **BNULL
|
||||||
)
|
)
|
||||||
|
disc_golf = models.ForeignKey(
|
||||||
|
DiscGolfCourse, on_delete=models.DO_NOTHING, **BNULL
|
||||||
|
)
|
||||||
media_type = models.CharField(
|
media_type = models.CharField(
|
||||||
max_length=20, choices=MediaType.choices, default=MediaType.VIDEO
|
max_length=20, choices=MediaType.choices, default=MediaType.VIDEO
|
||||||
)
|
)
|
||||||
@ -798,6 +858,12 @@ class Scrobble(TimeStampedModel):
|
|||||||
format="JPEG",
|
format="JPEG",
|
||||||
options={"quality": 75},
|
options={"quality": 75},
|
||||||
)
|
)
|
||||||
|
screenshot_large = ImageSpecField(
|
||||||
|
source="screenshot",
|
||||||
|
processors=[ResizeToFit(800, 800)],
|
||||||
|
format="JPEG",
|
||||||
|
options={"quality": 85},
|
||||||
|
)
|
||||||
long_play_seconds = models.BigIntegerField(**BNULL)
|
long_play_seconds = models.BigIntegerField(**BNULL)
|
||||||
long_play_complete = models.BooleanField(**BNULL)
|
long_play_complete = models.BooleanField(**BNULL)
|
||||||
long_play_last_scrobble = models.ForeignKey(
|
long_play_last_scrobble = models.ForeignKey(
|
||||||
@ -900,7 +966,7 @@ class Scrobble(TimeStampedModel):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def finish_url(self) -> str:
|
def finish_url(self) -> str:
|
||||||
return reverse("scrobbles:finish", kwargs={"uuid": self.uuid})
|
return reverse("scrobbles:finish", kwargs={"pk": self.pk})
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
class_name = self.media_obj.__class__.__name__
|
class_name = self.media_obj.__class__.__name__
|
||||||
@ -942,10 +1008,7 @@ class Scrobble(TimeStampedModel):
|
|||||||
return super(Scrobble, self).save(*args, **kwargs)
|
return super(Scrobble, self).save(*args, **kwargs)
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
if not self.uuid:
|
return reverse("scrobbles:detail", kwargs={"pk": self.pk})
|
||||||
self.uuid = uuid4()
|
|
||||||
self.save(update_fields=["uuid"])
|
|
||||||
return reverse("scrobbles:detail", kwargs={"uuid": self.uuid})
|
|
||||||
|
|
||||||
def get_share_url(self):
|
def get_share_url(self):
|
||||||
if self.visibility == Visibility.PRIVATE:
|
if self.visibility == Visibility.PRIVATE:
|
||||||
@ -1313,6 +1376,8 @@ class Scrobble(TimeStampedModel):
|
|||||||
media_obj = self.channel
|
media_obj = self.channel
|
||||||
if self.birding_location:
|
if self.birding_location:
|
||||||
media_obj = self.birding_location
|
media_obj = self.birding_location
|
||||||
|
if self.disc_golf:
|
||||||
|
media_obj = self.disc_golf
|
||||||
return media_obj
|
return media_obj
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
@ -1871,6 +1936,9 @@ class FavoriteMedia(TimeStampedModel):
|
|||||||
birding_location = models.ForeignKey(
|
birding_location = models.ForeignKey(
|
||||||
BirdingLocation, on_delete=models.CASCADE, **BNULL
|
BirdingLocation, on_delete=models.CASCADE, **BNULL
|
||||||
)
|
)
|
||||||
|
disc_golf = models.ForeignKey(
|
||||||
|
DiscGolfCourse, on_delete=models.CASCADE, **BNULL
|
||||||
|
)
|
||||||
media_type = models.CharField(max_length=20, choices=Scrobble.MediaType.choices)
|
media_type = models.CharField(max_length=20, choices=Scrobble.MediaType.choices)
|
||||||
sent_to_mopidy = models.BooleanField(default=False)
|
sent_to_mopidy = models.BooleanField(default=False)
|
||||||
|
|
||||||
@ -1921,6 +1989,8 @@ class FavoriteMedia(TimeStampedModel):
|
|||||||
media_obj = self.channel
|
media_obj = self.channel
|
||||||
if self.birding_location:
|
if self.birding_location:
|
||||||
media_obj = self.birding_location
|
media_obj = self.birding_location
|
||||||
|
if self.disc_golf:
|
||||||
|
media_obj = self.disc_golf
|
||||||
return media_obj
|
return media_obj
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -1950,6 +2020,7 @@ class FavoriteMedia(TimeStampedModel):
|
|||||||
"Mood": "mood",
|
"Mood": "mood",
|
||||||
"BrickSet": "brick_set",
|
"BrickSet": "brick_set",
|
||||||
"BirdingLocation": "birding_location",
|
"BirdingLocation": "birding_location",
|
||||||
|
"DiscGolf": "disc_golf",
|
||||||
}
|
}
|
||||||
|
|
||||||
fk = fk_map.get(media_type)
|
fk = fk_map.get(media_type)
|
||||||
|
|||||||
@ -13,6 +13,7 @@ from books.models import Book, BookLogData, BookPageLogData
|
|||||||
from books.utils import parse_readcomicsonline_uri
|
from books.utils import parse_readcomicsonline_uri
|
||||||
from bricksets.models import BrickSet
|
from bricksets.models import BrickSet
|
||||||
from dateutil.parser import parse
|
from dateutil.parser import parse
|
||||||
|
from discgolf.models import DiscGolfCourse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from foods.models import Food
|
from foods.models import Food
|
||||||
from foods.sources.rscraper import RecipeScraperService
|
from foods.sources.rscraper import RecipeScraperService
|
||||||
@ -330,8 +331,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 = {
|
||||||
@ -1329,3 +1328,32 @@ def manual_scrobble_food(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return Scrobble.create_or_update(food, user_id, scrobble_dict)
|
return Scrobble.create_or_update(food, user_id, scrobble_dict)
|
||||||
|
|
||||||
|
|
||||||
|
def manual_scrobble_discgolf(
|
||||||
|
item_id: str,
|
||||||
|
user_id: int,
|
||||||
|
source: str = "Vrobbler",
|
||||||
|
action: Optional[str] = None,
|
||||||
|
):
|
||||||
|
from discgolf.models import DiscGolfCourse
|
||||||
|
|
||||||
|
course, _ = DiscGolfCourse.objects.get_or_create(title=item_id.strip())
|
||||||
|
|
||||||
|
scrobble_dict = {
|
||||||
|
"user_id": user_id,
|
||||||
|
"timestamp": timezone.now(),
|
||||||
|
"playback_position_seconds": 0,
|
||||||
|
"source": source,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"[scrobblers] manual disc golf scrobble request received",
|
||||||
|
extra={
|
||||||
|
"course_id": course.id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"scrobble_dict": scrobble_dict,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return Scrobble.create_or_update(course, user_id, 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
|
||||||
|
|
||||||
@ -170,6 +171,16 @@ def process_ebird_csv_import(import_id):
|
|||||||
birding_import.process()
|
birding_import.process()
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def process_udisc_csv_import(import_id):
|
||||||
|
UDiscCSVImport = apps.get_model("scrobbles", "UDiscCSVImport")
|
||||||
|
udisc_import = UDiscCSVImport.objects.filter(id=import_id).first()
|
||||||
|
if not udisc_import:
|
||||||
|
logger.warn(f"UDiscCSVImport not found with id {import_id}")
|
||||||
|
return
|
||||||
|
udisc_import.process()
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def process_scale_csv_import(import_id):
|
def process_scale_csv_import(import_id):
|
||||||
ScaleCSVImport = apps.get_model("scrobbles", "ScaleCSVImport")
|
ScaleCSVImport = apps.get_model("scrobbles", "ScaleCSVImport")
|
||||||
@ -241,6 +252,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 +266,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
|
||||||
|
|||||||
@ -44,7 +44,7 @@ urlpatterns = [
|
|||||||
name="lookup-manual-scrobble",
|
name="lookup-manual-scrobble",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"long-play-finish/<slug:uuid>/",
|
"long-play-finish/<slug:media_uuid>/",
|
||||||
views.scrobble_longplay_finish,
|
views.scrobble_longplay_finish,
|
||||||
name="longplay-finish",
|
name="longplay-finish",
|
||||||
),
|
),
|
||||||
@ -147,6 +147,11 @@ urlpatterns = [
|
|||||||
views.ScrobbleBirdingCSVImportDetailView.as_view(),
|
views.ScrobbleBirdingCSVImportDetailView.as_view(),
|
||||||
name="ebird-csv-import-detail",
|
name="ebird-csv-import-detail",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"imports/udisc-csv/<slug:slug>/",
|
||||||
|
views.ScrobbleUDiscCSVImportDetailView.as_view(),
|
||||||
|
name="udisc-csv-import-detail",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"long-plays/",
|
"long-plays/",
|
||||||
views.ScrobbleLongPlaysView.as_view(),
|
views.ScrobbleLongPlaysView.as_view(),
|
||||||
@ -160,38 +165,38 @@ urlpatterns = [
|
|||||||
name="shared-detail",
|
name="shared-detail",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"scrobbles/<slug:uuid>/",
|
"scrobbles/<int:pk>/",
|
||||||
views.ScrobbleDetailView.as_view(),
|
views.ScrobbleDetailView.as_view(),
|
||||||
name="detail",
|
name="detail",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"scrobbles/<slug:uuid>/regenerate-share-token/",
|
"scrobbles/<int:pk>/regenerate-share-token/",
|
||||||
views.RegenerateShareTokenView.as_view(),
|
views.RegenerateShareTokenView.as_view(),
|
||||||
name="regenerate-share-token",
|
name="regenerate-share-token",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"scrobbles/<slug:uuid>/change-visibility/",
|
"scrobbles/<int:pk>/change-visibility/",
|
||||||
views.ChangeVisibilityView.as_view(),
|
views.ChangeVisibilityView.as_view(),
|
||||||
name="change-visibility",
|
name="change-visibility",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"scrobbles/<slug:uuid>/share-analytics/",
|
"scrobbles/<int:pk>/share-analytics/",
|
||||||
views.ScrobbleShareAnalyticsView.as_view(),
|
views.ScrobbleShareAnalyticsView.as_view(),
|
||||||
name="share-analytics",
|
name="share-analytics",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"scrobbles/<slug:uuid>/add-to-mopidy-queue/",
|
"scrobbles/<int:pk>/add-to-mopidy-queue/",
|
||||||
views.add_to_mopidy_queue,
|
views.add_to_mopidy_queue,
|
||||||
name="add-to-mopidy-queue",
|
name="add-to-mopidy-queue",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"scrobbles/<slug:uuid>/add-to-mopidy-monthly-playlist/",
|
"scrobbles/<int:pk>/add-to-mopidy-monthly-playlist/",
|
||||||
views.add_to_mopidy_monthly_playlist,
|
views.add_to_mopidy_monthly_playlist,
|
||||||
name="add-to-mopidy-monthly-playlist",
|
name="add-to-mopidy-monthly-playlist",
|
||||||
),
|
),
|
||||||
path("scrobbles/<slug:uuid>/start/", views.scrobble_start, name="start"),
|
path("scrobbles/<slug:media_uuid>/start/", views.scrobble_start, name="start"),
|
||||||
path("scrobbles/<slug:uuid>/finish/", views.scrobble_finish, name="finish"),
|
path("scrobbles/<int:pk>/finish/", views.scrobble_finish, name="finish"),
|
||||||
path("scrobbles/<slug:uuid>/cancel/", views.scrobble_cancel, name="cancel"),
|
path("scrobbles/<int:pk>/cancel/", views.scrobble_cancel, name="cancel"),
|
||||||
path(
|
path(
|
||||||
"favorite/<str:media_type>/<int:object_id>/toggle/",
|
"favorite/<str:media_type>/<int:object_id>/toggle/",
|
||||||
views.toggle_favorite,
|
views.toggle_favorite,
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
|
import html
|
||||||
import logging
|
import logging
|
||||||
import requests
|
import requests
|
||||||
import re
|
import re
|
||||||
@ -153,10 +154,11 @@ def import_lastfm_for_all_users(restart=False):
|
|||||||
last_processed = lfm_import.processed_finished
|
last_processed = lfm_import.processed_finished
|
||||||
else:
|
else:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Not resuming failed LastFM import {lfm_import.id} for user {user_id}, use restart=True to restart"
|
"No existing LastFM import for user %s, "
|
||||||
"No existing LastFM import, we should start a monthly parsing of lastFm for this user going back to 2002"
|
"starting a full parse",
|
||||||
|
user_id,
|
||||||
)
|
)
|
||||||
continue
|
last_processed = None
|
||||||
|
|
||||||
lfm_client = LastFM(user=get_user_model().objects.filter(id=user_id).first())
|
lfm_client = LastFM(user=get_user_model().objects.filter(id=user_id).first())
|
||||||
|
|
||||||
@ -795,6 +797,7 @@ def tokenize_title_to_tags(title: str) -> list[str]:
|
|||||||
if not title:
|
if not title:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
title = html.unescape(title)
|
||||||
cleaned = re.sub(r"[\(\)\[\]\{\}]", "", title)
|
cleaned = re.sub(r"[\(\)\[\]\{\}]", "", title)
|
||||||
cleaned = re.sub(r"[^\w\s]", "", cleaned)
|
cleaned = re.sub(r"[^\w\s]", "", cleaned)
|
||||||
|
|
||||||
|
|||||||
@ -87,6 +87,7 @@ from scrobbles.models import (
|
|||||||
ScrobbleQuerySet,
|
ScrobbleQuerySet,
|
||||||
ShareViewLog,
|
ShareViewLog,
|
||||||
TrailGPXImport,
|
TrailGPXImport,
|
||||||
|
UDiscCSVImport,
|
||||||
)
|
)
|
||||||
from scrobbles.scrobblers import *
|
from scrobbles.scrobblers import *
|
||||||
from scrobbles.tasks import (
|
from scrobbles.tasks import (
|
||||||
@ -535,6 +536,8 @@ class BaseScrobbleImportDetailView(DetailView):
|
|||||||
title = "Scale CSV Import"
|
title = "Scale CSV Import"
|
||||||
if self.model == TrailGPXImport:
|
if self.model == TrailGPXImport:
|
||||||
title = "Trail GPX Import"
|
title = "Trail GPX Import"
|
||||||
|
if self.model == UDiscCSVImport:
|
||||||
|
title = "uDisc CSV Import"
|
||||||
context_data["title"] = title
|
context_data["title"] = title
|
||||||
return context_data
|
return context_data
|
||||||
|
|
||||||
@ -571,6 +574,10 @@ class ScrobbleBirdingCSVImportDetailView(BaseScrobbleImportDetailView):
|
|||||||
model = EBirdCSVImport
|
model = EBirdCSVImport
|
||||||
|
|
||||||
|
|
||||||
|
class ScrobbleUDiscCSVImportDetailView(BaseScrobbleImportDetailView):
|
||||||
|
model = UDiscCSVImport
|
||||||
|
|
||||||
|
|
||||||
class ManualScrobbleView(FormView):
|
class ManualScrobbleView(FormView):
|
||||||
form_class = ScrobbleForm
|
form_class = ScrobbleForm
|
||||||
template_name = "scrobbles/manual_form.html"
|
template_name = "scrobbles/manual_form.html"
|
||||||
@ -839,10 +846,10 @@ def import_audioscrobbler_file(request):
|
|||||||
|
|
||||||
@api_view(["GET"])
|
@api_view(["GET"])
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
def scrobble_start(request, uuid):
|
def scrobble_start(request, media_uuid):
|
||||||
logger.info(
|
logger.info(
|
||||||
"[scrobble_start] called",
|
"[scrobble_start] called",
|
||||||
extra={"request": request, "uuid": uuid},
|
extra={"request": request, "media_uuid": media_uuid},
|
||||||
)
|
)
|
||||||
user = request.user
|
user = request.user
|
||||||
success_url = request.META.get("HTTP_REFERER")
|
success_url = request.META.get("HTTP_REFERER")
|
||||||
@ -853,14 +860,14 @@ def scrobble_start(request, uuid):
|
|||||||
media_obj = None
|
media_obj = None
|
||||||
for app, model in PLAY_AGAIN_MEDIA.items():
|
for app, model in PLAY_AGAIN_MEDIA.items():
|
||||||
media_model = apps.get_model(app_label=app, model_name=model)
|
media_model = apps.get_model(app_label=app, model_name=model)
|
||||||
media_obj = media_model.objects.filter(uuid=uuid).first()
|
media_obj = media_model.objects.filter(uuid=media_uuid).first()
|
||||||
if media_obj:
|
if media_obj:
|
||||||
break
|
break
|
||||||
|
|
||||||
if not media_obj:
|
if not media_obj:
|
||||||
logger.info(
|
logger.info(
|
||||||
"[scrobble_start] media object not found",
|
"[scrobble_start] media object not found",
|
||||||
extra={"uuid": uuid, "user_id": user.id},
|
extra={"media_uuid": media_uuid, "user_id": user.id},
|
||||||
)
|
)
|
||||||
raise Exception("No media object provided to scrobble")
|
raise Exception("No media object provided to scrobble")
|
||||||
|
|
||||||
@ -897,7 +904,7 @@ def scrobble_start(request, uuid):
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
messages.add_message(
|
messages.add_message(
|
||||||
request, messages.ERROR, f"Media with uuid {uuid} not found."
|
request, messages.ERROR, f"Media with uuid {media_uuid} not found."
|
||||||
)
|
)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -915,7 +922,7 @@ def scrobble_start(request, uuid):
|
|||||||
|
|
||||||
|
|
||||||
@api_view(["GET"])
|
@api_view(["GET"])
|
||||||
def scrobble_longplay_finish(request, uuid):
|
def scrobble_longplay_finish(request, media_uuid):
|
||||||
user = request.user
|
user = request.user
|
||||||
success_url = request.META.get("HTTP_REFERER")
|
success_url = request.META.get("HTTP_REFERER")
|
||||||
|
|
||||||
@ -923,7 +930,7 @@ def scrobble_longplay_finish(request, uuid):
|
|||||||
return HttpResponseRedirect(success_url)
|
return HttpResponseRedirect(success_url)
|
||||||
|
|
||||||
# Try scrobble UUID first
|
# Try scrobble UUID first
|
||||||
scrobble = Scrobble.objects.filter(uuid=uuid, user=user).first()
|
scrobble = Scrobble.objects.filter(uuid=media_uuid, user=user).first()
|
||||||
if scrobble:
|
if scrobble:
|
||||||
if scrobble.long_play_complete == True:
|
if scrobble.long_play_complete == True:
|
||||||
scrobble.long_play_complete = None
|
scrobble.long_play_complete = None
|
||||||
@ -947,13 +954,13 @@ def scrobble_longplay_finish(request, uuid):
|
|||||||
media_obj = None
|
media_obj = None
|
||||||
for app, model in LONG_PLAY_MEDIA.items():
|
for app, model in LONG_PLAY_MEDIA.items():
|
||||||
media_model = apps.get_model(app_label=app, model_name=model)
|
media_model = apps.get_model(app_label=app, model_name=model)
|
||||||
media_obj = media_model.objects.filter(uuid=uuid).first()
|
media_obj = media_model.objects.filter(uuid=media_uuid).first()
|
||||||
if media_obj:
|
if media_obj:
|
||||||
break
|
break
|
||||||
|
|
||||||
if not media_obj:
|
if not media_obj:
|
||||||
messages.add_message(
|
messages.add_message(
|
||||||
request, messages.ERROR, f"Media with uuid {uuid} not found."
|
request, messages.ERROR, f"Media with uuid {media_uuid} not found."
|
||||||
)
|
)
|
||||||
return HttpResponseRedirect(success_url)
|
return HttpResponseRedirect(success_url)
|
||||||
|
|
||||||
@ -976,14 +983,14 @@ def scrobble_longplay_finish(request, uuid):
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
messages.add_message(
|
messages.add_message(
|
||||||
request, messages.ERROR, f"Media with uuid {uuid} not found."
|
request, messages.ERROR, f"Media with uuid {media_uuid} not found."
|
||||||
)
|
)
|
||||||
return HttpResponseRedirect(success_url)
|
return HttpResponseRedirect(success_url)
|
||||||
|
|
||||||
|
|
||||||
@api_view(["GET"])
|
@api_view(["GET"])
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
def scrobble_finish(request, uuid):
|
def scrobble_finish(request, pk):
|
||||||
user = request.user
|
user = request.user
|
||||||
success_url = request.META.get("HTTP_REFERER")
|
success_url = request.META.get("HTTP_REFERER")
|
||||||
if not success_url:
|
if not success_url:
|
||||||
@ -992,7 +999,7 @@ def scrobble_finish(request, uuid):
|
|||||||
if not user.is_authenticated:
|
if not user.is_authenticated:
|
||||||
return HttpResponseRedirect(success_url)
|
return HttpResponseRedirect(success_url)
|
||||||
|
|
||||||
scrobble = Scrobble.objects.filter(user=user, uuid=uuid).first()
|
scrobble = Scrobble.objects.filter(user=user, pk=pk).first()
|
||||||
if scrobble:
|
if scrobble:
|
||||||
scrobble.stop(force_finish=True)
|
scrobble.stop(force_finish=True)
|
||||||
messages.add_message(
|
messages.add_message(
|
||||||
@ -1007,14 +1014,14 @@ def scrobble_finish(request, uuid):
|
|||||||
|
|
||||||
@api_view(["GET"])
|
@api_view(["GET"])
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
def scrobble_cancel(request, uuid):
|
def scrobble_cancel(request, pk):
|
||||||
user = request.user
|
user = request.user
|
||||||
success_url = reverse_lazy("vrobbler-home")
|
success_url = reverse_lazy("vrobbler-home")
|
||||||
|
|
||||||
if not user.is_authenticated:
|
if not user.is_authenticated:
|
||||||
return HttpResponseRedirect(success_url)
|
return HttpResponseRedirect(success_url)
|
||||||
|
|
||||||
scrobble = Scrobble.objects.filter(user=user, uuid=uuid).first()
|
scrobble = Scrobble.objects.filter(user=user, pk=pk).first()
|
||||||
if scrobble:
|
if scrobble:
|
||||||
scrobble.cancel()
|
scrobble.cancel()
|
||||||
messages.add_message(
|
messages.add_message(
|
||||||
@ -1028,11 +1035,11 @@ def scrobble_cancel(request, uuid):
|
|||||||
|
|
||||||
|
|
||||||
@require_POST
|
@require_POST
|
||||||
def add_to_mopidy_queue(request, uuid):
|
def add_to_mopidy_queue(request, pk):
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
return redirect("scrobbles:detail", uuid=uuid)
|
return redirect("scrobbles:detail", pk=pk)
|
||||||
|
|
||||||
scrobble = get_object_or_404(Scrobble, uuid=uuid, user=request.user)
|
scrobble = get_object_or_404(Scrobble, pk=pk, user=request.user)
|
||||||
mopidy_url = request.user.profile.mopidy_api_url
|
mopidy_url = request.user.profile.mopidy_api_url
|
||||||
|
|
||||||
if not mopidy_url:
|
if not mopidy_url:
|
||||||
@ -1041,22 +1048,22 @@ def add_to_mopidy_queue(request, uuid):
|
|||||||
messages.ERROR,
|
messages.ERROR,
|
||||||
"Mopidy API URL not configured in your profile settings.",
|
"Mopidy API URL not configured in your profile settings.",
|
||||||
)
|
)
|
||||||
return redirect("scrobbles:detail", uuid=uuid)
|
return redirect("scrobbles:detail", pk=pk)
|
||||||
|
|
||||||
from scrobbles.tasks import add_scrobble_to_mopidy_queue as task
|
from scrobbles.tasks import add_scrobble_to_mopidy_queue as task
|
||||||
|
|
||||||
task.delay(scrobble.id)
|
task.delay(scrobble.id)
|
||||||
msg = f'Adding "{scrobble.media_obj}" to Mopidy queue.'
|
msg = f'Adding "{scrobble.media_obj}" to Mopidy queue.'
|
||||||
messages.add_message(request, messages.SUCCESS, msg)
|
messages.add_message(request, messages.SUCCESS, msg)
|
||||||
return redirect("scrobbles:detail", uuid=uuid)
|
return redirect("scrobbles:detail", pk=pk)
|
||||||
|
|
||||||
|
|
||||||
@require_POST
|
@require_POST
|
||||||
def add_to_mopidy_monthly_playlist(request, uuid):
|
def add_to_mopidy_monthly_playlist(request, pk):
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
return redirect("scrobbles:detail", uuid=uuid)
|
return redirect("scrobbles:detail", pk=pk)
|
||||||
|
|
||||||
scrobble = get_object_or_404(Scrobble, uuid=uuid, user=request.user)
|
scrobble = get_object_or_404(Scrobble, pk=pk, user=request.user)
|
||||||
profile = request.user.profile
|
profile = request.user.profile
|
||||||
pattern = profile.monthly_mopidy_playlist_pattern
|
pattern = profile.monthly_mopidy_playlist_pattern
|
||||||
|
|
||||||
@ -1066,7 +1073,7 @@ def add_to_mopidy_monthly_playlist(request, uuid):
|
|||||||
messages.ERROR,
|
messages.ERROR,
|
||||||
"Monthly playlist pattern or Mopidy API URL not configured in your profile.",
|
"Monthly playlist pattern or Mopidy API URL not configured in your profile.",
|
||||||
)
|
)
|
||||||
return redirect("scrobbles:detail", uuid=uuid)
|
return redirect("scrobbles:detail", pk=pk)
|
||||||
|
|
||||||
now = now_user_timezone(profile)
|
now = now_user_timezone(profile)
|
||||||
playlist_name = DateFormat(now).format(pattern)
|
playlist_name = DateFormat(now).format(pattern)
|
||||||
@ -1079,7 +1086,7 @@ def add_to_mopidy_monthly_playlist(request, uuid):
|
|||||||
messages.SUCCESS,
|
messages.SUCCESS,
|
||||||
f'Adding "{scrobble.media_obj}" to monthly playlist "{playlist_name}".',
|
f'Adding "{scrobble.media_obj}" to monthly playlist "{playlist_name}".',
|
||||||
)
|
)
|
||||||
return redirect("scrobbles:detail", uuid=uuid)
|
return redirect("scrobbles:detail", pk=pk)
|
||||||
|
|
||||||
|
|
||||||
@require_POST
|
@require_POST
|
||||||
@ -1108,6 +1115,7 @@ def toggle_favorite(request, media_type, object_id):
|
|||||||
"Mood": ("moods", "Mood"),
|
"Mood": ("moods", "Mood"),
|
||||||
"BrickSet": ("bricksets", "BrickSet"),
|
"BrickSet": ("bricksets", "BrickSet"),
|
||||||
"BirdingLocation": ("birds", "BirdingLocation"),
|
"BirdingLocation": ("birds", "BirdingLocation"),
|
||||||
|
"DiscGolf": ("discgolf", "DiscGolfCourse"),
|
||||||
}
|
}
|
||||||
|
|
||||||
app_label, model_name = app_model_map.get(media_type, (None, None))
|
app_label, model_name = app_model_map.get(media_type, (None, None))
|
||||||
@ -1184,8 +1192,6 @@ class ScrobbleStatusView(LoginRequiredMixin, TemplateView):
|
|||||||
|
|
||||||
class ScrobbleDetailView(DetailView):
|
class ScrobbleDetailView(DetailView):
|
||||||
model = Scrobble
|
model = Scrobble
|
||||||
slug_field = "uuid"
|
|
||||||
slug_url_kwarg = "uuid"
|
|
||||||
paginate_by = 100
|
paginate_by = 100
|
||||||
|
|
||||||
def get_object(self, queryset=None):
|
def get_object(self, queryset=None):
|
||||||
@ -1385,15 +1391,15 @@ class ScrobbleExploreView(ListView):
|
|||||||
|
|
||||||
|
|
||||||
class RegenerateShareTokenView(LoginRequiredMixin, View):
|
class RegenerateShareTokenView(LoginRequiredMixin, View):
|
||||||
def post(self, request, uuid):
|
def post(self, request, pk):
|
||||||
scrobble = get_object_or_404(Scrobble, uuid=uuid, user=request.user)
|
scrobble = get_object_or_404(Scrobble, pk=pk, user=request.user)
|
||||||
scrobble.regenerate_share_token()
|
scrobble.regenerate_share_token()
|
||||||
return redirect(scrobble.get_absolute_url())
|
return redirect(scrobble.get_absolute_url())
|
||||||
|
|
||||||
|
|
||||||
class ChangeVisibilityView(LoginRequiredMixin, View):
|
class ChangeVisibilityView(LoginRequiredMixin, View):
|
||||||
def post(self, request, uuid):
|
def post(self, request, pk):
|
||||||
scrobble = get_object_or_404(Scrobble, uuid=uuid, user=request.user)
|
scrobble = get_object_or_404(Scrobble, pk=pk, user=request.user)
|
||||||
visibility = request.POST.get("visibility")
|
visibility = request.POST.get("visibility")
|
||||||
if visibility not in (Visibility.PUBLIC, Visibility.SHARED, Visibility.PRIVATE):
|
if visibility not in (Visibility.PUBLIC, Visibility.SHARED, Visibility.PRIVATE):
|
||||||
return redirect(scrobble.get_absolute_url())
|
return redirect(scrobble.get_absolute_url())
|
||||||
@ -1404,8 +1410,6 @@ class ChangeVisibilityView(LoginRequiredMixin, View):
|
|||||||
|
|
||||||
class ScrobbleShareAnalyticsView(LoginRequiredMixin, DetailView):
|
class ScrobbleShareAnalyticsView(LoginRequiredMixin, DetailView):
|
||||||
model = Scrobble
|
model = Scrobble
|
||||||
slug_field = "uuid"
|
|
||||||
slug_url_kwarg = "uuid"
|
|
||||||
template_name = "scrobbles/scrobble_share_analytics.html"
|
template_name = "scrobbles/scrobble_share_analytics.html"
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
@ -1721,6 +1725,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": (
|
||||||
|
|||||||
@ -8,7 +8,6 @@ PERIOD_CHOICES = [
|
|||||||
("last_30", "Last 30 days"),
|
("last_30", "Last 30 days"),
|
||||||
("last_90", "Last 90 days"),
|
("last_90", "Last 90 days"),
|
||||||
("last_year", "Last year"),
|
("last_year", "Last year"),
|
||||||
("all_time", "All time"),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -18,7 +17,7 @@ class TrendResult(TimeStampedModel):
|
|||||||
period = models.CharField(
|
period = models.CharField(
|
||||||
max_length=20,
|
max_length=20,
|
||||||
choices=PERIOD_CHOICES,
|
choices=PERIOD_CHOICES,
|
||||||
default="all_time",
|
default="last_30",
|
||||||
)
|
)
|
||||||
computed_at = models.DateTimeField(auto_now_add=True)
|
computed_at = models.DateTimeField(auto_now_add=True)
|
||||||
data = models.JSONField(default=dict)
|
data = models.JSONField(default=dict)
|
||||||
|
|||||||
78
vrobbler/apps/trends/templates/trends/_mood_by_time.html
Normal file
78
vrobbler/apps/trends/templates/trends/_mood_by_time.html
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h5 class="mb-3">By Hour of Day</h5>
|
||||||
|
{% if data.hours %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Hour</th>
|
||||||
|
<th class="text-end">Avg Quality</th>
|
||||||
|
<th class="text-end">Check-ins</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for entry in data.hours %}
|
||||||
|
{% if entry.count > 0 %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{% if entry.hour == 0 %}
|
||||||
|
12 AM
|
||||||
|
{% elif entry.hour < 12 %}
|
||||||
|
{{ entry.hour }} AM
|
||||||
|
{% elif entry.hour == 12 %}
|
||||||
|
12 PM
|
||||||
|
{% else %}
|
||||||
|
{{ entry.hour|add:"-12" }} PM
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<span class="{% if entry.avg_quality >= 5 %}text-success{% elif entry.avg_quality >= 4 %}text-info{% elif entry.avg_quality >= 3 %}text-warning{% else %}text-danger{% endif %}">
|
||||||
|
{{ entry.avg_quality }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-end">{{ entry.count }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted">No hourly data.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h5 class="mb-3">By Day of Week</h5>
|
||||||
|
{% if data.days %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Day</th>
|
||||||
|
<th class="text-end">Avg Quality</th>
|
||||||
|
<th class="text-end">Check-ins</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for entry in data.days %}
|
||||||
|
{% if entry.count > 0 %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ entry.day_name }}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<span class="{% if entry.avg_quality >= 5 %}text-success{% elif entry.avg_quality >= 4 %}text-info{% elif entry.avg_quality >= 3 %}text-warning{% else %}text-danger{% endif %}">
|
||||||
|
{{ entry.avg_quality }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-end">{{ entry.count }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted">No daily data.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
{% if data.moods %}
|
||||||
|
<p class="text-muted mb-3">
|
||||||
|
Total mood check-ins{% if current_period_label %} ({{ current_period_label }}){% endif %}: <strong>{{ data.total }}</strong>
|
||||||
|
· Positive: <strong>{{ data.positive_count }}</strong>
|
||||||
|
· Negative: <strong>{{ data.negative_count }}</strong>
|
||||||
|
</p>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Mood</th>
|
||||||
|
<th class="text-end">Count</th>
|
||||||
|
<th>Distribution</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% with max=data.moods.0.count %}
|
||||||
|
{% for entry in data.moods %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ entry.mood }}</td>
|
||||||
|
<td class="text-end">{{ entry.count }}</td>
|
||||||
|
<td style="width: 40%;">
|
||||||
|
{% if max > 0 %}
|
||||||
|
<div class="progress" style="height: 12px;">
|
||||||
|
<div class="progress-bar" role="progressbar"
|
||||||
|
style="width: {% widthratio entry.count max 100 %}%;"
|
||||||
|
aria-valuenow="{{ entry.count }}"
|
||||||
|
aria-valuemin="0" aria-valuemax="{{ max }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% endwith %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted">No mood distribution data found.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
47
vrobbler/apps/trends/templates/trends/_mood_streaks.html
Normal file
47
vrobbler/apps/trends/templates/trends/_mood_streaks.html
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
{% if data.current_streak %}
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<strong>Current streak:</strong>
|
||||||
|
{{ data.current_streak.length }} consecutive
|
||||||
|
<span class="{% if data.current_streak.mood_type == 'positive' %}text-success{% else %}text-danger{% endif %}">
|
||||||
|
{{ data.current_streak.mood_type }}
|
||||||
|
</span>
|
||||||
|
check-ins since {{ data.current_streak.start_date }}.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if data.streaks %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Mood Type</th>
|
||||||
|
<th class="text-end">Length</th>
|
||||||
|
<th>Start</th>
|
||||||
|
<th>End</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for streak in data.streaks %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ forloop.counter }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="{% if streak.mood_type == 'positive' %}text-success{% elif streak.mood_type == 'negative' %}text-danger{% endif %}">
|
||||||
|
{{ streak.mood_type|title }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-end">{{ streak.length }}</td>
|
||||||
|
<td>{{ streak.start_date }}</td>
|
||||||
|
<td>{{ streak.end_date }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted">No streak data found.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
39
vrobbler/apps/trends/templates/trends/_mood_trajectory.html
Normal file
39
vrobbler/apps/trends/templates/trends/_mood_trajectory.html
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
{% if data.trajectory %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th class="text-end">Avg Quality</th>
|
||||||
|
<th class="text-end">Check-ins</th>
|
||||||
|
<th>Mood Bar</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for entry in data.trajectory %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ entry.date }}</td>
|
||||||
|
<td class="text-end">{{ entry.avg_quality }}</td>
|
||||||
|
<td class="text-end">{{ entry.count }}</td>
|
||||||
|
<td style="width: 40%;">
|
||||||
|
<div class="progress" style="height: 16px;">
|
||||||
|
<div class="progress-bar {% if entry.avg_quality >= 5 %}bg-success{% elif entry.avg_quality >= 4 %}bg-info{% elif entry.avg_quality >= 3 %}bg-warning{% else %}bg-danger{% endif %}"
|
||||||
|
role="progressbar"
|
||||||
|
style="width: {% widthratio entry.avg_quality 7 100 %}%;"
|
||||||
|
aria-valuenow="{{ entry.avg_quality }}"
|
||||||
|
aria-valuemin="1" aria-valuemax="7">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted">No mood check-in data found.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
64
vrobbler/apps/trends/templates/trends/_mood_weather.html
Normal file
64
vrobbler/apps/trends/templates/trends/_mood_weather.html
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h5 class="mb-3">By Weather Condition</h5>
|
||||||
|
{% if data.conditions %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Condition</th>
|
||||||
|
<th class="text-end">Avg Quality</th>
|
||||||
|
<th class="text-end">Check-ins</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for entry in data.conditions %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ entry.condition }}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<span class="{% if entry.avg_quality >= 5 %}text-success{% elif entry.avg_quality >= 4 %}text-info{% elif entry.avg_quality >= 3 %}text-warning{% else %}text-danger{% endif %}">
|
||||||
|
{{ entry.avg_quality }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-end">{{ entry.count }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted">No weather-linked mood data found.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h5 class="mb-3">By Temperature Range</h5>
|
||||||
|
{% if data.temp_ranges %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Temp Range</th>
|
||||||
|
<th class="text-end">Avg Quality</th>
|
||||||
|
<th class="text-end">Check-ins</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for entry in data.temp_ranges %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ entry.range }}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<span class="{% if entry.avg_quality >= 5 %}text-success{% elif entry.avg_quality >= 4 %}text-info{% elif entry.avg_quality >= 3 %}text-warning{% else %}text-danger{% endif %}">
|
||||||
|
{{ entry.avg_quality }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-end">{{ entry.count }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted">No temperature-linked mood data found.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@ -67,5 +67,20 @@
|
|||||||
{% elif trend.slug == "activity-distribution" %}
|
{% elif trend.slug == "activity-distribution" %}
|
||||||
{% include "trends/_activity_distribution.html" %}
|
{% include "trends/_activity_distribution.html" %}
|
||||||
|
|
||||||
|
{% elif trend.slug == "mood-trajectory" %}
|
||||||
|
{% include "trends/_mood_trajectory.html" %}
|
||||||
|
|
||||||
|
{% elif trend.slug == "mood-by-time" %}
|
||||||
|
{% include "trends/_mood_by_time.html" %}
|
||||||
|
|
||||||
|
{% elif trend.slug == "mood-distribution" %}
|
||||||
|
{% include "trends/_mood_distribution.html" %}
|
||||||
|
|
||||||
|
{% elif trend.slug == "mood-streaks" %}
|
||||||
|
{% include "trends/_mood_streaks.html" %}
|
||||||
|
|
||||||
|
{% elif trend.slug == "mood-weather" %}
|
||||||
|
{% include "trends/_mood_weather.html" %}
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -7,6 +7,13 @@ from trends.trends.concurrent import (
|
|||||||
compute_concurrent_listening,
|
compute_concurrent_listening,
|
||||||
compute_concurrent_reading,
|
compute_concurrent_reading,
|
||||||
)
|
)
|
||||||
|
from trends.trends.mood import (
|
||||||
|
compute_mood_by_time,
|
||||||
|
compute_mood_distribution,
|
||||||
|
compute_mood_streaks,
|
||||||
|
compute_mood_trajectory,
|
||||||
|
compute_mood_weather,
|
||||||
|
)
|
||||||
from trends.trends.reading import compute_reading_pace_vs_activity
|
from trends.trends.reading import compute_reading_pace_vs_activity
|
||||||
from trends.trends.trending import compute_trending_up
|
from trends.trends.trending import compute_trending_up
|
||||||
|
|
||||||
@ -28,6 +35,11 @@ compute_activity_distribution = register("activity-distribution")(
|
|||||||
# compute_concurrent_listening
|
# compute_concurrent_listening
|
||||||
# )
|
# )
|
||||||
compute_concurrent_reading = register("concurrent-reading")(compute_concurrent_reading)
|
compute_concurrent_reading = register("concurrent-reading")(compute_concurrent_reading)
|
||||||
|
compute_mood_by_time = register("mood-by-time")(compute_mood_by_time)
|
||||||
|
compute_mood_distribution = register("mood-distribution")(compute_mood_distribution)
|
||||||
|
compute_mood_streaks = register("mood-streaks")(compute_mood_streaks)
|
||||||
|
compute_mood_trajectory = register("mood-trajectory")(compute_mood_trajectory)
|
||||||
|
compute_mood_weather = register("mood-weather")(compute_mood_weather)
|
||||||
compute_peak_hours = register("peak-hours")(compute_peak_hours)
|
compute_peak_hours = register("peak-hours")(compute_peak_hours)
|
||||||
compute_reading_pace_vs_activity = register("reading-pace-vs-activity")(
|
compute_reading_pace_vs_activity = register("reading-pace-vs-activity")(
|
||||||
compute_reading_pace_vs_activity
|
compute_reading_pace_vs_activity
|
||||||
|
|||||||
208
vrobbler/apps/trends/trends/mood.py
Normal file
208
vrobbler/apps/trends/trends/mood.py
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
from collections import Counter, defaultdict
|
||||||
|
|
||||||
|
from django.db.models import Q
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
|
|
||||||
|
def _mood_scrobbles(user, period="last_30"):
|
||||||
|
from trends.utils import get_date_range
|
||||||
|
|
||||||
|
start, end = get_date_range(period)
|
||||||
|
filters = Q(user=user, media_type=Scrobble.MediaType.MOOD)
|
||||||
|
if start:
|
||||||
|
filters &= Q(timestamp__gte=start)
|
||||||
|
if end:
|
||||||
|
filters &= Q(timestamp__lte=end)
|
||||||
|
return Scrobble.objects.filter(filters).select_related("mood")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_quality(raw):
|
||||||
|
try:
|
||||||
|
return int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _avg_quality(values):
|
||||||
|
nums = [v for v in values if v is not None]
|
||||||
|
if not nums:
|
||||||
|
return 0.0
|
||||||
|
return round(sum(nums) / len(nums), 2)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_mood_trajectory(user, period="last_30"):
|
||||||
|
scrobbles = _mood_scrobbles(user, period).order_by("timestamp")
|
||||||
|
by_date = defaultdict(list)
|
||||||
|
for s in scrobbles:
|
||||||
|
quality = _parse_quality(s.log.get("mood_quality"))
|
||||||
|
if quality is not None:
|
||||||
|
day_key = s.timestamp.strftime("%Y-%m-%d")
|
||||||
|
by_date[day_key].append(quality)
|
||||||
|
|
||||||
|
trajectory = []
|
||||||
|
for date_key in sorted(by_date):
|
||||||
|
values = by_date[date_key]
|
||||||
|
trajectory.append(
|
||||||
|
{
|
||||||
|
"date": date_key,
|
||||||
|
"avg_quality": _avg_quality(values),
|
||||||
|
"count": len(values),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"trajectory": trajectory}
|
||||||
|
|
||||||
|
|
||||||
|
def compute_mood_by_time(user, period="last_30"):
|
||||||
|
scrobbles = _mood_scrobbles(user, period)
|
||||||
|
by_hour = defaultdict(list)
|
||||||
|
by_day = defaultdict(list)
|
||||||
|
|
||||||
|
for s in scrobbles:
|
||||||
|
quality = _parse_quality(s.log.get("mood_quality"))
|
||||||
|
if quality is not None and s.timestamp:
|
||||||
|
by_hour[s.timestamp.hour].append(quality)
|
||||||
|
by_day[s.timestamp.isoweekday()].append(quality)
|
||||||
|
|
||||||
|
hours = []
|
||||||
|
for h in range(24):
|
||||||
|
vals = by_hour.get(h, [])
|
||||||
|
hours.append(
|
||||||
|
{
|
||||||
|
"hour": h,
|
||||||
|
"avg_quality": _avg_quality(vals),
|
||||||
|
"count": len(vals),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
DAY_NAMES = {
|
||||||
|
1: "Monday",
|
||||||
|
2: "Tuesday",
|
||||||
|
3: "Wednesday",
|
||||||
|
4: "Thursday",
|
||||||
|
5: "Friday",
|
||||||
|
6: "Saturday",
|
||||||
|
7: "Sunday",
|
||||||
|
}
|
||||||
|
days = []
|
||||||
|
for d in range(1, 8):
|
||||||
|
vals = by_day.get(d, [])
|
||||||
|
days.append(
|
||||||
|
{
|
||||||
|
"day_index": d,
|
||||||
|
"day_name": DAY_NAMES[d],
|
||||||
|
"avg_quality": _avg_quality(vals),
|
||||||
|
"count": len(vals),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"hours": hours, "days": days}
|
||||||
|
|
||||||
|
|
||||||
|
def compute_mood_distribution(user, period="last_30"):
|
||||||
|
scrobbles = _mood_scrobbles(user, period)
|
||||||
|
mood_counts = Counter()
|
||||||
|
type_counts = Counter()
|
||||||
|
|
||||||
|
for s in scrobbles:
|
||||||
|
if s.mood and s.mood.title:
|
||||||
|
mood_counts[s.mood.title] += 1
|
||||||
|
mood_type = s.log.get("mood_type")
|
||||||
|
if mood_type:
|
||||||
|
type_counts[mood_type] += 1
|
||||||
|
|
||||||
|
moods = [
|
||||||
|
{"mood": mood, "count": count}
|
||||||
|
for mood, count in mood_counts.most_common()
|
||||||
|
]
|
||||||
|
total = sum(mood_counts.values())
|
||||||
|
|
||||||
|
return {
|
||||||
|
"moods": moods,
|
||||||
|
"total": total,
|
||||||
|
"positive_count": type_counts.get("positive", 0),
|
||||||
|
"negative_count": type_counts.get("negative", 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def compute_mood_streaks(user, period="last_30"):
|
||||||
|
scrobbles = list(
|
||||||
|
_mood_scrobbles(user, period).order_by("timestamp")
|
||||||
|
)
|
||||||
|
if not scrobbles:
|
||||||
|
return {"streaks": [], "current_streak": None}
|
||||||
|
|
||||||
|
streaks = []
|
||||||
|
current_start = scrobbles[0].timestamp.date()
|
||||||
|
current_type = scrobbles[0].log.get("mood_type") or "unknown"
|
||||||
|
current_length = 1
|
||||||
|
|
||||||
|
for s in scrobbles[1:]:
|
||||||
|
mood_type = s.log.get("mood_type") or "unknown"
|
||||||
|
if mood_type == current_type:
|
||||||
|
current_length += 1
|
||||||
|
else:
|
||||||
|
streaks.append(
|
||||||
|
{
|
||||||
|
"start_date": current_start.isoformat(),
|
||||||
|
"end_date": scrobbles[scrobbles.index(s) - 1].timestamp.date().isoformat(),
|
||||||
|
"mood_type": current_type,
|
||||||
|
"length": current_length,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
current_start = s.timestamp.date()
|
||||||
|
current_type = mood_type
|
||||||
|
current_length = 1
|
||||||
|
|
||||||
|
streaks.append(
|
||||||
|
{
|
||||||
|
"start_date": current_start.isoformat(),
|
||||||
|
"end_date": scrobbles[-1].timestamp.date().isoformat(),
|
||||||
|
"mood_type": current_type,
|
||||||
|
"length": current_length,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
streaks.sort(key=lambda x: x["length"], reverse=True)
|
||||||
|
|
||||||
|
current_streak = {
|
||||||
|
"mood_type": current_type,
|
||||||
|
"length": current_length,
|
||||||
|
"start_date": current_start.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"streaks": streaks[:10], "current_streak": current_streak}
|
||||||
|
|
||||||
|
|
||||||
|
def compute_mood_weather(user, period="last_30"):
|
||||||
|
scrobbles = _mood_scrobbles(user, period)
|
||||||
|
by_condition = defaultdict(list)
|
||||||
|
by_temp_range = defaultdict(list)
|
||||||
|
|
||||||
|
for s in scrobbles:
|
||||||
|
quality = _parse_quality(s.log.get("mood_quality"))
|
||||||
|
if quality is None:
|
||||||
|
continue
|
||||||
|
desc = s.log.get("weather_description")
|
||||||
|
temp = s.log.get("weather_temp")
|
||||||
|
if desc:
|
||||||
|
by_condition[desc].append(quality)
|
||||||
|
if temp is not None:
|
||||||
|
try:
|
||||||
|
temp_f = float(temp)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
bucket = f"{(int(temp_f) // 10) * 10}-{(int(temp_f) // 10) * 10 + 9}F"
|
||||||
|
by_temp_range[bucket].append(quality)
|
||||||
|
|
||||||
|
conditions = [
|
||||||
|
{"condition": cond, "avg_quality": _avg_quality(vals), "count": len(vals)}
|
||||||
|
for cond, vals in sorted(by_condition.items(), key=lambda x: len(x[1]), reverse=True)
|
||||||
|
]
|
||||||
|
|
||||||
|
temp_ranges = [
|
||||||
|
{"range": rng, "avg_quality": _avg_quality(vals), "count": len(vals)}
|
||||||
|
for rng, vals in sorted(by_temp_range.items())
|
||||||
|
]
|
||||||
|
|
||||||
|
return {"conditions": conditions, "temp_ranges": temp_ranges}
|
||||||
@ -10,7 +10,6 @@ PERIOD_DAYS = {
|
|||||||
"last_30": 30,
|
"last_30": 30,
|
||||||
"last_90": 90,
|
"last_90": 90,
|
||||||
"last_year": 365,
|
"last_year": 365,
|
||||||
"all_time": None,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
PERIOD_LABELS = dict(PERIOD_CHOICES)
|
PERIOD_LABELS = dict(PERIOD_CHOICES)
|
||||||
@ -19,8 +18,15 @@ TIME_BOUND_TRENDS = {
|
|||||||
"activity-distribution",
|
"activity-distribution",
|
||||||
"concurrent-reading",
|
"concurrent-reading",
|
||||||
"concurrent-listening",
|
"concurrent-listening",
|
||||||
|
"mood-by-time",
|
||||||
|
"mood-distribution",
|
||||||
|
"mood-streaks",
|
||||||
|
"mood-trajectory",
|
||||||
|
"mood-weather",
|
||||||
|
"peak-hours",
|
||||||
"reading-pace-vs-activity",
|
"reading-pace-vs-activity",
|
||||||
"trending-up",
|
"trending-up",
|
||||||
|
"weekly-rhythm",
|
||||||
}
|
}
|
||||||
|
|
||||||
TREND_PERIOD_OVERRIDES = {
|
TREND_PERIOD_OVERRIDES = {
|
||||||
@ -32,9 +38,7 @@ def get_supported_periods(trend_slug):
|
|||||||
if trend_slug in TREND_PERIOD_OVERRIDES:
|
if trend_slug in TREND_PERIOD_OVERRIDES:
|
||||||
slugs = TREND_PERIOD_OVERRIDES[trend_slug]
|
slugs = TREND_PERIOD_OVERRIDES[trend_slug]
|
||||||
return {s: PERIOD_LABELS[s] for s in slugs}
|
return {s: PERIOD_LABELS[s] for s in slugs}
|
||||||
if trend_slug in TIME_BOUND_TRENDS:
|
return dict(PERIOD_LABELS)
|
||||||
return dict(PERIOD_LABELS)
|
|
||||||
return {"all_time": PERIOD_LABELS["all_time"]}
|
|
||||||
|
|
||||||
|
|
||||||
def get_period_days(period):
|
def get_period_days(period):
|
||||||
@ -61,7 +65,7 @@ def get_period_nav(current_period, trend_slug):
|
|||||||
return prev_period, next_period
|
return prev_period, next_period
|
||||||
|
|
||||||
|
|
||||||
def compute_and_save_trend(user, slug, period="all_time"):
|
def compute_and_save_trend(user, slug, period="last_30"):
|
||||||
"""Compute a single trend for a given period and persist the result.
|
"""Compute a single trend for a given period and persist the result.
|
||||||
|
|
||||||
Returns elapsed seconds on success, raises on failure.
|
Returns elapsed seconds on success, raises on failure.
|
||||||
|
|||||||
@ -20,6 +20,31 @@ TREND_METADATA = {
|
|||||||
"description": "What music did you listen to while reading books?",
|
"description": "What music did you listen to while reading books?",
|
||||||
"icon": "📖",
|
"icon": "📖",
|
||||||
},
|
},
|
||||||
|
"mood-trajectory": {
|
||||||
|
"title": "Mood Trajectory",
|
||||||
|
"description": "How your mood quality has changed over time.",
|
||||||
|
"icon": "📈",
|
||||||
|
},
|
||||||
|
"mood-by-time": {
|
||||||
|
"title": "Mood by Time",
|
||||||
|
"description": "How your mood varies by hour of day and day of week.",
|
||||||
|
"icon": "🕐",
|
||||||
|
},
|
||||||
|
"mood-distribution": {
|
||||||
|
"title": "Mood Distribution",
|
||||||
|
"description": "Which moods you feel most often.",
|
||||||
|
"icon": "🎭",
|
||||||
|
},
|
||||||
|
"mood-streaks": {
|
||||||
|
"title": "Mood Streaks",
|
||||||
|
"description": "Your longest runs of positive and negative moods.",
|
||||||
|
"icon": "🔥",
|
||||||
|
},
|
||||||
|
"mood-weather": {
|
||||||
|
"title": "Mood & Weather",
|
||||||
|
"description": "How weather conditions correlate with your mood.",
|
||||||
|
"icon": "🌤",
|
||||||
|
},
|
||||||
"peak-hours": {
|
"peak-hours": {
|
||||||
"title": "Peak Activity Hours",
|
"title": "Peak Activity Hours",
|
||||||
"description": "What time of day are you most active?",
|
"description": "What time of day are you most active?",
|
||||||
@ -86,7 +111,7 @@ class TrendDetailView(LoginRequiredMixin, TemplateView):
|
|||||||
ctx["trend_not_found"] = True
|
ctx["trend_not_found"] = True
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
period = self.request.GET.get("period", "all_time")
|
period = self.request.GET.get("period", "last_30")
|
||||||
|
|
||||||
meta = TREND_METADATA.get(slug, {})
|
meta = TREND_METADATA.get(slug, {})
|
||||||
ctx["trend"] = {
|
ctx["trend"] = {
|
||||||
|
|||||||
@ -10,26 +10,27 @@ def hrs_to_secs(hrs: float) -> int:
|
|||||||
return int(hrs * 60 * 60)
|
return int(hrs * 60 * 60)
|
||||||
|
|
||||||
|
|
||||||
def lookup_game_from_hltb(name_or_id: str) -> Optional[dict]:
|
def lookup_game_from_hltb(name_or_id: str, search_by_title: bool = False) -> Optional[dict]:
|
||||||
"""Lookup game on HowLongToBeat.com via HLtB ID or a name string and return
|
"""Lookup game on HowLongToBeat.com via HLtB ID or a name string and return
|
||||||
the data in a dictonary mapped to our internal game fields
|
the data in a dictonary mapped to our internal game fields
|
||||||
|
|
||||||
"""
|
"""
|
||||||
hltb_game = {}
|
hltb_game = {}
|
||||||
|
|
||||||
try:
|
if not search_by_title:
|
||||||
hltb_id = int(name_or_id)
|
try:
|
||||||
except ValueError:
|
hltb_id = int(name_or_id)
|
||||||
hltb_id = None
|
except ValueError:
|
||||||
|
hltb_id = None
|
||||||
|
|
||||||
if hltb_id:
|
if hltb_id:
|
||||||
hltb_game = HowLongToBeat().search_from_id(hltb_id)
|
hltb_game = HowLongToBeat().search_from_id(hltb_id)
|
||||||
logger.info(f"Found game on HLtB for ID {hltb_id}")
|
logger.info(f"Found game on HLtB for ID {hltb_id}")
|
||||||
|
|
||||||
if not hltb_game:
|
if not hltb_game:
|
||||||
results = HowLongToBeat().search(name_or_id)
|
results = HowLongToBeat().search(name_or_id)
|
||||||
if not results:
|
if not results:
|
||||||
logger.warn(f"Lookup of game on HLtB failed for ID {name_or_id}")
|
logger.warn(f"Lookup of game on HLtB failed via search {name_or_id!r}")
|
||||||
return
|
return
|
||||||
|
|
||||||
hltb_game = results[0]
|
hltb_game = results[0]
|
||||||
|
|||||||
@ -19,6 +19,7 @@ GAMES_URL = "https://api.igdb.com/v4/games"
|
|||||||
ALT_NAMES_URL = "https://api.igdb.com/v4/alternative_names"
|
ALT_NAMES_URL = "https://api.igdb.com/v4/alternative_names"
|
||||||
SCREENSHOT_URL = "https://api.igdb.com/v4/screenshots"
|
SCREENSHOT_URL = "https://api.igdb.com/v4/screenshots"
|
||||||
COVER_URL = "https://api.igdb.com/v4/covers"
|
COVER_URL = "https://api.igdb.com/v4/covers"
|
||||||
|
PLATFORMS_URL = "https://api.igdb.com/v4/platforms"
|
||||||
|
|
||||||
IGDB_CLIENT_ID = getattr(settings, "IGDB_CLIENT_ID")
|
IGDB_CLIENT_ID = getattr(settings, "IGDB_CLIENT_ID")
|
||||||
IGDB_CLIENT_SECRET = getattr(settings, "IGDB_CLIENT_SECRET")
|
IGDB_CLIENT_SECRET = getattr(settings, "IGDB_CLIENT_SECRET")
|
||||||
@ -35,6 +36,20 @@ def get_igdb_token() -> str:
|
|||||||
return results.get("access_token")
|
return results.get("access_token")
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_platform_names(platform_ids: list, headers: dict) -> list:
|
||||||
|
"""Resolve IGDB platform IDs to platform names"""
|
||||||
|
if not platform_ids:
|
||||||
|
return []
|
||||||
|
ids_str = ",".join(str(pid) for pid in platform_ids)
|
||||||
|
body = f"fields name; where id = ({ids_str});"
|
||||||
|
resp = requests.post(PLATFORMS_URL, data=body, headers=headers)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.warn(f"Failed to resolve platform IDs {platform_ids}")
|
||||||
|
return []
|
||||||
|
results = json.loads(resp.content)
|
||||||
|
return [p["name"] for p in results if "name" in p]
|
||||||
|
|
||||||
|
|
||||||
def lookup_game_id_from_gdb(name: str) -> str:
|
def lookup_game_id_from_gdb(name: str) -> str:
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
@ -62,9 +77,10 @@ def lookup_game_id_from_gdb(name: str) -> str:
|
|||||||
"details": results.get("details"),
|
"details": results.get("details"),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
# Sort our result by IDs so we always get the lowest ID, which is likely to be the least esoteric game
|
# Sort results by release date (oldest first) to prefer the original game
|
||||||
results = sorted(results, key=lambda k: k.get("game", 250000))
|
results = [r for r in results if r.get("game")]
|
||||||
return results[0].get("game", "")
|
results = sorted(results, key=lambda k: k.get("published_at") or 9999999999)
|
||||||
|
return results[0].get("game", "") if results else ""
|
||||||
|
|
||||||
|
|
||||||
def lookup_game_from_igdb(name_or_igdb_id: str) -> Dict:
|
def lookup_game_from_igdb(name_or_igdb_id: str) -> Dict:
|
||||||
@ -118,6 +134,16 @@ def lookup_game_from_igdb(name_or_igdb_id: str) -> Dict:
|
|||||||
for genre in game.get("genres"):
|
for genre in game.get("genres"):
|
||||||
genres.append(genre["name"])
|
genres.append(genre["name"])
|
||||||
|
|
||||||
|
platforms = []
|
||||||
|
if "release_dates" in game.keys():
|
||||||
|
platform_ids = set()
|
||||||
|
for rd in game["release_dates"]:
|
||||||
|
pid = rd.get("platform")
|
||||||
|
if pid is not None:
|
||||||
|
platform_ids.add(pid)
|
||||||
|
if platform_ids:
|
||||||
|
platforms = lookup_platform_names(list(platform_ids), headers)
|
||||||
|
|
||||||
game_dict = {
|
game_dict = {
|
||||||
"igdb_id": game.get("id"),
|
"igdb_id": game.get("id"),
|
||||||
"title": game.get("name"),
|
"title": game.get("name"),
|
||||||
@ -129,6 +155,7 @@ def lookup_game_from_igdb(name_or_igdb_id: str) -> Dict:
|
|||||||
"release_date": release_date,
|
"release_date": release_date,
|
||||||
"summary": game.get("summary"),
|
"summary": game.get("summary"),
|
||||||
"genres": genres,
|
"genres": genres,
|
||||||
|
"platforms": platforms,
|
||||||
}
|
}
|
||||||
|
|
||||||
return game_dict
|
return game_dict
|
||||||
|
|||||||
0
vrobbler/apps/videogames/management/__init__.py
Normal file
0
vrobbler/apps/videogames/management/__init__.py
Normal file
@ -0,0 +1,529 @@
|
|||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MISSING_ALL = [
|
||||||
|
"cover",
|
||||||
|
"screenshot",
|
||||||
|
"summary",
|
||||||
|
"rating",
|
||||||
|
"release_date",
|
||||||
|
"release_year",
|
||||||
|
"igdb_id",
|
||||||
|
"hltb_id",
|
||||||
|
]
|
||||||
|
|
||||||
|
MISSING_GROUPS = {
|
||||||
|
"cover": lambda g: not bool(g.cover),
|
||||||
|
"screenshot": lambda g: not bool(g.screenshot),
|
||||||
|
"summary": lambda g: not g.summary,
|
||||||
|
"rating": lambda g: g.rating is None,
|
||||||
|
"release_date": lambda g: g.release_date is None,
|
||||||
|
"release_year": lambda g: g.release_year is None,
|
||||||
|
"igdb_id": lambda g: g.igdb_id is None,
|
||||||
|
"hltb_id": lambda g: g.hltb_id is None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _game_matches(game, flags):
|
||||||
|
if not flags:
|
||||||
|
return False
|
||||||
|
for flag in flags:
|
||||||
|
fn = MISSING_GROUPS.get(flag)
|
||||||
|
if fn and fn(game):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Backfill missing metadata on video games from IGDB and HowLongToBeat"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"--commit",
|
||||||
|
action="store_true",
|
||||||
|
help="Commit changes to the database",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--batch-size",
|
||||||
|
type=int,
|
||||||
|
default=100,
|
||||||
|
help="Number of games to process per batch (default: 100)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--sleep",
|
||||||
|
type=float,
|
||||||
|
default=0.5,
|
||||||
|
help="Seconds to sleep between API calls (default: 0.5)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--force",
|
||||||
|
action="store_true",
|
||||||
|
help="Re-fetch metadata even if data already exists",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--game-id",
|
||||||
|
type=int,
|
||||||
|
help="Only process a specific game by ID",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--fix-broken-images",
|
||||||
|
action="store_true",
|
||||||
|
help="Check and refetch broken/deleted game images (cover, screenshot, hltb_cover)",
|
||||||
|
)
|
||||||
|
for flag in MISSING_ALL:
|
||||||
|
parser.add_argument(
|
||||||
|
f"--missing-{flag}",
|
||||||
|
dest="missing_flags",
|
||||||
|
action="append_const",
|
||||||
|
const=flag,
|
||||||
|
help=f"Process games missing {flag}",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--all",
|
||||||
|
action="store_true",
|
||||||
|
dest="all_missing",
|
||||||
|
help="Process games missing any metadata field",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
from videogames.models import VideoGame
|
||||||
|
|
||||||
|
commit = options["commit"]
|
||||||
|
batch_size = options["batch_size"]
|
||||||
|
sleep_secs = options["sleep"]
|
||||||
|
force = options["force"]
|
||||||
|
game_id = options["game_id"]
|
||||||
|
fix_broken_images = options.get("fix_broken_images", False)
|
||||||
|
flags = options.get("missing_flags") or []
|
||||||
|
all_missing = options["all_missing"]
|
||||||
|
|
||||||
|
if all_missing:
|
||||||
|
flags = MISSING_ALL
|
||||||
|
|
||||||
|
if not flags and not game_id and not force and not fix_broken_images:
|
||||||
|
self.stdout.write(
|
||||||
|
"No filters specified. Use --all, --missing-*, --game-id, --force, or --fix-broken-images."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if game_id:
|
||||||
|
qs = VideoGame.objects.filter(id=game_id)
|
||||||
|
else:
|
||||||
|
qs = VideoGame.objects.all()
|
||||||
|
|
||||||
|
if flags:
|
||||||
|
qs = [g for g in qs.iterator() if _game_matches(g, flags)]
|
||||||
|
else:
|
||||||
|
qs = list(qs)
|
||||||
|
|
||||||
|
total = len(qs)
|
||||||
|
self.stdout.write(f"Found {total} games to process")
|
||||||
|
|
||||||
|
if not commit:
|
||||||
|
self.stdout.write(
|
||||||
|
"Dry run — no API calls will be made. Use --commit to run lookups."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
title_mismatches = []
|
||||||
|
enriched = 0
|
||||||
|
skipped = 0
|
||||||
|
stats = {
|
||||||
|
"cover_fixed": 0,
|
||||||
|
"screenshot_fixed": 0,
|
||||||
|
"summary_fixed": 0,
|
||||||
|
"rating_fixed": 0,
|
||||||
|
"release_date_fixed": 0,
|
||||||
|
"release_year_fixed": 0,
|
||||||
|
"igdb_id_found": 0,
|
||||||
|
"hltb_id_found": 0,
|
||||||
|
"images_fixed": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
enriched_any = bool(flags or game_id or force)
|
||||||
|
|
||||||
|
if enriched_any:
|
||||||
|
for batch_num, offset in enumerate(range(0, len(qs), batch_size)):
|
||||||
|
batch = qs[offset : offset + batch_size]
|
||||||
|
for game in batch:
|
||||||
|
result = self._enrich_game(game, sleep_secs, force)
|
||||||
|
self._check_retroarch_name(game, title_mismatches)
|
||||||
|
if result:
|
||||||
|
enriched += 1
|
||||||
|
for key in stats:
|
||||||
|
if result.get(key):
|
||||||
|
stats[key] += 1
|
||||||
|
else:
|
||||||
|
skipped += 1
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
f" Batch {batch_num + 1}: {offset + len(batch)}/{total} — "
|
||||||
|
f"enriched: {enriched}, skipped: {skipped}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if fix_broken_images:
|
||||||
|
broken_stats = self._fix_broken_images(qs, sleep_secs)
|
||||||
|
stats["images_fixed"] = broken_stats["images_fixed"]
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
f"\nResults (commit={commit}):\n"
|
||||||
|
f" Games enriched: {enriched}\n"
|
||||||
|
f" Games skipped: {skipped}\n"
|
||||||
|
f" Covers fixed: {stats['cover_fixed']}\n"
|
||||||
|
f" Screenshots fixed: {stats['screenshot_fixed']}\n"
|
||||||
|
f" Summaries fixed: {stats['summary_fixed']}\n"
|
||||||
|
f" Ratings fixed: {stats['rating_fixed']}\n"
|
||||||
|
f" Release dates fixed: {stats['release_date_fixed']}\n"
|
||||||
|
f" Release years fixed: {stats['release_year_fixed']}\n"
|
||||||
|
f" IGDB IDs found: {stats['igdb_id_found']}\n"
|
||||||
|
f" HLtB IDs found: {stats['hltb_id_found']}"
|
||||||
|
)
|
||||||
|
if fix_broken_images:
|
||||||
|
self.stdout.write(f" Broken images fixed: {stats['images_fixed']}")
|
||||||
|
|
||||||
|
if title_mismatches:
|
||||||
|
self.stdout.write("\nTitle vs retroarch_name mismatches (not auto-fixed):")
|
||||||
|
for retroarch_name, title, game_id in title_mismatches:
|
||||||
|
self.stdout.write(
|
||||||
|
f" Game #{game_id}: retroarch_name={retroarch_name!r} vs title={title!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _clean_retroarch_name(self, name):
|
||||||
|
if not name:
|
||||||
|
return ""
|
||||||
|
name = name.strip()
|
||||||
|
if "(" in name:
|
||||||
|
name = name.split("(")[0].strip()
|
||||||
|
return name
|
||||||
|
|
||||||
|
def _check_retroarch_name(self, game, mismatches):
|
||||||
|
if not game.retroarch_name:
|
||||||
|
return
|
||||||
|
cleaned = self._clean_retroarch_name(game.retroarch_name)
|
||||||
|
if cleaned.lower() != game.title.lower():
|
||||||
|
mismatches.append((game.retroarch_name, game.title, game.id))
|
||||||
|
if "retroarch-mismatch" not in game.tags.names():
|
||||||
|
game.tags.add("retroarch-mismatch")
|
||||||
|
self.stdout.write(
|
||||||
|
f" [TAG] {game} — tagged as retroarch-mismatch"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _enrich_game(self, game, sleep_secs, force):
|
||||||
|
from videogames.igdb import lookup_game_id_from_gdb, lookup_game_from_igdb
|
||||||
|
from videogames.howlongtobeat import lookup_game_from_hltb
|
||||||
|
|
||||||
|
search_name = self._clean_retroarch_name(game.retroarch_name) or game.title
|
||||||
|
|
||||||
|
changed = {}
|
||||||
|
|
||||||
|
if not game.hltb_id:
|
||||||
|
hltb_data = None
|
||||||
|
if search_name:
|
||||||
|
hltb_data = lookup_game_from_hltb(search_name, search_by_title=True)
|
||||||
|
time.sleep(sleep_secs)
|
||||||
|
if not hltb_data and game.title and game.title != search_name:
|
||||||
|
hltb_data = lookup_game_from_hltb(game.title, search_by_title=True)
|
||||||
|
time.sleep(sleep_secs)
|
||||||
|
if hltb_data:
|
||||||
|
result = self._apply_hltb_data(game, hltb_data, force)
|
||||||
|
if result:
|
||||||
|
changed.update(result)
|
||||||
|
|
||||||
|
igdb_data = None
|
||||||
|
if not game.igdb_id and search_name:
|
||||||
|
igdb_id = lookup_game_id_from_gdb(search_name)
|
||||||
|
time.sleep(sleep_secs)
|
||||||
|
if igdb_id:
|
||||||
|
igdb_data = lookup_game_from_igdb(str(igdb_id))
|
||||||
|
time.sleep(sleep_secs)
|
||||||
|
elif game.igdb_id:
|
||||||
|
igdb_data = lookup_game_from_igdb(str(game.igdb_id))
|
||||||
|
time.sleep(sleep_secs)
|
||||||
|
|
||||||
|
if igdb_data:
|
||||||
|
igdb_title = igdb_data.get("title", "")
|
||||||
|
igdb_title_clean = self._clean_retroarch_name(igdb_title)
|
||||||
|
if igdb_title_clean.lower() == search_name.lower():
|
||||||
|
if game.igdb_id is None and igdb_data.get("igdb_id"):
|
||||||
|
game.igdb_id = int(igdb_data["igdb_id"])
|
||||||
|
game.save(update_fields=["igdb_id"])
|
||||||
|
changed["igdb_id_found"] = True
|
||||||
|
self.stdout.write(f" [IGDB_ID] {game} — found IGDB ID {game.igdb_id}")
|
||||||
|
result = self._apply_igdb_data(game, igdb_data, force)
|
||||||
|
if result:
|
||||||
|
changed.update(result)
|
||||||
|
else:
|
||||||
|
self.stdout.write(
|
||||||
|
f" [IGDB] {game} — title mismatch (IGDB: {igdb_title!r} vs expected: {search_name!r}), re-searching…"
|
||||||
|
)
|
||||||
|
resolved = False
|
||||||
|
for candidate in (search_name, game.title if game.title != search_name else None):
|
||||||
|
if not candidate:
|
||||||
|
continue
|
||||||
|
new_id = lookup_game_id_from_gdb(candidate)
|
||||||
|
time.sleep(sleep_secs)
|
||||||
|
if not new_id:
|
||||||
|
continue
|
||||||
|
new_data = lookup_game_from_igdb(str(new_id))
|
||||||
|
time.sleep(sleep_secs)
|
||||||
|
if not new_data:
|
||||||
|
continue
|
||||||
|
new_title = new_data.get("title", "")
|
||||||
|
new_title_clean = self._clean_retroarch_name(new_title)
|
||||||
|
if new_title_clean.lower() == candidate.lower():
|
||||||
|
game.igdb_id = int(new_id)
|
||||||
|
if new_title and new_title != game.title:
|
||||||
|
game.title = new_title
|
||||||
|
changed["title_updated"] = True
|
||||||
|
self.stdout.write(f" [TITLE] {game} — updated title to {new_title!r} from IGDB")
|
||||||
|
game.save(update_fields=["igdb_id"] + (["title"] if changed.get("title_updated") else []))
|
||||||
|
changed["igdb_id_found"] = True
|
||||||
|
self.stdout.write(f" [IGDB_ID] {game} — re-found IGDB ID {game.igdb_id}")
|
||||||
|
if "igdb-mismatch" in game.tags.names():
|
||||||
|
game.tags.remove("igdb-mismatch")
|
||||||
|
self.stdout.write(f" [TAG] {game} — removed igdb-mismatch tag")
|
||||||
|
result = self._apply_igdb_data(game, new_data, force)
|
||||||
|
if result:
|
||||||
|
changed.update(result)
|
||||||
|
resolved = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not resolved and "igdb-mismatch" not in game.tags.names():
|
||||||
|
game.tags.add("igdb-mismatch")
|
||||||
|
self.stdout.write(f" [TAG] {game} — tagged igdb-mismatch")
|
||||||
|
|
||||||
|
# If retroarch-mismatch tag exists but no longer applies, remove it
|
||||||
|
if "retroarch-mismatch" in game.tags.names():
|
||||||
|
cleaned = self._clean_retroarch_name(game.retroarch_name or "")
|
||||||
|
if cleaned.lower() == game.title.lower():
|
||||||
|
game.tags.remove("retroarch-mismatch")
|
||||||
|
self.stdout.write(f" [TAG] {game} — removed retroarch-mismatch tag (title now matches)")
|
||||||
|
|
||||||
|
return changed if changed else None
|
||||||
|
|
||||||
|
def _apply_igdb_data(self, game, data, force):
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
changed = {
|
||||||
|
"cover_fixed": False,
|
||||||
|
"screenshot_fixed": False,
|
||||||
|
"summary_fixed": False,
|
||||||
|
"rating_fixed": False,
|
||||||
|
"release_date_fixed": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
update_fields = []
|
||||||
|
|
||||||
|
if data.get("alternative_name") and not game.alternative_name:
|
||||||
|
game.alternative_name = data["alternative_name"]
|
||||||
|
update_fields.append("alternative_name")
|
||||||
|
|
||||||
|
if data.get("summary") and (not game.summary or force):
|
||||||
|
game.summary = data["summary"]
|
||||||
|
update_fields.append("summary")
|
||||||
|
changed["summary_fixed"] = True
|
||||||
|
|
||||||
|
if data.get("rating") is not None and (game.rating is None or force):
|
||||||
|
game.rating = data["rating"]
|
||||||
|
update_fields.append("rating")
|
||||||
|
changed["rating_fixed"] = True
|
||||||
|
|
||||||
|
if data.get("rating_count") is not None and (game.rating_count is None or force):
|
||||||
|
game.rating_count = data["rating_count"]
|
||||||
|
update_fields.append("rating_count")
|
||||||
|
|
||||||
|
if data.get("release_date") and (game.release_date is None or force):
|
||||||
|
game.release_date = data["release_date"]
|
||||||
|
update_fields.append("release_date")
|
||||||
|
changed["release_date_fixed"] = True
|
||||||
|
|
||||||
|
if update_fields:
|
||||||
|
game.save(update_fields=update_fields)
|
||||||
|
self.stdout.write(f" [IGDB] {game} — {', '.join(update_fields)}")
|
||||||
|
|
||||||
|
cover_url = data.get("cover_url")
|
||||||
|
if cover_url:
|
||||||
|
r = requests.get(cover_url)
|
||||||
|
if r.status_code == 200:
|
||||||
|
fname = f"{game.title}_{game.uuid}.jpg"
|
||||||
|
game.cover.save(fname, ContentFile(r.content), save=True)
|
||||||
|
changed["cover_fixed"] = True
|
||||||
|
self.stdout.write(f" [COVER] {game} — cover saved from IGDB")
|
||||||
|
|
||||||
|
screenshot_url = data.get("screenshot_url")
|
||||||
|
if screenshot_url:
|
||||||
|
r = requests.get(screenshot_url)
|
||||||
|
if r.status_code == 200:
|
||||||
|
fname = f"{game.title}_{game.uuid}.jpg"
|
||||||
|
game.screenshot.save(fname, ContentFile(r.content), save=True)
|
||||||
|
changed["screenshot_fixed"] = True
|
||||||
|
self.stdout.write(f" [SCREENSHOT] {game} — screenshot saved from IGDB")
|
||||||
|
|
||||||
|
genres = data.get("genres", [])
|
||||||
|
if genres:
|
||||||
|
existing = set(game.genre.names())
|
||||||
|
new_genres = [g for g in genres if g not in existing]
|
||||||
|
if new_genres:
|
||||||
|
game.genre.add(*new_genres)
|
||||||
|
self.stdout.write(f" [GENRES] {game} — added {len(new_genres)} genres")
|
||||||
|
|
||||||
|
platforms = data.get("platforms", [])
|
||||||
|
if platforms:
|
||||||
|
existing = set(game.platforms.values_list("name", flat=True))
|
||||||
|
new_platforms = [p for p in platforms if p not in existing]
|
||||||
|
if new_platforms:
|
||||||
|
from videogames.models import VideoGamePlatform
|
||||||
|
|
||||||
|
for name in new_platforms:
|
||||||
|
p, _ = VideoGamePlatform.objects.get_or_create(name=name)
|
||||||
|
game.platforms.add(p)
|
||||||
|
self.stdout.write(f" [PLATFORMS] {game} — added {len(new_platforms)} platforms")
|
||||||
|
|
||||||
|
if "igdb-enriched" not in game.tags.names():
|
||||||
|
game.tags.add("igdb-enriched")
|
||||||
|
self.stdout.write(f" [TAG] {game} — tagged igdb-enriched")
|
||||||
|
|
||||||
|
return changed if any(changed.values()) else None
|
||||||
|
|
||||||
|
def _apply_hltb_data(self, game, data, force):
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
changed = {
|
||||||
|
"hltb_id_found": False,
|
||||||
|
"release_year_fixed": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
update_fields = []
|
||||||
|
|
||||||
|
hltb_title = data.get("title", "")
|
||||||
|
if hltb_title and hltb_title != game.title:
|
||||||
|
game.title = hltb_title
|
||||||
|
update_fields.append("title")
|
||||||
|
self.stdout.write(f" [TITLE] {game} — updated title to {hltb_title!r}")
|
||||||
|
|
||||||
|
if data.get("hltb_id") and (game.hltb_id is None or force):
|
||||||
|
game.hltb_id = data["hltb_id"]
|
||||||
|
update_fields.append("hltb_id")
|
||||||
|
changed["hltb_id_found"] = True
|
||||||
|
self.stdout.write(f" [HLTB_ID] {game} — found HLtB ID {data['hltb_id']}")
|
||||||
|
|
||||||
|
if data.get("release_year") and (game.release_year is None or force):
|
||||||
|
game.release_year = data["release_year"]
|
||||||
|
update_fields.append("release_year")
|
||||||
|
changed["release_year_fixed"] = True
|
||||||
|
|
||||||
|
if data.get("main_story_time") and (game.main_story_time is None or force):
|
||||||
|
game.main_story_time = data["main_story_time"]
|
||||||
|
update_fields.append("main_story_time")
|
||||||
|
|
||||||
|
if data.get("main_extra_time") and (game.main_extra_time is None or force):
|
||||||
|
game.main_extra_time = data["main_extra_time"]
|
||||||
|
update_fields.append("main_extra_time")
|
||||||
|
|
||||||
|
if data.get("completionist_time") and (game.completionist_time is None or force):
|
||||||
|
game.completionist_time = data["completionist_time"]
|
||||||
|
update_fields.append("completionist_time")
|
||||||
|
|
||||||
|
if data.get("hltb_score") is not None and (game.hltb_score is None or force):
|
||||||
|
game.hltb_score = data["hltb_score"]
|
||||||
|
update_fields.append("hltb_score")
|
||||||
|
|
||||||
|
if update_fields:
|
||||||
|
game.save(update_fields=update_fields)
|
||||||
|
self.stdout.write(f" [HLTB] {game} — {', '.join(update_fields)}")
|
||||||
|
|
||||||
|
cover_url = data.get("cover_url")
|
||||||
|
if cover_url:
|
||||||
|
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||||
|
r = requests.get(cover_url, headers=headers)
|
||||||
|
if r.status_code == 200:
|
||||||
|
fname = f"{game.title}_cover_{game.uuid}.jpg"
|
||||||
|
game.hltb_cover.save(fname, ContentFile(r.content), save=True)
|
||||||
|
self.stdout.write(f" [HLTB_COVER] {game} — cover saved from HLtB")
|
||||||
|
|
||||||
|
platforms = data.get("platforms", [])
|
||||||
|
if platforms:
|
||||||
|
existing = set(game.platforms.values_list("name", flat=True))
|
||||||
|
new_platforms = [p for p in platforms if p not in existing]
|
||||||
|
if new_platforms:
|
||||||
|
from videogames.models import VideoGamePlatform
|
||||||
|
|
||||||
|
for name in new_platforms:
|
||||||
|
p, _ = VideoGamePlatform.objects.get_or_create(name=name)
|
||||||
|
game.platforms.add(p)
|
||||||
|
self.stdout.write(f" [PLATFORMS] {game} — added {len(new_platforms)} platforms")
|
||||||
|
|
||||||
|
if "hltb-enriched" not in game.tags.names():
|
||||||
|
game.tags.add("hltb-enriched")
|
||||||
|
self.stdout.write(f" [TAG] {game} — tagged hltb-enriched")
|
||||||
|
|
||||||
|
return changed if any(changed.values()) else None
|
||||||
|
|
||||||
|
def _fix_broken_images(self, games, sleep_secs):
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from videogames.igdb import lookup_game_from_igdb
|
||||||
|
from videogames.howlongtobeat import lookup_game_from_hltb
|
||||||
|
|
||||||
|
stats = {"cover_fixed": 0, "screenshot_fixed": 0, "images_fixed": 0}
|
||||||
|
|
||||||
|
for game in games:
|
||||||
|
for field_name, source in [
|
||||||
|
("cover", "igdb"),
|
||||||
|
("screenshot", "igdb"),
|
||||||
|
("hltb_cover", "hltb"),
|
||||||
|
]:
|
||||||
|
field = getattr(game, field_name)
|
||||||
|
if not field.name:
|
||||||
|
continue
|
||||||
|
if field.storage.exists(field.name):
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
f" [IMAGE] {game} — {field_name} is broken (file missing), refetching…"
|
||||||
|
)
|
||||||
|
|
||||||
|
if source == "igdb" and game.igdb_id:
|
||||||
|
data = lookup_game_from_igdb(str(game.igdb_id))
|
||||||
|
time.sleep(sleep_secs)
|
||||||
|
if not data:
|
||||||
|
continue
|
||||||
|
url = data.get("cover_url" if field_name == "cover" else "screenshot_url")
|
||||||
|
if not url:
|
||||||
|
continue
|
||||||
|
r = requests.get(url)
|
||||||
|
if r.status_code != 200:
|
||||||
|
continue
|
||||||
|
fname = f"{game.title}_{game.uuid}.jpg"
|
||||||
|
getattr(game, field_name).save(fname, ContentFile(r.content), save=True)
|
||||||
|
stats["images_fixed"] += 1
|
||||||
|
self.stdout.write(f" [IMAGE] {game} — {field_name} refetched from IGDB")
|
||||||
|
|
||||||
|
elif source == "hltb" and game.hltb_id:
|
||||||
|
data = lookup_game_from_hltb(str(game.hltb_id))
|
||||||
|
time.sleep(sleep_secs)
|
||||||
|
if not data:
|
||||||
|
continue
|
||||||
|
url = data.get("cover_url")
|
||||||
|
if not url:
|
||||||
|
continue
|
||||||
|
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||||
|
r = requests.get(url, headers=headers)
|
||||||
|
if r.status_code != 200:
|
||||||
|
continue
|
||||||
|
fname = f"{game.title}_cover_{game.uuid}.jpg"
|
||||||
|
game.hltb_cover.save(fname, ContentFile(r.content), save=True)
|
||||||
|
stats["images_fixed"] += 1
|
||||||
|
self.stdout.write(f" [IMAGE] {game} — hltb_cover refetched from HLtB")
|
||||||
|
|
||||||
|
return stats
|
||||||
@ -215,12 +215,16 @@ class VideoGame(LongPlayScrobblableMixin):
|
|||||||
def fix_metadata(self, force_update: bool = False):
|
def fix_metadata(self, force_update: bool = False):
|
||||||
from videogames.utils import (
|
from videogames.utils import (
|
||||||
get_or_create_videogame,
|
get_or_create_videogame,
|
||||||
|
load_game_data_from_hltb,
|
||||||
load_game_data_from_igdb,
|
load_game_data_from_igdb,
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.hltb_id and force_update:
|
if self.hltb_id and force_update:
|
||||||
get_or_create_videogame(str(self.hltb_id), force_update)
|
get_or_create_videogame(str(self.hltb_id), force_update)
|
||||||
|
|
||||||
|
if not self.hltb_id:
|
||||||
|
load_game_data_from_hltb(self.id)
|
||||||
|
|
||||||
if not self.igdb_id:
|
if not self.igdb_id:
|
||||||
# This almost never works without intervention
|
# This almost never works without intervention
|
||||||
self.igdb_id = lookup_game_id_from_gdb(self.title)
|
self.igdb_id = lookup_game_id_from_gdb(self.title)
|
||||||
|
|||||||
@ -153,6 +153,13 @@ def import_retroarch_lrtl_files(playlog_path: str, user_id: int) -> List[dict]:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
logger.info(f"Queued scrobble for game {found_game.id}")
|
logger.info(f"Queued scrobble for game {found_game.id}")
|
||||||
|
|
||||||
|
log_data = {"emulated": True}
|
||||||
|
if last_scrobble and last_scrobble.log:
|
||||||
|
prev = last_scrobble.log
|
||||||
|
if prev.get("emulator"):
|
||||||
|
log_data["emulator"] = prev["emulator"]
|
||||||
|
|
||||||
new_scrobbles.append(
|
new_scrobbles.append(
|
||||||
Scrobble(
|
Scrobble(
|
||||||
video_game_id=found_game.id,
|
video_game_id=found_game.id,
|
||||||
@ -168,6 +175,7 @@ def import_retroarch_lrtl_files(playlog_path: str, user_id: int) -> List[dict]:
|
|||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
source="Retroarch",
|
source="Retroarch",
|
||||||
media_type=Scrobble.MediaType.VIDEO_GAME,
|
media_type=Scrobble.MediaType.VIDEO_GAME,
|
||||||
|
log=log_data,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
created_scrobbles = Scrobble.objects.bulk_create(new_scrobbles)
|
created_scrobbles = Scrobble.objects.bulk_create(new_scrobbles)
|
||||||
|
|||||||
@ -7,8 +7,6 @@ from videogames.howlongtobeat import lookup_game_from_hltb
|
|||||||
from videogames.igdb import lookup_game_from_igdb
|
from videogames.igdb import lookup_game_from_igdb
|
||||||
from videogames.models import VideoGame, VideoGamePlatform
|
from videogames.models import VideoGame, VideoGamePlatform
|
||||||
|
|
||||||
from vrobbler.apps.videogames.exceptions import GameNotFound
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@ -16,22 +14,33 @@ def get_or_create_videogame(
|
|||||||
name_or_id: str,
|
name_or_id: str,
|
||||||
force_update: bool = False,
|
force_update: bool = False,
|
||||||
) -> Optional[VideoGame]:
|
) -> Optional[VideoGame]:
|
||||||
"""Look up game by name or ID from HowLongToBeat"""
|
"""Look up game by name or ID from HowLongToBeat, then enrich with IGDB"""
|
||||||
|
|
||||||
game_dict = lookup_game_from_hltb(name_or_id)
|
hltb_data = lookup_game_from_hltb(name_or_id)
|
||||||
|
|
||||||
if not game_dict:
|
if hltb_data:
|
||||||
game_dict = lookup_game_from_igdb(name_or_id)
|
game = _create_update_from_dict(hltb_data, force_update)
|
||||||
|
else:
|
||||||
|
igdb_data = lookup_game_from_igdb(name_or_id)
|
||||||
|
if igdb_data:
|
||||||
|
game = _create_update_from_dict(igdb_data, force_update)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
if not game_dict:
|
if game:
|
||||||
return
|
game.fix_metadata()
|
||||||
|
return game
|
||||||
|
|
||||||
|
|
||||||
|
def _create_update_from_dict(
|
||||||
|
game_dict: dict, force_update: bool = False
|
||||||
|
) -> Optional[VideoGame]:
|
||||||
|
|
||||||
# Create missing platforms and prep for loading after create
|
|
||||||
platform_ids = []
|
platform_ids = []
|
||||||
if "platforms" in game_dict.keys():
|
if "platforms" in game_dict.keys():
|
||||||
platforms = game_dict.get("platforms", [])
|
platforms = game_dict.get("platforms", [])
|
||||||
if platforms:
|
if platforms:
|
||||||
for platform in game_dict.get("platforms", []):
|
for platform in platforms:
|
||||||
p, _created = VideoGamePlatform.objects.get_or_create(name=platform)
|
p, _created = VideoGamePlatform.objects.get_or_create(name=platform)
|
||||||
platform_ids.append(p.id)
|
platform_ids.append(p.id)
|
||||||
game_dict.pop("platforms")
|
game_dict.pop("platforms")
|
||||||
@ -48,7 +57,7 @@ def get_or_create_videogame(
|
|||||||
|
|
||||||
title = game_dict.get("title")
|
title = game_dict.get("title")
|
||||||
if not title:
|
if not title:
|
||||||
raise GameNotFound(name_or_id)
|
return None
|
||||||
|
|
||||||
hltb_id = game_dict.get("hltb_id")
|
hltb_id = game_dict.get("hltb_id")
|
||||||
igdb_id = game_dict.get("igdb_id")
|
igdb_id = game_dict.get("igdb_id")
|
||||||
@ -69,21 +78,19 @@ def get_or_create_videogame(
|
|||||||
VideoGame.objects.filter(pk=game.id).update(**game_dict)
|
VideoGame.objects.filter(pk=game.id).update(**game_dict)
|
||||||
game.refresh_from_db()
|
game.refresh_from_db()
|
||||||
|
|
||||||
# Associate plaforms
|
|
||||||
if platform_ids:
|
if platform_ids:
|
||||||
game.platforms.add(*platform_ids)
|
game.platforms.add(*platform_ids)
|
||||||
|
|
||||||
if genres:
|
if genres:
|
||||||
game.genre.add(*genres)
|
game.genre.add(*genres)
|
||||||
|
|
||||||
if not game.screenshot and screenshot_url:
|
if screenshot_url:
|
||||||
r = requests.get(screenshot_url)
|
r = requests.get(screenshot_url)
|
||||||
if r.status_code == 200:
|
if r.status_code == 200:
|
||||||
fname = f"{game.title}_{game.uuid}.jpg"
|
fname = f"{game.title}_{game.uuid}.jpg"
|
||||||
game.screenshot.save(fname, ContentFile(r.content), save=True)
|
game.screenshot.save(fname, ContentFile(r.content), save=True)
|
||||||
|
|
||||||
# Go get cover image if the URL is present
|
if cover_url:
|
||||||
if cover_url and not game.hltb_cover:
|
|
||||||
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||||
r = requests.get(cover_url, headers=headers)
|
r = requests.get(cover_url, headers=headers)
|
||||||
logger.debug(r.status_code)
|
logger.debug(r.status_code)
|
||||||
@ -91,12 +98,89 @@ def get_or_create_videogame(
|
|||||||
fname = f"{game.title}_cover_{game.uuid}.jpg"
|
fname = f"{game.title}_cover_{game.uuid}.jpg"
|
||||||
game.hltb_cover.save(fname, ContentFile(r.content), save=True)
|
game.hltb_cover.save(fname, ContentFile(r.content), save=True)
|
||||||
logger.debug("Loaded cover image from HLtB")
|
logger.debug("Loaded cover image from HLtB")
|
||||||
game.fix_metadata()
|
|
||||||
|
tag = "hltb-enriched" if hltb_id else "igdb-enriched"
|
||||||
|
if tag not in game.tags.names():
|
||||||
|
game.tags.add(tag)
|
||||||
|
logger.info(f"Game {game} tagged {tag}")
|
||||||
|
|
||||||
return game
|
return game
|
||||||
|
|
||||||
|
|
||||||
def load_game_data_from_igdb(game_id: int, igdb_id: str = "") -> Optional[VideoGame]:
|
def load_game_data_from_hltb(
|
||||||
|
game_id: int, expected_title: str = ""
|
||||||
|
) -> Optional[VideoGame]:
|
||||||
|
"""Look up HLtB data for an existing game and apply it"""
|
||||||
|
game = VideoGame.objects.filter(id=game_id).first()
|
||||||
|
if not game:
|
||||||
|
logger.warn(f"Video game with ID {game_id} not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"Looking up HLtB data for {game}")
|
||||||
|
hltb_data = lookup_game_from_hltb(game.title, search_by_title=True)
|
||||||
|
if not hltb_data:
|
||||||
|
logger.warn(f"No HLtB data found for {game}")
|
||||||
|
return
|
||||||
|
|
||||||
|
update_fields = []
|
||||||
|
|
||||||
|
hltb_title = hltb_data.get("title", "")
|
||||||
|
if hltb_title and hltb_title != game.title:
|
||||||
|
game.title = hltb_title
|
||||||
|
update_fields.append("title")
|
||||||
|
logger.info(f"Game {game.id} title updated to {hltb_title!r}")
|
||||||
|
|
||||||
|
if hltb_data.get("hltb_id") and (game.hltb_id is None):
|
||||||
|
game.hltb_id = hltb_data["hltb_id"]
|
||||||
|
update_fields.append("hltb_id")
|
||||||
|
|
||||||
|
if hltb_data.get("release_year") and (game.release_year is None):
|
||||||
|
game.release_year = hltb_data["release_year"]
|
||||||
|
update_fields.append("release_year")
|
||||||
|
|
||||||
|
if hltb_data.get("main_story_time") and (game.main_story_time is None):
|
||||||
|
game.main_story_time = hltb_data["main_story_time"]
|
||||||
|
update_fields.append("main_story_time")
|
||||||
|
|
||||||
|
if hltb_data.get("main_extra_time") and (game.main_extra_time is None):
|
||||||
|
game.main_extra_time = hltb_data["main_extra_time"]
|
||||||
|
update_fields.append("main_extra_time")
|
||||||
|
|
||||||
|
if hltb_data.get("completionist_time") and (game.completionist_time is None):
|
||||||
|
game.completionist_time = hltb_data["completionist_time"]
|
||||||
|
update_fields.append("completionist_time")
|
||||||
|
|
||||||
|
if hltb_data.get("hltb_score") is not None and (game.hltb_score is None):
|
||||||
|
game.hltb_score = hltb_data["hltb_score"]
|
||||||
|
update_fields.append("hltb_score")
|
||||||
|
|
||||||
|
if update_fields:
|
||||||
|
game.save(update_fields=update_fields)
|
||||||
|
|
||||||
|
platforms = hltb_data.get("platforms", [])
|
||||||
|
if platforms:
|
||||||
|
for name in platforms:
|
||||||
|
p, _ = VideoGamePlatform.objects.get_or_create(name=name)
|
||||||
|
game.platforms.add(p)
|
||||||
|
|
||||||
|
cover_url = hltb_data.get("cover_url")
|
||||||
|
if cover_url:
|
||||||
|
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||||
|
r = requests.get(cover_url, headers=headers)
|
||||||
|
if r.status_code == 200:
|
||||||
|
fname = f"{game.title}_cover_{game.uuid}.jpg"
|
||||||
|
game.hltb_cover.save(fname, ContentFile(r.content), save=True)
|
||||||
|
|
||||||
|
if "hltb-enriched" not in game.tags.names():
|
||||||
|
game.tags.add("hltb-enriched")
|
||||||
|
logger.info(f"Game {game} tagged hltb-enriched")
|
||||||
|
|
||||||
|
return game
|
||||||
|
|
||||||
|
|
||||||
|
def load_game_data_from_igdb(
|
||||||
|
game_id: int, igdb_id: str = "", expected_title: str = ""
|
||||||
|
) -> Optional[VideoGame]:
|
||||||
"""Look up game, if it doesn't exist, lookup data from igdb"""
|
"""Look up game, if it doesn't exist, lookup data from igdb"""
|
||||||
game = VideoGame.objects.filter(id=game_id).first()
|
game = VideoGame.objects.filter(id=game_id).first()
|
||||||
if not game:
|
if not game:
|
||||||
@ -116,25 +200,68 @@ def load_game_data_from_igdb(game_id: int, igdb_id: str = "") -> Optional[VideoG
|
|||||||
logger.warn(f"No game data found on IGDB for ID {igdb_id}")
|
logger.warn(f"No game data found on IGDB for ID {igdb_id}")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
igdb_title = game_dict.get("title", "")
|
||||||
|
igdb_title_clean = igdb_title.split(" (")[0].strip() if " (" in igdb_title else igdb_title
|
||||||
|
expected = expected_title or game.title
|
||||||
|
if igdb_title_clean.lower() != expected.lower():
|
||||||
|
logger.info(
|
||||||
|
f"IGDB title {igdb_title!r} doesn't match expected {expected!r} for {game} — re-searching…"
|
||||||
|
)
|
||||||
|
from videogames.igdb import lookup_game_id_from_gdb
|
||||||
|
|
||||||
|
new_id = lookup_game_id_from_gdb(expected)
|
||||||
|
if new_id:
|
||||||
|
new_data = lookup_game_from_igdb(str(new_id))
|
||||||
|
if new_data:
|
||||||
|
new_title = new_data.get("title", "")
|
||||||
|
new_title_clean = new_title.split(" (")[0].strip() if " (" in new_title else new_title
|
||||||
|
if new_title_clean.lower() == expected.lower():
|
||||||
|
game_dict = new_data
|
||||||
|
igdb_id = int(new_id)
|
||||||
|
if game.igdb_id != igdb_id:
|
||||||
|
game.igdb_id = igdb_id
|
||||||
|
game.save(update_fields=["igdb_id"])
|
||||||
|
logger.info(f"Game {game} IGDB ID updated to {igdb_id}")
|
||||||
|
|
||||||
|
igdb_title = game_dict.get("title", "")
|
||||||
|
igdb_title_clean = igdb_title.split(" (")[0].strip() if " (" in igdb_title else igdb_title
|
||||||
|
if igdb_title_clean.lower() != expected.lower():
|
||||||
|
if "igdb-mismatch" not in game.tags.names():
|
||||||
|
game.tags.add("igdb-mismatch")
|
||||||
|
logger.info(
|
||||||
|
f"Game {game} tagged igdb-mismatch (IGDB: {igdb_title!r} vs expected: {expected!r})"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
screenshot_url = game_dict.pop("screenshot_url")
|
screenshot_url = game_dict.pop("screenshot_url")
|
||||||
cover_url = game_dict.pop("cover_url")
|
cover_url = game_dict.pop("cover_url")
|
||||||
genres = game_dict.pop("genres")
|
genres = game_dict.pop("genres")
|
||||||
|
platforms = game_dict.pop("platforms", [])
|
||||||
|
|
||||||
VideoGame.objects.filter(pk=game.id).update(**game_dict)
|
VideoGame.objects.filter(pk=game.id).update(**game_dict)
|
||||||
game.refresh_from_db()
|
game.refresh_from_db()
|
||||||
|
|
||||||
game.genre.add(*genres)
|
game.genre.add(*genres)
|
||||||
|
|
||||||
if not game.screenshot and screenshot_url:
|
if platforms:
|
||||||
|
for name in platforms:
|
||||||
|
p, _ = VideoGamePlatform.objects.get_or_create(name=name)
|
||||||
|
game.platforms.add(p)
|
||||||
|
|
||||||
|
if screenshot_url:
|
||||||
r = requests.get(screenshot_url)
|
r = requests.get(screenshot_url)
|
||||||
if r.status_code == 200:
|
if r.status_code == 200:
|
||||||
fname = f"{game.title}_{game.uuid}.jpg"
|
fname = f"{game.title}_{game.uuid}.jpg"
|
||||||
game.screenshot.save(fname, ContentFile(r.content), save=True)
|
game.screenshot.save(fname, ContentFile(r.content), save=True)
|
||||||
|
|
||||||
if not game.cover and cover_url:
|
if cover_url:
|
||||||
r = requests.get(cover_url)
|
r = requests.get(cover_url)
|
||||||
if r.status_code == 200:
|
if r.status_code == 200:
|
||||||
fname = f"{game.title}_{game.uuid}.jpg"
|
fname = f"{game.title}_{game.uuid}.jpg"
|
||||||
game.cover.save(fname, ContentFile(r.content), save=True)
|
game.cover.save(fname, ContentFile(r.content), save=True)
|
||||||
|
|
||||||
|
if "igdb-enriched" not in game.tags.names():
|
||||||
|
game.tags.add("igdb-enriched")
|
||||||
|
logger.info(f"Game {game} tagged igdb-enriched")
|
||||||
|
|
||||||
return game
|
return game
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.db import transaction
|
from django.db import models, transaction
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -25,6 +25,11 @@ class Command(BaseCommand):
|
|||||||
type=str,
|
type=str,
|
||||||
help="Only process series with this imdb_id",
|
help="Only process series with this imdb_id",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--needs-metadata",
|
||||||
|
action="store_true",
|
||||||
|
help="Only process series missing imdb_id or cover image",
|
||||||
|
)
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
from videos.models import Series
|
from videos.models import Series
|
||||||
@ -32,18 +37,30 @@ class Command(BaseCommand):
|
|||||||
force = options["force"]
|
force = options["force"]
|
||||||
dry_run = options["dry_run"]
|
dry_run = options["dry_run"]
|
||||||
imdb_id = options["imdb_id"]
|
imdb_id = options["imdb_id"]
|
||||||
|
needs_metadata = options["needs_metadata"]
|
||||||
|
|
||||||
qs = Series.objects.all()
|
qs = Series.objects.all()
|
||||||
if imdb_id:
|
if imdb_id:
|
||||||
qs = qs.filter(imdb_id=imdb_id)
|
qs = qs.filter(imdb_id=imdb_id)
|
||||||
|
if needs_metadata:
|
||||||
|
qs = qs.filter(
|
||||||
|
models.Q(imdb_id__isnull=True)
|
||||||
|
| models.Q(imdb_id="")
|
||||||
|
| models.Q(cover_image__isnull=True)
|
||||||
|
| models.Q(cover_image="")
|
||||||
|
)
|
||||||
|
|
||||||
total = qs.count()
|
total = qs.count()
|
||||||
self.stdout.write(f"Processing {total} series")
|
self.stdout.write(f"Processing {total} series")
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
for series in qs.iterator():
|
for series in qs.iterator():
|
||||||
|
has_imdb = bool(series.imdb_id)
|
||||||
|
has_image = bool(series.cover_image)
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
f" [DRY RUN] Would fix {series.name} (imdb_id={series.imdb_id})"
|
f" [DRY RUN] Would fix {series.name}"
|
||||||
|
f" (imdb_id={'✓' if has_imdb else '✗'}"
|
||||||
|
f", image={'✓' if has_image else '✗'})"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@ -321,13 +321,27 @@ class Series(TimeStampedModel):
|
|||||||
return not last_scrobble.played_to_completion
|
return not last_scrobble.played_to_completion
|
||||||
|
|
||||||
def fix_metadata(self, force_update=False):
|
def fix_metadata(self, force_update=False):
|
||||||
name_or_id = self.name
|
from tmdbv3api import TV
|
||||||
if self.imdb_id:
|
|
||||||
name_or_id = self.imdb_id
|
|
||||||
video_metadata: VideoMetadata = lookup_video_from_tmdb(name_or_id)
|
|
||||||
|
|
||||||
if not video_metadata.title:
|
if not self.imdb_id:
|
||||||
logger.warning(f"No imdb data for {self}")
|
tv = TV()
|
||||||
|
results = tv.search(self.name)
|
||||||
|
if results:
|
||||||
|
show_id = results[0].id
|
||||||
|
external_ids = tv.external_ids(show_id)
|
||||||
|
if external_ids and external_ids.imdb_id:
|
||||||
|
self.imdb_id = external_ids.imdb_id
|
||||||
|
self.save(update_fields=["imdb_id"])
|
||||||
|
else:
|
||||||
|
logger.warning(f"No IMDB ID found on TMDB for {self}")
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
logger.warning(f"No results on TMDB for {self.name}")
|
||||||
|
return
|
||||||
|
|
||||||
|
video_metadata = lookup_video_from_tmdb(self.imdb_id)
|
||||||
|
if not video_metadata or not video_metadata.title:
|
||||||
|
logger.warning(f"No metadata for {self}")
|
||||||
return
|
return
|
||||||
|
|
||||||
if video_metadata.cover_url and (not self.cover_image or force_update):
|
if video_metadata.cover_url and (not self.cover_image or force_update):
|
||||||
@ -336,8 +350,8 @@ class Series(TimeStampedModel):
|
|||||||
fname = f"{self.name}_{self.uuid}.jpg"
|
fname = f"{self.name}_{self.uuid}.jpg"
|
||||||
self.cover_image.save(fname, ContentFile(r.content), save=True)
|
self.cover_image.save(fname, ContentFile(r.content), save=True)
|
||||||
|
|
||||||
self.plot = video_metadata.plot
|
self.plot = video_metadata.plot or ""
|
||||||
self.imdb_rating = video_metadata.imdb_rating
|
self.imdb_rating = getattr(video_metadata, "imdb_rating", None)
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
if video_metadata.genres:
|
if video_metadata.genres:
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
import pendulum
|
import pendulum
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@ -8,6 +9,8 @@ from videos.metadata import VideoMetadata, VideoType
|
|||||||
|
|
||||||
TMDB_KEY = getattr(settings, "TMDB_API_KEY", "")
|
TMDB_KEY = getattr(settings, "TMDB_API_KEY", "")
|
||||||
|
|
||||||
|
os.environ.setdefault("TMDB_API_KEY", TMDB_KEY)
|
||||||
|
|
||||||
tmdb = TMDb(key=TMDB_KEY, language="en-US", region="US")
|
tmdb = TMDb(key=TMDB_KEY, language="en-US", region="US")
|
||||||
|
|
||||||
TMDB_IMAGE_URL = "https://image.tmdb.org/t/p/original"
|
TMDB_IMAGE_URL = "https://image.tmdb.org/t/p/original"
|
||||||
@ -43,6 +46,28 @@ def lookup_video_from_tmdb(name_or_id: str, kind: str = "movie") -> VideoMetadat
|
|||||||
) # TODO: enrich this with TMDB url
|
) # TODO: enrich this with TMDB url
|
||||||
video_metadata.year = pendulum.parse(media.release_date).year
|
video_metadata.year = pendulum.parse(media.release_date).year
|
||||||
video_metadata.genres = [g.get("name", "") for g in media.genres]
|
video_metadata.genres = [g.get("name", "") for g in media.genres]
|
||||||
|
video_metadata.tmdb_id = media.id
|
||||||
|
video_metadata.base_run_time_seconds = media.runtime * 60
|
||||||
|
video_metadata.plot = media.overview
|
||||||
|
video_metadata.overview = media.overview
|
||||||
|
video_metadata.tmdb_rating = media.vote_average
|
||||||
|
|
||||||
|
if len(tmdb_result.tv_results) > 0:
|
||||||
|
media = TV().details(tmdb_result.tv_results[0].id)
|
||||||
|
video_metadata.video_type = VideoType.TV_EPISODE.value
|
||||||
|
video_metadata.title = media.name
|
||||||
|
video_metadata.cover_url = (
|
||||||
|
TMDB_IMAGE_URL + media.poster_path
|
||||||
|
)
|
||||||
|
video_metadata.year = pendulum.parse(media.first_air_date).year if media.first_air_date else None
|
||||||
|
video_metadata.genres = [g.get("name", "") for g in media.genres]
|
||||||
|
video_metadata.tmdb_id = media.id
|
||||||
|
video_metadata.base_run_time_seconds = (
|
||||||
|
media.episode_run_time[0] * 60 if media.episode_run_time else 1800
|
||||||
|
)
|
||||||
|
video_metadata.plot = media.overview
|
||||||
|
video_metadata.overview = media.overview
|
||||||
|
video_metadata.tmdb_rating = media.vote_average
|
||||||
|
|
||||||
if len(tmdb_result.tv_episode_results) > 0:
|
if len(tmdb_result.tv_episode_results) > 0:
|
||||||
video_metadata.video_type = VideoType.TV_EPISODE.value
|
video_metadata.video_type = VideoType.TV_EPISODE.value
|
||||||
@ -63,15 +88,12 @@ def lookup_video_from_tmdb(name_or_id: str, kind: str = "movie") -> VideoMetadat
|
|||||||
series.save()
|
series.save()
|
||||||
video_metadata.tv_series_id = series.id
|
video_metadata.tv_series_id = series.id
|
||||||
|
|
||||||
|
video_metadata.tmdb_id = media.id
|
||||||
|
video_metadata.plot = media.overview
|
||||||
|
video_metadata.overview = media.overview
|
||||||
|
|
||||||
if not media:
|
if not media:
|
||||||
logger.warning("Video not found on TMDB", extra={"imdb_id": imdb_id})
|
logger.warning("Video not found on TMDB", extra={"imdb_id": imdb_id})
|
||||||
return video_metadata
|
return video_metadata
|
||||||
|
|
||||||
video_metadata.tmdb_id = media.id
|
|
||||||
video_metadata.base_run_time_seconds = media.runtime * 60
|
|
||||||
video_metadata.plot = media.overview
|
|
||||||
video_metadata.overview = media.overview
|
|
||||||
video_metadata.tmdb_rating = media.vote_average
|
|
||||||
# video_metadata.next_imdb_id = imdb_result.get("next episode", None)
|
|
||||||
|
|
||||||
return video_metadata
|
return video_metadata
|
||||||
|
|||||||
@ -3,3 +3,6 @@ from django.apps import AppConfig
|
|||||||
|
|
||||||
class WebpagesConfig(AppConfig):
|
class WebpagesConfig(AppConfig):
|
||||||
name = "webpages"
|
name = "webpages"
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
import webpages.signals # noqa
|
||||||
|
|||||||
0
vrobbler/apps/webpages/management/__init__.py
Normal file
0
vrobbler/apps/webpages/management/__init__.py
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from scrobbles.utils import tokenize_title_to_tags
|
||||||
|
from webpages.models import WebPage
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(s: str) -> str:
|
||||||
|
return re.sub(r"[^\x20-\x7e]", "", s)
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Backfill auto tags on webpages from domain and title"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"--commit",
|
||||||
|
action="store_true",
|
||||||
|
help="Actually add tags",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
commit = options["commit"]
|
||||||
|
|
||||||
|
webpages = WebPage.objects.all()
|
||||||
|
total = webpages.count()
|
||||||
|
updated_count = 0
|
||||||
|
skipped_count = 0
|
||||||
|
|
||||||
|
for i, webpage in enumerate(webpages.iterator(), start=1):
|
||||||
|
new_tags = set()
|
||||||
|
|
||||||
|
if webpage.domain:
|
||||||
|
parts = webpage.domain.root.split(".")
|
||||||
|
for part in parts:
|
||||||
|
part = part.strip().lower()
|
||||||
|
if part and part != "www":
|
||||||
|
new_tags.add(part)
|
||||||
|
|
||||||
|
if webpage.title:
|
||||||
|
title_tags = tokenize_title_to_tags(webpage.title)
|
||||||
|
new_tags.update(title_tags)
|
||||||
|
|
||||||
|
existing_tags = {
|
||||||
|
t.name for t in webpage.tags.all()
|
||||||
|
}
|
||||||
|
tags_to_add = new_tags - existing_tags
|
||||||
|
|
||||||
|
if tags_to_add:
|
||||||
|
updated_count += 1
|
||||||
|
if commit:
|
||||||
|
for tag in tags_to_add:
|
||||||
|
webpage.tags.add(tag)
|
||||||
|
self.stdout.write(
|
||||||
|
f"[{i}/{total}] Added tags to {_clean(str(webpage))}: "
|
||||||
|
f"{sorted(tags_to_add)}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.stdout.write(
|
||||||
|
f"[{i}/{total}] [DRY RUN] Would add tags to "
|
||||||
|
f"{_clean(str(webpage))}: {sorted(tags_to_add)}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
skipped_count += 1
|
||||||
|
|
||||||
|
self.stdout.write(f"\nDone. {updated_count} webpages to update, "
|
||||||
|
f"{skipped_count} already up to date.")
|
||||||
29
vrobbler/apps/webpages/signals.py
Normal file
29
vrobbler/apps/webpages/signals.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from django.db.models.signals import post_save
|
||||||
|
from django.dispatch import receiver
|
||||||
|
|
||||||
|
from scrobbles.utils import tokenize_title_to_tags
|
||||||
|
from webpages.models import WebPage
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=WebPage)
|
||||||
|
def add_auto_tags_to_webpage(sender, instance, **kwargs):
|
||||||
|
existing_tags = {t.name for t in instance.tags.all()}
|
||||||
|
|
||||||
|
if instance.domain:
|
||||||
|
domain_parts = instance.domain.root.split(".")
|
||||||
|
for part in domain_parts:
|
||||||
|
part = part.strip().lower()
|
||||||
|
if part and part != "www" and part not in existing_tags:
|
||||||
|
instance.tags.add(part)
|
||||||
|
existing_tags.add(part)
|
||||||
|
|
||||||
|
if instance.title:
|
||||||
|
title_tags = tokenize_title_to_tags(instance.title)
|
||||||
|
for tag in title_tags:
|
||||||
|
if tag not in existing_tags:
|
||||||
|
instance.tags.add(tag)
|
||||||
|
existing_tags.add(tag)
|
||||||
@ -16,8 +16,23 @@ from webpages.models import WebPage
|
|||||||
class WebPageListView(ScrobbleableListView):
|
class WebPageListView(ScrobbleableListView):
|
||||||
model = WebPage
|
model = WebPage
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
queryset = super().get_queryset()
|
||||||
|
tag_name = self.request.GET.get("tag")
|
||||||
|
if tag_name:
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from taggit.models import TaggedItem
|
||||||
|
ct = ContentType.objects.get_for_model(WebPage)
|
||||||
|
webpages = TaggedItem.objects.filter(
|
||||||
|
content_type=ct,
|
||||||
|
tag__name__iexact=tag_name,
|
||||||
|
).values_list("object_id", flat=True)
|
||||||
|
queryset = queryset.filter(pk__in=webpages)
|
||||||
|
return queryset
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context_data = super().get_context_data(**kwargs)
|
context_data = super().get_context_data(**kwargs)
|
||||||
|
context_data["active_tag"] = self.request.GET.get("tag", "")
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
start_day_of_week = now - datetime.timedelta(days=now.weekday())
|
start_day_of_week = now - datetime.timedelta(days=now.weekday())
|
||||||
|
|||||||
@ -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",
|
||||||
@ -219,6 +228,7 @@ INSTALLED_APPS = [
|
|||||||
"foods",
|
"foods",
|
||||||
"lifeevents",
|
"lifeevents",
|
||||||
"moods",
|
"moods",
|
||||||
|
"discgolf",
|
||||||
"birds",
|
"birds",
|
||||||
"mathfilters",
|
"mathfilters",
|
||||||
"rest_framework",
|
"rest_framework",
|
||||||
@ -352,11 +362,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")
|
||||||
|
|||||||
@ -322,8 +322,8 @@
|
|||||||
<span class="progress-bar-fill" style="width: {{scrobble.percent_played}}%;"></span>
|
<span class="progress-bar-fill" style="width: {{scrobble.percent_played}}%;"></span>
|
||||||
</div>
|
</div>
|
||||||
<p class="action-buttons">
|
<p class="action-buttons">
|
||||||
<a href="{% url "scrobbles:cancel" scrobble.uuid %}">Cancel</a>
|
<a href="{% url "scrobbles:cancel" scrobble.id %}">Cancel</a>
|
||||||
<a class="right" href="{% url "scrobbles:finish" scrobble.uuid %}">Finish</a>
|
<a class="right" href="{% url "scrobbles:finish" scrobble.id %}">Finish</a>
|
||||||
</p>
|
</p>
|
||||||
{% if not forloop.last %}<hr/>{% endif %}
|
{% if not forloop.last %}<hr/>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -65,7 +65,7 @@
|
|||||||
{% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
|
{% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td>
|
<td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td>
|
||||||
<td>{% if scrobble.long_play_complete == True %}Yes <small><a href="{% url 'scrobbles:longplay-finish' uuid=scrobble.uuid %}">(not complete?)</a></small>{% else %}<a href="{% url 'scrobbles:longplay-finish' uuid=scrobble.uuid %}">No</a>{% endif %}</td>
|
<td>{% if scrobble.long_play_complete == True %}Yes <small><a href="{% url 'scrobbles:longplay-finish' media_uuid=scrobble.uuid %}">(not complete?)</a></small>{% else %}<a href="{% url 'scrobbles:longplay-finish' media_uuid=scrobble.uuid %}">No</a>{% endif %}</td>
|
||||||
<td>{% if scrobble.in_progress %}Now reading{% else %}{{scrobble.session_pages_read}}{% endif %}</td>
|
<td>{% if scrobble.in_progress %}Now reading{% else %}{{scrobble.session_pages_read}}{% endif %}</td>
|
||||||
<td>{% for author in scrobble.book.authors.all %}<a href="{{author.get_absolute_url}}">{{author}}</a>{% if not forloop.last %}, {% endif %}{% endfor %}</td>
|
<td>{% for author in scrobble.book.authors.all %}<a href="{{author.get_absolute_url}}">{{author}}</a>{% if not forloop.last %}, {% endif %}{% endfor %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -48,7 +48,7 @@
|
|||||||
{% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
|
{% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td>
|
<td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td>
|
||||||
<td>{% if scrobble.long_play_complete == True %}Yes <small><a href="{% url 'scrobbles:longplay-finish' uuid=scrobble.uuid %}">(not complete?)</a></small>{% else %}<a href="{% url 'scrobbles:longplay-finish' uuid=scrobble.uuid %}">No</a>{% endif %}</td>
|
<td>{% if scrobble.long_play_complete == True %}Yes <small><a href="{% url 'scrobbles:longplay-finish' media_uuid=scrobble.uuid %}">(not complete?)</a></small>{% else %}<a href="{% url 'scrobbles:longplay-finish' media_uuid=scrobble.uuid %}">No</a>{% endif %}</td>
|
||||||
<td>{% if scrobble.in_progress %}Now reading{% else %}{{scrobble.session_pages_read}}{% endif %}</td>
|
<td>{% if scrobble.in_progress %}Now reading{% else %}{{scrobble.session_pages_read}}{% endif %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
44
vrobbler/templates/discgolf/discgolfcourse_detail.html
Normal file
44
vrobbler/templates/discgolf/discgolfcourse_detail.html
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
{% extends "base_list.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}{{object.title}}{% endblock %}
|
||||||
|
|
||||||
|
{% block lists %}
|
||||||
|
{% if object.description %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md">
|
||||||
|
<p>{{object.description}}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if charts %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md">
|
||||||
|
{% include "scrobbles/_chart_links.html" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md">
|
||||||
|
<h3>Last scrobbles</h3>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Date</th>
|
||||||
|
<th scope="col">Notes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for scrobble in scrobbles.all %}
|
||||||
|
<tr>
|
||||||
|
<td><a href={{scrobble.get_absolute_url}}>{{scrobble.local_timestamp}}</a></td>
|
||||||
|
<td>{{scrobble.logdata.notes_as_str}}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
13
vrobbler/templates/discgolf/discgolfcourse_list.html
Normal file
13
vrobbler/templates/discgolf/discgolfcourse_list.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{% extends "base_list.html" %}
|
||||||
|
|
||||||
|
{% block title %}Disc Golf Rounds{% endblock %}
|
||||||
|
|
||||||
|
{% block lists %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md">
|
||||||
|
<div class="table-responsive">
|
||||||
|
{% include "_scrobblable_list.html" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@ -81,6 +81,15 @@
|
|||||||
<p>No trails hiked today</p>
|
<p>No trails hiked today</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<h3><a href="{% url 'discgolf:course_list' %}">Disc Golf</a></h3>
|
||||||
|
{% if DiscGolf %}
|
||||||
|
{% with scrobbles=DiscGolf count=DiscGolf_count time=DiscGolf_time %}
|
||||||
|
{% include "scrobbles/_scrobble_table.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
{% else %}
|
||||||
|
<p>No courses played today</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md">
|
<div class="col-md">
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{% load humanize %}
|
{% load humanize %}
|
||||||
{% load naturalduration %}
|
{% load naturalduration %}
|
||||||
<tr {% if scrobble.in_progress %}class="in-progress"{% endif %}>
|
<tr {% if scrobble.in_progress %}class="in-progress"{% endif %}>
|
||||||
<td>{% if scrobble.in_progress %}<a href="{{scrobble.get_absolute_url}}">{{scrobble.media_obj.strings.verb}} now</a> | <a class="right" href="{% url "scrobbles:finish" scrobble.uuid %}">Finish</a>{% else %}<a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp|naturaltime}}</a>{% endif %}</td>
|
<td>{% if scrobble.in_progress %}<a href="{{scrobble.get_absolute_url}}">{{scrobble.media_obj.strings.verb}} now</a> | <a class="right" href="{% url "scrobbles:finish" scrobble.id %}">Finish</a>{% else %}<a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp|naturaltime}}</a>{% endif %}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if scrobble.media_type in "Task" %}
|
{% if scrobble.media_type in "Task" %}
|
||||||
<p><em><a href="{{scrobble.media_obj.get_absolute_url}}">{{scrobble.media_obj.title|truncatechars_html:45}} - {% if scrobble.logdata %}{% if scrobble.logdata.title %}{{scrobble.logdata.title}}{% endif %}{% endif %}</a></em></p>
|
<p><em><a href="{{scrobble.media_obj.get_absolute_url}}">{{scrobble.media_obj.title|truncatechars_html:45}} - {% if scrobble.logdata %}{% if scrobble.logdata.title %}{{scrobble.logdata.title}}{% endif %}{% endif %}</a></em></p>
|
||||||
|
|||||||
@ -205,7 +205,7 @@ header.navbar { display: none !important; }
|
|||||||
style="background:{% if not cd.is_today %}{{ cd.color }}{% endif %};">
|
style="background:{% if not cd.is_today %}{{ cd.color }}{% endif %};">
|
||||||
<div class="day-number"><a href="{% url 'vrobbler-home' %}?date={{ year }}-{{ month|stringformat:'02d' }}-{{ cd.day|stringformat:'02d' }}" style="color:inherit;text-decoration:none;">{{ cd.day }}</a>{% if cd.total_count > 0 %} <span style="font-weight:400;color:#999;font-size:0.75rem;">{{ cd.total_count }}</span>{% endif %}</div>
|
<div class="day-number"><a href="{% url 'vrobbler-home' %}?date={{ year }}-{{ month|stringformat:'02d' }}-{{ cd.day|stringformat:'02d' }}" style="color:inherit;text-decoration:none;">{{ cd.day }}</a>{% if cd.total_count > 0 %} <span style="font-weight:400;color:#999;font-size:0.75rem;">{{ cd.total_count }}</span>{% endif %}</div>
|
||||||
{% for s in cd.scrobbles %}
|
{% for s in cd.scrobbles %}
|
||||||
<a href="{% url 'scrobbles:detail' uuid=s.uuid %}"
|
<a href="{% url 'scrobbles:detail' pk=s.id %}"
|
||||||
class="event-card"
|
class="event-card"
|
||||||
title="{{ s.title }} — {{ s.media_type }}">{{ s.emoji }} {{ s.title }}</a>
|
title="{{ s.title }} — {{ s.media_type }}">{{ s.emoji }} {{ s.title }}</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@ -88,7 +88,7 @@
|
|||||||
{% endif %}">
|
{% endif %}">
|
||||||
{{ object.get_visibility_display }}
|
{{ object.get_visibility_display }}
|
||||||
</span>
|
</span>
|
||||||
<form method="post" action="{% url 'scrobbles:change-visibility' object.uuid %}" class="d-inline-flex align-items-center gap-1">
|
<form method="post" action="{% url 'scrobbles:change-visibility' object.id %}" class="d-inline-flex align-items-center gap-1">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<select name="visibility" class="form-select form-select-sm" style="width:auto;" onchange="this.form.submit()">
|
<select name="visibility" class="form-select form-select-sm" style="width:auto;" onchange="this.form.submit()">
|
||||||
<option value="private" {% if object.visibility == 'private' %}selected{% endif %}>Private</option>
|
<option value="private" {% if object.visibility == 'private' %}selected{% endif %}>Private</option>
|
||||||
@ -100,25 +100,25 @@
|
|||||||
<span class="small text-muted">Share link:</span>
|
<span class="small text-muted">Share link:</span>
|
||||||
<code class="small" id="share-link">{{ request.scheme }}://{{ request.get_host }}{{ object.get_share_url }}</code>
|
<code class="small" id="share-link">{{ request.scheme }}://{{ request.get_host }}{{ object.get_share_url }}</code>
|
||||||
<button class="btn btn-sm btn-outline-secondary" onclick="navigator.clipboard.writeText(document.getElementById('share-link').textContent.trim())">Copy</button>
|
<button class="btn btn-sm btn-outline-secondary" onclick="navigator.clipboard.writeText(document.getElementById('share-link').textContent.trim())">Copy</button>
|
||||||
<form method="post" action="{% url 'scrobbles:regenerate-share-token' object.uuid %}" class="d-inline">
|
<form method="post" action="{% url 'scrobbles:regenerate-share-token' object.id %}" class="d-inline">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button type="submit" class="btn btn-sm btn-outline-warning">Regenerate</button>
|
<button type="submit" class="btn btn-sm btn-outline-warning">Regenerate</button>
|
||||||
</form>
|
</form>
|
||||||
{% if object.share_view_count %}
|
{% if object.share_view_count %}
|
||||||
<span class="text-muted small ms-2">{{ object.share_view_count }} view{{ object.share_view_count|pluralize }}</span>
|
<span class="text-muted small ms-2">{{ object.share_view_count }} view{{ object.share_view_count|pluralize }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="{% url 'scrobbles:share-analytics' object.uuid %}" class="btn btn-sm btn-outline-info ms-2">Analytics</a>
|
<a href="{% url 'scrobbles:share-analytics' object.id %}" class="btn btn-sm btn-outline-info ms-2">Analytics</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if object.media_type == "Track" and has_mopidy_uri and user.profile.mopidy_api_url %}
|
{% if object.media_type == "Track" and has_mopidy_uri and user.profile.mopidy_api_url %}
|
||||||
<form method="post" action="{% url 'scrobbles:add-to-mopidy-queue' object.uuid %}" class="mb-1">
|
<form method="post" action="{% url 'scrobbles:add-to-mopidy-queue' object.id %}" class="mb-1">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button type="submit" class="btn btn-sm btn-outline-secondary">add to mopidy queue</button>
|
<button type="submit" class="btn btn-sm btn-outline-secondary">add to mopidy queue</button>
|
||||||
</form>
|
</form>
|
||||||
{% if user.profile.monthly_mopidy_playlist_pattern %}
|
{% if user.profile.monthly_mopidy_playlist_pattern %}
|
||||||
<form method="post" action="{% url 'scrobbles:add-to-mopidy-monthly-playlist' object.uuid %}" class="mb-1">
|
<form method="post" action="{% url 'scrobbles:add-to-mopidy-monthly-playlist' object.id %}" class="mb-1">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button type="submit" class="btn btn-sm btn-outline-secondary">add to monthly playlist</button>
|
<button type="submit" class="btn btn-sm btn-outline-secondary">add to monthly playlist</button>
|
||||||
</form>
|
</form>
|
||||||
@ -185,6 +185,12 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if object.screenshot %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<img src="{{ object.screenshot_large.url }}" class="img-fluid rounded" style="max-height: 600px;" alt="Screenshot" />
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Tags:
|
Tags:
|
||||||
{% if object.tags.all %}
|
{% if object.tags.all %}
|
||||||
@ -282,7 +288,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for scrobble in related_scrobbles %}
|
{% for scrobble in related_scrobbles %}
|
||||||
<tr{% if scrobble.id == object.id %} class="table-active fw-bold"{% endif %}>
|
<tr{% if scrobble.id == object.id %} class="table-active fw-bold"{% endif %}>
|
||||||
<td>{% if scrobble.id == object.id %}{{ scrobble.timestamp|date:"M d, Y" }}{% else %}<a href="{% url 'scrobbles:detail' scrobble.uuid %}">{{ scrobble.timestamp|date:"M d, Y" }}</a>{% endif %}</td>
|
<td>{% if scrobble.id == object.id %}{{ scrobble.timestamp|date:"M d, Y" }}{% else %}<a href="{% url 'scrobbles:detail' scrobble.id %}">{{ scrobble.timestamp|date:"M d, Y" }}</a>{% endif %}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if scrobble.media_type == "Task" and scrobble.logdata.title %}{{ scrobble.media_obj.title }}: {{ scrobble.logdata.title }}{% else %}{{ scrobble.media_obj.title|default:scrobble.media_obj }}{% endif %}
|
{% if scrobble.media_type == "Task" and scrobble.logdata.title %}{{ scrobble.media_obj.title }}: {{ scrobble.logdata.title }}{% else %}{{ scrobble.media_obj.title|default:scrobble.media_obj }}{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -103,7 +103,7 @@
|
|||||||
{% for scrobble in scrobbles %}
|
{% for scrobble in scrobbles %}
|
||||||
<div class="result-item">
|
<div class="result-item">
|
||||||
<div class="result-title">
|
<div class="result-title">
|
||||||
<a href="{% url 'scrobbles:detail' scrobble.uuid %}">
|
<a href="{% url 'scrobbles:detail' scrobble.id %}">
|
||||||
{{ scrobble|truncatechars:100 }}
|
{{ scrobble|truncatechars:100 }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -117,6 +117,12 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if object.screenshot %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<img src="{{ object.screenshot_large.url }}" class="img-fluid rounded" style="max-height: 600px;" alt="Screenshot" />
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Tags:
|
Tags:
|
||||||
{% if object.tags.all %}
|
{% if object.tags.all %}
|
||||||
|
|||||||
@ -81,7 +81,7 @@
|
|||||||
<td><a href="{{scrobble.get_media_source_url}}">{{scrobble.logdata.title}}</a></td>
|
<td><a href="{{scrobble.get_media_source_url}}">{{scrobble.logdata.title}}</a></td>
|
||||||
<td>{{scrobble.logdata.notes_as_str}}</td>
|
<td>{{scrobble.logdata.notes_as_str}}</td>
|
||||||
<td>{{scrobble.source}}</td>
|
<td>{{scrobble.source}}</td>
|
||||||
<td>{% if scrobble.long_play_complete == True %}Yes <small><a href="{% url 'scrobbles:longplay-finish' uuid=scrobble.uuid %}">(not complete?)</a></small>{% else %}<a href="{% url 'scrobbles:longplay-finish' uuid=scrobble.uuid %}">No</a>{% endif %}</td>
|
<td>{% if scrobble.long_play_complete == True %}Yes <small><a href="{% url 'scrobbles:longplay-finish' media_uuid=scrobble.uuid %}">(not complete?)</a></small>{% else %}<a href="{% url 'scrobbles:longplay-finish' media_uuid=scrobble.uuid %}">No</a>{% endif %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@ -87,7 +87,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
|
|
||||||
<td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td>
|
<td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td>
|
||||||
<td>{% if scrobble.long_play_complete == True %}Yes <small><a href="{% url 'scrobbles:longplay-finish' uuid=scrobble.uuid %}">(not complete?)</a></small>{% else %}<a href="{% url 'scrobbles:longplay-finish' uuid=scrobble.uuid %}">Not yet</a>{% endif %}</td>
|
<td>{% if scrobble.long_play_complete == True %}Yes <small><a href="{% url 'scrobbles:longplay-finish' media_uuid=scrobble.uuid %}">(not complete?)</a></small>{% else %}<a href="{% url 'scrobbles:longplay-finish' media_uuid=scrobble.uuid %}">Not yet</a>{% endif %}</td>
|
||||||
<td>{% if scrobble.in_progress %}Now playing{% else %}{{scrobble.playback_position_seconds|natural_duration}}{% endif %}</td>
|
<td>{% if scrobble.in_progress %}Now playing{% else %}{{scrobble.playback_position_seconds|natural_duration}}{% endif %}</td>
|
||||||
<td>{% for platform in scrobble.video_game.platforms.all %}<a href="{{platform.get_absolute_url}}">{{platform}}</a>{% if not forloop.last %}, {% endif %}{% endfor %}</td>
|
<td>{% for platform in scrobble.video_game.platforms.all %}<a href="{{platform.get_absolute_url}}">{{platform}}</a>{% if not forloop.last %}, {% endif %}{% endfor %}</td>
|
||||||
<td>{% if scrobble.videogame_save_data %}<a href="{{scrobble.videogame_save_data.url}}">Save data</a>{% else %}Not yet{% endif %}</td>
|
<td>{% if scrobble.videogame_save_data %}<a href="{{scrobble.videogame_save_data.url}}">Save data</a>{% else %}Not yet{% endif %}</td>
|
||||||
|
|||||||
@ -204,6 +204,13 @@
|
|||||||
<p>Source: <a href="{{object.url}}">{{object.domain}}</a></p>
|
<p>Source: <a href="{{object.url}}">{{object.domain}}</a></p>
|
||||||
{% if object.date %}<p>Published: <em>{{object.date}}</em></p>{% endif %}
|
{% if object.date %}<p>Published: <em>{{object.date}}</em></p>{% endif %}
|
||||||
<p>Time to read: {{object.estimated_time_to_read_in_minutes}} minutes</p>
|
<p>Time to read: {{object.estimated_time_to_read_in_minutes}} minutes</p>
|
||||||
|
{% if object.tags.all %}
|
||||||
|
<p>Tags:
|
||||||
|
{% for tag in object.tags.all %}
|
||||||
|
<a href="{% url 'webpages:webpage_list' %}?tag={{ tag.name|urlencode }}">{{ tag.name }}</a>{% if not forloop.last %}, {% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if object.extract %}
|
{% if object.extract %}
|
||||||
<div class="col">
|
<div class="col">
|
||||||
|
|||||||
@ -7,6 +7,9 @@ from rest_framework import routers
|
|||||||
|
|
||||||
import vrobbler.apps.scrobbles.views as scrobbles_views
|
import vrobbler.apps.scrobbles.views as scrobbles_views
|
||||||
|
|
||||||
|
from vrobbler.apps.discgolf import urls as discgolf_urls
|
||||||
|
from vrobbler.apps.discgolf.api.views import DiscGolfCourseViewSet
|
||||||
|
|
||||||
from vrobbler.apps.boardgames import urls as boardgame_urls
|
from vrobbler.apps.boardgames import urls as boardgame_urls
|
||||||
from vrobbler.apps.boardgames.api.views import (
|
from vrobbler.apps.boardgames.api.views import (
|
||||||
BoardGameViewSet,
|
BoardGameViewSet,
|
||||||
@ -153,7 +156,7 @@ router.register(r"lifeevents", LifeEventViewSet)
|
|||||||
|
|
||||||
router.register(r"birds", BirdViewSet)
|
router.register(r"birds", BirdViewSet)
|
||||||
router.register(r"birding-locations", BirdingLocationViewSet)
|
router.register(r"birding-locations", BirdingLocationViewSet)
|
||||||
|
router.register(r"disc-golf-courses", DiscGolfCourseViewSet)
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("api/v1/", include(router.urls)),
|
path("api/v1/", include(router.urls)),
|
||||||
path("api/v1/auth", include("rest_framework.urls")),
|
path("api/v1/auth", include("rest_framework.urls")),
|
||||||
@ -171,6 +174,7 @@ urlpatterns = [
|
|||||||
path("", include(locations_urls, namespace="locations")),
|
path("", include(locations_urls, namespace="locations")),
|
||||||
path("", include(trails_urls, namespace="trails")),
|
path("", include(trails_urls, namespace="trails")),
|
||||||
path("", include(beers_urls, namespace="beers")),
|
path("", include(beers_urls, namespace="beers")),
|
||||||
|
path("", include(discgolf_urls, namespace="discgolf")),
|
||||||
path("", include(foods_urls, namespace="foods")),
|
path("", include(foods_urls, namespace="foods")),
|
||||||
path("", include(puzzles_urls, namespace="puzzles")),
|
path("", include(puzzles_urls, namespace="puzzles")),
|
||||||
path("", include(tasks_urls, namespace="tasks")),
|
path("", include(tasks_urls, namespace="tasks")),
|
||||||
|
|||||||
Reference in New Issue
Block a user