Compare commits
259 Commits
a36abacfbd
...
44.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a504e45de | |||
| 7618d0ba30 | |||
| ce4dc40033 | |||
| b0b22b79dc | |||
| 6471413681 | |||
| 50b10689fc | |||
| 85bddb6cba | |||
| c285b0d3b3 | |||
| 671fe8d86f | |||
| 89817110de | |||
| ee01e3d8df | |||
| a70343d6f3 | |||
| 3e72042c24 | |||
| 087c7775ae | |||
| 3f71065ad6 | |||
| 801672124f | |||
| 811e9c1ce9 | |||
| 415b32bdc7 | |||
| 22319c807a | |||
| f9ba6fec14 | |||
| 5f55163147 | |||
| a6ef34623e | |||
| 7cb48d20f6 | |||
| 445103a878 | |||
| 579da8c44e | |||
| daabd2f37f | |||
| 039c58cf89 | |||
| 410c033f12 | |||
| ce302e4d45 | |||
| 19589c9463 | |||
| 3d9506b14e | |||
| 23b87278b2 | |||
| 0b8e027c30 | |||
| 1bd9f0d942 | |||
| fa7890cb21 | |||
| 957c32e3a7 | |||
| 8d069df9d1 | |||
| 96d1d7ac6b | |||
| 009b2ba243 | |||
| 4f051ae250 | |||
| 7e9fbb1bf6 | |||
| ec1a54f623 | |||
| b502667ca6 | |||
| 263874288a | |||
| bca90c97ae | |||
| 12f49a6cee | |||
| 034c7cb413 | |||
| e737870733 | |||
| 95757650f6 | |||
| 1928acf8a6 | |||
| 22956c7c7f | |||
| 17aed1191d | |||
| 0b1fb8667c | |||
| 13dd5b67d0 | |||
| 20c7874466 | |||
| 62d8ffd794 | |||
| bea2b2d187 | |||
| 034cb99c77 | |||
| 37187f33dd | |||
| d7a23c3832 | |||
| 6d45571e75 | |||
| 88fd0ed7f8 | |||
| 2100cedc1a | |||
| 2b17a92c6c | |||
| 72fd1ab90e | |||
| 301440909b | |||
| 389641002d | |||
| 43d514cf5b | |||
| 25baeca2b0 | |||
| b4e15c73c1 | |||
| 90f3d38687 | |||
| 8afb227267 | |||
| 425abebc9a | |||
| afb61f6622 | |||
| 65815fc198 | |||
| 75479d91a3 | |||
| 99789e5477 | |||
| fd95f1e686 | |||
| e133c4149b | |||
| 7e75828012 | |||
| 664148e702 | |||
| 768819b664 | |||
| 760d453165 | |||
| 2fac5815b1 | |||
| f4b30ade70 | |||
| 1cbb84c29f | |||
| b622b151d4 | |||
| 4e1c3ffbf0 | |||
| 8a419c7bbc | |||
| 0639033aa9 | |||
| 6927729284 | |||
| 766f9db17c | |||
| 25cbd88071 | |||
| 972568bebc | |||
| 939c89d368 | |||
| 83e7061b92 | |||
| a171517f92 | |||
| a4030e89ec | |||
| dce31ed840 | |||
| 645e81299b | |||
| 72fa41977e | |||
| 56745b33f4 | |||
| dd2f44e72f | |||
| 41890d14d9 | |||
| c5f1ee2d64 | |||
| 26176ccd73 | |||
| 9b7fa0d4f8 | |||
| aeb460d677 | |||
| 4e059683b0 | |||
| d3146433f2 | |||
| 7b487f8494 | |||
| f7675b8a02 | |||
| d036dbe4fd | |||
| 86c495f22f | |||
| b9324d6443 | |||
| c11858810c | |||
| 9bafe45951 | |||
| 4d8e925f8c | |||
| e7fff25543 | |||
| 62c200ab05 | |||
| 7c33095d87 | |||
| cb50de13c0 | |||
| 08152de086 | |||
| 0d6b2c4afc | |||
| 9d3f7f434f | |||
| 2b88f89794 | |||
| 3014a30616 | |||
| c7850878fe | |||
| db5b673cd5 | |||
| 217e2443e2 | |||
| af8b1d4f8a | |||
| d69bc6c235 | |||
| cc0f7db453 | |||
| 11bde2a306 | |||
| cd4f6ff71d | |||
| bf7e5677e6 | |||
| 2b04a17d77 | |||
| 97f35f62b8 | |||
| dfd51c1343 | |||
| e47328e572 | |||
| e85d6ec779 | |||
| f160f5a7b8 | |||
| b967c526f1 | |||
| 77f143299d | |||
| 7b7c66de8f | |||
| 3b8d7421b1 | |||
| e707c94b70 | |||
| a5510d7294 | |||
| 666224875b | |||
| 1866b43cbe | |||
| df6a16f1e7 | |||
| 752b4afaa9 | |||
| 5175a9a39a | |||
| 9d519138aa | |||
| cc31c7e22e | |||
| eb604f5eb2 | |||
| 2b2b20d1b7 | |||
| 41a7255ed8 | |||
| fc4db68725 | |||
| b1d6f4726b | |||
| cbdb5c49d0 | |||
| df2108807d | |||
| de733d5893 | |||
| acf0c342bf | |||
| f486b1614b | |||
| 9642aebfc0 | |||
| 9780e39825 | |||
| 5739b23f99 | |||
| 181700b05e | |||
| 05cbcc1967 | |||
| ef4e510814 | |||
| e912eda6e4 | |||
| caf56289b4 | |||
| 49a2429a4c | |||
| 88178e5ad2 | |||
| 69dd47eac7 | |||
| b11bb782e3 | |||
| 523ed3a499 | |||
| decaba82f2 | |||
| 4931e2d87b | |||
| 25a60f4d60 | |||
| 6aef34e43f | |||
| 710aff5de4 | |||
| df673eaccc | |||
| 3b266083b0 | |||
| 29e179adad | |||
| b6af201ba3 | |||
| 1bf558938d | |||
| ce7128c7ac | |||
| 9ce6dd8876 | |||
| 78651af802 | |||
| 0896517345 | |||
| 0d6fb5928b | |||
| 2dbd752609 | |||
| 01aa0cba76 | |||
| c5b7e57005 | |||
| 0a74c692d2 | |||
| 192d0c489b | |||
| 9a8d5a7608 | |||
| 6f6063e204 | |||
| 59998ac849 | |||
| 3058bfdd22 | |||
| 173bbfa91f | |||
| 6b278ad99f | |||
| 2ca3dd1ed9 | |||
| 1e21bd9481 | |||
| ea66ee376d | |||
| 64854f78a6 | |||
| 3d2f3cbe71 | |||
| 931246e043 | |||
| 77fd289c38 | |||
| a5e8c97063 | |||
| bf01f45eca | |||
| 3beb0bc879 | |||
| 70bf06df38 | |||
| 062386a5b6 | |||
| 49f1410814 | |||
| 2ab6fc6cba | |||
| 3db0330cfe | |||
| b949a84763 | |||
| 45d524ca61 | |||
| 5b3e91fdc1 | |||
| a79cc4c217 | |||
| 06acc9ec2b | |||
| 4121915aa3 | |||
| cce2db0ea1 | |||
| 6766ea7dbb | |||
| 125222845b | |||
| 41bb52c551 | |||
| 24c7c33ac2 | |||
| 1ea29fee1b | |||
| 21355bae41 | |||
| 7bb53809ad | |||
| d576467db8 | |||
| ab4b5470b7 | |||
| 18e7af5052 | |||
| d4aefa6678 | |||
| 0917025cac | |||
| b61339b25c | |||
| e36a0726c8 | |||
| 44c6e014f5 | |||
| 881450eb8d | |||
| d9a7a1cfd9 | |||
| 573321420d | |||
| a874e9c712 | |||
| 8ca73693d1 | |||
| 9f3b7b0361 | |||
| 1a2c39b4d3 | |||
| 44eb193b33 | |||
| 466d8d26c6 | |||
| 61c583e497 | |||
| 83d89001a7 | |||
| 02d13b5a99 | |||
| bf17a0eeab | |||
| d29708bd59 | |||
| d5d27256f8 | |||
| 565adfe58e | |||
| 16cd990c34 | |||
| 190f486c49 |
68
.gitea/workflows/build.yml
Normal file
68
.gitea/workflows/build.yml
Normal file
@ -0,0 +1,68 @@
|
||||
name: build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
VROBBLER_DATABASE_URL: sqlite:///test.db
|
||||
VROBBLER_USDA_API_KEY: ${{ vars.VROBBLER_USDA_API_KEY }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Cache pip/poetry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/pip
|
||||
~/.cache/pypoetry
|
||||
key: ${{ runner.os }}-py311-${{ hashFiles('**/poetry.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-py311-
|
||||
|
||||
- name: Install Poetry
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install poetry
|
||||
|
||||
- name: Install deps
|
||||
run: |
|
||||
cp vrobbler.conf.test vrobbler.conf
|
||||
poetry install --with test
|
||||
|
||||
- name: Pytest with coverage
|
||||
run: |
|
||||
poetry run pytest -n 5 --cov-report term:skip-covered --cov=vrobbler tests
|
||||
|
||||
- name: Notify success (ntfy)
|
||||
if: success()
|
||||
run: |
|
||||
curl -fsS \
|
||||
-H "Title: vrobbler CI success" \
|
||||
-H "Priority: low" \
|
||||
-H "Tags: success,vrobbler" \
|
||||
-H "Actions: view, Changes, ${{ gitea.server_url }}/${{ gitea.repository }}/commit/${{ gitea.sha }}; view, Build, ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}" \
|
||||
-d "✅ Build succeeded: ${{ gitea.repository }} @ ${{ gitea.sha }}" \
|
||||
https://ntfy.unbl.ink/drone
|
||||
|
||||
- name: Notify failure (ntfy)
|
||||
if: failure()
|
||||
run: |
|
||||
curl -fsS \
|
||||
-H "Title: vrobbler CI failure" \
|
||||
-H "Priority: high" \
|
||||
-H "Tags: failure,vrobbler" \
|
||||
-H "Actions: view, Changes, ${{ gitea.server_url }}/${{ gitea.repository }}/commit/${{ gitea.sha }}; view, Build, ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}" \
|
||||
-d "❌ Build failed: ${{ gitea.repository }} @ ${{ gitea.sha }}" \
|
||||
https://ntfy.unbl.ink/drone
|
||||
@ -1,10 +1,8 @@
|
||||
name: build & deploy
|
||||
name: deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
tags: ["*"]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
@ -22,7 +20,6 @@ jobs:
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
# Cache pip + Poetry caches (rough equivalent to your mounted pip_cache)
|
||||
- name: Cache pip/poetry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@ -47,7 +44,6 @@ jobs:
|
||||
run: |
|
||||
poetry run pytest -n 5 --cov-report term:skip-covered --cov=vrobbler tests
|
||||
|
||||
# Notifications (success/failure) for the test job
|
||||
- name: Notify success (ntfy)
|
||||
if: success()
|
||||
run: |
|
||||
@ -70,14 +66,54 @@ jobs:
|
||||
-d "❌ Build failed: ${{ gitea.repository }} @ ${{ gitea.sha }}" \
|
||||
https://ntfy.unbl.ink/drone
|
||||
|
||||
deploy:
|
||||
# Only deploy on tags (equivalent to Drone when: ref: refs/tags/*)
|
||||
# if: startsWith(gitea.ref, 'refs/tags/')
|
||||
build-and-deploy:
|
||||
needs: [test]
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Deploy via SSH
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install Poetry
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install poetry
|
||||
|
||||
- name: Write commit hash to build file
|
||||
run: |
|
||||
mkdir -p build_meta
|
||||
echo "${{ gitea.sha }}" | cut -c1-8 > build_meta/commit.txt
|
||||
|
||||
- name: Build package with commit info
|
||||
run: |
|
||||
echo "commit = '$(echo ${{ gitea.sha }} | cut -c1-8)'" > vrobbler/_commit.py
|
||||
poetry build
|
||||
git checkout vrobbler/_commit.py
|
||||
|
||||
- name: Clean old wheels from server
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: vrobbler.service
|
||||
username: root
|
||||
key: ${{ secrets.JAIL_KEY }}
|
||||
script: |
|
||||
rm -f /var/lib/vrobbler/dist/*.whl
|
||||
|
||||
- name: Copy wheel to server and deploy
|
||||
uses: appleboy/scp-action@v1.0.0
|
||||
with:
|
||||
host: vrobbler.service
|
||||
username: root
|
||||
key: ${{ secrets.JAIL_KEY }}
|
||||
source: "dist/*.whl"
|
||||
target: "/var/lib/vrobbler"
|
||||
|
||||
- name: Install wheel and restart services
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: vrobbler.service
|
||||
@ -85,11 +121,16 @@ jobs:
|
||||
key: ${{ secrets.JAIL_KEY }}
|
||||
command_timeout: 2m
|
||||
script: |
|
||||
set -e
|
||||
mkdir -p /var/lib/vrobbler
|
||||
echo "${{ gitea.sha }}" | cut -c1-8 > /var/lib/vrobbler/commit.txt
|
||||
pip uninstall -y vrobbler
|
||||
pip install git+https://code.lab.unbl.ink/secstate/vrobbler.git@main
|
||||
pip install /var/lib/vrobbler/dist/*.whl
|
||||
rm -f /var/lib/vrobbler/dist/*.whl
|
||||
python3 -c "import vrobbler; print(f'vrobbler {vrobbler.__version__} installed OK')"
|
||||
vrobbler migrate
|
||||
vrobbler collectstatic --noinput
|
||||
immortalctl restart celery && immortalctl restart vrobbler
|
||||
immortalctl restart vrobbler-celery && immortalctl restart vrobbler-celerybeat && immortalctl restart vrobbler
|
||||
|
||||
- name: Notify deploy success (ntfy)
|
||||
if: success()
|
||||
16
AGENTS.md
Normal file
16
AGENTS.md
Normal file
@ -0,0 +1,16 @@
|
||||
This is a Django-based web application that has an API, but primarily functions
|
||||
with traditional Django views with HTML templates to display data that mostly
|
||||
constitutes "scrobbled" items. The app started as a way to track a user's
|
||||
watched videos via a Jellyfin server, but has since grown to keep track of a
|
||||
number of media types: music tracks, tasks, videos, web pages, food, life
|
||||
events, sports events, podcasts, video games, board games, beers, brick (lego)
|
||||
sets, puzzles, books and geolocations.
|
||||
|
||||
The project is written in Python and prefers to use "fat" models where logical
|
||||
methods are contained in either instance methods on instatiated data models, or
|
||||
classmethods on the Django model class itself. When logic grows too complex,
|
||||
helper functions should be pulled out into utils.py files and the model instance
|
||||
ro class method should call the utility function.
|
||||
|
||||
Be sure to check pyproject.toml for project defaults. Specifically for black and
|
||||
isort expectations.
|
||||
685
PROJECT.org
685
PROJECT.org
@ -1,5 +1,6 @@
|
||||
#+title: Vrobbler Project
|
||||
|
||||
We should convert this PROJECT file to put tickets in a subdirectory, tickets, with each ticket having it's own shortid_title.org
|
||||
* Overview
|
||||
Vrobbler began humbly enough as a way to use Jellyfin's webhook to keep track of
|
||||
the shows and movies I was watching. More specifically, I broke my ankle a few
|
||||
@ -92,8 +93,15 @@ fetching and simple saving.
|
||||
:LOGBOOK:
|
||||
CLOCK: [2025-07-09 Wed 09:55]--[2025-07-09 Wed 10:15] => 0:20
|
||||
:END:
|
||||
* Backlog [14/33]
|
||||
* Backlog [0/13] :vrobbler:project:personal:
|
||||
** TODO [#C] Add sentiment parsing for Scrobbles with notes :vrobbler:project:scrobbles:sentiment:
|
||||
:PROPERTIES:
|
||||
:ID: 37781d6a-f3b0-48b2-bf98-33c2c791cf85
|
||||
:END:
|
||||
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :vrobbler:personal:bug:music:scrobbles:
|
||||
:PROPERTIES:
|
||||
:ID: 702462cf-d54b-48c6-8a7c-78b8de751deb
|
||||
:END:
|
||||
** TODO [#C] Move to using more robust mopidy-webhooks pacakge form pypi :utility:improvement:
|
||||
:PROPERTIES:
|
||||
:ID: ab31fdc3-359c-1b1d-6b9d-546b476021ba
|
||||
@ -389,33 +397,76 @@ CLOCK: [2025-07-09 Wed 09:55]--[2025-07-09 Wed 10:15] => 0:20
|
||||
}
|
||||
#+end_src
|
||||
** TODO [#C] User should be able to enable auto trail tracking via amail reader with Garmin LiveTrack URLs :vrobbler:trails:project:feature:personal:
|
||||
** TODO [#C] Allow users to see tasks on calendar view :vrobbler:personal:project:templates:feature:
|
||||
https://codepen.io/oliviale/pen/QYqybo
|
||||
** TODO [#C] Come up with a possible flow using WebDAV and super-productivity for tasks :personal:feature:project:vrobbler:tasks:
|
||||
:PROPERTIES:
|
||||
:ID: 133bcf71-078f-4efa-a029-1eae4b4d146d
|
||||
:END:
|
||||
** TODO [#C] Fix exporting so it works reliably :exporting:project:feature:
|
||||
|
||||
*** Description
|
||||
|
||||
The existing export function is very naieve. It runs in the web process, takes
|
||||
too long and just dumps tracks. We should make it more robust by creating one
|
||||
CSV file per scrobble media type and writing them into a zip file that gets
|
||||
placed in the media directory:
|
||||
|
||||
`/media/exports/user_<user_id>/<timestamp>-export.zip`
|
||||
|
||||
And this should all be done in a celery task that is just kicked off by the
|
||||
"Export" button on the frontend
|
||||
|
||||
** TODO [#B] Add importer class for IMAP imports :vrobbler:feature:imap:importers:project:personal:
|
||||
:PROPERTIES:
|
||||
:ID: b1426d92-2feb-4d15-9738-d5b7b0594f96
|
||||
:END:
|
||||
** TODO [#B] Add AllTrails as a source for Trail data :vrobbler:trails:feature:personal:project:
|
||||
:PROPERTIES:
|
||||
:ID: 39313362-cdfe-46e7-bbd4-9139a65c0b3c
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
Pretty clear, I would love to make trails more useful. Historically I wasn't
|
||||
hiking a lot, which made the source for this a bit silly. But it's clear that
|
||||
AllTrails is the best source, though having TrailForks is nice to.
|
||||
** TODO [#B] Add `garmin_activity_id` to the TrailMetadataLog class :vrobbler:trails:feature:personal:project:
|
||||
Would be nice to have some loose connection to the actual event in my Garmin profile.
|
||||
** TODO [#B] Explore a way to add metadata editing to scrobbles after saving :vrobbler:spike:scrobbling:personal:project:
|
||||
|
||||
Could be as simple as a JSON form on the scrobble detail page (do I have have
|
||||
one of those yet?).
|
||||
** TODO [#B] Explore a good way to show notes and descriptions from scrobbles to users :personal:project:scrobbling:vrobbler:spike:
|
||||
** TODO [#B] Add webdav syncing to retroarch imports :vrobbler:videogames:webdav:feature:project:personal:
|
||||
** TODO [#B] Add `garmin_activity_id` to the TrailLogData class :trails:feature:personal:project:
|
||||
:PROPERTIES:
|
||||
:ID: 5a4fb0f8-0555-40ec-b06f-93c26bd686f4
|
||||
:END:
|
||||
*** Description
|
||||
|
||||
Would be nice to have some loose connection to the actual event in my Garmin
|
||||
profile.
|
||||
|
||||
** TODO [#B] Fix how we show notes and descriptions from scrobbles to users :metadata:notes:tasks:
|
||||
:PROPERTIES:
|
||||
:ID: adf4c513-a417-4ec9-8831-f01ffcf63276
|
||||
:END:
|
||||
*** Description
|
||||
|
||||
Currently the display of notes leaves something to be desired. The biggest issue
|
||||
is that they don't look good on mobile and are probably trying to be too cute.
|
||||
Rather than post-it note style, we should just put notes in a list under the
|
||||
description, above the Edit Log toggle, with timestamps for when they were
|
||||
added.
|
||||
|
||||
They should also probably support markdown formatting and that should be
|
||||
displayed in the template.
|
||||
|
||||
** TODO [#B] Add CSV endpoint for book scrobbles that LibraryThing can ingest :personal:project:books:feature:export:
|
||||
https://app.todoist.com/app/task/add-a-csv-endpoint-for-users-book-reads-that-library-thing-can-ingest-6X7QPMRp265xMXqg#comment-6X7QrXq6gJjMP4hg
|
||||
** TODO [#B] Fix PuzzleLogData has no attribute form :vrobbler:puzzles:personal:project:logdata:
|
||||
** TODO [#B] Scrape ComicBookRoundUp ratings for comic book metadata :vrobbler:books:feature:comicbook:personal:project:
|
||||
|
||||
- Note taken on [2025-09-25 Thu 10:51]
|
||||
|
||||
As an example https://comicbookroundup.com/comic-books/reviews/humanoids-publishing/the-history-of-science-fiction
|
||||
** TODO [#B] Add PuzzleLogData class with with_people and completed :vrobbler:feature:puzzles:logdata:personal:project:
|
||||
** TODO [#A] Find page numbers for comic books from ComicVine :vrobbler:feature:books:personal:project:
|
||||
** TODO [#A] Fix koreader scrobble imports to use DST properly :vrobbler:personal:bug:books:imports:
|
||||
** TODO [#B] Find page numbers for comic books from ComicVine :feature:books:
|
||||
:PROPERTIES:
|
||||
:ID: 79f867c3-1288-4143-b6bf-2a452983ee9f
|
||||
:END:
|
||||
** TODO [#B] Fix koreader scrobble imports to use DST properly :bug:books:imports:
|
||||
:PROPERTIES:
|
||||
:ID: 79758cba-a440-48b6-a637-efb88827acf2
|
||||
:END:
|
||||
|
||||
- Note taken on [2025-09-25 Thu 10:37] \\
|
||||
|
||||
@ -429,8 +480,607 @@ https://app.todoist.com/app/task/add-a-csv-endpoint-for-users-book-reads-that-li
|
||||
an hour for part of the year. Also, we'd need to adjust any old scrobbles that
|
||||
took place with DST off to roll them back by an hour.
|
||||
|
||||
** TODO [#A] When creating org-mode tasks, don't copy comments :vrobbler:bug:scrobbles:tasks:
|
||||
** TODO Add sentiment parsing for Scrobbles with notes :vrobbler:project:scrobbles:sentiment:
|
||||
|
||||
*** Description
|
||||
|
||||
This is a long-standing problem when daylight saving time takes effect. Time is
|
||||
manually set on a KoReader device (or at least, always saved in local time). So
|
||||
whatever time KoReader reports, we need to know, given the date and the user
|
||||
profile's historic timezone, how many hours to adjust the KoReader time to get
|
||||
to GMT to save it in the database.
|
||||
|
||||
** TODO [#B] Add ability to add mopidy tracks to Monthly playlists :feature:favorites:tracks:
|
||||
:PROPERTIES:
|
||||
:ID: c872ff0a-e71f-415f-b5a6-e62ea9634d14
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Now that we can favorite a mopidy track and have it added to a Favorites playlist, it would
|
||||
be great if we could also populate a monthly_mopidy_playlist_pattern in a user profile and,
|
||||
if configured, you could press "Add to monthly playlist" button on a given track that has
|
||||
a mopidy_uri in it's log, and it would be added to the playlist.
|
||||
|
||||
The patterns would be based on traditional Django date formatting patterns: https://gregbrown.co/code/date-format
|
||||
|
||||
So "Y m F" would yield "2026 05 May" if the link is clicked in May of 2026.
|
||||
|
||||
* Version 44.0 [1/1]
|
||||
** DONE [#B] Add favorite feature for scrobbles :feature:favorites:scrobbles:
|
||||
:PROPERTIES:
|
||||
:ID: 2780ae5f-fe23-49a5-8b33-d19e7f3e8ec6
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Would be great to have a FavoriteMedia data model that would accept any media_type ID and a user_id
|
||||
marking that media as a favorite for that user.
|
||||
|
||||
Additionally, for tracks, we should add the ability to set a "favorites_mopidy_playlist" in a user profile
|
||||
and if populated, and a track media type is favorited, and the track has a mopidy_uri value in a scrobble log,
|
||||
send a POST to the mopidy server RPC endpoint for the favorite playlist and add the track.
|
||||
|
||||
|
||||
|
||||
* Version 43.0 [5/5]
|
||||
** DONE [#B] Can we show a graph of all past Weigh-in tasks :scale:tasks:graphs:javascript:
|
||||
:PROPERTIES:
|
||||
:ID: ae499d87-03bf-4e48-9b2c-1a421a46af11
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
I wonder if, as a special type of task, Weigh-in's could show a graph of the
|
||||
metrics that are stored against all the past weigh-ins?
|
||||
|
||||
The graph would contain all Weigh-in scrobbles for that user, no matter which
|
||||
date is being viewed, and the highlighted value on the graph would be the date
|
||||
being viewed.
|
||||
|
||||
Probably could use something like chart.js although maybe that's too heavy?
|
||||
|
||||
And can we have each metric overlayed on the same graph?
|
||||
|
||||
** DONE [#B] When viewing scrobbles by tag, sum the total time :scrobbles:tags:
|
||||
:PROPERTIES:
|
||||
:ID: d51f23df-c2c5-4e1a-b000-67c89032af02
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
On scrobbles filtered by tags, we should see a sum of the time spent doing those tasks, in a human
|
||||
readable format like "X days, X hours, X minutes and X seconds"
|
||||
|
||||
** DONE [#A] Orgmode tasks are not updated if in progress :tasks:orgmode:bug:
|
||||
:PROPERTIES:
|
||||
:ID: 7dcebb2c-7c4c-4ac5-bee6-c2e36c3811f9
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Currently if you POST to the orgmode webhook with a task that's already in progress, the request just stops there.
|
||||
|
||||
We should add logic where if the task is in-progress, instead of doing nothing,
|
||||
it checks the webhook payload against the in-progress tasks and updates the
|
||||
description of the scrobble.log with the incoming task description if it's
|
||||
different. And the same for comments. If a comment (by timestamp key) is
|
||||
different in the webhook than what's in the scrobble.log, update the comment in
|
||||
the scrobble.log
|
||||
|
||||
** DONE [#A] Ignore tag 'inprogress' for Tasks :bug:tasks:tags:
|
||||
:PROPERTIES:
|
||||
:ID: cd37c1ec-e2fc-b93c-daf8-6b329712c3f1
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
When scrobbling tasks from Todoist, the tag `inprogress` is always in the
|
||||
payload, because that's how we parse tasks starting from the Todoist webhooks.
|
||||
|
||||
But we don't really need anything tagged as `inprogress` Can we ignore this tag
|
||||
when applying tags to Task scrobbles coming from Todoist?`
|
||||
|
||||
|
||||
|
||||
** DONE [#A] Deploys are now throwing an unknown version error :bug:tooling:releases:
|
||||
:PROPERTIES:
|
||||
:ID: 3870f9d3-b5ed-4b87-9e8c-9bf905bfb766
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Almost everything is working, but for some reason `__version__` does not seem to
|
||||
exist.
|
||||
|
||||
#+begin_src sh
|
||||
out: Installing collected packages: vrobbler
|
||||
out: Successfully installed vrobbler-42.0
|
||||
err: WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv
|
||||
err: Traceback (most recent call last):
|
||||
err: File "<string>", line 1, in <module>
|
||||
err: AttributeError: module 'vrobbler' has no attribute '__version__'
|
||||
2026/06/04 17:18:15 Process exited with status 1
|
||||
failed to remove container: Error response from daemon: removal of container c8ac64bee9b6bf5978d2c16f299e5ac271d8bbf7192b7a4023c3712bc2444f8b is already in progress
|
||||
❌ Failure - Main Install wheel and restart services
|
||||
exit with `FAILURE`: 1
|
||||
#+end_src
|
||||
|
||||
|
||||
* Version 42.0 [1/1]
|
||||
** DONE [#B] Add ability to add track to current Mopidy queue :feature:mopidy:tracks:
|
||||
:PROPERTIES:
|
||||
:ID: 79d5b580-4ea6-461b-4c6c-2c950d8b3e4c
|
||||
:END:
|
||||
|
||||
|
||||
* Version 41.0 [5/5]
|
||||
** DONE [#B] For any scrobble detail page with notes display them better :templates:notes:scrobbles:
|
||||
:PROPERTIES:
|
||||
:ID: c0dcf9da-227f-4a22-bcd9-9d46053607d9
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Currently notes are displayed as little post-it notes. This is cute, but not terribly useful.
|
||||
|
||||
We should update note rendering to be a simple newest to oldest display in a
|
||||
single column with the timestamp has a small header, and the content rendered as
|
||||
markdown with a small bar or horizontal divider marking them from the next note.
|
||||
|
||||
** DONE [#A] Imports should send notifications :feature:notifications:imports:
|
||||
:PROPERTIES:
|
||||
:ID: 6f78f8d5-ecaa-4d8a-a666-ae4e27653191
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Currently importing board games sends out a ntfy message when a scrobble is
|
||||
created.
|
||||
|
||||
We should do the same thing for other import types; namely: gpx, ebird, and scale.
|
||||
|
||||
** DONE [#A] Board game imports send duplicate ntfy message :bug:notifications:boardgames:
|
||||
:PROPERTIES:
|
||||
:ID: 8f067432-0399-4b79-9e93-727edcccedbd
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
When a board game scrobble is created via a bgstats import, ntfy messages are
|
||||
sent.
|
||||
|
||||
But right now they are duplicated (two are sent at the same time). Can we review
|
||||
the code to see why this is happening and fix it?
|
||||
|
||||
** DONE [#A] Too many geolocation notifications go out :bug:notifications:geolocations:
|
||||
:PROPERTIES:
|
||||
:ID: 6357ad7a-fe4e-49dd-a063-55d87e459c17
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Currently ntfy gets overwhelemed when there's more than a hundred or so messages left in a queue on a client.
|
||||
|
||||
It would be nice if we could not spam ntfy, and this is especially true with Geolocations, where we really
|
||||
don't need to alert folks unless they have a named Geolocation (has a title). Can we adjust the ntfy
|
||||
sending for Geolocations to only send if the scrobbled location has a title?
|
||||
|
||||
|
||||
** DONE [#C] Fix bug where Weigh-in imports do not set title :bug:tasks:scale:
|
||||
:PROPERTIES:
|
||||
:ID: 622e354a-8e66-4ecd-9e1c-a53f0a2ec362
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Currently when we import a scale CSV row and create data, the title is left
|
||||
blank which makes it look funny in a list view. Let's save the weight as the
|
||||
title of the Weigh-in task.
|
||||
|
||||
|
||||
* Version 40.2 [1/1]
|
||||
** DONE [#A] Try fixing deploy bugs again :tooling:releases:bug:
|
||||
:PROPERTIES:
|
||||
:ID: 15894943-be1d-200f-8400-a136770ad9d2
|
||||
:END:
|
||||
|
||||
|
||||
* Version 40.1 [2/2]
|
||||
** DONE [#A] Releases are still broken :bug:releases:tooling:
|
||||
:PROPERTIES:
|
||||
:ID: bca37a18-afa2-4ddd-a11b-ef0555f38bc9
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Deploys are still broken, even with them being pulled apart and run separately.
|
||||
|
||||
We need to address the way the commit ends up stashed in the codebase.
|
||||
|
||||
** DONE [#C] Fix bug on chart pages where trail titles missing :bug:trails:charts:
|
||||
:PROPERTIES:
|
||||
:ID: 21075430-8a93-4e59-9a02-479315960ae6
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
When trails are rendered on the chart views, there are no titles, which means we don't see
|
||||
anything except the ranking number.
|
||||
|
||||
|
||||
* Version 40.0 [2/2]
|
||||
** DONE [#A] Fix error in org-mode task sync :emacs:orgmode:tasks:bug:
|
||||
:PROPERTIES:
|
||||
:ID: 03256d2a-48aa-4be7-aeb3-fa1cfddc86bf
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
#+begin_src python
|
||||
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/tasks/webhooks.py", line 236, in post
|
||||
emacs_scrobble_update_task(
|
||||
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/scrobbles/scrobblers.py", line 844, in emacs_scrobble_update_task
|
||||
for note in emacs_notes:
|
||||
TypeError: 'NoneType' object is not iterable
|
||||
#+end_src
|
||||
** DONE [#B] Adjust how similar artists are shown :feature:templates:artists:music:
|
||||
:PROPERTIES:
|
||||
:ID: 2a081620-a0a2-4851-a7cf-4043f9c2ee31
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Currently we show the top 10 similar artists on the Artist detail page linked to
|
||||
the artist detail page on Vrobbler.
|
||||
|
||||
First off, this is very slow. We should look into speeding up the rendering of
|
||||
the similar artists widget.
|
||||
|
||||
Second, the artist name in the similar artist list should be a link to the
|
||||
Vrobbler artist detail page, but there should also be a [musicbrainz] link next
|
||||
to it, that links out to the musicbrainz page whether we have the artist in the
|
||||
Vrobbler database or not.
|
||||
|
||||
|
||||
|
||||
* Version 39.3 [2/2]
|
||||
** DONE [#A] Issue found when doing a release :bug:tooling:release:cicd:
|
||||
:PROPERTIES:
|
||||
:ID: a8fc3ec9-74ec-4190-8ac2-62cd8a33e828
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
#+begin_src sh
|
||||
err: ERROR: Cannot install vrobbler 0.16.1 (from /var/lib/vrobbler/dist/vrobbler-0.16.1-py3-none-any.whl) and vrobbler 38.0 (from /var/lib/vrobbler/dist/vrobbler-38.0-py3-none-any.whl) because these package versions have conflicting dependencies.
|
||||
out: The conflict is caused by:
|
||||
out: The user requested vrobbler 0.16.1 (from /var/lib/vrobbler/dist/vrobbler-0.16.1-py3-none-any.whl)
|
||||
out: The user requested vrobbler 38.0 (from /var/lib/vrobbler/dist/vrobbler-38.0-py3-none-any.whl)
|
||||
out: To fix this you could try to:
|
||||
out: 1. loosen the range of package versions you've specified
|
||||
out: 2. remove package versions to allow pip attempt to solve the dependency conflict
|
||||
err: ERROR: ResolutionImpossible: for help visit https://pip.pypa.io/en/latest/topics/dependency-resolution/#dealing-with-dependency-conflicts
|
||||
2026/06/01 14:15:00 Process exited with status 1
|
||||
failed to remove container: Error response from daemon: removal of container 3fe0eaf032c5518aca4ab71734b52bda7c54ed406136b82136ab7155bf5ff3c1 is already in progress
|
||||
#+end_src
|
||||
|
||||
** DONE [#A] Fix deploy actions running twice :bug:tooling:cicd:
|
||||
:PROPERTIES:
|
||||
:ID: aa56f2a6-2b61-4ddf-9e27-9eadcddf8412
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Turns out we're now running the build-deploy action twice, once on branch push
|
||||
and once on tag push.
|
||||
|
||||
We should do builds on each push and build and deploys only when a new tag is
|
||||
detected.
|
||||
|
||||
|
||||
* Version 39.2 [2/2]
|
||||
** DONE [#B] Releases do not pin commit to the repo for display :bug:tooling:releases:
|
||||
:PROPERTIES:
|
||||
:ID: 2a9f2ff5-2642-47ab-ba1d-e41825411713
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Somewhere in implementing the justfile release flow, we lost the capture of the
|
||||
latest commit in the relesae flow so the footer now always says: vXX.x (unknown)
|
||||
|
||||
It should have the first bit of the commit in the parens at the end.
|
||||
|
||||
** DONE [#B] Fix the way timestamps are stored for notes on tasks :bug:scrobbles:tasks:
|
||||
:PROPERTIES:
|
||||
:ID: 32973bb3-079b-8cdf-6495-82f8ae907299
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
:PROPERTIES:
|
||||
:ID: d833a3df-6eb2-36bb-1863-e438e5d36151
|
||||
:END:
|
||||
|
||||
Turns out Todoist uses a human-readable timestamp format for comments. We should
|
||||
adapt that for use from org-mode as well.
|
||||
|
||||
Format should be: "%Y-%m-%dT%H:%M:%S.%fZ"
|
||||
|
||||
|
||||
* Version 39.1 [1/1]
|
||||
** DONE [#A] Fix bug in tests for notes saving :bug:scrobbles:forms:
|
||||
:PROPERTIES:
|
||||
:ID: 68a011b2-bb6f-3ba8-2312-5947c41db9ac
|
||||
:END:
|
||||
|
||||
* Version 39.0 [3/3]
|
||||
** DONE [#B] Clean up org-mode tasks metadata :bug:tasks:metadata:
|
||||
:PROPERTIES:
|
||||
:ID: 0c762d09-fc69-4e75-be40-7eaaf04f178e
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Org-mode tasks have a `Description` subheader, which should populate the
|
||||
"Description" of a task.
|
||||
|
||||
The title should come from the actual TODO content, with tags coming from the
|
||||
tags in colons after the task. This behaviour should already work.
|
||||
|
||||
Next, any comments should go under a `Comments` subheader. Under these should be a list tag like:
|
||||
|
||||
"Note taken on [2026-05-31 Sun 20:03]"
|
||||
|
||||
Which declares it's a note with a timestamp like "YYYY-MM-DD d HH:MM".
|
||||
|
||||
When scrobbling a task from org-mode, the description should always be copied to
|
||||
the scrobble's log["description"].
|
||||
|
||||
Any comments that exist on the task when the scrobble is first created, should
|
||||
be ignored.
|
||||
|
||||
Any comments on a task that is updated (a re-POST'd while the task is in
|
||||
progress, should add a comment in the log["notes"], a dictionary, keyed off the
|
||||
timestamp the note string. If a note for that datetime already exists, the
|
||||
content should be replaced with the new comment.
|
||||
|
||||
Additionally, when a task is re-POST'd while the task is in progress, the
|
||||
description should also be compared, and if different, the newest description
|
||||
should be used.
|
||||
|
||||
Note, the biggest change for this flow is making sure comments that already
|
||||
exist are not added to any new tasks and that both comments and descriptions are
|
||||
added or updated if the new values are different than what is already in the
|
||||
currently in-progress task.
|
||||
|
||||
If the task is completed, don't touch it.
|
||||
|
||||
*** Comments
|
||||
- Note taken on [2026-05-31 Sun 20:03]
|
||||
|
||||
** DONE [#A] Actually push branches up and add a just command to do it :release:justfile:tooling:
|
||||
:PROPERTIES:
|
||||
:ID: 50aa5daa-a802-6aa9-38a3-218b7a9d4b34
|
||||
:END:
|
||||
** DONE [#A] Try to fix deploy failing with bad release plan :release:tooling:pyproject:
|
||||
:PROPERTIES:
|
||||
:ID: 63dc633c-4382-e6a5-e663-b01871ce86ce
|
||||
:END:
|
||||
|
||||
* Version 38.0 [38/38]
|
||||
** DONE [#A] Fix release flow to be easier to trigger :pyproject:release:tooling:
|
||||
:PROPERTIES:
|
||||
:ID: e321f8a3-26ea-b316-8493-294b50c0b516
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
We should have a command like `just relesae <major|minor>` that can cut new
|
||||
releases on command by updating the PROJECT.org file with a release header for
|
||||
all DONE tasks and creating the appropriate release commits and tags based on
|
||||
either the new release version type.
|
||||
|
||||
It should also update the pyproject file.
|
||||
|
||||
** DONE [#A] Move imported retroarch lrtl files to processed/ directory on WebDAV :webdav:retroarch:importers:
|
||||
:PROPERTIES:
|
||||
:ID: 881b5af6-e96f-4e71-9e66-2d30824e5504
|
||||
:END:
|
||||
|
||||
- File: ~vrobbler/apps/scrobbles/importers/webdav.py~ (line 439)
|
||||
- Same pattern as the GPX importer: after importing a =.csv= file from
|
||||
WebDAV, move it to =var/retroarch/processed/= with a timestamp appended.
|
||||
|
||||
** DONE [#A] Add listenbrainz support for similar tracks :feature:music:metadata:
|
||||
:PROPERTIES:
|
||||
:ID: a3abba14-7603-db51-58e1-1941a47fa35e
|
||||
:END:
|
||||
** DONE [#B] Consolidate albums in the same musicbrainz_releasegroup_id :music:albums:metadata:
|
||||
:PROPERTIES:
|
||||
:ID: 6b331bdb-1516-bb86-7cb3-8cb9e3de59ce
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
When we look up albums, we should check if one already exists with the same
|
||||
musicbrainz_releasegroup_id and prefer that one, rather than creating a new
|
||||
album.
|
||||
|
||||
Also, we should create a data migration to clean up albums with matching
|
||||
musicbrainz_releasegroup_id fields by consolidating tracks around the first
|
||||
album found.
|
||||
|
||||
Whether the track was enriched by musicbrainz or not, the track should get
|
||||
tagged with `musicbrainz_enriched` or `musicbrainz_notfound`
|
||||
|
||||
** DONE [#A] Clean up metadata on music tracks :music:tracks:metadata:musicbrainz:
|
||||
:PROPERTIES:
|
||||
:ID: 3a007bd7-b901-43cb-892e-102d39fffc85
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
There's a over 3K tracks without a musicbrainz_id and almost 30K without a
|
||||
base_run_time_seconds. We should have a clean up script that can run through the
|
||||
ones missing musicbrainz_ids and see about getting metadata from musicbrainz and
|
||||
for the 30K without base_run_time_seconds, check with their musicbrainz_ids to
|
||||
see if we can look up the track length, and if not, tag the track with
|
||||
"missing-metadata" tag.
|
||||
|
||||
Also, if the tracks without musicbrainz_ids actually are not in the MB database,
|
||||
we should tag those with "not-in-musicbrainz"
|
||||
|
||||
And a big part of this work will probably involve checking for "Various Artists"
|
||||
tracks where the track erroneously got set with a generic artist. In those
|
||||
cases, we should try just looking up by track title.
|
||||
|
||||
** DONE [#B] Make artists_m2m field source of artist truth for albums :music:bug:albums:
|
||||
:PROPERTIES:
|
||||
:ID: 05a24455-0e71-45ef-ac6e-c1b7b843047d
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Albums have an FK for album_artist, but like artists, the M2M should be the
|
||||
source of truth. We should migrate all uses of album_artist to an `artist`
|
||||
property on the Album model and use a data migration to populate artists with
|
||||
the album_artist value.
|
||||
|
||||
** DONE [#A] Fix various artist album problem with Superwolves (track with multiple artists) :vrobbler:project:music:bug:artists:
|
||||
:PROPERTIES:
|
||||
:ID: 590bc038-745f-710b-8272-4d8a3d2efa01
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
We have an issue with tracks where there are two artists, like `Matt Sweeney &
|
||||
Bonnie "Prince" Billy`
|
||||
|
||||
I think we need to allow creating a single artist with both names, so the Artist
|
||||
is the full name. That said, the ampersand is usually used to split feature
|
||||
artsits (I think) so I'm not sure how this would work reliably.
|
||||
|
||||
Also, it's possible musicbrainz does not have dual artist listings. So it's
|
||||
possible the longer term solution is to allow multiple artists per track the way
|
||||
we now allow tracks to be on multiple albums. We could deprecate the Artist FK
|
||||
or at least make it optional, and then require a M2M between Track and Artist.
|
||||
|
||||
Then this one would be a Track by both `Matt Sweeney` and `Bonnie "Prince"
|
||||
Billy`
|
||||
|
||||
** DONE [#A] Move imported eBird CSV files to processed/ directory on WebDAV :webdav:ebird:importers:
|
||||
:PROPERTIES:
|
||||
:ID: 445e1253-d353-4b55-b1d8-39d0a0dcdd34
|
||||
:END:
|
||||
|
||||
- File: ~vrobbler/apps/scrobbles/importers/webdav.py~ (line 439)
|
||||
- Same pattern as the GPX importer: after importing a =.csv= file from
|
||||
WebDAV, move it to =var/ebird/processed/= with a timestamp appended.
|
||||
|
||||
** DONE [#A] Move imported Board Game CSV files to processed/ directory on WebDAV :webdav:boardgames:importers:
|
||||
:PROPERTIES:
|
||||
:ID: a3f8d30c-d2c3-4ee7-b062-f0f16bd9b0b4
|
||||
:END:
|
||||
|
||||
- File: ~vrobbler/apps/scrobbles/importers/webdav.py~ (line 496)
|
||||
- Same pattern as the GPX importer: after importing a =.csv= file from
|
||||
WebDAV, move it to =var/bgstats/processed/= with a timestamp appended.
|
||||
|
||||
** DONE [#A] Move imported Scale CSV files to processed/ directory on WebDAV :webdav:scale:importers:
|
||||
:PROPERTIES:
|
||||
:ID: 1a0de363-d1ea-466e-9966-e24941a6180b
|
||||
:END:
|
||||
- File: ~vrobbler/apps/scrobbles/importers/webdav.py~ (line 496)
|
||||
- Same pattern as the GPX importer: after importing a =.csv= file from
|
||||
WebDAV, move it to =var/scale/processed/= with a timestamp appended.
|
||||
|
||||
** DONE [#A] Allow special parameter to re-import already processed GPX files :imports:gpx:
|
||||
:PROPERTIES:
|
||||
:ID: 166c0809-c11a-4d02-8071-7a69dcb36e64
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Now that we stash imported GPX files in the processed/ subdirectory on import,
|
||||
it would be nice to have a flag like --include-processed on the webdav importer
|
||||
that would import files both in the root of gpx/ and also in the processed
|
||||
directory. This would aide testing imports in staging quickly without constantly
|
||||
moving files back and forth.
|
||||
|
||||
** DONE [#A] Move imported GPX files to processed/ directory on WebDAV :webdav:gpx:importers:
|
||||
:PROPERTIES:
|
||||
:ID: db2b02fc-817c-4c39-bd51-13f9b77c7888
|
||||
:END:
|
||||
- File: ~vrobbler/apps/scrobbles/importers/webdav.py~ (line 198)
|
||||
- After importing a GPX/FIT file from WebDAV, move it to a =processed/=
|
||||
subdirectory with a timestamp appended. This eliminates the DB lookup for
|
||||
already-imported filenames — any file present in the top-level directory
|
||||
is new. Also makes manual re-imports easy (just move a file back).
|
||||
|
||||
** DONE [#A] Add CSS Grid calendar view for scrobbles :vrobbler:personal:project:templates:feature:
|
||||
:PROPERTIES:
|
||||
:ID: be915acf-d803-466a-8770-823819ebf2a9
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Calendar view at /scrobbles/calendar/ showing select media types (Tasks,
|
||||
Birding, Food, Trails, VideoGames, Books) in a CSS Grid month layout.
|
||||
|
||||
- Emoji badges per scrobble on each day cell, hover reveals title
|
||||
- Click emoji to navigate to scrobble detail
|
||||
- Prev/Next month navigation
|
||||
- Based on the CodePen CSS Grid calendar pattern:
|
||||
https://codepen.io/oliviale/pen/QYqybo
|
||||
|
||||
** DONE [#C] Come up with a possible flow using WebDAV and super-productivity for tasks :personal:feature:project:vrobbler:tasks:
|
||||
:PROPERTIES:
|
||||
:ID: dd751edf-6192-2ed0-cd85-905f423f82b0
|
||||
:END:
|
||||
** DONE [#B] Fix PuzzleLogData has no attribute form :vrobbler:puzzles:personal:project:logdata:
|
||||
:PROPERTIES:
|
||||
:ID: cb8e697a-9fc2-c463-5d4d-fa15d3e833a0
|
||||
:END:
|
||||
** DONE [#B] Add PuzzleLogData class with with_people and completed :vrobbler:feature:puzzles:logdata:personal:project:
|
||||
:PROPERTIES:
|
||||
:ID: 1b38c8a8-86d3-b73d-c584-dfb0497970dc
|
||||
:END:
|
||||
** DONE Add weather lookup to the mood check-in flow :vrobbler:project:moods:feature:checkin:
|
||||
:PROPERTIES:
|
||||
:ID: 175678e2-bfe1-0833-f2a9-f03b9323db78
|
||||
:END:
|
||||
<2026-05-20 Wed>
|
||||
** DONE Add importing of openScale CSV files to Tasks :vrobbler:project:personal:tasks:openscale:
|
||||
:PROPERTIES:
|
||||
:ID: 344d1f5d-dda6-a1fb-da53-e00eebdd2262
|
||||
:END:
|
||||
** DONE Add ability to track Birding sessions via BirdingLocation scrobbles :vrobbler:project:birds:feature:
|
||||
:PROPERTIES:
|
||||
:ID: 8200ce29-a691-5cf6-c11a-c77e3d8b64c6
|
||||
:END:
|
||||
** DONE List only the last 20 scrobbles per category on the home page :vrobbler:project:scrobbles:templates:
|
||||
:PROPERTIES:
|
||||
:ID: b56402b9-5542-a10c-6c13-e12f56f2a2d1
|
||||
:END:
|
||||
** DONE Fix display of notes so they look like stickies :vrobbler:project:personal:notes:scrobbles:
|
||||
:PROPERTIES:
|
||||
:ID: 0ace8814-e96e-fa54-28d1-c57dcb508f1e
|
||||
:END:
|
||||
** DONE Add searching to scrobbles :vrobbler:project:personal:search:scrobbles:
|
||||
:PROPERTIES:
|
||||
:ID: b4690c76-d741-4320-20e8-c003251fe035
|
||||
:END:
|
||||
** DONE Fix uniqueness of imdb_id messing up youtube videos :vrobbler:project:bug:videos:
|
||||
:PROPERTIES:
|
||||
:ID: 610b8c4c-abf1-8630-a4fd-08d9939da9f5
|
||||
:END:
|
||||
|
||||
Turns out you can't make imdb_id unique by itself or you never get to create
|
||||
youtube videos. Rather, we should make imdb_id and youtube_id unique together so
|
||||
imdb_id can be empty for every youtube_id and vice versa.
|
||||
|
||||
** DONE Fix genearting chart records :vrobbler:bug:personal:project:chartrecords:
|
||||
:PROPERTIES:
|
||||
:ID: e749d762-44ac-dd7e-017e-2423016737ff
|
||||
:END:
|
||||
|
||||
** DONE [#A] Save raw scrobble request data to every scrobble log :vrobbler:personal:feature:scrobbles:
|
||||
:PROPERTIES:
|
||||
:ID: b4686db4-1319-399c-9b84-f0b1036c6815
|
||||
@ -605,6 +1255,7 @@ urllib.error.HTTPError: HTTP Error 500: Internal Server Error
|
||||
- Note taken on [2025-09-30 Tue 09:33]
|
||||
|
||||
This may have already been resolved ... need to just confirm it.
|
||||
|
||||
* Version 37.0 [4/4]
|
||||
** DONE [#A] Tasks from org-mode should properly update notes and leave them out of the body :vrobbler:bug:tasks:
|
||||
:PROPERTIES:
|
||||
|
||||
20
README.md
20
README.md
@ -21,3 +21,23 @@ VROBBLER_KEEP_DETAILED_SCROBBLE_LOGS=True
|
||||
VROBBLER_DATABASE_URL="postgres://vrobbler:<pass>@db.service:5432/vrobbler"
|
||||
VROBBLER_REDIS_URL="redis://:<pass>@cache.service:6379/0"
|
||||
```
|
||||
|
||||
## Database Backup
|
||||
|
||||
A backup command is available via `./manage.py backup_database` (also runs on a cron schedule via Celery). It dumps the database with `pg_dump`, compresses with gzip, and optionally copies the backup to a remote host via SCP.
|
||||
|
||||
Configure these additional settings as needed:
|
||||
|
||||
```
|
||||
VROBBLER_DB_BACKUP_SSH_KEY="/path/to/ssh/private/key"
|
||||
VROBBLER_DB_BACKUP_SSH_DEST="user@backup.example.com:/remote/path/"
|
||||
VROBBLER_DB_BACKUP_NTFY_URL="https://ntfy.sh/your-topic"
|
||||
```
|
||||
|
||||
- `VROBBLER_DB_BACKUP_SSH_KEY` — Path to the SSH private key used for remote copy.
|
||||
- `VROBBLER_DB_BACKUP_SSH_DEST` — SCP destination (user@host:path). If set, the backup is copied to the remote host and old backups are pruned.
|
||||
- `VROBBLER_DB_BACKUP_LOCAL_DIR` — Local directory for backup storage. Defaults to `/var/backups/`. Backups are stored in a `vrobbler/` subdirectory.
|
||||
- `VROBBLER_DB_BACKUP_NTFY_URL` — ntfy.sh URL for success notifications. Defaults to `https://ntfy.unbl.ink/backups`.
|
||||
|
||||
Retention is hardcoded: keeps daily backups for 7 days, plus one per month for 12 months.
|
||||
```
|
||||
|
||||
5
data/birding-example.csv
Normal file
5
data/birding-example.csv
Normal file
@ -0,0 +1,5 @@
|
||||
Species,Count,Location,Observation Type,Observation Date,Start Time,Duration,Distance,Area,Party Size,Complete Checklist,# of species,Details
|
||||
Canada Goose,6,"120 Perkins Street, Castine, Maine, US (44.384, -68.805)",Stationary,"May 10, 2026",4:15 PM,9 minute(s),,,4,true,4 species,
|
||||
Common Loon,1,"120 Perkins Street, Castine, Maine, US (44.384, -68.805)",Stationary,"May 10, 2026",4:15 PM,9 minute(s),,,4,true,4 species,
|
||||
Double-crested Cormorant,1,"120 Perkins Street, Castine, Maine, US (44.384, -68.805)",Stationary,"May 10, 2026",4:15 PM,9 minute(s),,,4,true,4 species,
|
||||
Boat-tailed Grackle,2,"120 Perkins Street, Castine, Maine, US (44.384, -68.805)",Stationary,"May 10, 2026",4:15 PM,9 minute(s),,,4,true,4 species,Sitting together on roof line of a house on Water Street. 20 meters away. Both birds were mostly black with green accents on the breast with long tails which they were repeatedly fanning out to show the V shape.
|
||||
|
BIN
data/sample-trail.fit
Normal file
BIN
data/sample-trail.fit
Normal file
Binary file not shown.
3360
data/sample_trail.gpx
Normal file
3360
data/sample_trail.gpx
Normal file
File diff suppressed because one or more lines are too long
2
data/scale-example.csv
Normal file
2
data/scale-example.csv
Normal file
@ -0,0 +1,2 @@
|
||||
DATE,TIME,BICEPS,BMI,BMR,BODY_FAT,BONE,CALIPER,CALIPER_1,CALIPER_2,CALIPER_3,CALORIES,CHEST,COMMENT,HEART_RATE,HIPS,LBM,MUSCLE,NECK,TDEE,THIGH,VISCERAL_FAT,WAIST,WATER,WEIGHT,WHR,WHTR
|
||||
2026-05-20,11:56:58.076,,31.09,1706.74,29.084072,3.438837,,,,,,,,,,,33.07067,,2645.46,,,,54.445187,192.68378,,
|
||||
|
13
justfile
13
justfile
@ -8,3 +8,16 @@ django:
|
||||
|
||||
shell:
|
||||
poetry run python manage.py shell
|
||||
|
||||
celery:
|
||||
poetry run celery -A vrobbler worker -l info --concurrency=2 --pool=threads
|
||||
|
||||
celery-beat:
|
||||
poetry run celery -A vrobbler beat -l info
|
||||
|
||||
release kind="minor":
|
||||
poetry run python scripts/release.py {{kind}}
|
||||
|
||||
push:
|
||||
git push && git push gitea
|
||||
git push --tags && git push --tags gitea
|
||||
|
||||
1222
poetry.lock
generated
1222
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,11 +1,11 @@
|
||||
[tool.poetry]
|
||||
name = "vrobbler"
|
||||
version = "0.16.1"
|
||||
version = "44.0"
|
||||
description = ""
|
||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = ">=3.11,<3.14"
|
||||
python = ">=3.11,<3.15"
|
||||
Django = "^4.0.3"
|
||||
django-extensions = "^3.1.5"
|
||||
python-dateutil = "^2.8.2"
|
||||
@ -28,7 +28,6 @@ django-markdownify = "^0.9.1"
|
||||
gunicorn = "^20.1.0"
|
||||
django-simple-history = "^3.1.1"
|
||||
musicbrainzngs = "^0.7.1"
|
||||
cinemagoerng = {git = "https://github.com/cinemagoer/cinemagoerng"}
|
||||
pysportsdb = "^0.1.0"
|
||||
pytz = "^2022.7.1"
|
||||
django-redis = "^5.2.0"
|
||||
@ -60,6 +59,9 @@ feedparser = "^6.0.12"
|
||||
titlecase = "^2.4.1"
|
||||
bgg-api = "^1.1.13"
|
||||
recipe-scrapers = "^15.11.0"
|
||||
gpxpy = "^1.6.2"
|
||||
fitparse = "^1.2.0"
|
||||
lxml = ">=5.5.0"
|
||||
|
||||
[tool.poetry.group.test]
|
||||
optional = true
|
||||
@ -88,7 +90,7 @@ testpaths = ["tests"]
|
||||
DJANGO_SETTINGS_MODULE='vrobbler.settings-testing'
|
||||
|
||||
[tool.black]
|
||||
line-length = 79
|
||||
line-length = 88
|
||||
target-version = ["py39", "py310"]
|
||||
include = ".py$"
|
||||
exclude = "migrations"
|
||||
@ -105,6 +107,8 @@ exclude_dirs = ["*/tests/*", "*/migrations/*"]
|
||||
[tool.poetry.scripts]
|
||||
vrobbler = "vrobbler.cli:main"
|
||||
|
||||
[tool.poetry_bumpversion.file."vrobbler/__init__.py"]
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=1.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
217
scripts/release.py
Executable file
217
scripts/release.py
Executable file
@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Cut a new release: collect DONE items from Backlog into a new Version section.
|
||||
|
||||
Usage:
|
||||
poetry run python scripts/release.py major
|
||||
poetry run python scripts/release.py minor
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_FILE = Path("PROJECT.org")
|
||||
PYPROJECT_FILE = Path("pyproject.toml")
|
||||
|
||||
BACKLOG_RE = re.compile(r"^\* Backlog\s+\[(\d+)/(\d+)\](.*)$")
|
||||
VERSION_RE = re.compile(r"^\* Version\s+(\d+\.\d+)\s+\[\d+/\d+\]")
|
||||
DONE_HEADER_RE = re.compile(r"^(\*\* DONE\s+)(.*)$")
|
||||
ITEM_HEADER_RE = re.compile(r"^\*\* ")
|
||||
|
||||
|
||||
def parse_done_line(line):
|
||||
"""Extract a clean title from a ** DONE line, stripping priority and tags."""
|
||||
rest = line[8:].strip() # remove "** DONE "
|
||||
# strip priority marker like [#A]
|
||||
rest = re.sub(r"^\[#[A-C]\]\s+", "", rest, count=1)
|
||||
# strip org-mode tags at end (space-colon-tags)
|
||||
rest = re.sub(r"\s+:\S.*:\s*$", "", rest)
|
||||
return rest
|
||||
|
||||
|
||||
def bump_version(current_major, current_minor, kind):
|
||||
if kind == "major":
|
||||
return current_major + 1, 0
|
||||
elif kind == "minor":
|
||||
return current_major, current_minor + 1
|
||||
else:
|
||||
raise ValueError(f"Unknown bump kind: {kind}")
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2 or sys.argv[1] not in ("major", "minor"):
|
||||
print(f"Usage: {sys.argv[0]} <major|minor>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
kind = sys.argv[1]
|
||||
|
||||
lines = PROJECT_FILE.read_text().splitlines(keepends=True)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 1. Identify top-level sections
|
||||
# ---------------------------------------------------------------
|
||||
section_starts = []
|
||||
for i, line in enumerate(lines):
|
||||
if line.startswith("* ") and not line.startswith("** "):
|
||||
section_starts.append(i)
|
||||
section_starts.append(len(lines))
|
||||
|
||||
backlog_idx = None
|
||||
version_idx = None
|
||||
|
||||
for idx, start in enumerate(section_starts[:-1]):
|
||||
header = lines[start].strip()
|
||||
if header.startswith("* Backlog"):
|
||||
backlog_idx = idx
|
||||
if header.startswith("* Version"):
|
||||
version_idx = idx # last occurrence wins
|
||||
|
||||
if backlog_idx is None:
|
||||
print("ERROR: no Backlog section found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if version_idx is None:
|
||||
print("ERROR: no Version section found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
backlog_start = section_starts[backlog_idx]
|
||||
backlog_end = section_starts[backlog_idx + 1]
|
||||
|
||||
# Find the newest Version section (first after Backlog) that matches
|
||||
# our expected format (e.g. "37.0" not "0.11.4").
|
||||
version_start = None
|
||||
for idx in range(backlog_idx + 1, version_idx + 1):
|
||||
header = lines[section_starts[idx]].strip()
|
||||
if VERSION_RE.match(header):
|
||||
version_start = section_starts[idx]
|
||||
break
|
||||
|
||||
if version_start is None:
|
||||
print("ERROR: no parseable Version header found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
version_header = lines[version_start].strip()
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 2. Parse current version from the newest * Version header
|
||||
# ---------------------------------------------------------------
|
||||
vm = VERSION_RE.match(version_header)
|
||||
current_version = vm.group(1)
|
||||
major_str, minor_str = current_version.split(".")
|
||||
current_major = int(major_str)
|
||||
current_minor = int(minor_str)
|
||||
new_major, new_minor = bump_version(current_major, current_minor, kind)
|
||||
new_version = f"{new_major}.{new_minor}"
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 3. Collect ** DONE items from the Backlog section
|
||||
# ---------------------------------------------------------------
|
||||
backlog_lines = lines[backlog_start:backlog_end]
|
||||
|
||||
# Split Backlog into items at each ** line (skip the section header)
|
||||
items = [] # list of (start_idx, end_idx, is_done)
|
||||
item_start = None
|
||||
for i in range(1, len(backlog_lines)):
|
||||
if ITEM_HEADER_RE.match(backlog_lines[i]):
|
||||
if item_start is not None:
|
||||
items.append((item_start, i, backlog_lines[item_start].startswith("** DONE")))
|
||||
item_start = i
|
||||
if item_start is not None:
|
||||
items.append((item_start, len(backlog_lines), backlog_lines[item_start].startswith("** DONE")))
|
||||
|
||||
done_items = [(s, e) for s, e, is_done in items if is_done]
|
||||
kept_items = [(s, e) for s, e, is_done in items if not is_done]
|
||||
|
||||
if not done_items:
|
||||
print("No DONE items found in Backlog — nothing to release.")
|
||||
sys.exit(0)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 4. Build the new Version section text
|
||||
# ---------------------------------------------------------------
|
||||
version_section_lines = [f"* Version {new_version} [{len(done_items)}/{len(done_items)}]\n"]
|
||||
for s, e in done_items:
|
||||
version_section_lines.extend(backlog_lines[s:e])
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 5. Build updated Backlog section
|
||||
# ---------------------------------------------------------------
|
||||
backlog_header_line = backlog_lines[0]
|
||||
bm = BACKLOG_RE.match(backlog_header_line.strip())
|
||||
if not bm:
|
||||
print(f"ERROR: could not parse backlog header: {backlog_header_line!r}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
done_count = int(bm.group(1))
|
||||
total_count = int(bm.group(2))
|
||||
tags = bm.group(3)
|
||||
|
||||
new_done = done_count - len(done_items)
|
||||
new_total = total_count - len(done_items)
|
||||
new_backlog_header = f"* Backlog [{new_done}/{new_total}]{tags}\n"
|
||||
|
||||
backlog_body = []
|
||||
for s, e in kept_items:
|
||||
backlog_body.extend(backlog_lines[s:e])
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 6. Assemble the new file
|
||||
# ---------------------------------------------------------------
|
||||
before_backlog = lines[:backlog_start]
|
||||
after_backlog = lines[backlog_end:version_start]
|
||||
|
||||
# Everything from the first Version section onwards
|
||||
from_version = lines[version_start:]
|
||||
|
||||
output = (
|
||||
before_backlog
|
||||
+ [new_backlog_header]
|
||||
+ backlog_body
|
||||
+ version_section_lines
|
||||
+ ["\n"]
|
||||
+ after_backlog
|
||||
+ from_version
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 7. Update pyproject.toml
|
||||
# ---------------------------------------------------------------
|
||||
pyproject = PYPROJECT_FILE.read_text()
|
||||
pyproject = re.sub(
|
||||
r'^version = "[\d.]+"',
|
||||
f'version = "{new_version}"',
|
||||
pyproject,
|
||||
count=1,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 8. Write files
|
||||
# ---------------------------------------------------------------
|
||||
PROJECT_FILE.write_text("".join(output))
|
||||
PYPROJECT_FILE.write_text(pyproject)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 9. Build commit body from done item titles
|
||||
# ---------------------------------------------------------------
|
||||
commit_lines = []
|
||||
for s, e in done_items:
|
||||
title = parse_done_line(backlog_lines[s])
|
||||
if title:
|
||||
commit_lines.append(f"- {title}")
|
||||
|
||||
commit_body = "\n".join(commit_lines)
|
||||
commit_message = f"[release] Bump to version {new_version}\n\n{commit_body}"
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 10. Git commit + tag
|
||||
# ---------------------------------------------------------------
|
||||
subprocess.run(["git", "add", str(PROJECT_FILE), str(PYPROJECT_FILE)], check=True)
|
||||
subprocess.run(["git", "commit", "-m", commit_message], check=True)
|
||||
subprocess.run(["git", "tag", new_version], check=True)
|
||||
|
||||
print(f"\nReleased v{new_version} — tag {new_version} created.")
|
||||
print(f"Moved {len(done_items)} DONE item(s) from Backlog to Version section.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
0
tests/birds_tests/__init__.py
Normal file
0
tests/birds_tests/__init__.py
Normal file
70
tests/birds_tests/conftest.py
Normal file
70
tests/birds_tests/conftest.py
Normal file
@ -0,0 +1,70 @@
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from birds.models import (
|
||||
Bird,
|
||||
BirdSightingEntry,
|
||||
BirdSightingLogData,
|
||||
BirdingLocation,
|
||||
)
|
||||
from django.contrib.auth import get_user_model
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user(db):
|
||||
return User.objects.create(email="birder@example.com")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bird(db):
|
||||
return Bird.objects.create(
|
||||
common_name="Northern Cardinal",
|
||||
scientific_name="Cardinalis cardinalis",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def birding_location(db):
|
||||
return BirdingLocation.objects.create(title="Test Park")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def birding_csv_content():
|
||||
return """Species,Count,Location,Observation Type,Observation Date,Start Time,Duration,Distance,Area,Party Size,Complete Checklist,# of species,Details
|
||||
Canada Goose,6,Test Park,Stationary,"May 10, 2026",4:15 PM,9 minute(s),,,4,true,4 species,
|
||||
Northern Cardinal,2,Test Park,Stationary,"May 10, 2026",4:15 PM,9 minute(s),,,4,true,4 species,At the feeder
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def birding_csv_file(birding_csv_content):
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".csv", delete=False, encoding="utf-8"
|
||||
) as f:
|
||||
f.write(birding_csv_content)
|
||||
return f.name
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scrobble_with_sightings(user, birding_location, bird):
|
||||
return Scrobble.objects.create(
|
||||
user=user,
|
||||
birding_location=birding_location,
|
||||
media_type=Scrobble.MediaType.BIRDING_LOCATION,
|
||||
timestamp="2026-05-10 16:15:00+00:00",
|
||||
played_to_completion=True,
|
||||
log={
|
||||
"birds": [
|
||||
BirdSightingEntry(
|
||||
bird_id=bird.id, quantity=2, sighting_notes="At the feeder"
|
||||
).asdict
|
||||
],
|
||||
"duration_minutes": 9,
|
||||
"observation_type": "Stationary",
|
||||
"party_size": 4,
|
||||
"complete_checklist": True,
|
||||
},
|
||||
)
|
||||
96
tests/birds_tests/test_dataclasses.py
Normal file
96
tests/birds_tests/test_dataclasses.py
Normal file
@ -0,0 +1,96 @@
|
||||
from birds.models import BirdSightingEntry, BirdSightingLogData
|
||||
|
||||
|
||||
class TestBirdSightingEntry:
|
||||
def test_create_entry(self, db, bird):
|
||||
entry = BirdSightingEntry(bird_id=bird.id, quantity=3)
|
||||
assert entry.bird_id == bird.id
|
||||
assert entry.quantity == 3
|
||||
assert entry.sighting_notes is None
|
||||
|
||||
def test_entry_default_quantity(self, db, bird):
|
||||
entry = BirdSightingEntry(bird_id=bird.id)
|
||||
assert entry.quantity == 1
|
||||
|
||||
def test_entry_str(self, db, bird):
|
||||
entry = BirdSightingEntry(
|
||||
bird_id=bird.id, quantity=2, sighting_notes="in the tree"
|
||||
)
|
||||
expected = f"{bird.common_name} x2 (in the tree)"
|
||||
assert str(entry) == expected
|
||||
|
||||
def test_entry_str_no_notes(self, db, bird):
|
||||
entry = BirdSightingEntry(bird_id=bird.id, quantity=1)
|
||||
expected = f"{bird.common_name} x1"
|
||||
assert str(entry) == expected
|
||||
|
||||
def test_entry_bird_property(self, db, bird):
|
||||
entry = BirdSightingEntry(bird_id=bird.id)
|
||||
assert entry.bird == bird
|
||||
|
||||
def test_entry_bird_property_none(self, db):
|
||||
entry = BirdSightingEntry(bird_id=None)
|
||||
assert entry.bird is None
|
||||
|
||||
def test_entry_asdict(self, db, bird):
|
||||
entry = BirdSightingEntry(
|
||||
bird_id=bird.id, quantity=4, sighting_notes="flying south"
|
||||
)
|
||||
d = entry.asdict
|
||||
assert d["bird_id"] == bird.id
|
||||
assert d["quantity"] == 4
|
||||
assert d["sighting_notes"] == "flying south"
|
||||
|
||||
|
||||
class TestBirdSightingLogData:
|
||||
def test_empty_logdata(self):
|
||||
logdata = BirdSightingLogData()
|
||||
assert logdata.birds is None
|
||||
assert logdata.duration_minutes is None
|
||||
assert logdata.observation_type is None
|
||||
assert logdata.party_size is None
|
||||
assert logdata.complete_checklist is None
|
||||
|
||||
def test_with_birds(self, db, bird):
|
||||
entry = BirdSightingEntry(bird_id=bird.id, quantity=2).asdict
|
||||
logdata = BirdSightingLogData(
|
||||
birds=[entry],
|
||||
duration_minutes=15,
|
||||
observation_type="Traveling",
|
||||
party_size=3,
|
||||
complete_checklist=True,
|
||||
)
|
||||
assert len(logdata.birds) == 1
|
||||
assert logdata.duration_minutes == 15
|
||||
assert logdata.observation_type == "Traveling"
|
||||
assert logdata.party_size == 3
|
||||
assert logdata.complete_checklist is True
|
||||
|
||||
def test_bird_list_property(self, db, bird):
|
||||
entry = BirdSightingEntry(bird_id=bird.id, quantity=2).asdict
|
||||
logdata = BirdSightingLogData(birds=[entry])
|
||||
assert bird.common_name in logdata.bird_list
|
||||
|
||||
def test_bird_list_empty(self):
|
||||
logdata = BirdSightingLogData()
|
||||
assert logdata.bird_list == ""
|
||||
|
||||
def test_as_html_with_all_fields(self, db, bird):
|
||||
entry = BirdSightingEntry(bird_id=bird.id, quantity=2).asdict
|
||||
logdata = BirdSightingLogData(
|
||||
birds=[entry],
|
||||
observation_type="Stationary",
|
||||
distance="2 km",
|
||||
area="Woodland",
|
||||
party_size=4,
|
||||
complete_checklist=True,
|
||||
weather="Sunny",
|
||||
)
|
||||
html = logdata.as_html()
|
||||
assert "Stationary" in html
|
||||
assert "2 km" in html
|
||||
assert "Woodland" in html
|
||||
assert "Party size: 4" in html
|
||||
assert "Complete checklist: True" in html
|
||||
assert "Sunny" in html
|
||||
assert bird.common_name in html
|
||||
139
tests/birds_tests/test_importer.py
Normal file
139
tests/birds_tests/test_importer.py
Normal file
@ -0,0 +1,139 @@
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from birds.importer import (
|
||||
import_birding_csv,
|
||||
parse_bool,
|
||||
parse_coords,
|
||||
parse_duration,
|
||||
parse_int,
|
||||
parse_timestamp,
|
||||
)
|
||||
from birds.models import Bird, BirdingLocation, BirdingCSVImport
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
|
||||
class TestParserHelpers:
|
||||
def test_parse_duration(self):
|
||||
assert parse_duration("9 minute(s)") == 9
|
||||
assert parse_duration("120 minute(s)") == 120
|
||||
assert parse_duration("") is None
|
||||
assert parse_duration(None) is None
|
||||
assert parse_duration("not a duration") is None
|
||||
|
||||
def test_parse_coords(self):
|
||||
loc = "Some Place, US (44.384, -68.805)"
|
||||
lat, lon = parse_coords(loc)
|
||||
assert lat == 44.384
|
||||
assert lon == -68.805
|
||||
|
||||
def test_parse_coords_no_match(self):
|
||||
loc = "Some Place, US"
|
||||
lat, lon = parse_coords(loc)
|
||||
assert lat is None
|
||||
assert lon is None
|
||||
|
||||
def test_parse_timestamp(self):
|
||||
dt = parse_timestamp("May 10, 2026", "4:15 PM")
|
||||
assert dt is not None
|
||||
assert dt.year == 2026
|
||||
assert dt.month == 5
|
||||
assert dt.day == 10
|
||||
assert dt.hour == 16
|
||||
assert dt.minute == 15
|
||||
|
||||
def test_parse_timestamp_no_time(self):
|
||||
dt = parse_timestamp("May 10, 2026", "")
|
||||
assert dt is not None
|
||||
assert dt.year == 2026
|
||||
|
||||
def test_parse_timestamp_invalid(self):
|
||||
assert parse_timestamp("not a date", "") is None
|
||||
|
||||
def test_parse_bool(self):
|
||||
assert parse_bool("true") is True
|
||||
assert parse_bool("True") is True
|
||||
assert parse_bool("yes") is True
|
||||
assert parse_bool("1") is True
|
||||
assert parse_bool("false") is False
|
||||
assert parse_bool("") is None
|
||||
assert parse_bool(None) is None
|
||||
|
||||
def test_parse_int(self):
|
||||
assert parse_int("42") == 42
|
||||
assert parse_int("") is None
|
||||
assert parse_int(None) is None
|
||||
assert parse_int("not a number") is None
|
||||
|
||||
|
||||
class TestImportBirdingCSV:
|
||||
def test_import_creates_birds(self, user, birding_csv_file):
|
||||
import_birding_csv(birding_csv_file, user.id)
|
||||
assert Bird.objects.filter(common_name="Canada Goose").exists()
|
||||
assert Bird.objects.filter(common_name="Northern Cardinal").exists()
|
||||
|
||||
def test_import_creates_location(self, user, birding_csv_file):
|
||||
import_birding_csv(birding_csv_file, user.id)
|
||||
assert BirdingLocation.objects.filter(title="Test Park").exists()
|
||||
|
||||
def test_import_creates_scrobble(self, user, birding_csv_file):
|
||||
import_birding_csv(birding_csv_file, user.id)
|
||||
assert Scrobble.objects.filter(
|
||||
source="Birding CSV Import"
|
||||
).count() == 1
|
||||
|
||||
def test_import_logdata_fields(self, user, birding_csv_file):
|
||||
import_birding_csv(birding_csv_file, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="Birding CSV Import").first()
|
||||
log = scrobble.log
|
||||
assert log["duration_minutes"] == 9
|
||||
assert log["observation_type"] == "Stationary"
|
||||
assert log["party_size"] == 4
|
||||
assert log["complete_checklist"] is True
|
||||
assert len(log["birds"]) == 2
|
||||
|
||||
def test_import_sighting_details(self, user, birding_csv_file):
|
||||
import_birding_csv(birding_csv_file, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="Birding CSV Import").first()
|
||||
birds = scrobble.log["birds"]
|
||||
cardinal = next(b for b in birds if b["quantity"] == 2)
|
||||
assert cardinal["sighting_notes"] == "At the feeder"
|
||||
|
||||
def test_import_idempotent(self, user, birding_csv_file):
|
||||
import_birding_csv(birding_csv_file, user.id)
|
||||
import_birding_csv(birding_csv_file, user.id)
|
||||
assert Scrobble.objects.filter(
|
||||
source="Birding CSV Import"
|
||||
).count() == 1
|
||||
|
||||
def test_import_bird_quantities(self, user, birding_csv_file):
|
||||
import_birding_csv(birding_csv_file, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="Birding CSV Import").first()
|
||||
birds = scrobble.log["birds"]
|
||||
goose = next(b for b in birds if b["quantity"] == 6)
|
||||
assert goose is not None
|
||||
|
||||
def test_import_sets_stop_timestamp(self, user, birding_csv_file):
|
||||
import_birding_csv(birding_csv_file, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="Birding CSV Import").first()
|
||||
assert scrobble.stop_timestamp is not None
|
||||
expected = scrobble.timestamp + timedelta(minutes=9)
|
||||
assert scrobble.stop_timestamp == expected
|
||||
|
||||
|
||||
class TestBirdingCSVImportModel:
|
||||
def test_create_import_model(self, db, user):
|
||||
imp = BirdingCSVImport.objects.create(user=user)
|
||||
assert imp.uuid is not None
|
||||
assert imp.import_type == "Birding CSV"
|
||||
assert "Birding" in str(imp)
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_process_via_model(self, user, birding_csv_file):
|
||||
imp = BirdingCSVImport.objects.create(user=user)
|
||||
with open(birding_csv_file, "rb") as f:
|
||||
imp.csv_file.save("test.csv", f, save=True)
|
||||
imp.process()
|
||||
imp.refresh_from_db()
|
||||
assert imp.process_count == 1
|
||||
assert imp.processed_finished is not None
|
||||
30
tests/birds_tests/test_models.py
Normal file
30
tests/birds_tests/test_models.py
Normal file
@ -0,0 +1,30 @@
|
||||
import pytest
|
||||
from birds.models import Bird
|
||||
|
||||
|
||||
class TestBirdModel:
|
||||
def test_create_bird(self, db):
|
||||
bird = Bird.objects.create(common_name="Blue Jay")
|
||||
assert bird.common_name == "Blue Jay"
|
||||
assert bird.uuid is not None
|
||||
assert str(bird) == "Blue Jay"
|
||||
|
||||
def test_find_or_create_new(self, db):
|
||||
bird = Bird.find_or_create("American Robin")
|
||||
assert bird.common_name == "American Robin"
|
||||
|
||||
def test_find_or_create_existing(self, db, bird):
|
||||
result = Bird.find_or_create("Northern Cardinal")
|
||||
assert result.id == bird.id
|
||||
assert result.common_name == "Northern Cardinal"
|
||||
|
||||
def test_find_or_create_case_insensitive(self, db, bird):
|
||||
result = Bird.find_or_create("northern cardinal")
|
||||
assert result.id == bird.id
|
||||
|
||||
def test_bird_str(self, db):
|
||||
bird = Bird.objects.create(common_name="Mourning Dove")
|
||||
assert str(bird) == "Mourning Dove"
|
||||
|
||||
def test_bird_scientific_name(self, db, bird):
|
||||
assert bird.scientific_name == "Cardinalis cardinalis"
|
||||
42
tests/birds_tests/test_views.py
Normal file
42
tests/birds_tests/test_views.py
Normal file
@ -0,0 +1,42 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
from scrobbles.models import EBirdCSVImport
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class TestBirdingLocationViews:
|
||||
def test_birding_location_list_anonymous(self, db):
|
||||
client = Client()
|
||||
response = client.get(reverse("birds:birding_location_list"))
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_bird_list_anonymous(self, db):
|
||||
client = Client()
|
||||
response = client.get(reverse("birds:bird_list"))
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestBirdingCSVImportViews:
|
||||
def test_upload_view_requires_login(self, db):
|
||||
client = Client()
|
||||
response = client.get(reverse("birds:csv-upload"))
|
||||
assert response.status_code == 302
|
||||
|
||||
def test_import_detail_view_requires_login(self, db):
|
||||
client = Client()
|
||||
response = client.get(
|
||||
reverse("birds:csv_import_detail", kwargs={"slug": "00000000-0000-0000-0000-000000000001"})
|
||||
)
|
||||
assert response.status_code == 302
|
||||
|
||||
def test_import_detail_authenticated(self, db):
|
||||
user = User.objects.create(email="birder@example.com")
|
||||
client = Client()
|
||||
client.force_login(user)
|
||||
imp = EBirdCSVImport.objects.create(user=user)
|
||||
response = client.get(
|
||||
reverse("scrobbles:ebird-csv-import-detail", kwargs={"slug": imp.uuid})
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@ -93,41 +93,29 @@ class TestRecipeScraperService:
|
||||
assert result is True
|
||||
|
||||
def test_scrape_returns_title(self, scraper):
|
||||
result = scraper.scrape(
|
||||
RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe"
|
||||
)
|
||||
result = scraper.scrape(RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe")
|
||||
assert result["title"] == "Test Recipe"
|
||||
|
||||
def test_scrape_returns_ingredients(self, scraper):
|
||||
result = scraper.scrape(
|
||||
RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe"
|
||||
)
|
||||
result = scraper.scrape(RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe")
|
||||
assert len(result["ingredients"]) == 3
|
||||
assert "1 cup flour" in result["ingredients"]
|
||||
|
||||
def test_scrape_returns_instructions(self, scraper):
|
||||
result = scraper.scrape(
|
||||
RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe"
|
||||
)
|
||||
result = scraper.scrape(RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe")
|
||||
assert len(result["instructions"]) > 0
|
||||
assert "Mix ingredients together" in result["instructions"]
|
||||
|
||||
def test_scrape_returns_yields(self, scraper):
|
||||
result = scraper.scrape(
|
||||
RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe"
|
||||
)
|
||||
result = scraper.scrape(RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe")
|
||||
assert result["yields"] == "4 servings"
|
||||
|
||||
def test_scrape_returns_total_time(self, scraper):
|
||||
result = scraper.scrape(
|
||||
RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe"
|
||||
)
|
||||
result = scraper.scrape(RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe")
|
||||
assert result["total_time"] == 30
|
||||
|
||||
def test_scrape_returns_url(self, scraper):
|
||||
result = scraper.scrape(
|
||||
RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe"
|
||||
)
|
||||
result = scraper.scrape(RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe")
|
||||
assert result["url"] == "https://example.com/recipe"
|
||||
|
||||
def test_scrape_raises_on_invalid_html(self, scraper):
|
||||
|
||||
@ -9,9 +9,7 @@ from foods.sources.usda import (
|
||||
class TestUSDAFoodAPI:
|
||||
@pytest.fixture
|
||||
def usda_api(self):
|
||||
with patch(
|
||||
"vrobbler.apps.foods.sources.usda.settings"
|
||||
) as mock_settings:
|
||||
with patch("vrobbler.apps.foods.sources.usda.settings") as mock_settings:
|
||||
mock_settings.USDA_API_KEY = "test_api_key"
|
||||
return USDAFoodAPI(api_key="test_api_key")
|
||||
|
||||
|
||||
@ -1,9 +1,7 @@
|
||||
import pytest
|
||||
from vrobbler.apps.podcasts.scrapers import scrape_data_from_google_podcasts
|
||||
|
||||
expected_desc_snippet = (
|
||||
"NPR's Up First is the news you need to start your day. "
|
||||
)
|
||||
expected_desc_snippet = "NPR's Up First is the news you need to start your day. "
|
||||
|
||||
expected_img_url = "https://encrypted-tbn2.gstatic.com/images?q=tbn:ANd9GcR1F0CfR24RR6sme531yIkCrnK4zzmo97jeualO5drVPKG6oCk"
|
||||
expected_google_url = "https://podcasts.google.com/feed/aHR0cHM6Ly9mZWVkcy5ucHIub3JnLzUxMDMxOC9wb2RjYXN0LnhtbA"
|
||||
|
||||
@ -68,9 +68,7 @@ class MopidyRequest:
|
||||
"artist": kwargs.get("artist", self.artist),
|
||||
"album": kwargs.get("album", self.album),
|
||||
"track_number": int(kwargs.get("track_number", self.track_number)),
|
||||
"run_time_ticks": int(
|
||||
kwargs.get("run_time_ticks", self.run_time_ticks)
|
||||
),
|
||||
"run_time_ticks": int(kwargs.get("run_time_ticks", self.run_time_ticks)),
|
||||
"run_time": int(kwargs.get("run_time", self.run_time)),
|
||||
"playback_time_ticks": int(
|
||||
kwargs.get("playback_time_ticks", self.playback_time_ticks)
|
||||
@ -113,9 +111,7 @@ def mopidy_track():
|
||||
@pytest.fixture
|
||||
def mopidy_track_diff_album_request_data(**kwargs):
|
||||
mb_album_id = "0c56c457-afe1-4679-baab-759ba8dd2a58"
|
||||
return MopidyRequest(
|
||||
album="Gold", musicbrainz_album_id=mb_album_id
|
||||
).request_json
|
||||
return MopidyRequest(album="Gold", musicbrainz_album_id=mb_album_id).request_json
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@ -7,23 +7,37 @@ from django.contrib.auth import get_user_model
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from music.aggregators import live_charts, scrobble_counts, week_of_scrobbles
|
||||
from music.models import Album, Artist
|
||||
from profiles.models import UserProfile
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
|
||||
def build_scrobbles(client, request_json, num=7, spacing=2):
|
||||
def build_scrobbles(client, request_json, num=7, spacing=2, auth_token=None):
|
||||
from rest_framework.authtoken.models import Token
|
||||
import pytz
|
||||
|
||||
url = reverse("scrobbles:mopidy-webhook")
|
||||
user = get_user_model().objects.create(username="Test User")
|
||||
user.profile.timezone = "US/Eastern"
|
||||
user.profile.save()
|
||||
headers = {}
|
||||
if auth_token:
|
||||
headers = {"Authorization": f"Token {auth_token}"}
|
||||
user = Token.objects.get(key=auth_token).user
|
||||
|
||||
client.post(url, request_json, content_type="application/json", headers=headers)
|
||||
track = Scrobble.objects.last().track
|
||||
|
||||
est = pytz.timezone("US/Eastern")
|
||||
for i in range(num):
|
||||
client.post(url, request_json, content_type="application/json")
|
||||
s = Scrobble.objects.last()
|
||||
s.user = user
|
||||
s.timestamp = timezone.now() - timedelta(days=i * spacing)
|
||||
s.played_to_completion = True
|
||||
s.save()
|
||||
naive_time = timezone.now().replace(tzinfo=None) - timedelta(days=i * spacing)
|
||||
aware_time = est.localize(naive_time)
|
||||
Scrobble.objects.create(
|
||||
user=user,
|
||||
track=track,
|
||||
timestamp=aware_time,
|
||||
played_to_completion=True,
|
||||
source="Mopidy",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@ -41,9 +55,11 @@ def test_scrobble_counts_data(
|
||||
mock_get_album,
|
||||
client,
|
||||
mopidy_track,
|
||||
valid_auth_token,
|
||||
):
|
||||
build_scrobbles(client, mopidy_track.request_json)
|
||||
user = get_user_model().objects.first()
|
||||
user = build_scrobbles(
|
||||
client, mopidy_track.request_json, auth_token=valid_auth_token
|
||||
)
|
||||
count_dict = scrobble_counts(user)
|
||||
assert count_dict == {
|
||||
"alltime": 7,
|
||||
@ -69,9 +85,11 @@ def test_live_charts(
|
||||
mock_get_album,
|
||||
client,
|
||||
mopidy_track,
|
||||
valid_auth_token,
|
||||
):
|
||||
build_scrobbles(client, mopidy_track.request_json, 7, 1)
|
||||
user = get_user_model().objects.first()
|
||||
user = build_scrobbles(
|
||||
client, mopidy_track.request_json, 7, 1, auth_token=valid_auth_token
|
||||
)
|
||||
|
||||
week = week_of_scrobbles(user)
|
||||
assert list(week.values()) == [1, 1, 1, 1, 1, 1, 1]
|
||||
|
||||
@ -3,6 +3,24 @@ from unittest.mock import MagicMock, patch
|
||||
from scrobbles.scrobblers import jellyfin_scrobble_media, mopidy_scrobble_media
|
||||
|
||||
|
||||
def test_jellyfin_scrobble_video_with_no_imdb_id():
|
||||
with patch("scrobbles.scrobblers.Video") as mock_video_class:
|
||||
mock_video_class.find_or_create.return_value = None
|
||||
|
||||
post_data = {
|
||||
"ItemType": "Video",
|
||||
"Name": "Test Video",
|
||||
"Provider_imdb": "",
|
||||
"PlaybackPosition": "00:05:00",
|
||||
"NotificationType": "PlaybackProgress",
|
||||
"UtcTimestamp": "2024-01-15T10:30:00Z",
|
||||
}
|
||||
|
||||
result = jellyfin_scrobble_media(post_data, 1)
|
||||
|
||||
mock_video_class.find_or_create.assert_called_once_with(None)
|
||||
|
||||
|
||||
def test_jellyfin_scrobble_media_ignores_progress_with_zero_position():
|
||||
|
||||
post_data = {
|
||||
|
||||
@ -6,7 +6,5 @@ from vrobbler.apps.scrobbles.utils import timestamp_user_tz_to_utc
|
||||
|
||||
|
||||
def test_timestamp_user_tz_to_utc():
|
||||
timestamp = timestamp_user_tz_to_utc(
|
||||
1685561082, pytz.timezone("US/Eastern")
|
||||
)
|
||||
timestamp = timestamp_user_tz_to_utc(1685561082, pytz.timezone("US/Eastern"))
|
||||
assert timestamp == datetime(2023, 5, 31, 23, 24, 42, tzinfo=pytz.utc)
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import patch, MagicMock
|
||||
from django.utils import timezone
|
||||
from django.contrib.auth import get_user_model
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import time_machine
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.urls import reverse
|
||||
from music.models import Track, Artist, Album
|
||||
from django.utils import timezone
|
||||
from music.models import Album, Artist, Track
|
||||
from podcasts.models import PodcastEpisode
|
||||
from scrobbles.models import Scrobble
|
||||
from tasks.models import Task
|
||||
@ -32,7 +32,9 @@ def test_get_not_allowed_from_jellyfin(client, valid_auth_token):
|
||||
def test_bad_mopidy_request_data(client, valid_auth_token):
|
||||
url = reverse("scrobbles:mopidy-webhook")
|
||||
headers = {"Authorization": f"Token {valid_auth_token}"}
|
||||
response = client.post(url, headers)
|
||||
response = client.post(
|
||||
url, "not valid json", content_type="application/json", headers=headers
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert (
|
||||
response.data["detail"]
|
||||
@ -44,7 +46,9 @@ def test_bad_mopidy_request_data(client, valid_auth_token):
|
||||
def test_bad_jellyfin_request_data(client, valid_auth_token):
|
||||
url = reverse("scrobbles:jellyfin-webhook")
|
||||
headers = {"Authorization": f"Token {valid_auth_token}"}
|
||||
response = client.post(url, headers)
|
||||
response = client.post(
|
||||
url, "not valid json", content_type="application/json", headers=headers
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert (
|
||||
response.data["detail"]
|
||||
@ -139,9 +143,10 @@ def test_mopidy_track_webhook_creates_track_and_scrobble(
|
||||
album = Album.objects.create(name="Sublime", album_artist=artist)
|
||||
track = Track.objects.create(
|
||||
title="Same in the End",
|
||||
artist=artist,
|
||||
artist_fk=artist,
|
||||
base_run_time_seconds=60,
|
||||
)
|
||||
track.artists.add(artist)
|
||||
track.albums.add(album)
|
||||
|
||||
mock_artist_fc.return_value = artist
|
||||
@ -177,9 +182,10 @@ def test_jellyfin_track_webhook_creates_track_and_scrobble(
|
||||
album = Album.objects.create(name="Emotion", album_artist=artist)
|
||||
track = Track.objects.create(
|
||||
title="Emotion",
|
||||
artist=artist,
|
||||
artist_fk=artist,
|
||||
base_run_time_seconds=60,
|
||||
)
|
||||
track.artists.add(artist)
|
||||
track.albums.add(album)
|
||||
|
||||
mock_artist_fc.return_value = artist
|
||||
@ -221,9 +227,10 @@ def test_mopidy_track_webhook_stores_raw_data(
|
||||
album = Album.objects.create(name="Sublime", album_artist=artist)
|
||||
track = Track.objects.create(
|
||||
title="Same in the End",
|
||||
artist=artist,
|
||||
artist_fk=artist,
|
||||
base_run_time_seconds=60,
|
||||
)
|
||||
track.artists.add(artist)
|
||||
track.albums.add(album)
|
||||
|
||||
mock_artist_fc.return_value = artist
|
||||
@ -261,10 +268,11 @@ def test_mopidy_track_webhook_stores_album_id(
|
||||
album = Album.objects.create(name="Sublime", album_artist=artist)
|
||||
track = Track.objects.create(
|
||||
title="Same in the End",
|
||||
artist=artist,
|
||||
artist_fk=artist,
|
||||
album=album,
|
||||
base_run_time_seconds=60,
|
||||
)
|
||||
track.artists.add(artist)
|
||||
|
||||
mock_artist_fc.return_value = artist
|
||||
mock_track_fc.return_value = track
|
||||
@ -299,9 +307,10 @@ def test_jellyfin_track_webhook_stores_raw_data(
|
||||
album = Album.objects.create(name="Emotion", album_artist=artist)
|
||||
track = Track.objects.create(
|
||||
title="Emotion",
|
||||
artist=artist,
|
||||
artist_fk=artist,
|
||||
base_run_time_seconds=60,
|
||||
)
|
||||
track.artists.add(artist)
|
||||
track.albums.add(album)
|
||||
|
||||
mock_artist_fc.return_value = artist
|
||||
@ -343,11 +352,11 @@ def test_jellyfin_track_webhook_stores_album_id(
|
||||
album = Album.objects.create(name="Emotion", album_artist=artist)
|
||||
track = Track.objects.create(
|
||||
title="Emotion",
|
||||
artist=artist,
|
||||
artist_fk=artist,
|
||||
album=album,
|
||||
base_run_time_seconds=60,
|
||||
)
|
||||
track.albums.add(album)
|
||||
track.artists.add(artist)
|
||||
|
||||
mock_artist_fc.return_value = artist
|
||||
mock_track_fc.return_value = track
|
||||
@ -498,9 +507,7 @@ def test_scrobble_detail_view_with_notes_as_flat_list(client):
|
||||
user = get_user_model().objects.create_user(
|
||||
username="testuser", email="test@example.com", password="testpass"
|
||||
)
|
||||
task = Task.objects.create(
|
||||
title="Test Task", description="Test description"
|
||||
)
|
||||
task = Task.objects.create(title="Test Task", description="Test description")
|
||||
scrobble = Scrobble.objects.create(
|
||||
task=task,
|
||||
media_type="Task",
|
||||
@ -522,9 +529,7 @@ def test_scrobble_detail_view_with_notes_as_dict_timestamps(client):
|
||||
user = get_user_model().objects.create_user(
|
||||
username="testuser", email="test@example.com", password="testpass"
|
||||
)
|
||||
task = Task.objects.create(
|
||||
title="Test Task", description="Test description"
|
||||
)
|
||||
task = Task.objects.create(title="Test Task", description="Test description")
|
||||
scrobble = Scrobble.objects.create(
|
||||
task=task,
|
||||
media_type="Task",
|
||||
@ -552,9 +557,7 @@ def test_scrobble_detail_view_with_notes_and_labels(client):
|
||||
user = get_user_model().objects.create_user(
|
||||
username="testuser", email="test@example.com", password="testpass"
|
||||
)
|
||||
task = Task.objects.create(
|
||||
title="Test Task", description="Test description"
|
||||
)
|
||||
task = Task.objects.create(title="Test Task", description="Test description")
|
||||
scrobble = Scrobble.objects.create(
|
||||
task=task,
|
||||
media_type="Task",
|
||||
@ -580,9 +583,7 @@ def test_scrobble_detail_view_post_updates_log(client):
|
||||
user = get_user_model().objects.create_user(
|
||||
username="testuser", email="test@example.com", password="testpass"
|
||||
)
|
||||
task = Task.objects.create(
|
||||
title="Test Task", description="Test description"
|
||||
)
|
||||
task = Task.objects.create(title="Test Task", description="Test description")
|
||||
scrobble = Scrobble.objects.create(
|
||||
task=task,
|
||||
media_type="Task",
|
||||
@ -606,7 +607,8 @@ def test_scrobble_detail_view_post_updates_log(client):
|
||||
|
||||
scrobble.refresh_from_db()
|
||||
assert scrobble.log["description"] == "Updated description"
|
||||
assert scrobble.log["notes"] == ["Updated note"]
|
||||
assert isinstance(scrobble.log["notes"], dict)
|
||||
assert list(scrobble.log["notes"].values()) == ["Updated note"]
|
||||
|
||||
|
||||
@pytest.mark.skip("Need to refactor")
|
||||
@ -708,3 +710,32 @@ def test_scrobble_jellyfin_track_create_new(
|
||||
scrobble = Scrobble.objects.get(id=1)
|
||||
assert scrobble.media_obj.__class__ == Track
|
||||
assert scrobble.media_obj.title == "Emotion"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_not_allowed_from_gps(client, valid_auth_token):
|
||||
url = reverse("scrobbles:gps-webhook")
|
||||
headers = {"Authorization": f"Token {valid_auth_token}"}
|
||||
response = client.get(url, headers=headers)
|
||||
assert response.status_code == 405
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_gps_webhook_creates_location(client, valid_auth_token):
|
||||
url = reverse("scrobbles:gps-webhook")
|
||||
headers = {"Authorization": f"Token {valid_auth_token}"}
|
||||
gps_data = {
|
||||
"lat": "40.7128",
|
||||
"lon": "-74.0060",
|
||||
"alt": "10.5",
|
||||
"time": "2024-01-14T12:00:00Z",
|
||||
"prov": "gps",
|
||||
}
|
||||
response = client.post(
|
||||
url,
|
||||
gps_data,
|
||||
content_type="application/json",
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "scrobble_id" in response.data
|
||||
|
||||
127
tests/tasks_tests/test_todoist.py
Normal file
127
tests/tasks_tests/test_todoist.py
Normal file
@ -0,0 +1,127 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_profile(db):
|
||||
user = User.objects.create_user(username="testuser", password="testpass")
|
||||
return user.profile
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestGenerateTodoistOauthUrl:
|
||||
def test_generates_url_with_state(self, user_profile):
|
||||
from tasks.todoist import generate_todoist_oauth_url
|
||||
|
||||
url = generate_todoist_oauth_url(user_profile.user_id)
|
||||
|
||||
user_profile.refresh_from_db()
|
||||
assert user_profile.todoist_state is not None
|
||||
assert len(user_profile.todoist_state) == 32
|
||||
assert url.startswith("https://todoist.com/oauth/authorize")
|
||||
assert user_profile.todoist_state in url
|
||||
|
||||
def test_updates_existing_state(self, user_profile):
|
||||
from tasks.todoist import generate_todoist_oauth_url
|
||||
|
||||
old_state = "oldstate12345678901234567890123"
|
||||
user_profile.todoist_state = old_state
|
||||
user_profile.save()
|
||||
|
||||
url = generate_todoist_oauth_url(user_profile.user_id)
|
||||
|
||||
user_profile.refresh_from_db()
|
||||
assert user_profile.todoist_state != old_state
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestGetTodoistAccessToken:
|
||||
def test_raises_when_profile_not_found(self):
|
||||
from tasks.todoist import get_todoist_access_token
|
||||
|
||||
with pytest.raises(Exception, match="Could not find profile"):
|
||||
get_todoist_access_token(user_id=999, state="anystate", code="anycode")
|
||||
|
||||
def test_raises_when_state_mismatch(self, user_profile):
|
||||
from tasks.todoist import get_todoist_access_token
|
||||
|
||||
user_profile.todoist_state = "correctstate1234567890123"
|
||||
user_profile.save()
|
||||
|
||||
with pytest.raises(Exception, match="state mismatch"):
|
||||
get_todoist_access_token(
|
||||
user_id=user_profile.user_id, state="wrongstate", code="anycode"
|
||||
)
|
||||
|
||||
@patch("tasks.todoist.requests.post")
|
||||
def test_exchanges_code_for_token(self, mock_post, user_profile):
|
||||
from tasks.todoist import get_todoist_access_token
|
||||
|
||||
user_profile.todoist_state = "correctstate1234567890123"
|
||||
user_profile.save()
|
||||
|
||||
mock_token_response = MagicMock()
|
||||
mock_token_response.status_code = 200
|
||||
mock_token_response.json.return_value = {"access_token": "test_access_token"}
|
||||
mock_post.return_value = mock_token_response
|
||||
|
||||
get_todoist_access_token(
|
||||
user_id=user_profile.user_id,
|
||||
state="correctstate1234567890123",
|
||||
code="testcode",
|
||||
)
|
||||
|
||||
user_profile.refresh_from_db()
|
||||
assert user_profile.todoist_auth_key == "test_access_token"
|
||||
assert user_profile.todoist_state is None
|
||||
|
||||
@patch("tasks.todoist.requests.post")
|
||||
def test_fetches_todoist_user_id(self, mock_post, user_profile):
|
||||
from tasks.todoist import get_todoist_access_token
|
||||
|
||||
user_profile.todoist_state = "correctstate1234567890123"
|
||||
user_profile.save()
|
||||
|
||||
mock_token_response = MagicMock()
|
||||
mock_token_response.status_code = 200
|
||||
mock_token_response.json.return_value = {"access_token": "test_access_token"}
|
||||
|
||||
mock_sync_response = MagicMock()
|
||||
mock_sync_response.status_code = 200
|
||||
mock_sync_response.json.return_value = {"user": {"id": "12345"}}
|
||||
|
||||
mock_post.side_effect = [mock_token_response, mock_sync_response]
|
||||
|
||||
get_todoist_access_token(
|
||||
user_id=user_profile.user_id,
|
||||
state="correctstate1234567890123",
|
||||
code="testcode",
|
||||
)
|
||||
|
||||
user_profile.refresh_from_db()
|
||||
assert user_profile.todoist_user_id == "12345"
|
||||
|
||||
@patch("tasks.todoist.requests.post")
|
||||
def test_handles_token_exchange_failure(self, mock_post, user_profile):
|
||||
from tasks.todoist import get_todoist_access_token
|
||||
|
||||
user_profile.todoist_state = "correctstate1234567890123"
|
||||
user_profile.save()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 400
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
get_todoist_access_token(
|
||||
user_id=user_profile.user_id,
|
||||
state="correctstate1234567890123",
|
||||
code="badcode",
|
||||
)
|
||||
|
||||
user_profile.refresh_from_db()
|
||||
assert user_profile.todoist_auth_key is None
|
||||
assert user_profile.todoist_state == "correctstate1234567890123"
|
||||
@ -1,8 +1,9 @@
|
||||
import os
|
||||
from unittest.mock import patch, MagicMock
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from vrobbler.context_processors import version_info
|
||||
|
||||
|
||||
@ -13,10 +14,9 @@ def mock_request():
|
||||
|
||||
class TestVersionInfo:
|
||||
def test_returns_version_and_commit(self, mock_request):
|
||||
"""Test that git commit is returned when _commit.py doesn't exist"""
|
||||
with (
|
||||
patch(
|
||||
"vrobbler.context_processors.get_version"
|
||||
) as mock_get_version,
|
||||
patch("vrobbler.context_processors.get_version") as mock_get_version,
|
||||
patch(
|
||||
"vrobbler.context_processors.subprocess.check_output"
|
||||
) as mock_check_output,
|
||||
@ -24,7 +24,18 @@ class TestVersionInfo:
|
||||
mock_get_version.return_value = "1.0.0"
|
||||
mock_check_output.return_value = b"abc1234"
|
||||
|
||||
result = version_info(mock_request)
|
||||
# Mock the import to raise ImportError so git is used
|
||||
import builtins
|
||||
|
||||
original_import = builtins.__import__
|
||||
|
||||
def mock_import(name, *args, **kwargs):
|
||||
if name == "vrobbler._commit":
|
||||
raise ImportError("No module named 'vrobbler._commit'")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
with patch("builtins.__import__", side_effect=mock_import):
|
||||
result = version_info(mock_request)
|
||||
|
||||
assert result["app_version"] == "1.0.0"
|
||||
assert result["git_commit"] == "abc1234"
|
||||
@ -32,9 +43,7 @@ class TestVersionInfo:
|
||||
def test_uses_env_commit_if_set(self, mock_request):
|
||||
with (
|
||||
patch.dict(os.environ, {"VROBBLER_COMMIT": "env_commit_hash"}),
|
||||
patch(
|
||||
"vrobbler.context_processors.get_version"
|
||||
) as mock_get_version,
|
||||
patch("vrobbler.context_processors.get_version") as mock_get_version,
|
||||
):
|
||||
mock_get_version.return_value = "1.0.0"
|
||||
|
||||
@ -42,11 +51,72 @@ class TestVersionInfo:
|
||||
|
||||
assert result["git_commit"] == "env_commit_hash"
|
||||
|
||||
def test_uses_commit_from_module_when_available(self, mock_request):
|
||||
"""Test that commit from _commit.py module is used when available"""
|
||||
with (patch("vrobbler.context_processors.get_version") as mock_get_version,):
|
||||
mock_get_version.return_value = "1.0.0"
|
||||
|
||||
result = version_info(mock_request)
|
||||
|
||||
# Should use whatever value is in vrobbler/_commit.py
|
||||
# Could be "unknown" or an actual commit hash
|
||||
assert "git_commit" in result
|
||||
assert result["git_commit"] != ""
|
||||
|
||||
def test_uses_commit_from_file_when_module_unavailable(self, mock_request):
|
||||
"""Test that commit from /var/lib/vrobbler/commit.txt is used"""
|
||||
with (
|
||||
patch("vrobbler.context_processors.get_version") as mock_get_version,
|
||||
patch("pathlib.Path.exists", return_value=True),
|
||||
patch("pathlib.Path.read_text", return_value="file_commit_hash"),
|
||||
):
|
||||
mock_get_version.return_value = "1.0.0"
|
||||
|
||||
# Mock the import to raise ImportError
|
||||
import builtins
|
||||
|
||||
original_import = builtins.__import__
|
||||
|
||||
def mock_import(name, *args, **kwargs):
|
||||
if name == "vrobbler._commit":
|
||||
raise ImportError("No module named 'vrobbler._commit'")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
with patch("builtins.__import__", side_effect=mock_import):
|
||||
result = version_info(mock_request)
|
||||
|
||||
assert result["git_commit"] == "file_commit_hash"
|
||||
|
||||
def test_falls_back_to_git_when_file_unavailable(self, mock_request):
|
||||
"""Test fallback to git when _commit.py and file don't exist"""
|
||||
with (
|
||||
patch("vrobbler.context_processors.get_version") as mock_get_version,
|
||||
patch("pathlib.Path.exists", return_value=False),
|
||||
patch(
|
||||
"vrobbler.context_processors.subprocess.check_output"
|
||||
) as mock_check_output,
|
||||
):
|
||||
mock_get_version.return_value = "1.0.0"
|
||||
mock_check_output.return_value = b"git_commit_hash"
|
||||
|
||||
# Mock the import to raise ImportError
|
||||
import builtins
|
||||
|
||||
original_import = builtins.__import__
|
||||
|
||||
def mock_import(name, *args, **kwargs):
|
||||
if name == "vrobbler._commit":
|
||||
raise ImportError("No module named 'vrobbler._commit'")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
with patch("builtins.__import__", side_effect=mock_import):
|
||||
result = version_info(mock_request)
|
||||
|
||||
assert result["git_commit"] == "git_commit_hash"
|
||||
|
||||
def test_returns_unknown_when_version_fails(self, mock_request):
|
||||
with (
|
||||
patch(
|
||||
"vrobbler.context_processors.get_version"
|
||||
) as mock_get_version,
|
||||
patch("vrobbler.context_processors.get_version") as mock_get_version,
|
||||
patch(
|
||||
"vrobbler.context_processors.subprocess.check_output"
|
||||
) as mock_check_output,
|
||||
@ -62,9 +132,7 @@ class TestVersionInfo:
|
||||
import subprocess
|
||||
|
||||
with (
|
||||
patch(
|
||||
"vrobbler.context_processors.get_version"
|
||||
) as mock_get_version,
|
||||
patch("vrobbler.context_processors.get_version") as mock_get_version,
|
||||
patch(
|
||||
"vrobbler.context_processors.subprocess.check_output"
|
||||
) as mock_check_output,
|
||||
@ -80,9 +148,7 @@ class TestVersionInfo:
|
||||
import subprocess
|
||||
|
||||
with (
|
||||
patch(
|
||||
"vrobbler.context_processors.get_version"
|
||||
) as mock_get_version,
|
||||
patch("vrobbler.context_processors.get_version") as mock_get_version,
|
||||
patch(
|
||||
"vrobbler.context_processors.subprocess.check_output"
|
||||
) as mock_check_output,
|
||||
|
||||
0
tests/trails_tests/__init__.py
Normal file
0
tests/trails_tests/__init__.py
Normal file
281
tests/trails_tests/test_gpx_importer.py
Normal file
281
tests/trails_tests/test_gpx_importer.py
Normal file
@ -0,0 +1,281 @@
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.files import File
|
||||
|
||||
from locations.models import GeoLocation
|
||||
from scrobbles.importers.trail_gpx import (
|
||||
compute_trail_stats,
|
||||
find_route_waypoint,
|
||||
import_trail_gpx,
|
||||
parse_trackpoints,
|
||||
)
|
||||
from scrobbles.models import Scrobble, TrailGPXImport
|
||||
from trails.models import Trail, TrailLogData
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
SAMPLE_GPX = os.path.join(
|
||||
os.path.dirname(__file__), "..", "..", "data", "sample_trail.gpx"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user(db):
|
||||
return User.objects.create(email="trailblazer@example.com")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_gpx_path():
|
||||
return SAMPLE_GPX
|
||||
|
||||
|
||||
class TestParseTrackpoints:
|
||||
def test_parses_gpx(self, sample_gpx_path):
|
||||
result = parse_trackpoints(sample_gpx_path)
|
||||
points = result["points"]
|
||||
assert len(points) == 837
|
||||
assert result["name"] == "Morning Run ⛅"
|
||||
assert result["description"] == "Run"
|
||||
lat, lon, ele, t = points[0]
|
||||
assert round(lat, 6) == 34.190598
|
||||
assert round(lon, 6) == -118.844015
|
||||
assert ele == 305.3
|
||||
assert t is not None
|
||||
|
||||
def test_first_and_last_times(self, sample_gpx_path):
|
||||
result = parse_trackpoints(sample_gpx_path)
|
||||
points = result["points"]
|
||||
first_time = points[0][3]
|
||||
last_time = points[-1][3]
|
||||
duration = (last_time - first_time).total_seconds()
|
||||
assert duration == pytest.approx(3770, abs=5)
|
||||
|
||||
def test_gpx_extra_metadata(self, sample_gpx_path):
|
||||
result = parse_trackpoints(sample_gpx_path)
|
||||
extra = result["extra"]
|
||||
assert extra["avg_heartrate"] == 159
|
||||
assert extra["max_heartrate"] == 183
|
||||
assert extra["avg_speed_kmh"] == pytest.approx(9.82, abs=0.1)
|
||||
assert extra["activity_type"] == "Run"
|
||||
assert extra["moving_time_seconds"] == 3008
|
||||
assert extra["total_elevation_gain_m"] == 246.4
|
||||
|
||||
|
||||
class TestImportTrailGPX:
|
||||
def test_creates_trail(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
assert Trail.objects.filter(title="Morning Run ⛅").exists()
|
||||
|
||||
def test_creates_geolocation(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
assert GeoLocation.objects.filter(lat=34.190598, lon=-118.844015).exists()
|
||||
|
||||
def test_sets_trailhead(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
trail = Trail.objects.filter(title="Morning Run ⛅").first()
|
||||
assert trail.trailhead_location is not None
|
||||
assert round(trail.trailhead_location.lat, 6) == 34.190598
|
||||
|
||||
def test_creates_scrobble(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
assert Scrobble.objects.filter(source="GPX Import").count() == 1
|
||||
|
||||
def test_scrobble_timestamps(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="GPX Import").first()
|
||||
assert scrobble.timestamp.isoformat().startswith("2022-06-05T13:55:09")
|
||||
assert scrobble.stop_timestamp.isoformat().startswith("2022-06-05T14:57:59")
|
||||
assert scrobble.media_type == Scrobble.MediaType.TRAIL
|
||||
|
||||
def test_scrobble_has_trail_fk(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="GPX Import").first()
|
||||
assert scrobble.trail is not None
|
||||
assert scrobble.trail.title == "Morning Run ⛅"
|
||||
|
||||
def test_scrobble_has_gpx_file(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="GPX Import").first()
|
||||
assert scrobble.gpx_file
|
||||
assert scrobble.gpx_file.name.endswith(".gpx")
|
||||
|
||||
def test_lookup_existing_trail_by_trailhead(self, user, sample_gpx_path):
|
||||
geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015)
|
||||
trail = Trail.objects.create(title="Existing Trail", trailhead_location=geo)
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="GPX Import").first()
|
||||
assert scrobble.trail.id == trail.id
|
||||
|
||||
def test_dedup(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
assert Scrobble.objects.filter(source="GPX Import").count() == 1
|
||||
|
||||
def test_scrobble_log_has_stats(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="GPX Import").first()
|
||||
log = scrobble.log
|
||||
assert log["distance_km"] == pytest.approx(8.2, abs=0.2)
|
||||
assert log["elevation_gain_m"] == pytest.approx(260, abs=20)
|
||||
assert log["moving_time_seconds"] == pytest.approx(3770, abs=10)
|
||||
assert log["avg_speed_kmh"] == pytest.approx(7.8, abs=0.5)
|
||||
assert log["description"] == "Run"
|
||||
|
||||
def test_scrobble_playback_position(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="GPX Import").first()
|
||||
assert scrobble.playback_position_seconds == pytest.approx(3770, abs=5)
|
||||
|
||||
def test_scrobble_has_timezone(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="GPX Import").first()
|
||||
assert scrobble.timezone is not None
|
||||
assert isinstance(scrobble.timezone, str)
|
||||
|
||||
def test_scrobble_log_extra_metadata(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="GPX Import").first()
|
||||
log = scrobble.log
|
||||
assert log["avg_heartrate"] == 159
|
||||
assert log["max_heartrate"] == 183
|
||||
assert log["activity_type"] == "Run"
|
||||
|
||||
def test_scrobble_log_no_calories_in_gpx(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="GPX Import").first()
|
||||
assert scrobble.log.get("calories") is None
|
||||
|
||||
|
||||
class TestComputeTrailStats:
|
||||
def test_computes_distance_and_elevation(self, sample_gpx_path):
|
||||
result = parse_trackpoints(sample_gpx_path)
|
||||
stats = compute_trail_stats(result["points"])
|
||||
assert stats["distance_km"] == pytest.approx(8.2, abs=0.2)
|
||||
assert stats["elevation_gain_m"] == pytest.approx(260, abs=20)
|
||||
assert stats["moving_time_seconds"] == pytest.approx(3770, abs=10)
|
||||
assert stats["avg_speed_kmh"] == pytest.approx(7.8, abs=0.5)
|
||||
|
||||
|
||||
class TestTrailGPXImportModel:
|
||||
def test_create_import_model(self, db, user, sample_gpx_path):
|
||||
imp = TrailGPXImport.objects.create(
|
||||
user=user,
|
||||
original_filename="test_trail.gpx",
|
||||
)
|
||||
assert imp.uuid is not None
|
||||
assert imp.import_type == "Trail GPX"
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_process_via_model(self, user, sample_gpx_path):
|
||||
imp = TrailGPXImport.objects.create(
|
||||
user=user,
|
||||
original_filename="Morning Run.gpx",
|
||||
)
|
||||
with open(sample_gpx_path, "rb") as f:
|
||||
imp.gpx_file.save("Morning Run.gpx", File(f), save=True)
|
||||
imp.process()
|
||||
imp.refresh_from_db()
|
||||
assert imp.process_count == 1
|
||||
assert imp.processed_finished is not None
|
||||
|
||||
|
||||
class TestFindRouteWaypoint:
|
||||
def test_returns_halfway_point(self, sample_gpx_path):
|
||||
result = parse_trackpoints(sample_gpx_path)
|
||||
pt = find_route_waypoint(result["points"])
|
||||
assert pt is not None
|
||||
lat, lon = pt
|
||||
assert lat == pytest.approx(34.177853, abs=0.001)
|
||||
assert lon == pytest.approx(-118.829944, abs=0.001)
|
||||
|
||||
def test_returns_last_point_for_short_track(self):
|
||||
points = [(34.0, -118.0, None, None), (34.001, -118.001, None, None)]
|
||||
pt = find_route_waypoint(points)
|
||||
assert pt == (34.001, -118.001)
|
||||
|
||||
def test_returns_none_for_empty_points(self):
|
||||
assert find_route_waypoint([]) is None
|
||||
|
||||
|
||||
class TestFindByTrailhead:
|
||||
def test_exact_match(self, db):
|
||||
geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015)
|
||||
trail = Trail.objects.create(title="Test Trail", trailhead_location=geo)
|
||||
found = Trail.find_by_trailhead(34.190598, -118.844015)
|
||||
assert found == trail
|
||||
|
||||
def test_within_tolerance(self, db):
|
||||
geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015)
|
||||
trail = Trail.objects.create(title="Nearby Trail", trailhead_location=geo)
|
||||
found = Trail.find_by_trailhead(34.191000, -118.844000, tolerance_m=100)
|
||||
assert found == trail
|
||||
|
||||
def test_beyond_tolerance(self, db):
|
||||
geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015)
|
||||
Trail.objects.create(title="Far Trail", trailhead_location=geo)
|
||||
found = Trail.find_by_trailhead(34.200000, -118.850000, tolerance_m=50)
|
||||
assert found is None
|
||||
|
||||
def test_no_trailhead_returns_none(self, db):
|
||||
Trail.objects.create(title="No Location")
|
||||
found = Trail.find_by_trailhead(34.190598, -118.844015)
|
||||
assert found is None
|
||||
|
||||
def test_same_trailhead_same_route_matches(self, db):
|
||||
geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015)
|
||||
trail = Trail.objects.create(
|
||||
title="Same Route Trail",
|
||||
trailhead_location=geo,
|
||||
route_lat=34.192167,
|
||||
route_lon=-118.843143,
|
||||
)
|
||||
found = Trail.find_by_trailhead(
|
||||
34.190598, -118.844015,
|
||||
route_lat=34.192167, route_lon=-118.843143,
|
||||
tolerance_m=100,
|
||||
)
|
||||
assert found == trail
|
||||
|
||||
def test_same_trailhead_different_route_does_not_match(self, db):
|
||||
geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015)
|
||||
Trail.objects.create(
|
||||
title="Different Route Trail",
|
||||
trailhead_location=geo,
|
||||
route_lat=34.200000,
|
||||
route_lon=-118.850000,
|
||||
)
|
||||
found = Trail.find_by_trailhead(
|
||||
34.190598, -118.844015,
|
||||
route_lat=34.192167, route_lon=-118.843143,
|
||||
tolerance_m=100,
|
||||
)
|
||||
assert found is None
|
||||
|
||||
def test_legacy_trail_without_route_still_matches(self, db):
|
||||
geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015)
|
||||
trail = Trail.objects.create(
|
||||
title="Legacy Trail",
|
||||
trailhead_location=geo,
|
||||
)
|
||||
found = Trail.find_by_trailhead(
|
||||
34.190598, -118.844015,
|
||||
route_lat=34.192167, route_lon=-118.843143,
|
||||
tolerance_m=100,
|
||||
)
|
||||
assert found == trail
|
||||
|
||||
|
||||
class TestFindOrCreate:
|
||||
def test_find_existing(self, db):
|
||||
Trail.objects.create(title="Existing Trail")
|
||||
trail = Trail.find_or_create("Existing Trail")
|
||||
assert trail.title == "Existing Trail"
|
||||
|
||||
def test_create_new(self, db):
|
||||
trail = Trail.find_or_create("New Trail")
|
||||
assert trail.title == "New Trail"
|
||||
assert Trail.objects.count() == 1
|
||||
@ -48,12 +48,8 @@ class TestVideoAPI:
|
||||
assert response.status_code == 200
|
||||
assert response.data["title"] == "Test Video"
|
||||
|
||||
def test_filter_videos_by_channel(
|
||||
self, client, auth_headers, channel, video
|
||||
):
|
||||
response = client.get(
|
||||
f"/api/v1/videos/?channel={channel.id}", **auth_headers
|
||||
)
|
||||
def test_filter_videos_by_channel(self, client, auth_headers, channel, video):
|
||||
response = client.get(f"/api/v1/videos/?channel={channel.id}", **auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert len(response.data["results"]) == 1
|
||||
assert response.data["results"][0]["channel"] == channel.id
|
||||
@ -68,9 +64,7 @@ class TestChannelAPI:
|
||||
assert response.data["results"][0]["name"] == "Test Channel"
|
||||
|
||||
def test_get_channel(self, client, auth_headers, channel):
|
||||
response = client.get(
|
||||
f"/api/v1/channels/{channel.id}/", **auth_headers
|
||||
)
|
||||
response = client.get(f"/api/v1/channels/{channel.id}/", **auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.data["name"] == "Test Channel"
|
||||
|
||||
|
||||
@ -1,14 +0,0 @@
|
||||
import pytest
|
||||
from videos.sources.imdb import lookup_video_from_imdb
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Should not hit IMDB api in CI")
|
||||
def test_lookup_imdb_without_tt():
|
||||
metadata = lookup_video_from_imdb("8946378")
|
||||
assert not metadata.imdb_id
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Should not hit IMDB api in CI")
|
||||
def test_lookup_imdb_with_tt():
|
||||
metadata = lookup_video_from_imdb("tt8946378")
|
||||
assert metadata.title == "Knives Out"
|
||||
|
||||
17
tests/videos_tests/test_video_find_or_create.py
Normal file
17
tests/videos_tests/test_video_find_or_create.py
Normal file
@ -0,0 +1,17 @@
|
||||
import pytest
|
||||
from videos.models import Video
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestVideoFindOrCreate:
|
||||
def test_find_or_create_with_none_returns_none(self):
|
||||
result = Video.find_or_create(None)
|
||||
assert result is None
|
||||
|
||||
def test_find_or_create_with_empty_string_returns_none(self):
|
||||
result = Video.find_or_create("")
|
||||
assert result is None
|
||||
|
||||
def test_find_or_create_with_invalid_id_returns_none(self):
|
||||
result = Video.find_or_create("invalid-id")
|
||||
assert result is None
|
||||
@ -2,4 +2,5 @@
|
||||
# Django starts so that shared_task will use this app.
|
||||
from .celery import app as celery_app
|
||||
|
||||
__all__ = ("celery_app",)
|
||||
__version__ = "42.0"
|
||||
__all__ = ("celery_app", "__version__")
|
||||
|
||||
1
vrobbler/_commit.py
Normal file
1
vrobbler/_commit.py
Normal file
@ -0,0 +1 @@
|
||||
commit = "unknown"
|
||||
26
vrobbler/apps/beers/migrations/0007_beer_tags.py
Normal file
26
vrobbler/apps/beers/migrations/0007_beer_tags.py
Normal file
@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.2.29 on 2026-03-26 21:25
|
||||
|
||||
from django.db import migrations
|
||||
import taggit.managers
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("taggit", "0004_alter_taggeditem_content_type_alter_taggeditem_tag"),
|
||||
("beers", "0006_remove_beer_run_time_seconds_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="beer",
|
||||
name="tags",
|
||||
field=taggit.managers.TaggableManager(
|
||||
blank=True,
|
||||
help_text="A comma-separated list of tags.",
|
||||
through="taggit.TaggedItem",
|
||||
to="taggit.Tag",
|
||||
verbose_name="Tags",
|
||||
),
|
||||
),
|
||||
]
|
||||
26
vrobbler/apps/beers/migrations/0008_alter_beer_genre.py
Normal file
26
vrobbler/apps/beers/migrations/0008_alter_beer_genre.py
Normal file
@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.2.29 on 2026-05-01 15:49
|
||||
|
||||
from django.db import migrations
|
||||
import taggit.managers
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("scrobbles", "0075_add_channel_scrobble"),
|
||||
("beers", "0007_beer_tags"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="beer",
|
||||
name="genre",
|
||||
field=taggit.managers.TaggableManager(
|
||||
blank=True,
|
||||
help_text="A comma-separated list of tags.",
|
||||
through="scrobbles.ObjectWithGenres",
|
||||
to="scrobbles.Genre",
|
||||
verbose_name="Genre",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -67,9 +67,7 @@ class Beer(ScrobblableMixin):
|
||||
)
|
||||
untappd_id = models.CharField(max_length=255, **BNULL)
|
||||
untappd_rating = models.FloatField(**BNULL)
|
||||
producer = models.ForeignKey(
|
||||
BeerProducer, on_delete=models.DO_NOTHING, **BNULL
|
||||
)
|
||||
producer = models.ForeignKey(BeerProducer, on_delete=models.DO_NOTHING, **BNULL)
|
||||
|
||||
def get_absolute_url(self) -> str:
|
||||
return reverse("beers:beer_detail", kwargs={"slug": self.uuid})
|
||||
@ -130,9 +128,7 @@ class Beer(ScrobblableMixin):
|
||||
)
|
||||
style_ids.append(style_inst.id)
|
||||
|
||||
producer, _created = BeerProducer.objects.get_or_create(
|
||||
**producer_dict
|
||||
)
|
||||
producer, _created = BeerProducer.objects.get_or_create(**producer_dict)
|
||||
beer_dict["producer_id"] = producer.id
|
||||
beer = Beer.objects.create(**beer_dict)
|
||||
for style_id in style_ids:
|
||||
|
||||
@ -85,9 +85,7 @@ def get_ibu_from_soup(soup) -> Optional[int]:
|
||||
def get_rating_from_soup(soup) -> str:
|
||||
rating = ""
|
||||
try:
|
||||
rating = float(
|
||||
soup.find(class_="num").get_text().strip("(").strip(")")
|
||||
)
|
||||
rating = float(soup.find(class_="num").get_text().strip("(").strip(")"))
|
||||
except AttributeError:
|
||||
rating = None
|
||||
except ValueError:
|
||||
@ -124,9 +122,7 @@ def get_beer_from_untappd_id(untappd_id: str) -> dict:
|
||||
beer_dict = {"untappd_id": untappd_id}
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warn(
|
||||
"Bad response from untappd.com", extra={"response": response}
|
||||
)
|
||||
logger.warn("Bad response from untappd.com", extra={"response": response})
|
||||
return beer_dict
|
||||
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
|
||||
0
vrobbler/apps/birds/__init__.py
Normal file
0
vrobbler/apps/birds/__init__.py
Normal file
30
vrobbler/apps/birds/admin.py
Normal file
30
vrobbler/apps/birds/admin.py
Normal file
@ -0,0 +1,30 @@
|
||||
from birds.models import Bird, BirdingCSVImport, BirdingLocation
|
||||
from django.contrib import admin
|
||||
from scrobbles.admin import ScrobbleInline
|
||||
|
||||
|
||||
@admin.register(Bird)
|
||||
class BirdAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = ("uuid", "common_name", "scientific_name", "ebird_code")
|
||||
ordering = ("-created",)
|
||||
search_fields = ("common_name", "scientific_name")
|
||||
|
||||
|
||||
@admin.register(BirdingLocation)
|
||||
class BirdingLocationAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = ("uuid", "title")
|
||||
ordering = ("-created",)
|
||||
raw_id_fields = ("geo_location",)
|
||||
search_fields = ("title",)
|
||||
inlines = [
|
||||
ScrobbleInline,
|
||||
]
|
||||
|
||||
|
||||
@admin.register(BirdingCSVImport)
|
||||
class BirdingCSVImportAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = ("uuid", "process_count", "processed_finished", "processing_started")
|
||||
ordering = ("-created",)
|
||||
0
vrobbler/apps/birds/api/__init__.py
Normal file
0
vrobbler/apps/birds/api/__init__.py
Normal file
14
vrobbler/apps/birds/api/serializers.py
Normal file
14
vrobbler/apps/birds/api/serializers.py
Normal file
@ -0,0 +1,14 @@
|
||||
from rest_framework import serializers
|
||||
from birds.models import Bird, BirdingLocation
|
||||
|
||||
|
||||
class BirdSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = Bird
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class BirdingLocationSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = BirdingLocation
|
||||
fields = "__all__"
|
||||
15
vrobbler/apps/birds/api/views.py
Normal file
15
vrobbler/apps/birds/api/views.py
Normal file
@ -0,0 +1,15 @@
|
||||
from rest_framework import permissions, viewsets
|
||||
from birds.api import serializers
|
||||
from birds import models
|
||||
|
||||
|
||||
class BirdViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.Bird.objects.all().order_by("-created")
|
||||
serializer_class = serializers.BirdSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
|
||||
class BirdingLocationViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.BirdingLocation.objects.all().order_by("-created")
|
||||
serializer_class = serializers.BirdingLocationSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
5
vrobbler/apps/birds/apps.py
Normal file
5
vrobbler/apps/birds/apps.py
Normal file
@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BirdsConfig(AppConfig):
|
||||
name = "birds"
|
||||
85
vrobbler/apps/birds/forms.py
Normal file
85
vrobbler/apps/birds/forms.py
Normal file
@ -0,0 +1,85 @@
|
||||
import json
|
||||
|
||||
from birds.models import Bird, BirdSightingEntry
|
||||
from django import forms
|
||||
|
||||
|
||||
class BirdSightingsWidget(forms.Widget):
|
||||
template_name = "birds/bird_sightings_widget.html"
|
||||
|
||||
class Media:
|
||||
js = ("birds/bird_sightings.js",)
|
||||
|
||||
def value_from_datadict(self, data, files, name):
|
||||
bird_ids = data.getlist(f"{name}_bird_id")
|
||||
quantities = data.getlist(f"{name}_quantity")
|
||||
notes = data.getlist(f"{name}_sighting_notes")
|
||||
return {
|
||||
"bird_id": bird_ids,
|
||||
"quantity": quantities,
|
||||
"sighting_notes": notes,
|
||||
}
|
||||
|
||||
def get_context(self, name, value, attrs):
|
||||
context = super().get_context(name, value, attrs)
|
||||
sightings = []
|
||||
if value:
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
value = []
|
||||
for item in (value or []):
|
||||
if isinstance(item, dict):
|
||||
sightings.append(item)
|
||||
elif isinstance(item, BirdSightingEntry):
|
||||
sightings.append(item.asdict)
|
||||
context["widget"]["sightings"] = sightings
|
||||
context["widget"]["birds"] = Bird.objects.all().order_by("common_name")
|
||||
return context
|
||||
|
||||
|
||||
class BirdSightingsField(forms.Field):
|
||||
widget = BirdSightingsWidget
|
||||
|
||||
def clean(self, value):
|
||||
if not value:
|
||||
return None
|
||||
result = []
|
||||
bird_ids = value.get("bird_id", []) if isinstance(value, dict) else []
|
||||
quantities = value.get("quantity", []) if isinstance(value, dict) else []
|
||||
notes_list = (
|
||||
value.get("sighting_notes", []) if isinstance(value, dict) else []
|
||||
)
|
||||
|
||||
if isinstance(bird_ids, list):
|
||||
for i, bird_id in enumerate(bird_ids):
|
||||
if not bird_id:
|
||||
continue
|
||||
try:
|
||||
bird_id = int(bird_id)
|
||||
quantity = int(quantities[i]) if i < len(quantities) else 1
|
||||
except (ValueError, TypeError, IndexError):
|
||||
continue
|
||||
note = notes_list[i] if i < len(notes_list) else ""
|
||||
entry = BirdSightingEntry(
|
||||
bird_id=bird_id,
|
||||
quantity=quantity,
|
||||
sighting_notes=note or None,
|
||||
)
|
||||
result.append(entry.asdict)
|
||||
elif bird_ids:
|
||||
try:
|
||||
bird_id = int(bird_ids)
|
||||
quantity = int(quantities) if quantities else 1
|
||||
except (ValueError, TypeError):
|
||||
raise forms.ValidationError("Invalid bird sighting data")
|
||||
note = notes_list if notes_list else ""
|
||||
entry = BirdSightingEntry(
|
||||
bird_id=bird_id,
|
||||
quantity=quantity,
|
||||
sighting_notes=note or None,
|
||||
)
|
||||
result.append(entry.asdict)
|
||||
|
||||
return result if result else None
|
||||
189
vrobbler/apps/birds/importer.py
Normal file
189
vrobbler/apps/birds/importer.py
Normal file
@ -0,0 +1,189 @@
|
||||
import csv
|
||||
import logging
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from birds.models import Bird, BirdSightingEntry, BirdSightingLogData, BirdingLocation
|
||||
from scrobbles.models import Scrobble
|
||||
from scrobbles.notifications import ScrobbleNtfyNotification
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
User = get_user_model()
|
||||
|
||||
LOCATION_COORDS_RE = re.compile(r"\(([\d\.\-]+),\s*([\d\.\-]+)\)")
|
||||
DURATION_RE = re.compile(r"(\d+)\s*minute")
|
||||
|
||||
|
||||
def parse_duration(duration_str):
|
||||
if not duration_str:
|
||||
return None
|
||||
match = DURATION_RE.search(duration_str)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def parse_coords(location_str):
|
||||
match = LOCATION_COORDS_RE.search(location_str)
|
||||
if match:
|
||||
return float(match.group(1)), float(match.group(2))
|
||||
return None, None
|
||||
|
||||
|
||||
def parse_timestamp(date_str, time_str):
|
||||
try:
|
||||
dt = datetime.strptime(f"{date_str} {time_str}", "%B %d, %Y %I:%M %p")
|
||||
return dt
|
||||
except (ValueError, TypeError):
|
||||
try:
|
||||
dt = datetime.strptime(date_str, "%B %d, %Y")
|
||||
return dt
|
||||
except (ValueError, TypeError):
|
||||
logger.warning(f"Could not parse date/time: {date_str} {time_str}")
|
||||
return None
|
||||
|
||||
|
||||
def parse_bool(value):
|
||||
if not value:
|
||||
return None
|
||||
return value.strip().lower() in ("true", "yes", "1")
|
||||
|
||||
|
||||
def parse_int(value):
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return int(value.strip())
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def import_birding_csv(file_path, user_id):
|
||||
user = User.objects.get(id=user_id)
|
||||
new_scrobbles = []
|
||||
|
||||
with open(file_path, newline="", encoding="utf-8-sig") as f:
|
||||
reader = csv.DictReader(f)
|
||||
rows = list(reader)
|
||||
|
||||
groups = defaultdict(list)
|
||||
for row in rows:
|
||||
key = (
|
||||
row.get("Location", "").strip(),
|
||||
row.get("Observation Date", "").strip(),
|
||||
row.get("Start Time", "").strip(),
|
||||
)
|
||||
groups[key].append(row)
|
||||
|
||||
for (location_str, date_str, time_str), sighting_rows in groups.items():
|
||||
if not location_str:
|
||||
logger.warning("Skipping rows with no location")
|
||||
continue
|
||||
|
||||
timestamp = parse_timestamp(date_str, time_str)
|
||||
if not timestamp:
|
||||
continue
|
||||
|
||||
timestamp = user.profile.get_timestamp_with_tz(timestamp)
|
||||
|
||||
location_title = (
|
||||
LOCATION_COORDS_RE.sub("", location_str).strip().rstrip(",").strip()
|
||||
)
|
||||
if not location_title:
|
||||
location_title = location_str
|
||||
|
||||
location = BirdingLocation.find_or_create(location_title)
|
||||
lat, lon = parse_coords(location_str)
|
||||
if lat and lon and not location.geo_location:
|
||||
from locations.models import GeoLocation
|
||||
|
||||
geo, _ = GeoLocation.objects.get_or_create(
|
||||
lat=round(lat, 6),
|
||||
lon=round(lon, 6),
|
||||
defaults={"altitude": None},
|
||||
)
|
||||
location.geo_location = geo
|
||||
location.save(update_fields=["geo_location"])
|
||||
|
||||
first_row = sighting_rows[0]
|
||||
|
||||
birds_data = []
|
||||
for row in sighting_rows:
|
||||
species = row.get("Species", "").strip()
|
||||
if not species:
|
||||
continue
|
||||
count = parse_int(row.get("Count")) or 1
|
||||
details = row.get("Details", "").strip()
|
||||
|
||||
bird = Bird.find_or_create(species)
|
||||
entry = BirdSightingEntry(
|
||||
bird_id=bird.id, quantity=count, sighting_notes=details or None
|
||||
)
|
||||
birds_data.append(entry.asdict)
|
||||
|
||||
duration_minutes = parse_duration(first_row.get("Duration", ""))
|
||||
logdata = BirdSightingLogData(
|
||||
birds=birds_data,
|
||||
duration_minutes=duration_minutes,
|
||||
observation_type=first_row.get("Observation Type", "").strip() or None,
|
||||
distance=first_row.get("Distance", "").strip() or None,
|
||||
area=first_row.get("Area", "").strip() or None,
|
||||
party_size=parse_int(first_row.get("Party Size")),
|
||||
complete_checklist=parse_bool(first_row.get("Complete Checklist")),
|
||||
)
|
||||
|
||||
log_dict = logdata.asdict
|
||||
|
||||
weather_loc = location.geo_location
|
||||
if not weather_loc:
|
||||
last_loc = (
|
||||
Scrobble.objects.filter(
|
||||
user=user,
|
||||
media_type=Scrobble.MediaType.GEO_LOCATION,
|
||||
geo_location__isnull=False,
|
||||
)
|
||||
.order_by("-timestamp")
|
||||
.first()
|
||||
)
|
||||
if last_loc:
|
||||
weather_loc = last_loc.geo_location
|
||||
if weather_loc:
|
||||
weather = weather_loc.current_weather
|
||||
if weather:
|
||||
log_dict["weather"] = weather["description"]
|
||||
log_dict["temperature"] = weather["temp"]
|
||||
|
||||
stop_timestamp = timestamp + timedelta(minutes=duration_minutes) if duration_minutes else None
|
||||
|
||||
tz = getattr(timestamp.tzinfo, "name", None)
|
||||
|
||||
scrobble = Scrobble(
|
||||
user=user,
|
||||
timestamp=timestamp,
|
||||
timezone=tz,
|
||||
stop_timestamp=stop_timestamp,
|
||||
source="Birding CSV Import",
|
||||
birding_location=location,
|
||||
log=log_dict,
|
||||
played_to_completion=True,
|
||||
in_progress=False,
|
||||
media_type=Scrobble.MediaType.BIRDING_LOCATION,
|
||||
)
|
||||
existing = Scrobble.objects.filter(
|
||||
timestamp=timestamp,
|
||||
birding_location=location,
|
||||
user=user,
|
||||
).first()
|
||||
if existing:
|
||||
logger.debug(f"Skipping existing scrobble for {location}")
|
||||
continue
|
||||
new_scrobbles.append(scrobble)
|
||||
|
||||
created = Scrobble.objects.bulk_create(new_scrobbles)
|
||||
logger.info(f"Created {len(created)} birding scrobbles")
|
||||
for scrobble in created:
|
||||
ScrobbleNtfyNotification(scrobble).send()
|
||||
return created
|
||||
144
vrobbler/apps/birds/migrations/0001_initial.py
Normal file
144
vrobbler/apps/birds/migrations/0001_initial.py
Normal file
@ -0,0 +1,144 @@
|
||||
# Generated by Django 4.2.29 on 2026-05-15 15:05
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
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"),
|
||||
("locations", "0010_clean_start"),
|
||||
("scrobbles", "0075_add_channel_scrobble"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Bird",
|
||||
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
|
||||
),
|
||||
),
|
||||
("common_name", models.CharField(max_length=255)),
|
||||
(
|
||||
"scientific_name",
|
||||
models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
("description", models.TextField(blank=True, null=True)),
|
||||
(
|
||||
"ebird_code",
|
||||
models.CharField(
|
||||
blank=True, db_index=True, max_length=255, null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"photo",
|
||||
models.ImageField(blank=True, null=True, upload_to="birds/photos/"),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"get_latest_by": "modified",
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="BirdingLocation",
|
||||
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)),
|
||||
(
|
||||
"ebird_hotspot_id",
|
||||
models.CharField(blank=True, max_length=255, 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",
|
||||
),
|
||||
),
|
||||
(
|
||||
"geo_location",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to="locations.geolocation",
|
||||
),
|
||||
),
|
||||
(
|
||||
"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,
|
||||
},
|
||||
),
|
||||
]
|
||||
67
vrobbler/apps/birds/migrations/0002_birdingcsvimport.py
Normal file
67
vrobbler/apps/birds/migrations/0002_birdingcsvimport.py
Normal file
@ -0,0 +1,67 @@
|
||||
# Generated by Django 4.2.29 on 2026-05-15 15:41
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django_extensions.db.fields
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("birds", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="BirdingCSVImport",
|
||||
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)),
|
||||
(
|
||||
"csv_file",
|
||||
models.FileField(
|
||||
blank=True, null=True, upload_to="birding-csv-uploads/"
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Birding CSV Import",
|
||||
},
|
||||
),
|
||||
]
|
||||
0
vrobbler/apps/birds/migrations/__init__.py
Normal file
0
vrobbler/apps/birds/migrations/__init__.py
Normal file
302
vrobbler/apps/birds/models.py
Normal file
302
vrobbler/apps/birds/models.py
Normal file
@ -0,0 +1,302 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from functools import cached_property
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.files import File
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
from imagekit.models import ImageSpecField
|
||||
from imagekit.processors import ResizeToFit
|
||||
from locations.models import GeoLocation
|
||||
from scrobbles.dataclasses import BaseLogData, WithPeopleLogData
|
||||
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
BNULL = {"blank": True, "null": True}
|
||||
|
||||
|
||||
@dataclass
|
||||
class BirdSightingEntry(BaseLogData):
|
||||
bird_id: Optional[int] = None
|
||||
quantity: int = 1
|
||||
sighting_notes: Optional[str] = None
|
||||
|
||||
@property
|
||||
def bird(self) -> Optional["Bird"]:
|
||||
if not self.bird_id:
|
||||
return None
|
||||
return Bird.objects.filter(id=self.bird_id).first()
|
||||
|
||||
def __str__(self) -> str:
|
||||
name = self.bird.common_name if self.bird else "Unknown"
|
||||
out = f"{name} x{self.quantity}"
|
||||
if self.sighting_notes:
|
||||
out += f" ({self.sighting_notes})"
|
||||
return out
|
||||
|
||||
|
||||
@dataclass
|
||||
class BirdSightingLogData(BaseLogData, WithPeopleLogData):
|
||||
birds: Optional[list[BirdSightingEntry]] = None
|
||||
duration_minutes: Optional[int] = None
|
||||
observation_type: Optional[str] = None
|
||||
distance: Optional[str] = None
|
||||
area: Optional[str] = None
|
||||
party_size: Optional[int] = None
|
||||
complete_checklist: Optional[bool] = None
|
||||
weather: Optional[str] = None
|
||||
temperature: Optional[int] = None
|
||||
guide: Optional[str] = None
|
||||
|
||||
_excluded_fields = {}
|
||||
|
||||
@cached_property
|
||||
def bird_list(self) -> str:
|
||||
if self.birds:
|
||||
return ", ".join(
|
||||
[BirdSightingEntry(**b).__str__() for b in self.birds]
|
||||
)
|
||||
return ""
|
||||
|
||||
def as_html(self) -> str:
|
||||
html_parts = []
|
||||
|
||||
if self.observation_type:
|
||||
html_parts.append(
|
||||
f'<div class="birding-obs-type">Type: {self.observation_type}</div>'
|
||||
)
|
||||
|
||||
if self.distance:
|
||||
html_parts.append(
|
||||
f'<div class="birding-distance">Distance: {self.distance}</div>'
|
||||
)
|
||||
|
||||
if self.area:
|
||||
html_parts.append(
|
||||
f'<div class="birding-area">Area: {self.area}</div>'
|
||||
)
|
||||
|
||||
if self.party_size:
|
||||
html_parts.append(
|
||||
f'<div class="birding-party">Party size: {self.party_size}</div>'
|
||||
)
|
||||
|
||||
if self.complete_checklist is not None:
|
||||
html_parts.append(
|
||||
f'<div class="birding-checklist">Complete checklist: {self.complete_checklist}</div>'
|
||||
)
|
||||
|
||||
if self.weather:
|
||||
html_parts.append(
|
||||
f'<div class="birding-weather">Weather: {self.weather}</div>'
|
||||
)
|
||||
|
||||
if self.temperature:
|
||||
html_parts.append(
|
||||
f'<div class="birding-temp">Temp: {self.temperature}°</div>'
|
||||
)
|
||||
|
||||
if self.guide:
|
||||
html_parts.append(
|
||||
f'<div class="birding-guide">Guide: {self.guide}</div>'
|
||||
)
|
||||
|
||||
if self.duration_minutes:
|
||||
html_parts.append(
|
||||
f'<div class="birding-duration">Duration: {self.duration_minutes} min</div>'
|
||||
)
|
||||
|
||||
if self.birds:
|
||||
birds_html = []
|
||||
for bird_data in self.birds:
|
||||
sighting = BirdSightingEntry(**bird_data)
|
||||
bird_info = sighting.bird.common_name if sighting.bird else "Unknown"
|
||||
extra = f" x{sighting.quantity}"
|
||||
if sighting.sighting_notes:
|
||||
extra += f" \u2014 {sighting.sighting_notes}"
|
||||
birds_html.append(
|
||||
f'<div class="bird-sighting">{bird_info}{extra}</div>'
|
||||
)
|
||||
html_parts.append(
|
||||
f'<div class="bird-sightings">{"".join(birds_html)}</div>'
|
||||
)
|
||||
|
||||
return "".join(html_parts)
|
||||
|
||||
@classmethod
|
||||
def override_fields(cls) -> dict:
|
||||
from birds.forms import BirdSightingsField
|
||||
|
||||
fields = {}
|
||||
for base in cls.mro()[1:]:
|
||||
if hasattr(base, "override_fields"):
|
||||
base_fields = base.override_fields()
|
||||
fields.update(base_fields)
|
||||
custom_fields = {
|
||||
"birds": BirdSightingsField(required=False),
|
||||
}
|
||||
fields.update(custom_fields)
|
||||
return fields
|
||||
|
||||
|
||||
class Bird(TimeStampedModel):
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
common_name = models.CharField(max_length=255)
|
||||
scientific_name = models.CharField(max_length=255, **BNULL)
|
||||
description = models.TextField(**BNULL)
|
||||
ebird_code = models.CharField(max_length=255, **BNULL, db_index=True)
|
||||
photo = models.ImageField(upload_to="birds/photos/", **BNULL)
|
||||
photo_small = ImageSpecField(
|
||||
source="photo",
|
||||
processors=[ResizeToFit(100, 100)],
|
||||
format="JPEG",
|
||||
options={"quality": 60},
|
||||
)
|
||||
photo_medium = ImageSpecField(
|
||||
source="photo",
|
||||
processors=[ResizeToFit(300, 300)],
|
||||
format="JPEG",
|
||||
options={"quality": 75},
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.common_name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("birds:bird_detail", kwargs={"slug": self.uuid})
|
||||
|
||||
@classmethod
|
||||
def find_or_create(cls, common_name: str) -> "Bird":
|
||||
bird = cls.objects.filter(common_name__iexact=common_name).first()
|
||||
if not bird:
|
||||
bird = cls.objects.create(common_name=common_name)
|
||||
return bird
|
||||
|
||||
|
||||
class BirdingLocation(ScrobblableMixin):
|
||||
description = models.TextField(**BNULL)
|
||||
geo_location = models.ForeignKey(
|
||||
GeoLocation, **BNULL, on_delete=models.DO_NOTHING
|
||||
)
|
||||
ebird_hotspot_id = models.CharField(max_length=255, **BNULL)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("birds:birding_location_detail", kwargs={"slug": self.uuid})
|
||||
|
||||
@property
|
||||
def subtitle(self):
|
||||
return ""
|
||||
|
||||
@property
|
||||
def strings(self) -> ScrobblableConstants:
|
||||
return ScrobblableConstants(verb="Birding at", tags="bird")
|
||||
|
||||
@property
|
||||
def logdata_cls(self):
|
||||
return BirdSightingLogData
|
||||
|
||||
def primary_image_url(self) -> str:
|
||||
return ""
|
||||
|
||||
def fix_metadata(self) -> None:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def find_or_create(cls, title: str) -> "BirdingLocation":
|
||||
location = cls.objects.filter(title__iexact=title).first()
|
||||
if not location:
|
||||
location = cls.objects.create(title=title)
|
||||
return location
|
||||
|
||||
|
||||
class BirdingCSVImport(TimeStampedModel):
|
||||
user = models.ForeignKey(User, on_delete=models.DO_NOTHING, **BNULL)
|
||||
uuid = models.UUIDField(editable=False, default=uuid4)
|
||||
processing_started = models.DateTimeField(**BNULL)
|
||||
processed_finished = models.DateTimeField(**BNULL)
|
||||
process_log = models.TextField(**BNULL)
|
||||
process_count = models.IntegerField(**BNULL)
|
||||
csv_file = models.FileField(upload_to="birding-csv-uploads/", **BNULL)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Birding CSV Import"
|
||||
|
||||
def __str__(self):
|
||||
return f"Birding import on {self.human_start}"
|
||||
|
||||
@property
|
||||
def human_start(self):
|
||||
start = "Unknown"
|
||||
if self.processing_started:
|
||||
start = self.processing_started.strftime("%B %d, %Y at %H:%M")
|
||||
return start
|
||||
|
||||
@property
|
||||
def import_type(self):
|
||||
return "Birding CSV"
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("birds:csv_import_detail", kwargs={"slug": self.uuid})
|
||||
|
||||
@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
|
||||
|
||||
def mark_started(self):
|
||||
self.processing_started = timezone.now()
|
||||
self.save(update_fields=["processing_started"])
|
||||
|
||||
def mark_finished(self):
|
||||
self.processed_finished = timezone.now()
|
||||
self.save(update_fields=["processed_finished"])
|
||||
|
||||
def record_log(self, scrobbles):
|
||||
self.process_log = ""
|
||||
if not scrobbles:
|
||||
self.process_count = 0
|
||||
self.save(update_fields=["process_log", "process_count"])
|
||||
return
|
||||
for count, scrobble in enumerate(scrobbles):
|
||||
scrobble_str = (
|
||||
f"{scrobble.id}\t{scrobble.timestamp}\t{scrobble.media_obj}"
|
||||
)
|
||||
log_line = f"{scrobble_str}"
|
||||
if count > 0:
|
||||
log_line = "\n" + log_line
|
||||
self.process_log += log_line
|
||||
self.process_count = len(scrobbles)
|
||||
self.save(update_fields=["process_log", "process_count"])
|
||||
|
||||
def scrobbles(self):
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
scrobble_ids = []
|
||||
if self.process_log:
|
||||
for line in self.process_log.split("\n"):
|
||||
sid = line.split("\t")[0]
|
||||
if sid:
|
||||
scrobble_ids.append(sid)
|
||||
return Scrobble.objects.filter(id__in=scrobble_ids)
|
||||
|
||||
def process(self, force=False):
|
||||
if self.processed_finished and not force:
|
||||
logger.info(f"{self} already processed on {self.processed_finished}")
|
||||
return
|
||||
from birds.importer import import_birding_csv
|
||||
|
||||
self.mark_started()
|
||||
scrobbles = import_birding_csv(self.upload_file_path, self.user_id)
|
||||
self.record_log(scrobbles)
|
||||
self.mark_finished()
|
||||
27
vrobbler/apps/birds/static/birds/bird_sightings.js
Normal file
27
vrobbler/apps/birds/static/birds/bird_sightings.js
Normal file
@ -0,0 +1,27 @@
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
var widget = document.querySelector(".bird-sightings-widget");
|
||||
if (!widget) return;
|
||||
|
||||
var list = widget.querySelector(".bird-sightings-list");
|
||||
|
||||
widget.addEventListener("click", function (e) {
|
||||
if (e.target.classList.contains("add-sighting-row")) {
|
||||
var rows = list.querySelectorAll(".bird-sighting-row");
|
||||
var template = rows[rows.length - 1];
|
||||
if (!template) return;
|
||||
var clone = template.cloneNode(true);
|
||||
clone.querySelectorAll("select, input").forEach(function (el) {
|
||||
el.value = "";
|
||||
});
|
||||
clone.querySelector('input[name$="_quantity"]').value = "1";
|
||||
list.appendChild(clone);
|
||||
}
|
||||
|
||||
if (e.target.classList.contains("remove-sighting")) {
|
||||
var rows = list.querySelectorAll(".bird-sighting-row");
|
||||
if (rows.length > 1) {
|
||||
e.target.closest(".bird-sighting-row").remove();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,46 @@
|
||||
<div class="bird-sightings-widget">
|
||||
<div class="bird-sightings-list">
|
||||
{% for sighting in widget.sightings %}
|
||||
<div class="bird-sighting-row row mb-2">
|
||||
<div class="col-md-6">
|
||||
<select name="{{widget.name}}_bird_id" class="form-control">
|
||||
<option value="">Select bird...</option>
|
||||
{% for bird in widget.birds %}
|
||||
<option value="{{bird.id}}" {% if sighting.bird_id == bird.id %}selected{% endif %}>{{bird.common_name}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input type="number" name="{{widget.name}}_quantity" class="form-control" value="{{sighting.quantity|default:1}}" min="1" placeholder="Qty">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<input type="text" name="{{widget.name}}_sighting_notes" class="form-control" value="{{sighting.sighting_notes|default:''}}" placeholder="Notes">
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<button type="button" class="btn btn-sm btn-outline-danger remove-sighting">×</button>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="bird-sighting-row row mb-2">
|
||||
<div class="col-md-6">
|
||||
<select name="{{widget.name}}_bird_id" class="form-control">
|
||||
<option value="">Select bird...</option>
|
||||
{% for bird in widget.birds %}
|
||||
<option value="{{bird.id}}">{{bird.common_name}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input type="number" name="{{widget.name}}_quantity" class="form-control" value="1" min="1" placeholder="Qty">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<input type="text" name="{{widget.name}}_sighting_notes" class="form-control" value="" placeholder="Notes">
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<button type="button" class="btn btn-sm btn-outline-danger remove-sighting">×</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary add-sighting-row mt-2">Add bird</button>
|
||||
</div>
|
||||
37
vrobbler/apps/birds/urls.py
Normal file
37
vrobbler/apps/birds/urls.py
Normal file
@ -0,0 +1,37 @@
|
||||
from birds import views
|
||||
from django.urls import path
|
||||
|
||||
app_name = "birds"
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"birding-locations/",
|
||||
views.BirdingLocationListView.as_view(),
|
||||
name="birding_location_list",
|
||||
),
|
||||
path(
|
||||
"birding-locations/<slug:slug>/",
|
||||
views.BirdingLocationDetailView.as_view(),
|
||||
name="birding_location_detail",
|
||||
),
|
||||
path(
|
||||
"birds/",
|
||||
views.BirdListView.as_view(),
|
||||
name="bird_list",
|
||||
),
|
||||
path(
|
||||
"birds/<slug:slug>/",
|
||||
views.BirdDetailView.as_view(),
|
||||
name="bird_detail",
|
||||
),
|
||||
path(
|
||||
"upload/birding-csv/",
|
||||
views.BirdingCSVImportCreateView.as_view(),
|
||||
name="csv-upload",
|
||||
),
|
||||
path(
|
||||
"imports/birding-csv/<slug:slug>/",
|
||||
views.BirdingCSVImportDetailView.as_view(),
|
||||
name="csv_import_detail",
|
||||
),
|
||||
]
|
||||
63
vrobbler/apps/birds/views.py
Normal file
63
vrobbler/apps/birds/views.py
Normal file
@ -0,0 +1,63 @@
|
||||
from birds.models import Bird, BirdingLocation
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse_lazy
|
||||
from django.views import generic
|
||||
from scrobbles.models import EBirdCSVImport as BirdingCSVImport
|
||||
from scrobbles.views import (
|
||||
ScrobbleableDetailView,
|
||||
ScrobbleableListView,
|
||||
JsonableResponseMixin,
|
||||
)
|
||||
|
||||
|
||||
class BirdingLocationListView(ScrobbleableListView):
|
||||
model = BirdingLocation
|
||||
|
||||
|
||||
class BirdingLocationDetailView(ScrobbleableDetailView):
|
||||
model = BirdingLocation
|
||||
|
||||
|
||||
class BirdListView(generic.ListView):
|
||||
model = Bird
|
||||
paginate_by = 200
|
||||
ordering = "common_name"
|
||||
|
||||
|
||||
class BirdDetailView(generic.DetailView):
|
||||
model = Bird
|
||||
slug_field = "uuid"
|
||||
|
||||
|
||||
class BirdingCSVImportCreateView(
|
||||
LoginRequiredMixin, JsonableResponseMixin, generic.CreateView
|
||||
):
|
||||
model = BirdingCSVImport
|
||||
fields = ["csv_file"]
|
||||
template_name = "scrobbles/upload_form.html"
|
||||
success_url = reverse_lazy("vrobbler-home")
|
||||
|
||||
def form_valid(self, form):
|
||||
self.object = form.save(commit=False)
|
||||
self.object.user = self.request.user
|
||||
self.object.original_filename = (
|
||||
form.cleaned_data["csv_file"].name
|
||||
)
|
||||
self.object.save()
|
||||
self.object.process()
|
||||
return HttpResponseRedirect(self.request.META.get("HTTP_REFERER"))
|
||||
|
||||
|
||||
class BirdingCSVImportDetailView(LoginRequiredMixin, generic.DetailView):
|
||||
model = BirdingCSVImport
|
||||
slug_field = "uuid"
|
||||
template_name = "scrobbles/import_detail.html"
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(user=self.request.user)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context_data = super().get_context_data(**kwargs)
|
||||
context_data["title"] = "eBird CSV Import"
|
||||
return context_data
|
||||
@ -14,9 +14,7 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SEARCH_ID_URL = (
|
||||
"https://boardgamegeek.com/xmlapi/search?search={query}&exact=1"
|
||||
)
|
||||
SEARCH_ID_URL = "https://boardgamegeek.com/xmlapi/search?search={query}&exact=1"
|
||||
GAME_ID_URL = "https://boardgamegeek.com/xmlapi/boardgame/{id}"
|
||||
BGG_ACCESS_TOKEN = getattr(settings, "BGG_ACCESS_TOKEN", "")
|
||||
BASE_HEADERS = {
|
||||
|
||||
26
vrobbler/apps/boardgames/migrations/0014_boardgame_tags.py
Normal file
26
vrobbler/apps/boardgames/migrations/0014_boardgame_tags.py
Normal file
@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.2.29 on 2026-03-26 21:25
|
||||
|
||||
from django.db import migrations
|
||||
import taggit.managers
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("taggit", "0004_alter_taggeditem_content_type_alter_taggeditem_tag"),
|
||||
("boardgames", "0013_boardgame_publishers"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="boardgame",
|
||||
name="tags",
|
||||
field=taggit.managers.TaggableManager(
|
||||
blank=True,
|
||||
help_text="A comma-separated list of tags.",
|
||||
through="taggit.TaggedItem",
|
||||
to="taggit.Tag",
|
||||
verbose_name="Tags",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.2.29 on 2026-05-01 15:49
|
||||
|
||||
from django.db import migrations
|
||||
import taggit.managers
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("scrobbles", "0075_add_channel_scrobble"),
|
||||
("boardgames", "0014_boardgame_tags"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="boardgame",
|
||||
name="genre",
|
||||
field=taggit.managers.TaggableManager(
|
||||
blank=True,
|
||||
help_text="A comma-separated list of tags.",
|
||||
through="scrobbles.ObjectWithGenres",
|
||||
to="scrobbles.Genre",
|
||||
verbose_name="Genre",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -80,6 +80,7 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
|
||||
board: Optional[str] = None
|
||||
rounds: Optional[int] = None
|
||||
details: Optional[str] = None
|
||||
raw_data: Optional[dict] = None
|
||||
|
||||
_excluded_fields = {
|
||||
"lichess_id",
|
||||
@ -89,6 +90,26 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
|
||||
"variant",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def override_fields(cls) -> dict:
|
||||
from scrobbles.forms import NotesDictField
|
||||
|
||||
fields = {}
|
||||
for base in cls.mro()[1:]:
|
||||
if hasattr(base, "override_fields"):
|
||||
base_fields = base.override_fields()
|
||||
fields.update(base_fields)
|
||||
custom_fields = {
|
||||
"notes": NotesDictField(required=False),
|
||||
"location_id": forms.ModelChoiceField(
|
||||
queryset=BoardGameLocation.objects.all(),
|
||||
required=False,
|
||||
widget=forms.Select(),
|
||||
),
|
||||
}
|
||||
fields.update(custom_fields)
|
||||
return fields
|
||||
|
||||
@cached_property
|
||||
def location(self):
|
||||
if not self.location_id:
|
||||
@ -99,29 +120,38 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
|
||||
def player_log(self) -> str:
|
||||
if self.players:
|
||||
return ", ".join(
|
||||
[
|
||||
BoardGameScoreLogData(**player).__str__()
|
||||
for player in self.players
|
||||
]
|
||||
[BoardGameScoreLogData(**player).__str__() for player in self.players]
|
||||
)
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def override_fields(cls) -> dict:
|
||||
fields = {}
|
||||
for base in cls.mro()[1:]:
|
||||
if hasattr(base, "override_fields"):
|
||||
base_fields = base.override_fields()
|
||||
fields.update(base_fields)
|
||||
custom_fields = {
|
||||
"location_id": forms.ModelChoiceField(
|
||||
queryset=BoardGameLocation.objects.all(),
|
||||
required=False,
|
||||
widget=forms.Select(),
|
||||
def as_html(self) -> str:
|
||||
html_parts = []
|
||||
|
||||
if self.board:
|
||||
html_parts.append(f'<div class="boardgame-board">{self.board}</div>')
|
||||
|
||||
if self.location:
|
||||
html_parts.append(f'<div class="boardgame-location">{self.location}</div>')
|
||||
|
||||
if self.players:
|
||||
players_html = []
|
||||
for player_data in self.players:
|
||||
player = BoardGameScoreLogData(**player_data)
|
||||
player_info = player.name
|
||||
if player.score:
|
||||
player_info += f" ({player.score})"
|
||||
if player.win:
|
||||
player_info += " 🏆"
|
||||
if player.new:
|
||||
player_info += " (new)"
|
||||
players_html.append(
|
||||
f'<div class="boardgame-player">{player_info}</div>'
|
||||
)
|
||||
html_parts.append(
|
||||
f'<div class="boardgame-players">{"".join(players_html)}</div>'
|
||||
)
|
||||
}
|
||||
fields.update(custom_fields)
|
||||
return fields
|
||||
|
||||
return "".join(html_parts)
|
||||
|
||||
|
||||
class BoardGamePublisher(TimeStampedModel):
|
||||
@ -134,9 +164,7 @@ class BoardGamePublisher(TimeStampedModel):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse(
|
||||
"boardgames:publisher_detail", kwargs={"slug": self.uuid}
|
||||
)
|
||||
return reverse("boardgames:publisher_detail", kwargs={"slug": self.uuid})
|
||||
|
||||
|
||||
class BoardGameDesigner(TimeStampedModel):
|
||||
@ -149,9 +177,7 @@ class BoardGameDesigner(TimeStampedModel):
|
||||
return str(self.name)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse(
|
||||
"boardgames:designer_detail", kwargs={"slug": self.uuid}
|
||||
)
|
||||
return reverse("boardgames:designer_detail", kwargs={"slug": self.uuid})
|
||||
|
||||
|
||||
class BoardGameLocation(TimeStampedModel):
|
||||
@ -159,23 +185,17 @@ class BoardGameLocation(TimeStampedModel):
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
bgstats_id = models.UUIDField(**BNULL)
|
||||
description = models.TextField(**BNULL)
|
||||
geo_location = models.ForeignKey(
|
||||
GeoLocation, **BNULL, on_delete=models.DO_NOTHING
|
||||
)
|
||||
geo_location = models.ForeignKey(GeoLocation, **BNULL, on_delete=models.DO_NOTHING)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return str(self.name)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse(
|
||||
"boardgames:location_detail", kwargs={"slug": self.uuid}
|
||||
)
|
||||
return reverse("boardgames:location_detail", kwargs={"slug": self.uuid})
|
||||
|
||||
|
||||
class BoardGame(ScrobblableMixin):
|
||||
COMPLETION_PERCENT = getattr(
|
||||
settings, "BOARD_GAME_COMPLETION_PERCENT", 100
|
||||
)
|
||||
COMPLETION_PERCENT = getattr(settings, "BOARD_GAME_COMPLETION_PERCENT", 100)
|
||||
|
||||
FIELDS_FROM_BGGEEK = [
|
||||
"igdb_id",
|
||||
@ -250,9 +270,7 @@ class BoardGame(ScrobblableMixin):
|
||||
return self.title
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse(
|
||||
"boardgames:boardgame_detail", kwargs={"slug": self.uuid}
|
||||
)
|
||||
return reverse("boardgames:boardgame_detail", kwargs={"slug": self.uuid})
|
||||
|
||||
@property
|
||||
def logdata_cls(self):
|
||||
@ -277,7 +295,6 @@ class BoardGame(ScrobblableMixin):
|
||||
def fix_metadata(self, data: dict = {}, force_update=False) -> None:
|
||||
|
||||
if not self.published_date or force_update:
|
||||
|
||||
if not data:
|
||||
data = lookup_boardgame_from_bgg(str(self.bggeek_id))
|
||||
|
||||
@ -316,9 +333,7 @@ class BoardGame(ScrobblableMixin):
|
||||
self.cover.save(fname, ContentFile(r.content), save=True)
|
||||
|
||||
@classmethod
|
||||
def find_or_create(
|
||||
cls, lookup_id: str, data: dict[str, Any] = {}
|
||||
) -> "BoardGame":
|
||||
def find_or_create(cls, lookup_id: str, data: dict[str, Any] = {}) -> "BoardGame":
|
||||
"""Given a Lookup ID (either BGG or BGA ID), return a board game object"""
|
||||
game = cls.objects.filter(bggeek_id=lookup_id).first()
|
||||
if not game:
|
||||
@ -336,7 +351,10 @@ class BoardGame(ScrobblableMixin):
|
||||
elif data.get("name"):
|
||||
bgg_data = lookup_boardgame_from_bgg(title=data.get("name"))
|
||||
else:
|
||||
bgg_data = lookup_boardgame_from_bgg(title=lookup_id)
|
||||
if int(lookup_id):
|
||||
bgg_data = lookup_boardgame_from_bgg(lookup_id=lookup_id)
|
||||
else:
|
||||
bgg_data = lookup_boardgame_from_bgg(title=lookup_id)
|
||||
|
||||
mechanics = bgg_data.pop("mechanics", [])
|
||||
designers = bgg_data.pop("designers", [])
|
||||
@ -354,9 +372,7 @@ class BoardGame(ScrobblableMixin):
|
||||
game.uses_teams = data.get("useTeams", False)
|
||||
game.bgstats_id = data.get("uuid", None)
|
||||
if publisher:
|
||||
publisher, _ = BoardGamePublisher.objects.get_or_create(
|
||||
name=publisher
|
||||
)
|
||||
publisher, _ = BoardGamePublisher.objects.get_or_create(name=publisher)
|
||||
game.publisher = publisher
|
||||
game.save()
|
||||
|
||||
@ -369,9 +385,7 @@ class BoardGame(ScrobblableMixin):
|
||||
|
||||
if publishers:
|
||||
for name in publishers:
|
||||
publisher, _ = BoardGamePublisher.objects.get_or_create(
|
||||
name=name
|
||||
)
|
||||
publisher, _ = BoardGamePublisher.objects.get_or_create(name=name)
|
||||
game.publishers.add(publisher)
|
||||
|
||||
return game
|
||||
|
||||
@ -11,9 +11,7 @@ User = get_user_model()
|
||||
def import_chess_games_for_user_id(user_id: int, commit: bool = False) -> dict:
|
||||
user = User.objects.get(id=user_id)
|
||||
|
||||
client = berserk.Client(
|
||||
session=berserk.TokenSession(settings.LICHESS_API_KEY)
|
||||
)
|
||||
client = berserk.Client(session=berserk.TokenSession(settings.LICHESS_API_KEY))
|
||||
games = client.games.export_by_player(user.profile.lichess_username)
|
||||
for game_dict in games:
|
||||
chess, created = BoardGame.objects.get_or_create(title="Chess")
|
||||
@ -62,9 +60,7 @@ def import_chess_games_for_user_id(user_id: int, commit: bool = False) -> dict:
|
||||
white_player.get("aiLevel", "")
|
||||
)
|
||||
else:
|
||||
other_player["name_str"] = white_player.get("user", {}).get(
|
||||
"name", ""
|
||||
)
|
||||
other_player["name_str"] = white_player.get("user", {}).get("name", "")
|
||||
other_player["lichess_username"] = other_player["name_str"]
|
||||
|
||||
other_player["color"] = "white"
|
||||
@ -82,9 +78,7 @@ def import_chess_games_for_user_id(user_id: int, commit: bool = False) -> dict:
|
||||
black_player.get("aiLevel", "")
|
||||
)
|
||||
else:
|
||||
other_player["name_str"] = black_player.get("user", {}).get(
|
||||
"name", ""
|
||||
)
|
||||
other_player["name_str"] = black_player.get("user", {}).get("name", "")
|
||||
other_player["lichess_username"] = other_player["name_str"]
|
||||
other_player["color"] = "black"
|
||||
if winner == "white":
|
||||
@ -119,6 +113,8 @@ def import_chess_games_for_all_users():
|
||||
scrobbles_to_create = []
|
||||
for user in User.objects.filter(profile__lichess_username__isnull=False):
|
||||
scrobble_dict = import_chess_games_for_user_id(user.id)
|
||||
if not scrobble_dict:
|
||||
continue
|
||||
scrobbles_to_create.append(Scrobble(**scrobble_dict))
|
||||
|
||||
if scrobbles_to_create:
|
||||
|
||||
@ -3,7 +3,11 @@ from django.utils import timezone
|
||||
from django.views import generic
|
||||
from boardgames.models import BoardGame, BoardGameDesigner, BoardGamePublisher
|
||||
from scrobbles.models import Scrobble
|
||||
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
|
||||
from scrobbles.views import (
|
||||
ChartContextMixin,
|
||||
ScrobbleableListView,
|
||||
ScrobbleableDetailView,
|
||||
)
|
||||
|
||||
|
||||
class BoardGameListView(ScrobbleableListView):
|
||||
@ -33,9 +37,7 @@ class BoardGameListView(ScrobbleableListView):
|
||||
for designer in scrobble.board_game.designers.all():
|
||||
designers_this_week[designer.id] = {
|
||||
"designer": designer,
|
||||
"count": designers_this_week.get(designer.id, {}).get(
|
||||
"count", 0
|
||||
)
|
||||
"count": designers_this_week.get(designer.id, {}).get("count", 0)
|
||||
+ 1,
|
||||
}
|
||||
|
||||
@ -44,9 +46,7 @@ class BoardGameListView(ScrobbleableListView):
|
||||
for designer in scrobble.board_game.designers.all():
|
||||
designers_this_month[designer.id] = {
|
||||
"designer": designer,
|
||||
"count": designers_this_month.get(designer.id, {}).get(
|
||||
"count", 0
|
||||
)
|
||||
"count": designers_this_month.get(designer.id, {}).get("count", 0)
|
||||
+ 1,
|
||||
}
|
||||
|
||||
@ -64,7 +64,7 @@ class BoardGameListView(ScrobbleableListView):
|
||||
return context_data
|
||||
|
||||
|
||||
class BoardGameDetailView(ScrobbleableDetailView):
|
||||
class BoardGameDetailView(ScrobbleableDetailView, ChartContextMixin):
|
||||
model = BoardGame
|
||||
|
||||
|
||||
|
||||
@ -6,9 +6,7 @@ import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
USER_AGENT = (
|
||||
"Mozilla/5.0 (Android 4.4; Mobile; rv:41.0) Gecko/41.0 Firefox/41.0"
|
||||
)
|
||||
USER_AGENT = "Mozilla/5.0 (Android 4.4; Mobile; rv:41.0) Gecko/41.0 Firefox/41.0"
|
||||
AMAZON_SEARCH_URL = "https://www.amazon.com/s?k={amazon_id}"
|
||||
|
||||
|
||||
@ -95,32 +93,20 @@ def get_amazon_product_dict(amazon_id: str) -> dict:
|
||||
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
try:
|
||||
data_dict["title"] = soup.findAll("span", class_="celwidget")[
|
||||
1
|
||||
].text.strip()
|
||||
data_dict["cover_url"] = soup.find("img", class_="frontImage").get(
|
||||
"src"
|
||||
)
|
||||
data_dict["summary"] = soup.findAll(
|
||||
"div", class_="a-expander-content"
|
||||
)[1].text
|
||||
data_dict["title"] = soup.findAll("span", class_="celwidget")[1].text.strip()
|
||||
data_dict["cover_url"] = soup.find("img", class_="frontImage").get("src")
|
||||
data_dict["summary"] = soup.findAll("div", class_="a-expander-content")[1].text
|
||||
meta = soup.findAll("div", class_="rpi-attribute-value")
|
||||
data_dict["isbn"] = meta[AmazonAttribute.ISBN_10.value].text.strip()
|
||||
pages = meta[AmazonAttribute.PAGES.value].text
|
||||
if "pages" in pages:
|
||||
data_dict["pages"] = (
|
||||
meta[AmazonAttribute.PAGES.value]
|
||||
.text.split("pages")[0]
|
||||
.strip()
|
||||
meta[AmazonAttribute.PAGES.value].text.split("pages")[0].strip()
|
||||
)
|
||||
except IndexError as e:
|
||||
logger.error(
|
||||
f"Amazon lookup is failing for this product {amazon_id}: {e}"
|
||||
)
|
||||
logger.error(f"Amazon lookup is failing for this product {amazon_id}: {e}")
|
||||
except AttributeError as e:
|
||||
logger.error(
|
||||
f"Amazon lookup is failing for this product {amazon_id}: {e}"
|
||||
)
|
||||
logger.error(f"Amazon lookup is failing for this product {amazon_id}: {e}")
|
||||
|
||||
return data_dict
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
from enum import Enum
|
||||
|
||||
BOOKS_TITLES_TO_IGNORE = [
|
||||
"KOReader Quickstart Guide",
|
||||
@ -7,3 +8,16 @@ BOOKS_TITLES_TO_IGNORE = [
|
||||
]
|
||||
|
||||
READCOMICSONLINE_URL = "https://readcomicsonline.ru"
|
||||
|
||||
|
||||
class MediaSourceTag(str, Enum):
|
||||
OPENLIBRARY = "source_openlibrary"
|
||||
GOOGLE_BOOKS = "source_google_books"
|
||||
COMICVINE = "source_comicvine"
|
||||
LOCG = "source_locg"
|
||||
KOREADER = "source_koreader"
|
||||
SEMANTIC_SCHOLAR = "source_semantic_scholar"
|
||||
|
||||
@classmethod
|
||||
def choices(cls):
|
||||
return [(tag.value, tag.name.replace("_", " ").title()) for tag in cls]
|
||||
|
||||
@ -146,9 +146,7 @@ def build_book_map(rows) -> dict:
|
||||
)
|
||||
continue
|
||||
book = Book.objects.filter(
|
||||
koreader_data_by_hash__icontains=book_row[
|
||||
KoReaderBookColumn.MD5.value
|
||||
]
|
||||
koreader_data_by_hash__icontains=book_row[KoReaderBookColumn.MD5.value]
|
||||
).first()
|
||||
|
||||
if not book:
|
||||
@ -202,15 +200,11 @@ def build_page_data(page_rows: list, book_map: dict, user_tz=None) -> dict:
|
||||
"end_ts": start_ts + duration,
|
||||
}
|
||||
if book_ids_not_found:
|
||||
logger.info(
|
||||
f"Found pages for books not in file: {set(book_ids_not_found)}"
|
||||
)
|
||||
logger.info(f"Found pages for books not in file: {set(book_ids_not_found)}")
|
||||
return book_map
|
||||
|
||||
|
||||
def build_scrobbles_from_book_map(
|
||||
book_map: dict, user: "User"
|
||||
) -> list["Scrobble"]:
|
||||
def build_scrobbles_from_book_map(book_map: dict, user: "User") -> list["Scrobble"]:
|
||||
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
||||
|
||||
scrobbles_to_create = []
|
||||
@ -240,9 +234,9 @@ def build_scrobbles_from_book_map(
|
||||
|
||||
seconds_from_last_page = 0
|
||||
if prev_page_stats:
|
||||
seconds_from_last_page = stats.get(
|
||||
"end_ts"
|
||||
) - prev_page_stats.get("start_ts")
|
||||
seconds_from_last_page = stats.get("end_ts") - prev_page_stats.get(
|
||||
"start_ts"
|
||||
)
|
||||
|
||||
playback_position_seconds = playback_position_seconds + stats.get(
|
||||
"duration"
|
||||
@ -251,9 +245,7 @@ def build_scrobbles_from_book_map(
|
||||
end_of_reading = pages_processed == total_pages_read
|
||||
big_jump_to_this_page = (cur_page_number - last_page_number) > 10
|
||||
is_session_gap = seconds_from_last_page > SESSION_GAP_SECONDS
|
||||
if (
|
||||
is_session_gap and not big_jump_to_this_page
|
||||
) or end_of_reading:
|
||||
if (is_session_gap and not big_jump_to_this_page) or end_of_reading:
|
||||
should_create_scrobble = True
|
||||
|
||||
if should_create_scrobble:
|
||||
@ -381,9 +373,7 @@ def process_koreader_sqlite_file(file_path, user_id) -> list:
|
||||
return new_scrobbles
|
||||
|
||||
book_map = build_page_data(
|
||||
cur.execute(
|
||||
"SELECT * from page_stat_data ORDER BY id_book, start_time"
|
||||
),
|
||||
cur.execute("SELECT * from page_stat_data ORDER BY id_book, start_time"),
|
||||
book_map,
|
||||
tz,
|
||||
)
|
||||
|
||||
@ -13,9 +13,7 @@ HEADERS = {
|
||||
}
|
||||
LOCG_WRTIER_URL = ""
|
||||
LOCG_WRITER_DETAIL_URL = "https://leagueofcomicgeeks.com/people/{slug}"
|
||||
LOCG_SEARCH_URL = (
|
||||
"https://leagueofcomicgeeks.com/search/ajax_issues?query={query}"
|
||||
)
|
||||
LOCG_SEARCH_URL = "https://leagueofcomicgeeks.com/search/ajax_issues?query={query}"
|
||||
LOCG_DETAIL_URL = "https://leagueofcomicgeeks.com/comic/{locg_slug}"
|
||||
|
||||
|
||||
@ -72,26 +70,19 @@ def lookup_comic_by_locg_slug(slug: str) -> dict:
|
||||
attrs = soup.findAll("div", class_="details-addtl-block")
|
||||
try:
|
||||
data_dict["pages"] = (
|
||||
attrs[1]
|
||||
.find("div", class_="value")
|
||||
.text.split("pages")[0]
|
||||
.strip()
|
||||
attrs[1].find("div", class_="value").text.split("pages")[0].strip()
|
||||
)
|
||||
except IndexError:
|
||||
logger.warn(f"No ISBN field")
|
||||
try:
|
||||
data_dict["isbn"] = (
|
||||
attrs[3].find("div", class_="value").text.strip()
|
||||
)
|
||||
data_dict["isbn"] = attrs[3].find("div", class_="value").text.strip()
|
||||
except IndexError:
|
||||
logger.warn(f"No ISBN field")
|
||||
|
||||
writer_slug = None
|
||||
try:
|
||||
writer_slug = (
|
||||
soup.findAll("div", class_="name")[5]
|
||||
.a.get("href")
|
||||
.split("people/")[1]
|
||||
soup.findAll("div", class_="name")[5].a.get("href").split("people/")[1]
|
||||
)
|
||||
except IndexError:
|
||||
logger.warn(f"No wrtier found")
|
||||
|
||||
@ -37,7 +37,5 @@ class Command(BaseCommand):
|
||||
def handle(self, *args, **options):
|
||||
scrobbles_to_create = []
|
||||
|
||||
for scrobble in Scrobble.objects.filter(
|
||||
media_type="Book", source="KOReader"
|
||||
):
|
||||
for scrobble in Scrobble.objects.filter(media_type="Book", source="KOReader"):
|
||||
update_scrobble_from_page_data(scrobble)
|
||||
|
||||
@ -52,8 +52,7 @@ class Command(BaseCommand):
|
||||
seconds_from_last_page = 0
|
||||
if prev_page:
|
||||
seconds_from_last_page = (
|
||||
page.end_time.timestamp()
|
||||
- prev_page.start_time.timestamp()
|
||||
page.end_time.timestamp() - prev_page.start_time.timestamp()
|
||||
)
|
||||
playback_position_seconds = (
|
||||
playback_position_seconds + page.duration_seconds
|
||||
@ -62,9 +61,7 @@ class Command(BaseCommand):
|
||||
end_of_reading = pages_processed == total_pages
|
||||
big_jump_to_this_page = False
|
||||
if prev_page:
|
||||
big_jump_to_this_page = (
|
||||
page.number - prev_page.number
|
||||
) > 10
|
||||
big_jump_to_this_page = (page.number - prev_page.number) > 10
|
||||
if (
|
||||
seconds_from_last_page > SESSION_GAP_SECONDS
|
||||
and not big_jump_to_this_page
|
||||
@ -113,9 +110,7 @@ class Command(BaseCommand):
|
||||
user_id=user.id,
|
||||
).first()
|
||||
if scrobble:
|
||||
logger.info(
|
||||
f"Found existing scrobble {scrobble}, updating"
|
||||
)
|
||||
logger.info(f"Found existing scrobble {scrobble}, updating")
|
||||
scrobble.book_page_data = scrobble_page_data
|
||||
scrobble.playback_position_seconds = (
|
||||
scrobble.calc_reading_duration()
|
||||
|
||||
@ -1,10 +1,6 @@
|
||||
from typing import Optional
|
||||
|
||||
|
||||
YOUTUBE_VIDEO_URL = "https://www.youtube.com/watch?v="
|
||||
IMDB_VIDEO_URL = "https://www.imdb.com/title/"
|
||||
|
||||
|
||||
class BookType:
|
||||
...
|
||||
|
||||
|
||||
26
vrobbler/apps/books/migrations/0034_book_tags.py
Normal file
26
vrobbler/apps/books/migrations/0034_book_tags.py
Normal file
@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.2.29 on 2026-03-26 21:23
|
||||
|
||||
from django.db import migrations
|
||||
import taggit.managers
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("taggit", "0004_alter_taggeditem_content_type_alter_taggeditem_tag"),
|
||||
("books", "0033_alter_book_issue_number_alter_book_volume_number"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="book",
|
||||
name="tags",
|
||||
field=taggit.managers.TaggableManager(
|
||||
blank=True,
|
||||
help_text="A comma-separated list of tags.",
|
||||
through="taggit.TaggedItem",
|
||||
to="taggit.Tag",
|
||||
verbose_name="Tags",
|
||||
),
|
||||
),
|
||||
]
|
||||
26
vrobbler/apps/books/migrations/0035_paper_tags.py
Normal file
26
vrobbler/apps/books/migrations/0035_paper_tags.py
Normal file
@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.2.29 on 2026-03-26 21:25
|
||||
|
||||
from django.db import migrations
|
||||
import taggit.managers
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("taggit", "0004_alter_taggeditem_content_type_alter_taggeditem_tag"),
|
||||
("books", "0034_book_tags"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="paper",
|
||||
name="tags",
|
||||
field=taggit.managers.TaggableManager(
|
||||
blank=True,
|
||||
help_text="A comma-separated list of tags.",
|
||||
through="taggit.TaggedItem",
|
||||
to="taggit.Tag",
|
||||
verbose_name="Tags",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,37 @@
|
||||
# Generated by Django 4.2.29 on 2026-05-01 15:49
|
||||
|
||||
from django.db import migrations
|
||||
import taggit.managers
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("scrobbles", "0075_add_channel_scrobble"),
|
||||
("books", "0035_paper_tags"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="book",
|
||||
name="genre",
|
||||
field=taggit.managers.TaggableManager(
|
||||
blank=True,
|
||||
help_text="A comma-separated list of tags.",
|
||||
through="scrobbles.ObjectWithGenres",
|
||||
to="scrobbles.Genre",
|
||||
verbose_name="Genre",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="paper",
|
||||
name="genre",
|
||||
field=taggit.managers.TaggableManager(
|
||||
blank=True,
|
||||
help_text="A comma-separated list of tags.",
|
||||
through="scrobbles.ObjectWithGenres",
|
||||
to="scrobbles.Genre",
|
||||
verbose_name="Genre",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -6,12 +6,24 @@ from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
import requests
|
||||
from books.constants import READCOMICSONLINE_URL
|
||||
from books.constants import MediaSourceTag, READCOMICSONLINE_URL
|
||||
from books.locg import (
|
||||
lookup_comic_by_locg_slug,
|
||||
lookup_comic_from_locg,
|
||||
lookup_comic_writer_by_locg_slug,
|
||||
)
|
||||
from books.openlibrary import (
|
||||
lookup_author_from_openlibrary,
|
||||
lookup_book_from_openlibrary,
|
||||
)
|
||||
from books.sources.comicvine import (
|
||||
ComicVineClient,
|
||||
lookup_comic_from_comicvine,
|
||||
)
|
||||
from books.sources.google import lookup_book_from_google
|
||||
from books.sources.openlibrary import (
|
||||
lookup_book_from_openlibrary as lookup_book_from_ol,
|
||||
)
|
||||
from books.sources.semantic import lookup_paper_from_semantic
|
||||
from books.utils import get_comic_issue_url
|
||||
from django.conf import settings
|
||||
@ -32,16 +44,6 @@ from scrobbles.utils import get_scrobbles_for_media, next_url_if_exists
|
||||
from taggit.managers import TaggableManager
|
||||
from thefuzz import fuzz
|
||||
|
||||
from vrobbler.apps.books.locg import (
|
||||
lookup_comic_by_locg_slug,
|
||||
lookup_comic_from_locg,
|
||||
lookup_comic_writer_by_locg_slug,
|
||||
)
|
||||
from vrobbler.apps.books.sources.comicvine import (
|
||||
ComicVineClient,
|
||||
lookup_comic_from_comicvine,
|
||||
)
|
||||
|
||||
COMICVINE_API_KEY = getattr(settings, "COMICVINE_API_KEY", "")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -106,11 +108,9 @@ class Author(TimeStampedModel):
|
||||
def __str__(self):
|
||||
return f"{self.name}"
|
||||
|
||||
def enrich_from_semantic(self, overwrite=False):
|
||||
...
|
||||
def enrich_from_semantic(self, overwrite=False): ...
|
||||
|
||||
def enrich_from_google_books(self, overwrite=False):
|
||||
...
|
||||
def enrich_from_google_books(self, overwrite=False): ...
|
||||
|
||||
def enrich_from_openlibrary(self, overwrite=False):
|
||||
data_dict = lookup_author_from_openlibrary(self.openlibrary_id)
|
||||
@ -133,9 +133,7 @@ class Author(TimeStampedModel):
|
||||
|
||||
class Book(LongPlayScrobblableMixin):
|
||||
COMPLETION_PERCENT = getattr(settings, "BOOK_COMPLETION_PERCENT", 95)
|
||||
AVG_PAGE_READING_SECONDS = getattr(
|
||||
settings, "AVERAGE_PAGE_READING_SECONDS", 60
|
||||
)
|
||||
AVG_PAGE_READING_SECONDS = getattr(settings, "AVERAGE_PAGE_READING_SECONDS", 60)
|
||||
|
||||
title = models.CharField(max_length=255)
|
||||
original_title = models.CharField(max_length=255, **BNULL)
|
||||
@ -172,7 +170,7 @@ class Book(LongPlayScrobblableMixin):
|
||||
)
|
||||
summary = models.TextField(**BNULL)
|
||||
|
||||
genre = TaggableManager(through=ObjectWithGenres)
|
||||
genre = TaggableManager(through=ObjectWithGenres, blank=True, verbose_name="Genre")
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.issue_number and "Issue" not in str(self.title):
|
||||
@ -181,6 +179,13 @@ class Book(LongPlayScrobblableMixin):
|
||||
return f"{self.title} - Volume {self.volume_number}"
|
||||
return f"{self.title}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.pages:
|
||||
self.base_run_time_seconds = int(self.pages) * int(
|
||||
self.AVG_PAGE_READING_SECONDS
|
||||
)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def subtitle(self):
|
||||
return f" by {self.author}"
|
||||
@ -203,6 +208,10 @@ class Book(LongPlayScrobblableMixin):
|
||||
def get_absolute_url(self):
|
||||
return reverse("books:book_detail", kwargs={"slug": self.uuid})
|
||||
|
||||
@property
|
||||
def resume_start_url(self):
|
||||
return reverse("scrobbles:start", kwargs={"uuid": self.uuid}) + "?resume=1"
|
||||
|
||||
@classmethod
|
||||
def get_from_comicvine(
|
||||
cls, title: str, overwrite: bool = False, force_new: bool = False
|
||||
@ -245,8 +254,9 @@ class Book(LongPlayScrobblableMixin):
|
||||
def find_or_create(
|
||||
cls,
|
||||
title: str,
|
||||
author: str | None = None,
|
||||
url: str = "",
|
||||
enrich: bool = False,
|
||||
enrich: bool = True,
|
||||
commit: bool = True,
|
||||
):
|
||||
"""Given a title, get a Book instance.
|
||||
@ -260,9 +270,7 @@ class Book(LongPlayScrobblableMixin):
|
||||
# TODO use either a Google Books id identifier or author name like for tracks
|
||||
book, created = cls.objects.get_or_create(original_title=title)
|
||||
if not created:
|
||||
logger.info(
|
||||
"Found exact match for book by title", extra={"title": title}
|
||||
)
|
||||
logger.info("Found exact match for book by title", extra={"title": title})
|
||||
|
||||
if not enrich:
|
||||
logger.info(
|
||||
@ -272,15 +280,25 @@ class Book(LongPlayScrobblableMixin):
|
||||
return book
|
||||
|
||||
book_dict = None
|
||||
source_tag = None
|
||||
if READCOMICSONLINE_URL in url:
|
||||
book_dict = lookup_comic_from_comicvine(title)
|
||||
if book_dict:
|
||||
source_tag = MediaSourceTag.COMICVINE
|
||||
book_dict["readcomics_url"] = get_comic_issue_url(url)
|
||||
book_dict["next_readcomics_url"] = next_url_if_exists(
|
||||
book_dict["readcomics_url"]
|
||||
)
|
||||
|
||||
if not book_dict:
|
||||
book_dict = lookup_book_from_ol(title, author=author)
|
||||
if book_dict:
|
||||
source_tag = MediaSourceTag.OPENLIBRARY
|
||||
|
||||
if not book_dict:
|
||||
book_dict = lookup_book_from_google(title)
|
||||
if book_dict:
|
||||
source_tag = MediaSourceTag.GOOGLE_BOOKS
|
||||
|
||||
if not book_dict:
|
||||
logger.warning(
|
||||
@ -291,14 +309,12 @@ class Book(LongPlayScrobblableMixin):
|
||||
author_list = []
|
||||
authors = book_dict.pop("authors", [])
|
||||
cover_url = book_dict.pop("cover_url", "")
|
||||
genres = book_dict.pop("generes", [])
|
||||
genres = book_dict.pop("genres", book_dict.pop("generes", []))
|
||||
|
||||
if authors:
|
||||
for author_str in authors:
|
||||
if author_str:
|
||||
author, a_created = Author.objects.get_or_create(
|
||||
name=author_str
|
||||
)
|
||||
author, a_created = Author.objects.get_or_create(name=author_str)
|
||||
author_list.append(author)
|
||||
if a_created:
|
||||
# TODO enrich author
|
||||
@ -311,8 +327,11 @@ class Book(LongPlayScrobblableMixin):
|
||||
book.save()
|
||||
|
||||
book.save_image_from_url(cover_url)
|
||||
book.genre.add(*genres)
|
||||
if genres:
|
||||
book.genre.add(*genres)
|
||||
book.authors.add(*author_list)
|
||||
if source_tag:
|
||||
book.tags.add(source_tag.value)
|
||||
|
||||
return book
|
||||
|
||||
@ -332,13 +351,9 @@ class Book(LongPlayScrobblableMixin):
|
||||
if not data:
|
||||
logger.warn(f"Checking openlibrary for {self.title}")
|
||||
if self.openlibrary_id and force_update:
|
||||
data = lookup_book_from_openlibrary(
|
||||
str(self.openlibrary_id)
|
||||
)
|
||||
data = lookup_book_from_ol(str(self.openlibrary_id))
|
||||
else:
|
||||
data = lookup_book_from_openlibrary(
|
||||
str(self.title), author_name
|
||||
)
|
||||
data = lookup_book_from_ol(str(self.title), author_name)
|
||||
|
||||
if not data:
|
||||
if self.locg_slug:
|
||||
@ -385,10 +400,7 @@ class Book(LongPlayScrobblableMixin):
|
||||
if "pages" in data.keys() and data.get("pages") == None:
|
||||
data.pop("pages")
|
||||
|
||||
if (
|
||||
not isinstance(data.get("pages"), int)
|
||||
and "pages" in data.keys()
|
||||
):
|
||||
if not isinstance(data.get("pages"), int) and "pages" in data.keys():
|
||||
logger.info(
|
||||
f"Pages for {self} from OL expected to be int, but got {data.get('pages')}"
|
||||
)
|
||||
@ -397,7 +409,7 @@ class Book(LongPlayScrobblableMixin):
|
||||
# Pop this, so we can look it up later
|
||||
cover_url = data.pop("cover_url", "")
|
||||
|
||||
subject_key_list = data.pop("subject_key_list", "")
|
||||
subject_key_list = data.pop("subject_key_list", [])
|
||||
|
||||
# Fun trick for updating all fields at once
|
||||
Book.objects.filter(pk=self.id).update(**data)
|
||||
@ -412,17 +424,10 @@ class Book(LongPlayScrobblableMixin):
|
||||
fname = f"{self.title}_{self.uuid}.jpg"
|
||||
self.cover.save(fname, ContentFile(r.content), save=True)
|
||||
|
||||
if self.pages:
|
||||
self.base_run_time_seconds = int(self.pages) * int(
|
||||
self.AVG_PAGE_READING_SECONDS
|
||||
)
|
||||
|
||||
self.save()
|
||||
|
||||
def fix_authors_metadata(self, openlibrary_author_id):
|
||||
author = Author.objects.filter(
|
||||
openlibrary_id=openlibrary_author_id
|
||||
).first()
|
||||
author = Author.objects.filter(openlibrary_id=openlibrary_author_id).first()
|
||||
if not author:
|
||||
data = lookup_author_from_openlibrary(openlibrary_author_id)
|
||||
author_image_url = data.pop("author_headshot_url", None)
|
||||
@ -433,9 +438,7 @@ class Book(LongPlayScrobblableMixin):
|
||||
r = requests.get(author_image_url)
|
||||
if r.status_code == 200:
|
||||
fname = f"{author.name}_{author.uuid}.jpg"
|
||||
author.headshot.save(
|
||||
fname, ContentFile(r.content), save=True
|
||||
)
|
||||
author.headshot.save(fname, ContentFile(r.content), save=True)
|
||||
self.authors.add(author)
|
||||
|
||||
def get_author_from_locg(self, locg_slug):
|
||||
@ -451,9 +454,7 @@ class Book(LongPlayScrobblableMixin):
|
||||
author.headshot.save(fname, ContentFile(r.content), save=True)
|
||||
self.authors.add(author)
|
||||
|
||||
def page_data_for_user(
|
||||
self, user_id: int, convert_timestamps: bool = True
|
||||
) -> dict:
|
||||
def page_data_for_user(self, user_id: int, convert_timestamps: bool = True) -> dict:
|
||||
scrobbles = self.scrobble_set.filter(user=user_id)
|
||||
|
||||
pages = {}
|
||||
@ -461,9 +462,7 @@ class Book(LongPlayScrobblableMixin):
|
||||
if scrobble.logdata.page_data:
|
||||
for page, data in scrobble.logdata.page_data.items():
|
||||
if convert_timestamps:
|
||||
data["start_ts"] = datetime.fromtimestamp(
|
||||
data["start_ts"]
|
||||
)
|
||||
data["start_ts"] = datetime.fromtimestamp(data["start_ts"])
|
||||
data["end_ts"] = datetime.fromtimestamp(data["end_ts"])
|
||||
pages[page] = data
|
||||
sorted_pages = OrderedDict(
|
||||
@ -502,9 +501,7 @@ class Paper(LongPlayScrobblableMixin):
|
||||
"""Keeps track of Academic Papers"""
|
||||
|
||||
COMPLETION_PERCENT = getattr(settings, "PAPER_COMPLETION_PERCENT", 60)
|
||||
AVG_PAGE_READING_SECONDS = getattr(
|
||||
settings, "AVERAGE_PAGE_READING_SECONDS", 60
|
||||
)
|
||||
AVG_PAGE_READING_SECONDS = getattr(settings, "AVERAGE_PAGE_READING_SECONDS", 60)
|
||||
|
||||
title = models.CharField(max_length=255)
|
||||
semantic_title = models.CharField(max_length=255, **BNULL)
|
||||
@ -525,7 +522,7 @@ class Paper(LongPlayScrobblableMixin):
|
||||
tldr = models.CharField(max_length=255, **BNULL)
|
||||
openaccess_pdf_url = models.CharField(max_length=255, **BNULL)
|
||||
|
||||
genre = TaggableManager(through=ObjectWithGenres)
|
||||
genre = TaggableManager(through=ObjectWithGenres, blank=True, verbose_name="Genre")
|
||||
|
||||
@classmethod
|
||||
def get_from_semantic(cls, title: str, overwrite: bool = False) -> "Paper":
|
||||
|
||||
@ -10,7 +10,9 @@ from thefuzz import fuzz
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ISBN_URL = "https://openlibrary.org/isbn/{isbn}.json"
|
||||
SEARCH_URL = "https://openlibrary.org/search.json?q={query}&sort=editions&mode=everything"
|
||||
SEARCH_URL = (
|
||||
"https://openlibrary.org/search.json?q={query}&sort=editions&mode=everything"
|
||||
)
|
||||
AUTHOR_SEARCH_URL = "https://openlibrary.org/search/authors.json?q={query}"
|
||||
COVER_URL = "https://covers.openlibrary.org/b/olid/{id}-L.jpg"
|
||||
AUTHOR_URL = "https://openlibrary.org/authors/{id}.json"
|
||||
@ -80,9 +82,7 @@ def lookup_author_from_openlibrary(olid: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def lookup_book_from_openlibrary(
|
||||
title: str, author: Optional[str] = None
|
||||
) -> dict:
|
||||
def lookup_book_from_openlibrary(title: str, author: Optional[str] = None) -> dict:
|
||||
title_quoted = urllib.parse.quote(title)
|
||||
author_quoted = ""
|
||||
if author:
|
||||
|
||||
@ -160,9 +160,7 @@ class ComicVineClient(object):
|
||||
# or not the request cache is to be used, store the procedure in a
|
||||
# local function to avoid repetition.
|
||||
def __httpget():
|
||||
response = requests.get(
|
||||
self.API_URL, headers=self.HEADERS, params=params
|
||||
)
|
||||
response = requests.get(self.API_URL, headers=self.HEADERS, params=params)
|
||||
|
||||
if not response.ok:
|
||||
self._handle_http_error(response)
|
||||
@ -214,19 +212,13 @@ def lookup_comic_from_comicvine(title: str) -> dict:
|
||||
|
||||
api_key = getattr(settings, "COMICVINE_API_KEY", "")
|
||||
if not api_key:
|
||||
logger.warning(
|
||||
"No ComicVine API key configured, not looking anything up"
|
||||
)
|
||||
logger.warning("No ComicVine API key configured, not looking anything up")
|
||||
return {}
|
||||
|
||||
client = ComicVineClient(
|
||||
api_key=getattr(settings, "COMICVINE_API_KEY", None)
|
||||
)
|
||||
client = ComicVineClient(api_key=getattr(settings, "COMICVINE_API_KEY", None))
|
||||
|
||||
raw_results = client.search(title).get("results")
|
||||
results = [
|
||||
r for r in raw_results if r.get("resource_type") == resource_type
|
||||
]
|
||||
results = [r for r in raw_results if r.get("resource_type") == resource_type]
|
||||
if not results:
|
||||
logger.warning("No comic found on ComicVine")
|
||||
return {}
|
||||
|
||||
@ -6,9 +6,7 @@ import requests
|
||||
from django.conf import settings
|
||||
|
||||
API_KEY = settings.GOOGLE_API_KEY
|
||||
GOOGLE_BOOKS_URL = (
|
||||
'https://www.googleapis.com/books/v1/volumes?q="{title}"&key={key}'
|
||||
)
|
||||
GOOGLE_BOOKS_URL = 'https://www.googleapis.com/books/v1/volumes?q="{title}"&key={key}'
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -21,14 +19,10 @@ def lookup_book_from_google(title: str) -> dict:
|
||||
response = requests.get(url, headers=headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning(
|
||||
"Bad response from Google", extra={"response": response}
|
||||
)
|
||||
logger.warning("Bad response from Google", extra={"response": response})
|
||||
return book_dict
|
||||
|
||||
google_result = (
|
||||
json.loads(response.content).get("items", [{}])[0].get("volumeInfo")
|
||||
)
|
||||
google_result = json.loads(response.content).get("items", [{}])[0].get("volumeInfo")
|
||||
if not google_result:
|
||||
return {}
|
||||
|
||||
@ -69,8 +63,8 @@ def lookup_book_from_google(title: str) -> dict:
|
||||
|
||||
book_dict["base_run_time_seconds"] = 3600
|
||||
if book_dict.get("pages"):
|
||||
book_dict["base_run_time_seconds"] = book_dict.get(
|
||||
"pages", 10
|
||||
) * getattr(settings, "AVERAGE_PAGE_READING_SECONDS", 60)
|
||||
book_dict["base_run_time_seconds"] = book_dict.get("pages", 10) * getattr(
|
||||
settings, "AVERAGE_PAGE_READING_SECONDS", 60
|
||||
)
|
||||
|
||||
return book_dict
|
||||
|
||||
160
vrobbler/apps/books/sources/openlibrary.py
Normal file
160
vrobbler/apps/books/sources/openlibrary.py
Normal file
@ -0,0 +1,160 @@
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
OPENLIBRARY_BASE_URL = "https://openlibrary.org"
|
||||
|
||||
|
||||
def lookup_book_from_openlibrary(title: str, author: str | None = None) -> dict:
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{OPENLIBRARY_BASE_URL}/search.json",
|
||||
params={"title": title, "limit": 5},
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
search_results = response.json()
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not search for {title}: {e}")
|
||||
return {}
|
||||
|
||||
docs = search_results.get("docs", [])
|
||||
|
||||
title_lower = title.lower()
|
||||
for work_doc in docs:
|
||||
found_title = work_doc.get("title", "")
|
||||
if found_title.lower() == title_lower:
|
||||
olid = work_doc.get("key", "").replace("/works/", "")
|
||||
if not olid:
|
||||
continue
|
||||
break
|
||||
else:
|
||||
logger.info(f"OpenLibrary: no close match for '{title}', skipping")
|
||||
return {}
|
||||
|
||||
try:
|
||||
work_response = requests.get(
|
||||
f"{OPENLIBRARY_BASE_URL}/works/{olid}.json", timeout=10
|
||||
)
|
||||
work_response.raise_for_status()
|
||||
work_json = work_response.json()
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not get work JSON for {olid}: {e}")
|
||||
return {}
|
||||
|
||||
book_dict: dict = {"title": work_json.get("title"), "openlibrary_id": olid}
|
||||
|
||||
description = work_json.get("description")
|
||||
if description:
|
||||
if isinstance(description, dict):
|
||||
book_dict["summary"] = description.get("value", "")
|
||||
else:
|
||||
book_dict["summary"] = str(description)
|
||||
|
||||
subjects = work_json.get("subjects", [])
|
||||
if subjects:
|
||||
book_dict["genres"] = subjects[:10]
|
||||
|
||||
covers = work_json.get("covers", [])
|
||||
if covers:
|
||||
book_dict["cover_url"] = (
|
||||
f"https://covers.openlibrary.org/b/id/{covers[0]}-L.jpg"
|
||||
)
|
||||
|
||||
try:
|
||||
editions_response = requests.get(
|
||||
f"{OPENLIBRARY_BASE_URL}/works/{olid}/editions.json", timeout=10
|
||||
)
|
||||
if editions_response.status_code == 200:
|
||||
editions_data = editions_response.json()
|
||||
entries = editions_data.get("entries", [])
|
||||
if entries:
|
||||
edition = entries[0]
|
||||
isbn_list = edition.get("isbn_10", [])
|
||||
if isbn_list:
|
||||
book_dict["isbn_10"] = isbn_list[0]
|
||||
isbn_list = edition.get("isbn_13", [])
|
||||
if isbn_list:
|
||||
book_dict["isbn_13"] = isbn_list[0]
|
||||
publishers = edition.get("publishers", [])
|
||||
if publishers:
|
||||
book_dict["publisher"] = publishers[0]
|
||||
pub_date = edition.get("publish_date")
|
||||
if pub_date:
|
||||
if len(pub_date) >= 4:
|
||||
try:
|
||||
book_dict["first_publish_year"] = int(pub_date[:4])
|
||||
except ValueError:
|
||||
pass
|
||||
pages = edition.get("number_of_pages_median")
|
||||
if pages:
|
||||
book_dict["pages"] = pages
|
||||
edition_covers = edition.get("covers", [])
|
||||
if edition_covers and not book_dict.get("cover_url"):
|
||||
book_dict["cover_url"] = (
|
||||
f"https://covers.openlibrary.org/b/id/{edition_covers[0]}-L.jpg"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not get editions for {olid}: {e}")
|
||||
|
||||
if author:
|
||||
book_dict["authors"] = [author]
|
||||
elif work_doc.get("author_name"):
|
||||
book_dict["authors"] = work_doc.get("author_name", [])
|
||||
|
||||
return book_dict
|
||||
|
||||
|
||||
def lookup_author_from_openlibrary(name: str) -> dict:
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{OPENLIBRARY_BASE_URL}/search.json",
|
||||
params={"author": name, "limit": 1},
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
search_results = response.json()
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not search for author {name}: {e}")
|
||||
return {}
|
||||
|
||||
docs = search_results.get("docs", [])
|
||||
if not docs:
|
||||
return {}
|
||||
|
||||
author_doc = docs[0]
|
||||
author_olid = author_doc.get("key", "").replace("/authors/", "")
|
||||
if not author_olid:
|
||||
return {}
|
||||
|
||||
author_dict: dict = {
|
||||
"name": author_doc.get("name", name),
|
||||
"openlibrary_id": author_olid,
|
||||
}
|
||||
|
||||
try:
|
||||
author_response = requests.get(
|
||||
f"{OPENLIBRARY_BASE_URL}/authors/{author_olid}.json", timeout=10
|
||||
)
|
||||
if author_response.status_code == 200:
|
||||
author_json = author_response.json()
|
||||
bio = author_json.get("bio")
|
||||
if bio:
|
||||
if isinstance(bio, dict):
|
||||
author_dict["bio"] = bio.get("value", "")
|
||||
else:
|
||||
author_dict["bio"] = str(bio)
|
||||
|
||||
wikipedia = author_json.get("wikipedia")
|
||||
if wikipedia:
|
||||
author_dict["wikipedia_url"] = wikipedia
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not get author JSON for {author_olid}: {e}")
|
||||
|
||||
author_dict["author_headshot_url"] = (
|
||||
f"https://covers.openlibrary.org/a/olid/{author_olid}-L.jpg"
|
||||
)
|
||||
|
||||
return author_dict
|
||||
@ -18,9 +18,7 @@ def get_api_result(url):
|
||||
response = requests.get(url, headers=headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning(
|
||||
"Bad response from Semantic", extra={"response": response}
|
||||
)
|
||||
logger.warning("Bad response from Semantic", extra={"response": response})
|
||||
return None
|
||||
|
||||
return response
|
||||
@ -64,12 +62,10 @@ def lookup_paper_from_semantic(title: str) -> dict:
|
||||
paper_dict["tldr"] = result.get("bib", {}).get("abstract")
|
||||
paper_dict["journal"] = result.get("journal", {}).get("name")
|
||||
paper_dict["journal_volume"] = result.get("journal", {}).get("volume")
|
||||
paper_dict["openaccess_pdf_url"] = result.get("openAccessPdf", {}).get(
|
||||
"url"
|
||||
paper_dict["openaccess_pdf_url"] = result.get("openAccessPdf", {}).get("url")
|
||||
paper_dict["base_run_time_seconds"] = paper_dict.get("pages", 10) * getattr(
|
||||
settings, "AVERAGE_PAGE_READING_SECONDS", 60
|
||||
)
|
||||
paper_dict["base_run_time_seconds"] = paper_dict.get(
|
||||
"pages", 10
|
||||
) * getattr(settings, "AVERAGE_PAGE_READING_SECONDS", 60)
|
||||
paper_dict["author_dicts"] = result.get("authors")
|
||||
paper_dict["genres"] = result.get("fieldsOfStudy")
|
||||
|
||||
|
||||
@ -99,7 +99,9 @@ class KoReaderBookRows:
|
||||
]
|
||||
)
|
||||
if end_session:
|
||||
start_time += 3600 # one second over an hour, marking a new reading session
|
||||
start_time += (
|
||||
3600 # one second over an hour, marking a new reading session
|
||||
)
|
||||
end_session = False
|
||||
else:
|
||||
start_time += AVERAGE_PAGE_READING_SECONDS
|
||||
|
||||
@ -32,17 +32,13 @@ def test_load_page_data_to_map(get_mock, koreader_rows, valid_response):
|
||||
)
|
||||
assert (
|
||||
len(book_map[1]["pages"])
|
||||
== koreader_rows.BOOK_ROWS[0][
|
||||
KoReaderBookColumn.TOTAL_READ_PAGES.value
|
||||
]
|
||||
== koreader_rows.BOOK_ROWS[0][KoReaderBookColumn.TOTAL_READ_PAGES.value]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@mock.patch("requests.get")
|
||||
def test_build_scrobbles_from_pages(
|
||||
get_mock, koreader_rows, demo_user, valid_response
|
||||
):
|
||||
def test_build_scrobbles_from_pages(get_mock, koreader_rows, demo_user, valid_response):
|
||||
get_mock.return_value = valid_response
|
||||
book_map = build_book_map(koreader_rows.BOOK_ROWS)
|
||||
book_map = build_page_data(koreader_rows.PAGE_STATS_ROWS, book_map)
|
||||
|
||||
26
vrobbler/apps/bricksets/migrations/0004_brickset_tags.py
Normal file
26
vrobbler/apps/bricksets/migrations/0004_brickset_tags.py
Normal file
@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.2.29 on 2026-03-26 21:25
|
||||
|
||||
from django.db import migrations
|
||||
import taggit.managers
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("taggit", "0004_alter_taggeditem_content_type_alter_taggeditem_tag"),
|
||||
("bricksets", "0003_remove_brickset_run_time_seconds_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="brickset",
|
||||
name="tags",
|
||||
field=taggit.managers.TaggableManager(
|
||||
blank=True,
|
||||
help_text="A comma-separated list of tags.",
|
||||
through="taggit.TaggedItem",
|
||||
to="taggit.Tag",
|
||||
verbose_name="Tags",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.2.29 on 2026-05-01 15:49
|
||||
|
||||
from django.db import migrations
|
||||
import taggit.managers
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("scrobbles", "0075_add_channel_scrobble"),
|
||||
("bricksets", "0004_brickset_tags"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="brickset",
|
||||
name="genre",
|
||||
field=taggit.managers.TaggableManager(
|
||||
blank=True,
|
||||
help_text="A comma-separated list of tags.",
|
||||
through="scrobbles.ObjectWithGenres",
|
||||
to="scrobbles.Genre",
|
||||
verbose_name="Genre",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -26,9 +26,7 @@ class BrickSet(LongPlayScrobblableMixin):
|
||||
number = models.CharField(max_length=10, **BNULL)
|
||||
release_year = models.IntegerField(**BNULL)
|
||||
piece_count = models.IntegerField(**BNULL)
|
||||
brickset_rating = models.DecimalField(
|
||||
max_digits=3, decimal_places=1, **BNULL
|
||||
)
|
||||
brickset_rating = models.DecimalField(max_digits=3, decimal_places=1, **BNULL)
|
||||
lego_item_number = models.CharField(max_length=10, **BNULL)
|
||||
box_image = models.ImageField(upload_to="brickset/boxes/", **BNULL)
|
||||
box_image_small = ImageSpecField(
|
||||
|
||||
0
vrobbler/apps/charts/__init__.py
Normal file
0
vrobbler/apps/charts/__init__.py
Normal file
59
vrobbler/apps/charts/admin.py
Normal file
59
vrobbler/apps/charts/admin.py
Normal file
@ -0,0 +1,59 @@
|
||||
from charts.models import ChartRecord
|
||||
from django.contrib import admin
|
||||
|
||||
|
||||
@admin.register(ChartRecord)
|
||||
class ChartRecordAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = (
|
||||
"user",
|
||||
"rank",
|
||||
"count",
|
||||
"year",
|
||||
"month",
|
||||
"week",
|
||||
"day",
|
||||
"media_name",
|
||||
"media_type",
|
||||
)
|
||||
list_filter = (
|
||||
"user",
|
||||
"year",
|
||||
"month",
|
||||
"week",
|
||||
"day",
|
||||
)
|
||||
ordering = ("-created",)
|
||||
search_fields = (
|
||||
"artist__name",
|
||||
"album__name",
|
||||
"track__title",
|
||||
"tv_series__name",
|
||||
"video__title",
|
||||
"podcast__name",
|
||||
"board_game__name",
|
||||
"book__title",
|
||||
"food__name",
|
||||
"trail__name",
|
||||
)
|
||||
raw_id_fields = (
|
||||
"user",
|
||||
"artist",
|
||||
"album",
|
||||
"track",
|
||||
"tv_series",
|
||||
"video",
|
||||
"podcast",
|
||||
"podcast_episode",
|
||||
"board_game",
|
||||
"trail",
|
||||
"geo_location",
|
||||
"food",
|
||||
"book",
|
||||
)
|
||||
|
||||
def media_name(self, obj):
|
||||
return obj.media_obj
|
||||
|
||||
def media_type(self, obj):
|
||||
return obj.media_type
|
||||
6
vrobbler/apps/charts/apps.py
Normal file
6
vrobbler/apps/charts/apps.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ChartsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "charts"
|
||||
0
vrobbler/apps/charts/management/__init__.py
Normal file
0
vrobbler/apps/charts/management/__init__.py
Normal file
113
vrobbler/apps/charts/management/commands/build_charts.py
Normal file
113
vrobbler/apps/charts/management/commands/build_charts.py
Normal file
@ -0,0 +1,113 @@
|
||||
import logging
|
||||
|
||||
from charts.utils import (
|
||||
build_all_historical_charts,
|
||||
build_charts_since,
|
||||
rebuild_all_charts,
|
||||
)
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_chart_counts():
|
||||
from charts.models import ChartRecord
|
||||
|
||||
daily = ChartRecord.objects.filter(day__isnull=False).count()
|
||||
weekly = ChartRecord.objects.filter(week__isnull=False, day__isnull=True).count()
|
||||
monthly = ChartRecord.objects.filter(
|
||||
month__isnull=False, week__isnull=True, day__isnull=True
|
||||
).count()
|
||||
yearly = ChartRecord.objects.filter(
|
||||
month__isnull=True, week__isnull=True, day__isnull=True
|
||||
).count()
|
||||
total = ChartRecord.objects.count()
|
||||
|
||||
return {
|
||||
"daily": daily,
|
||||
"weekly": weekly,
|
||||
"monthly": monthly,
|
||||
"yearly": yearly,
|
||||
"total": total,
|
||||
}
|
||||
|
||||
|
||||
def print_chart_summary(before=None):
|
||||
counts = get_chart_counts()
|
||||
if before:
|
||||
diff = {k: counts[k] - before[k] for k in counts}
|
||||
print(
|
||||
f" Daily: {counts['daily']} ({diff['daily']:+d}), "
|
||||
f"Weekly: {counts['weekly']} ({diff['weekly']:+d}), "
|
||||
f"Monthly: {counts['monthly']} ({diff['monthly']:+d}), "
|
||||
f"Yearly: {counts['yearly']} ({diff['yearly']:+d}), "
|
||||
f"Total: {counts['total']} ({diff['total']:+d})"
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f" Daily: {counts['daily']}, "
|
||||
f"Weekly: {counts['weekly']}, "
|
||||
f"Monthly: {counts['monthly']}, "
|
||||
f"Yearly: {counts['yearly']}, "
|
||||
f"Total: {counts['total']}"
|
||||
)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Build missing charts for all users"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--user-id",
|
||||
type=int,
|
||||
help="Build charts for a specific user only",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--full-rebuild",
|
||||
action="store_true",
|
||||
help="Delete existing chart records and rebuild from scratch",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
full_rebuild = options["full_rebuild"]
|
||||
|
||||
if full_rebuild:
|
||||
self.stdout.write("Full rebuild requested - will delete existing records")
|
||||
|
||||
if options["user_id"]:
|
||||
user = User.objects.filter(id=options["user_id"]).first()
|
||||
if not user:
|
||||
self.stderr.write(
|
||||
self.style.ERROR(f"User with id {options['user_id']} not found")
|
||||
)
|
||||
return
|
||||
users = [user]
|
||||
action = "Full rebuild for" if full_rebuild else "Progressive build for"
|
||||
self.stdout.write(f"{action} user: {user}")
|
||||
else:
|
||||
users = User.objects.all()
|
||||
action = "Full rebuild for" if full_rebuild else "Progressive build for"
|
||||
self.stdout.write(f"{action} charts for {users.count()} users...")
|
||||
|
||||
before_counts = get_chart_counts()
|
||||
self.stdout.write("Chart counts before:")
|
||||
print_chart_summary()
|
||||
|
||||
for user in users:
|
||||
try:
|
||||
logger.info(f"Building charts for {user}")
|
||||
if full_rebuild:
|
||||
rebuild_all_charts(user)
|
||||
else:
|
||||
build_charts_since(user)
|
||||
self.stdout.write(self.style.SUCCESS(f" OK {user}"))
|
||||
except Exception as e:
|
||||
self.stderr.write(f" FAILED {user}: {e}")
|
||||
|
||||
self.stdout.write("Chart counts after (diff):")
|
||||
print_chart_summary(before_counts)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Done!"))
|
||||
@ -0,0 +1,87 @@
|
||||
import logging
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db.models import Count, Max
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MEDIA_FIELDS = [
|
||||
"artist",
|
||||
"album",
|
||||
"track",
|
||||
"tv_series",
|
||||
"video",
|
||||
"podcast",
|
||||
"podcast_episode",
|
||||
"board_game",
|
||||
"trail",
|
||||
"geo_location",
|
||||
"food",
|
||||
"book",
|
||||
]
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Clean up duplicate ChartRecords"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Show what would be deleted without actually deleting",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from charts.models import ChartRecord
|
||||
|
||||
dry_run = options["dry_run"]
|
||||
|
||||
total_deleted = 0
|
||||
|
||||
for media_field in MEDIA_FIELDS:
|
||||
field_id = f"{media_field}_id"
|
||||
|
||||
duplicates = (
|
||||
ChartRecord.objects.filter(**{f"{field_id}__isnull": False})
|
||||
.values("user_id", "year", "month", "week", "day", field_id)
|
||||
.annotate(
|
||||
count=Count("id"),
|
||||
max_id=Max("id"),
|
||||
)
|
||||
.filter(count__gt=1)
|
||||
)
|
||||
|
||||
for dup in duplicates:
|
||||
max_id = dup["max_id"]
|
||||
to_delete = list(
|
||||
ChartRecord.objects.filter(
|
||||
user_id=dup["user_id"],
|
||||
year=dup["year"],
|
||||
month=dup["month"],
|
||||
week=dup["week"],
|
||||
day=dup["day"],
|
||||
**{field_id: dup[field_id]},
|
||||
)
|
||||
.exclude(id=max_id)
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(
|
||||
f"Would delete {len(to_delete)} duplicate ChartRecords "
|
||||
f"({media_field}, user={dup['user_id']}, year={dup['year']}, "
|
||||
f"month={dup['month']}, week={dup['week']}, day={dup['day']})"
|
||||
)
|
||||
else:
|
||||
deleted, _ = ChartRecord.objects.filter(id__in=to_delete).delete()
|
||||
total_deleted += deleted
|
||||
self.stdout.write(
|
||||
f"Deleted {deleted} duplicate ChartRecords "
|
||||
f"({media_field}, user={dup['user_id']}, year={dup['year']}, "
|
||||
f"month={dup['month']}, week={dup['week']}, day={dup['day']})"
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(self.style.WARNING("Dry run complete - no changes made"))
|
||||
else:
|
||||
self.stdout.write(self.style.SUCCESS(f"Total deleted: {total_deleted}"))
|
||||
@ -0,0 +1,22 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import connection
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Copy ChartRecord data from scrobbles to charts app"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO charts_chartrecord
|
||||
(created, modified, user_id, rank, count, year, month, week, day,
|
||||
video_id, series_id, artist_id, track_id, period_start, period_end)
|
||||
SELECT created, modified, user_id, rank, count, year, month, week, day,
|
||||
video_id, series_id, artist_id, track_id, period_start, period_end
|
||||
FROM scrobbles_chartrecord
|
||||
"""
|
||||
)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS("Copied ChartRecord data to charts app")
|
||||
)
|
||||
239
vrobbler/apps/charts/migrations/0001_initial.py
Normal file
239
vrobbler/apps/charts/migrations/0001_initial.py
Normal file
@ -0,0 +1,239 @@
|
||||
# Generated by Django 4.2.29 on 2026-03-21 19:34
|
||||
|
||||
import django.db.models.deletion
|
||||
import django_extensions.db.fields
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("boardgames", "0013_boardgame_publishers"),
|
||||
("locations", "0008_remove_geolocation_run_time_seconds_and_more"),
|
||||
("podcasts", "0018_remove_podcastepisode_run_time_seconds_and_more"),
|
||||
("music", "0029_remove_track_run_time_seconds_and_more"),
|
||||
("videos", "0025_add_imdb_id_unique"),
|
||||
("trails", "0006_remove_trail_run_time_seconds_and_more"),
|
||||
("books", "0033_alter_book_issue_number_alter_book_volume_number"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("foods", "0005_food_nutrition_and_sources"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ChartRecord",
|
||||
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"
|
||||
),
|
||||
),
|
||||
("year", models.IntegerField()),
|
||||
("month", models.IntegerField(blank=True, null=True)),
|
||||
("week", models.IntegerField(blank=True, null=True)),
|
||||
("day", models.IntegerField(blank=True, null=True)),
|
||||
("rank", models.IntegerField()),
|
||||
("count", models.IntegerField(default=0)),
|
||||
(
|
||||
"album",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="music.album",
|
||||
),
|
||||
),
|
||||
(
|
||||
"artist",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="music.artist",
|
||||
),
|
||||
),
|
||||
(
|
||||
"board_game",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="boardgames.boardgame",
|
||||
),
|
||||
),
|
||||
(
|
||||
"book",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="books.book",
|
||||
),
|
||||
),
|
||||
(
|
||||
"food",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="foods.food",
|
||||
),
|
||||
),
|
||||
(
|
||||
"geo_location",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="locations.geolocation",
|
||||
),
|
||||
),
|
||||
(
|
||||
"podcast",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="podcasts.podcast",
|
||||
),
|
||||
),
|
||||
(
|
||||
"podcast_episode",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="podcasts.podcastepisode",
|
||||
),
|
||||
),
|
||||
(
|
||||
"track",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="music.track",
|
||||
),
|
||||
),
|
||||
(
|
||||
"trail",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="trails.trail",
|
||||
),
|
||||
),
|
||||
(
|
||||
"tv_series",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="videos.series",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"video",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="videos.video",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"indexes": [
|
||||
models.Index(
|
||||
fields=["user", "year", "artist", "rank"],
|
||||
name="charts_char_user_id_dc09e6_idx",
|
||||
),
|
||||
models.Index(
|
||||
fields=["user", "year", "album", "rank"],
|
||||
name="charts_char_user_id_218be5_idx",
|
||||
),
|
||||
models.Index(
|
||||
fields=["user", "year", "track", "rank"],
|
||||
name="charts_char_user_id_db95f8_idx",
|
||||
),
|
||||
models.Index(
|
||||
fields=["user", "year", "tv_series", "rank"],
|
||||
name="charts_char_user_id_2a16e9_idx",
|
||||
),
|
||||
models.Index(
|
||||
fields=["user", "year", "video", "rank"],
|
||||
name="charts_char_user_id_717866_idx",
|
||||
),
|
||||
models.Index(
|
||||
fields=["user", "year", "podcast", "rank"],
|
||||
name="charts_char_user_id_c79e3b_idx",
|
||||
),
|
||||
models.Index(
|
||||
fields=["user", "year", "board_game", "rank"],
|
||||
name="charts_char_user_id_73fbd2_idx",
|
||||
),
|
||||
models.Index(
|
||||
fields=["user", "year", "trail", "rank"],
|
||||
name="charts_char_user_id_ad3c30_idx",
|
||||
),
|
||||
models.Index(
|
||||
fields=["user", "year", "geo_location", "rank"],
|
||||
name="charts_char_user_id_245e01_idx",
|
||||
),
|
||||
models.Index(
|
||||
fields=["user", "year", "food", "rank"],
|
||||
name="charts_char_user_id_f9fe30_idx",
|
||||
),
|
||||
models.Index(
|
||||
fields=["user", "year", "book", "rank"],
|
||||
name="charts_char_user_id_e0321e_idx",
|
||||
),
|
||||
models.Index(
|
||||
fields=["user", "year", "week", "artist", "rank"],
|
||||
name="charts_char_user_id_18f06e_idx",
|
||||
),
|
||||
models.Index(
|
||||
fields=["user", "year", "week", "tv_series", "rank"],
|
||||
name="charts_char_user_id_8dcc60_idx",
|
||||
),
|
||||
models.Index(
|
||||
fields=["user", "year", "month", "artist", "rank"],
|
||||
name="charts_char_user_id_3ef8c3_idx",
|
||||
),
|
||||
models.Index(
|
||||
fields=["user", "year", "month", "tv_series", "rank"],
|
||||
name="charts_char_user_id_dd54d7_idx",
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
vrobbler/apps/charts/migrations/__init__.py
Normal file
0
vrobbler/apps/charts/migrations/__init__.py
Normal file
152
vrobbler/apps/charts/models.py
Normal file
152
vrobbler/apps/charts/models.py
Normal file
@ -0,0 +1,152 @@
|
||||
import calendar
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
|
||||
User = get_user_model()
|
||||
BNULL = {"blank": True, "null": True}
|
||||
|
||||
|
||||
class ChartRecord(TimeStampedModel):
|
||||
"""Stores rankings for a user/media/time period combination.
|
||||
|
||||
Example entries:
|
||||
- Bjork was #3 artist in 2022
|
||||
- Homogenic was #10 album in April 2025
|
||||
- The Office was #1 TV series in Week #34 2024
|
||||
"""
|
||||
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, **BNULL)
|
||||
|
||||
year = models.IntegerField()
|
||||
month = models.IntegerField(**BNULL)
|
||||
week = models.IntegerField(**BNULL)
|
||||
day = models.IntegerField(**BNULL)
|
||||
|
||||
artist = models.ForeignKey("music.Artist", on_delete=models.CASCADE, **BNULL)
|
||||
album = models.ForeignKey("music.Album", on_delete=models.CASCADE, **BNULL)
|
||||
track = models.ForeignKey("music.Track", on_delete=models.CASCADE, **BNULL)
|
||||
tv_series = models.ForeignKey("videos.Series", on_delete=models.CASCADE, **BNULL)
|
||||
video = models.ForeignKey("videos.Video", on_delete=models.CASCADE, **BNULL)
|
||||
podcast = models.ForeignKey("podcasts.Podcast", on_delete=models.CASCADE, **BNULL)
|
||||
podcast_episode = models.ForeignKey(
|
||||
"podcasts.PodcastEpisode", on_delete=models.CASCADE, **BNULL
|
||||
)
|
||||
board_game = models.ForeignKey(
|
||||
"boardgames.BoardGame", on_delete=models.CASCADE, **BNULL
|
||||
)
|
||||
trail = models.ForeignKey("trails.Trail", on_delete=models.CASCADE, **BNULL)
|
||||
geo_location = models.ForeignKey(
|
||||
"locations.GeoLocation", on_delete=models.CASCADE, **BNULL
|
||||
)
|
||||
food = models.ForeignKey("foods.Food", on_delete=models.CASCADE, **BNULL)
|
||||
book = models.ForeignKey("books.Book", on_delete=models.CASCADE, **BNULL)
|
||||
|
||||
rank = models.IntegerField()
|
||||
count = models.IntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=["user", "year", "artist", "rank"]),
|
||||
models.Index(fields=["user", "year", "album", "rank"]),
|
||||
models.Index(fields=["user", "year", "track", "rank"]),
|
||||
models.Index(fields=["user", "year", "tv_series", "rank"]),
|
||||
models.Index(fields=["user", "year", "video", "rank"]),
|
||||
models.Index(fields=["user", "year", "podcast", "rank"]),
|
||||
models.Index(fields=["user", "year", "board_game", "rank"]),
|
||||
models.Index(fields=["user", "year", "trail", "rank"]),
|
||||
models.Index(fields=["user", "year", "geo_location", "rank"]),
|
||||
models.Index(fields=["user", "year", "food", "rank"]),
|
||||
models.Index(fields=["user", "year", "book", "rank"]),
|
||||
models.Index(fields=["user", "year", "week", "artist", "rank"]),
|
||||
models.Index(fields=["user", "year", "week", "tv_series", "rank"]),
|
||||
models.Index(fields=["user", "year", "month", "artist", "rank"]),
|
||||
models.Index(fields=["user", "year", "month", "tv_series", "rank"]),
|
||||
]
|
||||
|
||||
@property
|
||||
def media_obj(self):
|
||||
for attr in [
|
||||
"artist",
|
||||
"album",
|
||||
"track",
|
||||
"tv_series",
|
||||
"video",
|
||||
"podcast",
|
||||
"podcast_episode",
|
||||
"board_game",
|
||||
"trail",
|
||||
"geo_location",
|
||||
"food",
|
||||
"book",
|
||||
]:
|
||||
obj = getattr(self, attr)
|
||||
if obj:
|
||||
return obj
|
||||
return None
|
||||
|
||||
@property
|
||||
def media_type(self):
|
||||
mapping = {
|
||||
"artist": "Artist",
|
||||
"album": "Album",
|
||||
"track": "Track",
|
||||
"tv_series": "TV Series",
|
||||
"video": "Video",
|
||||
"podcast": "Podcast",
|
||||
"podcast_episode": "Podcast Episode",
|
||||
"board_game": "Board Game",
|
||||
"trail": "Trail",
|
||||
"geo_location": "Location",
|
||||
"food": "Food",
|
||||
"book": "Book",
|
||||
}
|
||||
for attr, name in mapping.items():
|
||||
if getattr(self, attr) is not None:
|
||||
return name
|
||||
return None
|
||||
|
||||
@property
|
||||
def period_str(self) -> str:
|
||||
if self.day:
|
||||
return f"{calendar.month_name[self.month]} {self.day}, {self.year}"
|
||||
if self.week:
|
||||
return f"Week {self.week}, {self.year}"
|
||||
if self.month:
|
||||
return f"{calendar.month_name[self.month]} {self.year}"
|
||||
return str(self.year)
|
||||
|
||||
@property
|
||||
def period_type(self) -> str:
|
||||
if self.day:
|
||||
return "day"
|
||||
if self.week:
|
||||
return "week"
|
||||
if self.month:
|
||||
return "month"
|
||||
return "year"
|
||||
|
||||
def __str__(self):
|
||||
obj = self.media_obj
|
||||
obj_name = str(obj) if obj else "Unknown"
|
||||
return f"#{self.rank} {obj_name} in {self.period_str}"
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("charts:charts-home")
|
||||
|
||||
@property
|
||||
def chart_url(self) -> str:
|
||||
url = reverse("charts:charts-home")
|
||||
date_str = str(self.year)
|
||||
if self.week:
|
||||
date_str = f"{self.year}-W{self.week}"
|
||||
elif self.month:
|
||||
date_str = f"{self.year}-{self.month:02d}"
|
||||
return f"{url}?date={date_str}"
|
||||
|
||||
@property
|
||||
def rank_emoji(self) -> str:
|
||||
emojis = {1: "🥇", 2: "🥈", 3: "🥉"}
|
||||
return emojis.get(self.rank, f"#{self.rank}")
|
||||
357
vrobbler/apps/charts/templates/charts/chart_index.html
Normal file
357
vrobbler/apps/charts/templates/charts/chart_index.html
Normal file
@ -0,0 +1,357 @@
|
||||
{% extends "base_list.html" %}
|
||||
{% load chart_tags %}
|
||||
|
||||
{% block title %}Charts{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
.container { margin-bottom:100px; }
|
||||
h2 { padding-top:20px; }
|
||||
.image-wrapper {
|
||||
contain: content;
|
||||
}
|
||||
.image-wrapper :hover {
|
||||
background:rgba(0,0,0,0.3);
|
||||
}
|
||||
.caption {
|
||||
position: fixed;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
padding: 3px;
|
||||
font-size: 90%;
|
||||
color:white;
|
||||
background:rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.caption-medium {
|
||||
position: fixed;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
padding: 3px;
|
||||
font-size: 75%;
|
||||
color:white;
|
||||
background:rgba(0,0,0,0.4);
|
||||
|
||||
}
|
||||
.caption-small {
|
||||
position: fixed;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
padding: 3px;
|
||||
font-size: 60%;
|
||||
color:white;
|
||||
background:rgba(0,0,0,0.4);
|
||||
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block lists %}
|
||||
<div class="row mb-3">
|
||||
<div class="col-auto">
|
||||
{% if prev_period %}
|
||||
<a href="{{ prev_period }}" class="btn btn-outline-secondary btn-sm">← Previous</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-auto text-center">
|
||||
<span class="badge bg-secondary">{{ period_type|title }}</span>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
{% if next_period %}
|
||||
<a href="{{ next_period }}" class="btn btn-outline-secondary btn-sm">Next →</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-auto ms-auto">
|
||||
{% if chart_years %}
|
||||
<div class="btn-group btn-group-sm">
|
||||
{% for y in chart_years|slice:":5" %}
|
||||
<a href="/charts/?date={{ y }}" class="btn btn-sm {% if year == y %}btn-primary{% else %}btn-outline-secondary{% endif %}">{{ y }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<small class="text-muted me-2">Months:</small>
|
||||
{% for m in "1,2,3,4,5,6,7,8,9,10,11,12"|split:"," %}
|
||||
<a href="/charts/?date={{ year }}-{{ m|stringformat:"02d" }}" class="btn btn-xs {% if month == m %}btn-primary{% else %}btn-outline-secondary{% endif %}" style="padding:1px 4px;font-size:10px;">{{ m }}</a>
|
||||
{% endfor %}
|
||||
<span class="text-muted mx-2">|</span>
|
||||
<small class="text-muted me-2">Weeks:</small>
|
||||
{% for w in "1,4,8,13,17,21,26,30,34,39,43,47,52"|split:"," %}
|
||||
<a href="/charts/?date={{ year }}-W{{ w|stringformat:"02d" }}" class="btn btn-xs {% if week == w %}btn-primary{% else %}btn-outline-secondary{% endif %}" style="padding:1px 3px;font-size:9px;">{{ w }}</a>
|
||||
{% endfor %}
|
||||
<a href="#" class="btn btn-xs btn-outline-secondary" style="padding:1px 3px;font-size:9px;" onclick="var w=prompt('Week number (1-52):');if(w)this.href='/charts/?date={{ year }}-W'+w.padStart(2,'0');return false;">+</a>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<a href="{% url 'charts:spotify-tracks' %}" class="btn btn-sm btn-outline-secondary">🎵 Spotify Tracks</a>
|
||||
<a href="{% url 'charts:bandcamp-tracks' %}" class="btn btn-sm btn-outline-secondary">🎵 Bandcamp Tracks</a>
|
||||
</div>
|
||||
</div>
|
||||
{% if chart_type == "maloja" %}
|
||||
{% include "scrobbles/_top_charts.html" %}
|
||||
{% else %}
|
||||
<div class="row">
|
||||
{% if charts.artist %}
|
||||
<div class="col-md">
|
||||
<h2>🎤 Top Artists</h2>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Rank</th>
|
||||
<th scope="col">Artist</th>
|
||||
<th scope="col">Scrobbles</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for chart in charts.artist %}
|
||||
<tr>
|
||||
<td>{{chart.rank}}</td>
|
||||
<td><a href="{{chart.artist.get_absolute_url}}">{{chart.artist.name}}</a></td>
|
||||
<td>{{chart.count}}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if charts.track %}
|
||||
<div class="col-md">
|
||||
<h2>🎵 Top Tracks</h2>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Rank</th>
|
||||
<th scope="col">Track</th>
|
||||
<th scope="col">Scrobbles</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for chart in charts.track %}
|
||||
<tr>
|
||||
<td>{{chart.rank}}</td>
|
||||
<td><a href="{{chart.track.get_absolute_url}}">{{chart.track.title}}</a></td>
|
||||
<td>{{chart.count}}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if charts.tv_series %}
|
||||
<div class="col-md">
|
||||
<h2>📺 Top TV Shows</h2>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Rank</th>
|
||||
<th scope="col">TV Show</th>
|
||||
<th scope="col">Episodes Watched</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for chart in charts.tv_series %}
|
||||
<tr>
|
||||
<td>{{chart.rank}}</td>
|
||||
<td><a href="{{chart.tv_series.get_absolute_url}}">{{chart.tv_series.name}}</a></td>
|
||||
<td>{{chart.count}}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if charts.album %}
|
||||
<div class="col-md">
|
||||
<h2>💿 Top Albums</h2>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Rank</th>
|
||||
<th scope="col">Album</th>
|
||||
<th scope="col">Scrobbles</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for chart in charts.album %}
|
||||
<tr>
|
||||
<td>{{chart.rank}}</td>
|
||||
<td><a href="{{chart.album.get_absolute_url}}">{{chart.album.title}}</a></td>
|
||||
<td>{{chart.count}}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if charts.video %}
|
||||
<div class="col-md">
|
||||
<h2>🎬 Top Videos</h2>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Rank</th>
|
||||
<th scope="col">Video</th>
|
||||
<th scope="col">Plays</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for chart in charts.video %}
|
||||
<tr>
|
||||
<td>{{chart.rank}}</td>
|
||||
<td><a href="{{chart.video.get_absolute_url}}">{{chart.video.title}}</a></td>
|
||||
<td>{{chart.count}}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if charts.board_game %}
|
||||
<div class="col-md">
|
||||
<h2>🎲 Top Board Games</h2>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Rank</th>
|
||||
<th scope="col">Board Game</th>
|
||||
<th scope="col">Plays</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for chart in charts.board_game %}
|
||||
<tr>
|
||||
<td>{{chart.rank}}</td>
|
||||
<td><a href="{{chart.board_game.get_absolute_url}}">{{chart.board_game.title}}</a></td>
|
||||
<td>{{chart.count}}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if charts.book %}
|
||||
<div class="col-md">
|
||||
<h2>📚 Top Books</h2>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Rank</th>
|
||||
<th scope="col">Book</th>
|
||||
<th scope="col">Pages Read</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for chart in charts.book %}
|
||||
<tr>
|
||||
<td>{{chart.rank}}</td>
|
||||
<td><a href="{{chart.book.get_absolute_url}}">{{chart.book.title}}</a></td>
|
||||
<td>{{chart.count}}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if charts.food %}
|
||||
<div class="col-md">
|
||||
<h2>🍽️ Top Foods</h2>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Rank</th>
|
||||
<th scope="col">Food</th>
|
||||
<th scope="col">Times Eaten</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for chart in charts.food %}
|
||||
<tr>
|
||||
<td>{{chart.rank}}</td>
|
||||
<td><a href="{{chart.food.get_absolute_url}}">{{chart.food.title}}</a></td>
|
||||
<td>{{chart.count}}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if charts.podcast %}
|
||||
<div class="col-md">
|
||||
<h2>🎙️ Top Podcasts</h2>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Rank</th>
|
||||
<th scope="col">Podcast</th>
|
||||
<th scope="col">Episodes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for chart in charts.podcast %}
|
||||
<tr>
|
||||
<td>{{chart.rank}}</td>
|
||||
<td><a href="{{chart.podcast.get_absolute_url}}">{{chart.podcast.title}}</a></td>
|
||||
<td>{{chart.count}}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if charts.trail %}
|
||||
<div class="col-md">
|
||||
<h2>🥾 Top Trails</h2>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Rank</th>
|
||||
<th scope="col">Trail</th>
|
||||
<th scope="col">Hikes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for chart in charts.trail %}
|
||||
<tr>
|
||||
<td>{{chart.rank}}</td>
|
||||
<td><a href="{{chart.trail.get_absolute_url}}">{{chart.trail.title}}</a></td>
|
||||
<td>{{chart.count}}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
0
vrobbler/apps/charts/templatetags/__init__.py
Normal file
0
vrobbler/apps/charts/templatetags/__init__.py
Normal file
15
vrobbler/apps/charts/templatetags/chart_tags.py
Normal file
15
vrobbler/apps/charts/templatetags/chart_tags.py
Normal file
@ -0,0 +1,15 @@
|
||||
from django import template
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter
|
||||
def get_item(dictionary, key):
|
||||
if isinstance(dictionary, dict):
|
||||
return dictionary.get(key)
|
||||
return None
|
||||
|
||||
|
||||
@register.filter
|
||||
def split(value, arg):
|
||||
return value.split(arg)
|
||||
268
vrobbler/apps/charts/tests/test_utils.py
Normal file
268
vrobbler/apps/charts/tests/test_utils.py
Normal file
@ -0,0 +1,268 @@
|
||||
import datetime
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
|
||||
from charts.models import ChartRecord
|
||||
from charts.utils import build_charts
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user(db):
|
||||
return User.objects.create_user(username="charttester", password="testpass123")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scrobble_data(user, db):
|
||||
from scrobbles.models import Scrobble
|
||||
from music.models import Track, Artist, Album
|
||||
|
||||
artist = Artist.objects.create(name="Test Artist")
|
||||
album = Album.objects.create(name="Test Album", album_artist=artist)
|
||||
track = Track.objects.create(title="Test Track", artist=artist, album=album)
|
||||
|
||||
return {
|
||||
"user": user,
|
||||
"track": track,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_build_daily_charts_creates_daily_record(user, scrobble_data):
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
Scrobble.objects.create(
|
||||
user=user,
|
||||
track=scrobble_data["track"],
|
||||
played_to_completion=True,
|
||||
timestamp=timezone.make_aware(datetime.datetime(2024, 3, 15, 10, 0)),
|
||||
)
|
||||
|
||||
build_charts(user, year=2024, month=3, day=15)
|
||||
|
||||
daily = ChartRecord.objects.filter(
|
||||
user=user,
|
||||
year=2024,
|
||||
month=3,
|
||||
day=15,
|
||||
week__isnull=True,
|
||||
track__isnull=False,
|
||||
)
|
||||
assert daily.exists()
|
||||
assert daily.first().track == scrobble_data["track"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_build_monthly_charts_creates_monthly_record(user, scrobble_data):
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
Scrobble.objects.create(
|
||||
user=user,
|
||||
track=scrobble_data["track"],
|
||||
played_to_completion=True,
|
||||
timestamp=timezone.make_aware(datetime.datetime(2024, 3, 15, 10, 0)),
|
||||
)
|
||||
|
||||
build_charts(user, year=2024, month=3)
|
||||
|
||||
monthly = ChartRecord.objects.filter(
|
||||
user=user,
|
||||
year=2024,
|
||||
month=3,
|
||||
week__isnull=True,
|
||||
day__isnull=True,
|
||||
track__isnull=False,
|
||||
)
|
||||
assert monthly.exists()
|
||||
assert monthly.first().track == scrobble_data["track"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_build_weekly_charts_creates_weekly_record(user, scrobble_data):
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
Scrobble.objects.create(
|
||||
user=user,
|
||||
track=scrobble_data["track"],
|
||||
played_to_completion=True,
|
||||
timestamp=timezone.make_aware(datetime.datetime(2024, 3, 15, 10, 0)),
|
||||
)
|
||||
|
||||
build_charts(user, year=2024, week=11)
|
||||
|
||||
weekly = ChartRecord.objects.filter(
|
||||
user=user,
|
||||
year=2024,
|
||||
week=11,
|
||||
month__isnull=True,
|
||||
day__isnull=True,
|
||||
track__isnull=False,
|
||||
)
|
||||
assert weekly.exists()
|
||||
assert weekly.first().track == scrobble_data["track"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_build_yearly_charts_creates_yearly_record(user, scrobble_data):
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
Scrobble.objects.create(
|
||||
user=user,
|
||||
track=scrobble_data["track"],
|
||||
played_to_completion=True,
|
||||
timestamp=timezone.make_aware(datetime.datetime(2024, 3, 15, 10, 0)),
|
||||
)
|
||||
|
||||
build_charts(user, year=2024)
|
||||
|
||||
yearly = ChartRecord.objects.filter(
|
||||
user=user,
|
||||
year=2024,
|
||||
month__isnull=True,
|
||||
week__isnull=True,
|
||||
day__isnull=True,
|
||||
track__isnull=False,
|
||||
)
|
||||
assert yearly.exists()
|
||||
assert yearly.first().track == scrobble_data["track"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_build_charts_ranks_by_count(user, scrobble_data):
|
||||
from scrobbles.models import Scrobble
|
||||
from music.models import Track, Artist
|
||||
|
||||
track1 = scrobble_data["track"]
|
||||
track2 = Track.objects.create(
|
||||
title="Test Track 2",
|
||||
artist=track1.artist,
|
||||
album=track1.album,
|
||||
)
|
||||
|
||||
Scrobble.objects.create(
|
||||
user=user,
|
||||
track=track1,
|
||||
played_to_completion=True,
|
||||
timestamp=timezone.make_aware(datetime.datetime(2024, 3, 15, 10, 0)),
|
||||
)
|
||||
Scrobble.objects.create(
|
||||
user=user,
|
||||
track=track1,
|
||||
played_to_completion=True,
|
||||
timestamp=timezone.make_aware(datetime.datetime(2024, 3, 15, 11, 0)),
|
||||
)
|
||||
Scrobble.objects.create(
|
||||
user=user,
|
||||
track=track2,
|
||||
played_to_completion=True,
|
||||
timestamp=timezone.make_aware(datetime.datetime(2024, 3, 15, 12, 0)),
|
||||
)
|
||||
|
||||
build_charts(user, year=2024, month=3, day=15)
|
||||
|
||||
daily = ChartRecord.objects.filter(
|
||||
user=user,
|
||||
year=2024,
|
||||
month=3,
|
||||
day=15,
|
||||
track__isnull=False,
|
||||
).order_by("rank")
|
||||
|
||||
assert daily.count() == 2
|
||||
assert daily[0].track == track1
|
||||
assert daily[0].rank == 1
|
||||
assert daily[0].count == 2
|
||||
assert daily[1].track == track2
|
||||
assert daily[1].rank == 2
|
||||
assert daily[1].count == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_build_daily_charts_deletes_existing_daily_record(user, scrobble_data):
|
||||
from scrobbles.models import Scrobble
|
||||
from music.models import Track, Artist
|
||||
|
||||
track1 = scrobble_data["track"]
|
||||
track2 = Track.objects.create(
|
||||
title="Test Track 2",
|
||||
artist=track1.artist,
|
||||
album=track1.album,
|
||||
)
|
||||
|
||||
Scrobble.objects.create(
|
||||
user=user,
|
||||
track=track1,
|
||||
played_to_completion=True,
|
||||
timestamp=timezone.make_aware(datetime.datetime(2024, 3, 15, 10, 0)),
|
||||
)
|
||||
|
||||
build_charts(user, year=2024, month=3, day=15)
|
||||
assert ChartRecord.objects.filter(
|
||||
user=user, year=2024, month=3, day=15, track=track1
|
||||
).exists()
|
||||
|
||||
Scrobble.objects.create(
|
||||
user=user,
|
||||
track=track2,
|
||||
played_to_completion=True,
|
||||
timestamp=timezone.make_aware(datetime.datetime(2024, 3, 15, 11, 0)),
|
||||
)
|
||||
|
||||
build_charts(user, year=2024, month=3, day=15)
|
||||
|
||||
daily = ChartRecord.objects.filter(
|
||||
user=user,
|
||||
year=2024,
|
||||
month=3,
|
||||
day=15,
|
||||
track__isnull=False,
|
||||
)
|
||||
assert daily.count() == 2
|
||||
assert daily.filter(track=track1).exists()
|
||||
assert daily.filter(track=track2).exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_build_monthly_charts_deletes_existing_monthly_record(user, scrobble_data):
|
||||
from scrobbles.models import Scrobble
|
||||
from music.models import Track, Artist
|
||||
|
||||
track1 = scrobble_data["track"]
|
||||
track2 = Track.objects.create(
|
||||
title="Test Track 2",
|
||||
artist=track1.artist,
|
||||
album=track1.album,
|
||||
)
|
||||
|
||||
Scrobble.objects.create(
|
||||
user=user,
|
||||
track=track1,
|
||||
played_to_completion=True,
|
||||
timestamp=timezone.make_aware(datetime.datetime(2024, 3, 15, 10, 0)),
|
||||
)
|
||||
|
||||
build_charts(user, year=2024, month=3)
|
||||
|
||||
ChartRecord.objects.filter(user=user, year=2024, month=3, track=track1).delete()
|
||||
|
||||
Scrobble.objects.create(
|
||||
user=user,
|
||||
track=track2,
|
||||
played_to_completion=True,
|
||||
timestamp=timezone.make_aware(datetime.datetime(2024, 3, 16, 11, 0)),
|
||||
)
|
||||
|
||||
build_charts(user, year=2024, month=3)
|
||||
|
||||
monthly = ChartRecord.objects.filter(
|
||||
user=user,
|
||||
year=2024,
|
||||
month=3,
|
||||
week__isnull=True,
|
||||
day__isnull=True,
|
||||
track__isnull=False,
|
||||
)
|
||||
assert monthly.count() == 2
|
||||
18
vrobbler/apps/charts/urls.py
Normal file
18
vrobbler/apps/charts/urls.py
Normal file
@ -0,0 +1,18 @@
|
||||
from charts.views import (
|
||||
BandcampTracksView,
|
||||
BirdsChartView,
|
||||
ChartDetailView,
|
||||
ChartRecordView,
|
||||
SpotifyTracksView,
|
||||
)
|
||||
from django.urls import path
|
||||
|
||||
app_name = "charts"
|
||||
|
||||
urlpatterns = [
|
||||
path("charts/", ChartRecordView.as_view(), name="charts-home"),
|
||||
path("charts/spotify/", SpotifyTracksView.as_view(), name="spotify-tracks"),
|
||||
path("charts/bandcamp/", BandcampTracksView.as_view(), name="bandcamp-tracks"),
|
||||
path("charts/birds/", BirdsChartView.as_view(), name="birds-chart"),
|
||||
path("charts/<slug:media_type>/", ChartDetailView.as_view(), name="chart-detail"),
|
||||
]
|
||||
419
vrobbler/apps/charts/utils.py
Normal file
419
vrobbler/apps/charts/utils.py
Normal file
@ -0,0 +1,419 @@
|
||||
import calendar
|
||||
import datetime
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import pytz
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.db.models import Count, Q
|
||||
from django.utils import timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_time_filter(
|
||||
year: Optional[int],
|
||||
month: Optional[int],
|
||||
week: Optional[int],
|
||||
day: Optional[int],
|
||||
user,
|
||||
):
|
||||
"""Build a Q filter for scrobble timestamps based on time period."""
|
||||
if not year:
|
||||
return Q()
|
||||
|
||||
if user and user.is_authenticated:
|
||||
tz = pytz.timezone(user.profile.timezone)
|
||||
else:
|
||||
tz = pytz.timezone(settings.TIME_ZONE)
|
||||
|
||||
if day and month:
|
||||
start = timezone.make_aware(datetime.datetime(year, month, day, 0, 0, 0))
|
||||
end = timezone.make_aware(datetime.datetime(year, month, day, 23, 59, 59))
|
||||
elif week:
|
||||
period = datetime.date.fromisocalendar(year, week, 1)
|
||||
start = timezone.make_aware(
|
||||
datetime.datetime.combine(period, datetime.time.min)
|
||||
)
|
||||
end = start + datetime.timedelta(days=6, seconds=86399)
|
||||
elif month:
|
||||
last_day = calendar.monthrange(year, month)[1]
|
||||
start = timezone.make_aware(datetime.datetime(year, month, 1))
|
||||
end = timezone.make_aware(datetime.datetime(year, month, last_day, 23, 59, 59))
|
||||
else:
|
||||
start = timezone.make_aware(datetime.datetime(year, 1, 1))
|
||||
end = timezone.make_aware(datetime.datetime(year, 12, 31, 23, 59, 59))
|
||||
|
||||
return Q(timestamp__gte=start, timestamp__lte=end)
|
||||
|
||||
|
||||
def build_charts(
|
||||
user,
|
||||
year: Optional[int] = None,
|
||||
month: Optional[int] = None,
|
||||
week: Optional[int] = None,
|
||||
day: Optional[int] = None,
|
||||
media_types: Optional[list] = None,
|
||||
):
|
||||
"""Build chart records for all or specified media types using upsert."""
|
||||
ChartRecord = apps.get_model("charts", "ChartRecord")
|
||||
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
||||
|
||||
if media_types is None:
|
||||
media_types = [
|
||||
"artist",
|
||||
"album",
|
||||
"track",
|
||||
"tv_series",
|
||||
"video",
|
||||
"podcast",
|
||||
"podcast_episode",
|
||||
"board_game",
|
||||
"trail",
|
||||
"food",
|
||||
"book",
|
||||
]
|
||||
|
||||
period_filter = Q(year=year)
|
||||
if day and month:
|
||||
period_filter = (
|
||||
period_filter & Q(day=day) & Q(month=month) & Q(week__isnull=True)
|
||||
)
|
||||
elif week:
|
||||
period_filter = (
|
||||
period_filter & Q(week=week) & Q(day__isnull=True) & Q(month__isnull=True)
|
||||
)
|
||||
elif month:
|
||||
period_filter = (
|
||||
period_filter & Q(month=month) & Q(week__isnull=True) & Q(day__isnull=True)
|
||||
)
|
||||
else:
|
||||
period_filter = (
|
||||
period_filter
|
||||
& Q(month__isnull=True)
|
||||
& Q(week__isnull=True)
|
||||
& Q(day__isnull=True)
|
||||
)
|
||||
|
||||
time_filter = get_time_filter(year, month, week, day, user)
|
||||
base_qs = Scrobble.objects.filter(user=user, played_to_completion=True)
|
||||
|
||||
if time_filter:
|
||||
base_qs = base_qs.filter(time_filter)
|
||||
|
||||
media_config = {
|
||||
"artist": {
|
||||
"filter": Q(track__isnull=False) & Q(track__artists__isnull=False),
|
||||
"values": "track__artists",
|
||||
"annotate": Count("id", distinct=True),
|
||||
},
|
||||
"album": {
|
||||
"filter": Q(track__isnull=False) & Q(track__album__isnull=False),
|
||||
"values": "track__album",
|
||||
"annotate": Count("id", distinct=True),
|
||||
},
|
||||
"track": {
|
||||
"filter": Q(track__isnull=False),
|
||||
"values": "track",
|
||||
"annotate": Count("id", distinct=True),
|
||||
},
|
||||
"tv_series": {
|
||||
"filter": Q(video__isnull=False) & Q(video__tv_series__isnull=False),
|
||||
"values": "video__tv_series",
|
||||
"annotate": Count("id", distinct=True),
|
||||
},
|
||||
"video": {
|
||||
"filter": Q(video__isnull=False),
|
||||
"values": "video",
|
||||
"annotate": Count("id", distinct=True),
|
||||
},
|
||||
"podcast": {
|
||||
"filter": Q(podcast_episode__isnull=False)
|
||||
& Q(podcast_episode__podcast__isnull=False),
|
||||
"values": "podcast_episode__podcast",
|
||||
"annotate": Count("id", distinct=True),
|
||||
},
|
||||
"podcast_episode": {
|
||||
"filter": Q(podcast_episode__isnull=False),
|
||||
"values": "podcast_episode",
|
||||
"annotate": Count("id", distinct=True),
|
||||
},
|
||||
"board_game": {
|
||||
"filter": Q(board_game__isnull=False),
|
||||
"values": "board_game",
|
||||
"annotate": Count("id", distinct=True),
|
||||
},
|
||||
"trail": {
|
||||
"filter": Q(trail__isnull=False),
|
||||
"values": "trail",
|
||||
"annotate": Count("id", distinct=True),
|
||||
},
|
||||
"geo_location": {
|
||||
"filter": Q(geo_location__isnull=False),
|
||||
"values": "geo_location",
|
||||
"annotate": Count("id", distinct=True),
|
||||
},
|
||||
"food": {
|
||||
"filter": Q(food__isnull=False),
|
||||
"values": "food",
|
||||
"annotate": Count("id", distinct=True),
|
||||
},
|
||||
"book": {
|
||||
"filter": Q(book__isnull=False),
|
||||
"values": "book",
|
||||
"annotate": Count("id", distinct=True),
|
||||
},
|
||||
}
|
||||
|
||||
for media_type in media_types:
|
||||
if media_type not in media_config:
|
||||
continue
|
||||
|
||||
config = media_config[media_type]
|
||||
|
||||
results = list(
|
||||
base_qs.filter(config["filter"])
|
||||
.values(config["values"])
|
||||
.annotate(scrobble_count=config["annotate"])
|
||||
.order_by("-scrobble_count")
|
||||
)
|
||||
|
||||
if not results:
|
||||
continue
|
||||
|
||||
unique_counts = sorted(set(r["scrobble_count"] for r in results), reverse=True)
|
||||
ranks = {count: rank for rank, count in enumerate(unique_counts, start=1)}
|
||||
|
||||
media_field = f"{media_type}_id"
|
||||
records_to_create = []
|
||||
records_to_update = []
|
||||
|
||||
existing = ChartRecord.objects.filter(
|
||||
period_filter, user=user, **{media_field + "__isnull": False}
|
||||
)
|
||||
existing_by_media_id = {getattr(r, media_field): r for r in existing}
|
||||
found_media_ids = set()
|
||||
|
||||
for result in results:
|
||||
media_id = result[config["values"]]
|
||||
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
|
||||
)
|
||||
|
||||
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:
|
||||
"""Build charts for yesterday's date."""
|
||||
tz = pytz.timezone(settings.TIME_ZONE)
|
||||
if user and user.is_authenticated:
|
||||
tz = pytz.timezone(user.profile.timezone)
|
||||
|
||||
now = timezone.now().astimezone(tz)
|
||||
yesterday = now - datetime.timedelta(days=1)
|
||||
|
||||
logger.info(f"Building charts for {yesterday.date()} for {user}")
|
||||
|
||||
build_charts(
|
||||
user=user,
|
||||
year=yesterday.year,
|
||||
month=yesterday.month,
|
||||
day=yesterday.day,
|
||||
media_types=media_types,
|
||||
)
|
||||
|
||||
|
||||
def build_weekly_charts(
|
||||
user, year: int, week: int, media_types: Optional[list] = None
|
||||
) -> None:
|
||||
"""Build weekly charts for a specific ISO week."""
|
||||
build_charts(
|
||||
user=user,
|
||||
year=year,
|
||||
week=week,
|
||||
media_types=media_types,
|
||||
)
|
||||
|
||||
|
||||
def build_monthly_charts(
|
||||
user, year: int, month: int, media_types: Optional[list] = None
|
||||
) -> None:
|
||||
"""Build monthly charts for a specific month."""
|
||||
build_charts(
|
||||
user=user,
|
||||
year=year,
|
||||
month=month,
|
||||
media_types=media_types,
|
||||
)
|
||||
|
||||
|
||||
def build_yearly_charts(user, year: int, media_types: Optional[list] = None) -> None:
|
||||
"""Build yearly charts for a specific year."""
|
||||
build_charts(
|
||||
user=user,
|
||||
year=year,
|
||||
media_types=media_types,
|
||||
)
|
||||
|
||||
|
||||
def build_all_historical_charts(user, media_types: Optional[list] = None) -> None:
|
||||
"""Build all historical charts for a user."""
|
||||
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
||||
|
||||
first_scrobble = (
|
||||
Scrobble.objects.filter(user=user, played_to_completion=True)
|
||||
.order_by("timestamp")
|
||||
.first()
|
||||
)
|
||||
|
||||
if not first_scrobble:
|
||||
logger.info(f"No scrobbles found for {user}")
|
||||
return
|
||||
|
||||
now = timezone.now()
|
||||
current = first_scrobble.timestamp.date()
|
||||
last_year = None
|
||||
last_week = None
|
||||
|
||||
while current <= now.date():
|
||||
year = current.year
|
||||
month = current.month
|
||||
day = current.day
|
||||
week = current.isocalendar()[1]
|
||||
|
||||
build_monthly_charts(user, year, month, media_types)
|
||||
build_daily_charts(user, year, month, day, media_types)
|
||||
|
||||
if last_week != week:
|
||||
build_weekly_charts(user, year, week, media_types)
|
||||
last_week = week
|
||||
|
||||
if last_year != year:
|
||||
build_yearly_charts(user, year, media_types)
|
||||
last_year = year
|
||||
|
||||
current += datetime.timedelta(days=1)
|
||||
|
||||
|
||||
def build_daily_charts(
|
||||
user, year: int, month: int, day: int, media_types: Optional[list] = None
|
||||
) -> None:
|
||||
"""Build daily charts for a specific day."""
|
||||
build_charts(
|
||||
user=user,
|
||||
year=year,
|
||||
month=month,
|
||||
day=day,
|
||||
media_types=media_types,
|
||||
)
|
||||
|
||||
|
||||
def build_charts_since(user, media_types: Optional[list] = None) -> None:
|
||||
"""Build/update charts starting from the last known ChartRecord date."""
|
||||
ChartRecord = apps.get_model("charts", "ChartRecord")
|
||||
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
||||
|
||||
latest_daily = (
|
||||
ChartRecord.objects.filter(user=user, day__isnull=False)
|
||||
.order_by("-year", "-month", "-day")
|
||||
.first()
|
||||
)
|
||||
|
||||
if latest_daily:
|
||||
start_date = datetime.date(
|
||||
latest_daily.year, latest_daily.month, latest_daily.day
|
||||
)
|
||||
logger.info(
|
||||
f"Building charts since {start_date} for {user} (last known record)"
|
||||
)
|
||||
else:
|
||||
first_scrobble = (
|
||||
Scrobble.objects.filter(user=user, played_to_completion=True)
|
||||
.order_by("timestamp")
|
||||
.first()
|
||||
)
|
||||
if not first_scrobble:
|
||||
logger.info(f"No scrobbles found for {user}")
|
||||
return
|
||||
start_date = first_scrobble.timestamp.date()
|
||||
logger.info(
|
||||
f"No existing charts found for {user}, building from "
|
||||
f"first scrobble: {start_date}"
|
||||
)
|
||||
|
||||
start_date -= datetime.timedelta(days=1)
|
||||
last_week = start_date.isocalendar()[1]
|
||||
last_month = start_date.month
|
||||
last_year = start_date.year
|
||||
|
||||
now = timezone.now()
|
||||
current = start_date + datetime.timedelta(days=1)
|
||||
|
||||
while current <= now.date():
|
||||
year = current.year
|
||||
month = current.month
|
||||
day = current.day
|
||||
week = current.isocalendar()[1]
|
||||
|
||||
if last_month != month:
|
||||
build_monthly_charts(user, year, month, media_types)
|
||||
last_month = month
|
||||
|
||||
build_daily_charts(user, year, month, day, media_types)
|
||||
|
||||
if last_week != week:
|
||||
build_weekly_charts(user, year, week, media_types)
|
||||
last_week = week
|
||||
|
||||
if last_year != year:
|
||||
build_yearly_charts(user, year, media_types)
|
||||
last_year = year
|
||||
|
||||
current += datetime.timedelta(days=1)
|
||||
|
||||
|
||||
def rebuild_all_charts(user, media_types: Optional[list] = None) -> None:
|
||||
"""Delete all existing chart records for a user and rebuild them."""
|
||||
ChartRecord = apps.get_model("charts", "ChartRecord")
|
||||
deleted_count = ChartRecord.objects.filter(user=user).delete()[0]
|
||||
logger.info(f"Deleted {deleted_count} existing chart records for {user}")
|
||||
build_all_historical_charts(user, media_types)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user