Compare commits

...

117 Commits

Author SHA1 Message Date
a36abacfbd [widgets] Fix import of live_charts
All checks were successful
build & deploy / test (push) Successful in 1m58s
build & deploy / deploy (push) Successful in 22s
2026-03-21 14:17:20 -04:00
7666958974 [tests] Add efficiency tests for scrobbles 2026-03-21 14:09:21 -04:00
3a02bcad9d [scrobbles] Improve query efficency
All checks were successful
build & deploy / test (push) Successful in 2m1s
build & deploy / deploy (push) Successful in 23s
2026-03-21 14:05:30 -04:00
6a2cb4a881 [scrobbles] Add PersonScrobble junction table
Some checks failed
build & deploy / test (push) Successful in 6m3s
build & deploy / deploy (push) Failing after 19s
2026-03-21 13:44:37 -04:00
10af7190ab [people] Fix the scrobble count fn
All checks were successful
build & deploy / test (push) Successful in 3m46s
build & deploy / deploy (push) Successful in 23s
2026-03-21 12:15:16 -04:00
62c81c65ef [people] Count people scrobbles by board games too
All checks were successful
build & deploy / test (push) Successful in 1m54s
build & deploy / deploy (push) Successful in 24s
2026-03-21 12:00:45 -04:00
29a677142c [people] Fix if person_ids is None
All checks were successful
build & deploy / test (push) Successful in 1m56s
build & deploy / deploy (push) Successful in 21s
2026-03-21 11:38:36 -04:00
29cd2f8015 [people] Fix scrobble count on sqlite 2026-03-21 11:37:18 -04:00
5e835595c3 [people] Add boardgamearena_id field 2026-03-21 11:32:08 -04:00
b75b259bd9 [people] Add rough scrobble count field for sorting
All checks were successful
build & deploy / test (push) Successful in 1m56s
build & deploy / deploy (push) Successful in 22s
2026-03-21 11:26:41 -04:00
c1ef057ad2 [people] Clean up form and add link to settings
All checks were successful
build & deploy / test (push) Successful in 1m50s
build & deploy / deploy (push) Successful in 22s
2026-03-21 11:10:14 -04:00
8bfc5646f9 [people] Add a create/edit page for people 2026-03-21 11:07:37 -04:00
851c747a60 [lifeevents] Add life events to index and templates
All checks were successful
build & deploy / test (push) Successful in 1m55s
build & deploy / deploy (push) Successful in 27s
2026-03-21 10:55:06 -04:00
49b8b86249 [templates] Clean up widgets
All checks were successful
build & deploy / test (push) Successful in 1m53s
build & deploy / deploy (push) Successful in 25s
2026-03-20 18:37:11 -04:00
e8f120f85e [boardgames] Fix lookups using BGG library
All checks were successful
build & deploy / test (push) Successful in 1m52s
build & deploy / deploy (push) Successful in 22s
2026-03-20 14:14:28 -04:00
34a48c8c7b [widgets] Add widget css customization 2026-03-20 13:27:33 -04:00
4a18c01cdb [settings] Clean up settings widget 2026-03-20 13:21:10 -04:00
f4de4dbcb7 [settings] Fix form display and show widgets 2026-03-20 13:17:53 -04:00
64ec3c1cca [widgets] Add first run at widgets
All checks were successful
build & deploy / test (push) Successful in 2m36s
build & deploy / deploy (push) Successful in 29s
2026-03-20 13:11:32 -04:00
7a74f7f882 [tests] Add scrobble tests
Some checks failed
build & deploy / test (push) Successful in 1m49s
build & deploy / deploy (push) Failing after 2m5s
2026-03-19 21:17:27 -04:00
373db5563a [videos] IMDB blew us up again, switch to TMdb
Some checks failed
build & deploy / test (push) Has been cancelled
build & deploy / deploy (push) Has been cancelled
2026-03-19 21:16:12 -04:00
349b10904a [podcasts] Add domain link
All checks were successful
build & deploy / test (push) Successful in 1m41s
build & deploy / deploy (push) Successful in 20s
2026-03-18 11:36:13 -04:00
8e8d25aa1d [podcasts] Add aggregation to templates
All checks were successful
build & deploy / test (push) Successful in 1m40s
build & deploy / deploy (push) Successful in 20s
2026-03-18 11:07:45 -04:00
28db747b59 [podcasts] Add podcast aggregation widget 2026-03-18 10:55:27 -04:00
a0b867e20a [videos] Clean up metadata enrichment
All checks were successful
build & deploy / test (push) Successful in 1m48s
build & deploy / deploy (push) Successful in 21s
2026-03-18 10:48:14 -04:00
6d25e5f663 [boardgames] Fix manual import and add aggregations
Some checks failed
build & deploy / deploy (push) Has been cancelled
build & deploy / test (push) Has been cancelled
2026-03-18 10:47:47 -04:00
2e7d6364a2 [templates] Adding some aggregation widgets
All checks were successful
build & deploy / test (push) Successful in 1m45s
build & deploy / deploy (push) Successful in 26s
2026-03-18 10:23:52 -04:00
a8dc336950 [format] Blacken some stuff
All checks were successful
build & deploy / test (push) Successful in 1m39s
build & deploy / deploy (push) Successful in 21s
2026-03-17 13:26:54 -04:00
653aabfbb1 [scrobbles] Fix adding comments to webpages
Some checks failed
build & deploy / test (push) Successful in 1m40s
build & deploy / deploy (push) Has been cancelled
2026-03-17 13:24:50 -04:00
48e13af2e8 [scrobbles] Add note taking to webpages
Some checks failed
build & deploy / test (push) Successful in 1m51s
build & deploy / deploy (push) Failing after 2m2s
2026-03-17 11:42:58 -04:00
f1f615f0ed [project] Update task list 2026-03-14 13:58:56 -04:00
052d75ea26 [webpages] Fix bad title scrapes
All checks were successful
build & deploy / test (push) Successful in 1m40s
build & deploy / deploy (push) Successful in 21s
2026-03-14 13:58:21 -04:00
223de52a12 [webpages] Add images to webpages
All checks were successful
build & deploy / test (push) Successful in 1m41s
build & deploy / deploy (push) Successful in 22s
2026-03-14 13:54:27 -04:00
a5ff6abf56 [foods] Add catch for common domain exclusions
All checks were successful
build & deploy / test (push) Successful in 1m42s
build & deploy / deploy (push) Successful in 23s
2026-03-14 13:45:15 -04:00
74252b8759 [project] Add justfile
All checks were successful
build & deploy / test (push) Successful in 1m40s
build & deploy / deploy (push) Successful in 21s
2026-03-14 13:34:08 -04:00
466f33dd3c [foods] Fix bug in exception handling 2026-03-14 13:33:50 -04:00
c9abb22bfb [notifications] Show a finish link when appropriate 2026-03-14 13:33:31 -04:00
18d21e6651 [scrobbles] Add auto stopping
All checks were successful
build & deploy / test (push) Successful in 1m44s
build & deploy / deploy (push) Successful in 27s
2026-03-14 13:19:18 -04:00
1f67d4c0a6 [music] Fix logdata bug
All checks were successful
build & deploy / test (push) Successful in 1m38s
build & deploy / deploy (push) Successful in 20s
2026-03-14 02:04:30 -04:00
ff3fe00afa [templates] Dont need two bootstrap.min.css files
All checks were successful
build & deploy / test (push) Successful in 1m40s
build & deploy / deploy (push) Successful in 19s
2026-03-14 02:01:14 -04:00
2e289f3852 [scrobbles] source_id is screwing things up 2026-03-14 02:00:36 -04:00
c0659f26a5 [scrobbles] Only redirect if media has a URL
All checks were successful
build & deploy / test (push) Successful in 1m41s
build & deploy / deploy (push) Successful in 20s
2026-03-13 18:15:22 -04:00
8ac6ed542f [black] Apparently we didn't get it all
Some checks failed
build & deploy / test (push) Successful in 1m41s
build & deploy / deploy (push) Failing after 2m2s
2026-03-13 17:57:39 -04:00
14e32dae12 [scrobbles] Add notes_as_str to all datalogs and fix homepage 2026-03-13 17:56:39 -04:00
31d3a85e8c [videos] Dont always lookup from IMDB
All checks were successful
build & deploy / test (push) Successful in 1m41s
build & deploy / deploy (push) Successful in 23s
2026-03-13 15:54:57 -04:00
7cf817025b [templates] Try using defensive checks for missing media
All checks were successful
build & deploy / test (push) Successful in 1m46s
build & deploy / deploy (push) Successful in 21s
2026-03-12 14:12:01 -04:00
f5675e5319 [scrobblers] Allow scrobbling food from the top bar
All checks were successful
build & deploy / test (push) Successful in 1m49s
build & deploy / deploy (push) Successful in 21s
2026-03-12 14:04:04 -04:00
40617b77e2 [charts] Display all top shows and channels
All checks were successful
build & deploy / test (push) Successful in 1m49s
build & deploy / deploy (push) Successful in 20s
2026-03-12 10:07:34 -04:00
64967aa357 [charts] Truncate images for shows and channels
All checks were successful
build & deploy / test (push) Successful in 1m42s
build & deploy / deploy (push) Successful in 20s
2026-03-12 10:00:50 -04:00
88166d01eb [project] Cleaning up backlog
All checks were successful
build & deploy / test (push) Successful in 1m45s
build & deploy / deploy (push) Successful in 20s
2026-03-12 09:53:43 -04:00
f506fa465f [charts] Add youtube aggregator
All checks were successful
build & deploy / test (push) Successful in 1m41s
build & deploy / deploy (push) Successful in 52s
2026-03-12 00:26:28 -04:00
5934dcdf8e [format] Blacken everything
All checks were successful
build & deploy / test (push) Successful in 1m53s
build & deploy / deploy (push) Successful in 1m12s
2026-03-11 23:54:24 -04:00
1e11679419 [project] Finish off video aggregators 2026-03-11 23:53:36 -04:00
0e5d8e6b2f [charts] Add TV charts 2026-03-11 23:53:08 -04:00
118e208a36 [project] Update task list
All checks were successful
build & deploy / test (push) Successful in 1m41s
build & deploy / deploy (push) Successful in 22s
2026-03-11 21:30:55 -04:00
93666cec54 [scrobbles] Fix log data messiness with Jellyfin and Mopidy 2026-03-11 21:30:38 -04:00
6d5ebb68e3 [videos] Don't spam IMDB with series metadata 2026-03-11 21:28:17 -04:00
c89b434d18 [tests] Add tests for the context processor
All checks were successful
build & deploy / test (push) Successful in 1m39s
build & deploy / deploy (push) Successful in 21s
2026-03-11 17:09:06 -04:00
e83f922e32 [format] Blacken tests
All checks were successful
build & deploy / test (push) Successful in 1m38s
build & deploy / deploy (push) Successful in 20s
2026-03-11 16:50:17 -04:00
508e2db60e [ci] Try fixing context processor 2026-03-11 16:50:04 -04:00
e9f9004d6d [scrobbles] Fix Album import
Some checks failed
build & deploy / deploy (push) Has been cancelled
build & deploy / test (push) Has been cancelled
2026-03-11 16:49:10 -04:00
e3364d15ce [scrobblers] Actually lookup the album ID
Some checks failed
build & deploy / test (push) Failing after 1m15s
build & deploy / deploy (push) Has been skipped
2026-03-11 16:34:48 -04:00
744e9f1d38 [format] Blacken some files
All checks were successful
build & deploy / test (push) Successful in 1m40s
build & deploy / deploy (push) Successful in 20s
2026-03-11 16:28:05 -04:00
a343e2f3fa [templates] Add version and commit to footer 2026-03-11 16:26:51 -04:00
4036e883fd [make] Update manual deploy make command
Some checks failed
build & deploy / test (push) Successful in 1m40s
build & deploy / deploy (push) Failing after 8s
2026-03-11 16:22:52 -04:00
ff6de28b24 [ci] Deploy if tests pass 2026-03-11 16:22:35 -04:00
ad03f22b20 [koreader] Fix broken tests
All checks were successful
build & deploy / test (push) Successful in 1m41s
build & deploy / deploy (push) Has been skipped
2026-03-11 16:21:01 -04:00
4f8cf4f244 [scrobbles] Fix mopidy lookup
All checks were successful
build & deploy / test (push) Successful in 1m38s
build & deploy / deploy (push) Has been skipped
2026-03-11 16:16:10 -04:00
d6efdb6979 [scrobbles] Blacken scrobblers.py
Some checks failed
build & deploy / test (push) Failing after 1m15s
build & deploy / deploy (push) Has been skipped
2026-03-11 16:08:58 -04:00
d38e960897 [scrobbles] Add same change to mopidy (album_id)
Some checks failed
build & deploy / deploy (push) Has been cancelled
build & deploy / test (push) Has been cancelled
2026-03-11 16:08:31 -04:00
88a8b8320c [scrobbles] Save album_id to log 2026-03-11 15:57:57 -04:00
5d9834b63d [scrobbles] Fix note str output
All checks were successful
build & deploy / test (push) Successful in 1m41s
build & deploy / deploy (push) Has been skipped
2026-03-11 11:35:01 -04:00
2dc7acc536 [scrobbles] Blacken views 2026-03-11 11:17:12 -04:00
3a79c17006 [scrobbles] Add admin list to index 2026-03-08 17:55:45 -04:00
c43771c757 [git] Ignore static files 2026-03-08 17:07:13 -04:00
a5eff556be [profiles] Fix migration
All checks were successful
build & deploy / test (push) Successful in 1m48s
build & deploy / deploy (push) Has been skipped
2026-03-08 17:06:48 -04:00
5b8559efd0 [scrobbles] Add raw data storage to Jellyfins scrobbles
All checks were successful
build & deploy / test (push) Successful in 1m46s
build & deploy / deploy (push) Has been skipped
2026-03-08 17:00:25 -04:00
e3fb529419 [scripts] Add koreader test scripts
All checks were successful
build & deploy / test (push) Successful in 1m42s
build & deploy / deploy (push) Has been skipped
2026-03-08 16:57:11 -04:00
8357ce8901 [scrobbles] Blacken files and add local dev static
All checks were successful
build & deploy / test (push) Successful in 1m47s
build & deploy / deploy (push) Has been skipped
2026-03-08 16:50:43 -04:00
8309a4e3c3 [scrobbles] Add timestamp indexes
All checks were successful
build & deploy / test (push) Successful in 1m45s
build & deploy / deploy (push) Has been skipped
2026-03-08 16:41:01 -04:00
7d8e1ac817 [tests] Add tests for koreader and views
All checks were successful
build & deploy / test (push) Successful in 1m44s
build & deploy / deploy (push) Has been skipped
2026-03-08 01:05:17 -05:00
446144bc51 [ci] Prob didn't need AI to add that env variable
All checks were successful
build & deploy / test (push) Successful in 1m41s
build & deploy / deploy (push) Has been skipped
2026-03-06 13:16:08 -05:00
d36502ec09 [reqs] Add recipe-scrapers
Some checks failed
build & deploy / test (push) Failing after 1m20s
build & deploy / deploy (push) Has been skipped
2026-03-06 12:26:32 -05:00
770a51b9c0 [foods] Add recipe website parsing
Some checks failed
build & deploy / test (push) Failing after 1m10s
build & deploy / deploy (push) Has been skipped
2026-03-06 10:46:34 -05:00
210ff0a4aa [deps] Upgrade dependencies 2026-03-05 10:56:05 -05:00
db52ebeea7 [ci] Clean up the button
All checks were successful
build & deploy / test (push) Successful in 1m36s
build & deploy / deploy (push) Has been skipped
2026-03-02 22:43:53 -05:00
0e7286ac4e [ci] Add changes and build to ntfy
All checks were successful
build & deploy / test (push) Successful in 1m36s
build & deploy / deploy (push) Has been skipped
2026-03-02 22:39:22 -05:00
4ee687b9c7 [ci] Fix runner label
All checks were successful
build & deploy / test (push) Successful in 5m0s
build & deploy / deploy (push) Has been skipped
2026-03-02 17:18:06 -05:00
4b8785ead7 [ci] Update runner label
Some checks failed
build & deploy / test (push) Has been cancelled
build & deploy / deploy (push) Has been cancelled
2026-03-02 12:53:06 -05:00
bec7ef337e [ci] Add gitea workflow
Some checks failed
build & deploy / test (push) Has been cancelled
build & deploy / deploy (push) Has been cancelled
2026-03-02 12:40:39 -05:00
e7bc38d0f8 [make] Fix bad call to code.unbl.ink 2026-03-02 10:36:39 -05:00
1be8e4b083 [ci] Update hostname to code repo 2026-03-02 10:33:58 -05:00
88a94aadca [git] Add tmp ignore 2026-03-02 10:30:39 -05:00
2d8f433314 [tests] Fix slow aggregation tests 2026-03-02 10:30:32 -05:00
d1844c01a0 [tests] Speed up tests 2026-03-02 09:19:36 -05:00
9848e5874d [videos] Fix Video API views 2026-03-02 08:48:53 -05:00
a027e877f7 [scrobblers] Use MediaSourceId to avoid multiple Jellyfin scrobbles 2026-03-02 08:42:01 -05:00
82a7fd8673 [videos] Fix bad imdb_link format 2026-01-22 23:18:07 -05:00
c897507de4 [boardgames] Fix bug in bggeek_id 2026-01-20 21:18:04 -05:00
009ed2ace3 [boardgames] Fix bgg_id not being save 2026-01-20 20:41:23 -05:00
d945acfb92 [git] Fix merge commit 2026-01-20 20:32:04 -05:00
8fd0ed6e17 [scrobbles] Auto set playback seconds 2025-12-22 10:23:34 -05:00
a5d72ce4c3 [videos] Linting the imports 2025-12-02 17:47:23 -05:00
809014736c [videos] Cleaning up tt on imdb videos 2025-12-02 17:47:07 -05:00
fcccd6d9a4 [project] Update org tasks 2025-12-02 17:47:07 -05:00
a5295d7973 [notifications] Fix bug in getting profile for mood notification 2025-11-24 15:30:46 -05:00
186ae18e1f [tasks] Fix when note is a string 2025-11-18 12:35:53 -05:00
6f7f739ca6 [videos] Add management command and fix lookup of series 2025-11-18 10:32:54 -05:00
c2ba8a48ac [release] Update project log for 37 2025-11-17 22:07:23 -05:00
1530de3188 [scrobbles] Remove debug printing 2025-11-17 21:45:38 -05:00
2d235c0577 [scrobbles] Fix missing note error 2025-11-17 21:45:17 -05:00
b0eb58953b [food] Add calories if they're missing 2025-11-17 21:45:08 -05:00
7309181fed [scrobbles] Fix dataclass dict converstion error 2025-11-17 21:44:50 -05:00
971fee5b4b [food] No need to rate food 2025-11-17 21:44:39 -05:00
920a9180c8 [books] Remove unused max_length parameter 2025-11-17 21:44:19 -05:00
d568a377f0 [templates] Add longplay complete to templates 2025-11-17 20:36:50 -05:00
3851624dd7 [tests] Fix IMDB test and bump reqs 2025-11-17 20:09:34 -05:00
149 changed files with 8133 additions and 1669 deletions

View File

@ -34,7 +34,7 @@ steps:
command_timeout: 2m
script:
- pip uninstall -y vrobbler
- pip install git+https://code.unbl.ink/secstate/vrobbler.git@main
- pip install git+https://code.lab.unbl.ink/secstate/vrobbler.git@main
- vrobbler migrate
- vrobbler collectstatic --noinput
- immortalctl restart celery && immortalctl restart vrobbler
@ -50,8 +50,15 @@ steps:
topic: drone
priority: low
tags:
- failure
- success
- vrobbler
actions:
- action: view
label: Changes
url: "{{ .Commit.Link }}"
- action: view
label: Build
url: "{{ .Build.Link }}"
- name: build failure notification
image: parrazam/drone-ntfy:0.3-linux-amd64
when:
@ -61,8 +68,15 @@ steps:
topic: drone
priority: high
tags:
- success
- failure
- vrobbler
actions:
- action: view
label: Changes
url: "{{ .Commit.Link }}"
- action: view
label: Build
url: "{{ .Build.Link }}"
volumes:
- name: docker
host:

View File

@ -0,0 +1,114 @@
name: build & deploy
on:
push:
branches: ["**"]
tags: ["*"]
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"
# Cache pip + Poetry caches (rough equivalent to your mounted pip_cache)
- 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
# Notifications (success/failure) for the test job
- 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
deploy:
# Only deploy on tags (equivalent to Drone when: ref: refs/tags/*)
# if: startsWith(gitea.ref, 'refs/tags/')
needs: [test]
runs-on: ubuntu-latest
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: vrobbler.service
username: root
key: ${{ secrets.JAIL_KEY }}
command_timeout: 2m
script: |
pip uninstall -y vrobbler
pip install git+https://code.lab.unbl.ink/secstate/vrobbler.git@main
vrobbler migrate
vrobbler collectstatic --noinput
immortalctl restart celery && immortalctl restart vrobbler
- name: Notify deploy success (ntfy)
if: success()
run: |
curl -fsS \
-H "Title: vrobbler deploy success" \
-H "Priority: low" \
-H "Tags: success,vrobbler,deploy" \
-H "Actions: view, View changes, ${{ gitea.server_url }}/${{ gitea.repository }}/commit/${{ gitea.sha }}; view, View build, ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}" \
-d "🚀 Deploy succeeded: ${{ gitea.ref_name }} (${{ gitea.sha }})" \
https://ntfy.unbl.ink/drone
- name: Notify deploy failure (ntfy)
if: failure()
run: |
curl -fsS \
-H "Title: vrobbler deploy failure" \
-H "Priority: high" \
-H "Tags: failure,vrobbler,deploy" \
-H "Actions: view, View changes, ${{ gitea.server_url }}/${{ gitea.repository }}/commit/${{ gitea.sha }}; view, View build, ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}" \
-d "💥 Deploy failed: ${{ gitea.ref_name }} (${{ gitea.sha }})" \
https://ntfy.unbl.ink/drone

2
.gitignore vendored
View File

@ -3,3 +3,5 @@ vrobbler.conf
media/
dist/
.coverage
tmp/*
vrobbler/static/*

View File

@ -1,5 +1,5 @@
deploy:
ssh vrobbler.service "rm -rf /usr/local/lib/python3.11/site-packages/vrobbler-0.15.4.dist-info/ && pip install git+https://code.unbl.ink/secstate/vrobbler.git@develop && immortalctl restart vrobbler && immortalctl restart vrobbler-celery && vrobbler migrate"
ssh vrobbler.service "pip uninstall vrobbler && pip install git+https://code.lab.unbl.ink/secstate/vrobbler.git && immortalctl restart vrobbler && immortalctl restart vrobbler-celery && vrobbler migrate"
logs:
ssh life.unbl.ink tail -n 100 -f /var/log/vrobbler.json
test:

View File

@ -92,7 +92,7 @@ fetching and simple saving.
:LOGBOOK:
CLOCK: [2025-07-09 Wed 09:55]--[2025-07-09 Wed 10:15] => 0:20
:END:
* Backlog [1/23]
* Backlog [14/33]
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :vrobbler:personal:bug:music:scrobbles:
** TODO [#C] Move to using more robust mopidy-webhooks pacakge form pypi :utility:improvement:
:PROPERTIES:
@ -393,15 +393,6 @@ CLOCK: [2025-07-09 Wed 09:55]--[2025-07-09 Wed 10:15] => 0:20
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:
** TODO [#B] Add importer class for IMAP imports :vrobbler:feature:imap:importers:project:personal:
** TODO [#B] Clean up follow up notifications for board games and beer that ask if you're still scrobbling :vrobbler:personal:project:beers:boardgames:notifications:feature:
- Note taken on [2025-09-30 Tue 09:32]
I added this feature in a very rough way, but now we should add "Action"
headers so that we can either Finish or Cancel the associated scrobble:
https://docs.ntfy.sh/publish/#send-http-request
** TODO [#B] Add AllTrails as a source for Trail data :vrobbler:trails:feature:personal:project:
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
@ -409,7 +400,9 @@ 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?).
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 CSV endpoint for book scrobbles that LibraryThing can ingest :personal:project:books:feature:export:
@ -421,7 +414,7 @@ https://app.todoist.com/app/task/add-a-csv-endpoint-for-users-book-reads-that-li
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] Tasks from org-mode should properly update notes and leave them out of the body :vrobbler:bug:tasks:
** 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:
- Note taken on [2025-09-25 Thu 10:37] \\
@ -435,16 +428,201 @@ https://app.todoist.com/app/task/add-a-csv-endpoint-for-users-book-reads-that-li
the importer to be aware of whether a user is using DST or not and roll back
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:
** DONE [#A] Save raw scrobble request data to every scrobble log :vrobbler:personal:feature:scrobbles:
:PROPERTIES:
:ID: b4686db4-1319-399c-9b84-f0b1036c6815
:END:
** TODO [#A] Allow scrobbling from the Food list page's start links :vrobbler:bug:food:scrobbling:personal:project:
https://life.lab.unbl.ink/scrobble/e39779c8-62a5-46a6-bdef-fb7662810dc6/start/
** TODO [#A] Puzzles (and all longplays) should have a "Completed?" column on their detail page :vrobbler:bug:puzzles:personal:project:
** TODO [#A] Fix raw text webpage title not truncating to 254 chars :vrobbler:personal:bug:webpages:
The idea here is that no matter where the data comes from, we should just save
it in the scrobble for posterity, so we can always in some form recover the
original intent of the scrobble.
It may also allow us to clean up junk scrobbles if the data was just horribly
wrong.
** DONE [#B] Clean up follow up notifications for if you're still scrobbling :vrobbler:personal:project:beers:boardgames:notifications:feature:
:PROPERTIES:
:ID: 7937545f-2a62-4188-ad49-34f85fad0c25
:END:
- Note taken on [2025-09-30 Tue 09:32]
I added this feature in a very rough way, but now we should add "Action"
headers so that we can either Finish or Cancel the associated scrobble:
https://docs.ntfy.sh/publish/#send-http-request
** DONE [#A] Fix lookup of music tracks from Musicbrainz :vrobbler:bug:tracks:music:
:PROPERTIES:
:ID: 2bc1cc0b-6c4e-bc3d-e7c3-47df364df206
:END:
Turns out we're not looking up music tracks properly, again.
** DONE Check opencode about a way to present stats like movies per month :vrobbler:scrobbles:stats:personal:project:
:PROPERTIES:
:ID: eed39a0b-3aa7-dc65-9e03-a58fa401c8c6
:END:
** DONE Fix bug in Jellyfin audio track playback :vrobbler:personal:project:bug:music:jellyfin:
:PROPERTIES:
:ID: 002bd234-4507-5313-fb59-2d3baa49b847
:END:
#+begin_src shell
ERROR django.request:241 log_response Internal Server Error: /webhook/jellyfin/
Traceback (most recent call last):
File "/usr/local/lib/python3.11/site-packages/django/core/handlers/exception.py", line 55, in inner
response = get_response(request)
^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/django/core/handlers/base.py", line 197, in _get_response
response = wrapped_callback(request, *callback_args, **callback_kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/django/views/decorators/csrf.py", line 56, in wrapper_view
return view_func(*args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/django/views/decorators/csrf.py", line 56, in wrapper_view
return view_func(*args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/django/views/generic/base.py", line 104, in view
return self.dispatch(request, *args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/rest_framework/views.py", line 509, in dispatch
response = self.handle_exception(exc)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/rest_framework/views.py", line 469, in handle_exception
self.raise_uncaught_exception(exc)
File "/usr/local/lib/python3.11/site-packages/rest_framework/views.py", line 480, in raise_uncaught_exception
raise exc
File "/usr/local/lib/python3.11/site-packages/rest_framework/views.py", line 506, in dispatch
response = handler(request, *args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/rest_framework/decorators.py", line 50, in handler
return func(*args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/scrobbles/views.py", line 494, in jellyfin_webhook
scrobble = jellyfin_scrobble_media(post_data, request.user.id)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/scrobbles/scrobblers.py", line 177, in jellyfin_scrobble_media
log["album_id"] = media_obj.album_id
~~~^^^^^^^^^^^^
TypeError: 'tuple' object does not support item assignment
#+end_src
** DONE [#B] Auto calc duration if no playback time seconds present :vrobbler:bug:scrobbles:personal:project:
:PROPERTIES:
:ID: e16228b2-b062-bd00-32e6-b2353e6406e9
:END:
** DONE Fix bug in video find_or_create :vrobbler:personal:project:bug:videos:
:PROPERTIES:
:ID: 10e611a5-fbcf-7473-25f0-c9bad7b79ffd
:END:
The error:
#+begin_src shell
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/scrobbles/views.py", line 493, in jellyfin_webhook
scrobble = jellyfin_scrobble_media(post_data, request.user.id)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/scrobbles/scrobblers.py", line 149, in jellyfin_scrobble_media
media_obj = Video.find_or_create(imdb_id)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/videos/models.py", line 390, in find_or_create
return cls.get_from_imdb_id(source_id, overwrite)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/videos/models.py", line 374, in get_from_imdb_id
video.tv_series = Series.find_or_create(
^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/videos/models.py", line 203, in find_or_create
vdict, _, cover, genres = lookup_video_from_imdb(
^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/videos/sources/imdb.py", line 17, in lookup_video_from_imdb
imdb_result = imdb.get_title(imdb_id)
^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/cinemagoerng/web.py", line 127, in get_title
data = _scrape(spec=spec, context=context, headers=headers)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/cinemagoerng/web.py", line 120, in _scrape
document = fetch(url, headers=request_headers)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/cinemagoerng/web.py", line 44, in fetch
with urlopen(request) as response:
^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/urllib/request.py", line 216, in urlopen
return opener.open(url, data, timeout)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/urllib/request.py", line 525, in open
response = meth(req, response)
^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/urllib/request.py", line 634, in http_response
response = self.parent.error(
^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/urllib/request.py", line 563, in error
return self._call_chain(*args)
^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/urllib/request.py", line 496, in _call_chain
result = func(*args)
^^^^^^^^^^^
File "/usr/local/lib/python3.11/urllib/request.py", line 643, in http_error_default
raise HTTPError(req.full_url, code, msg, hdrs, fp)
urllib.error.HTTPError: HTTP Error 500: Internal Server Error
#+end_src
- Note taken on [2026-03-11 Wed 20:53]
It turns out that every time a TV show was scrobbled were were hitting IMDB.
Woof.
** DONE Update admin page to be easier to use :vrobbler:djadmin:project:personal:
:PROPERTIES:
:ID: 0779be1f-1ad7-ddc7-8861-d3c7c833cbe9
:END:
** DONE Fix migrations and update repo :vrobbler:scrobbles:admin:personal:project:
:PROPERTIES:
:ID: ccbc2ef0-68bf-9ef7-dcaf-febab5ebefe2
:END:
** DONE Add recipe parsing for food lookups :vrobbler:foods:project:feature:personal:
:PROPERTIES:
:ID: 86456c78-247b-fb63-7ae8-d6d17e7666b1
:END:
** DONE [#A] Videos are scrobbling duplicates again :vrobbler:bug:videos:scrobbles:
:PROPERTIES:
:ID: a46884fe-7ef1-410a-316b-7ac6d7599331
:END:
<2026-03-06 Fri>
** DONE Fix board games not saving BGG id on lookup :vrobbler:bug:boardgames:
:PROPERTIES:
:ID: 506c2965-51d6-6cb9-fc4f-4f0468d2d62f
:END:
** DONE Fix board game lookup with name like Unmatched Game System :vrobbler:bug:boardgames:
:PROPERTIES:
:ID: 57956723-b34a-37ad-fde9-44947cc9bb65
:END:
** DONE [#A] Fix raw text webpage title not truncating to 254 chars :vrobbler:personal:bug:webpages:
:PROPERTIES:
:ID: 13fa0efd-2c3f-dd07-deb2-62882096feff
:END:
- Note taken on [2025-09-30 Tue 09:33]
This may have already been resolved ... need to just confirm it.
** TODO [#A] Find page numbers for comic books from ComicVine :vrobbler:feature:books:personal:project:
* 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:
:ID: c8410001-dbb7-1536-bd89-9784189e058f
:END:
** DONE [#A] Allow scrobbling from the Food list page's start links :vrobbler:bug:food:scrobbling:personal:project:
:PROPERTIES:
:ID: 00f99f60-ac00-6cde-311d-c31f41a01353
:END:
https://life.lab.unbl.ink/scrobble/e39779c8-62a5-46a6-bdef-fb7662810dc6/start/
** DONE [#B] Food scrobbles should inherit calories from obj if missing :vrobbler:feature:food:personal:project:
:PROPERTIES:
:ID: 3322ff69-4252-db65-36b3-fae56c1b9327
:END:
** DONE [#A] Puzzles (and all longplays) should have a "Completed?" column on their detail page :vrobbler:bug:puzzles:personal:project:
:PROPERTIES:
:ID: e3e49a9a-67d2-8ad8-1114-6f05effee9b7
:END:
* Version 36.0 [1/1]
** DONE [#A] Refactor how videos are scrobbled :vrobbler:vidoes:feature:personal:project:
:PROPERTIES:

10
justfile Normal file
View File

@ -0,0 +1,10 @@
dj-port := "0.0.0.0:" + env_var_or_default("DJANGO_PORT", "8000")
default:
@just --list
django:
poetry run python manage.py runserver {{dj-port}}
shell:
poetry run python manage.py shell

2411
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -59,6 +59,7 @@ themoviedb = "^1.0.2"
feedparser = "^6.0.12"
titlecase = "^2.4.1"
bgg-api = "^1.1.13"
recipe-scrapers = "^15.11.0"
[tool.poetry.group.test]
optional = true
@ -82,8 +83,7 @@ types-requests = "^2.27"
bandit = "^1.7.4"
[tool.pytest.ini_options]
minversion = "6.0"
addopts = "-ra -q --reuse-db"
addopts = "-ra -q --reuse-db --no-migrations"
testpaths = ["tests"]
DJANGO_SETTINGS_MODULE='vrobbler.settings-testing'

27
scripts/README.org Normal file
View File

@ -0,0 +1,27 @@
#+title: Readme
Scripts are a collection of helpful utility scripts, or simple gut-check tests for various functional pieces.
* test_recipe_scraper.py
Asserts various urls by making actual calls out to the internet, while our test suite mocks return values.
#+begin_src shell
python ../manage.py shell < ../scripts/test_recipe_scraper.py
#+end_src
#+RESULTS:
| Eagerly | running | all | tasks |
| Connected | to | sqlite@db.sqlite3 | |
| Checking: | https://cookingwithmike.com/quinoa-meatloaf/ | | |
| Checking: | https://www.kingarthurbaking.com/recipes/overnight-sourdough-waffles-recipe | | |
| Checking: | https://dirt.fyi/article/2026/02/25-years-of-ipod-brain?src=longreads | | |
* test_koreader_import.py
Run through an actual koreader sqlite file and make sure imports work as expected
#+begin_src shell
rm db.sqlite3
cp ../db.sqlite3 .
python ../manage.py shell < ../scripts/test_koreader_import.py
#+end_src

Binary file not shown.

View File

@ -0,0 +1,6 @@
#!/usr/bin/env python3
from books.koreader import process_koreader_sqlite_file
process_koreader_sqlite_file("./koreader-test.sqlite3", 1)

View File

@ -0,0 +1,21 @@
import requests
from foods.sources.rscraper import (
RecipeScraperService,
)
test_urls = {
"https://cookingwithmike.com/quinoa-meatloaf/": True,
"https://www.kingarthurbaking.com/recipes/overnight-sourdough-waffles-recipe": True,
"https://dirt.fyi/article/2026/02/25-years-of-ipod-brain?src=longreads": False,
"https://tastesbetterfromscratch.com/belgian-waffles/": True,
}
for k, v in test_urls.items():
html = requests.get(k).text
print("Checking: ", k)
if v:
assert RecipeScraperService().is_recipe(html, k)
else:
assert not RecipeScraperService().is_recipe(html, k)

View File

View File

@ -0,0 +1,198 @@
import pytest
from foods.sources.rscraper import (
RecipeScraperService,
)
RECIPE_HTML_WITH_SCHEMA = """
<!DOCTYPE html>
<html>
<head>
<script type="application/ld+json">
{
"@context": "https://schema.org/",
"@type": "Recipe",
"name": "Test Recipe",
"author": {
"@type": "Person",
"name": "Test Author"
},
"recipeIngredient": ["1 cup flour", "2 eggs", "1/2 cup sugar"],
"recipeInstructions": [
{
"@type": "HowToStep",
"text": "Mix ingredients together"
}
],
"totalTime": "PT30M",
"recipeYield": "4 servings"
}
</script>
</head>
<body>
<h1>Test Recipe</h1>
</body>
</html>
"""
RECIPE_HTML_WITHOUT_SCHEMA = """
<!DOCTYPE html>
<html>
<head>
<title>Not a Recipe Page</title>
</head>
<body>
<h1>Welcome to My Blog</h1>
<p>This is just a regular blog post about cooking.</p>
</body>
</html>
"""
RECIPE_HTML_WITH_MICRODATA = """
<!DOCTYPE html>
<html>
<head>
<title>Test Recipe</title>
</head>
<body itemscope itemtype="http://schema.org/Recipe">
<h1 itemprop="name">Microdata Recipe</h1>
<div itemprop="author" itemscope itemtype="http://schema.org/Person">
<span itemprop="name">Test Author</span>
</div>
<div itemprop="recipeIngredient">1 cup flour</div>
<div itemprop="recipeIngredient">2 eggs</div>
<div itemprop="recipeInstructions">
<div itemprop="text">Mix all ingredients</div>
</div>
</body>
</html>
"""
class TestRecipeScraperService:
@pytest.fixture
def scraper(self):
return RecipeScraperService()
def test_is_recipe_with_valid_schema(self, scraper):
result = scraper.is_recipe(
RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe"
)
assert result is True
def test_is_recipe_without_schema(self, scraper):
result = scraper.is_recipe(
RECIPE_HTML_WITHOUT_SCHEMA, "https://example.com/blog"
)
assert result is False
def test_is_recipe_with_microdata(self, scraper):
result = scraper.is_recipe(
RECIPE_HTML_WITH_MICRODATA, "https://example.com/recipe"
)
assert result is True
def test_scrape_returns_title(self, scraper):
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"
)
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"
)
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"
)
assert result["yields"] == "4 servings"
def test_scrape_returns_total_time(self, scraper):
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"
)
assert result["url"] == "https://example.com/recipe"
def test_scrape_raises_on_invalid_html(self, scraper):
with pytest.raises(ValueError):
scraper.scrape("", "https://example.com/recipe")
def test_scrape_handles_missing_optional_fields(self, scraper):
minimal_html = """
<!DOCTYPE html>
<html>
<head>
<script type="application/ld+json">
{
"@context": "https://schema.org/",
"@type": "Recipe",
"name": "Minimal Recipe"
}
</script>
</head>
<body></body>
</html>
"""
result = scraper.scrape(minimal_html, "https://example.com/minimal")
assert result["title"] == "Minimal Recipe"
assert result["ingredients"] == []
assert result["instructions"] == []
def test_parse_servings(self, scraper):
assert scraper.parse_servings("4 servings") == 4
assert scraper.parse_servings("6 people") == 6
assert scraper.parse_servings("2") == 2
assert scraper.parse_servings("serves 8") == 8
assert scraper.parse_servings(None) is None
assert scraper.parse_servings("") is None
def test_extract_tags_from_cuisine(self, scraper):
recipe_data = {"cuisine": "Italian"}
tags = scraper.extract_tags(recipe_data)
assert "Italian" in tags
def test_extract_tags_from_cuisine_list(self, scraper):
recipe_data = {"cuisine": ["Italian", "Mexican"]}
tags = scraper.extract_tags(recipe_data)
assert "Italian" in tags
assert "Mexican" in tags
def test_extract_tags_from_dietary(self, scraper):
recipe_data = {"dietary": "Gluten-Free"}
tags = scraper.extract_tags(recipe_data)
assert "Gluten-Free" in tags
def test_extract_tags_from_course(self, scraper):
recipe_data = {"course": "Dessert"}
tags = scraper.extract_tags(recipe_data)
assert "Dessert" in tags
def test_extract_tags_from_keywords(self, scraper):
recipe_data = {"keywords": "easy, quick, healthy"}
tags = scraper.extract_tags(recipe_data)
assert "easy" in tags
assert "quick" in tags
assert "healthy" in tags
def test_extract_tags_from_keywords_list(self, scraper):
recipe_data = {"keywords": ["comfort food", "winter"]}
tags = scraper.extract_tags(recipe_data)
assert "comfort food" in tags
assert "winter" in tags

View File

@ -0,0 +1,135 @@
import pytest
from unittest.mock import patch
from foods.sources.usda import (
USDAFoodAPI,
NutritionCalculator,
)
class TestUSDAFoodAPI:
@pytest.fixture
def usda_api(self):
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")
def test_extract_nutrients_with_nutrient_number(self, usda_api):
food_data = {
"description": "Test Food",
"foodNutrients": [
{
"nutrientNumber": "203",
"nutrientName": "Protein",
"value": 10.0,
},
{
"nutrientNumber": "204",
"nutrientName": "Total lipid (fat)",
"value": 5.0,
},
{
"nutrientNumber": "205",
"nutrientName": "Carbohydrate, by difference",
"value": 20.0,
},
{
"nutrientNumber": "208",
"nutrientName": "Energy",
"value": 150.0,
},
{
"nutrientNumber": "269",
"nutrientName": "Sugars, total",
"value": 5.0,
},
],
}
result = usda_api.extract_nutrients(food_data)
assert result["protein"] == 10.0
assert result["fat"] == 5.0
assert result["carbohydrates"] == 20.0
assert result["calories"] == 150.0
assert result["sugar"] == 5.0
def test_extract_nutrients_with_nested_nutrient(self, usda_api):
food_data = {
"description": "Test Food",
"foodNutrients": [
{
"nutrient": {"id": 203, "name": "Protein"},
"value": 10.0,
},
],
}
result = usda_api.extract_nutrients(food_data)
assert result["protein"] == 10.0
def test_extract_nutrients_with_empty_nutrients(self, usda_api):
food_data = {"description": "Test Food", "foodNutrients": []}
result = usda_api.extract_nutrients(food_data)
assert result["protein"] == 0
assert result["calories"] == 0
def test_extract_nutrients_with_no_nutrients_key(self, usda_api):
food_data = {"description": "Test Food"}
result = usda_api.extract_nutrients(food_data)
assert result["protein"] == 0
class TestNutritionCalculator:
@pytest.fixture
def calculator(self):
with patch("vrobbler.apps.foods.sources.usda.USDAFoodAPI"):
return NutritionCalculator()
def test_parse_ingredient_with_fraction(self, calculator):
result = calculator.parse_ingredient("1/2 cup flour")
assert result["quantity"] == 0.5
assert result["unit"] == "cup"
assert result["ingredient"] == "flour"
def test_parse_ingredient_with_mixed_number(self, calculator):
result = calculator.parse_ingredient("1 1/2 cups sugar")
assert result["quantity"] == 1.5
assert result["unit"] == "cups"
assert result["ingredient"] == "sugar"
def test_parse_ingredient_with_decimal(self, calculator):
result = calculator.parse_ingredient("0.5 tsp salt")
assert result["quantity"] == 0.5
assert result["unit"] == "tsp"
assert result["ingredient"] == "salt"
def test_parse_ingredient_with_whole_number(self, calculator):
result = calculator.parse_ingredient("3 eggs")
assert result["quantity"] == 3
assert result["unit"] is None
assert result["ingredient"] == "eggs"
def test_parse_ingredient_with_no_quantity(self, calculator):
result = calculator.parse_ingredient("salt to taste")
assert result["quantity"] == 1
def test_clean_ingredient_name_removes_modifiers(self, calculator):
result = calculator._clean_ingredient_name("fresh chopped onions")
assert "fresh" not in result.lower()
assert "chopped" not in result.lower()
def test_clean_ingredient_name_removes_parentheses(self, calculator):
result = calculator._clean_ingredient_name("flour (sifted)")
assert "(" not in result
assert ")" not in result
def test_convert_to_grams_cup(self, calculator):
result = calculator._convert_to_grams(2, "cups", "flour")
assert result == 480
def test_convert_to_grams_tablespoon(self, calculator):
result = calculator._convert_to_grams(3, "tbsp", "olive oil")
assert result == 45
def test_convert_to_grams_unknown_unit(self, calculator):
result = calculator._convert_to_grams(1, "unknown", "something")
assert result == 100

View File

@ -22,8 +22,18 @@ def boardgame_scrobble():
played_to_completion=True,
log={
"players": [
{"person_id": first.id, "win": True, "score": 30, "color": "Blue"},
{"person_id": second.id, "win": False, "score": 28, "color": "Red"}
{
"person_id": first.id,
"win": True,
"score": 30,
"color": "Blue",
},
{
"person_id": second.id,
"win": False,
"score": 28,
"color": "Red",
},
],
},
)
@ -115,6 +125,7 @@ def mopidy_podcast_request_data():
mopidy_uri=mopidy_uri, artist="NPR", album="Up First"
).request_json
@pytest.fixture
def mopidy_podcast_https_request_data():
mopidy_uri = "podcast+https://feeds.npr.org/510318/podcast.xml#85b9c4c4-ae09-43d9-8853-31ccf43f68e6"
@ -122,6 +133,7 @@ def mopidy_podcast_https_request_data():
mopidy_uri=mopidy_uri, artist="NPR", album="Up First"
).request_json
class JellyfinTrackRequest:
name = "Emotion"
artist = "Carly Rae Jepsen"
@ -136,6 +148,7 @@ class JellyfinTrackRequest:
musicbrainz_album_id = "03b864cd-7761-314c-a892-05a89ddff00d"
musicbrainz_artist_id = "95f5b748-d370-47fe-85bd-0af2dc450bc0"
status = "resumed"
client_name = "Jellyfin"
def __init__(self, **kwargs):
self.request_data = {
@ -159,6 +172,7 @@ class JellyfinTrackRequest:
"musicbrainz_artist_id", self.musicbrainz_artist_id
),
"Status": kwargs.get("status", self.status),
"ClientName": kwargs.get("client_name", self.client_name),
}
def __eq__(self, other):

View File

@ -1,4 +1,5 @@
from datetime import datetime, timedelta
from unittest.mock import patch
import pytest
import time_machine
@ -26,8 +27,21 @@ def build_scrobbles(client, request_json, num=7, spacing=2):
@pytest.mark.django_db
@patch("music.models.get_album_metadata_with_artist", return_value={})
@patch("music.models.get_track_metadata_with_artist", return_value={})
@patch("music.models.get_recording_mbid_exact", return_value=(None, None))
@patch("music.models.lookup_artist_from_tadb", return_value={})
@patch("music.models.lookup_album_from_tadb", return_value={})
@time_machine.travel(datetime(2022, 3, 4, 1, 24))
def test_scrobble_counts_data(client, mopidy_track):
def test_scrobble_counts_data(
mock_lookup_album_tadb,
mock_lookup_artist_tadb,
mock_get_recording,
mock_get_track,
mock_get_album,
client,
mopidy_track,
):
build_scrobbles(client, mopidy_track.request_json)
user = get_user_model().objects.first()
count_dict = scrobble_counts(user)
@ -41,8 +55,21 @@ def test_scrobble_counts_data(client, mopidy_track):
@pytest.mark.django_db
@patch("music.models.get_album_metadata_with_artist", return_value={})
@patch("music.models.get_track_metadata_with_artist", return_value={})
@patch("music.models.get_recording_mbid_exact", return_value=(None, None))
@patch("music.models.lookup_artist_from_tadb", return_value={})
@patch("music.models.lookup_album_from_tadb", return_value={})
@time_machine.travel(datetime(2022, 3, 4, 1, 24))
def test_live_charts(client, mopidy_track):
def test_live_charts(
mock_lookup_album_tadb,
mock_lookup_artist_tadb,
mock_get_recording,
mock_get_track,
mock_get_album,
client,
mopidy_track,
):
build_scrobbles(client, mopidy_track.request_json, 7, 1)
user = get_user_model().objects.first()

View File

@ -1,6 +1,6 @@
import pytest
#from scrobbles.dataclasses import BoardGameLogData, BoardGameScoreLogData
# from scrobbles.dataclasses import BoardGameLogData, BoardGameScoreLogData
@pytest.mark.skip("Need to get local tests running working again")
@ -19,7 +19,7 @@ def test_boardgame_log_data(boardgame_scrobble):
new=None,
rank=None,
seat_order=None,
role=None
role=None,
),
BoardGameScoreLogData(
person_id=2,
@ -32,7 +32,7 @@ def test_boardgame_log_data(boardgame_scrobble):
new=None,
rank=None,
seat_order=None,
role=None
role=None,
),
],
difficulty=None,

View File

@ -0,0 +1,41 @@
from unittest.mock import MagicMock, patch
from scrobbles.scrobblers import jellyfin_scrobble_media, mopidy_scrobble_media
def test_jellyfin_scrobble_media_ignores_progress_with_zero_position():
post_data = {
"ItemType": "Audio",
"PlaybackPosition": "00:00:00",
"NotificationType": "PlaybackProgress",
}
result = jellyfin_scrobble_media(post_data, 1)
assert result is None
def test_mopidy_scrobble_handles_missing_mopidy_uri():
with patch("scrobbles.scrobblers.Track") as mock_track_class:
with patch("scrobbles.scrobblers.parse_mopidy_uri", return_value=None):
mock_track = MagicMock()
mock_track.scrobble_for_user = MagicMock(return_value=MagicMock())
mock_track_class.find_or_create.return_value = mock_track
post_data = {
"name": "Test Song",
"artist": "Test Artist",
"album": "Test Album",
"run_time": 180000,
}
result = mopidy_scrobble_media(post_data, 1)
mock_track_class.find_or_create.assert_called_once_with(
title="Test Song",
artist_name="Test Artist",
album_name="Test Album",
run_time_seconds=180000,
)

View File

@ -1,13 +1,15 @@
from datetime import datetime, timedelta
from unittest.mock import patch
from unittest.mock import patch, MagicMock
from django.utils import timezone
from django.contrib.auth import get_user_model
import pytest
import time_machine
from django.urls import reverse
from music.models import Track
from music.models import Track, Artist, Album
from podcasts.models import PodcastEpisode
from scrobbles.models import Scrobble
from tasks.models import Task
@pytest.mark.django_db
@ -18,6 +20,14 @@ def test_get_not_allowed_from_mopidy(client, valid_auth_token):
assert response.status_code == 405
@pytest.mark.django_db
def test_get_not_allowed_from_jellyfin(client, valid_auth_token):
url = reverse("scrobbles:jellyfin-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_bad_mopidy_request_data(client, valid_auth_token):
url = reverse("scrobbles:mopidy-webhook")
@ -30,6 +40,338 @@ def test_bad_mopidy_request_data(client, valid_auth_token):
)
@pytest.mark.django_db
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)
assert response.status_code == 400
assert (
response.data["detail"]
== "JSON parse error - Expecting value: line 1 column 1 (char 0)"
)
@pytest.mark.django_db
@patch("music.models.Artist.find_or_create")
@patch("music.models.Track.find_or_create")
def test_create_scrobble_from_mopidy_track_webhook(
mock_track_fc,
mock_artist_fc,
client,
valid_auth_token,
mopidy_track,
):
mock_artist = MagicMock(spec=Artist)
mock_artist.id = 1
mock_artist_fc.return_value = mock_artist
mock_track = MagicMock(spec=Track)
mock_track.id = 1
mock_track.scrobble_for_user.return_value = Scrobble(
id=1, track_id=1, user_id=1, in_progress=True
)
mock_track_fc.return_value = mock_track
url = reverse("scrobbles:mopidy-webhook")
headers = {"Authorization": f"Token {valid_auth_token}"}
response = client.post(
url,
mopidy_track.request_data,
content_type="application/json",
headers=headers,
)
assert response.status_code == 200
assert response.data == {"scrobble_id": 1}
mock_track.scrobble_for_user.assert_called_once()
@pytest.mark.django_db
@patch("music.models.Artist.find_or_create")
@patch("music.models.Track.find_or_create")
def test_create_scrobble_from_jellyfin_track_webhook(
mock_track_fc,
mock_artist_fc,
client,
valid_auth_token,
jellyfin_track,
):
mock_artist = MagicMock(spec=Artist)
mock_artist.id = 1
mock_artist_fc.return_value = mock_artist
mock_track = MagicMock(spec=Track)
mock_track.id = 1
mock_track.scrobble_for_user.return_value = Scrobble(
id=1, track_id=1, user_id=1, in_progress=True
)
mock_track_fc.return_value = mock_track
url = reverse("scrobbles:jellyfin-webhook")
headers = {"Authorization": f"Token {valid_auth_token}"}
with time_machine.travel(datetime(2024, 1, 14, 12, 00, 1)):
jellyfin_track.request_data["UtcTimestamp"] = timezone.now().strftime(
"%Y-%m-%d %H:%M:%S"
)
response = client.post(
url,
jellyfin_track.request_json,
content_type="application/json",
headers=headers,
)
assert response.status_code == 200
assert response.data == {"scrobble_id": 1}
mock_track.scrobble_for_user.assert_called_once()
@pytest.mark.django_db
@patch("music.models.Artist.find_or_create")
@patch("music.models.Track.find_or_create")
def test_mopidy_track_webhook_creates_track_and_scrobble(
mock_track_fc,
mock_artist_fc,
client,
valid_auth_token,
mopidy_track,
):
artist = Artist.objects.create(name="Sublime")
album = Album.objects.create(name="Sublime", album_artist=artist)
track = Track.objects.create(
title="Same in the End",
artist=artist,
base_run_time_seconds=60,
)
track.albums.add(album)
mock_artist_fc.return_value = artist
mock_track_fc.return_value = track
url = reverse("scrobbles:mopidy-webhook")
headers = {"Authorization": f"Token {valid_auth_token}"}
response = client.post(
url,
mopidy_track.request_data,
content_type="application/json",
headers=headers,
)
assert response.status_code == 200
assert response.data == {"scrobble_id": 1}
scrobble = Scrobble.objects.get(id=1)
assert scrobble.track == track
assert scrobble.source == "Mopidy"
@pytest.mark.django_db
@patch("music.models.Artist.find_or_create")
@patch("music.models.Track.find_or_create")
def test_jellyfin_track_webhook_creates_track_and_scrobble(
mock_track_fc,
mock_artist_fc,
client,
valid_auth_token,
jellyfin_track,
):
artist = Artist.objects.create(name="Carly Rae Jepsen")
album = Album.objects.create(name="Emotion", album_artist=artist)
track = Track.objects.create(
title="Emotion",
artist=artist,
base_run_time_seconds=60,
)
track.albums.add(album)
mock_artist_fc.return_value = artist
mock_track_fc.return_value = track
url = reverse("scrobbles:jellyfin-webhook")
headers = {"Authorization": f"Token {valid_auth_token}"}
with time_machine.travel(datetime(2024, 1, 14, 12, 00, 1)):
jellyfin_track.request_data["UtcTimestamp"] = timezone.now().strftime(
"%Y-%m-%d %H:%M:%S"
)
response = client.post(
url,
jellyfin_track.request_json,
content_type="application/json",
headers=headers,
)
assert response.status_code == 200
assert response.data == {"scrobble_id": 1}
scrobble = Scrobble.objects.get(id=1)
assert scrobble.track == track
assert scrobble.source == "Jellyfin"
assert "raw_data" in scrobble.log
@pytest.mark.django_db
@patch("music.models.Artist.find_or_create")
@patch("music.models.Track.find_or_create")
def test_mopidy_track_webhook_stores_raw_data(
mock_track_fc,
mock_artist_fc,
client,
valid_auth_token,
mopidy_track,
):
artist = Artist.objects.create(name="Sublime")
album = Album.objects.create(name="Sublime", album_artist=artist)
track = Track.objects.create(
title="Same in the End",
artist=artist,
base_run_time_seconds=60,
)
track.albums.add(album)
mock_artist_fc.return_value = artist
mock_track_fc.return_value = track
url = reverse("scrobbles:mopidy-webhook")
headers = {"Authorization": f"Token {valid_auth_token}"}
response = client.post(
url,
mopidy_track.request_data,
content_type="application/json",
headers=headers,
)
assert response.status_code == 200
assert response.data == {"scrobble_id": 1}
scrobble = Scrobble.objects.get(id=1)
assert scrobble.track == track
assert scrobble.source == "Mopidy"
assert "raw_data" in scrobble.log
assert scrobble.log["raw_data"]["name"] == "Same in the End"
@pytest.mark.django_db
@patch("music.models.Artist.find_or_create")
@patch("music.models.Track.find_or_create")
def test_mopidy_track_webhook_stores_album_id(
mock_track_fc,
mock_artist_fc,
client,
valid_auth_token,
mopidy_track,
):
artist = Artist.objects.create(name="Sublime")
album = Album.objects.create(name="Sublime", album_artist=artist)
track = Track.objects.create(
title="Same in the End",
artist=artist,
album=album,
base_run_time_seconds=60,
)
mock_artist_fc.return_value = artist
mock_track_fc.return_value = track
url = reverse("scrobbles:mopidy-webhook")
headers = {"Authorization": f"Token {valid_auth_token}"}
response = client.post(
url,
mopidy_track.request_data,
content_type="application/json",
headers=headers,
)
assert response.status_code == 200
scrobble = Scrobble.objects.get(id=1)
assert "album_id" in scrobble.log
assert scrobble.log["album_id"] == album.id
assert "album" not in scrobble.log
@pytest.mark.django_db
@patch("music.models.Artist.find_or_create")
@patch("music.models.Track.find_or_create")
def test_jellyfin_track_webhook_stores_raw_data(
mock_track_fc,
mock_artist_fc,
client,
valid_auth_token,
jellyfin_track,
):
artist = Artist.objects.create(name="Carly Rae Jepsen")
album = Album.objects.create(name="Emotion", album_artist=artist)
track = Track.objects.create(
title="Emotion",
artist=artist,
base_run_time_seconds=60,
)
track.albums.add(album)
mock_artist_fc.return_value = artist
mock_track_fc.return_value = track
url = reverse("scrobbles:jellyfin-webhook")
headers = {"Authorization": f"Token {valid_auth_token}"}
with time_machine.travel(datetime(2024, 1, 14, 12, 00, 1)):
jellyfin_track.request_data["UtcTimestamp"] = timezone.now().strftime(
"%Y-%m-%d %H:%M:%S"
)
response = client.post(
url,
jellyfin_track.request_json,
content_type="application/json",
headers=headers,
)
assert response.status_code == 200
scrobble = Scrobble.objects.get(id=1)
assert scrobble.track == track
assert scrobble.source == "Jellyfin"
assert "raw_data" in scrobble.log
assert scrobble.log["raw_data"]["Name"] == "Emotion"
@pytest.mark.django_db
@patch("music.models.Artist.find_or_create")
@patch("music.models.Track.find_or_create")
def test_jellyfin_track_webhook_stores_album_id(
mock_track_fc,
mock_artist_fc,
client,
valid_auth_token,
jellyfin_track,
):
artist = Artist.objects.create(name="Carly Rae Jepsen")
album = Album.objects.create(name="Emotion", album_artist=artist)
track = Track.objects.create(
title="Emotion",
artist=artist,
album=album,
base_run_time_seconds=60,
)
track.albums.add(album)
mock_artist_fc.return_value = artist
mock_track_fc.return_value = track
url = reverse("scrobbles:jellyfin-webhook")
headers = {"Authorization": f"Token {valid_auth_token}"}
with time_machine.travel(datetime(2024, 1, 14, 12, 00, 1)):
jellyfin_track.request_data["UtcTimestamp"] = timezone.now().strftime(
"%Y-%m-%d %H:%M:%S"
)
response = client.post(
url,
jellyfin_track.request_json,
content_type="application/json",
headers=headers,
)
assert response.status_code == 200
scrobble = Scrobble.objects.get(id=1)
assert "album_id" in scrobble.log
assert scrobble.log["album_id"] == album.id
@pytest.mark.skip("Need to refactor")
@pytest.mark.django_db
@patch("music.utils.lookup_artist_from_mb", return_value={})
@ -146,9 +488,125 @@ def test_scrobble_jellyfin_track(
assert response.status_code == 200
assert response.data == {"scrobble_id": 1}
scrobble = Scrobble.objects.get(id=1)
assert scrobble.media_obj.__class__ == Track
assert scrobble.media_obj.title == "Emotion"
scrobble = Scrobble.objects.get(id=1)
assert scrobble.media_obj.__class__ == Track
assert scrobble.media_obj.title == "Emotion"
@pytest.mark.django_db
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"
)
scrobble = Scrobble.objects.create(
task=task,
media_type="Task",
user=user,
log={
"notes": ["First note", "Second note"],
"description": "Test description",
},
)
url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid})
response = client.get(url)
assert response.status_code == 200
assert "First note" in response.content.decode()
assert "Second note" in response.content.decode()
@pytest.mark.django_db
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"
)
scrobble = Scrobble.objects.create(
task=task,
media_type="Task",
user=user,
log={
"notes": [
{"2024-01-01 10:00:00": "Note at first timestamp"},
{"2024-01-02 11:30:00": "Note at second timestamp"},
],
"description": "Test description",
},
)
url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid})
response = client.get(url)
assert response.status_code == 200
content = response.content.decode()
assert "2024-01-01 10:00:00" in content
assert "Note at first timestamp" in content
assert "2024-01-02 11:30:00" in content
assert "Note at second timestamp" in content
@pytest.mark.django_db
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"
)
scrobble = Scrobble.objects.create(
task=task,
media_type="Task",
user=user,
log={
"notes": [
{"2024-01-01 10:00:00": "Note with label"},
],
"labels": ["work", "urgent"],
"description": "Test description",
},
)
url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid})
response = client.get(url)
assert response.status_code == 200
content = response.content.decode()
assert "work" in content
assert "urgent" in content
@pytest.mark.django_db
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"
)
scrobble = Scrobble.objects.create(
task=task,
media_type="Task",
user=user,
log={
"notes": ["Original note"],
"description": "Original description",
},
)
url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid})
client.force_login(user)
response = client.post(
url,
{
"description": "Updated description",
"notes": "Updated note",
},
)
assert response.status_code == 302
scrobble.refresh_from_db()
assert scrobble.log["description"] == "Updated description"
assert scrobble.log["notes"] == ["Updated note"]
@pytest.mark.skip("Need to refactor")

View File

@ -0,0 +1,95 @@
import os
from unittest.mock import patch, MagicMock
from pathlib import Path
import pytest
from vrobbler.context_processors import version_info
@pytest.fixture
def mock_request():
return MagicMock()
class TestVersionInfo:
def test_returns_version_and_commit(self, mock_request):
with (
patch(
"vrobbler.context_processors.get_version"
) as mock_get_version,
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"abc1234"
result = version_info(mock_request)
assert result["app_version"] == "1.0.0"
assert result["git_commit"] == "abc1234"
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,
):
mock_get_version.return_value = "1.0.0"
result = version_info(mock_request)
assert result["git_commit"] == "env_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.subprocess.check_output"
) as mock_check_output,
):
mock_get_version.side_effect = Exception("not found")
mock_check_output.return_value = b"abc1234"
result = version_info(mock_request)
assert result["app_version"] == "unknown"
def test_returns_unknown_when_git_fails(self, mock_request):
import subprocess
with (
patch(
"vrobbler.context_processors.get_version"
) as mock_get_version,
patch(
"vrobbler.context_processors.subprocess.check_output"
) as mock_check_output,
):
mock_get_version.return_value = "1.0.0"
mock_check_output.side_effect = subprocess.SubprocessError()
result = version_info(mock_request)
assert result["git_commit"] == "unknown"
def test_returns_unknown_when_git_not_found(self, mock_request):
import subprocess
with (
patch(
"vrobbler.context_processors.get_version"
) as mock_get_version,
patch(
"vrobbler.context_processors.subprocess.check_output"
) as mock_check_output,
):
mock_get_version.return_value = "1.0.0"
mock_check_output.side_effect = FileNotFoundError()
result = version_info(mock_request)
assert result["git_commit"] == "unknown"

View File

@ -0,0 +1,104 @@
import pytest
from django.contrib.auth import get_user_model
from rest_framework.authtoken.models import Token
from videos.models import Channel, Series, Video
User = get_user_model()
@pytest.fixture
def auth_headers():
user = User.objects.create(email="api@test.com")
token = Token.objects.create(user=user)
return {"HTTP_AUTHORIZATION": f"Token {token.key}"}
@pytest.fixture
def channel():
return Channel.objects.create(name="Test Channel")
@pytest.fixture
def series():
return Series.objects.create(
name="Test Series",
)
@pytest.fixture
def video(channel):
return Video.objects.create(
title="Test Video",
imdb_id="tt1234567",
channel=channel,
)
@pytest.mark.django_db
class TestVideoAPI:
def test_list_videos(self, client, auth_headers, video):
response = client.get("/api/v1/videos/", **auth_headers)
assert response.status_code == 200
assert len(response.data["results"]) == 1
assert response.data["results"][0]["title"] == "Test Video"
def test_get_video(self, client, auth_headers, video):
response = client.get(f"/api/v1/videos/{video.id}/", **auth_headers)
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
)
assert response.status_code == 200
assert len(response.data["results"]) == 1
assert response.data["results"][0]["channel"] == channel.id
@pytest.mark.django_db
class TestChannelAPI:
def test_list_channels(self, client, auth_headers, channel):
response = client.get("/api/v1/channels/", **auth_headers)
assert response.status_code == 200
assert len(response.data["results"]) == 1
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
)
assert response.status_code == 200
assert response.data["name"] == "Test Channel"
@pytest.mark.django_db
class TestSeriesAPI:
def test_list_series(self, client, auth_headers, series):
response = client.get("/api/v1/series/", **auth_headers)
assert response.status_code == 200
assert len(response.data["results"]) == 1
assert response.data["results"][0]["name"] == "Test Series"
def test_get_series(self, client, auth_headers, series):
response = client.get(f"/api/v1/series/{series.id}/", **auth_headers)
assert response.status_code == 200
assert response.data["name"] == "Test Series"
@pytest.mark.django_db
class TestVideoAPIUnauthorized:
def test_list_videos_unauthenticated(self, client, video):
response = client.get("/api/v1/videos/")
assert response.status_code == 401
def test_list_channels_unauthenticated(self, client, channel):
response = client.get("/api/v1/channels/")
assert response.status_code == 401
def test_list_series_unauthenticated(self, client, series):
response = client.get("/api/v1/series/")
assert response.status_code == 401

View File

@ -1,6 +1,14 @@
import pytest
from videos.sources.imdb import lookup_video_from_imdb
def test_lookup_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"

View File

@ -6,12 +6,14 @@ class BeerSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Beer
fields = "__all__"
class BeerProducerSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = BeerProducer
fields = "__all__"
class BeerStyleSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = BeerStyle

View File

@ -8,11 +8,13 @@ class BeerViewSet(viewsets.ModelViewSet):
serializer_class = serializers.BeerSerializer
permission_classes = [permissions.IsAuthenticated]
class BeerProducerViewSet(viewsets.ModelViewSet):
queryset = models.BeerProducer.objects.all().order_by("-created")
serializer_class = serializers.BeerProducerSerializer
permission_classes = [permissions.IsAuthenticated]
class BeerStyleViewSet(viewsets.ModelViewSet):
queryset = models.BeerStyle.objects.all().order_by("-created")
serializer_class = serializers.BeerStyleSerializer

View File

@ -1,21 +1,25 @@
from boardgames import models
from rest_framework import serializers
class BoardGameDesignerSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = models.BoardGameDesigner
fields = "__all__"
class BoardGamePublisherSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = models.BoardGamePublisher
fields = "__all__"
class BoardGameLocationSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = models.BoardGameLocation
fields = "__all__"
class BoardGameSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = models.BoardGame

View File

@ -19,7 +19,10 @@ SEARCH_ID_URL = (
)
GAME_ID_URL = "https://boardgamegeek.com/xmlapi/boardgame/{id}"
BGG_ACCESS_TOKEN = getattr(settings, "BGG_ACCESS_TOKEN", "")
BASE_HEADERS = {"User-Agent": "Vrobbler 31.0", "Authorization": f"Bearer {BGG_ACCESS_TOKEN}"}
BASE_HEADERS = {
"User-Agent": "Vrobbler 31.0",
"Authorization": f"Bearer {BGG_ACCESS_TOKEN}",
}
def take_first(thing: Optional[list]) -> str:

View File

@ -1,13 +1,13 @@
from functools import cached_property
import logging
from dataclasses import dataclass
from datetime import datetime
from typing import Optional, Any
from functools import cached_property
from typing import Any, Optional
from uuid import uuid4
from django import forms
import requests
from boardgames.sources.bgg import lookup_boardgame_from_bgg
from django import forms
from django.conf import settings
from django.core.files.base import ContentFile
from django.db import models
@ -321,22 +321,31 @@ class BoardGame(ScrobblableMixin):
) -> "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:
game = cls.objects.filter(title=lookup_id).first()
if game:
logger.info("Board game exists in database.", extra={"lookup_id": lookup_id, "data": data})
logger.info(
"Board game exists in database.",
extra={"lookup_id": lookup_id, "data": data},
)
return game
bgg_data = lookup_boardgame_from_bgg(data.get("name"))
if data.get("bggId"):
bgg_data = lookup_boardgame_from_bgg(lookup_id=data.get("bggId"))
elif data.get("name"):
bgg_data = lookup_boardgame_from_bgg(title=data.get("name"))
else:
bgg_data = lookup_boardgame_from_bgg(title=lookup_id)
mechanics = bgg_data.pop("mechanics", [])
designers = bgg_data.pop("designers", [])
categories = bgg_data.pop("categories", [])
publishers = bgg_data.pop("publishers", [])
publisher = bgg_data.pop("publisher", [])
cover_url = bgg_data.pop("cover_url")
game = cls.objects.create(
**bgg_data
)
game = cls.objects.create(**bgg_data)
game.save_image_from_url(cover_url)
game.cooperative = data.get("cooperative", False)
@ -344,6 +353,11 @@ class BoardGame(ScrobblableMixin):
game.no_points = data.get("noPoints", False)
game.uses_teams = data.get("useTeams", False)
game.bgstats_id = data.get("uuid", None)
if publisher:
publisher, _ = BoardGamePublisher.objects.get_or_create(
name=publisher
)
game.publisher = publisher
game.save()
if designers:

View File

@ -1,16 +1,23 @@
from typing import Any
from typing import Any, Union
from boardgamegeek import BGGClient
from django.conf import settings
def lookup_boardgame_from_bgg(title: str) -> dict[str, Any]:
game_dict = {"title": title}
def lookup_boardgame_from_bgg(
lookup_id: str | None = None, title: str | None = None
) -> dict[str, Any]:
game_dict: dict[str, Any] = {}
bgg = BGGClient(access_token=settings.BGG_ACCESS_TOKEN)
game = bgg.game(title)
if lookup_id:
game = bgg.game(game_id=lookup_id)
else:
game = bgg.game(title)
if game:
game_dict["title"] = game.name
game_dict["description"] = game.description
game_dict["published_year"] = game.yearpublished
game_dict["cover_url"] = game.image
@ -18,12 +25,16 @@ def lookup_boardgame_from_bgg(title: str) -> dict[str, Any]:
game_dict["max_players"] = game.maxplayers
game_dict["recommended_age"] = game.minage
game_dict["rating"] = game.rating_average
game_dict["bggeek_id"] = game.id
game_dict["bgg_rank"] = game.bgg_rank
game_dict["base_run_time_seconds"] = int(game.playingtime) * 60 if game.playingtime else None
game_dict["base_run_time_seconds"] = (
int(game.playingtime) * 60 if game.playingtime else None
)
game_dict["mechanics"] = game.mechanics
game_dict["categories"] = game.categories
game_dict["designers"] = game.designers
game_dict["publishers"] = game.publishers
if game.publishers:
game_dict["publisher"] = game.publishers[0]
return game_dict

View File

@ -1,11 +1,68 @@
import datetime
from django.utils import timezone
from django.views import generic
from boardgames.models import BoardGame, BoardGamePublisher
from boardgames.models import BoardGame, BoardGameDesigner, BoardGamePublisher
from scrobbles.models import Scrobble
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
class BoardGameListView(ScrobbleableListView):
model = BoardGame
def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs)
user = self.request.user
now = timezone.now()
start_day_of_week = now - datetime.timedelta(days=now.weekday())
start_day_of_month = now.replace(day=1)
scrobbles_this_week = Scrobble.objects.filter(
user=user,
board_game__isnull=False,
timestamp__gte=start_day_of_week,
).select_related("board_game")
scrobbles_this_month = Scrobble.objects.filter(
user=user,
board_game__isnull=False,
timestamp__gte=start_day_of_month,
).select_related("board_game")
designers_this_week = {}
for scrobble in scrobbles_this_week:
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
)
+ 1,
}
designers_this_month = {}
for scrobble in scrobbles_this_month:
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
)
+ 1,
}
context_data["designers_this_week"] = sorted(
designers_this_week.values(),
key=lambda x: x["count"],
reverse=True,
)
context_data["designers_this_month"] = sorted(
designers_this_month.values(),
key=lambda x: x["count"],
reverse=True,
)
return context_data
class BoardGameDetailView(ScrobbleableDetailView):
model = BoardGame

View File

@ -139,7 +139,6 @@ def build_book_map(rows) -> dict:
book_id_map = {}
for book_row in rows:
if book_row[KoReaderBookColumn.TITLE.value] in BOOKS_TITLES_TO_IGNORE:
logger.info(
"[build_book_map] Ignoring book title that is likely garbage",
@ -286,9 +285,9 @@ def build_scrobbles_from_book_map(
)
# Adjust for Daylight Saving Time
#if timestamp.dst() == timedelta(
# if timestamp.dst() == timedelta(
# 0
#) or stop_timestamp.dst() == timedelta(0):
# ) or stop_timestamp.dst() == timedelta(0):
# timestamp = timestamp - timedelta(hours=1)
# stop_timestamp = stop_timestamp - timedelta(hours=1)
@ -307,6 +306,10 @@ def build_scrobbles_from_book_map(
"page_data": scrobble_page_data,
"pages_read": len(scrobble_page_data.keys()),
}
if hasattr(timestamp.tzinfo, "tzname"):
tz = timestamp.tzinfo.tzname
if hasattr(timestamp.tzinfo, "name"):
tz = timestamp.tzinfo.name
scrobbles_to_create.append(
Scrobble(
book_id=book_id,
@ -320,7 +323,7 @@ def build_scrobbles_from_book_map(
in_progress=False,
played_to_completion=True,
long_play_complete=False,
timezone=timestamp.tzinfo.name,
timezone=tz,
)
)
# Then start over

View File

@ -14,14 +14,13 @@ logger = logging.getLogger(__name__)
# Grace period between page reads for it to be a new scrobble
SESSION_GAP_SECONDS = 1800 # a half hour
def update_scrobble_from_page_data(scrobble, commit=True):
page_list = list(scrobble.book_page_data.items())
first_page_start_ts = datetime.fromtimestamp(page_list[0][1]["start_ts"])
last_page_end_ts = datetime.fromtimestamp(page_list[-1][1]["end_ts"])
if (
datetime(2023, 10, 15) <= first_page_start_ts <= datetime(2023, 12, 15)
):
if datetime(2023, 10, 15) <= first_page_start_ts <= datetime(2023, 12, 15):
first_page_start_ts.replace(tzinfo=pytz.timezone("Europe/Paris"))
last_page_end_ts.replace(tzinfo=pytz.timezone("Europe/Paris"))
else:
@ -38,5 +37,7 @@ 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)

View File

@ -1,10 +1,8 @@
from enum import Enum
from typing import Optional
import pendulum
YOUTUBE_VIDEO_URL = "https://www.youtube.com/watch?v="
IMDB_VIDEO_URL = "https://www.imdb.com/title/tt"
IMDB_VIDEO_URL = "https://www.imdb.com/title/"
class BookType:

View File

@ -0,0 +1,23 @@
# Generated by Django 4.2.29 on 2026-03-08 05:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("books", "0032_remove_book_run_time_seconds_and_more"),
]
operations = [
migrations.AlterField(
model_name="book",
name="issue_number",
field=models.IntegerField(blank=True, null=True),
),
migrations.AlterField(
model_name="book",
name="volume_number",
field=models.IntegerField(blank=True, null=True),
),
]

View File

@ -153,8 +153,8 @@ class Book(LongPlayScrobblableMixin):
comicvine_id = models.CharField(max_length=255, **BNULL)
readcomics_url = models.CharField(max_length=255, **BNULL)
next_readcomics_url = models.CharField(max_length=255, **BNULL)
issue_number = models.IntegerField(max_length=5, **BNULL)
volume_number = models.IntegerField(max_length=5, **BNULL)
issue_number = models.IntegerField(**BNULL)
volume_number = models.IntegerField(**BNULL)
# OpenLibrary
openlibrary_id = models.CharField(max_length=255, **BNULL)
cover = models.ImageField(upload_to="books/covers/", **BNULL)
@ -204,7 +204,9 @@ class Book(LongPlayScrobblableMixin):
return reverse("books:book_detail", kwargs={"slug": self.uuid})
@classmethod
def get_from_comicvine(cls, title: str, overwrite: bool = False, force_new: bool =False) -> "Book":
def get_from_comicvine(
cls, title: str, overwrite: bool = False, force_new: bool = False
) -> "Book":
book, created = cls.objects.get_or_create(title=title)
if not created:
@ -241,7 +243,11 @@ class Book(LongPlayScrobblableMixin):
@classmethod
def find_or_create(
cls, title: str, url: str = "", enrich: bool = False, commit: bool = True
cls,
title: str,
url: str = "",
enrich: bool = False,
commit: bool = True,
):
"""Given a title, get a Book instance.
@ -269,13 +275,18 @@ class Book(LongPlayScrobblableMixin):
if READCOMICSONLINE_URL in url:
book_dict = lookup_comic_from_comicvine(title)
book_dict["readcomics_url"] = get_comic_issue_url(url)
book_dict["next_readcomics_url"] = next_url_if_exists(book_dict["readcomics_url"])
book_dict["next_readcomics_url"] = next_url_if_exists(
book_dict["readcomics_url"]
)
if not book_dict:
book_dict = lookup_book_from_google(title)
if not book_dict:
logger.warning("No book found in any source, using data as is", extra={"title": title})
logger.warning(
"No book found in any source, using data as is",
extra={"title": title},
)
author_list = []
authors = book_dict.pop("authors", [])

View File

@ -214,7 +214,9 @@ 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(
@ -223,9 +225,7 @@ def lookup_comic_from_comicvine(title: str) -> dict:
raw_results = client.search(title).get("results")
results = [
r
for r in raw_results
if r.get("resource_type") == resource_type
r for r in raw_results if r.get("resource_type") == resource_type
]
if not results:
logger.warning("No comic found on ComicVine")
@ -264,7 +264,7 @@ def lookup_comic_from_comicvine(title: str) -> dict:
"comicvine_data": found_result,
"summary": found_result.get("description"),
"publish_date": found_result.get("cover_date"),
"first_publish_year": found_result.get("cover_date", "")[:4]
"first_publish_year": found_result.get("cover_date", "")[:4],
}
return data_dict

View File

@ -7,7 +7,7 @@ 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}"
'https://www.googleapis.com/books/v1/volumes?q="{title}"&key={key}'
)
logger = logging.getLogger(__name__)
@ -69,8 +69,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

View File

@ -67,9 +67,9 @@ def lookup_paper_from_semantic(title: str) -> dict:
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")

View File

@ -1,11 +1,16 @@
import pytest
from unittest import mock
from datetime import datetime
from books.koreader import (
KoReaderBookColumn,
build_book_map,
build_page_data,
build_scrobbles_from_book_map,
get_author_str_from_row,
lookup_or_create_authors_from_author_str,
create_book_from_row,
process_koreader_sqlite_file,
)
@ -52,3 +57,157 @@ def test_build_scrobbles_from_pages(
assert len(scrobbles[3].logdata.page_data.keys()) == 20
assert len(scrobbles[4].logdata.page_data.keys()) == 20
assert len(scrobbles[5].logdata.page_data.keys()) == 18
def test_get_author_str_from_row():
row = [
1,
"Test Book",
"John Smith\nJane Doe",
0,
0,
0,
300,
"",
"",
"abc123",
0,
120,
]
result = get_author_str_from_row(row)
assert result == "John Smith, Jane Doe"
def test_get_author_str_from_row_strips_middle_initials():
row = [
1,
"Test Book",
"John A. Smith",
0,
0,
0,
300,
"",
"",
"abc123",
0,
120,
]
result = get_author_str_from_row(row)
assert result == "John Smith"
@pytest.mark.django_db
def test_lookup_or_create_authors_from_author_str_creates_new_author():
author = lookup_or_create_authors_from_author_str("New Author")
assert len(author) == 1
assert author[0].name == "New Author"
@pytest.mark.django_db
def test_lookup_or_create_authors_from_author_str_finds_existing():
from books.models import Author
Author.objects.create(name="Existing Author")
author = lookup_or_create_authors_from_author_str("Existing Author")
assert len(author) == 1
assert author[0].name == "Existing Author"
@pytest.mark.django_db
def test_lookup_or_create_authors_from_author_str_ignores_na():
author = lookup_or_create_authors_from_author_str("N/A")
assert len(author) == 0
@pytest.mark.django_db
def test_lookup_or_create_authors_from_author_str_handles_multiple():
author = lookup_or_create_authors_from_author_str("Author One, Author Two")
assert len(author) == 2
@pytest.mark.django_db
def test_create_book_from_row():
row = [
1,
"Test Book - Author Name",
"Test Author",
0,
0,
0,
300,
"",
"",
"abc123def456",
3600,
120,
]
book = create_book_from_row(row)
assert book.title == "Test Book"
assert book.pages == 300
assert "abc123def456" in book.koreader_data_by_hash
assert book.authors.count() == 1
@pytest.mark.django_db
def test_create_book_from_row_handles_title_with_author_suffix():
row = [
1,
"Test Book - Author_Name",
"N/A",
0,
0,
0,
300,
"",
"",
"abc123def456",
3600,
120,
]
book = create_book_from_row(row)
assert book.title == "Test Book"
assert book.authors.count() == 1
assert book.authors.first().name == "Author"
@pytest.mark.django_db
def test_create_book_from_row_handles_null_bytes():
row = [
1,
"Test\x00Book",
"Test\x00Author",
0,
0,
0,
300,
"",
"",
"abc123def456",
3600,
120,
]
book = create_book_from_row(row)
assert "TestBook" in book.title
assert "TestAuthor" in book.authors.first().name
@pytest.mark.django_db
def test_build_book_map_ignores_garbage_titles(koreader_rows):
garbage_row = [
999,
"KOReader Quickstart Guide",
"N/A",
0,
0,
0,
0,
"",
"",
"garbage123",
0,
0,
]
book_map = build_book_map(koreader_rows.BOOK_ROWS + [garbage_row])
assert len(book_map) == 1
assert 999 not in book_map

View File

@ -10,7 +10,7 @@ def parse_readcomicsonline_uri(uri: str) -> tuple:
except IndexError:
return "", "", ""
parts = path.split('/')
parts = path.split("/")
title = ""
volume = 1
page = 1
@ -27,7 +27,7 @@ def parse_readcomicsonline_uri(uri: str) -> tuple:
def get_comic_issue_url(url: str) -> str:
parsed = urlparse(url)
parts = [p for p in parsed.path.strip('/').split('/') if p]
parts = [p for p in parsed.path.strip("/").split("/") if p]
# Find the index of "comic"
try:
@ -42,7 +42,7 @@ def get_comic_issue_url(url: str) -> str:
# Look for the first numeric segment after the title
number = None
for segment in parts[comic_index + 2:]:
for segment in parts[comic_index + 2 :]:
if segment.isdigit():
number = segment
break
@ -55,5 +55,7 @@ def get_comic_issue_url(url: str) -> str:
normalized_path = "/" + "/".join(new_parts)
# Rebuild full URL (same scheme and host)
simplified_url = urlunparse(parsed._replace(path=normalized_path, query='', fragment=''))
simplified_url = urlunparse(
parsed._replace(path=normalized_path, query="", fragment="")
)
return simplified_url

View File

@ -0,0 +1,3 @@
#!/usr/bin/env python3
EXCLUDED_FOOD_DOMAINS = ["bbc.co.uk", "bbc.com", "www.bbc.com"]

View File

@ -0,0 +1,131 @@
# Generated by Django 4.2.29 on 2026-03-05 17:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("foods", "0004_remove_food_run_time_seconds_and_more"),
]
operations = [
migrations.AddField(
model_name="food",
name="carbohydrates",
field=models.DecimalField(
blank=True, decimal_places=2, max_digits=10, null=True
),
),
migrations.AddField(
model_name="food",
name="cook_time_minutes",
field=models.IntegerField(blank=True, null=True),
),
migrations.AddField(
model_name="food",
name="fat",
field=models.DecimalField(
blank=True, decimal_places=2, max_digits=10, null=True
),
),
migrations.AddField(
model_name="food",
name="fiber",
field=models.DecimalField(
blank=True, decimal_places=2, max_digits=10, null=True
),
),
migrations.AddField(
model_name="food",
name="ingredients",
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name="food",
name="instructions",
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name="food",
name="prep_time_minutes",
field=models.IntegerField(blank=True, null=True),
),
migrations.AddField(
model_name="food",
name="protein",
field=models.DecimalField(
blank=True, decimal_places=2, max_digits=10, null=True
),
),
migrations.AddField(
model_name="food",
name="servings",
field=models.IntegerField(blank=True, null=True),
),
migrations.AddField(
model_name="food",
name="sodium",
field=models.DecimalField(
blank=True, decimal_places=2, max_digits=10, null=True
),
),
migrations.AddField(
model_name="food",
name="source_site",
field=models.CharField(blank=True, max_length=100, null=True),
),
migrations.AddField(
model_name="food",
name="source_url",
field=models.URLField(blank=True, null=True, unique=True),
),
migrations.AddField(
model_name="food",
name="sugar",
field=models.DecimalField(
blank=True, decimal_places=2, max_digits=10, null=True
),
),
migrations.AddField(
model_name="food",
name="total_time_minutes",
field=models.IntegerField(blank=True, null=True),
),
migrations.AddField(
model_name="food",
name="yield_text",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddIndex(
model_name="food",
index=models.Index(
fields=["source_url"], name="foods_food_source__f42f6f_idx"
),
),
migrations.AddIndex(
model_name="food",
index=models.Index(
fields=["allrecipe_id"], name="foods_food_allreci_3e7a6c_idx"
),
),
migrations.AddIndex(
model_name="food",
index=models.Index(
fields=["description"], name="foods_food_descrip_fccd4d_idx"
),
),
migrations.RenameField(
model_name="food",
old_name="allrecipe_image",
new_name="recipe_image",
),
migrations.RemoveField(
model_name="foodcategory",
name="allrecipe_image",
),
migrations.AddField(
model_name="foodcategory",
name="category_image",
field=models.ImageField(blank=True, null=True, upload_to="food/category/"),
),
]

View File

@ -1,5 +1,6 @@
import json
from dataclasses import dataclass
from typing import Optional
from typing import Optional, Tuple
from uuid import uuid4
from django.apps import apps
@ -10,6 +11,8 @@ from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit
from scrobbles.dataclasses import BaseLogData, WithPeopleLogData
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
from foods.sources.rscraper import RecipeScraperService
from foods.sources.usda import NutritionCalculator
BNULL = {"blank": True, "null": True}
@ -18,21 +21,20 @@ BNULL = {"blank": True, "null": True}
class FoodLogData(BaseLogData, WithPeopleLogData):
calories: Optional[int] = None
meal: Optional[str] = None
rating: Optional[str] = None
class FoodCategory(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
name = models.CharField(max_length=255)
allrecipe_image = models.ImageField(upload_to="food/recipe/", **BNULL)
allrecipe_image_small = ImageSpecField(
source="recipe_image",
category_image = models.ImageField(upload_to="food/category/", **BNULL)
category_image_small = ImageSpecField(
source="category_image",
processors=[ResizeToFit(100, 100)],
format="JPEG",
options={"quality": 60},
)
allrecipe_image_medium = ImageSpecField(
source="recipe_image",
category_image_medium = ImageSpecField(
source="category_image",
processors=[ResizeToFit(300, 300)],
format="JPEG",
options={"quality": 75},
@ -40,6 +42,7 @@ class FoodCategory(TimeStampedModel):
allrecipe_id = models.CharField(max_length=255, **BNULL)
description = models.TextField(**BNULL)
@classmethod
def find_or_create(cls, title: str) -> "FoodCategory":
return cls.objects.filter(title=title).first()
@ -48,17 +51,42 @@ class FoodCategory(TimeStampedModel):
class Food(ScrobblableMixin):
description = models.TextField(**BNULL)
# Recipe source tracking
source_url = models.URLField(null=True, blank=True, unique=True)
source_site = models.CharField(max_length=100, null=True, blank=True)
# Recipe data
description = models.TextField(**BNULL) # Recipe title
ingredients = models.TextField(**BNULL) # JSON or newline-separated
instructions = models.TextField(**BNULL) # JSON or newline-separated
prep_time_minutes = models.IntegerField(**BNULL)
cook_time_minutes = models.IntegerField(**BNULL)
total_time_minutes = models.IntegerField(**BNULL)
servings = models.IntegerField(**BNULL)
yield_text = models.CharField(
max_length=255, **BNULL
) # e.g., "8 calzones"
# Nutrition (per serving)
calories = models.IntegerField(**BNULL)
allrecipe_image = models.ImageField(upload_to="food/recipe/", **BNULL)
allrecipe_image_small = ImageSpecField(
source="allrecipe_image",
protein = models.DecimalField(max_digits=10, decimal_places=2, **BNULL)
fat = models.DecimalField(max_digits=10, decimal_places=2, **BNULL)
carbohydrates = models.DecimalField(
max_digits=10, decimal_places=2, **BNULL
)
fiber = models.DecimalField(max_digits=10, decimal_places=2, **BNULL)
sugar = models.DecimalField(max_digits=10, decimal_places=2, **BNULL)
sodium = models.DecimalField(max_digits=10, decimal_places=2, **BNULL)
recipe_image = models.ImageField(upload_to="food/recipe/", **BNULL)
recipe_image_small = ImageSpecField(
source="recipe_image",
processors=[ResizeToFit(100, 100)],
format="JPEG",
options={"quality": 60},
)
allrecipe_image_medium = ImageSpecField(
source="allrecipe_image",
recipe_image_medium = ImageSpecField(
source="recipe_image",
processors=[ResizeToFit(300, 300)],
format="JPEG",
options={"quality": 75},
@ -69,6 +97,13 @@ class Food(ScrobblableMixin):
FoodCategory, on_delete=models.DO_NOTHING, **BNULL
)
class Meta:
indexes = [
models.Index(fields=["source_url"]),
models.Index(fields=["allrecipe_id"]),
models.Index(fields=["description"]),
]
def get_absolute_url(self) -> str:
return reverse("foods:food_detail", kwargs={"slug": self.uuid})
@ -92,8 +127,8 @@ class Food(ScrobblableMixin):
@property
def primary_image_url(self) -> str:
url = ""
if self.allrecipe_image:
url = self.allrecipe_image.url
if self.recipe_image:
url = self.recipe_image.url
return url
@property
@ -101,21 +136,155 @@ class Food(ScrobblableMixin):
return FoodLogData
@classmethod
def find_or_create(cls, allrecipe_id: str) -> "Food":
food = cls.objects.filter(allrecipe_id=allrecipe_id).first()
def find_or_create_from_recipe(
cls, url: str, category=None
) -> Tuple["Food", bool]:
"""
Scrape a recipe URL and create/update Food instance.
Uses django-taggit for tags and existing FoodCategory for category.
"""
# Check if URL already exists
existing = cls.objects.filter(source_url=url).first()
if existing:
return existing, False
if not food:
food_dict = get_food_from_allrecipe_id(allrecipe_id)
# category_dict = {}
# Scrape the recipe
scraper = RecipeScraperService()
recipe_data = scraper.scrape_url(url)
# category, _created = FoodCategory.objects.get_or_create(
# **category_dict
# )
food = Food.objects.create(**food_dict)
# for category_id in category_ids:
# food.category.add(category_id)
# Download image
recipe_image = None
if recipe_data.get("image"):
recipe_image = scraper.download_image(recipe_data["image"])
return food
# Extract tags
tags = scraper.extract_tags(recipe_data)
# Calculate nutrition
nutrition = recipe_data.get("nutrition")
if not nutrition:
calculator = NutritionCalculator()
servings = (
scraper.parse_servings(recipe_data.get("yields", "1")) or 1
)
nutrition = calculator.calculate_nutrition(
recipe_data.get("ingredients", []), servings
)
else:
servings = (
scraper.parse_servings(recipe_data.get("yields", "1")) or 1
)
# Get or create category (if not provided)
if not category and recipe_data.get("category"):
category, _ = FoodCategory.objects.get_or_create(
name=recipe_data["category"]
)
# Create Food instance
food = cls.objects.create(
title=recipe_data.get("title"),
source_url=url,
source_site=recipe_data.get("site"),
ingredients=json.dumps(recipe_data.get("ingredients", [])),
instructions=json.dumps(recipe_data.get("instructions", [])),
prep_time_minutes=recipe_data.get("prep_time"),
cook_time_minutes=recipe_data.get("cook_time"),
total_time_minutes=recipe_data.get("total_time"),
servings=servings,
yield_text=recipe_data.get("yields"),
# cuisine=recipe_data.get("cuisine"),
# course=recipe_data.get("course"),
calories=int(nutrition.get("calories", 0)) if nutrition else None,
protein=nutrition.get("protein") if nutrition else None,
fat=nutrition.get("fat") if nutrition else None,
carbohydrates=(
nutrition.get("carbohydrates") if nutrition else None
),
fiber=nutrition.get("fiber") if nutrition else None,
category=category,
)
# Add tags via django-taggit
if tags:
food.genre.add(*tags)
# Save image if downloaded
if recipe_image:
food.recipe_image.save(recipe_image.name, recipe_image, save=True)
if "allrecipes.com" in url:
food.allrecipe_id = url.split("/recipe/")[-1].split("/")[0]
food.save()
return food, True
@classmethod
def find_or_create_from_ingredients(
cls,
ingredients: list[str],
title: str,
servings: int = 1,
category=None,
) -> Tuple["Food", bool]:
"""
Create Food from ingredient list (no URL).
Calculates nutrition using USDA API.
"""
# Check if similar recipe exists
existing = cls.objects.filter(description__icontains=title).first()
if existing:
return existing, False
calculator = NutritionCalculator()
nutrition = calculator.calculate_nutrition(ingredients, servings)
food = cls.objects.create(
description=title,
ingredients=json.dumps(ingredients),
servings=servings,
calories=int(nutrition.get("calories", 0)) if nutrition else None,
protein=nutrition.get("protein") if nutrition else None,
fat=nutrition.get("fat") if nutrition else None,
carbohydrates=(
nutrition.get("carbohydrates") if nutrition else None
),
fiber=nutrition.get("fiber") if nutrition else None,
category=category,
)
return food, True
def refresh_nutrition(self) -> bool:
"""Recalculate nutrition from ingredients (useful if USDA data updated)."""
if not self.ingredients:
return False
try:
ingredients = json.loads(self.ingredients)
calculator = NutritionCalculator()
nutrition = calculator.calculate_nutrition(
ingredients, self.servings or 1
)
self.calories = int(nutrition.get("calories", 0))
self.protein = nutrition.get("protein")
self.fat = nutrition.get("fat")
self.carbohydrates = nutrition.get("carbohydrates")
self.fiber = nutrition.get("fiber")
self.save(
update_fields=[
"calories",
"protein",
"fat",
"carbohydrates",
"fiber",
]
)
return True
except Exception:
return False
def scrobbles(self, user_id):
Scrobble = apps.get_model("scrobbles", "Scrobble")

View File

@ -0,0 +1,190 @@
import logging
from typing import Dict, List, Optional
from urllib.parse import urlparse
import requests
from django.core.files.base import ContentFile
from foods.constants import EXCLUDED_FOOD_DOMAINS
from recipe_scrapers import scrape_html
from recipe_scrapers._exceptions import (
NoSchemaFoundInWildMode,
SchemaOrgException,
)
logger = logging.getLogger(__name__)
class RecipeScraperService:
def __init__(self):
self.session = requests.Session()
self.session.headers.update(
{"User-Agent": "Mozilla/5.0 (compatible; Vrobbler/0.3)"}
)
def scrape(self, html: str, org_url: str) -> Dict:
"""Scrape recipe data from HTML.
Args:
html: The HTML content of the page
org_url: The original URL the HTML was fetched from
"""
try:
scraper = scrape_html(html, org_url=org_url, supported_only=False)
except Exception as e:
raise ValueError(f"Failed to scrape {org_url}: {str(e)}")
data_dict = {
"title": scraper.title(),
"ingredients": self._safe_call(scraper, "ingredients", []),
"instructions": self._safe_call(scraper, "instructions_list", []),
"yields": self._safe_call(scraper, "yields"),
"nutrition": self._safe_call(scraper, "nutrition"),
"course": self._safe_call(scraper, "course"),
"dietary": self._safe_call(scraper, "dietary", []),
"prep_time": self._safe_call(scraper, "prep_time"),
"total_time": self._safe_call(scraper, "total_time"),
"image": self._safe_call(scraper, "image"),
"cook_time": self._safe_call(scraper, "cook_time"),
"cuisine": self._safe_call(scraper, "cuisine"),
"site": self._safe_call(scraper, "host"),
"url": org_url,
"category": self._safe_call(scraper, "category"),
"keywords": self._safe_call(scraper, "keywords"),
}
return data_dict
def scrape_url(self, url: str):
response = self.session.get(url)
if response.status_code != 200:
raise Exception("Recipe website returned non 200 response")
return self.scrape(response.text, org_url=url)
@classmethod
def is_recipe(self, html: str, org_url: str) -> bool:
"""Check if HTML contains a recipe.
Args:
html: The HTML content to check
org_url: The original URL the HTML was fetched from
"""
try:
scraper = scrape_html(html, org_url=org_url, supported_only=False)
return scraper.schema is not None
except NoSchemaFoundInWildMode:
return False
except Exception:
return False
@classmethod
def is_recipe_url(cls, url: str) -> bool:
"""Check if a URL points to a recipe by fetching and checking the HTML."""
domain = urlparse(url).netloc
if domain in EXCLUDED_FOOD_DOMAINS and "food" not in url:
logger.info("[food] domain found in EXCLUDED_FOOD_DOMAINS")
return False
try:
response = requests.get(
url,
timeout=10,
headers={
"User-Agent": "Mozilla/5.0 (compatible; Vrobbler/0.3)"
},
)
if response.status_code != 200:
logger.debug("Recipe website returned non 200 response")
return False
return cls.is_recipe(response.text, url)
except Exception as e:
return False
def _safe_call(self, scraper, method, default=None):
"""
Safely call a scraper method, returning default on error.
Handles both missing methods (AttributeError) and methods that return None.
"""
try:
# Check if method exists before calling
if not hasattr(scraper, method):
return default
result = getattr(scraper, method)()
return result if result else default
except (
AttributeError,
TypeError,
KeyError,
ValueError,
SchemaOrgException,
):
# AttributeError: method doesn't exist on this scraper class
# TypeError: method exists but can't be called
# KeyError: method exists but data structure is unexpected
return default
def download_image(self, image_url: str) -> Optional[ContentFile]:
"""Download recipe image and return as Django ContentFile."""
if not image_url:
return None
try:
response = self.session.get(image_url, timeout=10)
response.raise_for_status()
filename = image_url.split("/")[-1].split("?")[0]
if not filename.endswith((".jpg", ".jpeg", ".png", ".webp")):
filename = "recipe_image.jpg"
return ContentFile(response.content, name=filename)
except Exception:
return None
def parse_servings(self, yield_text: str) -> Optional[int]:
"""Extract number of servings from yield text."""
import re
if not yield_text:
return None
match = re.search(r"(\d+)", yield_text)
return int(match.group(1)) if match else None
def extract_tags(self, recipe_data: Dict) -> List[str]:
"""Extract all tags from recipe metadata."""
tags = set()
# Add cuisine
if recipe_data.get("cuisine"):
cuisine = recipe_data["cuisine"]
if isinstance(cuisine, str):
tags.add(cuisine.strip())
elif isinstance(cuisine, list):
tags.update([c.strip() for c in cuisine if c])
# Add dietary
if recipe_data.get("dietary"):
dietary = recipe_data["dietary"]
if isinstance(dietary, str):
tags.add(dietary.strip())
elif isinstance(dietary, list):
tags.update([d.strip() for d in dietary if d])
# Add course
if recipe_data.get("course"):
course = recipe_data["course"]
if isinstance(course, str):
tags.add(course.strip())
elif isinstance(course, list):
tags.update([c.strip() for c in course if c])
# Add keywords
keywords = recipe_data.get("keywords")
if keywords:
if isinstance(keywords, str):
tags.update(
[k.strip() for k in keywords.split(",") if k.strip()]
)
elif isinstance(keywords, list):
tags.update([k.strip() for k in keywords if k.strip()])
return list(tags)

View File

@ -0,0 +1,577 @@
from django.conf import settings
import requests
from typing import Dict, List, Optional
from functools import lru_cache
from typing import List, Dict
import re
from fractions import Fraction
import logging
logger = logging.getLogger(__name__)
class USDAFoodAPI:
BASE_URL = "https://api.nal.usda.gov/fdc/v1"
def __init__(self, api_key: Optional[str] = None):
self.api_key = api_key or getattr(settings, "USDA_API_KEY", None)
if not self.api_key:
raise ValueError(
"USDA API key not found. Set USDA_API_KEY in your Django settings."
)
self.session = requests.Session()
def _get_base_params(self) -> Dict:
"""Return base parameters including the API key."""
return {"api_key": self.api_key}
@lru_cache(maxsize=100)
def search_foods(
self, query: str, page_size: int = 50, page_number: int = 1
) -> List[Dict]:
"""Search for food items by keyword."""
url = f"{self.BASE_URL}/foods/search"
params = {
**self._get_base_params(),
"query": query,
"pageSize": page_size,
"pageNumber": page_number,
"includeNutrientCodes": "203,204,205,208,269",
}
response = self.session.get(url, params=params)
response.raise_for_status()
data = response.json()
return data.get("foods", [])
def get_food_details(self, fdc_id: int) -> Dict:
"""Retrieve detailed nutrient data for a specific food item."""
url = f"{self.BASE_URL}/food/{fdc_id}"
params = {
**self._get_base_params(),
"nutrients": [
203,
204,
205,
208,
269,
], # Protein, Fat, Carbs, Calories, Fiber
}
response = self.session.get(url, params=params)
response.raise_for_status()
return response.json()
def get_multiple_foods(self, fdc_ids: List[int]) -> List[Dict]:
"""Retrieve details for multiple food items."""
url = f"{self.BASE_URL}/foods"
params = {
**self._get_base_params(),
"fdcIds": fdc_ids,
"nutrients": ["203", "204", "205", "208", "269"],
}
response = self.session.get(url, params=params)
response.raise_for_status()
return response.json().get("foods", [])
def extract_nutrients(self, food_data: Dict) -> Dict:
"""Extract key nutrients into a clean dictionary."""
nutrients = {
"protein": 0,
"fat": 0,
"carbohydrates": 0,
"calories": 0,
"fiber": 0,
"sugar": 0,
"sodium": 0,
}
nutrient_map = {
"203": "protein",
"204": "fat",
"205": "carbohydrates",
"208": "calories",
"269": "fiber",
"2000": "sodium",
"269": "sugar",
}
food_nutrients = food_data.get("foodNutrients", [])
if not food_nutrients:
logger.warning(
f"No nutrients found for food: {food_data.get('description', 'unknown')}"
)
return nutrients
for nutrient in food_nutrients:
nutrient_id = None
value = None
if isinstance(nutrient, dict):
if "nutrientNumber" in nutrient:
nutrient_id = str(nutrient.get("nutrientNumber", ""))
value = nutrient.get("value")
elif "nutrient" in nutrient and isinstance(
nutrient["nutrient"], dict
):
nutrient_id = str(
nutrient.get("nutrient", {}).get("id", "")
)
value = nutrient.get("value")
elif "number" in nutrient:
nutrient_id = str(nutrient.get("number", ""))
value = nutrient.get("value")
if (
nutrient_id
and nutrient_id in nutrient_map
and value is not None
):
key = nutrient_map[nutrient_id]
try:
nutrients[key] = float(value)
except (TypeError, ValueError):
pass
return nutrients
def get_food_summary(self, fdc_id: int) -> Dict:
"""Return a simplified summary of a food item."""
details = self.get_food_details(fdc_id)
return {
"fdcId": details.get("fdcId"),
"description": details.get("description"),
"dataType": details.get("dataType"),
"brandOwner": details.get("brandOwner"),
"servingSize": details.get("servingSize"),
"servingSizeUnit": details.get("servingSizeUnit"),
"nutrients": self.extract_nutrients(details),
}
class NutritionCalculator:
"""Calculate recipe nutrition from ingredients using USDA API."""
# Common units to strip from ingredient names
UNITS = [
"cup",
"cups",
"c",
"tablespoon",
"tablespoons",
"tbsp",
"tb",
"t",
"teaspoon",
"teaspoons",
"tsp",
"ts",
"ounce",
"ounces",
"oz",
"fluid ounce",
"fluid ounces",
"fl oz",
"pound",
"pounds",
"lb",
"lbs",
"gram",
"grams",
"g",
"kilogram",
"kilograms",
"kg",
"milliliter",
"milliliters",
"ml",
"liter",
"liters",
"l",
"piece",
"pieces",
"whole",
"large",
"medium",
"small",
"can",
"cans",
"bottle",
"bottles",
"jar",
"jars",
"package",
"packages",
"pkg",
"box",
"boxes",
"slice",
"slices",
"clove",
"cloves",
"stick",
"sticks",
"pinch",
"dash",
"handful",
"bunch",
"sprig",
"sprigs",
]
# Common modifiers to strip (don't affect nutrition much)
MODIFIERS = [
"all-purpose",
"all purpose",
"ap",
"bread",
"cake",
"self-rising",
"self rising",
"granulated",
"powdered",
"confectioners",
"brown",
"white",
"raw",
"extra virgin",
"virgin",
"light",
"dark",
"heavy",
"whipping",
"fresh",
"dried",
"frozen",
"canned",
"jarred",
"bottled",
"chopped",
"diced",
"minced",
"sliced",
"grated",
"shredded",
"crushed",
"melted",
"softened",
"room temperature",
"cold",
"warm",
"hot",
"packed",
"sifted",
"sieved",
"ground",
"whole",
"halved",
"quartered",
"peeled",
"unpeeled",
"seeded",
"pitted",
"cored",
"trimmed",
"optional",
"to taste",
"as needed",
"for garnish",
"for serving",
]
def __init__(self):
self.usda = USDAFoodAPI()
self._unit_pattern = self._build_unit_pattern()
def _build_unit_pattern(self) -> re.Pattern:
"""Build regex pattern for matching units."""
# Sort by length (longest first) to match "tablespoon" before "tbsp"
sorted_units = sorted(self.UNITS, key=len, reverse=True)
unit_str = "|".join(re.escape(u) for u in sorted_units)
return re.compile(rf"\b({unit_str})\b", re.IGNORECASE)
def parse_ingredient(self, ingredient_text: str) -> Dict:
"""
Parse ingredient string into quantity, unit, and clean ingredient name.
Examples:
"2 cups all-purpose flour"{qty: 2, unit: 'cups', name: 'flour'}
"1 1/2 tsp salt"{qty: 1.5, unit: 'tsp', name: 'salt'}
"1/4 cup olive oil"{qty: 0.25, unit: 'cup', name: 'olive oil'}
"3 large eggs"{qty: 3, unit: 'large', name: 'eggs'}
"""
text = ingredient_text.strip()
# Parse quantity (handles whole numbers, fractions, and mixed numbers)
quantity, remaining = self._parse_quantity(text)
# Parse unit from remaining text
unit, remaining = self._parse_unit(remaining)
# Clean the ingredient name
ingredient_name = self._clean_ingredient_name(remaining)
return {
"quantity": quantity,
"unit": unit,
"ingredient": ingredient_name,
"original": text,
}
def _parse_quantity(self, text: str) -> tuple:
"""
Extract quantity from start of ingredient text.
Handles: "2", "1/2", "1 1/2", "0.5", etc.
"""
text = text.strip()
# Pattern for mixed numbers: "1 1/2", "2 3/4"
mixed_pattern = r"^(\d+)\s+(\d+)/(\d+)"
mixed_match = re.match(mixed_pattern, text)
if mixed_match:
whole = int(mixed_match.group(1))
numerator = int(mixed_match.group(2))
denominator = int(mixed_match.group(3))
quantity = whole + (numerator / denominator)
remaining = text[mixed_match.end() :].strip()
return quantity, remaining
# Pattern for simple fractions: "1/2", "3/4"
fraction_pattern = r"^(\d+)/(\d+)"
fraction_match = re.match(fraction_pattern, text)
if fraction_match:
numerator = int(fraction_match.group(1))
denominator = int(fraction_match.group(2))
quantity = numerator / denominator
remaining = text[fraction_match.end() :].strip()
return quantity, remaining
# Pattern for decimals: "0.5", "1.5"
decimal_pattern = r"^(\d+\.?\d*)"
decimal_match = re.match(decimal_pattern, text)
if decimal_match:
quantity = float(decimal_match.group(1))
remaining = text[decimal_match.end() :].strip()
return quantity, remaining
# No quantity found
return 1, text
def _parse_unit(self, text: str) -> tuple:
"""Extract unit from text."""
text = text.strip()
match = self._unit_pattern.match(text)
if match:
unit = match.group(1).lower()
remaining = text[match.end() :].strip()
return unit, remaining
return None, text
def _clean_ingredient_name(self, text: str) -> str:
"""
Clean ingredient name for better USDA matching.
Removes modifiers, extra whitespace, and normalizes text.
"""
text = text.strip()
# Remove parenthetical notes: "flour (sifted)" → "flour"
text = re.sub(r"\s*\([^)]*\)", "", text)
# Remove common modifiers
for modifier in sorted(self.MODIFIERS, key=len, reverse=True):
# Use word boundaries to avoid partial matches
pattern = rf"\b{re.escape(modifier)}\b"
text = re.sub(pattern, "", text, flags=re.IGNORECASE)
# Remove extra whitespace
text = re.sub(r"\s+", " ", text)
# Remove trailing punctuation
text = text.strip(" ,;:.")
# Remove leading "of" (e.g., "cup of flour" → "flour")
text = re.sub(r"^of\s+", "", text, flags=re.IGNORECASE)
return text.strip()
def _find_usda_match(self, ingredient_name: str) -> Optional[Dict]:
"""
Find best USDA match for an ingredient name.
Tries multiple search strategies for better matching.
"""
strategies = [
# Strategy 1: Direct search
ingredient_name,
# Strategy 2: Singular form (remove trailing 's')
(
ingredient_name.rstrip("s")
if ingredient_name.endswith("s")
else None
),
# Strategy 3: Remove common suffixes
re.sub(
r"\s+(fresh|dried|ground|chopped|sliced)$",
"",
ingredient_name,
flags=re.IGNORECASE,
),
# Strategy 4: Just the last word (sometimes works for simple ingredients)
(
ingredient_name.split()[-1]
if len(ingredient_name.split()) > 1
else None
),
]
for query in strategies:
if not query or len(query) < 2:
continue
try:
results = self.usda.search_foods(query, page_size=5)
if results:
logger.debug(
f"Found {query}: {results[0].get('description')}"
)
return results[0]
except Exception as e:
logger.warning(f"USDA search failed for '{query}': {e}")
continue
return None
def _convert_to_grams(
self, quantity: float, unit: str, ingredient_name: str
) -> float:
"""
Convert quantity to grams for more accurate nutrition calculation.
This is simplified - production code should use a proper conversion table.
"""
# Base conversions (approximate)
volume_to_grams = {
"cup": 240,
"cups": 240,
"tablespoon": 15,
"tablespoons": 15,
"tbsp": 15,
"teaspoon": 5,
"teaspoons": 5,
"tsp": 5,
"ounce": 28,
"ounces": 28,
"oz": 28,
"pound": 454,
"pounds": 454,
"lb": 454,
"lbs": 454,
"gram": 1,
"grams": 1,
"g": 1,
}
# Get base weight in grams
base_grams = volume_to_grams.get(
unit, 100
) # Default to 100g if unknown
# Adjust for ingredient type (very simplified)
if any(
word in ingredient_name.lower()
for word in ["flour", "sugar", "rice", "grain"]
):
base_grams = volume_to_grams.get(unit, 125) # Dry ingredients
elif any(
word in ingredient_name.lower()
for word in ["oil", "butter", "fat"]
):
base_grams = volume_to_grams.get(unit, 220) # Dense liquids
elif any(
word in ingredient_name.lower()
for word in ["milk", "water", "broth"]
):
base_grams = volume_to_grams.get(unit, 240) # Liquids
return quantity * base_grams
def calculate_nutrition(
self, ingredients: List[str], servings: int = 1
) -> Dict:
"""
Calculate total nutrition for a recipe from ingredient list.
Returns nutrition per serving.
"""
totals = {
"calories": 0,
"protein": 0,
"fat": 0,
"carbohydrates": 0,
"fiber": 0,
"sugar": 0,
"sodium": 0,
}
matched_count = 0
unmatched = []
for ingredient in ingredients:
parsed = self.parse_ingredient(ingredient)
# Find USDA match
match = self._find_usda_match(
" ".join(
[
str(parsed["quantity"]),
str(parsed["unit"]),
str(parsed["ingredient"]),
]
)
)
if not match:
match = self._find_usda_match(parsed["ingredient"])
if match:
try:
nutrients = self.usda.extract_nutrients(match)
# Convert to grams for more accurate calculation
grams = self._convert_to_grams(
parsed["quantity"],
parsed["unit"],
parsed["ingredient"],
)
# USDA data is typically per 100g, so scale accordingly
multiplier = grams / 100
totals["calories"] += (
nutrients.get("calories", 0) * multiplier
)
totals["protein"] += (
nutrients.get("protein", 0) * multiplier
)
totals["fat"] += nutrients.get("fat", 0) * multiplier
totals["carbohydrates"] += (
nutrients.get("carbohydrates", 0) * multiplier
)
totals["fiber"] += nutrients.get("fiber", 0) * multiplier
totals["sugar"] += nutrients.get("sugar", 0) * multiplier
totals["sodium"] += nutrients.get("sodium", 0) * multiplier
matched_count += 1
except Exception:
unmatched.append(parsed["original"])
else:
unmatched.append(parsed["original"])
# Divide by servings for per-serving nutrition
if servings > 0:
for key in totals:
totals[key] = round(totals[key] / servings, 2)
# Add metadata
totals["_matched"] = matched_count
totals["_total"] = len(ingredients)
totals["_unmatched"] = unmatched
return totals

View File

@ -1,12 +1,10 @@
from django.views import generic
from scrobbles.views import ScrobbleableDetailView, ScrobbleableListView
from lifeevents.models import LifeEvent
class LifeEventListView(generic.ListView):
class LifeEventListView(ScrobbleableListView):
model = LifeEvent
paginate_by = 20
class LifeEventDetailView(generic.DetailView):
class LifeEventDetailView(ScrobbleableDetailView):
model = LifeEvent
slug_field = "uuid"

View File

@ -3,6 +3,7 @@ from rest_framework import permissions, viewsets
from locations.api import serializers
from locations import models
class GeoLocationViewSet(viewsets.ModelViewSet):
queryset = models.GeoLocation.objects.all().order_by("-created")
serializer_class = serializers.GeoLocationSerializer

View File

@ -3,4 +3,3 @@ from django.apps import AppConfig
class LocationConfig(AppConfig):
name = "locations"

View File

@ -17,10 +17,12 @@ User = get_user_model()
GEOLOC_ACCURACY = int(getattr(settings, "GEOLOC_ACCURACY", 4))
GEOLOC_PROXIMITY = Decimal(getattr(settings, "GEOLOC_PROXIMITY", "0.0001"))
@dataclass
class GeoLocationLogData(BaseLogData, WithPeopleLogData):
pass
class GeoLocation(ScrobblableMixin):
COMPLETION_PERCENT = getattr(settings, "LOCATION_COMPLETION_PERCENT", 100)

View File

@ -9,7 +9,12 @@ class GeoLocationListView(generic.ListView):
paginate_by = 75
def get_queryset(self):
return super().get_queryset().filter(scrobble__user_id=self.request.user.id).order_by("-scrobble__timestamp")
return (
super()
.get_queryset()
.filter(scrobble__user_id=self.request.user.id)
.order_by("-scrobble__timestamp")
)
def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs)

View File

@ -16,9 +16,7 @@ def scrobble_counts(user=None):
now = now_user_timezone(user.profile)
user_filter = Q(user=user)
start_of_today = datetime.combine(
now.date(), datetime.min.time(), now.tzinfo
)
start_of_today = datetime.combine(now.date(), datetime.min.time(), now.tzinfo)
starting_day_of_current_week = now.date() - timedelta(
days=now.today().isoweekday() % 7
)
@ -31,9 +29,7 @@ def scrobble_counts(user=None):
media_type=Scrobble.MediaType.TRACK,
)
data = {}
data["today"] = finished_scrobbles_qs.filter(
timestamp__gte=start_of_today
).count()
data["today"] = finished_scrobbles_qs.filter(timestamp__gte=start_of_today).count()
data["week"] = finished_scrobbles_qs.filter(
timestamp__gte=starting_day_of_current_week
).count()
@ -47,9 +43,7 @@ def scrobble_counts(user=None):
return data
def week_of_scrobbles(
user=None, start=None, media: str = "tracks"
) -> dict[str, int]:
def week_of_scrobbles(user=None, start=None, media: str = "tracks") -> dict[str, int]:
now = timezone.now()
user_filter = Q()
@ -89,6 +83,7 @@ def live_charts(
media_type: str = "Track",
chart_period: str = "all",
limit: int = 15,
app_label: str = "music",
) -> QuerySet:
now = timezone.now()
tzinfo = now.tzinfo
@ -100,13 +95,11 @@ def live_charts(
seven_days_ago = now - timedelta(days=7)
thirty_days_ago = now - timedelta(days=30)
start_of_today = datetime.combine(now, datetime.min.time(), tzinfo)
start_day_of_week = start_of_today - timedelta(
days=now.today().isoweekday() % 7
)
start_day_of_week = start_of_today - timedelta(days=now.today().isoweekday() % 7)
start_day_of_month = now.replace(day=1)
start_day_of_year = now.replace(month=1, day=1)
media_model = apps.get_model(app_label="music", model_name=media_type)
media_model = apps.get_model(app_label=app_label, model_name=media_type)
period_queries = {
"today": {"scrobble__timestamp__gte": start_of_today},
@ -119,17 +112,13 @@ def live_charts(
}
time_filter = Q()
completion_filter = Q(
scrobble__user=user, scrobble__played_to_completion=True
)
completion_filter = Q(scrobble__user=user, scrobble__played_to_completion=True)
user_filter = Q(scrobble__user=user)
count_field = "scrobble"
if media_type == "Artist":
for period, query_dict in period_queries.items():
period_queries[period] = {
"track__" + k: v for k, v in query_dict.items()
}
period_queries[period] = {"track__" + k: v for k, v in query_dict.items()}
completion_filter = Q(
track__scrobble__user=user,
track__scrobble__played_to_completion=True,
@ -154,3 +143,221 @@ def live_charts(
def artist_scrobble_count(artist_id: int, filter: str = "today") -> int:
return Scrobble.objects.filter(track__artist=artist_id).count()
def live_tv_charts(
user: "User",
chart_period: str = "all",
limit: int = 15,
) -> QuerySet:
from django.db.models import OuterRef, Subquery
from videos.models import Video, Series
now = timezone.now()
tzinfo = now.tzinfo
now = now.date()
if user.is_authenticated:
now = now_user_timezone(user.profile)
tzinfo = now.tzinfo
seven_days_ago = now - timedelta(days=7)
thirty_days_ago = now - timedelta(days=30)
start_of_today = datetime.combine(now, datetime.min.time(), tzinfo)
start_day_of_week = start_of_today - timedelta(days=now.today().isoweekday() % 7)
start_day_of_month = now.replace(day=1)
start_day_of_year = now.replace(month=1, day=1)
period_queries = {
"today": Q(timestamp__gte=start_of_today),
"week": Q(timestamp__gte=start_day_of_week),
"last7": Q(timestamp__gte=seven_days_ago),
"last30": Q(timestamp__gte=thirty_days_ago),
"month": Q(timestamp__gte=start_day_of_month),
"year": Q(timestamp__gte=start_day_of_year),
"all": Q(),
}
time_filter = period_queries[chart_period]
completion_filter = Q(
user=user,
played_to_completion=True,
video__video_type=Video.VideoType.TV_EPISODE,
)
scrobble_counts = (
Scrobble.objects.filter(
completion_filter,
time_filter,
video__tv_series=OuterRef("pk"),
)
.values("video__tv_series")
.annotate(c=Count("id"))
.values("c")
)
return (
Series.objects.annotate(num_scrobbles=Subquery(scrobble_counts))
.filter(num_scrobbles__gt=0)
.order_by("-num_scrobbles")[:limit]
)
def live_youtube_channel_charts(
user: "User",
chart_period: str = "all",
limit: int = 15,
) -> QuerySet:
from django.db.models import OuterRef, Subquery
from videos.models import Video, Channel
now = timezone.now()
tzinfo = now.tzinfo
now = now_user_timezone(user.profile)
tzinfo = now.tzinfo
seven_days_ago = now - timedelta(days=7)
thirty_days_ago = now - timedelta(days=30)
start_of_today = datetime.combine(now, datetime.min.time(), tzinfo)
start_day_of_week = start_of_today - timedelta(days=now.today().isoweekday() % 7)
start_day_of_year = now.replace(month=1, day=1)
period_queries = {
"today": Q(timestamp__gte=start_of_today),
"week": Q(timestamp__gte=start_day_of_week),
"last7": Q(timestamp__gte=seven_days_ago),
"last30": Q(timestamp__gte=thirty_days_ago),
"year": Q(timestamp__gte=start_day_of_year),
"all": Q(),
}
time_filter = period_queries[chart_period]
completion_filter = Q(
user=user,
played_to_completion=True,
video__video_type=Video.VideoType.YOUTUBE,
)
scrobble_counts = (
Scrobble.objects.filter(
completion_filter,
time_filter,
video__channel=OuterRef("pk"),
)
.values("video__channel")
.annotate(c=Count("id"))
.values("c")
)
return (
Channel.objects.annotate(num_scrobbles=Subquery(scrobble_counts))
.filter(num_scrobbles__gt=0)
.order_by("-num_scrobbles")[:limit]
)
def batch_live_charts(
user: "User",
media_type: str = "Track",
limit: int = 15,
app_label: str = "music",
) -> dict[str, list]:
from django.db.models import Count, Q
now = timezone.now()
tzinfo = now.tzinfo
now = now.date()
if user.is_authenticated:
now = now_user_timezone(user.profile)
tzinfo = now.tzinfo
seven_days_ago = now - timedelta(days=7)
thirty_days_ago = now - timedelta(days=30)
start_of_today = datetime.combine(now, datetime.min.time(), tzinfo)
start_day_of_year = now.replace(month=1, day=1)
media_model = apps.get_model(app_label=app_label, model_name=media_type)
if media_type == "Artist":
base_filter = Q(
track__scrobble__user=user, track__scrobble__played_to_completion=True
)
count_field = "track__scrobble"
else:
base_filter = Q(scrobble__user=user, scrobble__played_to_completion=True)
count_field = "scrobble"
scrobble_filter_prefix = (
"track__scrobble__" if media_type == "Artist" else "scrobble__"
)
queryset = media_model.objects.filter(
Q(track__scrobble__user=user)
if media_type == "Artist"
else Q(scrobble__user=user)
).annotate(
num_scrobbles_today=Count(
count_field,
filter=Q(
**{
f"{scrobble_filter_prefix}played_to_completion": True,
f"{scrobble_filter_prefix}timestamp__gte": start_of_today,
}
),
distinct=True,
),
num_scrobbles_last7=Count(
count_field,
filter=Q(
**{
f"{scrobble_filter_prefix}played_to_completion": True,
f"{scrobble_filter_prefix}timestamp__gte": seven_days_ago,
}
),
distinct=True,
),
num_scrobbles_last30=Count(
count_field,
filter=Q(
**{
f"{scrobble_filter_prefix}played_to_completion": True,
f"{scrobble_filter_prefix}timestamp__gte": thirty_days_ago,
}
),
distinct=True,
),
num_scrobbles_year=Count(
count_field,
filter=Q(
**{
f"{scrobble_filter_prefix}played_to_completion": True,
f"{scrobble_filter_prefix}timestamp__gte": start_day_of_year,
}
),
distinct=True,
),
num_scrobbles=Count(
count_field,
filter=Q(
**{
f"{scrobble_filter_prefix}played_to_completion": True,
}
),
distinct=True,
),
)
items = list(queryset)
for item in items:
item.count = item.num_scrobbles
def filter_by_count_attr(attr_name):
filtered = [item for item in items if getattr(item, attr_name) > 0]
return sorted(filtered, key=lambda x: -getattr(x, attr_name))[:limit]
return {
"today": filter_by_count_attr("num_scrobbles_today"),
"last7": filter_by_count_attr("num_scrobbles_last7"),
"last30": filter_by_count_attr("num_scrobbles_last30"),
"year": filter_by_count_attr("num_scrobbles_year"),
"all": filter_by_count_attr("num_scrobbles"),
}

View File

@ -90,6 +90,7 @@ JELLYFIN_POST_KEYS = {
"TAGLINE": "TAGLINE",
"PLAYBACK_POSITION_TICKS": "PlaybackPositionTicks",
"PLAYBACK_POSITION": "PlaybackPosition",
"MEDIA_SOURCE_ID": "MediaSourceId",
"ARTIST_MB_ID": "Provider_musicbrainzartist",
"ALBUM_MB_ID": "Provider_musicbrainzalbum",
"RELEASEGROUP_MB_ID": "Provider_musicbrainzreleasegroup",

View File

@ -611,7 +611,7 @@ class Track(ScrobblableMixin):
return f"{self.title} by {self.artist}"
def logdata_cls(self):
return TrackLogData
return TrackLogData()
@property
def primary_album(self):
@ -715,7 +715,9 @@ class Track(ScrobblableMixin):
except Exception:
print("No musicbrainz result found, cannot enrich")
return track
track.base_run_time_seconds = run_time_seconds or int(length / 1000)
track.base_run_time_seconds = run_time_seconds or int(
length / 1000
)
track.musicbrainz_id = mbid
if commit:
track.save()

View File

@ -1,5 +1,5 @@
from django.contrib import admin
from people.models import Person
from people.models import Person, PersonScrobble
@admin.register(Person)
@ -8,3 +8,11 @@ class PersonAdmin(admin.ModelAdmin):
list_display = ("name", "bgg_username", "bgstats_id")
ordering = ("-created",)
search_fields = ("name",)
@admin.register(PersonScrobble)
class PersonScrobbleAdmin(admin.ModelAdmin):
list_display = ("person", "user", "scrobble", "created")
list_filter = ("created",)
ordering = ("-created",)
raw_id_fields = ("person", "user", "scrobble")

View File

@ -0,0 +1,9 @@
from django.apps import AppConfig
class PeopleConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "people"
def ready(self):
import people.signals # noqa

View File

@ -0,0 +1,24 @@
from django.core.management.base import BaseCommand
from people.models import PersonScrobble
from people.signals import get_person_ids_from_log
from scrobbles.models import Scrobble
class Command(BaseCommand):
help = "Backfill PersonScrobble junction table from existing scrobbles"
def handle(self, *args, **options):
count = 0
for scrobble in Scrobble.objects.all():
person_ids = get_person_ids_from_log(scrobble.log)
for person_id in person_ids:
obj, created = PersonScrobble.objects.get_or_create(
person_id=person_id,
user=scrobble.user,
scrobble=scrobble,
)
if created:
count += 1
self.stdout.write(f"Created {count} PersonScrobble records")
self.stdout.write(self.style.SUCCESS("Done!"))

View File

@ -0,0 +1,27 @@
# Generated by Django 4.2.29 on 2026-03-21 15:06
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("people", "0002_remove_person_bgstat_id_person_bgstats_id"),
]
operations = [
migrations.AddField(
model_name="person",
name="created_by",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="created_people",
to=settings.AUTH_USER_MODEL,
),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.29 on 2026-03-21 15:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("people", "0003_person_created_by"),
]
operations = [
migrations.AddField(
model_name="person",
name="scrobble_count",
field=models.IntegerField(default=0),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.29 on 2026-03-21 15:29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("people", "0004_add_scrobble_count"),
]
operations = [
migrations.AddField(
model_name="person",
name="boardgamearena_id",
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View File

@ -0,0 +1,84 @@
# Generated by Django 4.2.29 on 2026-03-21 17:14
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django_extensions.db.fields
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0072_add_scrobble_indexes"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("people", "0005_person_boardgamearena_id"),
]
operations = [
migrations.RemoveField(
model_name="person",
name="scrobble_count",
),
migrations.CreateModel(
name="PersonScrobble",
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"
),
),
(
"person",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="scrobble_associations",
to="people.person",
),
),
(
"scrobble",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="scrobbles.scrobble",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="person_scrobbles",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"indexes": [
models.Index(
fields=["person", "user"],
name="people_pers_person__b7ebc7_idx",
),
models.Index(
fields=["user", "scrobble"],
name="people_pers_user_id_4f5fe7_idx",
),
],
"unique_together": {("person", "scrobble")},
},
),
]

View File

@ -1,5 +1,6 @@
from django.contrib.auth import get_user_model
from django.db import models
from django.shortcuts import get_object_or_404
from django_extensions.db.models import TimeStampedModel
User = get_user_model()
@ -10,11 +11,63 @@ class Person(TimeStampedModel):
"""A non-system user model that can be optionally associated with a User."""
user = models.ForeignKey(User, on_delete=models.DO_NOTHING, **BNULL)
created_by = models.ForeignKey(
User,
on_delete=models.DO_NOTHING,
**BNULL,
related_name="created_people",
)
name = models.CharField(max_length=100, **BNULL)
bgstats_id = models.UUIDField(**BNULL)
boardgamearena_id = models.CharField(max_length=255, **BNULL)
bgg_username = models.CharField(max_length=100, **BNULL)
lichess_username = models.CharField(max_length=100, **BNULL)
bio = models.TextField(**BNULL)
def __str__(self):
return self.name
def get_scrobble_count(self, user=None):
if user is None:
user = self.created_by
return PersonScrobble.objects.filter(person=self, user=user).count()
def get_scrobbles(self, user=None):
if user is None:
user = self.created_by
from scrobbles.models import Scrobble
return Scrobble.objects.filter(
id__in=PersonScrobble.objects.filter(person=self, user=user).values_list(
"scrobble_id", flat=True
)
).order_by("-timestamp")
class PersonScrobble(TimeStampedModel):
"""Tracks the relationship between a Person and a Scrobble."""
person = models.ForeignKey(
Person,
on_delete=models.CASCADE,
related_name="scrobble_associations",
)
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="person_scrobbles",
)
scrobble = models.ForeignKey(
"scrobbles.Scrobble",
on_delete=models.CASCADE,
)
class Meta:
unique_together = ["person", "scrobble"]
indexes = [
models.Index(fields=["person", "user"]),
models.Index(fields=["user", "scrobble"]),
]
def __str__(self):
return f"{self.person.name} - {self.scrobble_id}"

View File

@ -0,0 +1,58 @@
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from scrobbles.models import Scrobble
def get_person_ids_from_log(log):
if not log or not isinstance(log, dict):
return set()
person_ids = set()
with_people_ids = log.get("with_people_ids") or []
if isinstance(with_people_ids, list):
person_ids.update(with_people_ids)
players = log.get("players") or []
if isinstance(players, list):
for player in players:
if isinstance(player, dict) and player.get("person_id"):
person_ids.add(player["person_id"])
return person_ids
@receiver(post_save, sender=Scrobble)
def sync_person_scrobbles_on_save(sender, instance, **kwargs):
from people.models import Person, PersonScrobble
person_ids = get_person_ids_from_log(instance.log)
existing = set(
PersonScrobble.objects.filter(scrobble=instance).values_list(
"person_id", flat=True
)
)
for person_id in person_ids:
person = Person.objects.filter(id=person_id).first()
if person and person.user and person.user == instance.user:
continue
if person_id not in existing:
PersonScrobble.objects.get_or_create(
person_id=person_id,
user=instance.user,
scrobble=instance,
)
PersonScrobble.objects.filter(scrobble=instance).exclude(
person_id__in=person_ids
).delete()
@receiver(post_delete, sender=Scrobble)
def sync_person_scrobbles_on_delete(sender, instance, **kwargs):
from people.models import PersonScrobble
PersonScrobble.objects.filter(scrobble=instance).delete()

View File

View File

@ -0,0 +1,180 @@
import pytest
from django.contrib.auth import get_user_model
from scrobbles.models import Scrobble
from people.models import Person, PersonScrobble
User = get_user_model()
@pytest.mark.django_db
def test_signal_creates_person_scrobble_on_save_with_people_ids():
creator = User.objects.create_user(username="creator", password="testpass123")
other_user = User.objects.create_user(username="other", password="testpass123")
person = Person.objects.create(name="Alice", created_by=creator, user=creator)
scrobble = Scrobble.objects.create(
user=other_user,
log={"with_people_ids": [person.id]},
)
assert PersonScrobble.objects.filter(person=person, user=other_user).count() == 1
@pytest.mark.django_db
def test_signal_creates_person_scrobble_on_save_with_players():
creator = User.objects.create_user(username="creator", password="testpass123")
other_user = User.objects.create_user(username="other", password="testpass123")
person = Person.objects.create(name="Bob", created_by=creator, user=creator)
scrobble = Scrobble.objects.create(
user=other_user,
log={
"players": [
{"person_id": person.id, "win": True, "rank": 1},
]
},
)
assert PersonScrobble.objects.filter(person=person, user=other_user).count() == 1
@pytest.mark.django_db
def test_signal_removes_person_scrobble_when_person_removed_from_with_people_ids():
creator = User.objects.create_user(username="creator", password="testpass123")
other_user = User.objects.create_user(username="other", password="testpass123")
person = Person.objects.create(name="Charlie", created_by=creator, user=creator)
scrobble = Scrobble.objects.create(
user=other_user,
log={"with_people_ids": [person.id]},
)
assert PersonScrobble.objects.filter(person=person).count() == 1
scrobble.log = {}
scrobble.save()
assert PersonScrobble.objects.filter(person=person).count() == 0
@pytest.mark.django_db
def test_signal_removes_person_scrobble_when_person_removed_from_players():
creator = User.objects.create_user(username="creator", password="testpass123")
other_user = User.objects.create_user(username="other", password="testpass123")
person = Person.objects.create(name="Diana", created_by=creator, user=creator)
scrobble = Scrobble.objects.create(
user=other_user,
log={
"players": [
{"person_id": person.id, "win": True, "rank": 1},
]
},
)
assert PersonScrobble.objects.filter(person=person).count() == 1
scrobble.log = {"players": []}
scrobble.save()
assert PersonScrobble.objects.filter(person=person).count() == 0
@pytest.mark.django_db
def test_signal_deletes_person_scrobble_on_scrobble_delete():
creator = User.objects.create_user(username="creator", password="testpass123")
other_user = User.objects.create_user(username="other", password="testpass123")
person = Person.objects.create(name="Eve", created_by=creator, user=creator)
scrobble = Scrobble.objects.create(
user=other_user,
log={"with_people_ids": [person.id]},
)
assert PersonScrobble.objects.filter(person=person).count() == 1
scrobble.delete()
assert PersonScrobble.objects.filter(person=person).count() == 0
@pytest.mark.django_db
def test_signal_skips_when_person_user_matches_scrobble_user():
user = User.objects.create_user(username="testuser", password="testpass123")
person = Person.objects.create(name="Frank", created_by=user, user=user)
scrobble = Scrobble.objects.create(
user=user,
log={"with_people_ids": [person.id]},
)
assert PersonScrobble.objects.filter(person=person).count() == 0
@pytest.mark.django_db
def test_signal_works_with_different_users():
user1 = User.objects.create_user(username="user1", password="testpass123")
user2 = User.objects.create_user(username="user2", password="testpass123")
person = Person.objects.create(name="Grace", created_by=user1, user=user1)
scrobble1 = Scrobble.objects.create(
user=user1,
log={"with_people_ids": [person.id]},
)
scrobble2 = Scrobble.objects.create(
user=user2,
log={"with_people_ids": [person.id]},
)
assert PersonScrobble.objects.filter(person=person, user=user1).count() == 0
assert PersonScrobble.objects.filter(person=person, user=user2).count() == 1
@pytest.mark.django_db
def test_person_get_scrobble_count():
creator = User.objects.create_user(username="creator", password="testpass123")
other_user = User.objects.create_user(username="other", password="testpass123")
person = Person.objects.create(name="Henry", created_by=creator, user=creator)
Scrobble.objects.create(user=other_user, log={"with_people_ids": [person.id]})
Scrobble.objects.create(user=other_user, log={"with_people_ids": [person.id]})
Scrobble.objects.create(user=other_user, log={})
assert person.get_scrobble_count(user=other_user) == 2
@pytest.mark.django_db
def test_person_get_scrobbles():
creator = User.objects.create_user(username="creator", password="testpass123")
other_user = User.objects.create_user(username="other", password="testpass123")
person = Person.objects.create(name="Iris", created_by=creator, user=creator)
scrobble1 = Scrobble.objects.create(
user=other_user, log={"with_people_ids": [person.id]}
)
scrobble2 = Scrobble.objects.create(
user=other_user, log={"with_people_ids": [person.id]}
)
Scrobble.objects.create(user=other_user, log={})
scrobbles = person.get_scrobbles(user=other_user)
assert scrobbles.count() == 2
assert scrobble1 in scrobbles
assert scrobble2 in scrobbles
@pytest.mark.django_db
def test_person_scrobble_unique_together():
creator = User.objects.create_user(username="creator", password="testpass123")
other_user = User.objects.create_user(username="other", password="testpass123")
person = Person.objects.create(name="Jack", created_by=creator, user=creator)
scrobble = Scrobble.objects.create(
user=other_user,
log={
"with_people_ids": [person.id],
"players": [{"person_id": person.id}],
},
)
assert PersonScrobble.objects.filter(person=person, scrobble=scrobble).count() == 1

View File

@ -0,0 +1,196 @@
import pytest
from django.contrib.auth import get_user_model
from django.test.utils import CaptureQueriesContext
from django.db import connection
from django.utils import timezone
from music.aggregators import batch_live_charts, live_charts
from scrobbles.models import Scrobble, Track, Artist
from videos.models import Video
User = get_user_model()
@pytest.mark.django_db
def test_scrobble_queryset_with_related_reduces_queries():
"""Test that Scrobble.objects.with_related() reduces query count."""
user = User.objects.create_user(username="perfuser", password="testpass123")
artist = Artist.objects.create(name="Test Artist")
track = Track.objects.create(
title="Test Track", artist=artist, base_run_time_seconds=180
)
num_scrobbles = 10
for i in range(num_scrobbles):
Scrobble.objects.create(
user=user,
track=track,
media_type=Scrobble.MediaType.TRACK,
played_to_completion=True,
timestamp=timezone.now() - timezone.timedelta(days=i),
)
with CaptureQueriesContext(connection) as context:
scrobbles = list(Scrobble.objects.with_related().filter(user=user))
for s in scrobbles:
_ = s.track.title
_ = s.track.artist.name
print(
f"\nwith_related() queries for {num_scrobbles} scrobbles: {len(context.captured_queries)}"
)
for q in context.captured_queries:
print(f" {q['sql'][:100]}...")
assert len(context.captured_queries) < num_scrobbles
@pytest.mark.django_db
def test_batch_live_charts_vs_individual_calls():
"""Test that batch_live_charts uses fewer queries than individual live_charts calls."""
user = User.objects.create_user(username="batchuser", password="testpass123")
artist = Artist.objects.create(name="Test Artist")
track = Track.objects.create(
title="Test Track", artist=artist, base_run_time_seconds=180
)
for i in range(5):
Scrobble.objects.create(
user=user,
track=track,
media_type=Scrobble.MediaType.TRACK,
played_to_completion=True,
timestamp=timezone.now() - timezone.timedelta(days=i),
)
with CaptureQueriesContext(connection) as batch_context:
batch_result = batch_live_charts(user=user, media_type="Track", limit=15)
with CaptureQueriesContext(connection) as individual_context:
individual_result = {
"today": list(
live_charts(user=user, media_type="Track", chart_period="today")
),
"last7": list(
live_charts(user=user, media_type="Track", chart_period="last7")
),
"last30": list(
live_charts(user=user, media_type="Track", chart_period="last30")
),
"year": list(
live_charts(user=user, media_type="Track", chart_period="year")
),
"all": list(live_charts(user=user, media_type="Track", chart_period="all")),
}
print(f"\nbatch_live_charts queries: {len(batch_context.captured_queries)}")
print(f"individual live_charts queries: {len(individual_context.captured_queries)}")
assert len(batch_context.captured_queries) <= len(
individual_context.captured_queries
)
@pytest.mark.django_db
def test_as_dict_by_type_uses_with_related():
"""Test that as_dict_by_type uses with_related() for reduced queries."""
user = User.objects.create_user(username="dictuser", password="testpass123")
artist = Artist.objects.create(name="Test Artist")
track = Track.objects.create(
title="Test Track", artist=artist, base_run_time_seconds=180
)
for i in range(5):
Scrobble.objects.create(
user=user,
track=track,
media_type=Scrobble.MediaType.TRACK,
played_to_completion=True,
timestamp=timezone.now() - timezone.timedelta(days=i),
)
scrobbles = Scrobble.objects.filter(user=user)
with CaptureQueriesContext(connection) as context:
result = Scrobble.as_dict_by_type(scrobbles)
print(f"\nas_dict_by_type queries: {len(context.captured_queries)}")
for q in context.captured_queries:
print(f" {q['sql'][:100]}...")
assert len(context.captured_queries) < 10
assert "Track" in result
assert len(result["Track"]) == 5
@pytest.mark.django_db
def test_person_scrobble_junction_efficient_count():
"""Test that PersonScrobble junction table enables efficient counts."""
from people.models import Person, PersonScrobble
creator = User.objects.create_user(username="creator2", password="testpass123")
other_user = User.objects.create_user(username="other2", password="testpass123")
person = Person.objects.create(name="Test Person", created_by=creator, user=creator)
artist = Artist.objects.create(name="Test Artist")
track = Track.objects.create(
title="Test Track", artist=artist, base_run_time_seconds=180
)
for i in range(10):
scrobble = Scrobble.objects.create(
user=other_user,
track=track,
media_type=Scrobble.MediaType.TRACK,
played_to_completion=True,
log={"with_people_ids": [person.id]},
)
count = person.get_scrobble_count(user=other_user)
assert count == 10
with CaptureQueriesContext(connection) as context:
assert person.get_scrobble_count(user=other_user) == 10
print(f"\nPersonScrobble count queries: {len(context.captured_queries)}")
for q in context.captured_queries:
print(f" {q['sql'][:100]}...")
assert len(context.captured_queries) <= 3
@pytest.mark.django_db
def test_video_scrobbles_with_related():
"""Test prefetching works for video scrobbles."""
user = User.objects.create_user(username="videouser", password="testpass123")
video = Video.objects.create(
title="Test Video",
video_type=Video.VideoType.MOVIE,
base_run_time_seconds=5400,
)
for i in range(3):
Scrobble.objects.create(
user=user,
video=video,
media_type=Scrobble.MediaType.VIDEO,
played_to_completion=True,
timestamp=timezone.now() - timezone.timedelta(days=i),
)
with CaptureQueriesContext(connection) as context:
scrobbles = list(Scrobble.objects.with_related().filter(user=user))
for s in scrobbles:
_ = s.video.title
_ = s.video.video_type
print(f"\nVideo scrobbles with_related() queries: {len(context.captured_queries)}")
for q in context.captured_queries:
print(f" {q['sql'][:100]}...")
assert len(context.captured_queries) < 5

View File

@ -0,0 +1,16 @@
from django.urls import path
from people import views
app_name = "people"
urlpatterns = [
path(
"people/", views.PersonCreateUpdateView.as_view(), name="person_form"
),
path(
"people/<int:pk>/edit/",
views.PersonUpdateView.as_view(),
name="person_edit",
),
]

View File

@ -0,0 +1,70 @@
from django.views import generic
from django.urls import reverse_lazy
from django.db.models import Count, Q
from people.models import Person, PersonScrobble
class PersonCreateUpdateView(generic.CreateView):
model = Person
fields = [
"name",
"bgstats_id",
"boardgamearena_id",
"bgg_username",
"lichess_username",
"bio",
]
template_name = "people/person_form.html"
success_url = reverse_lazy("people:person_form")
def get_initial(self):
initial = super().get_initial()
initial["created_by"] = self.request.user
return initial
def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs)
context_data["people"] = (
Person.objects.filter(created_by=self.request.user)
.annotate(
scrobble_count=Count(
"scrobble_associations",
filter=Q(scrobble_associations__user=self.request.user),
)
)
.order_by("-scrobble_count", "name")
)
return context_data
def form_valid(self, form):
form.instance.created_by = self.request.user
return super().form_valid(form)
class PersonUpdateView(generic.UpdateView):
model = Person
fields = [
"name",
"bgstats_id",
"boardgamearena_id",
"bgg_username",
"lichess_username",
"bio",
]
template_name = "people/person_form.html"
success_url = reverse_lazy("people:person_form")
def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs)
context_data["people"] = (
Person.objects.filter(created_by=self.request.user)
.annotate(
scrobble_count=Count(
"scrobble_associations",
filter=Q(scrobble_associations__user=self.request.user),
)
)
.order_by("-scrobble_count", "name")
)
return context_data

View File

@ -1,16 +1,19 @@
from podcasts import models
from rest_framework import serializers
class ProducerSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = models.Producer
fields = "__all__"
class PodcastEpisodeSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = models.PodcastEpisode
fields = "__all__"
class PodcastSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = models.Podcast

View File

@ -9,11 +9,13 @@ class ProducerViewSet(viewsets.ModelViewSet):
serializer_class = serializers.ProducerSerializer
permission_classes = [permissions.IsAuthenticated]
class PodcastViewSet(viewsets.ModelViewSet):
queryset = models.Podcast.objects.all().order_by("-created")
serializer_class = serializers.PodcastSerializer
permission_classes = [permissions.IsAuthenticated]
class PodcastEpisodeViewSet(viewsets.ModelViewSet):
queryset = models.PodcastEpisode.objects.all().order_by("-created")
serializer_class = serializers.PodcastEpisodeSerializer

View File

@ -60,6 +60,16 @@ class Podcast(TimeStampedModel):
def get_absolute_url(self):
return reverse("podcasts:podcast_detail", kwargs={"slug": self.uuid})
@property
def safe_cover_image_url(self) -> str:
if self.cover_image:
try:
if self.cover_image.storage.exists(self.cover_image.name):
return self.cover_image.url
except Exception:
pass
return "/static/images/not-found.jpg"
def scrobbles(self, user):
Scrobble = apps.get_model("scrobbles", "Scrobble")
return Scrobble.objects.filter(
@ -156,12 +166,14 @@ class PodcastEpisode(ScrobblableMixin):
producer before saving the epsiode so it can be scrobbled.
"""
log_context={"mopidy_uri": mopidy_uri, "media_type": "Podcast"}
log_context = {"mopidy_uri": mopidy_uri, "media_type": "Podcast"}
producer = None
if podcast_producer:
producer = Producer.find_or_create(podcast_producer)
podcast, created = Podcast.objects.get_or_create(name=podcast_name, defaults={"description": podcast_description})
podcast, created = Podcast.objects.get_or_create(
name=podcast_name, defaults={"description": podcast_description}
)
log_context["podcast_id"] = podcast.id
log_context["podcast_name"] = podcast.name
if created:
@ -178,7 +190,7 @@ class PodcastEpisode(ScrobblableMixin):
"number": episode_num,
"pub_date": pub_date,
"mopidy_uri": mopidy_uri,
}
},
)
if created:
log_context["episode_id"] = episode.id

View File

@ -13,6 +13,7 @@ logger = logging.getLogger(__name__)
# TODO This should be configurable in settings or per deploy
PODCAST_DATE_FORMAT = "YYYY-MM-DD"
def parse_duration(d):
if not d:
return None
@ -24,6 +25,7 @@ def parse_duration(d):
h, m, s = parts
return h * 3600 + m * 60 + s
def fetch_metadata_from_rss(uri: str) -> dict[str, Any]:
log_context = {"mopidy_uri": uri, "media_type": "Podcast"}
podcast_data: dict[str, Any] = {}
@ -38,13 +40,22 @@ def fetch_metadata_from_rss(uri: str) -> dict[str, Any]:
resp = requests.get(rss_url, timeout=10)
feed = feedparser.parse(resp.text)
except IndexError:
logger.warning("Tried to parse uri as RSS feed, but no target found", extra=log_context)
logger.warning(
"Tried to parse uri as RSS feed, but no target found",
extra=log_context,
)
return podcast_data
podcast_publisher = getattr(feed.feed, "itunes_publisher", "")
try:
podcast_owner = feed.feed.itunes_owner.get("name") if isinstance(feed.feed.itunes_owner, dict) else feed.feed.itunes_owner
podcast_other = feed.feed.get("managingeditor") or feed.feed.get("copyright")
podcast_owner = (
feed.feed.itunes_owner.get("name")
if isinstance(feed.feed.itunes_owner, dict)
else feed.feed.itunes_owner
)
podcast_other = feed.feed.get("managingeditor") or feed.feed.get(
"copyright"
)
except AttributeError:
podcast_owner = None
podcast_other = None
@ -53,7 +64,9 @@ def fetch_metadata_from_rss(uri: str) -> dict[str, Any]:
"podcast_name": getattr(feed.feed, "title", ""),
# "podcast_description": getattr(feed.feed, "description", ""),
# "podcast_link": getattr(feed.feed, "link", ""),
"podcast_producer": podcast_publisher or podcast_owner or podcast_other
"podcast_producer": podcast_publisher
or podcast_owner
or podcast_other,
}
for entry in feed.entries:
@ -62,7 +75,9 @@ def fetch_metadata_from_rss(uri: str) -> dict[str, Any]:
podcast_data["title"] = entry.title
podcast_data["episode_num"] = int(entry.get("itunes_episode", 0))
podcast_data["pub_date"] = parse(entry.get("published", None))
podcast_data["base_run_time_seconds"] = parse_duration(entry.get("itunes_duration", None))
podcast_data["base_run_time_seconds"] = parse_duration(
entry.get("itunes_duration", None)
)
# podcast_data["description"] = entry.get("description", None)
# podcast_data["episode_url"] = entry.enclosures[0].href if entry.get("enclosures") else None
return podcast_data
@ -77,7 +92,6 @@ def parse_mopidy_uri(uri: str) -> dict[str, Any]:
if "podcast+https" in uri:
return fetch_metadata_from_rss(uri)
parsed_uri = os.path.splitext(unquote(uri))[0].split("/")
podcast_data = {
@ -87,7 +101,6 @@ def parse_mopidy_uri(uri: str) -> dict[str, Any]:
"pub_date": None,
}
episode_str = parsed_uri[-1]
episode_num = None
episode_num_pad = 0
@ -116,13 +129,18 @@ def parse_mopidy_uri(uri: str) -> dict[str, Any]:
if podcast_data["episode_num"]:
gap_to_strip += episode_num_pad
podcast_data["title"] = episode_str[gap_to_strip:].replace("-", " ").strip()
podcast_data["title"] = (
episode_str[gap_to_strip:].replace("-", " ").strip()
)
return podcast_data
def get_or_create_podcast(post_data: dict) -> PodcastEpisode:
logger.info("Looking up podcast", extra={"post_data": post_data, "media_type": "Podcast"})
logger.info(
"Looking up podcast",
extra={"post_data": post_data, "media_type": "Podcast"},
)
mopidy_uri = post_data.get("mopidy_uri", "")
parsed_data = parse_mopidy_uri(mopidy_uri)

View File

@ -1,11 +1,64 @@
import datetime
from django.utils import timezone
from django.views import generic
from podcasts.models import Podcast
from podcasts.models import Podcast, PodcastEpisode
from scrobbles.models import Scrobble
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
class PodcastListView(generic.ListView):
model = Podcast
paginate_by = 20
class PodcastListView(ScrobbleableListView):
model = PodcastEpisode
template_name = "podcasts/podcast_list.html"
def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs)
user = self.request.user
now = timezone.now()
start_day_of_week = now - datetime.timedelta(days=now.weekday())
start_day_of_month = now.replace(day=1)
scrobbles_this_week = Scrobble.objects.filter(
user=user,
podcast_episode__isnull=False,
timestamp__gte=start_day_of_week,
).select_related("podcast_episode", "podcast_episode__podcast")
scrobbles_this_month = Scrobble.objects.filter(
user=user,
podcast_episode__isnull=False,
timestamp__gte=start_day_of_month,
).select_related("podcast_episode", "podcast_episode__podcast")
podcasts_this_week = {}
for scrobble in scrobbles_this_week:
podcast = scrobble.podcast_episode.podcast
if podcast:
podcasts_this_week[podcast.id] = {
"podcast": podcast,
"count": podcasts_this_week.get(podcast.id, {}).get("count", 0) + 1,
}
podcasts_this_month = {}
for scrobble in scrobbles_this_month:
podcast = scrobble.podcast_episode.podcast
if podcast:
podcasts_this_month[podcast.id] = {
"podcast": podcast,
"count": podcasts_this_month.get(podcast.id, {}).get("count", 0)
+ 1,
}
context_data["podcasts_this_week"] = sorted(
podcasts_this_week.values(), key=lambda x: x["count"], reverse=True
)
context_data["podcasts_this_month"] = sorted(
podcasts_this_month.values(),
key=lambda x: x["count"],
reverse=True,
)
return context_data
class PodcastDetailView(generic.DetailView):

View File

@ -6,14 +6,10 @@ from profiles.models import UserProfile
class UserProfileForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
self.request = kwargs.pop("request")
self.profile = UserProfile.objects.filter(
user=self.request.user
).first()
self.profile = UserProfile.objects.filter(user=self.request.user).first()
if not self.profile:
raise Exception
super(UserProfileForm, self).__init__(
*args, **kwargs, instance=self.profile
)
super(UserProfileForm, self).__init__(*args, instance=self.profile, **kwargs)
class Meta:
model = UserProfile
@ -36,6 +32,8 @@ class UserProfileForm(forms.ModelForm):
"ntfy_url",
"ntfy_enabled",
"redirect_to_webpage",
"enable_public_widgets",
"widget_custom_css",
]
widgets = {
"lastfm_password": forms.PasswordInput(render_value=True),

View File

@ -0,0 +1,23 @@
# Generated by Django 4.2.29 on 2026-03-08 05:52
from django.db import migrations, models
from profiles.constants import PRETTY_TIMEZONE_CHOICES
class Migration(migrations.Migration):
dependencies = [
("profiles", "0027_userprofile_mood_checkin_enabled_and_more"),
]
operations = [
migrations.AlterField(
model_name="userprofile",
name="timezone",
field=models.CharField(
choices=PRETTY_TIMEZONE_CHOICES,
default="UTC",
max_length=255,
),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.29 on 2026-03-20 16:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("profiles", "0028_alter_userprofile_timezone"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="enable_public_widgets",
field=models.BooleanField(default=False),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.29 on 2026-03-20 17:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("profiles", "0029_userprofile_enable_public_widgets"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="widget_custom_css",
field=models.TextField(blank=True, null=True),
),
]

View File

@ -17,9 +17,7 @@ logger = logging.getLogger(__name__)
class UserProfile(TimeStampedModel):
user = models.OneToOneField(
User, on_delete=models.CASCADE, related_name="profile"
)
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
timezone = models.CharField(
max_length=255, choices=PRETTY_TIMEZONE_CHOICES, default="UTC"
)
@ -63,6 +61,9 @@ class UserProfile(TimeStampedModel):
redirect_to_webpage = models.BooleanField(default=True)
enable_public_widgets = models.BooleanField(default=False)
widget_custom_css = models.TextField(**BNULL)
def __str__(self):
return f"User profile for {self.user}"

View File

@ -1,3 +1,4 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy
from django.http.response import HttpResponseBadRequest
from django.views.generic import FormView
@ -5,7 +6,7 @@ from django.views.generic import FormView
from profiles.forms import UserProfileForm
class ProfileFormView(FormView):
class ProfileFormView(LoginRequiredMixin, FormView):
form_class = UserProfileForm
template_name = "profiles/settings_form.html"
success_url = reverse_lazy("profiles:profile_settings")
@ -22,3 +23,8 @@ class ProfileFormView(FormView):
def form_valid(self, form):
form.save()
return super(ProfileFormView, self).form_valid(form)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["user_id"] = self.request.user.id
return context

View File

@ -1,11 +1,13 @@
from puzzles import models
from rest_framework import serializers
class PuzzleManufacturerSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = models.PuzzleManufacturer
fields = "__all__"
class PuzzleSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = models.Puzzle

View File

@ -3,6 +3,7 @@ from rest_framework import permissions, viewsets
from puzzles.api import serializers
from puzzles import models
class PuzzleManufacturerViewSet(viewsets.ModelViewSet):
queryset = models.PuzzleManufacturer.objects.all().order_by("-created")
serializer_class = serializers.PuzzleManufacturerSerializer

View File

@ -11,17 +11,19 @@ from django_extensions.db.models import TimeStampedModel
from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit
from puzzles.sources import ipdb
from scrobbles.dataclasses import JSONDataclass
from scrobbles.dataclasses import (
BaseLogData,
WithPeopleLogData,
LongPlayLogData,
)
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
BNULL = {"blank": True, "null": True}
@dataclass
class PuzzleLogData(JSONDataclass):
with_people: Optional[int] = None
class PuzzleLogData(BaseLogData, WithPeopleLogData, LongPlayLogData):
rating: Optional[str] = None
notes: Optional[str] = None
class PuzzleManufacturer(TimeStampedModel):

View File

@ -1,3 +1,5 @@
import re
from rest_framework import serializers
from scrobbles.models import (
AudioScrobblerTSVImport,
@ -7,10 +9,52 @@ from scrobbles.models import (
)
NOTE_POSITION_RE = re.compile(r"\[start:(\d+)\|end:(\d+)\]$")
class ScrobbleSerializer(serializers.HyperlinkedModelSerializer):
note = serializers.CharField(required=False, allow_blank=True)
note_position = serializers.DictField(required=False)
class Meta:
model = Scrobble
fields = "__all__"
extra_kwargs = {
"url": {"view_name": "scrobble-detail", "lookup_field": "uuid"},
}
def update(self, instance, validated_data):
note = validated_data.pop("note", None)
note_position = validated_data.pop("note_position", None)
instance = super().update(instance, validated_data)
if note is not None:
log = instance.log or {}
notes = log.get("notes", [])
if isinstance(notes, str):
notes = [notes]
elif notes is None:
notes = []
if note_position:
note = f"{note} [start:{note_position['start']}|end:{note_position['end']}]"
notes.append(note)
log["notes"] = notes
instance.log = log
instance.save(update_fields=["log"])
return instance
def parse_note_position(note):
match = NOTE_POSITION_RE.search(note)
if match:
start, end = match.groups()
text = NOTE_POSITION_RE.sub("", note)
return text, {"start": int(start), "end": int(end)}
return note, None
class KoReaderImportSerializer(serializers.HyperlinkedModelSerializer):

View File

@ -1,3 +1,5 @@
from logging import getLogger
from rest_framework import permissions, viewsets
from scrobbles.api.serializers import (
AudioScrobblerTSVImportSerializer,
@ -12,11 +14,14 @@ from scrobbles.models import (
LastFmImport,
)
logger = getLogger(__name__)
class ScrobbleViewSet(viewsets.ModelViewSet):
queryset = Scrobble.objects.all().order_by("-timestamp")
serializer_class = ScrobbleSerializer
permission_classes = [permissions.IsAuthenticated]
lookup_field = "uuid"
def get_queryset(self):
return super().get_queryset().filter(user=self.request.user)

View File

@ -1,5 +1,20 @@
from django.apps import AppConfig
from django.contrib import admin
class ScrobblesConfig(AppConfig):
name = "scrobbles"
def ready(self):
from scrobbles.models import Scrobble
original_index = admin.site.index
def custom_index(request, extra_context=None):
extra_context = extra_context or {}
extra_context["recent_scrobbles"] = Scrobble.objects.filter(
timestamp__isnull=False
).order_by("-timestamp")[:20]
return original_index(request, extra_context)
admin.site.index = custom_index

View File

@ -10,6 +10,13 @@ LONG_PLAY_MEDIA = {
"tasks": "Task",
}
# Media types that should just be finished if they go over time
AUTO_FINISH_MEDIA = {
"webpages": "WebPage",
"tracks": "Track",
"videos": "Video",
}
PLAY_AGAIN_MEDIA = {
"videogames": "VideoGame",
"books": "Book",
@ -55,6 +62,7 @@ MANUAL_SCROBBLE_FNS = {
"-p": "manual_scrobble_puzzle",
"-l": "manual_scrobble_brickset",
"-c": "manual_scrobble_book",
"-f": "manual_scrobble_food",
}

View File

@ -4,6 +4,7 @@ from django.utils import timezone
from scrobbles.constants import EXCLUDE_FROM_NOW_PLAYING
from scrobbles.models import Scrobble
def now_playing(request):
user = request.user
now = timezone.now()

View File

@ -13,7 +13,10 @@ User = get_user_model()
class ScrobbleLogDataEncoder(json.JSONEncoder):
def default(self, o):
return o.__dict__
try:
return o.__dict__
except AttributeError:
return {}
class ScrobbleLogDataDecoder(json.JSONDecoder):
@ -46,6 +49,23 @@ class BaseLogData(JSONDataclass):
def override_fields(cls) -> dict:
return {}
def notes_as_str(self) -> str:
note_str = ""
if not self.notes:
return note_str
if self.notes:
if isinstance(self.notes, str):
note_str = self.notes.encode("utf-8").decode("unicode_escape")
if isinstance(self.notes, list):
note_str = (
"\n".join(self.notes)
.encode("utf-8")
.decode("unicode_escape")
)
return note_str
@dataclass
class LongPlayLogData(JSONDataclass):

View File

@ -21,7 +21,7 @@ class ScrobbleForm(forms.Form):
widget=forms.TextInput(
attrs={
"class": "form-control form-control-dark w-100",
"placeholder": "Scrobble something (ttIMDB, -v Video Game title, -b Book title, -s TheSportsDB ID)",
"placeholder": "Scrobble something (ttIMDB, -v Video Game title, -b Book title, -s TheSportsDB ID, -f Food name - calories)",
"aria-label": "Scrobble something",
}
),
@ -77,11 +77,8 @@ def form_from_dataclass(dataclass):
override_fields = {}
for klass in dataclass.mro():
if hasattr(klass, "override_fields"):
print(klass, ": ", klass.override_fields())
override_fields.update(klass.override_fields())
print("overrides: ", override_fields)
for f in fields(dataclass):
print(f)
if f.name in override_fields:
form_fields[f.name] = override_fields[f.name]
continue

View File

@ -1,7 +1,5 @@
from django.core.management.base import BaseCommand
from vrobbler.apps.scrobbles.utils import (
send_mood_checkin_reminders
)
from vrobbler.apps.scrobbles.utils import send_mood_checkin_reminders
class Command(BaseCommand):

View File

@ -0,0 +1,18 @@
# Generated manually for deduplication fix
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0070_rename_brickset_scrobble_brick_set"),
]
operations = [
migrations.AddField(
model_name="scrobble",
name="source_id",
field=models.CharField(max_length=255, null=True, blank=True),
),
]

View File

@ -0,0 +1,47 @@
# Generated by Django 4.2.29 on 2026-03-08 05:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0071_add_scrobble_source_id"),
]
operations = [
migrations.AlterModelOptions(
name="scrobble",
options={},
),
migrations.AddIndex(
model_name="scrobble",
index=models.Index(
fields=["timestamp"], name="scrobbles_s_timesta_b24a39_idx"
),
),
migrations.AddIndex(
model_name="scrobble",
index=models.Index(
fields=["media_type"], name="scrobbles_s_media_t_76e16b_idx"
),
),
migrations.AddIndex(
model_name="scrobble",
index=models.Index(
fields=["source_id"], name="scrobbles_s_source__35e22e_idx"
),
),
migrations.AddIndex(
model_name="scrobble",
index=models.Index(
fields=[
"user",
"in_progress",
"played_to_completion",
"is_paused",
],
name="scrobbles_s_user_id_14e0bd_idx",
),
),
]

View File

@ -79,6 +79,7 @@ class ScrobblableMixin(TimeStampedModel):
self,
user_id,
source: str = "Vrobbler",
source_id: Optional[str] = None,
playback_position_seconds: int = 0,
status: str = "started",
log: Optional[dict] = None,
@ -88,6 +89,7 @@ class ScrobblableMixin(TimeStampedModel):
"user_id": user_id,
"timestamp": timezone.now(),
"source": source,
"source_id": source_id,
"status": status,
"playback_position_seconds": playback_position_seconds,
}

View File

@ -22,7 +22,7 @@ from django.db import models
from django.urls import reverse
from django.utils import timezone
from django_extensions.db.models import TimeStampedModel
from foods.models import Food
from foods.models import Food, FoodLogData
from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit
from lifeevents.models import LifeEvent
@ -41,7 +41,11 @@ from profiles.utils import (
)
from puzzles.models import Puzzle
from scrobbles import dataclasses as logdata
from scrobbles.constants import LONG_PLAY_MEDIA, MEDIA_END_PADDING_SECONDS
from scrobbles.constants import (
LONG_PLAY_MEDIA,
MEDIA_END_PADDING_SECONDS,
AUTO_FINISH_MEDIA,
)
from scrobbles.importers.lastfm import LastFM
from scrobbles.notifications import ScrobbleNtfyNotification
from scrobbles.stats import build_charts
@ -58,9 +62,7 @@ logger = logging.getLogger(__name__)
User = get_user_model()
BNULL = {"blank": True, "null": True}
POINTS_FOR_MOVEMENT_HISTORY = int(
getattr(settings, "POINTS_FOR_MOVEMENT_HISTORY", 3)
)
POINTS_FOR_MOVEMENT_HISTORY = int(getattr(settings, "POINTS_FOR_MOVEMENT_HISTORY", 3))
class BaseFileImportMixin(TimeStampedModel):
@ -96,7 +98,6 @@ class BaseFileImportMixin(TimeStampedModel):
from scrobbles.models import Scrobble
if not self.process_log:
logger.warning("No lines in process log found to undo")
return
@ -104,9 +105,7 @@ class BaseFileImportMixin(TimeStampedModel):
scrobble_id = line.split("\t")[0]
scrobble = Scrobble.objects.filter(id=scrobble_id).first()
if not scrobble:
logger.warning(
f"Could not find scrobble {scrobble_id} to undo"
)
logger.warning(f"Could not find scrobble {scrobble_id} to undo")
continue
logger.info(f"Removing scrobble {scrobble_id}")
if not dryrun:
@ -149,9 +148,7 @@ class BaseFileImportMixin(TimeStampedModel):
return
for count, scrobble in enumerate(scrobbles):
scrobble_str = (
f"{scrobble.id}\t{scrobble.timestamp}\t{scrobble.media_obj}"
)
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
@ -173,9 +170,7 @@ class KoReaderImport(BaseFileImportMixin):
return "KOReader"
def get_absolute_url(self):
return reverse(
"scrobbles:koreader-import-detail", kwargs={"slug": self.uuid}
)
return reverse("scrobbles:koreader-import-detail", kwargs={"slug": self.uuid})
def get_path(instance, filename):
extension = filename.split(".")[-1]
@ -211,15 +206,11 @@ class KoReaderImport(BaseFileImportMixin):
fix_profile_historic_timezones(self.user.profile)
if self.processed_finished and not force:
logger.info(
f"{self} already processed on {self.processed_finished}"
)
logger.info(f"{self} already processed on {self.processed_finished}")
return
self.mark_started()
scrobbles = process_koreader_sqlite_file(
self.upload_file_path, self.user.id
)
scrobbles = process_koreader_sqlite_file(self.upload_file_path, self.user.id)
self.record_log(scrobbles)
self.mark_finished()
@ -233,9 +224,7 @@ class AudioScrobblerTSVImport(BaseFileImportMixin):
return "AudiosScrobbler"
def get_absolute_url(self):
return reverse(
"scrobbles:tsv-import-detail", kwargs={"slug": self.uuid}
)
return reverse("scrobbles:tsv-import-detail", kwargs={"slug": self.uuid})
def get_path(instance, filename):
extension = filename.split(".")[-1]
@ -259,16 +248,12 @@ class AudioScrobblerTSVImport(BaseFileImportMixin):
fix_profile_historic_timezones(self.user.profile)
if self.processed_finished and not force:
logger.info(
f"{self} already processed on {self.processed_finished}"
)
logger.info(f"{self} already processed on {self.processed_finished}")
return
self.mark_started()
scrobbles = import_audioscrobbler_tsv_file(
self.upload_file_path, self.user.id
)
scrobbles = import_audioscrobbler_tsv_file(self.upload_file_path, self.user.id)
self.record_log(scrobbles)
self.mark_finished()
@ -282,9 +267,7 @@ class LastFmImport(BaseFileImportMixin):
return "LastFM"
def get_absolute_url(self):
return reverse(
"scrobbles:lastfm-import-detail", kwargs={"slug": self.uuid}
)
return reverse("scrobbles:lastfm-import-detail", kwargs={"slug": self.uuid})
def process(self, import_all=False):
"""Import scrobbles found on LastFM"""
@ -293,9 +276,7 @@ class LastFmImport(BaseFileImportMixin):
fix_profile_historic_timezones(self.user.profile)
if self.processed_finished:
logger.info(
f"{self} already processed on {self.processed_finished}"
)
logger.info(f"{self} already processed on {self.processed_finished}")
return
last_import = None
@ -333,9 +314,7 @@ class RetroarchImport(BaseFileImportMixin):
return "Retroarch"
def get_absolute_url(self):
return reverse(
"scrobbles:retroarch-import-detail", kwargs={"slug": self.uuid}
)
return reverse("scrobbles:retroarch-import-detail", kwargs={"slug": self.uuid})
def process(self, import_all=False, force=False):
"""Import scrobbles found on Retroarch"""
@ -343,9 +322,7 @@ class RetroarchImport(BaseFileImportMixin):
fix_profile_historic_timezones(self.user.profile)
if self.processed_finished and not force:
logger.info(
f"{self} already processed on {self.processed_finished}"
)
logger.info(f"{self} already processed on {self.processed_finished}")
return
if force:
@ -501,9 +478,37 @@ class ChartRecord(TimeStampedModel):
return cls.objects.filter(year=year, week=week, user=user)
class ScrobbleQuerySet(models.QuerySet):
def with_related(self):
return self.select_related("user").prefetch_related(
"video",
"track",
"track__artist",
"podcast_episode",
"podcast_episode__podcast",
"sport_event",
"book",
"paper",
"video_game",
"board_game",
"geo_location",
"beer",
"puzzle",
"food",
"trail",
"task",
"web_page",
"life_event",
"mood",
"brick_set",
)
class Scrobble(TimeStampedModel):
"""A scrobble tracks played media items by a user."""
objects = ScrobbleQuerySet.as_manager()
class MediaType(models.TextChoices):
"""Enum mapping a media model type to a string"""
@ -536,39 +541,25 @@ class Scrobble(TimeStampedModel):
podcast_episode = models.ForeignKey(
PodcastEpisode, on_delete=models.DO_NOTHING, **BNULL
)
sport_event = models.ForeignKey(
SportEvent, on_delete=models.DO_NOTHING, **BNULL
)
sport_event = models.ForeignKey(SportEvent, on_delete=models.DO_NOTHING, **BNULL)
book = models.ForeignKey(Book, on_delete=models.DO_NOTHING, **BNULL)
paper = models.ForeignKey(Paper, on_delete=models.DO_NOTHING, **BNULL)
video_game = models.ForeignKey(
VideoGame, on_delete=models.DO_NOTHING, **BNULL
)
board_game = models.ForeignKey(
BoardGame, on_delete=models.DO_NOTHING, **BNULL
)
geo_location = models.ForeignKey(
GeoLocation, on_delete=models.DO_NOTHING, **BNULL
)
video_game = models.ForeignKey(VideoGame, on_delete=models.DO_NOTHING, **BNULL)
board_game = models.ForeignKey(BoardGame, on_delete=models.DO_NOTHING, **BNULL)
geo_location = models.ForeignKey(GeoLocation, on_delete=models.DO_NOTHING, **BNULL)
beer = models.ForeignKey(Beer, on_delete=models.DO_NOTHING, **BNULL)
puzzle = models.ForeignKey(Puzzle, on_delete=models.DO_NOTHING, **BNULL)
food = models.ForeignKey(Food, on_delete=models.DO_NOTHING, **BNULL)
trail = models.ForeignKey(Trail, on_delete=models.DO_NOTHING, **BNULL)
task = models.ForeignKey(Task, on_delete=models.DO_NOTHING, **BNULL)
web_page = models.ForeignKey(WebPage, on_delete=models.DO_NOTHING, **BNULL)
life_event = models.ForeignKey(
LifeEvent, on_delete=models.DO_NOTHING, **BNULL
)
life_event = models.ForeignKey(LifeEvent, on_delete=models.DO_NOTHING, **BNULL)
mood = models.ForeignKey(Mood, on_delete=models.DO_NOTHING, **BNULL)
brick_set = models.ForeignKey(
BrickSet, on_delete=models.DO_NOTHING, **BNULL
)
brick_set = models.ForeignKey(BrickSet, on_delete=models.DO_NOTHING, **BNULL)
media_type = models.CharField(
max_length=14, choices=MediaType.choices, default=MediaType.VIDEO
)
user = models.ForeignKey(
User, blank=True, null=True, on_delete=models.DO_NOTHING
)
user = models.ForeignKey(User, blank=True, null=True, on_delete=models.DO_NOTHING)
# Time keeping
timestamp = models.DateTimeField(**BNULL)
@ -582,6 +573,7 @@ class Scrobble(TimeStampedModel):
# Metadata
source = models.CharField(max_length=255, **BNULL)
source_id = models.CharField(max_length=255, **BNULL)
log = models.JSONField(
**BNULL,
default=dict,
@ -595,9 +587,7 @@ class Scrobble(TimeStampedModel):
upload_to="scrobbles/videogame_save_data/", **BNULL
)
gpx_file = models.FileField(upload_to="scrobbles/gpx_file/", **BNULL)
screenshot = models.ImageField(
upload_to="scrobbles/videogame_screenshot/", **BNULL
)
screenshot = models.ImageField(upload_to="scrobbles/videogame_screenshot/", **BNULL)
screenshot_small = ImageSpecField(
source="screenshot",
processors=[ResizeToFit(100, 100)],
@ -613,6 +603,21 @@ class Scrobble(TimeStampedModel):
long_play_seconds = models.BigIntegerField(**BNULL)
long_play_complete = models.BooleanField(**BNULL)
class Meta:
indexes = [
models.Index(fields=["timestamp"]),
models.Index(fields=["media_type"]),
models.Index(fields=["source_id"]),
models.Index(
fields=[
"user",
"in_progress",
"played_to_completion",
"is_paused",
]
),
]
@classmethod
def for_year(cls, user, year):
return cls.objects.filter(timestamp__year=year, user=user).order_by(
@ -643,8 +648,13 @@ class Scrobble(TimeStampedModel):
@classmethod
def as_dict_by_type(cls, scrobble_qs: models.QuerySet) -> dict:
scrobbles_by_type = defaultdict(list)
scrobbles = (
scrobble_qs.with_related()
if hasattr(scrobble_qs, "with_related")
else scrobble_qs
)
for scrobble in scrobble_qs:
for scrobble in scrobbles:
scrobbles_by_type[scrobble.media_type].append(scrobble)
if not scrobbles_by_type.get(scrobble.media_type + "_count"):
scrobbles_by_type[scrobble.media_type + "_count"] = 0
@ -678,9 +688,7 @@ class Scrobble(TimeStampedModel):
from scrobbles.models import Scrobble
if self.logdata and self.logdata.serial_scrobble_id:
return Scrobble.objects.filter(
id=self.logdata.serial_scrobble_id
).first()
return Scrobble.objects.filter(id=self.logdata.serial_scrobble_id).first()
@property
def finish_url(self) -> str:
@ -702,6 +710,13 @@ class Scrobble(TimeStampedModel):
if self.media_obj:
self.media_type = self.MediaType(self.media_obj.__class__.__name__)
if (self.timestamp and self.stop_timestamp) and (
not self.playback_position_seconds or self.playback_position_seconds <= 0
):
self.playback_position_seconds = (
self.stop_timestamp - self.timestamp
).seconds
return super(Scrobble, self).save(*args, **kwargs)
def get_absolute_url(self):
@ -711,9 +726,9 @@ class Scrobble(TimeStampedModel):
return reverse("scrobbles:detail", kwargs={"uuid": self.uuid})
def push_to_archivebox(self):
pushable_media = hasattr(
self.media_obj, "push_to_archivebox"
) and callable(self.media_obj.push_to_archivebox)
pushable_media = hasattr(self.media_obj, "push_to_archivebox") and callable(
self.media_obj.push_to_archivebox
)
if pushable_media and self.user.profile.archivebox_url:
try:
@ -765,6 +780,12 @@ class Scrobble(TimeStampedModel):
except TypeError as e:
return logdata_cls()
@property
def notes(self):
if self.logdata:
return self.logdata.notes or []
return []
def redirect_url(self, user_id) -> str:
user = User.objects.filter(id=user_id).first()
redirect_url = self.media_obj.get_absolute_url()
@ -777,10 +798,7 @@ class Scrobble(TimeStampedModel):
logger.info(f"Redirecting to {self.media_obj.url}")
redirect_url = self.media_obj.url
if (
self.media_type == self.MediaType.VIDEO
and self.media_obj.youtube_id
):
if self.media_type == self.MediaType.VIDEO and self.media_obj.youtube_id:
redirect_url = self.media_obj.youtube_link
return redirect_url
@ -796,9 +814,7 @@ class Scrobble(TimeStampedModel):
@property
def local_stop_timestamp(self):
if self.stop_timestamp:
return timezone.localtime(
self.stop_timestamp, timezone=self.tzinfo
)
return timezone.localtime(self.stop_timestamp, timezone=self.tzinfo)
@property
def scrobble_media_key(self) -> str:
@ -926,9 +942,7 @@ class Scrobble(TimeStampedModel):
long_play_secs = 0
if self.previous and not self.previous.long_play_complete:
long_play_secs = self.previous.long_play_seconds or 0
percent = int(
((playback_seconds + long_play_secs) / run_time_secs) * 100
)
percent = int(((playback_seconds + long_play_secs) / run_time_secs) * 100)
return percent
@ -973,9 +987,7 @@ class Scrobble(TimeStampedModel):
expected_end = self.timestamp + datetime.timedelta(
seconds=self.media_obj.run_time_seconds
)
expected_end_padded = expected_end + datetime.timedelta(
seconds=padding_seconds
)
expected_end_padded = expected_end + datetime.timedelta(seconds=padding_seconds)
# Take our start time, add our media length and an extra 30 min (1800s) is it still in the future? keep going
is_in_progress = expected_end_padded > pendulum.now()
logger.info(
@ -991,7 +1003,10 @@ class Scrobble(TimeStampedModel):
@property
def can_be_updated(self) -> bool:
if self.media_obj.__class__.__name__ in LONG_PLAY_MEDIA.values() and self.source != "readcomicsonline.ru":
if (
self.media_obj.__class__.__name__ in LONG_PLAY_MEDIA.values()
and self.source != "readcomicsonline.ru"
):
logger.info(
"[scrobbling] cannot be updated, long play media",
extra={
@ -1035,11 +1050,9 @@ class Scrobble(TimeStampedModel):
@classmethod
def by_date(cls, media_type: str = "Track"):
cls.objects.filter(media_type=media_type).values(
"timestamp__date"
).annotate(count=models.Count("id")).values(
"timestamp__date", "count"
).order_by(
cls.objects.filter(media_type=media_type).values("timestamp__date").annotate(
count=models.Count("id")
).values("timestamp__date", "count").order_by(
"-count",
)
@ -1133,6 +1146,28 @@ class Scrobble(TimeStampedModel):
skip_in_progress_check = kwargs.get("skip_in_progress_check", False)
read_log_page = kwargs.get("read_log_page", None)
source_id = scrobble_data.get("source_id")
# If source_id is provided (e.g., MediaSourceId from Jellyfin), check for existing scrobble first
# This prevents duplicates when webhooks arrive faster than scrobble creation
# if source_id:
# existing_by_source_id = cls.objects.filter(
# media_query,
# user_id=user_id,
# source_id=source_id,
# ).first()
# if existing_by_source_id:
# logger.info(
# "[create_or_update] found existing scrobble by source_id, updating",
# extra={
# "scrobble_id": existing_by_source_id.id,
# "source_id": source_id,
# },
# )
# scrobble_data["playback_status"] = scrobble_data.pop(
# "status", None
# )
# return existing_by_source_id.update(scrobble_data)
# Find our last scrobble of this media item (track, video, etc)
scrobble = (
@ -1151,9 +1186,7 @@ class Scrobble(TimeStampedModel):
logger.warning(
f"[create_or_update] geoloc requires create_or_update_location"
)
scrobble = cls.create_or_update_location(
media, scrobble_data, user_id
)
scrobble = cls.create_or_update_location(media, scrobble_data, user_id)
return scrobble
if not skip_in_progress_check or read_log_page:
@ -1166,9 +1199,7 @@ class Scrobble(TimeStampedModel):
"scrobble_data": scrobble_data,
},
)
scrobble_data["playback_status"] = scrobble_data.pop(
"status", None
)
scrobble_data["playback_status"] = scrobble_data.pop("status", None)
# If it's marked as stopped, send it through our update mechanism, which will complete it
if scrobble and (
scrobble.can_be_updated
@ -1186,7 +1217,7 @@ class Scrobble(TimeStampedModel):
page_list.append(
BookPageLogData(
page_number=read_log_page,
start_ts=int(timezone.now().timestamp())
start_ts=int(timezone.now().timestamp()),
)
)
scrobble.log["page_data"] = page_list
@ -1199,7 +1230,14 @@ class Scrobble(TimeStampedModel):
scrobble_data.pop("playback_status")
if read_log_page:
scrobble_data["log"] = BookLogData(page_data=[BookPageLogData(page_number=read_log_page, start_ts=int(timezone.now().timestamp()))])
scrobble_data["log"] = BookLogData(
page_data=[
BookPageLogData(
page_number=read_log_page,
start_ts=int(timezone.now().timestamp()),
)
]
)
logger.info(
f"[scrobbling] creating new scrobble",
@ -1210,6 +1248,11 @@ class Scrobble(TimeStampedModel):
"source": source,
},
)
if mtype == cls.MediaType.FOOD and not scrobble_data.get("log", {}).get(
"calories", None
):
if media.calories:
scrobble_data["log"] = FoodLogData(calories=media.calories)
scrobble = cls.create(scrobble_data)
return scrobble
@ -1294,9 +1337,7 @@ class Scrobble(TimeStampedModel):
if existing_locations := location.in_proximity(named=True):
existing_location = existing_locations.first()
ts = int(pendulum.now().timestamp())
scrobble.log[
ts
] = f"Location {location.id} too close to this scrobble"
scrobble.log[ts] = f"Location {location.id} too close to this scrobble"
scrobble.save(update_fields=["log"])
logger.info(
f"[scrobbling] finished - found existing named location",
@ -1475,9 +1516,7 @@ class Scrobble(TimeStampedModel):
"""Returns true if our media is beyond our completion percent, unless
our type is geolocation in which case we always return false
"""
beyond_completion = (
self.percent_played >= self.media_obj.COMPLETION_PERCENT
)
beyond_completion = self.percent_played >= self.media_obj.COMPLETION_PERCENT
if self.media_type == "GeoLocation":
logger.info(
@ -1492,6 +1531,25 @@ class Scrobble(TimeStampedModel):
return beyond_completion
@property
def is_over_time(self) -> bool:
"""Has the scrobble overrun it's time?"""
elapsed_scrobble_seconds = (timezone.now() - self.timestamp).seconds
if elapsed_scrobble_seconds > self.media_obj.run_time_seconds:
return True
return False
def auto_finish(self) -> bool:
"""Check if media type should auto finish.
Return True if scrobble is finished, False if not.
"""
if self.is_over_time and self.media_type in AUTO_FINISH_MEDIA.values():
self.stop(force_finish=True)
return True
return False
def calculate_reading_stats(self, commit=True):
# --- Sort safely by numeric page_number ---
def safe_page_number(entry):

View File

@ -5,21 +5,23 @@ from django.conf import settings
from django.contrib.sites.models import Site
from django.urls import reverse
class BasicNtfyNotification(ABC):
ntfy_headers: dict = {}
ntfy_url: str = ""
title: str = ""
def __init__(self, profile: "UserProfile"):
self.profile = profile.user
self.profile = profile
protocol = "http" if settings.DEBUG else "https"
domain = Site.objects.get_current().domain
self.url_tmpl = f'{protocol}://{domain}' + '{path}'
self.url_tmpl = f"{protocol}://{domain}" + "{path}"
@abstractmethod
def send(self) -> None:
pass
class ScrobbleNotification(BasicNtfyNotification):
scrobble: "Scrobble"
@ -29,8 +31,7 @@ class ScrobbleNotification(BasicNtfyNotification):
self.media_obj = scrobble.media_obj
protocol = "http" if settings.DEBUG else "https"
domain = Site.objects.get_current().domain
self.url_tmpl = f'{protocol}://{domain}' + '{path}'
self.url_tmpl = f"{protocol}://{domain}" + "{path}"
@abstractmethod
def send(self) -> None:
@ -41,13 +42,21 @@ class ScrobbleNtfyNotification(ScrobbleNotification):
def __init__(self, scrobble, **kwargs):
super().__init__(scrobble)
self.ntfy_str: str = f"{self.scrobble.media_obj}"
self.click_url = self.url_tmpl.format(path=self.media_obj.get_absolute_url())
self.click_url = self.url_tmpl.format(
path=self.media_obj.get_absolute_url()
)
self.title = self.media_obj.strings.verb
self.actions = ""
if kwargs.get("end", False):
self.click_url = self.url_tmpl.format(path=self.scrobble.finish_url)
finish_url = self.url_tmpl.format(path=self.scrobble.finish_url)
self.actions = f"view, Finish, {finish_url}"
self.title = "Finish " + self.media_obj.strings.verb.lower() + "?"
if self.scrobble.log and isinstance(self.scrobble.log, dict) and self.scrobble.log.get("title"):
if (
self.scrobble.log
and isinstance(self.scrobble.log, dict)
and self.scrobble.log.get("title")
):
self.ntfy_str += f" - {self.scrobble.log.get('title')}"
def send(self):
@ -57,17 +66,21 @@ class ScrobbleNtfyNotification(ScrobbleNotification):
and self.user.profile.ntfy_enabled
and self.user.profile.ntfy_url
):
headers = {
"Title": self.title,
"Priority": self.media_obj.strings.priority,
"Tags": self.media_obj.strings.tags,
"Click": self.click_url,
}
if self.actions:
headers["Actions"] = self.actions
requests.post(
self.user.profile.ntfy_url,
data=self.ntfy_str.encode(encoding="utf-8"),
headers={
"Title": self.title,
"Priority": self.media_obj.strings.priority,
"Tags": self.media_obj.strings.tags,
"Click": self.click_url,
},
headers=headers,
)
class MoodNtfyNotification(BasicNtfyNotification):
def __init__(self, profile, **kwargs):
super().__init__(profile)

View File

@ -5,6 +5,7 @@ from typing import Any, Optional
import pendulum
import pytz
import requests
from beers.models import Beer
from boardgames.models import BoardGame, BoardGameDesigner, BoardGameLocation
from books.constants import READCOMICSONLINE_URL
@ -13,10 +14,12 @@ from books.utils import parse_readcomicsonline_uri
from bricksets.models import BrickSet
from dateutil.parser import parse
from django.utils import timezone
from foods.models import Food
from foods.sources.rscraper import RecipeScraperService
from locations.constants import LOCATION_PROVIDERS
from locations.models import GeoLocation
from music.constants import JELLYFIN_POST_KEYS, MOPIDY_POST_KEYS
from music.models import Track
from music.models import Album, Track
from people.models import Person
from podcasts.models import PodcastEpisode
from podcasts.utils import parse_mopidy_uri
@ -61,26 +64,45 @@ def mopidy_scrobble_media(post_data: dict, user_id: int) -> Scrobble:
},
)
album_id = None
if media_type == Scrobble.MediaType.PODCAST_EPISODE:
parsed_data = parse_mopidy_uri(post_data.get("mopidy_uri", ""))
if not parsed_data:
logger.warning("Tried to scrobble podcast but no uri found", extra={"post_data": post_data})
logger.warning(
"Tried to scrobble podcast but no uri found",
extra={"post_data": post_data},
)
return Scrobble()
media_obj = PodcastEpisode.find_or_create(**parsed_data)
else:
# TODO this kind of sucks, there's gotta be a way to get this in one shot
media_obj = Track.find_or_create(
title=post_data.get("name", ""),
artist_name=post_data.get("artist", ""),
album_name=post_data.get("album", ""),
run_time_seconds=post_data.get("run_time", 900000),
)
try:
album_id = (
Album.objects.filter(name=post_data.get("album", ""))
.first()
.id
)
except Exception:
pass
log = {}
try:
log = {"mopidy_source": post_data.get("mopidy_uri", "").split(":")[0]}
mopidy_source = post_data.get("mopidy_uri", "").split(":")[0]
except IndexError:
pass
mopidy_source = "Unknown"
log = {
"mopidy_source": mopidy_source,
"raw_data": post_data,
}
if album_id:
log["album_id"] = album_id
return media_obj.scrobble_for_user(
user_id,
@ -122,6 +144,7 @@ def jellyfin_scrobble_media(
post_data.get(JELLYFIN_POST_KEYS.get("PLAYBACK_POSITION_TICKS"), 1)
/ 10000000
)
album_id = None
if media_type == Scrobble.MediaType.VIDEO:
imdb_id = post_data.get("Provider_imdb", "")
media_obj = Video.find_or_create(imdb_id)
@ -134,6 +157,14 @@ def jellyfin_scrobble_media(
post_data.get("RunTime", 900000)
),
)
try:
album_id = (
Album.objects.filter(name=post_data.get("Album", ""))
.first()
.id
)
except Exception:
pass
# A hack because we don't worry about updating music ... we either finish it or we don't
playback_position_seconds = 0
@ -150,11 +181,17 @@ def jellyfin_scrobble_media(
elif post_data.get("NotificationType") == "PlaybackStop":
playback_status = "stopped"
log = {"raw_data": post_data}
if album_id:
log["album_id"] = album_id
return media_obj.scrobble_for_user(
user_id,
source=post_data.get(JELLYFIN_POST_KEYS.get("SOURCE")),
source_id=post_data.get(JELLYFIN_POST_KEYS.get("MEDIA_SOURCE_ID")),
playback_position_seconds=playback_position_seconds,
status=playback_status,
log=log,
)
@ -166,7 +203,10 @@ def web_scrobbler_scrobble_media(
def manual_scrobble_video(
video_id: str, user_id: int, source: str = "IMDb", action: Optional[str] = None
video_id: str,
user_id: int,
source: str = "IMDb",
action: Optional[str] = None,
):
video = Video.find_or_create(video_id)
@ -199,7 +239,10 @@ def manual_scrobble_video(
def manual_scrobble_event(
thesportsdb_id: str, user_id: int, source: str = "TheSportsDB", action: Optional[str] = None
thesportsdb_id: str,
user_id: int,
source: str = "TheSportsDB",
action: Optional[str] = None,
):
data_dict = lookup_event_from_thesportsdb(thesportsdb_id)
@ -214,7 +257,10 @@ def manual_scrobble_event(
def manual_scrobble_video_game(
hltb_id: str, user_id: int, source: str = "HLTB", action: Optional[str] = None
hltb_id: str,
user_id: int,
source: str = "HLTB",
action: Optional[str] = None,
):
game = VideoGame.objects.filter(hltb_id=hltb_id).first()
if not game:
@ -254,7 +300,10 @@ def manual_scrobble_video_game(
def manual_scrobble_book(
title: str, user_id: int, source: str = "Google Books", action: Optional[str] = None
title: str,
user_id: int,
source: str = "Google Books",
action: Optional[str] = None,
):
log = {}
page = None
@ -279,7 +328,9 @@ def manual_scrobble_book(
if not page:
page = 1
logger.info("[scrobblers] Book page included in scrobble, should update!")
logger.info(
"[scrobblers] Book page included in scrobble, should update!"
)
source = READCOMICSONLINE_URL.replace("https://", "")
@ -305,8 +356,9 @@ def manual_scrobble_book(
},
)
scrobble = Scrobble.create_or_update(book, user_id, scrobble_dict, read_log_page=page)
scrobble = Scrobble.create_or_update(
book, user_id, scrobble_dict, read_log_page=page
)
if action == "stop":
if url:
@ -321,7 +373,10 @@ def manual_scrobble_book(
def manual_scrobble_board_game(
bggeek_id: str, user_id: int, source: str = "BGG", action: Optional[str] = None
bggeek_id: str,
user_id: int,
source: str = "BGG",
action: Optional[str] = None,
) -> Scrobble | None:
boardgame = BoardGame.find_or_create(bggeek_id)
@ -522,13 +577,56 @@ def email_scrobble_board_game(
return scrobbles_created
def scrobble_from_recipe_website(
url: str,
user_id: int,
source: str = "Vrobbler",
action: Optional[str] = None,
) -> Scrobble:
"""Scrobble a recipe from a website URL."""
food, created = Food.find_or_create_from_recipe(url)
scrobble_dict = {
"user_id": user_id,
"timestamp": timezone.now(),
"playback_position_seconds": 0,
"source": source,
}
logger.info(
"[vrobbler-scrobble] recipe scrobble request received",
extra={
"food_id": food.id,
"user_id": user_id,
"scrobble_dict": scrobble_dict,
"media_type": Scrobble.MediaType.FOOD,
"created_bool": created,
},
)
scrobble = Scrobble.create_or_update(food, user_id, scrobble_dict)
if action == "stop":
scrobble.stop(force_finish=True)
return scrobble
def manual_scrobble_from_url(
url: str, user_id: int, source: str = "Vrobbler", action: Optional[str] = None
url: str,
user_id: int,
source: str = "Vrobbler",
action: Optional[str] = None,
) -> Scrobble:
"""We have scrobblable media URLs, and then any other webpages that
we want to scrobble as a media type in and of itself. This checks whether
we know about the content type, and routes it to the appropriate media
scrobbler. Otherwise, return nothing."""
if RecipeScraperService.is_recipe_url(url):
return scrobble_from_recipe_website(
url, user_id, source=source, action=action
)
content_key = ""
domain = extract_domain(url)
@ -721,9 +819,12 @@ def emacs_scrobble_update_task(
notes_updated = False
for note in emacs_notes:
existing_note_ts = [
n.get("timestamp") for n in scrobble.log.get("notes", [])
]
try:
existing_note_ts = [
n.get("timestamp") for n in scrobble.log.get("notes", [])
]
except AttributeError:
existing_note_ts = []
if not scrobble.log.get('notes"'):
scrobble.log["notes"] = []
if note.get("timestamp") not in existing_note_ts:
@ -836,7 +937,12 @@ def emacs_scrobble_task(
return scrobble
def manual_scrobble_task(url: str, user_id: int, source: str = "Vrobbler", action: Optional[str] = None):
def manual_scrobble_task(
url: str,
user_id: int,
source: str = "Vrobbler",
action: Optional[str] = None,
):
source_id = re.findall(r"\d+", url)[0]
description = ""
@ -855,12 +961,12 @@ def manual_scrobble_task(url: str, user_id: int, source: str = "Vrobbler", actio
"log": {"description": description, "source_id": source_id},
}
logger.info(
"[vrobbler-scrobble] webpage scrobble request received",
"[vrobbler-scrobble] task scrobble request received",
extra={
"task_id": task.id,
"user_id": user_id,
"scrobble_dict": scrobble_dict,
"media_type": Scrobble.MediaType.WEBPAGE,
"media_type": Scrobble.MediaType.TASK,
},
)
scrobble = Scrobble.create_or_update(task, user_id, scrobble_dict)
@ -868,8 +974,17 @@ def manual_scrobble_task(url: str, user_id: int, source: str = "Vrobbler", actio
def manual_scrobble_webpage(
url: str, user_id: int, source: str = "Bookmarklet", action: Optional[str] = None
url: str,
user_id: int,
source: str = "Bookmarklet",
action: Optional[str] = None,
):
if RecipeScraperService.is_recipe_url(url):
return scrobble_from_recipe_website(
url, user_id, source=source, action=action
)
webpage = WebPage.find_or_create({"url": url})
scrobble_dict = {
@ -995,7 +1110,10 @@ def web_scrobbler_scrobble_video_or_song(
def manual_scrobble_beer(
untappd_id: str, user_id: int, source: str = "Untappd", action: Optional[str] = None
untappd_id: str,
user_id: int,
source: str = "Untappd",
action: Optional[str] = None,
):
beer = Beer.find_or_create(untappd_id)
@ -1024,7 +1142,10 @@ def manual_scrobble_beer(
def manual_scrobble_puzzle(
ipdb_id: str, user_id: int, source: str = "IPDb", action: Optional[str] = None
ipdb_id: str,
user_id: int,
source: str = "IPDb",
action: Optional[str] = None,
):
puzzle = Puzzle.find_or_create(ipdb_id)
@ -1053,7 +1174,10 @@ def manual_scrobble_puzzle(
def manual_scrobble_brickset(
brickset_id: str, user_id: int, source: str = "BrickSet", action: Optional[str] = None
brickset_id: str,
user_id: int,
source: str = "BrickSet",
action: Optional[str] = None,
):
brickset = BrickSet.find_or_create(brickset_id)
@ -1081,3 +1205,48 @@ def manual_scrobble_brickset(
# TODO Kick out a process to enrich the media here, and in every scrobble event
# TODO Need to check for past scrobbles and auto populate serial scrobble id if possible
return Scrobble.create_or_update(brickset, user_id, scrobble_dict)
def manual_scrobble_food(
item_id: str,
user_id: int,
source: str = "Vrobbler",
action: Optional[str] = None,
):
match = re.match(r"(.+?)\s+-\s+(\d+)", item_id)
if not match:
logger.info(
"[manual_scrobble_food] invalid format, expected 'Food Name - calories'",
extra={"item_id": item_id, "user_id": user_id},
)
return
food_name = match.group(1).strip()
calories = int(match.group(2))
food = Food.objects.filter(title=food_name).first()
if not food:
food = Food.objects.create(
title=food_name,
description=food_name,
calories=calories,
)
scrobble_dict = {
"user_id": user_id,
"timestamp": timezone.now(),
"playback_position_seconds": 0,
"source": source,
}
logger.info(
"[scrobblers] manual food scrobble request received",
extra={
"food_id": food.id,
"user_id": user_id,
"scrobble_dict": scrobble_dict,
"media_type": Scrobble.MediaType.FOOD,
},
)
return Scrobble.create_or_update(food, user_id, scrobble_dict)

View File

@ -1,11 +1,26 @@
from django.urls import path
from scrobbles import views
from tasks.webhooks import todoist_webhook, emacs_webhook
from tasks.webhooks import emacs_webhook, todoist_webhook
app_name = "scrobbles"
urlpatterns = [
path("status/", views.ScrobbleStatusView.as_view(), name="status"),
path(
"widget/top-artists/<str:period>/<int:user_id>/",
views.EmbeddableTopArtistsWidget.as_view(),
name="embeddable-top-artists",
),
path(
"widget/top-board-games/<str:period>/<int:user_id>/",
views.EmbeddableTopBoardGamesWidget.as_view(),
name="embeddable-top-board-games",
),
path(
"widget/top-books/<str:period>/<int:user_id>/",
views.EmbeddableTopBooksWidget.as_view(),
name="embeddable-top-books",
),
path(
"manual/lookup/",
views.ManualScrobbleView.as_view(),

View File

@ -324,16 +324,14 @@ def send_stop_notifications_for_in_progress_scrobbles() -> int:
notifications_sent = 0
for scrobble in scrobbles_in_progress_qs:
elapsed_scrobble_seconds = (
timezone.now() - scrobble.timestamp
).seconds
if elapsed_scrobble_seconds > scrobble.media_obj.run_time_seconds:
finished = scrobble.auto_finish()
if scrobble.is_over_time and not finished:
ScrobbleNtfyNotification(scrobble, end=True).send()
notifications_sent += 1
return notifications_sent
def send_mood_checkin_reminders() -> int:
"""Get all profiles with mood check-ins enabled and checkin!"""
from profiles.models import UserProfile
@ -357,19 +355,32 @@ def extract_domain(url):
)
return domain
def fix_playback_position_seconds(*scrobbles: "Scrobble", commit=True) -> list["Scrobble"]:
def fix_playback_position_seconds(
*scrobbles: "Scrobble", commit=True
) -> list["Scrobble"]:
updated_scrobbles = list()
for scrobble in scrobbles:
if not scrobble.media_obj:
logger.info(f"No media object found for scrobble {scrobble.id}, cannot update elapsed time")
logger.info(
f"No media object found for scrobble {scrobble.id}, cannot update elapsed time"
)
continue
if scrobble.media_type == "Track" and scrobble.media_obj.run_time_seconds:
too_long = scrobble.playback_position_seconds > scrobble.media_obj.run_time_seconds
if (
scrobble.media_type == "Track"
and scrobble.media_obj.run_time_seconds
):
too_long = (
scrobble.playback_position_seconds
> scrobble.media_obj.run_time_seconds
)
zero = scrobble.playback_position_seconds == 0
null = not scrobble.playback_position_seconds
if too_long or zero or null:
scrobble.playback_position_seconds = scrobble.media_obj.run_time_seconds
scrobble.playback_position_seconds = (
scrobble.media_obj.run_time_seconds
)
updated_scrobbles.append(scrobble)
if commit:
scrobble.save(update_fields=["playback_position_seconds"])
@ -382,13 +393,17 @@ def base_scrobble_qs(user_id: int) -> models.QuerySet:
from scrobbles.models import Scrobble
return (
Scrobble.objects
.annotate(day=TruncDate("timestamp"))
.annotate(calories_int=Cast(KeyTextTransform("calories", "log"), models.IntegerField()))
Scrobble.objects.annotate(day=TruncDate("timestamp"))
.annotate(
calories_int=Cast(
KeyTextTransform("calories", "log"), models.IntegerField()
)
)
.filter(calories_int__isnull=False, user_id=user_id)
)
def get_daily_calories_for_user_by_day(user_id: int, date: date| str) -> int:
def get_daily_calories_for_user_by_day(user_id: int, date: date | str) -> int:
"""Return total calories for a user on a specific day."""
if isinstance(date, str):
@ -403,6 +418,7 @@ def get_daily_calories_for_user_by_day(user_id: int, date: date| str) -> int:
return agg.get("total_calories") or 0
def get_daily_calorie_dict_for_user(user_id: int) -> dict[date, int]:
"""Return {day: total_calories} for all days with scrobbles, in one query."""
qs = (
@ -414,18 +430,20 @@ def get_daily_calorie_dict_for_user(user_id: int) -> dict[date, int]:
return {entry["day"]: entry["total_calories"] for entry in qs}
def remove_last_part(url: str) -> str:
url = url.rstrip('/')
if '/' not in url:
url = url.rstrip("/")
if "/" not in url:
return url
return url.rsplit('/', 1)[0]
return url.rsplit("/", 1)[0]
def next_url_if_exists(url: str) -> str:
# Normalize (remove trailing slash)
url = url.rstrip('/')
url = url.rstrip("/")
# Find last number in the URL path
match = re.search(r'(\d+)(?:/?$)', url)
match = re.search(r"(\d+)(?:/?$)", url)
if not match:
logger.info("No numeric segment found in the URL", extra={"url": url})
return ""
@ -434,7 +452,7 @@ def next_url_if_exists(url: str) -> str:
new_number = number + 1
# Replace only the last occurrence of that number
new_url = re.sub(rf'{number}(?:/?$)', f'{new_number}/', url + '/', 1)
new_url = re.sub(rf"{number}(?:/?$)", f"{new_number}/", url + "/", 1)
# Check if the new URL exists
try:

View File

@ -8,11 +8,17 @@ import pytz
from dateutil.relativedelta import relativedelta
from django.apps import apps
from django.contrib import messages
from django.contrib.auth import get_user_model
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
from django.db.models import Count, Max, Q
from django.db.models.query import QuerySet
from django.http import FileResponse, HttpResponseRedirect, JsonResponse
from django.http import (
FileResponse,
Http404,
HttpResponseRedirect,
JsonResponse,
)
from django.shortcuts import redirect
from django.urls import reverse_lazy
from django.utils import timezone
@ -21,8 +27,18 @@ from django.views.generic import DetailView, FormView, TemplateView
from django.views.generic.edit import CreateView
from django.views.generic.list import ListView
from moods.models import Mood
from music.aggregators import live_charts, scrobble_counts, week_of_scrobbles
from music.aggregators import (
artist_scrobble_count,
batch_live_charts,
live_charts,
live_tv_charts,
live_youtube_channel_charts,
scrobble_counts,
week_of_scrobbles,
)
from pendulum.parsing.exceptions import ParserError
from profiles.models import UserProfile
from profiles.utils import now_user_timezone
from rest_framework import status
from rest_framework.decorators import (
api_view,
@ -60,6 +76,8 @@ from scrobbles.utils import (
get_long_plays_in_progress,
)
User = get_user_model()
logger = logging.getLogger(__name__)
@ -155,25 +173,17 @@ class RecentScrobbleList(ListView):
next_date = date + timedelta(weeks=+2)
prev_date = date + timedelta(weeks=-1)
if date.isocalendar()[1] < today.isocalendar()[1]:
data[
"next_link"
] = f"?date={next_date.strftime('%Y-W%W')}"
data["next_link"] = f"?date={next_date.strftime('%Y-W%W')}"
data["prev_link"] = f"?date={prev_date.strftime('%Y-W%W')}"
data[
"title"
] = f"Week {date.strftime('%-W')} of {date.year}"
data["title"] = f"Week {date.strftime('%-W')} of {date.year}"
data = data | Scrobble.as_dict_by_type(
Scrobble.for_week(
user_id, date.year, date.isocalendar()[1]
)
Scrobble.for_week(user_id, date.year, date.isocalendar()[1])
)
elif date_str == "this_month" or date_str.count("-") == 1:
next_date = date + relativedelta(months=1)
prev_date = date + relativedelta(months=-1)
if date.month < today.month:
data[
"next_link"
] = f"?date={next_date.strftime('%Y-%m')}"
data["next_link"] = f"?date={next_date.strftime('%Y-%m')}"
data["title"] = f"{date.strftime('%B %Y')}"
data["prev_link"] = f"?date={prev_date.strftime('%Y-%m')}"
data = data | Scrobble.as_dict_by_type(
@ -182,24 +192,16 @@ class RecentScrobbleList(ListView):
elif date_str == "today" or date_str.count("-") == 2:
next_date = date + timedelta(days=1)
prev_date = date - timedelta(days=1)
data[
"prev_link"
] = f"?date={prev_date.strftime('%Y-%m-%d')}"
data["prev_link"] = f"?date={prev_date.strftime('%Y-%m-%d')}"
if date < today:
data[
"next_link"
] = f"?date={next_date.strftime('%Y-%m-%d')}"
data["next_link"] = f"?date={next_date.strftime('%Y-%m-%d')}"
if date == today:
data["title"] = "Today"
else:
data["title"] = f"{date.strftime('%Y-%m-%d')}"
data[
"today_link"
] = f"?date={today.strftime('%Y-%m-%d')}"
data["today_link"] = f"?date={today.strftime('%Y-%m-%d')}"
data = data | Scrobble.as_dict_by_type(
Scrobble.for_day(
user_id, date.year, date.month, date.day
)
Scrobble.for_day(user_id, date.year, date.month, date.day)
)
elif date_str == "this_year" or date_str.count("-") == 0:
next_date = date + relativedelta(years=+1)
@ -222,9 +224,7 @@ class RecentScrobbleList(ListView):
data = data | Scrobble.as_dict_by_type(
Scrobble.for_day(user_id, date.year, date.month, date.day)
)
data[
"today_link"
] = "" # f"?date={today.strftime('%Y-%m-%d')}"
data["today_link"] = "" # f"?date={today.strftime('%Y-%m-%d')}"
data["active_imports"] = AudioScrobblerTSVImport.objects.filter(
processing_started__isnull=False,
@ -232,7 +232,14 @@ class RecentScrobbleList(ListView):
user=self.request.user,
)
data["counts"] = [] # scrobble_counts(user)
data["daily_calories"] = get_daily_calories_for_user_by_day(self.request.user.id, date)
data["daily_calories"] = get_daily_calories_for_user_by_day(
self.request.user.id, date
)
data["life_events_in_progress"] = Scrobble.objects.filter(
user=self.request.user,
life_event__isnull=False,
in_progress=True,
).order_by("-timestamp")
else:
data["weekly_data"] = week_of_scrobbles()
data["counts"] = scrobble_counts()
@ -251,9 +258,7 @@ class ScrobbleLongPlaysView(TemplateView):
def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs)
context_data["view"] = self.request.GET.get("view", "grid")
context_data["in_progress"] = get_long_plays_in_progress(
self.request.user
)
context_data["in_progress"] = get_long_plays_in_progress(self.request.user)
context_data["completed"] = get_long_plays_completed(self.request.user)
return context_data
@ -330,9 +335,7 @@ class ManualScrobbleView(FormView):
scrobble_fn = MANUAL_SCROBBLE_FNS[key]
scrobble = eval(scrobble_fn)(item_id, self.request.user.id)
return HttpResponseRedirect(
scrobble.redirect_url(self.request.user.id)
)
return HttpResponseRedirect(scrobble.redirect_url(self.request.user.id))
class JsonableResponseMixin:
@ -378,9 +381,7 @@ class AudioScrobblerImportCreateView(
return HttpResponseRedirect(self.request.META.get("HTTP_REFERER"))
class KoReaderImportCreateView(
LoginRequiredMixin, JsonableResponseMixin, CreateView
):
class KoReaderImportCreateView(LoginRequiredMixin, JsonableResponseMixin, CreateView):
model = KoReaderImport
fields = ["sqlite_file"]
template_name = "scrobbles/upload_form.html"
@ -439,9 +440,7 @@ def web_scrobbler_webhook(request):
playing_state = "started"
if request.POST.get("isPlaying") == "false":
playing_state = "stopped"
scrobble = web_scrobbler_scrobble_media(
youtube_id, user_id, status=playing_state
)
scrobble = web_scrobbler_scrobble_media(youtube_id, user_id, status=playing_state)
if not scrobble:
logger.warning(
"[web_scrobbler_webhook] failed",
@ -459,9 +458,7 @@ def web_scrobbler_webhook(request):
"[web_scrobbler_webhook] finished",
extra={"scrobble_id": scrobble.id},
)
return JsonResponse(
{"scrobble_id": scrobble.id}, status=status.HTTP_200_OK
)
return JsonResponse({"scrobble_id": scrobble.id}, status=status.HTTP_200_OK)
@csrf_exempt
@ -566,18 +563,12 @@ def import_audioscrobbler_file(request):
scrobbles_created = []
# tsv_file = request.FILES[0]
file_serializer = serializers.AudioScrobblerTSVImportSerializer(
data=request.data
)
file_serializer = serializers.AudioScrobblerTSVImportSerializer(data=request.data)
if file_serializer.is_valid():
import_file = file_serializer.save()
return Response(
{"scrobble_ids": scrobbles_created}, status=status.HTTP_200_OK
)
return Response({"scrobble_ids": scrobbles_created}, status=status.HTTP_200_OK)
else:
return Response(
file_serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
return Response(file_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@permission_classes([IsAuthenticated])
@ -625,7 +616,11 @@ def scrobble_start(request, uuid):
if (
user.profile.redirect_to_webpage
and (media_obj.__class__.__name__ == Scrobble.MediaType.WEBPAGE or media_obj.__class__.__name__ == Scrobble.MediaType.BOOK)
and (
media_obj.__class__.__name__ == Scrobble.MediaType.WEBPAGE
or media_obj.__class__.__name__ == Scrobble.MediaType.BOOK
)
and hasattr(media_obj, "url")
):
logger.info(f"Redirecting to {media_obj} detail page")
return HttpResponseRedirect(media_obj.url)
@ -754,9 +749,7 @@ class ChartRecordView(TemplateView):
).order_by("rank")
if charts.count() == 0:
ChartRecord.build(
user=self.request.user, model_str=media_type, **kwargs
)
ChartRecord.build(user=self.request.user, model_str=media_type, **kwargs)
charts = ChartRecord.objects.filter(
media_filter, user=self.request.user, **kwargs
).order_by("rank")
@ -788,9 +781,7 @@ class ChartRecordView(TemplateView):
media_type = self.request.GET.get("media", "Track")
user = self.request.user
params = {}
context_data["chart_type"] = self.request.GET.get(
"chart_type", "maloja"
)
context_data["chart_type"] = self.request.GET.get("chart_type", "maloja")
context_data["artist_charts"] = {}
if not date:
@ -804,22 +795,97 @@ class ChartRecordView(TemplateView):
"year": "This year",
"all": "All time",
}
context_data["current_artist_charts"] = {
"today": list(live_charts(**artist, chart_period="today")),
"last7": list(live_charts(**artist, chart_period="last7")),
"last30": list(live_charts(**artist, chart_period="last30")),
"year": list(live_charts(**artist, chart_period="year")),
"all": list(live_charts(**artist)),
}
context_data["current_artist_charts"] = batch_live_charts(
user=user, media_type="Artist", limit=limit
)
track = {"user": user, "media_type": "Track", "limit": limit}
context_data["current_track_charts"] = {
"today": list(live_charts(**track, chart_period="today")),
"last7": list(live_charts(**track, chart_period="last7")),
"last30": list(live_charts(**track, chart_period="last30")),
"year": list(live_charts(**track, chart_period="year")),
"all": list(live_charts(**track)),
context_data["current_track_charts"] = batch_live_charts(
user=user, media_type="Track", limit=limit
)
now = timezone.now()
tzinfo = now.tzinfo
if user.is_authenticated:
now = now_user_timezone(user.profile)
tzinfo = now.tzinfo
now = now.date()
start_of_today = datetime.combine(now, datetime.min.time(), tzinfo)
start_day_of_week = start_of_today - timedelta(days=now.isoweekday() % 7)
start_day_of_month = now.replace(day=1)
# TV Series Scrobbles - fetch all and filter in Python
series_all = list(
Scrobble.objects.with_related()
.filter(
user=user,
video__video_type=Video.VideoType.TV_EPISODE,
played_to_completion=True,
timestamp__gte=start_day_of_month,
)
.order_by("-timestamp")
)
context_data["series_this_week"] = [
s for s in series_all if s.timestamp >= start_day_of_week
]
context_data["series_this_month"] = series_all
# Movie Scrobbles - fetch all and filter in Python
movies_all = list(
Scrobble.objects.with_related()
.filter(
user=user,
video__video_type=Video.VideoType.MOVIE,
played_to_completion=True,
timestamp__gte=start_day_of_month,
)
.order_by("-timestamp")
)
context_data["videos_this_week"] = [
v for v in movies_all if v.timestamp >= start_day_of_week
]
context_data["videos_this_month"] = movies_all
# YouTube Scrobbles - fetch all and filter in Python
youtube_all = list(
Scrobble.objects.with_related()
.filter(
user=user,
video__video_type=Video.VideoType.YOUTUBE,
played_to_completion=True,
timestamp__gte=start_day_of_month,
)
.order_by("-timestamp")
)
context_data["youtube_this_week"] = [
y for y in youtube_all if y.timestamp >= start_day_of_week
]
context_data["youtube_this_month"] = youtube_all
context_data["tv_show_charts"] = {
"today": list(live_tv_charts(user=user, chart_period="today")),
"last7": list(live_tv_charts(user=user, chart_period="last7")),
"last30": list(live_tv_charts(user=user, chart_period="last30")),
"year": list(live_tv_charts(user=user, chart_period="year")),
"all": list(live_tv_charts(user=user, chart_period="all")),
}
context_data["youtube_channel_charts"] = {
"today": list(
live_youtube_channel_charts(user=user, chart_period="today")
),
"last7": list(
live_youtube_channel_charts(user=user, chart_period="last7")
),
"last30": list(
live_youtube_channel_charts(user=user, chart_period="last30")
),
"year": list(
live_youtube_channel_charts(user=user, chart_period="year")
),
"all": list(live_youtube_channel_charts(user=user, chart_period="all")),
}
return context_data
# Date provided, lookup past charts, returning nothing if it's now or in the future.
@ -855,9 +921,7 @@ class ChartRecordView(TemplateView):
params["day"] = day
month_str = calendar.month_name[month]
name = f"Chart for {month_str} {day}, {year}"
in_progress = (
now.month == month and now.year == year and now.day == day
)
in_progress = now.month == month and now.year == year and now.day == day
media_filter = self.get_media_filter("Track")
track_charts = ChartRecord.objects.filter(
@ -869,17 +933,13 @@ class ChartRecordView(TemplateView):
).order_by("rank")
if track_charts.count() == 0 and not in_progress:
ChartRecord.build(
user=self.request.user, model_str="Track", **params
)
ChartRecord.build(user=self.request.user, model_str="Track", **params)
media_filter = self.get_media_filter("Track")
track_charts = ChartRecord.objects.filter(
media_filter, user=self.request.user, **params
).order_by("rank")
if artist_charts.count() == 0 and not in_progress:
ChartRecord.build(
user=self.request.user, model_str="Artist", **params
)
ChartRecord.build(user=self.request.user, model_str="Artist", **params)
media_filter = self.get_media_filter("Artist")
artist_charts = ChartRecord.objects.filter(
media_filter, user=self.request.user, **params
@ -900,38 +960,24 @@ class ScrobbleStatusView(LoginRequiredMixin, TemplateView):
def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs)
user_scrobble_qs = Scrobble.objects.filter().order_by("-timestamp")
progress_plays = user_scrobble_qs.filter(
in_progress=True, is_paused=False
)
progress_plays = user_scrobble_qs.filter(in_progress=True, is_paused=False)
data["listening"] = progress_plays.filter(
Q(track__isnull=False) | Q(podcast_episode__isnull=False)
).first()
data["watching"] = progress_plays.filter(video__isnull=False).first()
data["going"] = progress_plays.filter(
geo_location__isnull=False
).first()
data["playing"] = progress_plays.filter(
board_game__isnull=False
).first()
data["sporting"] = progress_plays.filter(
sport_event__isnull=False
).first()
data["browsing"] = progress_plays.filter(
web_page__isnull=False
).first()
data["participating"] = progress_plays.filter(
life_event__isnull=False
).first()
data["going"] = progress_plays.filter(geo_location__isnull=False).first()
data["playing"] = progress_plays.filter(board_game__isnull=False).first()
data["sporting"] = progress_plays.filter(sport_event__isnull=False).first()
data["browsing"] = progress_plays.filter(web_page__isnull=False).first()
data["participating"] = progress_plays.filter(life_event__isnull=False).first()
data["working"] = progress_plays.filter(task__isnull=False).first()
long_plays = user_scrobble_qs.filter(
long_play_complete=False, played_to_completion=True
)
data["reading"] = long_plays.filter(book__isnull=False).first()
data["sessioning"] = long_plays.filter(
video_game__isnull=False
).first()
data["sessioning"] = long_plays.filter(video_game__isnull=False).first()
return data
@ -948,16 +994,7 @@ class ScrobbleDetailView(DetailView):
FormClass = self.get_form_class()
log = self.object.log or {}
initial_notes = log.get("notes", [])
if isinstance(initial_notes, list) and isinstance(initial_notes[0], dict):
notes_str = note_list_to_str(notes)
else:
notes_str = "\n".join(initial_notes)
notes_str_fixed = notes_str.encode("utf-8").decode(
"unicode_escape"
)
log["notes"] = notes_str_fixed
log["notes"] = self.object.logdata.notes_as_str()
return FormClass(initial=log)
@ -975,9 +1012,7 @@ class ScrobbleDetailView(DetailView):
data[field_name] = original_value
if data.get("with_people_ids", False):
data["with_people_ids"] = [
p.id for p in data["with_people_ids"]
]
data["with_people_ids"] = [p.id for p in data["with_people_ids"]]
if data.get("platform_id", False):
data["platform_id"] = data["platform_id"].id
@ -994,3 +1029,158 @@ class ScrobbleDetailView(DetailView):
if "log_form" not in context:
context["log_form"] = self.get_form()
return context
class BaseEmbeddableWidget(TemplateView):
template_name = "scrobbles/embeddable_top_media.html"
media_type = "Media"
count_label = "plays"
no_data_message = "No scrobbles"
model = None
scrobble_filter = {}
def get_user_and_profile(self):
user_id = self.kwargs.get("user_id")
user = User.objects.get(id=user_id)
profile = user.profile
if not profile.enable_public_widgets:
raise Http404("Public widgets are not enabled for this user")
return user, profile
def get_date_range(self, user):
now = timezone.now()
if user.is_authenticated:
now = now_user_timezone(user.profile)
now = now.date()
period = self.kwargs.get("period", "week")
if period == "month":
start = now.replace(day=1)
label = now.strftime("%B %Y")
elif period == "year":
start = now.replace(month=1, day=1)
label = now.strftime("%Y")
else:
start = now - timedelta(days=now.isoweekday() % 7)
label = "This Week"
return start, label
def get_items(self, user, start_date, model=None):
from django.db.models import Count
model = model or self.model
queryset = (
model.objects.filter(
scrobble__user=user,
scrobble__timestamp__gte=start_date,
**self.scrobble_filter,
)
.annotate(count=Count("scrobble", distinct=True))
.order_by("-count")[:10]
)
items = list(queryset)
for item in items:
if not hasattr(item, "name") and hasattr(item, "title"):
item.name = item.title
return items
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
user, profile = self.get_user_and_profile()
context["user"] = user
context["custom_css"] = profile.widget_custom_css
context["media_type"] = self.media_type
context["count_label"] = self.count_label
start_date, period_label = self.get_date_range(user)
context["period_label"] = period_label
items = self.get_items(user, start_date)
context["items"] = items
context["no_data_message"] = self.no_data_message
return context
class EmbeddableTopArtistsWidget(TemplateView):
template_name = "scrobbles/embeddable_top_media.html"
media_type = "Artists"
count_label = "scrobbles"
no_data_message = "No scrobbles"
def get_user_and_profile(self):
user_id = self.kwargs.get("user_id")
user = User.objects.get(id=user_id)
profile = user.profile
if not profile.enable_public_widgets:
raise Http404("Public widgets are not enabled for this user")
return user, profile
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
user, profile = self.get_user_and_profile()
context["user"] = user
context["custom_css"] = profile.widget_custom_css
context["media_type"] = self.media_type
context["count_label"] = self.count_label
context["no_data_message"] = self.no_data_message
period = self.kwargs.get("period", "week")
period_map = {"week": "last7", "month": "last30", "year": "year"}
chart_period = period_map.get(period, "last7")
artist = {"user": user, "media_type": "Artist", "limit": 10}
items = list(live_charts(**artist, chart_period=chart_period))
for item in items:
item.count = item.num_scrobbles
context["items"] = items
from datetime import timedelta
from django.utils import timezone
now = timezone.now()
if user.is_authenticated:
now = now_user_timezone(user.profile)
now = now.date()
if period == "month":
context["period_label"] = now.strftime("%B %Y")
elif period == "year":
context["period_label"] = now.strftime("%Y")
else:
start = now - timedelta(days=now.isoweekday() % 7)
context["period_label"] = "This Week"
return context
class EmbeddableTopBoardGamesWidget(BaseEmbeddableWidget):
media_type = "Board Games"
count_label = "plays"
no_data_message = "No board games played"
scrobble_filter = {"scrobble__played_to_completion": True}
def get_items(self, user, start_date):
from boardgames.models import BoardGame
return super().get_items(user, start_date, BoardGame)
class EmbeddableTopBooksWidget(BaseEmbeddableWidget):
media_type = "Books"
count_label = "reads"
no_data_message = "No books read"
scrobble_filter = {"scrobble__long_play_complete": True}
def get_items(self, user, start_date):
from books.models import Book
return super().get_items(user, start_date, Book)

Some files were not shown because too many files have changed in this diff Show More