Compare commits

...

76 Commits
39.1 ... 50.2

Author SHA1 Message Date
0a880a2f2f [release] Bump to version 50.2
All checks were successful
build / test (push) Successful in 2m6s
deploy / test (push) Successful in 2m7s
deploy / build-and-deploy (push) Successful in 35s
- Koreader imports only import single-page scrobbles the next day
- Fix bugs in celery tasks causing imports to fail
2026-06-11 09:58:56 -04:00
248d3f2d3e [settings] Check webdav every two minuts
All checks were successful
build / test (push) Successful in 2m13s
2026-06-11 09:41:50 -04:00
e243fec679 [books] Try fixing the one-off import issue 2026-06-11 09:41:19 -04:00
de9b4ee9c1 [release] Bump to version 50.1
All checks were successful
build / test (push) Successful in 2m2s
deploy / test (push) Successful in 1m57s
deploy / build-and-deploy (push) Successful in 32s
- Fix bug in charts where only #1 is displayed
2026-06-09 22:08:48 -04:00
bf9a6a9679 [charts] Fix only seeing first top media instance 2026-06-09 22:08:15 -04:00
709fed5cfe [release] Bump to version 50.0
All checks were successful
build / test (push) Successful in 2m7s
deploy / test (push) Successful in 2m6s
deploy / build-and-deploy (push) Successful in 31s
- Allow updating all a user's scrobble visibility at once
- Replace columsn of Top Artists, Tracks and Series with Maloja widget
2026-06-09 17:19:10 -04:00
b7df6299d0 [sharing] Add bulk scrobble share management 2026-06-09 17:18:51 -04:00
be16d513ef [charts] Add better chart views per Maloja
All checks were successful
build / test (push) Successful in 2m3s
2026-06-09 17:04:59 -04:00
15d27f6d94 [release] Bump to version 49.1
All checks were successful
build / test (push) Successful in 2m7s
deploy / test (push) Successful in 2m3s
deploy / build-and-deploy (push) Successful in 33s
- Fix bug with missing default visbility for scrobbles
2026-06-09 13:16:53 -04:00
c8292d1c06 [scrobbles] Back fill visibility field
Some checks failed
build / test (push) Has been cancelled
2026-06-09 13:15:57 -04:00
68f821fce1 [release] Bump to version 49.0
Some checks failed
build / test (push) Successful in 1m59s
deploy / test (push) Successful in 2m9s
deploy / build-and-deploy (push) Failing after 2m20s
- Fix broken tests with new sharing and add tests
2026-06-09 12:48:22 -04:00
ed2ed59f65 [scrobbles] Fix tests around visbility 2026-06-09 12:48:01 -04:00
17a7bb52fa [release] Bump to version 48.3
Some checks failed
build / test (push) Failing after 1m39s
deploy / test (push) Failing after 1m39s
deploy / build-and-deploy (push) Has been skipped
- Fix bug in missing sqids dep
2026-06-09 12:37:40 -04:00
bbac142b40 [deps] Add sqids
Some checks failed
build / test (push) Has been cancelled
2026-06-09 12:37:19 -04:00
5f55ec557f [release] Bump to version 48.2
Some checks failed
build / test (push) Failing after 1m21s
deploy / test (push) Failing after 1m18s
deploy / build-and-deploy (push) Has been skipped
- Lock down scrobbles and use sqids to share them
2026-06-09 12:33:50 -04:00
7f3076608f [scrobbles] Add sharing of scrobbles
Some checks failed
build / test (push) Has been cancelled
2026-06-09 12:33:25 -04:00
568772a0e6 [release] Bump to version 48.1
All checks were successful
deploy / test (push) Successful in 2m0s
build / test (push) Successful in 1m57s
deploy / build-and-deploy (push) Successful in 35s
- Generate a report of tracks with mistmatched metadata
- Date parsing failing in eBird imports
2026-06-08 11:18:08 -04:00
91c3376256 [music] Add mgmt command to see mismatched metadata
Some checks failed
build / test (push) Has been cancelled
2026-06-08 11:17:36 -04:00
58639c6fc1 [importers] Add error_log to surface import errors
All checks were successful
build / test (push) Successful in 2m2s
2026-06-08 10:58:02 -04:00
228441ddc5 [importers] Fix parsing of dates in ebird files 2026-06-08 10:52:50 -04:00
6341075f07 [agent] Add some notes about how we track tasks 2026-06-08 10:52:32 -04:00
a135b9f5f2 [project] Update format for PROJECT file
All checks were successful
build / test (push) Successful in 1m58s
2026-06-07 11:10:30 -04:00
9088412d1e [release] Bump to version 48.0
All checks were successful
build / test (push) Successful in 2m1s
deploy / test (push) Successful in 2m2s
deploy / build-and-deploy (push) Successful in 33s
- Show team or player images on sport detail and scrobble detail
- Add fix_metadta method to Video instances
2026-06-07 11:06:58 -04:00
c7339fbe31 [templates] Clean up how str and subtitles work 2026-06-07 11:06:30 -04:00
4ce3dc03c5 [videos] Add fix_metadata for videos 2026-06-07 10:13:55 -04:00
5a4ef678a8 [release] Bump to version 47.2
All checks were successful
build / test (push) Successful in 1m58s
deploy / test (push) Successful in 1m58s
deploy / build-and-deploy (push) Successful in 53s
- Add OMDB source as backup when TMDB returns nothing
2026-06-07 09:59:05 -04:00
5ca22efeaa [videos] Fix full metadata from OMDB
We were missing the series and episode number info.
2026-06-07 09:58:28 -04:00
912ea8bfac [videos] Add OMDB enrichment when TMDb fails 2026-06-07 09:47:52 -04:00
b541e1084d [release] Bump to version 47.1
All checks were successful
build / test (push) Successful in 2m0s
deploy / test (push) Successful in 2m1s
deploy / build-and-deploy (push) Successful in 32s
- Untangle the sports migrations errors
2026-06-06 23:47:25 -04:00
c9b9da4abc [sports] Fix migrations 2026-06-06 23:47:10 -04:00
8236f43026 [release] Bump to version 47.0
Some checks failed
build / test (push) Successful in 2m4s
deploy / test (push) Successful in 2m7s
deploy / build-and-deploy (push) Failing after 46s
- Change sports scrobbling a bit
2026-06-06 23:35:30 -04:00
ea1b43d1b8 [sports] Big sports structure revamp
Some checks failed
build / test (push) Has been cancelled
This should make scrobbling sports more like tasks.

The root scrobbled items are a little more generic, but
it's easier to see viewing patterns.
2026-06-06 23:32:21 -04:00
4bf22c96e9 [release] Bump to version 46.0
All checks were successful
build / test (push) Successful in 1m57s
deploy / test (push) Successful in 2m9s
deploy / build-and-deploy (push) Successful in 43s
- Add sentiment parsing for Scrobbles with notes
2026-06-05 19:42:21 -04:00
dec7a79509 [scrobbles] Add basic sentiment analysis
All checks were successful
build / test (push) Successful in 2m4s
2026-06-05 19:35:45 -04:00
371e1d654c [tooling] Release in one step
All checks were successful
build / test (push) Successful in 1m57s
2026-06-05 14:58:35 -04:00
bef7e683c5 [release] Bump to version 45.1
All checks were successful
build / test (push) Successful in 1m58s
deploy / test (push) Successful in 2m2s
deploy / build-and-deploy (push) Successful in 31s
- Mopidy favorites or monthly playlist adds should look at all scrobbles
2026-06-05 14:42:11 -04:00
ec219ef3ea [tracks] Fix adding tracks without mopidy_uri 2026-06-05 14:41:37 -04:00
dcc7229e90 [tooling] Just release does it all now
All checks were successful
build / test (push) Successful in 1m57s
2026-06-05 14:06:45 -04:00
73665ef19e [release] Bump to version 45.0
All checks were successful
build / test (push) Successful in 2m2s
deploy / test (push) Successful in 2m7s
deploy / build-and-deploy (push) Successful in 33s
- Add ability to add mopidy tracks to Monthly playlists
2026-06-05 13:57:06 -04:00
2536e330af [tracks] Use todays date for creating monthly playlists
All checks were successful
build / test (push) Successful in 2m3s
2026-06-05 13:41:30 -04:00
99c056adeb [tracks] Allow adding tracks to monthly playlists 2026-06-05 13:29:49 -04:00
7a504e45de [release] Bump to version 44.0
All checks were successful
build / test (push) Successful in 1m56s
deploy / test (push) Successful in 2m1s
deploy / build-and-deploy (push) Successful in 44s
- Add favorite feature for scrobbles
2026-06-05 11:26:39 -04:00
7618d0ba30 [tooling] Add full push back to justfile
Some checks failed
build / test (push) Has been cancelled
2026-06-05 11:25:48 -04:00
ce4dc40033 [favorites] Add ability to favorite and add to Mopidy
Some checks failed
build / test (push) Has been cancelled
2026-06-05 11:24:26 -04:00
b0b22b79dc [release] Bump to version 43.0
All checks were successful
build / test (push) Successful in 1m56s
deploy / test (push) Successful in 1m56s
deploy / build-and-deploy (push) Successful in 33s
- Can we show a graph of all past Weigh-in tasks
- When viewing scrobbles by tag, sum the total time
- Orgmode tasks are not updated if in progress
- Ignore tag 'inprogress' for Tasks
- Deploys are now throwing an unknown version error
2026-06-04 22:13:36 -04:00
6471413681 [tasks] Add weigh in graph 2026-06-04 22:13:14 -04:00
50b10689fc [scrobbles] Add total time to tag views 2026-06-04 22:01:35 -04:00
85bddb6cba [tasks] Better updating of org mode tasks 2026-06-04 15:16:44 -04:00
c285b0d3b3 [tasks] Exclude inpgrogress tag, they're always inprogress
All checks were successful
build / test (push) Successful in 2m14s
2026-06-04 14:45:10 -04:00
671fe8d86f [tooling] Fix releases once and for all 2026-06-04 14:44:19 -04:00
89817110de [release] Bump to version 42.0
Some checks failed
deploy / test (push) Successful in 2m6s
build / test (push) Successful in 2m1s
deploy / build-and-deploy (push) Failing after 26s
- Add ability to add track to current Mopidy queue
2026-06-04 13:13:21 -04:00
ee01e3d8df [tracks] Add mopidy queue button
All checks were successful
build / test (push) Successful in 2m2s
2026-06-04 13:11:21 -04:00
a70343d6f3 [release] Bump to version 41.0
Some checks failed
build / test (push) Successful in 1m56s
deploy / test (push) Successful in 1m58s
deploy / build-and-deploy (push) Failing after 34s
- For any scrobble detail page with notes display them better
- Imports should send notifications
- Board game imports send duplicate ntfy message
- Too many geolocation notifications go out
- Fix bug where Weigh-in imports do not set title
2026-06-04 11:22:48 -04:00
3e72042c24 [justfile] push for release should only push tags
Some checks failed
build / test (push) Has been cancelled
2026-06-04 11:22:14 -04:00
087c7775ae [templates] Fix note parsing
Some checks failed
build / test (push) Has been cancelled
2026-06-04 11:15:58 -04:00
3f71065ad6 [notifications] Send ntfy on more imports
All checks were successful
build / test (push) Successful in 2m6s
2026-06-04 10:50:32 -04:00
801672124f [notifications] Fix duplicate ntfy for board games
All checks were successful
build / test (push) Successful in 1m58s
2026-06-04 10:47:22 -04:00
811e9c1ce9 [notfications] Dont send ntfy on non-titled geolocations
All checks were successful
build / test (push) Successful in 2m7s
2026-06-04 10:44:21 -04:00
415b32bdc7 [tasks] Scale task sets title properly
All checks were successful
build / test (push) Successful in 1m57s
2026-06-03 16:49:11 -04:00
22319c807a [tooling] Fix deploys for realz
All checks were successful
build / test (push) Successful in 2m0s
2026-06-03 16:38:10 -04:00
f9ba6fec14 [release] Bump to version 40.2
Some checks failed
build / test (push) Successful in 2m0s
deploy / test (push) Successful in 1m59s
deploy / build-and-deploy (push) Failing after 25s
- Try fixing deploy bugs again
2026-06-03 16:32:09 -04:00
5f55163147 [tooling] Try fixing deploys one more time 2026-06-03 16:31:09 -04:00
a6ef34623e [release] Bump to version 40.1
Some checks failed
deploy / test (push) Successful in 2m5s
build / test (push) Successful in 2m6s
deploy / build-and-deploy (push) Failing after 28s
- Releases are still broken
- Fix bug on chart pages where trail titles missing
2026-06-03 16:13:54 -04:00
7cb48d20f6 [tooling] Try to fix deploy issues
Some checks failed
build / test (push) Has been cancelled
2026-06-03 16:13:16 -04:00
445103a878 [trails] Fix display of trails in charts
All checks were successful
build / test (push) Successful in 2m7s
2026-06-03 15:47:56 -04:00
579da8c44e [release] Bump to version 40.0
Some checks failed
build / test (push) Successful in 1m59s
deploy / test (push) Successful in 2m3s
deploy / build-and-deploy (push) Failing after 19s
- Fix error in org-mode task sync
- Adjust how similar artists are shown
2026-06-01 11:12:31 -04:00
daabd2f37f [tasks] Fix bug in syncing orgmode tasks without notes
All checks were successful
build / test (push) Successful in 1m50s
2026-06-01 11:02:56 -04:00
039c58cf89 [tooling] Try fixing deploys again
All checks were successful
build / test (push) Successful in 1m53s
2026-06-01 10:50:38 -04:00
410c033f12 [music] Fix slow loading artist page 2026-06-01 10:50:22 -04:00
ce302e4d45 [release] Bump to version 39.3
Some checks failed
build / test (push) Successful in 1m53s
deploy / test (push) Successful in 1m55s
deploy / build-and-deploy (push) Failing after 19s
- Issue found when doing a release
- Fix deploy actions running twice
2026-06-01 10:35:51 -04:00
19589c9463 [tooling] Fix failing deploys 2026-06-01 10:35:10 -04:00
3d9506b14e [tooling] Actulaly fix the release problems
All checks were successful
build / test (push) Successful in 2m1s
Turns out we need build and deploy in separate files to trigger at
different times. Now we build on all pushes, but only deploy on tag pushes.
2026-06-01 10:21:56 -04:00
23b87278b2 [tooling] Stop running release task twice 2026-06-01 10:12:00 -04:00
0b8e027c30 [release] Bump to version 39.2
Some checks failed
build & deploy / test (push) Successful in 1m59s
build & deploy / build-and-deploy (push) Failing after 24s
- Releases do not pin commit to the repo for display
- Fix the way timestamps are stored for notes on tasks
2026-06-01 10:11:02 -04:00
1bd9f0d942 [tooling] Fix commit stamping on release
All checks were successful
build & deploy / test (push) Successful in 1m56s
build & deploy / build-and-deploy (push) Has been skipped
2026-06-01 09:58:59 -04:00
fa7890cb21 [tasks] Fix timestamps for notes and syncing from org-mode (32973bb3)
All checks were successful
build & deploy / test (push) Successful in 1m54s
build & deploy / build-and-deploy (push) Has been skipped
2026-06-01 09:37:41 -04:00
99 changed files with 5480 additions and 567 deletions

View File

@ -0,0 +1,68 @@
name: build
on:
push:
branches: ["**"]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
env:
VROBBLER_DATABASE_URL: sqlite:///test.db
VROBBLER_USDA_API_KEY: ${{ vars.VROBBLER_USDA_API_KEY }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Cache pip/poetry
uses: actions/cache@v4
with:
path: |
~/.cache/pip
~/.cache/pypoetry
key: ${{ runner.os }}-py311-${{ hashFiles('**/poetry.lock') }}
restore-keys: |
${{ runner.os }}-py311-
- name: Install Poetry
run: |
python -m pip install --upgrade pip
pip install poetry
- name: Install deps
run: |
cp vrobbler.conf.test vrobbler.conf
poetry install --with test
- name: Pytest with coverage
run: |
poetry run pytest -n 5 --cov-report term:skip-covered --cov=vrobbler tests
- name: Notify success (ntfy)
if: success()
run: |
curl -fsS \
-H "Title: vrobbler CI success" \
-H "Priority: low" \
-H "Tags: success,vrobbler" \
-H "Actions: view, Changes, ${{ gitea.server_url }}/${{ gitea.repository }}/commit/${{ gitea.sha }}; view, Build, ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}" \
-d "✅ Build succeeded: ${{ gitea.repository }} @ ${{ gitea.sha }}" \
https://ntfy.unbl.ink/drone
- name: Notify failure (ntfy)
if: failure()
run: |
curl -fsS \
-H "Title: vrobbler CI failure" \
-H "Priority: high" \
-H "Tags: failure,vrobbler" \
-H "Actions: view, Changes, ${{ gitea.server_url }}/${{ gitea.repository }}/commit/${{ gitea.sha }}; view, Build, ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}" \
-d "❌ Build failed: ${{ gitea.repository }} @ ${{ gitea.sha }}" \
https://ntfy.unbl.ink/drone

View File

@ -1,10 +1,8 @@
name: build & deploy
name: deploy
on:
push:
branches: ["**"]
tags: ["*"]
pull_request:
jobs:
test:
@ -22,7 +20,6 @@ jobs:
with:
python-version: "3.11"
# Cache pip + Poetry caches (rough equivalent to your mounted pip_cache)
- name: Cache pip/poetry
uses: actions/cache@v4
with:
@ -47,7 +44,6 @@ jobs:
run: |
poetry run pytest -n 5 --cov-report term:skip-covered --cov=vrobbler tests
# Notifications (success/failure) for the test job
- name: Notify success (ntfy)
if: success()
run: |
@ -71,8 +67,6 @@ jobs:
https://ntfy.unbl.ink/drone
build-and-deploy:
# Only deploy on tags (equivalent to Drone when: ref: refs/tags/*)
if: startsWith(gitea.ref, 'refs/tags/')
needs: [test]
runs-on: ubuntu-latest
@ -97,12 +91,19 @@ jobs:
- name: Build package with commit info
run: |
# Write commit to _commit.py before build
echo "commit = '$(echo ${{ gitea.sha }} | cut -c1-8)'" > vrobbler/_commit.py
poetry build
# Restore original _commit.py
git checkout vrobbler/_commit.py
- name: Clean old wheels from server
uses: appleboy/ssh-action@v1.0.3
with:
host: vrobbler.service
username: root
key: ${{ secrets.JAIL_KEY }}
script: |
rm -f /var/lib/vrobbler/dist/*.whl
- name: Copy wheel to server and deploy
uses: appleboy/scp-action@v1.0.0
with:
@ -125,8 +126,8 @@ jobs:
echo "${{ gitea.sha }}" | cut -c1-8 > /var/lib/vrobbler/commit.txt
pip uninstall -y vrobbler
pip install /var/lib/vrobbler/dist/*.whl
python -c "import vrobbler; print(f'vrobbler {vrobbler.__version__} installed OK')"
rm -f /var/lib/vrobbler/dist/*.whl
python3 -c "import vrobbler; print(f'vrobbler {vrobbler.__version__} installed OK')"
vrobbler migrate
vrobbler collectstatic --noinput
immortalctl restart vrobbler-celery && immortalctl restart vrobbler-celerybeat && immortalctl restart vrobbler

View File

@ -14,3 +14,11 @@ ro class method should call the utility function.
Be sure to check pyproject.toml for project defaults. Specifically for black and
isort expectations.
All tasks live in the PROJECT.org file and include an org ID that is a uuid to make them unique.
In local development, environment variables for various sensitive values live in a .envrc file
The .envrc file can be loaded into a shell environment to allow access to most third party services
Care should be taken when using .envrc that we do not spam services we use in production with requests

File diff suppressed because it is too large Load Diff

View File

@ -15,9 +15,11 @@ celery:
celery-beat:
poetry run celery -A vrobbler beat -l info
release kind="minor":
poetry run python scripts/release.py {{kind}}
push:
git push && git push gitea
git push --tags && git push --tags gitea
release kind="minor":
poetry run python scripts/release.py {{kind}}
just push

29
poetry.lock generated
View File

@ -4966,6 +4966,18 @@ files = [
{file = "soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349"},
]
[[package]]
name = "sqids"
version = "0.5.2"
description = "Generate YouTube-like ids from numbers."
optional = false
python-versions = ">=3.6"
groups = ["main"]
files = [
{file = "sqids-0.5.2-py3-none-any.whl", hash = "sha256:0089ba823e21fd44290c7225f02fb0b5140c36e41959c04d86d3f6f2513799be"},
{file = "sqids-0.5.2.tar.gz", hash = "sha256:5ac08f0c5c9b6814bc2e7c79ee5931e0849d25d95c50e415771b022a44f58af9"},
]
[[package]]
name = "sqlparse"
version = "0.5.5"
@ -5439,6 +5451,21 @@ brotli = ["brotli (==1.0.9) ; os_name != \"nt\" and python_version < \"3\" and p
secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress ; python_version == \"2.7\"", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"]
socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
[[package]]
name = "vadersentiment"
version = "3.3.2"
description = "VADER Sentiment Analysis. VADER (Valence Aware Dictionary and sEntiment Reasoner) is a lexicon and rule-based sentiment analysis tool that is specifically attuned to sentiments expressed in social media, and works well on texts from other domains."
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "vaderSentiment-3.3.2-py2.py3-none-any.whl", hash = "sha256:3bf1d243b98b1afad575b9f22bc2cb1e212b94ff89ca74f8a23a588d024ea311"},
{file = "vaderSentiment-3.3.2.tar.gz", hash = "sha256:5d7c06e027fc8b99238edb0d53d970cf97066ef97654009890b83703849632f9"},
]
[package.dependencies]
requests = "*"
[[package]]
name = "vine"
version = "5.1.0"
@ -6005,4 +6032,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt
[metadata]
lock-version = "2.1"
python-versions = ">=3.11,<3.15"
content-hash = "bd3f14a9cfce403db426af98774f1e3c41b97283aa43f4bd80f84594ee0dd726"
content-hash = "cc5b3b44071d6b0ab4f05189580232cc129b4ed694ab3f0673c3d838c3af0f8a"

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "vrobbler"
version = "39.1"
version = "50.2"
description = ""
authors = ["Colin Powell <colin@unbl.ink>"]
@ -62,6 +62,8 @@ recipe-scrapers = "^15.11.0"
gpxpy = "^1.6.2"
fitparse = "^1.2.0"
lxml = ">=5.5.0"
vaderSentiment = "^3.3.2"
sqids = "^0.5.2"
[tool.poetry.group.test]
optional = true
@ -107,6 +109,8 @@ exclude_dirs = ["*/tests/*", "*/migrations/*"]
[tool.poetry.scripts]
vrobbler = "vrobbler.cli:main"
[tool.poetry_bumpversion.file."vrobbler/__init__.py"]
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"

View File

@ -124,7 +124,7 @@ def main():
if not done_items:
print("No DONE items found in Backlog — nothing to release.")
sys.exit(0)
sys.exit(1)
# ---------------------------------------------------------------
# 4. Build the new Version section text

View File

@ -1,3 +1,4 @@
import tempfile
from datetime import timedelta
import pytest
@ -128,6 +129,23 @@ class TestBirdingCSVImportModel:
assert imp.import_type == "Birding CSV"
assert "Birding" in str(imp)
def test_record_error(self, db, user):
imp = BirdingCSVImport.objects.create(user=user)
assert imp.error_log is None
imp.record_error("test error")
imp.refresh_from_db()
assert imp.error_log is not None
assert "test error" in imp.error_log
def test_record_error_appends(self, db, user):
imp = BirdingCSVImport.objects.create(user=user)
imp.record_error("first error")
imp.record_error("second error")
imp.refresh_from_db()
assert imp.error_log.count("\n") == 1
assert "first error" in imp.error_log
assert "second error" in imp.error_log
@pytest.mark.django_db(transaction=True)
def test_process_via_model(self, user, birding_csv_file):
imp = BirdingCSVImport.objects.create(user=user)
@ -137,3 +155,35 @@ class TestBirdingCSVImportModel:
imp.refresh_from_db()
assert imp.process_count == 1
assert imp.processed_finished is not None
def test_record_error_on_bad_csv(self, user, db):
content = """Species,Count,Location,Observation Type,Observation Date,Start Time,Duration,Distance,Area,Party Size,Complete Checklist,# of species,Details
Canada Goose,6,Test Park,Stationary,"Bad Date",4:15 PM,9 minute(s),,,4,true,4 species,
"""
with tempfile.NamedTemporaryFile(
mode="w", suffix=".csv", delete=False, encoding="utf-8"
) as f:
f.write(content)
file_path = f.name
errors = []
scrobbles = import_birding_csv(file_path, user.id, record_error=errors.append)
assert len(scrobbles) == 0
assert len(errors) == 1
assert "Could not parse date/time" in errors[0]
def test_record_error_on_bad_location(self, user, db):
content = """Species,Count,Location,Observation Type,Observation Date,Start Time,Duration,Distance,Area,Party Size,Complete Checklist,# of species,Details
Canada Goose,6,,Stationary,"May 10, 2026",4:15 PM,9 minute(s),,,4,true,4 species,
"""
with tempfile.NamedTemporaryFile(
mode="w", suffix=".csv", delete=False, encoding="utf-8"
) as f:
f.write(content)
file_path = f.name
errors = []
scrobbles = import_birding_csv(file_path, user.id, record_error=errors.append)
assert len(scrobbles) == 0
assert len(errors) == 1
assert "Skipping rows with no location" in errors[0]

View File

@ -8,7 +8,8 @@ from django.urls import reverse
from django.utils import timezone
from music.models import Album, Artist, Track
from podcasts.models import PodcastEpisode
from scrobbles.models import Scrobble
from scrobbles.models import Scrobble, ShareViewLog
from scrobbles.sqids import encode_scrobble_share
from tasks.models import Task
@ -512,6 +513,7 @@ def test_scrobble_detail_view_with_notes_as_flat_list(client):
task=task,
media_type="Task",
user=user,
visibility="public",
log={
"notes": ["First note", "Second note"],
"description": "Test description",
@ -534,6 +536,7 @@ def test_scrobble_detail_view_with_notes_as_dict_timestamps(client):
task=task,
media_type="Task",
user=user,
visibility="public",
log={
"notes": [
{"2024-01-01 10:00:00": "Note at first timestamp"},
@ -562,6 +565,7 @@ def test_scrobble_detail_view_with_notes_and_labels(client):
task=task,
media_type="Task",
user=user,
visibility="public",
log={
"notes": [
{"2024-01-01 10:00:00": "Note with label"},
@ -739,3 +743,293 @@ def test_gps_webhook_creates_location(client, valid_auth_token):
)
assert response.status_code == 200
assert "scrobble_id" in response.data
@pytest.mark.django_db
def test_share_view_shared_visibility(client):
user = get_user_model().objects.create_user(
username="shareuser", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=user, visibility="shared",
timestamp=timezone.now(),
)
url = reverse(
"scrobbles:shared-detail",
kwargs={"sqid": encode_scrobble_share(scrobble.id, scrobble.share_token_version)},
)
response = client.get(url)
assert response.status_code == 200
@pytest.mark.django_db
def test_share_view_public_visibility(client):
user = get_user_model().objects.create_user(
username="shareuser2", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=user, visibility="public",
timestamp=timezone.now(),
)
url = reverse(
"scrobbles:shared-detail",
kwargs={"sqid": encode_scrobble_share(scrobble.id, scrobble.share_token_version)},
)
response = client.get(url)
assert response.status_code == 200
@pytest.mark.django_db
def test_share_view_private_visibility_returns_404(client):
user = get_user_model().objects.create_user(
username="shareuser3", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=user, visibility="private",
timestamp=timezone.now(),
)
url = reverse(
"scrobbles:shared-detail",
kwargs={"sqid": encode_scrobble_share(scrobble.id, scrobble.share_token_version)},
)
response = client.get(url)
assert response.status_code == 404
@pytest.mark.django_db
def test_share_view_invalid_sqid_returns_404(client):
url = reverse("scrobbles:shared-detail", kwargs={"sqid": "InvalidSqid123"})
response = client.get(url)
assert response.status_code == 404
@pytest.mark.django_db
def test_share_view_expired_token_returns_404(client):
user = get_user_model().objects.create_user(
username="shareuser4", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=user, visibility="shared",
timestamp=timezone.now(),
)
old_sqid = encode_scrobble_share(scrobble.id, scrobble.share_token_version)
scrobble.regenerate_share_token()
url = reverse("scrobbles:shared-detail", kwargs={"sqid": old_sqid})
response = client.get(url)
assert response.status_code == 404
@pytest.mark.django_db
def test_share_view_increments_count_and_logs_view(client):
user = get_user_model().objects.create_user(
username="shareuser5", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=user, visibility="shared",
timestamp=timezone.now(),
)
assert scrobble.share_view_count == 0
url = reverse(
"scrobbles:shared-detail",
kwargs={"sqid": encode_scrobble_share(scrobble.id, scrobble.share_token_version)},
)
response = client.get(url)
assert response.status_code == 200
scrobble.refresh_from_db()
assert scrobble.share_view_count == 1
assert ShareViewLog.objects.filter(scrobble=scrobble).count() == 1
log_entry = ShareViewLog.objects.filter(scrobble=scrobble).first()
assert log_entry.ip_address == "127.0.0.1"
assert log_entry.user_agent == ""
assert log_entry.referrer == ""
@pytest.mark.django_db
def test_explore_view_shows_only_public_scrobbles(client):
user = get_user_model().objects.create_user(
username="exploreuser", password="testpass"
)
public_task = Task.objects.create(title="Public Task Title")
shared_task = Task.objects.create(title="Shared Task Title")
private_task = Task.objects.create(title="Private Task Title")
ts = timezone.now()
public_scrobble = Scrobble.objects.create(
task=public_task, media_type="Task", user=user, visibility="public",
timestamp=ts,
)
Scrobble.objects.create(
task=shared_task, media_type="Task", user=user, visibility="shared",
timestamp=ts,
)
Scrobble.objects.create(
task=private_task, media_type="Task", user=user, visibility="private",
timestamp=ts,
)
url = reverse("scrobbles:explore")
response = client.get(url)
assert response.status_code == 200
content = response.content.decode()
assert "Public Task Title" in content
assert "Shared Task Title" not in content
assert "Private Task Title" not in content
@pytest.mark.django_db
def test_change_visibility_owner_can_change(client):
user = get_user_model().objects.create_user(
username="visuser", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=user, visibility="private",
timestamp=timezone.now(),
)
client.force_login(user)
url = reverse("scrobbles:change-visibility", kwargs={"uuid": scrobble.uuid})
response = client.post(url, {"visibility": "shared"})
assert response.status_code == 302
scrobble.refresh_from_db()
assert scrobble.visibility == "shared"
@pytest.mark.django_db
def test_change_visibility_non_owner_gets_404(client):
owner = get_user_model().objects.create_user(
username="owner", password="testpass"
)
other = get_user_model().objects.create_user(
username="other", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=owner, visibility="private",
timestamp=timezone.now(),
)
client.force_login(other)
url = reverse("scrobbles:change-visibility", kwargs={"uuid": scrobble.uuid})
response = client.post(url, {"visibility": "shared"})
assert response.status_code == 404
scrobble.refresh_from_db()
assert scrobble.visibility == "private"
@pytest.mark.django_db
def test_change_visibility_anonymous_redirects_to_login(client):
user = get_user_model().objects.create_user(
username="anontest", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=user, visibility="private",
timestamp=timezone.now(),
)
url = reverse("scrobbles:change-visibility", kwargs={"uuid": scrobble.uuid})
response = client.post(url, {"visibility": "shared"})
assert response.status_code == 302
assert "/login/" in response.url
@pytest.mark.django_db
def test_regenerate_share_token_invalidates_old_sqid(client):
user = get_user_model().objects.create_user(
username="regentest", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=user, visibility="shared",
timestamp=timezone.now(),
)
old_sqid = encode_scrobble_share(scrobble.id, scrobble.share_token_version)
client.force_login(user)
url = reverse("scrobbles:regenerate-share-token", kwargs={"uuid": scrobble.uuid})
response = client.post(url)
assert response.status_code == 302
scrobble.refresh_from_db()
assert scrobble.share_token_version == 1
old_url = reverse("scrobbles:shared-detail", kwargs={"sqid": old_sqid})
old_response = client.get(old_url)
assert old_response.status_code == 404
new_sqid = encode_scrobble_share(scrobble.id, scrobble.share_token_version)
new_url = reverse("scrobbles:shared-detail", kwargs={"sqid": new_sqid})
new_response = client.get(new_url)
assert new_response.status_code == 200
@pytest.mark.django_db
def test_share_analytics_owner_can_view(client):
user = get_user_model().objects.create_user(
username="analyticsuser", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=user, visibility="shared",
timestamp=timezone.now(),
)
client.force_login(user)
url = reverse("scrobbles:share-analytics", kwargs={"uuid": scrobble.uuid})
response = client.get(url)
assert response.status_code == 200
@pytest.mark.django_db
def test_share_analytics_non_owner_gets_404(client):
owner = get_user_model().objects.create_user(
username="analyticsowner", password="testpass"
)
other = get_user_model().objects.create_user(
username="analyticsother", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=owner, visibility="shared",
timestamp=timezone.now(),
)
client.force_login(other)
url = reverse("scrobbles:share-analytics", kwargs={"uuid": scrobble.uuid})
response = client.get(url)
assert response.status_code == 404
@pytest.mark.django_db
def test_share_analytics_shows_view_logs(client):
user = get_user_model().objects.create_user(
username="analyticsviews", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=user, visibility="shared",
timestamp=timezone.now(),
)
sqid = encode_scrobble_share(scrobble.id, scrobble.share_token_version)
share_url = reverse("scrobbles:shared-detail", kwargs={"sqid": sqid})
client.get(share_url)
client.get(share_url)
client.force_login(user)
url = reverse("scrobbles:share-analytics", kwargs={"uuid": scrobble.uuid})
response = client.get(url)
assert response.status_code == 200
content = response.content.decode()
assert "127.0.0.1" in content
assert ShareViewLog.objects.filter(scrobble=scrobble).count() == 2

View File

@ -2,4 +2,5 @@
# Django starts so that shared_task will use this app.
from .celery import app as celery_app
__all__ = ("celery_app",)
__version__ = "42.0"
__all__ = ("celery_app", "__version__")

View File

@ -26,5 +26,5 @@ class BirdingLocationAdmin(admin.ModelAdmin):
@admin.register(BirdingCSVImport)
class BirdingCSVImportAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = ("uuid", "process_count", "processed_finished", "processing_started")
list_display = ("uuid", "process_count", "processed_finished", "processing_started", "error_log")
ordering = ("-created",)

View File

@ -2,12 +2,15 @@ import csv
import logging
import re
from collections import defaultdict
from datetime import datetime, timedelta
from datetime import timedelta
from dateutil import parser
from django.contrib.auth import get_user_model
from birds.models import Bird, BirdSightingEntry, BirdSightingLogData, BirdingLocation
from scrobbles.models import Scrobble
from scrobbles.notifications import ScrobbleNtfyNotification
logger = logging.getLogger(__name__)
User = get_user_model()
@ -34,11 +37,12 @@ def parse_coords(location_str):
def parse_timestamp(date_str, time_str):
try:
dt = datetime.strptime(f"{date_str} {time_str}", "%B %d, %Y %I:%M %p")
dt_str = f"{date_str} {time_str}".strip()
dt = parser.parse(dt_str)
return dt
except (ValueError, TypeError):
try:
dt = datetime.strptime(date_str, "%B %d, %Y")
dt = parser.parse(date_str)
return dt
except (ValueError, TypeError):
logger.warning(f"Could not parse date/time: {date_str} {time_str}")
@ -60,7 +64,7 @@ def parse_int(value):
return None
def import_birding_csv(file_path, user_id):
def import_birding_csv(file_path, user_id, record_error=None):
user = User.objects.get(id=user_id)
new_scrobbles = []
@ -79,11 +83,17 @@ def import_birding_csv(file_path, user_id):
for (location_str, date_str, time_str), sighting_rows in groups.items():
if not location_str:
logger.warning("Skipping rows with no location")
msg = "Skipping rows with no location"
logger.warning(msg)
if record_error:
record_error(msg)
continue
timestamp = parse_timestamp(date_str, time_str)
if not timestamp:
msg = f"Could not parse date/time: {date_str} {time_str}"
if record_error:
record_error(msg)
continue
timestamp = user.profile.get_timestamp_with_tz(timestamp)
@ -183,4 +193,6 @@ def import_birding_csv(file_path, user_id):
created = Scrobble.objects.bulk_create(new_scrobbles)
logger.info(f"Created {len(created)} birding scrobbles")
for scrobble in created:
ScrobbleNtfyNotification(scrobble).send()
return created

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.29 on 2026-06-08 14:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("birds", "0002_birdingcsvimport"),
]
operations = [
migrations.AddField(
model_name="birdingcsvimport",
name="error_log",
field=models.TextField(blank=True, null=True),
),
]

View File

@ -61,9 +61,7 @@ class BirdSightingLogData(BaseLogData, WithPeopleLogData):
@cached_property
def bird_list(self) -> str:
if self.birds:
return ", ".join(
[BirdSightingEntry(**b).__str__() for b in self.birds]
)
return ", ".join([BirdSightingEntry(**b).__str__() for b in self.birds])
return ""
def as_html(self) -> str:
@ -80,9 +78,7 @@ class BirdSightingLogData(BaseLogData, WithPeopleLogData):
)
if self.area:
html_parts.append(
f'<div class="birding-area">Area: {self.area}</div>'
)
html_parts.append(f'<div class="birding-area">Area: {self.area}</div>')
if self.party_size:
html_parts.append(
@ -105,9 +101,7 @@ class BirdSightingLogData(BaseLogData, WithPeopleLogData):
)
if self.guide:
html_parts.append(
f'<div class="birding-guide">Guide: {self.guide}</div>'
)
html_parts.append(f'<div class="birding-guide">Guide: {self.guide}</div>')
if self.duration_minutes:
html_parts.append(
@ -183,9 +177,7 @@ class Bird(TimeStampedModel):
class BirdingLocation(ScrobblableMixin):
description = models.TextField(**BNULL)
geo_location = models.ForeignKey(
GeoLocation, **BNULL, on_delete=models.DO_NOTHING
)
geo_location = models.ForeignKey(GeoLocation, **BNULL, on_delete=models.DO_NOTHING)
ebird_hotspot_id = models.CharField(max_length=255, **BNULL)
def get_absolute_url(self):
@ -193,7 +185,7 @@ class BirdingLocation(ScrobblableMixin):
@property
def subtitle(self):
return ""
return self.geo_location
@property
def strings(self) -> ScrobblableConstants:
@ -224,6 +216,7 @@ class BirdingCSVImport(TimeStampedModel):
processed_finished = models.DateTimeField(**BNULL)
process_log = models.TextField(**BNULL)
process_count = models.IntegerField(**BNULL)
error_log = models.TextField(**BNULL)
csv_file = models.FileField(upload_to="birding-csv-uploads/", **BNULL)
class Meta:
@ -269,9 +262,7 @@ class BirdingCSVImport(TimeStampedModel):
self.save(update_fields=["process_log", "process_count"])
return
for count, scrobble in enumerate(scrobbles):
scrobble_str = (
f"{scrobble.id}\t{scrobble.timestamp}\t{scrobble.media_obj}"
)
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
@ -279,6 +270,14 @@ class BirdingCSVImport(TimeStampedModel):
self.process_count = len(scrobbles)
self.save(update_fields=["process_log", "process_count"])
def record_error(self, error_message):
log_line = f"{timezone.now().isoformat()}: {error_message}"
if self.error_log:
self.error_log += "\n" + log_line
else:
self.error_log = log_line
self.save(update_fields=["error_log"])
def scrobbles(self):
from scrobbles.models import Scrobble
@ -297,6 +296,13 @@ class BirdingCSVImport(TimeStampedModel):
from birds.importer import import_birding_csv
self.mark_started()
scrobbles = import_birding_csv(self.upload_file_path, self.user_id)
self.record_log(scrobbles)
self.mark_finished()
try:
scrobbles = import_birding_csv(
self.upload_file_path, self.user_id, record_error=self.record_error
)
self.record_log(scrobbles)
except Exception as e:
self.record_error(f"Import failed: {e}")
logger.exception(f"Import failed for {self}")
finally:
self.mark_finished()

View File

@ -266,8 +266,9 @@ class BoardGame(ScrobblableMixin):
"self", **BNULL, on_delete=models.DO_NOTHING
)
def __str__(self):
return self.title
@property
def subtitle(self) -> str:
return self.publisher
def get_absolute_url(self):
return reverse("boardgames:boardgame_detail", kwargs={"slug": self.uuid})

View File

@ -187,18 +187,20 @@ def build_page_data(page_rows: list, book_map: dict, user_tz=None) -> dict:
book_ids_not_found.append(koreader_book_id)
continue
if "pages" not in book_map[koreader_book_id].keys():
book_map[koreader_book_id]["pages"] = {}
book_map[koreader_book_id].setdefault("pages", [])
page_number = page_row[KoReaderPageStatColumn.PAGE.value]
duration = page_row[KoReaderPageStatColumn.DURATION.value]
start_ts = page_row[KoReaderPageStatColumn.START_TIME.value]
book_map[koreader_book_id]["pages"][page_number] = {
"duration": duration,
"start_ts": start_ts,
"end_ts": start_ts + duration,
}
book_map[koreader_book_id]["pages"].append(
{
"page_number": page_number,
"duration": duration,
"start_ts": start_ts,
"end_ts": start_ts + duration,
}
)
if book_ids_not_found:
logger.info(f"Found pages for books not in file: {set(book_ids_not_found)}")
return book_map
@ -225,11 +227,12 @@ def build_scrobbles_from_book_map(book_map: dict, user: "User") -> list["Scrobbl
pages_processed = 0
total_pages_read = len(book_map[koreader_book_id]["pages"])
ordered_pages = sorted(
book_map[koreader_book_id]["pages"].items(),
key=lambda x: x[1]["start_ts"],
book_map[koreader_book_id]["pages"],
key=lambda x: x["start_ts"],
)
for cur_page_number, stats in ordered_pages:
for stats in ordered_pages:
page_number = stats["page_number"]
pages_processed += 1
seconds_from_last_page = 0
@ -243,12 +246,14 @@ def build_scrobbles_from_book_map(book_map: dict, user: "User") -> list["Scrobbl
)
end_of_reading = pages_processed == total_pages_read
big_jump_to_this_page = (cur_page_number - last_page_number) > 10
big_jump_to_this_page = (page_number - last_page_number) > 10
is_session_gap = seconds_from_last_page > SESSION_GAP_SECONDS
if (is_session_gap and not big_jump_to_this_page) or end_of_reading:
should_create_scrobble = True
if should_create_scrobble:
if not scrobble_page_data:
scrobble_page_data[page_number] = stats
scrobble_page_data = dict(
sorted(
scrobble_page_data.items(),
@ -276,13 +281,6 @@ def build_scrobbles_from_book_map(book_map: dict, user: "User") -> list["Scrobbl
datetime.fromtimestamp(int(last_page.get("end_ts")))
)
# Adjust for Daylight Saving Time
# if timestamp.dst() == timedelta(
# 0
# ) or stop_timestamp.dst() == timedelta(0):
# timestamp = timestamp - timedelta(hours=1)
# stop_timestamp = stop_timestamp - timedelta(hours=1)
scrobble = Scrobble.objects.filter(
timestamp=timestamp,
book_id=book_id,
@ -291,7 +289,7 @@ def build_scrobbles_from_book_map(book_map: dict, user: "User") -> list["Scrobbl
if not scrobble:
logger.info(
f"Queueing scrobble for {book_id}, page {cur_page_number}"
f"Queueing scrobble for {book_id}, page {page_number}"
)
log_data = {
"koreader_hash": book_dict.get("hash"),
@ -324,9 +322,9 @@ def build_scrobbles_from_book_map(book_map: dict, user: "User") -> list["Scrobbl
scrobble_page_data = {}
# We accumulate pages for the scrobble until we should create a new one
scrobble_page_data[cur_page_number] = stats
scrobble_page_data[page_number] = stats
last_page_number = cur_page_number
last_page_number = page_number
prev_page_stats = stats
if pages_not_found:
logger.info(f"Pages not found for books: {set(pages_not_found)}")

View File

@ -173,11 +173,7 @@ class Book(LongPlayScrobblableMixin):
genre = TaggableManager(through=ObjectWithGenres, blank=True, verbose_name="Genre")
def __str__(self) -> str:
if self.issue_number and "Issue" not in str(self.title):
return f"{self.title} - Issue {self.issue_number}"
if self.volume_number and "Volume" not in str(self.title):
return f"{self.title} - Volume {self.volume_number}"
return f"{self.title}"
return f"{self.title} - {self.subtitle}"
def save(self, *args, **kwargs):
if self.pages:
@ -188,7 +184,12 @@ class Book(LongPlayScrobblableMixin):
@property
def subtitle(self):
return f" by {self.author}"
subtitle = self.author
if self.issue_number and "Issue" not in str(self.title):
subtitle += " - Issue {self.issue_number}"
if self.volume_number and "Volume" not in str(self.title):
subtitle += " - Volume {self.volume_number}"
return subtitle
@property
def strings(self) -> ScrobblableConstants:

View File

@ -32,8 +32,6 @@ class KoReaderBookRows:
DEFAULT_STR = "N/A"
DEFAULT_INT = 0
DEFAULT_TIME = 1703800469
BOOK_ROWS = []
PAGE_STATS_ROWS = []
def _gen_random_row(self, i):
wiggle = random.randrange(15)
@ -110,6 +108,8 @@ class KoReaderBookRows:
end_session = True
def __init__(self, book_count=0, **kwargs):
self.BOOK_ROWS = []
self.PAGE_STATS_ROWS = []
self._generate_random_book_rows(book_count)
self._generate_custom_book_row(**kwargs)
self._generate_random_page_stats_rows()

View File

@ -7,7 +7,10 @@ register = template.Library()
def get_item(dictionary, key):
if isinstance(dictionary, dict):
return dictionary.get(key)
return None
try:
return dictionary[int(key)]
except (IndexError, KeyError, TypeError, ValueError):
return None
@register.filter

View File

@ -114,108 +114,106 @@ class ChartRecordView(TemplateView):
context["current_week"] = current_week
context["current_day"] = current_day
if chart_type == "maloja":
context["chart_keys"] = {
"today": "Today",
"week": "This Week",
"month": "This Month",
"year": "This Year",
"all": "All Time",
}
context["chart_keys"] = {
"today": "Today",
"week": "This Week",
"month": "This Month",
"year": "This Year",
"all": "All Time",
}
context["maloja_charts"] = {
"artist": {
"today": list(
self.get_charts_for_period(
user,
"artist",
year=current_year,
month=current_month,
day=current_day,
)
),
"week": list(
self.get_charts_for_period(
user,
"artist",
year=current_year,
week=current_week,
)
),
"month": list(
self.get_charts_for_period(
user,
"artist",
year=current_year,
month=current_month,
)
),
"year": list(
self.get_charts_for_period(user, "artist", year=current_year)
),
"all": list(self.get_charts_for_period(user, "artist")),
},
"track": {
"today": list(
self.get_charts_for_period(
user,
"track",
year=current_year,
month=current_month,
day=current_day,
)
),
"week": list(
self.get_charts_for_period(
user, "track", year=current_year, week=current_week
)
),
"month": list(
self.get_charts_for_period(
user,
"track",
year=current_year,
month=current_month,
)
),
"year": list(
self.get_charts_for_period(user, "track", year=current_year)
),
"all": list(self.get_charts_for_period(user, "track")),
},
"tv_series": {
"today": list(
self.get_charts_for_period(
user,
"tv_series",
year=current_year,
month=current_month,
day=current_day,
)
),
"week": list(
self.get_charts_for_period(
user,
"tv_series",
year=current_year,
week=current_week,
)
),
"month": list(
self.get_charts_for_period(
user,
"tv_series",
year=current_year,
month=current_month,
)
),
"year": list(
self.get_charts_for_period(user, "tv_series", year=current_year)
),
"all": list(self.get_charts_for_period(user, "tv_series")),
},
}
return context
context["maloja_charts"] = {
"artist": {
"today": list(
self.get_charts_for_period(
user,
"artist",
year=current_year,
month=current_month,
day=current_day,
)
),
"week": list(
self.get_charts_for_period(
user,
"artist",
year=current_year,
week=current_week,
)
),
"month": list(
self.get_charts_for_period(
user,
"artist",
year=current_year,
month=current_month,
)
),
"year": list(
self.get_charts_for_period(user, "artist", year=current_year)
),
"all": list(self.get_charts_for_period(user, "artist")),
},
"album": {
"today": list(
self.get_charts_for_period(
user,
"album",
year=current_year,
month=current_month,
day=current_day,
)
),
"week": list(
self.get_charts_for_period(
user, "album", year=current_year, week=current_week
)
),
"month": list(
self.get_charts_for_period(
user,
"album",
year=current_year,
month=current_month,
)
),
"year": list(
self.get_charts_for_period(user, "album", year=current_year)
),
"all": list(self.get_charts_for_period(user, "album")),
},
"tv_series": {
"today": list(
self.get_charts_for_period(
user,
"tv_series",
year=current_year,
month=current_month,
day=current_day,
)
),
"week": list(
self.get_charts_for_period(
user,
"tv_series",
year=current_year,
week=current_week,
)
),
"month": list(
self.get_charts_for_period(
user,
"tv_series",
year=current_year,
month=current_month,
)
),
"year": list(
self.get_charts_for_period(user, "tv_series", year=current_year)
),
"all": list(self.get_charts_for_period(user, "tv_series")),
},
}
if not date_param:
context["period"] = "current"

View File

@ -0,0 +1,127 @@
import csv
import logging
from django.core.management.base import BaseCommand
logger = logging.getLogger(__name__)
def _get_source(raw_data):
if "Artist" in raw_data:
return "Jellyfin"
if "artist" in raw_data:
return "Mopidy"
return None
def _get_raw_values(raw_data, source):
if source == "Jellyfin":
return raw_data.get("Artist", ""), raw_data.get("Album", "")
return raw_data.get("artist", ""), raw_data.get("album", "")
def _normalize(name):
return name.strip().casefold()
def _artist_mismatch(raw_artist, track_artist_names):
if not raw_artist or not track_artist_names:
return False
track_names = [_normalize(n) for n in track_artist_names.split(" / ")]
raw = _normalize(raw_artist)
if raw in track_names:
return False
if raw == _normalize(track_artist_names):
return False
return True
def _album_mismatch(raw_album, track_album_name):
if not raw_album or not track_album_name:
return False
return _normalize(raw_album) != _normalize(track_album_name)
class Command(BaseCommand):
help = (
"Outputs a CSV of track IDs where raw metadata from scrobble logs "
"does not match the track's stored artists or album"
)
def add_arguments(self, parser):
parser.add_argument(
"--file-path",
type=str,
default="/tmp/metadata-report.csv",
help="Output CSV file path (default: /tmp/metadata-report.csv)",
)
def handle(self, *args, **options):
from scrobbles.models import Scrobble
file_path = options["file_path"]
qs = (
Scrobble.objects.filter(media_type=Scrobble.MediaType.TRACK)
.exclude(log__isnull=True)
.exclude(log={})
.select_related("track__album")
.prefetch_related("track__artists")
.iterator()
)
rows = []
for scrobble in qs:
track = scrobble.track
if not track:
continue
raw_data = scrobble.log.get("raw_data")
if not raw_data:
continue
source = _get_source(raw_data)
if not source:
continue
raw_artist, raw_album = _get_raw_values(raw_data, source)
if not raw_artist and not raw_album:
continue
track_artist_names = " / ".join(
track.artists.all().values_list("name", flat=True)
)
track_album_name = track.album.name if track.album else ""
if _artist_mismatch(raw_artist, track_artist_names) or _album_mismatch(
raw_album, track_album_name
):
rows.append(
{
"track_id": track.id,
"track_artist_name": track_artist_names,
"track_album_name": track_album_name,
"raw_artist": raw_artist,
"raw_album": raw_album,
"source": source,
}
)
fieldnames = [
"track_id",
"track_artist_name",
"track_album_name",
"raw_artist",
"raw_album",
"source",
]
with open(file_path, "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
self.stdout.write(
self.style.SUCCESS(
f"Wrote {len(rows)} mismatched track(s) to {file_path}"
)
)

View File

@ -19,11 +19,7 @@ class ArtistListView(generic.ListView):
paginate_by = 100
def get_queryset(self):
qs = (
super()
.get_queryset()
.annotate(scrobble_count=Count("track__scrobble"))
)
qs = super().get_queryset().annotate(scrobble_count=Count("track__scrobble"))
genre = self.request.GET.get("genre")
if genre:
qs = qs.filter(theaudiodb_genre=genre)
@ -82,26 +78,30 @@ class ArtistDetailView(generic.DetailView):
similar = artist.similar_artists or []
if similar:
mbids = [sa["artist_mbid"] for sa in similar if sa.get("artist_mbid")]
top = similar[:10]
mbids = [sa["artist_mbid"] for sa in top if sa.get("artist_mbid")]
local_artists = {
a.musicbrainz_id: a
for a in Artist.objects.filter(musicbrainz_id__in=mbids)
}
for sa in similar:
for sa in top:
local = local_artists.get(sa.get("artist_mbid"))
sa["local_url"] = local.get_absolute_url() if local else None
context_data["similar_artists"] = similar
sa["musicbrainz_url"] = (
f"https://musicbrainz.org/artist/{sa['artist_mbid']}"
if sa.get("artist_mbid")
else None
)
context_data["similar_artists"] = top
if artist.theaudiodb_genre:
context_data["genre_count"] = (
Artist.objects.filter(theaudiodb_genre=artist.theaudiodb_genre)
.count()
)
context_data["genre_count"] = Artist.objects.filter(
theaudiodb_genre=artist.theaudiodb_genre
).count()
if artist.theaudiodb_mood:
context_data["mood_count"] = (
Artist.objects.filter(theaudiodb_mood=artist.theaudiodb_mood)
.count()
)
context_data["mood_count"] = Artist.objects.filter(
theaudiodb_mood=artist.theaudiodb_mood
).count()
return context_data

View File

@ -1,6 +1,8 @@
from django import forms
from profiles.models import UserProfile
from scrobbles.constants import Visibility
from scrobbles.models import Scrobble
class UserProfileForm(forms.ModelForm):
@ -31,6 +33,9 @@ class UserProfileForm(forms.ModelForm):
"webdav_auto_import",
"ntfy_url",
"ntfy_enabled",
"mopidy_api_url",
"favorites_mopidy_playlist",
"monthly_mopidy_playlist_pattern",
"redirect_to_webpage",
"enable_public_widgets",
"widget_custom_css",
@ -42,3 +47,51 @@ class UserProfileForm(forms.ModelForm):
"archivebox_password": forms.PasswordInput(render_value=True),
"webdav_pass": forms.PasswordInput(render_value=True),
}
MEDIA_TYPE_LABELS = {
mt.value: mt.label for mt in Scrobble.MediaType
}
INHERIT = ""
class BulkVisibilityForm(forms.Form):
bulk_action = forms.ChoiceField(
choices=[
(Visibility.PUBLIC, "Public"),
(Visibility.PRIVATE, "Private"),
],
widget=forms.RadioSelect,
required=False,
label="Set all non-shared scrobbles to",
)
def __init__(self, *args, **kwargs):
self.profile = kwargs.pop("profile")
super().__init__(*args, **kwargs)
media_types = Scrobble.MediaType.values
choices = [
(Visibility.PUBLIC, "Public"),
(Visibility.SHARED, "Shared"),
(Visibility.PRIVATE, "Private"),
]
existing_overrides = self.profile.media_type_visibility or {}
for mt in sorted(media_types):
label = MEDIA_TYPE_LABELS.get(mt, mt)
self.fields[f"media_type_{mt}"] = forms.ChoiceField(
choices=choices,
required=False,
label=label,
initial=existing_overrides.get(mt, Visibility.PRIVATE),
)
def clean(self):
cleaned = super().clean()
overrides = {}
for mt in Scrobble.MediaType.values:
val = cleaned.get(f"media_type_{mt}")
if val:
overrides[mt] = val
cleaned["media_type_visibility"] = overrides
return cleaned

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.30 on 2026-06-04 16:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("profiles", "0032_userprofile_weigh_in_units"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="mopidy_api_url",
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 4.2.30 on 2026-06-05 14:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("profiles", "0033_userprofile_mopidy_api_url"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="favorites_mopidy_playlist",
field=models.CharField(
blank=True,
help_text="Playlist name (e.g. 'Favorites'). Will map to m3u:Favorites.m3u8",
max_length=255,
null=True,
),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 4.2.30 on 2026-06-05 17:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("profiles", "0034_userprofile_favorites_mopidy_playlist"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="monthly_mopidy_playlist_pattern",
field=models.CharField(
blank=True,
help_text="Django date format pattern for monthly playlists (e.g. 'Y F')",
max_length=255,
null=True,
),
),
]

View File

@ -0,0 +1,26 @@
# Generated by Django 4.2.29 on 2026-06-09 15:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("profiles", "0035_userprofile_monthly_mopidy_playlist_pattern"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="default_scrobble_visibility",
field=models.CharField(
choices=[
("public", "Public"),
("shared", "Shared"),
("private", "Private"),
],
default="shared",
max_length=10,
),
),
]

View File

@ -0,0 +1,26 @@
# Generated by Django 4.2.29 on 2026-06-09 16:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("profiles", "0036_userprofile_default_scrobble_visibility"),
]
operations = [
migrations.AlterField(
model_name="userprofile",
name="default_scrobble_visibility",
field=models.CharField(
choices=[
("public", "Public"),
("shared", "Shared"),
("private", "Private"),
],
default="private",
max_length=10,
),
),
]

View File

@ -0,0 +1,20 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("profiles", "0037_alter_userprofile_default_scrobble_visibility"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="media_type_visibility",
field=models.JSONField(
blank=True,
default=dict,
help_text='Per-media-type visibility overrides, e.g. {"Video": "public", "Track": "private"}',
),
),
]

View File

@ -9,6 +9,11 @@ from django.utils.functional import cached_property
from django_extensions.db.models import TimeStampedModel
from encrypted_field import EncryptedField
from profiles.constants import PRETTY_TIMEZONE_CHOICES
VISIBILITY_CHOICES = (
("public", "Public"),
("shared", "Shared"),
("private", "Private"),
)
User = get_user_model()
BNULL = {"blank": True, "null": True}
@ -64,11 +69,33 @@ class UserProfile(TimeStampedModel):
ntfy_url = models.CharField(max_length=255, **BNULL)
ntfy_enabled = models.BooleanField(default=False)
mopidy_api_url = models.CharField(max_length=255, **BNULL)
favorites_mopidy_playlist = models.CharField(
max_length=255, **BNULL,
help_text="Playlist name (e.g. 'Favorites'). Will map to m3u:Favorites.m3u8",
)
monthly_mopidy_playlist_pattern = models.CharField(
max_length=255, **BNULL,
help_text="Django date format pattern for monthly playlists (e.g. 'Y F')",
)
redirect_to_webpage = models.BooleanField(default=True)
enable_public_widgets = models.BooleanField(default=False)
widget_custom_css = models.TextField(**BNULL)
default_scrobble_visibility = models.CharField(
max_length=10,
choices=VISIBILITY_CHOICES,
default="private",
)
media_type_visibility = models.JSONField(
default=dict,
blank=True,
help_text="Per-media-type visibility overrides, e.g. {\"Video\": \"public\", \"Track\": \"private\"}",
)
home_scrobble_limit = models.IntegerField(default=20)
weigh_in_units = models.CharField(

View File

@ -6,4 +6,9 @@ app_name = "profiles"
urlpatterns = [
path("settings/", views.ProfileFormView.as_view(), name="profile_settings"),
path(
"settings/visibility/",
views.BulkVisibilityView.as_view(),
name="bulk_visibility",
),
]

View File

@ -1,8 +1,12 @@
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Count, Q
from django.http.response import HttpResponseBadRequest
from django.urls import reverse_lazy
from django.views.generic import FormView
from profiles.forms import UserProfileForm
from profiles.forms import BulkVisibilityForm, UserProfileForm
from scrobbles.constants import Visibility
from scrobbles.models import Scrobble
from tasks.todoist import generate_todoist_oauth_url
@ -30,3 +34,46 @@ class ProfileFormView(LoginRequiredMixin, FormView):
context["profile"] = self.request.user.profile
context["todoist_oauth_url"] = generate_todoist_oauth_url(self.request.user.id)
return context
class BulkVisibilityView(LoginRequiredMixin, FormView):
template_name = "profiles/visibility_settings.html"
form_class = BulkVisibilityForm
success_url = reverse_lazy("profiles:bulk_visibility")
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs["profile"] = self.request.user.profile
return kwargs
def form_valid(self, form):
request = self.request
profile = request.user.profile
bulk_action = form.cleaned_data.get("bulk_action")
if bulk_action:
qs = Scrobble.objects.filter(
user=request.user,
).exclude(visibility=Visibility.SHARED)
total = qs.count()
qs.update(visibility=bulk_action)
messages.success(
request,
f"Updated {total} scrobble(s) to {bulk_action}.",
)
profile.media_type_visibility = form.cleaned_data["media_type_visibility"]
profile.save(update_fields=["media_type_visibility"])
messages.success(request, "Per-media-type visibility overrides saved.")
return super().form_valid(form)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
profile = self.request.user.profile
qs = Scrobble.objects.filter(user=self.request.user)
ctx["scrobble_count"] = qs.count()
ctx["visibility_counts"] = qs.values("visibility").annotate(
count=Count("id")
)
ctx["profile"] = profile
return ctx

View File

@ -4,11 +4,13 @@ from scrobbles.models import (
AudioScrobblerTSVImport,
BGStatsImport,
EBirdCSVImport,
FavoriteMedia,
KoReaderImport,
LastFmImport,
RetroarchImport,
ScaleCSVImport,
Scrobble,
ShareViewLog,
TrailGPXImport,
)
from scrobbles.mixins import Genre
@ -19,6 +21,7 @@ class ScrobbleInline(admin.TabularInline):
extra = 0
raw_id_fields = (
"video",
"channel",
"podcast_episode",
"track",
"video_game",
@ -29,6 +32,7 @@ class ScrobbleInline(admin.TabularInline):
"board_game",
"geo_location",
"task",
"puzzle",
"mood",
"brick_set",
"trail",
@ -53,52 +57,44 @@ class ImportBaseAdmin(admin.ModelAdmin):
"process_count",
"processed_finished",
"processing_started",
"error_log",
)
ordering = ("-created",)
@admin.register(AudioScrobblerTSVImport)
class AudioScrobblerTSVImportAdmin(ImportBaseAdmin):
...
class AudioScrobblerTSVImportAdmin(ImportBaseAdmin): ...
@admin.register(LastFmImport)
class LastFmImportAdmin(ImportBaseAdmin):
...
class LastFmImportAdmin(ImportBaseAdmin): ...
@admin.register(KoReaderImport)
class KoReaderImportAdmin(ImportBaseAdmin):
...
class KoReaderImportAdmin(ImportBaseAdmin): ...
@admin.register(RetroarchImport)
class RetroarchImportAdmin(ImportBaseAdmin):
...
class RetroarchImportAdmin(ImportBaseAdmin): ...
class RetroarchImportAdmin(ImportBaseAdmin):
...
class RetroarchImportAdmin(ImportBaseAdmin): ...
@admin.register(BGStatsImport)
class BGStatsImportAdmin(ImportBaseAdmin):
...
class BGStatsImportAdmin(ImportBaseAdmin): ...
@admin.register(EBirdCSVImport)
class EBirdCSVImportAdmin(ImportBaseAdmin):
...
class EBirdCSVImportAdmin(ImportBaseAdmin): ...
@admin.register(ScaleCSVImport)
class ScaleCSVImportAdmin(ImportBaseAdmin):
...
class ScaleCSVImportAdmin(ImportBaseAdmin): ...
@admin.register(TrailGPXImport)
class TrailGPXImportAdmin(ImportBaseAdmin):
...
class TrailGPXImportAdmin(ImportBaseAdmin): ...
@admin.register(Genre)
@ -121,6 +117,7 @@ class ScrobbleAdmin(admin.ModelAdmin):
"in_progress",
"is_paused",
"played_to_completion",
"visibility",
"user",
)
raw_id_fields = (
@ -148,6 +145,7 @@ class ScrobbleAdmin(admin.ModelAdmin):
"is_paused",
"in_progress",
"media_type",
"visibility",
"long_play_complete",
"source",
"timezone",
@ -164,3 +162,36 @@ class ScrobbleAdmin(admin.ModelAdmin):
def get_queryset(self, request):
qs = super().get_queryset(request).exclude(timestamp__year=None)
return qs
@admin.register(ShareViewLog)
class ShareViewLogAdmin(admin.ModelAdmin):
list_display = ("scrobble", "ip_address", "created")
list_filter = ("created",)
date_hierarchy = "created"
raw_id_fields = ("scrobble",)
@admin.register(FavoriteMedia)
class FavoriteMediaAdmin(admin.ModelAdmin):
list_display = ("user", "media_type", "sent_to_mopidy", "created")
list_filter = ("media_type", "sent_to_mopidy", "user")
date_hierarchy = "created"
raw_id_fields = (
"video",
"track",
"podcast_episode",
"sport_event",
"book",
"video_game",
"board_game",
"geo_location",
"task",
"mood",
"brick_set",
"trail",
"beer",
"web_page",
"life_event",
"birding_location",
)

View File

@ -1,6 +1,7 @@
import re
from rest_framework import serializers
from scrobbles.constants import Visibility
from scrobbles.models import (
AudioScrobblerTSVImport,
KoReaderImport,

View File

@ -1,6 +1,8 @@
from logging import getLogger
from rest_framework import permissions, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from scrobbles.api.serializers import (
AudioScrobblerTSVImportSerializer,
KoReaderImportSerializer,
@ -26,6 +28,12 @@ class ScrobbleViewSet(viewsets.ModelViewSet):
def get_queryset(self):
return super().get_queryset().filter(user=self.request.user)
@action(detail=True, methods=["post"])
def regenerate_share_token(self, request, uuid=None):
scrobble = self.get_object()
scrobble.regenerate_share_token()
return Response({"share_url": scrobble.get_share_url()})
class KoReaderImportViewSet(viewsets.ModelViewSet):
queryset = KoReaderImport.objects.all().order_by("-created")

View File

@ -1,6 +1,12 @@
from django.db import models
from enum import Enum
JELLYFIN_VIDEO_ITEM_TYPES = ["Episode", "Movie"]
class Visibility(models.TextChoices):
PUBLIC = "public", "Public"
SHARED = "shared", "Shared"
PRIVATE = "private", "Private"
JELLYFIN_AUDIO_ITEM_TYPES = ["Audio"]
LONG_PLAY_MEDIA = {

View File

@ -83,43 +83,126 @@ class BaseLogData(JSONDataclass):
return ""
@staticmethod
def _format_timestamp(ts: str) -> str:
from datetime import datetime, timezone
import re
cleaned = ts.strip().strip("[]")
dt = None
if cleaned.isdigit():
try:
seconds = int(cleaned)
dt = datetime.fromtimestamp(seconds, tz=timezone.utc)
except (ValueError, OSError):
pass
if dt is None:
for fmt in [
"%Y-%m-%dT%H:%M:%S.%fZ",
"%Y-%m-%dT%H:%M:%SZ",
"%Y-%m-%dT%H:%M:%S.%f",
"%Y-%m-%dT%H:%M:%S",
]:
try:
dt = datetime.strptime(cleaned, fmt)
break
except ValueError:
continue
if dt is None:
m = re.match(r"(\d{4}-\d{2}-\d{2})\s+\w{3}\s+(\d{2}:\d{2})", cleaned)
if m:
try:
dt = datetime.strptime(
f"{m.group(1)} {m.group(2)}", "%Y-%m-%d %H:%M"
)
except ValueError:
pass
if dt is None:
try:
dt = datetime.fromisoformat(cleaned)
except ValueError:
pass
if dt:
return dt.strftime("%b %-d, %Y %-I:%M %p")
return ts
def notes_as_html(self) -> str:
import html
import bleach
import markdown
from django.utils.safestring import mark_safe
if not self.notes:
return ""
html_notes = []
md = markdown.Markdown(extensions=["extra"])
allowed_tags = [
"p",
"br",
"strong",
"em",
"a",
"ul",
"ol",
"li",
"code",
"pre",
"blockquote",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"hr",
"img",
]
note_items = []
if isinstance(self.notes, dict):
for ts, text in self.notes.items():
note_text = " ".join(text.strip().split())
html_notes.append(
f'<div class="sticky-note">{ts}: {note_text}</div>'
)
for ts, text in sorted(self.notes.items(), reverse=True):
note_items.append((ts, text.strip()))
elif isinstance(self.notes, str):
for line in self.notes.split("\n"):
if line.strip():
escaped_line = html.escape(line)
html_notes.append(f'<div class="sticky-note">{escaped_line}</div>')
note_items.append((None, line.strip()))
elif isinstance(self.notes, list):
for note in self.notes:
if isinstance(note, dict):
timestamp, note_text = next(iter(note.items()))
note_text = " ".join(note_text.strip().split())
html_notes.append(
f'<div class="sticky-note">{timestamp}: {note_text}</div>'
)
note_items.append((timestamp, note_text.strip()))
elif isinstance(note, str):
for line in note.split("\n"):
if line.strip():
escaped_line = html.escape(line)
html_notes.append(
f'<div class="sticky-note">{escaped_line}</div>'
)
note_items.append((None, line.strip()))
return mark_safe("".join(html_notes))
html_parts = []
for i, (ts, text) in enumerate(note_items):
if i > 0:
html_parts.append('<hr class="note-divider">')
ts_html = ""
if ts:
ts_html = (
f'<h5 class="note-timestamp">{self._format_timestamp(ts)}</h5>'
)
content_html = bleach.clean(
md.convert(text),
tags=allowed_tags,
strip=True,
)
html_parts.append(
f'<div class="note-item">{ts_html}<div class="note-content">{content_html}</div></div>'
)
return mark_safe("".join(html_parts))
@dataclass
@ -127,6 +210,14 @@ class LongPlayLogData(JSONDataclass):
long_play_complete: bool = False
@dataclass
class SportEventLogData(BaseLogData):
thesportsdb_id: Optional[str] = None
start: Optional[str] = None
round_name: Optional[str] = None
season_name: Optional[str] = None
@dataclass
class WithPeopleLogData(JSONDataclass):
with_people_ids: Optional[list[int]] = None

View File

@ -145,8 +145,8 @@ class NotesDictField(forms.Field):
if isinstance(value, str):
if value.strip():
from datetime import datetime
return {datetime.now().isoformat(): value.strip()}
from scrobbles.utils import make_note_timestamp
return {make_note_timestamp(): value.strip()}
return {}
if isinstance(value, dict):

View File

@ -76,6 +76,7 @@ class LastFM:
in_progress=False,
media_type=Scrobble.MediaType.TRACK,
timezone=tz_timestamp.tzinfo.name,
visibility="private",
)
# Vrobbler scrobbles on finish, LastFM scrobbles on start
seconds_eariler = timestamp - timedelta(seconds=20)

View File

@ -5,6 +5,7 @@ from datetime import datetime, timedelta
from django.contrib.auth import get_user_model
from scrobbles.models import Scrobble
from scrobbles.notifications import ScrobbleNtfyNotification
from tasks.models import Task
logger = logging.getLogger(__name__)
@ -85,6 +86,11 @@ def import_scale_csv(file_path, user_id):
log_dict["unit_type"] = user.profile.weigh_in_units
weight_val = log_dict.get("weight")
if weight_val is not None:
unit = "kg" if user.profile.weigh_in_units == "metric" else "lbs"
log_dict["title"] = f"{weight_val} {unit}"
existing = Scrobble.objects.filter(
timestamp=start,
task=weigh_in,
@ -105,9 +111,12 @@ def import_scale_csv(file_path, user_id):
played_to_completion=True,
in_progress=False,
media_type=Scrobble.MediaType.TASK,
visibility="private",
)
new_scrobbles.append(new_scrobble)
created = Scrobble.objects.bulk_create(new_scrobbles)
logger.info(f"Created {len(created)} weigh-in scrobbles")
for scrobble in created:
ScrobbleNtfyNotification(scrobble).send()
return created

View File

@ -12,6 +12,7 @@ from django.core.files import File
from locations.models import GeoLocation
from scrobbles.models import Scrobble
from scrobbles.notifications import ScrobbleNtfyNotification
from trails.models import Trail, TrailLogData
logger = logging.getLogger(__name__)
@ -304,6 +305,7 @@ def import_trail_gpx(file_path, user_id, original_filename=None):
played_to_completion=True,
in_progress=False,
media_type=Scrobble.MediaType.TRAIL,
visibility="private",
)
_, ext = os.path.splitext(file_path)
@ -328,4 +330,6 @@ def import_trail_gpx(file_path, user_id, original_filename=None):
created = Scrobble.objects.bulk_create(new_scrobbles)
logger.info(f"Created {len(created)} trail scrobbles")
for scrobble in created:
ScrobbleNtfyNotification(scrobble).send()
return created

View File

@ -80,6 +80,7 @@ def import_audioscrobbler_tsv_file(file_path, user_id):
in_progress=False,
media_type=Scrobble.MediaType.TRACK,
timezone=timestamp.tzinfo.name,
visibility="private",
)
existing = Scrobble.objects.filter(
timestamp=timestamp, track=track, user=user

View File

@ -0,0 +1,68 @@
from django.core.management.base import BaseCommand
from django.db import models
from scrobbles.models import Scrobble
from scrobbles.utils import analyze_scrobble_sentiment
class Command(BaseCommand):
help = "Backfill VADER sentiment analysis for scrobbles with notes"
def add_arguments(self, parser):
parser.add_argument(
"--commit",
action="store_true",
help="Actually update scrobble logs with sentiment data",
)
parser.add_argument(
"--overwrite",
action="store_true",
help="Re-analyze scrobbles that already have sentiment data",
)
def handle(self, *args, **options):
commit = options["commit"]
overwrite = options["overwrite"]
qs = Scrobble.objects.filter(
~models.Q(log__notes__isnull=True)
& ~models.Q(log__notes=[])
& ~models.Q(log__notes={})
)
if not overwrite:
qs = qs.filter(log__sentiment__isnull=True)
total = qs.count()
analyzed_count = 0
skipped_count = 0
self.stdout.write(f"Found {total} scrobbles to process")
for scrobble in qs.iterator():
if commit:
analyzed = analyze_scrobble_sentiment(scrobble, overwrite=overwrite)
else:
notes_str = ""
if scrobble.logdata:
notes_str = scrobble.logdata.notes_as_str()
analyzed = bool(notes_str)
if analyzed:
analyzed_count += 1
if commit:
scores = (scrobble.log or {}).get("sentiment", {})
self.stdout.write(
f" Updated scrobble {scrobble.id}: compound={scores.get('compound', 'N/A')}"
)
else:
self.stdout.write(
f" [DRY RUN] Would analyze scrobble {scrobble.id}"
)
else:
skipped_count += 1
self.stdout.write(
f"\nAnalyzed {analyzed_count} scrobbles, skipped {skipped_count}"
)
if not commit:
self.stdout.write("Run with --commit to persist changes")

View File

@ -0,0 +1,287 @@
# Generated by Django 4.2.30 on 2026-06-05 14:55
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django_extensions.db.fields
import uuid
class Migration(migrations.Migration):
dependencies = [
("boardgames", "0015_alter_boardgame_genre"),
("trails", "0009_trail_route_waypoint"),
("moods", "0008_alter_mood_genre"),
("birds", "0002_birdingcsvimport"),
("bricksets", "0005_alter_brickset_genre"),
("music", "0036_artist_similar_artists"),
("puzzles", "0006_alter_puzzle_genre"),
("videogames", "0015_alter_videogame_genre"),
("lifeevents", "0005_alter_lifeevent_genre"),
("beers", "0008_alter_beer_genre"),
("foods", "0007_alter_food_genre"),
("tasks", "0007_alter_task_genre"),
("books", "0036_alter_book_genre_alter_paper_genre"),
("videos", "0030_alter_channel_genre_alter_series_genre_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("podcasts", "0020_alter_podcast_genre_alter_podcastepisode_genre"),
("webpages", "0009_alter_webpage_genre"),
("sports", "0018_alter_sportevent_genre"),
("locations", "0010_clean_start"),
("scrobbles", "0088_scalecsvimport_file_hash"),
]
operations = [
migrations.CreateModel(
name="FavoriteMedia",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created",
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name="created"
),
),
(
"modified",
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name="modified"
),
),
(
"uuid",
models.UUIDField(
blank=True, default=uuid.uuid4, editable=False, null=True
),
),
(
"media_type",
models.CharField(
choices=[
("Video", "Video"),
("Track", "Track"),
("PodcastEpisode", "Podcast episode"),
("SportEvent", "Sport event"),
("Book", "Book"),
("Paper", "Paper"),
("VideoGame", "Video game"),
("BoardGame", "Board game"),
("GeoLocation", "GeoLocation"),
("Trail", "Trail"),
("Beer", "Beer"),
("Puzzle", "Puzzle"),
("Food", "Food"),
("Task", "Task"),
("WebPage", "Web Page"),
("LifeEvent", "Life event"),
("Mood", "Mood"),
("BrickSet", "Brick set"),
("Channel", "Channel"),
("BirdingLocation", "Birding location"),
],
max_length=20,
),
),
("sent_to_mopidy", models.BooleanField(default=False)),
(
"beer",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="beers.beer",
),
),
(
"birding_location",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="birds.birdinglocation",
),
),
(
"board_game",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="boardgames.boardgame",
),
),
(
"book",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="books.book",
),
),
(
"brick_set",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="bricksets.brickset",
),
),
(
"channel",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="videos.channel",
),
),
(
"food",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="foods.food",
),
),
(
"geo_location",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="locations.geolocation",
),
),
(
"life_event",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="lifeevents.lifeevent",
),
),
(
"mood",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="moods.mood",
),
),
(
"paper",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="books.paper",
),
),
(
"podcast_episode",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="podcasts.podcastepisode",
),
),
(
"puzzle",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="puzzles.puzzle",
),
),
(
"sport_event",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="sports.sportevent",
),
),
(
"task",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="tasks.task",
),
),
(
"track",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="music.track",
),
),
(
"trail",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="trails.trail",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
(
"video",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="videos.video",
),
),
(
"video_game",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="videogames.videogame",
),
),
(
"web_page",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="webpages.webpage",
),
),
],
options={
"ordering": ["-created"],
},
),
]

View File

@ -0,0 +1,53 @@
# Generated by Django 4.2.29 on 2026-06-08 14:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0089_favoritemedia"),
]
operations = [
migrations.AddField(
model_name="audioscrobblertsvimport",
name="error_log",
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name="bgstatsimport",
name="error_log",
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name="ebirdcsvimport",
name="error_log",
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name="koreaderimport",
name="error_log",
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name="lastfmimport",
name="error_log",
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name="retroarchimport",
name="error_log",
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name="scalecsvimport",
name="error_log",
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name="trailgpximport",
name="error_log",
field=models.TextField(blank=True, null=True),
),
]

View File

@ -0,0 +1,32 @@
# Generated by Django 4.2.29 on 2026-06-09 15:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0090_audioscrobblertsvimport_error_log_and_more"),
]
operations = [
migrations.AddField(
model_name="scrobble",
name="share_token",
field=models.UUIDField(blank=True, editable=False, null=True, unique=True),
),
migrations.AddField(
model_name="scrobble",
name="visibility",
field=models.CharField(
choices=[
("public", "Public"),
("shared", "Shared"),
("private", "Private"),
],
db_index=True,
default="shared",
max_length=10,
),
),
]

View File

@ -0,0 +1,29 @@
from uuid import uuid4
from django.db import migrations
def backfill_share_token(apps, schema_editor):
Scrobble = apps.get_model("scrobbles", "Scrobble")
batch = []
for scrobble in Scrobble.objects.filter(share_token__isnull=True).iterator(
chunk_size=500
):
scrobble.share_token = uuid4()
batch.append(scrobble)
if batch:
Scrobble.objects.bulk_update(batch, ["share_token"], batch_size=500)
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0091_scrobble_share_token_scrobble_visibility"),
]
operations = [
migrations.RunPython(
backfill_share_token,
reverse_code=migrations.RunPython.noop,
),
]

View File

@ -0,0 +1,22 @@
# Generated by Django 4.2.29 on 2026-06-09 16:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0092_backfill_visibility_and_share_token"),
]
operations = [
migrations.RemoveField(
model_name="scrobble",
name="share_token",
),
migrations.AddField(
model_name="scrobble",
name="share_token_version",
field=models.PositiveIntegerField(default=0),
),
]

View File

@ -0,0 +1,75 @@
# Generated by Django 4.2.29 on 2026-06-09 16:24
from django.db import migrations, models
import django.db.models.deletion
import django_extensions.db.fields
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0093_remove_scrobble_share_token_and_more"),
]
operations = [
migrations.AddField(
model_name="scrobble",
name="share_view_count",
field=models.PositiveIntegerField(default=0),
),
migrations.AlterField(
model_name="scrobble",
name="visibility",
field=models.CharField(
choices=[
("public", "Public"),
("shared", "Shared"),
("private", "Private"),
],
db_index=True,
default="private",
max_length=10,
),
),
migrations.CreateModel(
name="ShareViewLog",
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"
),
),
("ip_address", models.GenericIPAddressField(blank=True, null=True)),
("user_agent", models.TextField(blank=True, null=True)),
("referrer", models.URLField(blank=True, max_length=2048, null=True)),
(
"scrobble",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="share_views",
to="scrobbles.scrobble",
),
),
],
options={
"get_latest_by": "modified",
"abstract": False,
},
),
]

View File

@ -0,0 +1,20 @@
from django.db import migrations
def backfill_null_visibility(apps, schema_editor):
Scrobble = apps.get_model("scrobbles", "Scrobble")
Scrobble.objects.filter(visibility__isnull=True).update(visibility="private")
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0094_scrobble_share_view_count_alter_scrobble_visibility_and_more"),
]
operations = [
migrations.RunPython(
backfill_null_visibility,
reverse_code=migrations.RunPython.noop,
),
]

View File

@ -65,6 +65,9 @@ class ScrobblableMixin(TimeStampedModel):
class Meta:
abstract = True
def __str__(self) -> str:
return f"{self.title} - {self.subtitle}"
@property
def run_time_seconds(self) -> int:
run_time = 900

View File

@ -18,6 +18,8 @@ from bricksets.models import BrickSet
from charts.utils import build_charts
from dataclass_wizard.errors import ParseError
from django.conf import settings
from scrobbles.constants import Visibility
from scrobbles.sqids import encode_scrobble_share
from django.contrib.auth import get_user_model
from django.core.files import File
from django.db import models
@ -74,6 +76,7 @@ class BaseFileImportMixin(TimeStampedModel):
processed_finished = models.DateTimeField(**BNULL)
process_log = models.TextField(**BNULL)
process_count = models.IntegerField(**BNULL)
error_log = models.TextField(**BNULL)
class Meta:
abstract = True
@ -158,6 +161,14 @@ class BaseFileImportMixin(TimeStampedModel):
self.process_count = len(scrobbles)
self.save(update_fields=["process_log", "process_count"])
def record_error(self, error_message):
log_line = f"{timezone.now().isoformat()}: {error_message}"
if self.error_log:
self.error_log += "\n" + log_line
else:
self.error_log = log_line
self.save(update_fields=["error_log"])
@property
def upload_file_path(self):
raise NotImplementedError
@ -213,9 +224,14 @@ class KoReaderImport(BaseFileImportMixin):
return
self.mark_started()
scrobbles = process_koreader_sqlite_file(self.upload_file_path, self.user.id)
self.record_log(scrobbles)
self.mark_finished()
try:
scrobbles = process_koreader_sqlite_file(self.upload_file_path, self.user.id)
self.record_log(scrobbles)
except Exception as e:
self.record_error(f"Import failed: {e}")
logger.exception(f"Import failed for {self}")
finally:
self.mark_finished()
class AudioScrobblerTSVImport(BaseFileImportMixin):
@ -255,10 +271,14 @@ class AudioScrobblerTSVImport(BaseFileImportMixin):
return
self.mark_started()
scrobbles = import_audioscrobbler_tsv_file(self.upload_file_path, self.user.id)
self.record_log(scrobbles)
self.mark_finished()
try:
scrobbles = import_audioscrobbler_tsv_file(self.upload_file_path, self.user.id)
self.record_log(scrobbles)
except Exception as e:
self.record_error(f"Import failed: {e}")
logger.exception(f"Import failed for {self}")
finally:
self.mark_finished()
class ScaleCSVImport(BaseFileImportMixin):
@ -297,9 +317,14 @@ class ScaleCSVImport(BaseFileImportMixin):
return
self.mark_started()
scrobbles = import_scale_csv(self.upload_file_path, self.user.id)
self.record_log(scrobbles)
self.mark_finished()
try:
scrobbles = import_scale_csv(self.upload_file_path, self.user.id)
self.record_log(scrobbles)
except Exception as e:
self.record_error(f"Import failed: {e}")
logger.exception(f"Import failed for {self}")
finally:
self.mark_finished()
class TrailGPXImport(BaseFileImportMixin):
@ -337,11 +362,16 @@ class TrailGPXImport(BaseFileImportMixin):
return
self.mark_started()
scrobbles = import_trail_gpx(
self.upload_file_path, self.user.id, self.original_filename
)
self.record_log(scrobbles)
self.mark_finished()
try:
scrobbles = import_trail_gpx(
self.upload_file_path, self.user.id, self.original_filename
)
self.record_log(scrobbles)
except Exception as e:
self.record_error(f"Import failed: {e}")
logger.exception(f"Import failed for {self}")
finally:
self.mark_finished()
class LastFmImport(BaseFileImportMixin):
@ -391,11 +421,14 @@ class LastFmImport(BaseFileImportMixin):
last_processed = last_import.processed_finished
self.mark_started()
scrobbles = lastfm.import_from_lastfm(last_processed, time_to=time_to)
self.record_log(scrobbles)
self.mark_finished()
try:
scrobbles = lastfm.import_from_lastfm(last_processed, time_to=time_to)
self.record_log(scrobbles)
except Exception as e:
self.record_error(f"Import failed: {e}")
logger.exception(f"Import failed for {self}")
finally:
self.mark_finished()
class RetroarchImport(BaseFileImportMixin):
@ -426,43 +459,48 @@ class RetroarchImport(BaseFileImportMixin):
logger.info(f"You told me to force import from Retroarch")
self.mark_started()
scrobbles = None
try:
if self.lrtl_file:
import os
import tempfile
import zipfile
if self.lrtl_file:
import os
import tempfile
import zipfile
tmpdir = tempfile.mkdtemp()
try:
zip_path = os.path.join(tmpdir, "archive.zip")
with open(zip_path, "wb") as f:
f.write(self.lrtl_file.read())
with zipfile.ZipFile(zip_path, "r") as zf:
zf.extractall(tmpdir)
os.unlink(zip_path)
scrobbles = retroarch.import_retroarch_lrtl_files(
tmpdir + "/",
self.user.id,
)
finally:
import shutil
shutil.rmtree(tmpdir, ignore_errors=True)
else:
if not self.user.profile.retroarch_path:
logger.info(
"Trying to import Retroarch logs, but user has no retroarch_path configured"
)
self.mark_finished()
return
tmpdir = tempfile.mkdtemp()
try:
zip_path = os.path.join(tmpdir, "archive.zip")
with open(zip_path, "wb") as f:
f.write(self.lrtl_file.read())
with zipfile.ZipFile(zip_path, "r") as zf:
zf.extractall(tmpdir)
os.unlink(zip_path)
scrobbles = retroarch.import_retroarch_lrtl_files(
tmpdir + "/",
self.user.profile.retroarch_path,
self.user.id,
)
finally:
import shutil
shutil.rmtree(tmpdir, ignore_errors=True)
else:
if not self.user.profile.retroarch_path:
logger.info(
"Tying to import Retroarch logs, but user has no retroarch_path configured"
)
self.mark_finished()
return
scrobbles = retroarch.import_retroarch_lrtl_files(
self.user.profile.retroarch_path,
self.user.id,
)
self.record_log(scrobbles)
self.mark_finished()
self.record_log(scrobbles)
except Exception as e:
self.record_error(f"Import failed: {e}")
logger.exception(f"Import failed for {self}")
finally:
self.mark_finished()
class BGStatsImport(BaseFileImportMixin):
@ -499,17 +537,21 @@ class BGStatsImport(BaseFileImportMixin):
return
self.mark_started()
try:
import json
import json
from scrobbles.scrobblers import email_scrobble_board_game
from scrobbles.scrobblers import email_scrobble_board_game
with open(self.upload_file_path, "r", encoding="utf-8") as f:
parsed_json = json.load(f)
scrobbles = email_scrobble_board_game(parsed_json, self.user_id)
with open(self.upload_file_path, "r", encoding="utf-8") as f:
parsed_json = json.load(f)
scrobbles = email_scrobble_board_game(parsed_json, self.user_id)
self.record_log(scrobbles)
self.mark_finished()
self.record_log(scrobbles)
except Exception as e:
self.record_error(f"Import failed: {e}")
logger.exception(f"Import failed for {self}")
finally:
self.mark_finished()
class EBirdCSVImport(BaseFileImportMixin):
@ -554,9 +596,16 @@ class EBirdCSVImport(BaseFileImportMixin):
return
self.mark_started()
scrobbles = import_birding_csv(self.upload_file_path, self.user_id)
self.record_log(scrobbles)
self.mark_finished()
try:
scrobbles = import_birding_csv(
self.upload_file_path, self.user_id, record_error=self.record_error
)
self.record_log(scrobbles)
except Exception as e:
self.record_error(f"Import failed: {e}")
logger.exception(f"Import failed for {self}")
finally:
self.mark_finished()
class ScrobbleQuerySet(models.QuerySet):
@ -586,6 +635,18 @@ class ScrobbleQuerySet(models.QuerySet):
)
class ShareViewLog(TimeStampedModel):
scrobble = models.ForeignKey(
"Scrobble", on_delete=models.CASCADE, related_name="share_views"
)
ip_address = models.GenericIPAddressField(**BNULL)
user_agent = models.TextField(**BNULL)
referrer = models.URLField(max_length=2048, **BNULL)
def __str__(self):
return f"View of {self.scrobble} at {self.created}"
class Scrobble(TimeStampedModel):
"""A scrobble tracks played media items by a user."""
@ -647,6 +708,14 @@ class Scrobble(TimeStampedModel):
media_type = models.CharField(
max_length=20, choices=MediaType.choices, default=MediaType.VIDEO
)
visibility = models.CharField(
max_length=10,
choices=Visibility.choices,
default=Visibility.PRIVATE,
db_index=True,
)
share_token_version = models.PositiveIntegerField(default=0)
share_view_count = models.PositiveIntegerField(default=0)
user = models.ForeignKey(User, blank=True, null=True, on_delete=models.DO_NOTHING)
# Time keeping
@ -828,6 +897,16 @@ class Scrobble(TimeStampedModel):
self.save(update_fields=["uuid"])
return reverse("scrobbles:detail", kwargs={"uuid": self.uuid})
def get_share_url(self):
if self.visibility == Visibility.PRIVATE:
return None
sqid = encode_scrobble_share(self.id, self.share_token_version)
return reverse("scrobbles:shared-detail", kwargs={"sqid": sqid})
def regenerate_share_token(self):
self.share_token_version += 1
self.save(update_fields=["share_token_version"])
def push_to_archivebox(self):
pushable_media = hasattr(self.media_obj, "push_to_archivebox") and callable(
self.media_obj.push_to_archivebox
@ -882,8 +961,13 @@ class Scrobble(TimeStampedModel):
logger.warning("Log data could not be loaded", e)
return logdata_cls()
# Strip log-only keys (stored in JSONField but not part of LogData dataclass)
logdata_kwargs = {
k: v for k, v in log_dict.items() if k in logdata_cls().__dataclass_fields__
}
try:
return logdata_cls(**log_dict)
return logdata_cls(**logdata_kwargs)
except ParseError as e:
logger.warning(
"Could not parse log data",
@ -1528,8 +1612,25 @@ class Scrobble(TimeStampedModel):
cls,
scrobble_data: dict,
) -> "Scrobble":
if "visibility" not in scrobble_data:
user = scrobble_data.get("user")
media_type = scrobble_data.get("media_type")
override = None
if user and media_type:
try:
profile = user.profile
overrides = profile.media_type_visibility or {}
override = overrides.get(media_type)
except user.__class__.profile.RelatedObjectDoesNotExist:
pass
scrobble_data["visibility"] = override or Visibility.PRIVATE
scrobble = cls.objects.create(**scrobble_data)
ScrobbleNtfyNotification(scrobble).send()
if not (
scrobble.media_type == cls.MediaType.GEO_LOCATION
and scrobble.media_obj
and not scrobble.media_obj.title
):
ScrobbleNtfyNotification(scrobble).send()
return scrobble
def stop(self, timestamp=None, force_finish=False) -> None:
@ -1714,3 +1815,131 @@ class Scrobble(TimeStampedModel):
if commit and "pages_read" in self.log:
self.save(update_fields=["log"])
class FavoriteMedia(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
user = models.ForeignKey(User, on_delete=models.CASCADE)
video = models.ForeignKey(Video, on_delete=models.CASCADE, **BNULL)
channel = models.ForeignKey("videos.Channel", on_delete=models.CASCADE, **BNULL)
track = models.ForeignKey(Track, on_delete=models.CASCADE, **BNULL)
podcast_episode = models.ForeignKey(
PodcastEpisode, on_delete=models.CASCADE, **BNULL
)
sport_event = models.ForeignKey(SportEvent, on_delete=models.CASCADE, **BNULL)
book = models.ForeignKey(Book, on_delete=models.CASCADE, **BNULL)
paper = models.ForeignKey(Paper, on_delete=models.CASCADE, **BNULL)
video_game = models.ForeignKey(VideoGame, on_delete=models.CASCADE, **BNULL)
board_game = models.ForeignKey(BoardGame, on_delete=models.CASCADE, **BNULL)
geo_location = models.ForeignKey(GeoLocation, on_delete=models.CASCADE, **BNULL)
beer = models.ForeignKey(Beer, on_delete=models.CASCADE, **BNULL)
puzzle = models.ForeignKey(Puzzle, on_delete=models.CASCADE, **BNULL)
food = models.ForeignKey(Food, on_delete=models.CASCADE, **BNULL)
trail = models.ForeignKey(Trail, on_delete=models.CASCADE, **BNULL)
task = models.ForeignKey(Task, on_delete=models.CASCADE, **BNULL)
web_page = models.ForeignKey(WebPage, on_delete=models.CASCADE, **BNULL)
life_event = models.ForeignKey(LifeEvent, on_delete=models.CASCADE, **BNULL)
mood = models.ForeignKey(Mood, on_delete=models.CASCADE, **BNULL)
brick_set = models.ForeignKey(BrickSet, on_delete=models.CASCADE, **BNULL)
birding_location = models.ForeignKey(
BirdingLocation, on_delete=models.CASCADE, **BNULL
)
media_type = models.CharField(
max_length=20, choices=Scrobble.MediaType.choices
)
sent_to_mopidy = models.BooleanField(default=False)
class Meta:
ordering = ["-created"]
def __str__(self):
return f"{self.user} favorites {self.media_obj}"
@property
def media_obj(self):
media_obj = None
if self.video:
media_obj = self.video
if self.track:
media_obj = self.track
if self.podcast_episode:
media_obj = self.podcast_episode
if self.sport_event:
media_obj = self.sport_event
if self.book:
media_obj = self.book
if self.video_game:
media_obj = self.video_game
if self.board_game:
media_obj = self.board_game
if self.geo_location:
media_obj = self.geo_location
if self.web_page:
media_obj = self.web_page
if self.life_event:
media_obj = self.life_event
if self.mood:
media_obj = self.mood
if self.brick_set:
media_obj = self.brick_set
if self.trail:
media_obj = self.trail
if self.beer:
media_obj = self.beer
if self.puzzle:
media_obj = self.puzzle
if self.task:
media_obj = self.task
if self.food:
media_obj = self.food
if self.channel:
media_obj = self.channel
if self.birding_location:
media_obj = self.birding_location
return media_obj
@classmethod
def toggle(cls, media_obj, user):
media_type = media_obj.__class__.__name__
if media_type not in Scrobble.MediaType.list():
raise ValueError(f"Unknown media type: {media_type}")
fk_map = {
"Video": "video",
"Channel": "channel",
"Track": "track",
"PodcastEpisode": "podcast_episode",
"SportEvent": "sport_event",
"Book": "book",
"Paper": "paper",
"VideoGame": "video_game",
"BoardGame": "board_game",
"GeoLocation": "geo_location",
"Beer": "beer",
"Puzzle": "puzzle",
"Food": "food",
"Trail": "trail",
"Task": "task",
"WebPage": "web_page",
"LifeEvent": "life_event",
"Mood": "mood",
"BrickSet": "brick_set",
"BirdingLocation": "birding_location",
}
fk = fk_map.get(media_type)
if not fk:
raise ValueError(f"No FK mapping for media type: {media_type}")
existing = cls.objects.filter(user=user, **{fk: media_obj}).first()
if existing:
existing.delete()
return None
return cls.objects.create(
user=user,
media_type=media_type,
**{fk: media_obj},
)

View File

@ -35,6 +35,7 @@ from scrobbles.notifications import ScrobbleNtfyNotification
from scrobbles.utils import (
convert_to_seconds,
extract_domain,
make_note_timestamp,
next_url_if_exists,
remove_last_part,
)
@ -241,12 +242,13 @@ def manual_scrobble_event(
):
data_dict = lookup_event_from_thesportsdb(thesportsdb_id)
event = SportEvent.find_or_create(data_dict)
event, logdata = SportEvent.find_or_create(data_dict)
scrobble_dict = {
"user_id": user_id,
"timestamp": timezone.now(),
"playback_position_seconds": 0,
"source": "TheSportsDB",
"log": logdata,
}
return Scrobble.create_or_update(event, user_id, scrobble_dict)
@ -528,7 +530,7 @@ def email_scrobble_board_game(
stop_timestamp = timestamp + timedelta(seconds=duration_seconds)
if comments:
log_data["notes"] = {str(int(stop_timestamp.timestamp())): comments}
log_data["notes"] = {make_note_timestamp(stop_timestamp): comments}
logger.info(f"Creating scrobble for {base_game} at {timestamp}")
log_data["raw_data"] = bgstat_data
@ -561,7 +563,6 @@ def email_scrobble_board_game(
scrobble.played_to_completion = True
scrobble.save()
scrobbles_created.append(scrobble)
ScrobbleNtfyNotification(scrobble).send()
return scrobbles_created
@ -688,7 +689,7 @@ def todoist_scrobble_update_task(
)
return
timestamp = todoist_note.get("posted_at") or str(int(timezone.now().timestamp()))
timestamp = todoist_note.get("posted_at") or make_note_timestamp()
if not scrobble.log.get("notes"):
scrobble.log["notes"] = {}
scrobble.log["notes"][timestamp] = todoist_note.get("notes")
@ -761,7 +762,10 @@ def todoist_scrobble_task(
todoist_task["title"] = todoist_task.pop("description")
todoist_task["description"] = todoist_task.pop("details")
todoist_task["labels"] = todoist_task.pop("todoist_label_list", [])
labels = todoist_task.pop("todoist_label_list", [])
todoist_task["labels"] = [
l for l in labels if l.lower() != "inprogress"
]
todoist_task.pop("todoist_type")
todoist_task.pop("todoist_event")
@ -786,12 +790,37 @@ def todoist_scrobble_task(
return scrobble
ORG_HEADING_RE = re.compile(r"^(\*+\s+.*)$", re.MULTILINE)
NOTE_CONTENT_SPLIT = re.compile(r"^\*{3,}\s", re.MULTILINE)
def _truncate_at_org_header(text: str) -> str:
"""Truncate text at the first org-mode heading (*** or more)."""
parts = NOTE_CONTENT_SPLIT.split(text, maxsplit=1)
return parts[0].strip()
def _extract_org_section(body: str | None, heading: str) -> str | None:
"""Extract content under a specific org-mode sub-heading (e.g. '*** Description')."""
if not body:
return None
sections = ORG_HEADING_RE.split(body)
# sections alternates: [prefix, heading1, content1, heading2, content2, ...]
for i, section in enumerate(sections):
if section.strip().startswith(heading):
if i + 1 < len(sections):
return sections[i + 1].strip()
return ""
return None
def emacs_scrobble_update_task(
emacs_id: str,
emacs_notes: list,
user_id: int,
description: Optional[str] = None,
) -> Optional[Scrobble]:
description = _extract_org_section(description, "*** Description")
scrobble = Scrobble.objects.filter(
in_progress=True,
user_id=user_id,
@ -818,6 +847,11 @@ def emacs_scrobble_update_task(
for note in emacs_notes:
timestamp = note.get("timestamp")
content = note.get("content")
if not content:
continue
content = _truncate_at_org_header(content)
if not content:
continue
if timestamp:
existing = scrobble.log["notes"].get(timestamp)
if existing != content:
@ -896,7 +930,7 @@ def emacs_scrobble_task(
task_data.pop("notes", None)
task_data["title"] = task_data.pop("description")
task_data["description"] = task_data.pop("body")
task_data["description"] = _extract_org_section(task_data.pop("body"), "*** Description")
task_data["labels"] = task_data.pop("labels")
task_data["orgmode_id"] = task_data.pop("source_id")

View File

@ -4,8 +4,14 @@ from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
from django.utils import timezone
from scrobbles.models import Scrobble
from scrobbles.tasks import CHARTABLE_MEDIA_TYPES, SCROBBLES_WITHOUT_CHARTS, update_charts_for_timestamp
from scrobbles.models import FavoriteMedia, Scrobble
from scrobbles.tasks import (
add_favorite_to_mopidy_playlist,
CHARTABLE_MEDIA_TYPES,
remove_favorite_from_mopidy_playlist,
SCROBBLES_WITHOUT_CHARTS,
update_charts_for_timestamp,
)
from scrobbles.utils import tokenize_title_to_tags
logger = logging.getLogger(__name__)
@ -72,3 +78,28 @@ def add_tags_from_task_title(sender, instance, **kwargs):
for tag in new_tags:
if tag not in existing_tags:
instance.tags.add(tag)
@receiver(post_save, sender=FavoriteMedia)
def add_to_mopidy_playlist_on_favorite(sender, instance, created, **kwargs):
if not created:
return
if instance.media_type != Scrobble.MediaType.TRACK:
return
if not instance.track:
return
add_favorite_to_mopidy_playlist.delay(instance.id)
@receiver(post_delete, sender=FavoriteMedia)
def remove_from_mopidy_playlist_on_unfavorite(sender, instance, **kwargs):
if instance.media_type != Scrobble.MediaType.TRACK:
return
if not instance.track_id:
return
remove_favorite_from_mopidy_playlist.delay(
user_id=instance.user_id,
track_id=instance.track_id,
)

View File

@ -0,0 +1,36 @@
from sqids import Sqids
_sqids = None
def _make_alphabet() -> str:
import hashlib
from django.conf import settings
digest = hashlib.sha256(settings.SECRET_KEY.encode()).hexdigest()
base = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
seed = int(digest[:16], 16)
shuffled = list(base)
for i in range(len(shuffled) - 1, 0, -1):
seed = (seed * 1103515245 + 12345) & 0x7FFFFFFF
j = seed % (i + 1)
shuffled[i], shuffled[j] = shuffled[j], shuffled[i]
return "".join(shuffled)
def get_sqids() -> Sqids:
global _sqids
if _sqids is None:
_sqids = Sqids(
alphabet=_make_alphabet(),
min_length=6,
)
return _sqids
def encode_scrobble_share(scrobble_id: int, version: int) -> str:
return get_sqids().encode([scrobble_id, version])
def decode_scrobble_share(sqid: str) -> list[int] | None:
return get_sqids().decode(sqid)

View File

@ -12,6 +12,7 @@ from charts.utils import (
from django.apps import apps
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import models
from django.utils import timezone
logger = logging.getLogger(__name__)
@ -542,3 +543,149 @@ def send_mood_checkin():
from vrobbler.apps.scrobbles.utils import send_mood_checkin_reminders
send_mood_checkin_reminders()
@shared_task
def backfill_scrobble_sentiment():
"""Backfill VADER sentiment for scrobbles with notes (replaces @hourly cron)."""
from scrobbles.models import Scrobble
from scrobbles.utils import analyze_scrobble_sentiment
qs = Scrobble.objects.filter(
models.Q(log__notes__isnull=False)
& ~models.Q(log__notes=[])
& ~models.Q(log__notes={})
& models.Q(log__sentiment__isnull=True)
)
count = 0
for scrobble in qs.iterator():
if analyze_scrobble_sentiment(scrobble):
count += 1
logger.info(
"Backfilled sentiment for %d scrobbles",
count,
)
@shared_task
def add_favorite_to_mopidy_playlist(favorite_id):
from scrobbles.models import FavoriteMedia
from scrobbles.utils import add_track_to_mopidy_favorites_playlist
favorite = FavoriteMedia.objects.filter(id=favorite_id).first()
if not favorite:
return
add_track_to_mopidy_favorites_playlist(favorite)
@shared_task
def remove_favorite_from_mopidy_playlist(user_id, track_id):
from music.models import Track
from scrobbles.utils import remove_track_from_mopidy_favorites_playlist
User = get_user_model()
try:
user = User.objects.get(id=user_id)
track = Track.objects.get(id=track_id)
except (User.DoesNotExist, Track.DoesNotExist):
return
import types
proxy = types.SimpleNamespace(
media_type="Track",
user=user,
user_id=user.id,
track=track,
)
remove_track_from_mopidy_favorites_playlist(proxy)
@shared_task
def add_scrobble_to_mopidy_queue(scrobble_id):
from scrobbles.models import Scrobble
scrobble = Scrobble.objects.filter(id=scrobble_id).first()
if not scrobble:
return
profile = scrobble.user.profile
mopidy_url = profile.mopidy_api_url
if not mopidy_url:
return
mopidy_uri = (scrobble.log or {}).get("raw_data", {}).get("mopidy_uri")
track = scrobble.track if scrobble.media_type == "Track" else None
if not mopidy_uri and track:
sibling = (
Scrobble.objects.filter(track=track, user=scrobble.user)
.order_by("-timestamp")
.iterator()
)
for s in sibling:
uri = (s.log or {}).get("raw_data", {}).get("mopidy_uri")
if uri:
mopidy_uri = uri
break
if not mopidy_uri:
logger.warning(
"No Mopidy URI found for scrobble",
extra={"scrobble_id": scrobble_id, "user_id": scrobble.user_id},
)
return
import requests
rpc_url = mopidy_url.rstrip("/") + "/rpc"
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": "core.tracklist.add",
"params": {"uris": [mopidy_uri]},
}
try:
resp = requests.post(rpc_url, json=payload, timeout=10)
resp.raise_for_status()
rpc_result = resp.json()
if rpc_result.get("error"):
logger.error(
"Mopidy error adding to queue",
extra={"error": rpc_result["error"], "scrobble_id": scrobble_id},
)
else:
logger.info(
"Added track to Mopidy queue",
extra={"scrobble_id": scrobble_id, "mopidy_uri": mopidy_uri},
)
except requests.RequestException as e:
logger.error(
"Failed to add track to Mopidy queue",
extra={"scrobble_id": scrobble_id, "error": str(e)},
)
@shared_task
def add_scrobble_to_mopidy_monthly_playlist(scrobble_id):
from scrobbles.models import Scrobble
from scrobbles.utils import add_track_to_mopidy_monthly_playlist
scrobble = Scrobble.objects.filter(id=scrobble_id).first()
if not scrobble:
return
track = scrobble.track if scrobble.media_type == "Track" else None
if track:
sibling = (
Scrobble.objects.filter(track=track, user=scrobble.user)
.order_by("-timestamp")
.iterator()
)
for s in sibling:
if (s.log or {}).get("raw_data", {}).get("mopidy_uri"):
scrobble = s
break
add_track_to_mopidy_monthly_playlist(scrobble)

View File

@ -153,12 +153,48 @@ urlpatterns = [
name="long-plays",
),
path("scrobbles/", views.ScrobbleListView.as_view(), name="scrobble-list"),
path("explore/", views.ScrobbleExploreView.as_view(), name="explore"),
path(
"shared/<str:sqid>/",
views.ScrobbleShareView.as_view(),
name="shared-detail",
),
path(
"scrobbles/<slug:uuid>/",
views.ScrobbleDetailView.as_view(),
name="detail",
),
path(
"scrobbles/<slug:uuid>/regenerate-share-token/",
views.RegenerateShareTokenView.as_view(),
name="regenerate-share-token",
),
path(
"scrobbles/<slug:uuid>/change-visibility/",
views.ChangeVisibilityView.as_view(),
name="change-visibility",
),
path(
"scrobbles/<slug:uuid>/share-analytics/",
views.ScrobbleShareAnalyticsView.as_view(),
name="share-analytics",
),
path(
"scrobbles/<slug:uuid>/add-to-mopidy-queue/",
views.add_to_mopidy_queue,
name="add-to-mopidy-queue",
),
path(
"scrobbles/<slug:uuid>/add-to-mopidy-monthly-playlist/",
views.add_to_mopidy_monthly_playlist,
name="add-to-mopidy-monthly-playlist",
),
path("scrobbles/<slug:uuid>/start/", views.scrobble_start, name="start"),
path("scrobbles/<slug:uuid>/finish/", views.scrobble_finish, name="finish"),
path("scrobbles/<slug:uuid>/cancel/", views.scrobble_cancel, name="cancel"),
path(
"favorite/<str:media_type>/<int:object_id>/toggle/",
views.toggle_favorite,
name="toggle-favorite",
),
]

View File

@ -15,6 +15,7 @@ from django.db import models
from django.db.models.fields.json import KeyTextTransform
from django.db.models.functions import Cast, TruncDate
from django.utils import timezone
from django.utils.dateformat import DateFormat
from profiles.models import UserProfile
from profiles.utils import now_user_timezone
from scrobbles.constants import LONG_PLAY_MEDIA
@ -32,6 +33,16 @@ from webdav.client import get_webdav_client
if TYPE_CHECKING:
from scrobbles.models import Scrobble
NOTE_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ"
def make_note_timestamp(dt: datetime | None = None) -> str:
if dt is None:
dt = timezone.now()
return dt.strftime(NOTE_TIMESTAMP_FORMAT)
logger = logging.getLogger(__name__)
User = get_user_model()
@ -77,8 +88,7 @@ def get_scrobbles_for_media(media_obj, user: User) -> models.QuerySet:
return Scrobble.objects.filter(media_query, user=user)
def get_recently_played_board_games(user: User) -> dict:
...
def get_recently_played_board_games(user: User) -> dict: ...
def get_long_plays_in_progress(user: User) -> dict:
@ -413,6 +423,288 @@ def get_daily_calorie_dict_for_user(user_id: int) -> dict[date, int]:
return {entry["day"]: entry["total_calories"] for entry in qs}
def _mopidy_rpc(profile, method, params=None):
rpc_url = profile.mopidy_api_url.rstrip("/") + "/rpc"
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": method,
}
if params:
payload["params"] = params
resp = requests.post(rpc_url, json=payload, timeout=10)
resp.raise_for_status()
result = resp.json()
if result.get("error"):
raise RuntimeError(f'Mopidy error: {result["error"]}')
return result.get("result")
def _ensure_mopidy_playlist(profile):
playlist_name = profile.favorites_mopidy_playlist
# Strip any m3u: prefix and .m3u8 suffix the user may have included
playlist_name = playlist_name.removeprefix("m3u:").removesuffix(".m3u8")
try:
playlists = _mopidy_rpc(profile, "core.playlists.as_list") or []
for pl in playlists:
if pl.get("name") == playlist_name:
existing = _mopidy_rpc(
profile, "core.playlists.lookup", {"uri": pl["uri"]}
)
if existing:
return existing
except (requests.RequestException, RuntimeError):
pass
result = _mopidy_rpc(
profile, "core.playlists.create",
{"name": playlist_name, "uri_scheme": "m3u"},
)
logger.info(
"Created Mopidy favorites playlist",
extra={"uri": result.get("uri") if result else playlist_name},
)
return result
def _scrobble_with_mopidy_uri(track, user):
"""Find a scrobble for this track+user that has a mopidy_uri in its log."""
from scrobbles.models import Scrobble
for scrobble in (
Scrobble.objects.filter(track=track, user=user)
.order_by("-timestamp")
.iterator()
):
raw_data = scrobble.log.get("raw_data") or {}
if raw_data.get("mopidy_uri"):
return scrobble
return None
def add_track_to_mopidy_favorites_playlist(favorite):
if favorite.media_type != "Track" or not favorite.track:
return
profile = favorite.user.profile
if not profile.favorites_mopidy_playlist or not profile.mopidy_api_url:
return
track = favorite.track
scrobble = _scrobble_with_mopidy_uri(track, favorite.user)
if not scrobble:
logger.warning(
"No Mopidy URI found for track",
extra={"track_id": track.id, "user_id": favorite.user_id},
)
return
mopidy_uri = (scrobble.log or {}).get("raw_data", {}).get("mopidy_uri")
try:
playlist = _ensure_mopidy_playlist(profile)
if playlist and playlist.get("uri"):
existing_tracks = playlist.get("tracks") or []
track_uris = [t["uri"] for t in existing_tracks if isinstance(t, dict)]
if mopidy_uri in track_uris:
logger.info(
"Track already in Mopidy favorites playlist",
extra={"track_id": track.id, "mopidy_uri": mopidy_uri},
)
favorite.sent_to_mopidy = True
favorite.save(update_fields=["sent_to_mopidy"])
return
new_track = {"__model__": "Track", "uri": mopidy_uri}
existing_tracks.append(new_track)
_mopidy_rpc(
profile,
"core.playlists.save",
{
"playlist": {
"__model__": "Playlist",
"uri": playlist["uri"],
"name": playlist.get("name", "Favorites"),
"tracks": existing_tracks,
"last_modified": playlist.get("last_modified"),
},
},
)
else:
_mopidy_rpc(profile, "core.tracklist.add", {"uris": [mopidy_uri]})
favorite.sent_to_mopidy = True
favorite.save(update_fields=["sent_to_mopidy"])
logger.info(
"Added track to Mopidy favorites playlist",
extra={"track_id": track.id, "user_id": favorite.user_id},
)
except (requests.RequestException, RuntimeError) as e:
logger.debug(e)
logger.error(
"Failed to add track to Mopidy favorites playlist",
extra={"track_id": track.id, "user_id": favorite.user_id, "error": str(e)},
)
def resubmit_favorites_to_mopidy(user):
from scrobbles.models import FavoriteMedia
favorites = FavoriteMedia.objects.filter(
user=user,
media_type="Track",
track__isnull=False,
)
for favorite in favorites:
add_track_to_mopidy_favorites_playlist(favorite)
def remove_track_from_mopidy_favorites_playlist(favorite):
if favorite.media_type != "Track" or not favorite.track:
return
profile = favorite.user.profile
if not profile.favorites_mopidy_playlist or not profile.mopidy_api_url:
return
track = favorite.track
scrobble = _scrobble_with_mopidy_uri(track, favorite.user)
if not scrobble:
logger.warning(
"No Mopidy URI found for track",
extra={"track_id": track.id, "user_id": favorite.user_id},
)
return
mopidy_uri = (scrobble.log or {}).get("raw_data", {}).get("mopidy_uri")
try:
playlist = _ensure_mopidy_playlist(profile)
if playlist and playlist.get("uri"):
existing_tracks = playlist.get("tracks") or []
filtered = [
t for t in existing_tracks
if not (isinstance(t, dict) and t.get("uri") == mopidy_uri)
]
if len(filtered) == len(existing_tracks):
logger.info(
"Track not found in Mopidy favorites playlist",
extra={"track_id": track.id, "mopidy_uri": mopidy_uri},
)
return
_mopidy_rpc(
profile,
"core.playlists.save",
{
"playlist": {
"__model__": "Playlist",
"uri": playlist["uri"],
"name": playlist.get("name", "Favorites"),
"tracks": filtered,
"last_modified": playlist.get("last_modified"),
},
},
)
logger.info(
"Removed track from Mopidy favorites playlist",
extra={"track_id": track.id, "user_id": favorite.user_id},
)
except (requests.RequestException, RuntimeError) as e:
logger.debug(e)
logger.error(
"Failed to remove track from Mopidy favorites playlist",
extra={"track_id": track.id, "user_id": favorite.user_id, "error": str(e)},
)
def _ensure_mopidy_playlist_by_name(profile, playlist_name):
"""Find or create a Mopidy playlist by name (without m3u: prefix handling)."""
playlist_name = playlist_name.removeprefix("m3u:").removesuffix(".m3u8")
try:
playlists = _mopidy_rpc(profile, "core.playlists.as_list") or []
for pl in playlists:
if pl.get("name") == playlist_name:
existing = _mopidy_rpc(
profile, "core.playlists.lookup", {"uri": pl["uri"]}
)
if existing:
return existing
except (requests.RequestException, RuntimeError):
pass
result = _mopidy_rpc(
profile, "core.playlists.create",
{"name": playlist_name, "uri_scheme": "m3u"},
)
return result
def add_track_to_mopidy_monthly_playlist(scrobble):
"""Add a scrobbled track to a monthly Mopidy playlist based on the user's pattern."""
profile = scrobble.user.profile
pattern = profile.monthly_mopidy_playlist_pattern
if not pattern or not profile.mopidy_api_url:
return
mopidy_uri = (scrobble.log or {}).get("raw_data", {}).get("mopidy_uri")
if not mopidy_uri:
return
now = now_user_timezone(profile)
playlist_name = DateFormat(now).format(pattern)
if not playlist_name:
return
try:
playlist = _ensure_mopidy_playlist_by_name(profile, playlist_name)
if playlist and playlist.get("uri"):
existing_tracks = playlist.get("tracks") or []
track_uris = [t["uri"] for t in existing_tracks if isinstance(t, dict)]
if mopidy_uri in track_uris:
logger.info(
"Track already in monthly Mopidy playlist",
extra={"playlist": playlist_name, "mopidy_uri": mopidy_uri},
)
return
new_track = {"__model__": "Track", "uri": mopidy_uri}
existing_tracks.append(new_track)
_mopidy_rpc(
profile,
"core.playlists.save",
{
"playlist": {
"__model__": "Playlist",
"uri": playlist["uri"],
"name": playlist.get("name", playlist_name),
"tracks": existing_tracks,
"last_modified": playlist.get("last_modified"),
},
},
)
else:
_mopidy_rpc(profile, "core.tracklist.add", {"uris": [mopidy_uri]})
logger.info(
"Added track to monthly Mopidy playlist",
extra={
"playlist": playlist_name,
"track_id": scrobble.media_obj.id,
"user_id": scrobble.user_id,
},
)
except (requests.RequestException, RuntimeError) as e:
logger.debug(e)
logger.error(
"Failed to add track to monthly Mopidy playlist",
extra={"playlist": playlist_name, "error": str(e)},
)
def remove_last_part(url: str) -> str:
url = url.rstrip("/")
if "/" not in url:
@ -507,8 +799,35 @@ def tokenize_title_to_tags(title: str) -> list[str]:
cleaned = re.sub(r"[^\w\s]", "", cleaned)
words = [
w.lower()
for w in cleaned.split()
if w.lower() not in STOPWORDS and len(w) > 2
w.lower() for w in cleaned.split() if w.lower() not in STOPWORDS and len(w) > 2
]
return words
def analyze_scrobble_sentiment(scrobble, overwrite=False) -> bool:
"""Run VADER sentiment analysis on a scrobble's notes.
Stores result in log["sentiment"] as a dict with keys:
neg, neu, pos, compound.
Returns True if analyzed, False if skipped (no notes or already done).
"""
from vaderSentiment.vaderSentiment import SentimentIntensityAnalyzer
log = scrobble.log or {}
if not overwrite and log.get("sentiment") is not None:
return False
notes_str = ""
if scrobble.logdata:
notes_str = scrobble.logdata.notes_as_str()
if not notes_str:
return False
analyzer = SentimentIntensityAnalyzer()
scores = analyzer.polarity_scores(notes_str)
log["sentiment"] = scores
scrobble.log = log
scrobble.save(update_fields=["log"])
return True

View File

@ -4,6 +4,8 @@ import logging
from datetime import datetime, timedelta
from uuid import uuid4
import requests
import pendulum
import pytz
from dateutil.relativedelta import relativedelta
@ -12,7 +14,7 @@ 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 import Count, F, Max, Q, Sum
from django.db.models.query import QuerySet
from rest_framework.authentication import TokenAuthentication
from rest_framework.authtoken.models import Token
@ -30,11 +32,13 @@ from django.http import (
HttpResponseRedirect,
JsonResponse,
)
from django.shortcuts import redirect
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse_lazy
from django.utils import timezone
from django.utils.dateformat import DateFormat
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import DetailView, FormView, TemplateView
from django.views.decorators.http import require_POST
from django.views.generic import DetailView, FormView, TemplateView, View
from django.views.generic.edit import CreateView
from django.views.generic.list import ListView
from moods.models import Mood
@ -68,16 +72,20 @@ from scrobbles.constants import (
)
from scrobbles.export import export_scrobbles
from scrobbles.forms import ExportScrobbleForm, ScrobbleForm
from scrobbles.constants import Visibility
from scrobbles.sqids import decode_scrobble_share
from scrobbles.models import (
AudioScrobblerTSVImport,
BGStatsImport,
EBirdCSVImport,
FavoriteMedia,
KoReaderImport,
LastFmImport,
RetroarchImport,
ScaleCSVImport,
Scrobble,
ScrobbleQuerySet,
ShareViewLog,
TrailGPXImport,
)
from scrobbles.scrobblers import *
@ -179,7 +187,7 @@ class ScrobbleableDetailView(ChartContextMixin, DetailView):
def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs)
scrobbles = []
if not self.request.user.is_anonymous:
if not self.request.user.is_anonymous and hasattr(self.object, "scrobble_set"):
scrobbles = self.object.scrobble_set.filter(
user=self.request.user
).order_by("-timestamp")
@ -363,7 +371,11 @@ class ScrobbleListView(LoginRequiredMixin, ListView):
qs = qs.filter(id__in=matching_ids).distinct()
else:
tag_list = []
visibility_param = self.request.GET.get("visibility", "")
if visibility_param in ("public", "shared", "private"):
qs = qs.filter(visibility=visibility_param)
self.tag_list = tag_list
self._full_queryset = qs
return qs
def _compute_overlap_groups(self, scrobbles):
@ -430,6 +442,12 @@ class ScrobbleListView(LoginRequiredMixin, ListView):
ctx["tag_list"] = getattr(self, "tag_list", [])
scrobbles = list(ctx.get("object_list", []))
ctx["overlap_map"] = self._compute_overlap_groups(scrobbles)
full_qs = getattr(self, "_full_queryset", None)
if full_qs is not None and getattr(self, "tag_list", []):
total = (
full_qs.aggregate(total=Sum("playback_position_seconds"))["total"] or 0
)
ctx["total_time_seconds"] = total
return ctx
@ -963,6 +981,113 @@ def scrobble_cancel(request, uuid):
return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
@require_POST
def add_to_mopidy_queue(request, uuid):
if not request.user.is_authenticated:
return redirect("scrobbles:detail", uuid=uuid)
scrobble = get_object_or_404(Scrobble, uuid=uuid, user=request.user)
mopidy_url = request.user.profile.mopidy_api_url
if not mopidy_url:
messages.add_message(
request,
messages.ERROR,
"Mopidy API URL not configured in your profile settings.",
)
return redirect("scrobbles:detail", uuid=uuid)
from scrobbles.tasks import add_scrobble_to_mopidy_queue as task
task.delay(scrobble.id)
msg = f'Adding "{scrobble.media_obj}" to Mopidy queue.'
messages.add_message(request, messages.SUCCESS, msg)
return redirect("scrobbles:detail", uuid=uuid)
@require_POST
def add_to_mopidy_monthly_playlist(request, uuid):
if not request.user.is_authenticated:
return redirect("scrobbles:detail", uuid=uuid)
scrobble = get_object_or_404(Scrobble, uuid=uuid, user=request.user)
profile = request.user.profile
pattern = profile.monthly_mopidy_playlist_pattern
if not pattern or not profile.mopidy_api_url:
messages.add_message(
request,
messages.ERROR,
"Monthly playlist pattern or Mopidy API URL not configured in your profile.",
)
return redirect("scrobbles:detail", uuid=uuid)
now = now_user_timezone(profile)
playlist_name = DateFormat(now).format(pattern)
from scrobbles.tasks import add_scrobble_to_mopidy_monthly_playlist as task
task.delay(scrobble.id)
messages.add_message(
request,
messages.SUCCESS,
f'Adding "{scrobble.media_obj}" to monthly playlist "{playlist_name}".',
)
return redirect("scrobbles:detail", uuid=uuid)
@require_POST
def toggle_favorite(request, media_type, object_id):
if not request.user.is_authenticated:
return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/"))
app_model_map = {
"Video": ("videos", "Video"),
"Channel": ("videos", "Channel"),
"Track": ("music", "Track"),
"PodcastEpisode": ("podcasts", "PodcastEpisode"),
"SportEvent": ("sports", "SportEvent"),
"Book": ("books", "Book"),
"Paper": ("books", "Paper"),
"VideoGame": ("videogames", "VideoGame"),
"BoardGame": ("boardgames", "BoardGame"),
"GeoLocation": ("locations", "GeoLocation"),
"Beer": ("beers", "Beer"),
"Puzzle": ("puzzles", "Puzzle"),
"Food": ("foods", "Food"),
"Trail": ("trails", "Trail"),
"Task": ("tasks", "Task"),
"WebPage": ("webpages", "WebPage"),
"LifeEvent": ("lifeevents", "LifeEvent"),
"Mood": ("moods", "Mood"),
"BrickSet": ("bricksets", "BrickSet"),
"BirdingLocation": ("birds", "BirdingLocation"),
}
app_label, model_name = app_model_map.get(media_type, (None, None))
if not app_label:
messages.add_message(
request, messages.ERROR, f"Unknown media type: {media_type}"
)
return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/"))
model = apps.get_model(app_label, model_name)
media_obj = get_object_or_404(model, id=object_id)
result = FavoriteMedia.toggle(media_obj, request.user)
is_favorited = result is not None
if not is_favorited:
msg = f'Removed "{media_obj}" from favorites.'
else:
msg = f'Added "{media_obj}" to favorites.'
if request.headers.get("x-requested-with") == "XMLHttpRequest":
return JsonResponse({"is_favorited": is_favorited, "message": msg})
messages.add_message(request, messages.SUCCESS, msg)
return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/"))
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def export(request):
@ -1017,6 +1142,15 @@ class ScrobbleDetailView(DetailView):
slug_url_kwarg = "uuid"
paginate_by = 100
def get_object(self, queryset=None):
scrobble = super().get_object(queryset=queryset)
user = self.request.user
if scrobble.visibility == Visibility.PUBLIC:
return scrobble
if user.is_authenticated and scrobble.user == user:
return scrobble
raise Http404
def get_form_class(self):
return self.object.media_obj.logdata_cls().form()
@ -1113,6 +1247,110 @@ class ScrobbleDetailView(DetailView):
except EmptyPage:
context["related_scrobbles"] = paginator.page(paginator.num_pages)
if self.request.user.is_authenticated:
fk_field = self.MEDIA_FK_MAP.get(media_type)
if fk_field and media_obj:
context["is_favorited"] = FavoriteMedia.objects.filter(
user=self.request.user, **{fk_field: media_obj}
).exists()
if media_type == "Track" and media_obj:
scrobbles = Scrobble.objects.filter(
track=media_obj, user=self.object.user
).order_by("-timestamp")[:20]
context["has_mopidy_uri"] = any(
(s.log or {}).get("raw_data", {}).get("mopidy_uri") for s in scrobbles
)
else:
context["has_mopidy_uri"] = False
return context
class ScrobbleShareView(TemplateView):
template_name = "scrobbles/scrobble_share.html"
def get_object(self):
sqid = self.kwargs.get("sqid")
decoded = decode_scrobble_share(sqid)
if not decoded or len(decoded) != 2:
raise Http404
scrobble_id, version = decoded
scrobble = get_object_or_404(Scrobble, id=scrobble_id)
if scrobble.share_token_version != version:
raise Http404
if scrobble.visibility not in (Visibility.PUBLIC, Visibility.SHARED):
raise Http404
Scrobble.objects.filter(id=scrobble.id).update(
share_view_count=F("share_view_count") + 1
)
scrobble.refresh_from_db(fields=["share_view_count"])
ShareViewLog.objects.create(
scrobble=scrobble,
ip_address=self.request.META.get("REMOTE_ADDR"),
user_agent=self.request.META.get("HTTP_USER_AGENT", "")[:500],
referrer=self.request.META.get("HTTP_REFERER", ""),
)
return scrobble
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
scrobble = self.get_object()
context["object"] = scrobble
context["log_form"] = None
context["related_scrobbles"] = Scrobble.objects.none()
context["has_mopidy_uri"] = False
if self.request.user.is_authenticated:
media_type = scrobble.media_type
fk_field = ScrobbleDetailView.MEDIA_FK_MAP.get(media_type)
media_obj = scrobble.media_obj
if fk_field and media_obj:
context["is_favorited"] = FavoriteMedia.objects.filter(
user=self.request.user, **{fk_field: media_obj}
).exists()
return context
class ScrobbleExploreView(ListView):
model = Scrobble
paginate_by = 100
template_name = "scrobbles/scrobble_explore.html"
queryset = Scrobble.objects.filter(visibility=Visibility.PUBLIC).order_by(
"-timestamp"
)
class RegenerateShareTokenView(LoginRequiredMixin, View):
def post(self, request, uuid):
scrobble = get_object_or_404(Scrobble, uuid=uuid, user=request.user)
scrobble.regenerate_share_token()
return redirect(scrobble.get_absolute_url())
class ChangeVisibilityView(LoginRequiredMixin, View):
def post(self, request, uuid):
scrobble = get_object_or_404(Scrobble, uuid=uuid, user=request.user)
visibility = request.POST.get("visibility")
if visibility not in (Visibility.PUBLIC, Visibility.SHARED, Visibility.PRIVATE):
return redirect(scrobble.get_absolute_url())
scrobble.visibility = visibility
scrobble.save(update_fields=["visibility"])
return redirect(scrobble.get_absolute_url())
class ScrobbleShareAnalyticsView(LoginRequiredMixin, DetailView):
model = Scrobble
slug_field = "uuid"
slug_url_kwarg = "uuid"
template_name = "scrobbles/scrobble_share_analytics.html"
def get_queryset(self):
return Scrobble.objects.filter(user=self.request.user)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
scrobble = self.object
context["share_views"] = scrobble.share_views.order_by("-created")[:50]
return context

View File

@ -59,19 +59,27 @@ class SportEventAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"title",
"league",
"event_type",
"start",
"comp_str",
"round",
)
list_filter = ("round__season", "home_team", "away_team")
list_filter = ("league", "event_type")
ordering = ("-created",)
inlines = [
ScrobbleInline,
]
def comp_str(self, obj):
if obj.home_team:
return f"{obj.away_team} @ {obj.home_team}"
if obj.player_one:
return f"{obj.player_one} v {obj.player_two}"
teams = list(obj.teams.all())
if len(teams) >= 2:
return f"{teams[1]} v {teams[0]}"
players = list(obj.players.all())
if len(players) >= 2:
return f"{players[0]} v {players[1]}"
if len(players) == 1:
return str(players[0])
if len(teams) == 1:
return str(teams[0])

View File

@ -0,0 +1,36 @@
from django.core.management.base import BaseCommand
from sports.models import League, Team
from sports.thesportsdb import enrich_league_logo, enrich_team_logo, has_logo
class Command(BaseCommand):
help = "Fetch missing league and team logos from TheSportsDB"
def add_arguments(self, parser):
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be done without making changes",
)
def handle(self, *args, **options):
dry_run = options["dry_run"]
leagues = League.objects.filter(thesportsdb_id__isnull=False)
for league in leagues:
if has_logo(league):
continue
if dry_run:
self.stdout.write(f"Would enrich logo for league: {league.name} ({league.thesportsdb_id})")
else:
enrich_league_logo(league)
teams = Team.objects.filter(thesportsdb_id__isnull=False)
for team in teams:
if has_logo(team):
continue
if dry_run:
self.stdout.write(f"Would enrich logo for team: {team.name} ({team.thesportsdb_id})")
else:
enrich_team_logo(team)

View File

@ -0,0 +1,24 @@
# Generated by Django 4.2.30 on 2026-06-06 15:29
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("sports", "0018_alter_sportevent_genre"),
]
operations = [
migrations.AddField(
model_name="sportevent",
name="league",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to="sports.league",
),
),
]

View File

@ -0,0 +1,111 @@
import json
from django.db import migrations, models
import django.db.models.deletion
def canonical_key(event):
if event.home_team_id and event.away_team_id:
return ("teams", event.league_id, event.home_team_id, event.away_team_id)
if event.player_one_id and event.player_two_id:
return ("players", event.league_id, event.player_one_id, event.player_two_id)
return ("title", event.league_id, event.event_type, (event.title or "").strip())
def build_logdata(event):
logdata = {}
if event.thesportsdb_id:
logdata["thesportsdb_id"] = event.thesportsdb_id
if event.start:
logdata["start"] = (
event.start.isoformat()
if hasattr(event.start, "isoformat")
else str(event.start)
)
if event.round:
logdata["round_name"] = event.round.name or str(event.round)
if event.round.season:
logdata["season_name"] = event.round.season.name or str(event.round.season)
return logdata
def merge_scrobble_logs(scrobble, logdata):
existing_log = scrobble.log or {}
if isinstance(existing_log, str):
existing_log = json.loads(existing_log)
existing_log.update(logdata)
scrobble.log = existing_log
scrobble.save(update_fields=["log"])
def populate_league(event):
if event.league:
return
if event.round and event.round.season and event.round.season.league:
event.league = event.round.season.league
event.save(update_fields=["league"])
def populate_m2m(event):
if event.home_team_id:
event.teams.add(event.home_team_id)
if event.away_team_id:
event.teams.add(event.away_team_id)
if event.player_one_id:
event.players.add(event.player_one_id)
if event.player_two_id:
event.players.add(event.player_two_id)
def migrate_sport_event_data(apps, schema_editor):
SportEvent = apps.get_model("sports", "SportEvent")
Scrobble = apps.get_model("scrobbles", "Scrobble")
db_alias = schema_editor.connection.alias
canonical_events = {}
for event in SportEvent.objects.using(db_alias).iterator():
populate_league(event)
key = canonical_key(event)
canonical = canonical_events.get(key)
if not canonical:
canonical_events[key] = event
populate_m2m(event)
logdata = build_logdata(event)
for scrobble in (
Scrobble.objects.using(db_alias).filter(sport_event=event).iterator()
):
merge_scrobble_logs(scrobble, logdata)
else:
logdata = build_logdata(event)
for scrobble in (
Scrobble.objects.using(db_alias).filter(sport_event=event).iterator()
):
scrobble.sport_event = canonical
merge_scrobble_logs(scrobble, logdata)
scrobble.save(update_fields=["sport_event", "log"])
event.delete()
class Migration(migrations.Migration):
dependencies = [
("sports", "0019_sportevent_league_alter_sportevent_away_team_and_more"),
]
operations = [
migrations.AddField(
model_name="sportevent",
name="teams",
field=models.ManyToManyField(blank=True, to="sports.team"),
),
migrations.AddField(
model_name="sportevent",
name="players",
field=models.ManyToManyField(blank=True, to="sports.player"),
),
migrations.RunPython(
migrate_sport_event_data, reverse_code=migrations.RunPython.noop
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 4.2.29 on 2026-06-07 03:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("sports", "0020_migrate_sport_event_data_to_logdata"),
]
operations = [
migrations.AddField(
model_name="team",
name="logo",
field=models.ImageField(
blank=True, null=True, upload_to="sports/team-logos/"
),
),
]

View File

@ -0,0 +1,27 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("sports", "0021_team_logo"),
]
operations = [
migrations.RemoveField(
model_name="sportevent",
name="home_team",
),
migrations.RemoveField(
model_name="sportevent",
name="away_team",
),
migrations.RemoveField(
model_name="sportevent",
name="player_one",
),
migrations.RemoveField(
model_name="sportevent",
name="player_two",
),
]

View File

@ -68,6 +68,7 @@ class Season(TheSportsDbMixin):
class Team(TheSportsDbMixin):
league = models.ForeignKey(League, on_delete=models.DO_NOTHING, **BNULL)
logo = models.ImageField(upload_to="sports/team-logos/", **BNULL)
class Player(TheSportsDbMixin):
@ -88,50 +89,61 @@ class Round(TheSportsDbMixin):
class SportEvent(ScrobblableMixin):
COMPLETION_PERCENT = getattr(settings, "SPORT_COMPLETION_PERCENT", 90)
thesportsdb_id = models.CharField(max_length=255, **BNULL)
event_type = models.CharField(
max_length=2,
choices=SportEventType.choices,
default=SportEventType.UNKNOWN,
)
round = models.ForeignKey(Round, on_delete=models.DO_NOTHING, **BNULL)
start = models.DateTimeField(**BNULL)
home_team = models.ForeignKey(
Team,
on_delete=models.DO_NOTHING,
related_name="home_event_set",
**BNULL,
)
away_team = models.ForeignKey(
Team,
on_delete=models.DO_NOTHING,
related_name="away_event_set",
**BNULL,
)
player_one = models.ForeignKey(
Player,
on_delete=models.DO_NOTHING,
related_name="player_one_set",
**BNULL,
)
player_two = models.ForeignKey(
Player,
on_delete=models.DO_NOTHING,
related_name="player_two_set",
**BNULL,
)
league = models.ForeignKey(League, on_delete=models.DO_NOTHING, **BNULL)
def __str__(self):
return (
f"{self.start.date()} - {self.round} - {self.home_team} v {self.away_team}"
)
@property
def logdata_cls(self):
from scrobbles.dataclasses import SportEventLogData
return SportEventLogData
teams = models.ManyToManyField(Team, blank=True)
players = models.ManyToManyField(Player, blank=True)
# Deprecated - data migrated to scrobble.log via SportEventLogData
thesportsdb_id = models.CharField(max_length=255, **BNULL)
start = models.DateTimeField(**BNULL)
round = models.ForeignKey(Round, on_delete=models.DO_NOTHING, **BNULL)
def save(self, *args, **kwargs):
old_instance = None
try:
old_instance = UserProfile.objects.get(pk=self.pk)
except:
pass
if not self.title or (old_instance and old_instance.title != self.title):
self.title = self.comp_str
super(SportEvent, self).save(*args, **kwargs)
def __str__(self) -> str:
return f"{self.title} - {self.subtitle}"
def get_absolute_url(self):
return reverse("sports:event_detail", kwargs={"slug": self.uuid})
@property
def subtitle(self):
return self.round.season.league
def subtitle(self) -> str:
league = self.league
if self.league and self.league.abbreviation_str:
league = self.league.abbreviation_str
return f"{league} {self.get_event_type_display()}"
@property
def comp_str(self) -> str:
if self.players.exists():
return " v ".join(str(p) for p in self.players.all())
if self.teams.exists():
return " v ".join(str(t) for t in self.teams.all())
return ""
@property
def strings(self) -> ScrobblableConstants:
@ -147,16 +159,17 @@ class SportEvent(ScrobblableMixin):
@property
def primary_image_url(self) -> str:
url = ""
if self.round.season.league.logo:
url = self.round.season.league.logo.url
return url
if self.league and self.league.logo:
return self.league.logo.url
return ""
@classmethod
def find_or_create(cls, data_dict: Dict) -> "Event":
"""Given a data dict from Jellyfin, does the heavy lifting of looking up
the video and, if need, TV Series, creating both if they don't yet
exist.
def find_or_create(cls, data_dict: Dict) -> tuple["Event", dict]:
"""Given a data dict from TheSportsDB, finds or creates a canonical
SportEvent by teams, players or title, and returns (event, logdata).
The logdata dict contains per-scrobble details (thesportsdb_id, start,
round/season names) that should be stored in the scrobble's log field.
"""
# Find or create our Sport
@ -187,32 +200,29 @@ class SportEvent(ScrobblableMixin):
# Find or create our Round
rid = data_dict.get("RoundId")
round, r_created = Round.objects.get_or_create(
round_obj, r_created = Round.objects.get_or_create(
thesportsdb_id=rid,
season=season,
name=rid,
)
if r_created:
round.season = season
round.save(update_fields=["season"])
round_obj.season = season
round_obj.save(update_fields=["season"])
# Set some special data for Tennis
player_one = None
player_two = None
if data_dict.get("Sport") == "Tennis":
event_name = data_dict.get("Name", "")
if not round.name:
round.name = get_round_name_from_event(event_name)
round.save(update_fields=["name"])
players_list = get_players_from_event(event_name)
player_one = Player.objects.filter(name__icontains=players_list[0]).first()
if not player_one:
player_one = Player.objects.create(name=players_list[0])
player_two = Player.objects.filter(name__icontains=players_list[1]).first()
if not player_two:
player_two = Player.objects.create(name=players_list[1])
# Build logdata with per-scrobble details
logdata = {}
logdata["thesportsdb_id"] = data_dict.get("EventId")
start = data_dict.get("Start")
if start:
logdata["start"] = (
start.isoformat() if hasattr(start, "isoformat") else str(start)
)
if round_obj:
logdata["round_name"] = round_obj.name or str(round_obj)
if round_obj.season:
logdata["season_name"] = round_obj.season.name or str(round_obj.season)
# Look up or create teams/players
home_team = None
away_team = None
if data_dict.get("HomeTeamName"):
@ -221,27 +231,73 @@ class SportEvent(ScrobblableMixin):
"thesportsdb_id": data_dict.get("HomeTeamId", ""),
"league": league,
}
home_team, _created = Team.objects.get_or_create(**home_team_dict)
home_team, ht_created = Team.objects.get_or_create(**home_team_dict)
if ht_created:
from sports.thesportsdb import enrich_team_logo
enrich_team_logo(home_team)
away_team_dict = {
"name": data_dict.get("AwayTeamName", ""),
"thesportsdb_id": data_dict.get("AwayTeamId", ""),
"league": league,
}
away_team, _created = Team.objects.get_or_create(**away_team_dict)
away_team, at_created = Team.objects.get_or_create(**away_team_dict)
if at_created:
from sports.thesportsdb import enrich_team_logo
event_dict = {
"thesportsdb_id": data_dict.get("EventId"),
"title": data_dict.get("Name"),
"event_type": sport.default_event_type,
"home_team": home_team,
"away_team": away_team,
"player_one": player_one,
"player_two": player_two,
"start": data_dict.get("Start"),
"round": round,
"base_run_time_seconds": data_dict.get("RunTime"),
}
event, _created = cls.objects.get_or_create(**event_dict)
enrich_team_logo(away_team)
return event
players_list = []
if data_dict.get("Sport") == "Tennis":
event_name = data_dict.get("Name", "")
if not round_obj.name:
round_obj.name = get_round_name_from_event(event_name)
round_obj.save(update_fields=["name"])
players_list = get_players_from_event(event_name)
# Find existing canonical event by teams, players, or title
event = None
if home_team and away_team:
event = (
cls.objects.filter(league=league, teams=home_team)
.filter(teams=away_team)
.first()
)
if not event and players_list:
player_objs = []
for player_name in players_list:
player = Player.objects.filter(name__icontains=player_name).first()
if not player:
player = Player.objects.create(name=player_name)
player_objs.append(player)
qs = cls.objects.filter(league=league, players=player_objs[0])
for player in player_objs[1:]:
qs = qs.filter(players=player)
event = qs.first()
if not event:
title = data_dict.get("Name", "").strip()
if title:
event = cls.objects.filter(league=league, title=title).first()
if not event:
event = cls.objects.create(
title=data_dict.get("Name"),
event_type=sport.default_event_type,
league=league,
base_run_time_seconds=data_dict.get("RunTime"),
)
# Ensure M2M is populated on the canonical event
if home_team and not event.teams.filter(id=home_team.id).exists():
event.teams.add(home_team)
if away_team and not event.teams.filter(id=away_team.id).exists():
event.teams.add(away_team)
for player_name in players_list:
player = Player.objects.filter(name__icontains=player_name).first()
if player and not event.players.filter(id=player.id).exists():
event.players.add(player)
return event, logdata

View File

@ -1,10 +1,12 @@
import logging
import requests
from dateutil.parser import parse
from django.conf import settings
from django.core.files.base import ContentFile
from django.utils import timezone
from pysportsdb import TheSportsDbClient
from sports.models import Sport
from sports.models import League, Sport, Team
logger = logging.getLogger(__name__)
@ -12,6 +14,84 @@ API_KEY = getattr(settings, "THESPORTSDB_API_KEY", "2")
client = TheSportsDbClient(api_key=API_KEY)
def has_logo(league_or_team) -> bool:
"""Check if a model instance has a logo (handles both NULL and empty string)."""
return bool(league_or_team.logo and league_or_team.logo.name)
def enrich_league_logo(league: League) -> None:
"""Fetch the league badge from TheSportsDB and save it as the league logo."""
if not league.thesportsdb_id or has_logo(league):
return
url = (
f"https://www.thesportsdb.com/api/v1/json/{API_KEY}"
f"/lookupleague.php?id={league.thesportsdb_id}"
)
try:
resp = requests.get(url, timeout=10)
data = resp.json()
leagues = data.get("leagues", [])
if not leagues:
return
badge_url = leagues[0].get("strBadge")
if badge_url:
r = requests.get(badge_url, timeout=10)
if r.status_code == 200:
fname = f"{league.uuid or league.thesportsdb_id}.png"
league.logo.save(fname, ContentFile(r.content), save=True)
logger.info(
"Saved league logo from TheSportsDB",
extra={"league_id": league.id, "league_name": league.name},
)
except Exception as e:
logger.warning(
"Failed to fetch league logo from TheSportsDB",
extra={"league_id": league.id, "error": str(e)},
)
def enrich_team_logo(team: Team) -> None:
"""Fetch the team badge from TheSportsDB and save it as the team logo."""
if not team.thesportsdb_id or has_logo(team):
return
try:
badge_url = None
# Try direct lookup by thesportsdb_id first (more reliable)
url = (
f"https://www.thesportsdb.com/api/v1/json/{API_KEY}"
f"/lookupteam.php?id={team.thesportsdb_id}"
)
resp = requests.get(url, timeout=10)
data = resp.json()
api_teams = data.get("teams", [])
if api_teams:
badge_url = api_teams[0].get("strBadge")
else:
# Fall back to name search
result = client.search_teams(team.name) or {}
api_teams = result.get("teams", [])
if api_teams:
badge_url = api_teams[0].get("strBadge")
if badge_url:
r = requests.get(badge_url, timeout=10)
if r.status_code == 200:
fname = f"{team.uuid or team.thesportsdb_id}.png"
team.logo.save(fname, ContentFile(r.content), save=True)
logger.info(
"Saved team logo from TheSportsDB",
extra={"team_id": team.id, "team_name": team.name},
)
except Exception as e:
logger.warning(
"Failed to fetch team logo from TheSportsDB",
extra={"team_id": team.id, "error": str(e)},
)
def lookup_event_from_thesportsdb(event_id: str) -> dict:
try:
@ -23,6 +103,18 @@ def lookup_event_from_thesportsdb(event_id: str) -> dict:
return {}
sport, _created = Sport.objects.get_or_create(thesportsdb_id=event.get("strSport"))
# Find or create the league and optionally enrich its logo
lid = event.get("idLeague")
league, l_created = League.objects.get_or_create(
thesportsdb_id=lid,
defaults={"name": event.get("strLeague", "")},
)
if l_created:
league.name = event.get("strLeague", "")
league.sport = sport
league.save(update_fields=["name", "sport"])
enrich_league_logo(league)
try:
start = parse(event.get("strTimestamp"))
except:
@ -38,7 +130,7 @@ def lookup_event_from_thesportsdb(event_id: str) -> dict:
"RunTime": sport.default_event_run_time_seconds,
"Sport": event.get("strSport"),
"Season": event.get("strSeason"),
"LeagueId": event.get("idLeague"),
"LeagueId": lid,
"LeagueName": event.get("strLeague"),
"HomeTeamId": event.get("idHomeTeam"),
"HomeTeamName": event.get("strHomeTeam"),

View File

@ -1,12 +1,12 @@
from django.views import generic
from sports.models import SportEvent
from vrobbler.apps.scrobbles.views import ScrobbleableDetailView, ScrobbleableListView
class SportEventListView(generic.ListView):
class SportEventListView(ScrobbleableListView):
model = SportEvent
paginate_by = 50
class SportEventDetailView(generic.DetailView):
class SportEventDetailView(ScrobbleableDetailView):
model = SportEvent
slug_field = "uuid"

View File

@ -76,33 +76,62 @@ class TaskLogData(BaseLogData):
return separator.join(lines).encode("utf-8").decode("unicode_escape")
def notes_as_html(self) -> str:
import bleach
import markdown
from django.utils.safestring import mark_safe
from scrobbles.dataclasses import BaseLogData
if not self.notes:
return ""
md = markdown.Markdown(extensions=["extra"])
allowed_tags = [
"p", "br", "strong", "em", "a", "ul", "ol", "li",
"code", "pre", "blockquote", "h1", "h2", "h3", "h4", "h5", "h6",
"hr", "img",
]
notes = self.notes
if isinstance(notes, dict):
notes = [{k: v} for k, v in notes.items()]
if isinstance(notes, str):
notes = [notes]
html_notes = []
note_items = []
for note in notes:
if isinstance(note, dict):
timestamp, note_text = next(iter(note.items()))
note_text = " ".join(note_text.strip().split())
html_notes.append(
f'<div class="sticky-note">{timestamp}: {note_text}</div>'
)
note_items.append((timestamp, note_text.strip()))
elif isinstance(note, str):
escaped = note.encode("utf-8").decode("unicode_escape")
for line in escaped.split("\n"):
if line.strip():
html_notes.append(f'<div class="sticky-note">{line}</div>')
note_items.append((None, line.strip()))
elif isinstance(note, list):
for item in note:
if isinstance(item, str):
html_notes.append(f'<div class="sticky-note">{item}</div>')
return "".join(html_notes)
note_items.append((None, item.strip()))
html_parts = []
for i, (ts, text) in enumerate(note_items):
if i > 0:
html_parts.append('<hr class="note-divider">')
ts_html = ""
if ts:
ts_html = f'<h5 class="note-timestamp">{BaseLogData._format_timestamp(ts)}</h5>'
content_html = bleach.clean(
md.convert(text),
tags=allowed_tags,
strip=True,
)
html_parts.append(
f'<div class="note-item">{ts_html}<div class="note-content">{content_html}</div></div>'
)
return mark_safe("".join(html_parts))
class Task(LongPlayScrobblableMixin):

View File

@ -64,6 +64,8 @@ def convert_old_todoist_log_to_new(commit=False):
def convert_notes_to_dict(commit=False):
from scrobbles.utils import make_note_timestamp
scrobbles = Scrobble.objects.filter(log__notes__isnull=False)
count = 0
for scrobble in scrobbles:
@ -71,7 +73,7 @@ def convert_notes_to_dict(commit=False):
print(f"Converting {scrobble} string note to dict")
if scrobble.log.get("notes") == "":
scrobble.log.pop("notes")
key = str(int(scrobble.timestamp.timestamp()))
key = make_note_timestamp(scrobble.timestamp)
notes = scrobble.log.pop("notes")
scrobble.log = {}
scrobble.log["notes"] = {key: notes}
@ -92,12 +94,14 @@ def convert_notes_to_dict(commit=False):
def convert_tasks_notes_list_to_dict(commit=False):
from scrobbles.utils import make_note_timestamp
scrobbles = Scrobble.objects.filter(task__isnull=False, log__notes__isnull=False)
count = 0
for scrobble in scrobbles:
notes = scrobble.log.get("notes")
if isinstance(notes, list):
key = str(int(scrobble.timestamp.timestamp()) + 10)
key = make_note_timestamp(scrobble.timestamp + timedelta(seconds=10))
parts = []
for note in notes:
if isinstance(note, dict):
@ -114,6 +118,8 @@ def convert_tasks_notes_list_to_dict(commit=False):
def convert_old_boardgame_log_to_new(commit=False):
from scrobbles.utils import make_note_timestamp
scrobbles = Scrobble.objects.filter(board_game__isnull=False, log__has_key="notes")
count = 0
for scrobble in scrobbles:
@ -123,7 +129,7 @@ def convert_old_boardgame_log_to_new(commit=False):
notes = [notes]
if isinstance(notes, list):
key_ts = scrobble.stop_timestamp or scrobble.timestamp
scrobble.log["notes"] = {str(int(key_ts.timestamp())): "\n".join(
scrobble.log["notes"] = {make_note_timestamp(key_ts): "\n".join(
str(n) for n in notes
)}
count += 1

View File

@ -1,5 +1,6 @@
import logging
import pendulum
from django.contrib import messages
from django.http import HttpResponseRedirect
from django.urls import reverse_lazy
@ -8,6 +9,7 @@ from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from scrobbles.models import Scrobble
from scrobbles.views import ScrobbleableDetailView, ScrobbleableListView
from tasks.models import Task
@ -23,6 +25,72 @@ class TaskListView(ScrobbleableListView):
class TaskDetailView(ScrobbleableDetailView):
model = Task
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
if self.object.title != "Weigh-in":
return ctx
scrobbles = list(
Scrobble.objects.filter(
user=self.request.user,
task=self.object,
log__weight__isnull=False,
).order_by("timestamp")
)
if not scrobbles:
return ctx
labels = []
weight_data = []
body_fat_data = []
bmi_data = []
for s in scrobbles:
ts = s.timestamp
if isinstance(ts, str):
ts = pendulum.parse(ts)
labels.append(ts.strftime("%Y-%m-%d"))
log = s.log if isinstance(s.log, dict) else {}
raw_weight = log.get("weight")
weight_data.append(
float(raw_weight) if raw_weight is not None else None
)
raw_bf = log.get("body_fat")
body_fat_data.append(
float(raw_bf) if raw_bf is not None else None
)
raw_bmi = log.get("bmi")
bmi_data.append(
float(raw_bmi) if raw_bmi is not None else None
)
ctx["weighin_chart"] = {
"labels": labels,
"datasets": [
{
"label": "Weight",
"data": weight_data,
"borderColor": "#4bc0c0",
"fill": False,
"yAxisID": "y",
},
{
"label": "Body Fat %",
"data": body_fat_data,
"borderColor": "#ff6384",
"fill": False,
"yAxisID": "y1",
},
{
"label": "BMI",
"data": bmi_data,
"borderColor": "#36a2eb",
"fill": False,
"yAxisID": "y2",
},
],
}
return ctx
@api_view(["GET"])
@permission_classes([IsAuthenticated])

View File

@ -232,10 +232,10 @@ class EmacsWebhookView(APIView):
status=status.HTTP_304_NOT_MODIFIED,
)
if task_in_progress:
if scrobble and scrobble.in_progress:
emacs_scrobble_update_task(
post_data.get("source_id"),
post_data.get("notes", []),
post_data.get("notes") or [],
user_id,
description=post_data.get("body"),
)

View File

@ -163,12 +163,9 @@ class VideoGame(LongPlayScrobblableMixin):
platforms = models.ManyToManyField(VideoGamePlatform)
retroarch_name = models.CharField(max_length=255, **BNULL)
def __str__(self):
return self.title
@property
def subtitle(self):
return f" On {self.platforms.first()}"
return f"{self.platforms.first()}"
@property
def strings(self) -> ScrobblableConstants:

View File

@ -8,21 +8,20 @@ def natural_duration(value):
if not value:
return
value = int(value)
total_minutes = int(value / 60)
hours = int(total_minutes / 60)
minutes = total_minutes - (hours * 60)
seconds = value % 60
value_str = ""
if seconds:
value_str = f"{seconds} seconds"
if minutes:
if value_str:
value_str = f"{minutes} minutes, " + value_str
else:
value_str = f"{minutes} minutes"
days = int(value / 86400)
remainder = value % 86400
hours = int(remainder / 3600)
minutes = int((remainder % 3600) / 60)
seconds = remainder % 60
parts = []
if days:
parts.append(f"{days} day{'s' if days != 1 else ''}")
if hours:
if value_str:
value_str = f"{hours} hours, " + value_str
else:
value_str = f"{hours} hours"
return value_str
parts.append(f"{hours} hour{'s' if hours != 1 else ''}")
if minutes:
parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}")
if seconds or not parts:
parts.append(f"{seconds} second{'s' if seconds != 1 else ''}")
if len(parts) == 1:
return parts[0]
return ", ".join(parts[:-1]) + " and " + parts[-1]

View File

@ -71,6 +71,7 @@ class VideoMetadata:
twitch_id: Optional[str] = "",
base_run_time_seconds: int = 900,
):
self.title = ""
self.imdb_id = imdb_id
self.youtube_id = youtube_id
self.twitch_id = twitch_id

View File

@ -20,6 +20,7 @@ from scrobbles.mixins import (
)
from taggit.managers import TaggableManager
from videos.metadata import VideoMetadata
from videos.sources.omdb import lookup_video_from_omdb
from videos.sources.tmdb import lookup_video_from_tmdb
from videos.sources.youtube import lookup_video_from_youtube
@ -224,6 +225,8 @@ class Series(TimeStampedModel):
def is_episode_playing(self, user_id: int) -> bool:
last_scrobble = self.scrobbles_for_user(user_id, include_playing=True).first()
if not last_scrobble:
return False
return not last_scrobble.played_to_completion
def fix_metadata(self, force_update=False):
@ -255,9 +258,20 @@ class Series(TimeStampedModel):
logger.info("Series not created and overwrite=False, returning")
return series
vdict, _, cover, genres = lookup_video_from_tmdb(
imdb_id
).as_dict_with_cover_and_genres()
try:
metadata = lookup_video_from_tmdb(imdb_id)
except Exception as e:
logger.warning(f"TMDB lookup failed for series {imdb_id}: {e}")
metadata = None
if not metadata or not metadata.title:
metadata = lookup_video_from_omdb(imdb_id)
if not metadata or not metadata.title:
logger.warning(f"No metadata found for series {imdb_id} from TMDB or OMDB")
return series
vdict, _, cover, genres = metadata.as_dict_with_cover_and_genres()
vdict.pop("video_type")
vdict["name"] = vdict.pop("title")
@ -406,6 +420,84 @@ class Video(ScrobblableMixin):
fname = f"{self.title}_{self.uuid}.jpg"
self.cover_image.save(fname, ContentFile(r.content), save=True)
def fix_metadata(self, force_update: bool = False) -> None:
if self.video_type == self.VideoType.YOUTUBE and self.youtube_id:
vdict, _, cover, genres = lookup_video_from_youtube(
self.youtube_id
).as_dict_with_cover_and_genres()
for k, v in vdict.items():
setattr(self, k, v)
self.save()
if cover:
self.save_image_from_url(cover)
if genres:
self.genre.add(*genres)
return
if self.video_type == self.VideoType.TWITCH and self.twitch_id:
from videos.sources.twitch import lookup_video_from_twitch
metadata = lookup_video_from_twitch(self.twitch_id)
self.title = metadata.title or f"Twitch VOD {self.twitch_id}"
self.overview = metadata.overview
self.base_run_time_seconds = metadata.base_run_time_seconds
if metadata.upload_date:
self.upload_date = metadata.upload_date
if metadata.year:
self.year = metadata.year
self.video_type = Video.VideoType.TWITCH
if metadata.channel_id:
from videos.models import Channel
self.channel = Channel.objects.filter(
id=metadata.channel_id
).first()
self.save()
if metadata.cover_url:
self.save_image_from_url(metadata.cover_url)
return
if self.imdb_id:
try:
metadata = lookup_video_from_tmdb(self.imdb_id)
except Exception as e:
logger.warning(f"TMDB lookup failed for {self.imdb_id}: {e}")
metadata = None
if not metadata or not metadata.title:
metadata = lookup_video_from_omdb(self.imdb_id)
if not metadata or not metadata.title:
logger.warning(f"No metadata found for {self} from TMDB or OMDB")
return
vdict, series_id, cover, genres = (
metadata.as_dict_with_cover_and_genres()
)
for k, v in vdict.items():
setattr(self, k, v)
if series_id:
self.tv_series = Series.find_or_create(imdb_id=series_id)
self.save()
if cover:
self.save_image_from_url(cover)
if genres:
self.genre.add(*genres)
return
logger.warning(
f"No metadata source available for {self} (type={self.video_type})"
)
@classmethod
def get_from_youtube_id(cls, youtube_id: str, overwrite: bool = False) -> "Video":
video, created = cls.objects.get_or_create(youtube_id=youtube_id)
@ -432,9 +524,20 @@ class Video(ScrobblableMixin):
if not created and not overwrite:
return video
vdict, series_id, cover, genres = lookup_video_from_tmdb(
imdb_id
).as_dict_with_cover_and_genres()
try:
metadata = lookup_video_from_tmdb(imdb_id)
except Exception as e:
logger.warning(f"TMDB lookup failed for {imdb_id}: {e}")
metadata = None
if not metadata or not metadata.title:
metadata = lookup_video_from_omdb(imdb_id)
if not metadata or not metadata.title:
logger.warning(f"No metadata found for {imdb_id} from TMDB or OMDB")
return video
vdict, series_id, cover, genres = metadata.as_dict_with_cover_and_genres()
if created or overwrite:
for k, v in vdict.items():

View File

@ -0,0 +1,83 @@
import logging
import re
from typing import Optional
import requests
from django.conf import settings
from videos.metadata import VideoMetadata, VideoType
logger = logging.getLogger(__name__)
OMDB_API_KEY = getattr(settings, "OMDB_API_KEY", "")
OMDB_URL = "https://www.omdbapi.com/"
RUNTIME_RE = re.compile(r"(\d+)\s*min")
def lookup_video_from_omdb(imdb_id: str) -> Optional[VideoMetadata]:
if not imdb_id.startswith("tt"):
imdb_id = f"tt{imdb_id}"
if not OMDB_API_KEY:
logger.warning("No OMDB API key configured")
return None
params = {"apikey": OMDB_API_KEY, "i": imdb_id, "plot": "full"}
try:
response = requests.get(OMDB_URL, params=params)
response.raise_for_status()
data = response.json()
except requests.exceptions.RequestException as e:
logger.error(f"OMDB API error for {imdb_id}: {e}")
return None
if data.get("Response") == "False":
logger.info(f"OMDB no result for {imdb_id}: {data.get('Error')}")
return None
metadata = VideoMetadata(imdb_id=imdb_id)
metadata.title = data.get("Title")
metadata.plot = data.get("Plot")
metadata.overview = data.get("Plot")
raw_year = data.get("Year")
if raw_year and raw_year.isdigit():
metadata.year = int(raw_year)
raw_rating = data.get("imdbRating")
if raw_rating and raw_rating != "N/A":
metadata.imdb_rating = raw_rating
raw_cover = data.get("Poster")
if raw_cover and raw_cover != "N/A":
metadata.cover_url = raw_cover
raw_runtime = data.get("Runtime")
if raw_runtime:
match = RUNTIME_RE.match(raw_runtime)
if match:
metadata.base_run_time_seconds = int(match.group(1)) * 60
media_type = data.get("Type")
if media_type == "movie":
metadata.video_type = VideoType.MOVIE.value
elif media_type in ("series", "episode"):
metadata.video_type = VideoType.TV_EPISODE.value
if media_type == "episode":
raw_season = data.get("Season")
if raw_season and raw_season != "N/A":
metadata.season_number = int(raw_season)
raw_episode = data.get("Episode")
if raw_episode and raw_episode != "N/A":
metadata.episode_number = int(raw_episode)
series_imdb_id = data.get("seriesID")
if series_imdb_id and series_imdb_id != "N/A":
metadata.tv_series_imdb_id = series_imdb_id
raw_genres = data.get("Genre")
if raw_genres:
metadata.genres = [g.strip() for g in raw_genres.split(",") if g.strip()]
return metadata

View File

@ -3,14 +3,11 @@ import logging
import pendulum
from django.conf import settings
from themoviedb import TMDb
from tmdbv3api import TV, Movie, TMDb as TMDb_direct
from tmdbv3api import TV, Movie
from videos.metadata import VideoMetadata, VideoType
TMDB_KEY = getattr(settings, "TMDB_API_KEY", "")
tmdb_direct = TMDb_direct()
tmdb_direct.api_key = TMDB_KEY
tmdb = TMDb(key=TMDB_KEY, language="en-US", region="US")
TMDB_IMAGE_URL = "https://image.tmdb.org/t/p/original"
@ -36,7 +33,7 @@ def lookup_video_from_tmdb(name_or_id: str, kind: str = "movie") -> VideoMetadat
video_metadata = VideoMetadata(imdb_id=imdb_id)
media = None
show = None
show_data = None
if len(tmdb_result.movie_results) > 0:
media = Movie().details(tmdb_result.movie_results[0].id)
video_metadata.video_type = VideoType.MOVIE.value

View File

@ -32,7 +32,11 @@ class SeriesDetailView(LoginRequiredMixin, ChartContextMixin, generic.DetailView
context_data = super().get_context_data(**kwargs)
context_data["scrobbles"] = self.object.scrobbles_for_user(user_id)
next_episode_id = self.object.last_scrobbled_episode(user_id).next_imdb_id or ""
next_episode_id = ""
if self.object.last_scrobbled_episode(user_id):
next_episode_id = (
self.object.last_scrobbled_episode(user_id).next_imdb_id or ""
)
if self.object.is_episode_playing(user_id):
next_episode_id = ""
if next_episode_id:

View File

@ -14,11 +14,12 @@ def version_info(request):
if not commit:
# Try to import from _commit.py module first
try:
from vrobbler._commit import commit
from vrobbler._commit import commit as _commit
except ImportError:
pass
else:
return {"app_version": app_version, "git_commit": commit}
if _commit and _commit != "unknown":
return {"app_version": app_version, "git_commit": _commit}
# Try to read from commit file (written during deploy)
commit_file = Path("/var/lib/vrobbler/commit.txt")

View File

@ -60,6 +60,7 @@ THEAUDIODB_API_KEY = os.getenv("VROBBLER_THEAUDIODB_API_KEY", "2")
PODCASTINDEX_API_KEY = os.getenv("VROBBLER_PODCASTINDEX_API_KEY", "")
PODCASTINDEX_API_SECRET = os.getenv("VROBBLER_PODCASTINDEX_API_SECRET", "")
TMDB_API_KEY = os.getenv("VROBBLER_TMDB_API_KEY", "")
OMDB_API_KEY = os.getenv("VROBBLER_OMDB_API_KEY", "")
LASTFM_API_KEY = os.getenv("VROBBLER_LASTFM_API_KEY")
LASTFM_SECRET_KEY = os.getenv("VROBBLER_LASTFM_SECRET_KEY")
IGDB_CLIENT_ID = os.getenv("VROBBLER_IGDB_CLIENT_ID")
@ -144,7 +145,7 @@ CELERY_BEAT_SCHEDULE = {
},
"import-from-webdav": {
"task": "scrobbles.tasks.import_from_webdav_all_users",
"schedule": crontab(minute="*/3"),
"schedule": crontab(minute="*/2"),
},
# Deprecated: BG Stats files now picked up from WebDAV var/bgstats/
# "import-from-imap": {
@ -163,6 +164,10 @@ CELERY_BEAT_SCHEDULE = {
"task": "scrobbles.tasks.send_mood_checkin",
"schedule": crontab(hour="*/4", minute=0),
},
"backfill-scrobble-sentiment": {
"task": "scrobbles.tasks.backfill_scrobble_sentiment",
"schedule": crontab(minute="0"),
},
}
INSTALLED_APPS = [

View File

@ -168,6 +168,35 @@
}
#scrobble-form { width: 100% }
.notes-list {
max-width: 600px;
}
.note-item {
padding: 4px 0;
}
.note-timestamp {
font-size: 0.8em;
color: #666;
margin: 0 0 4px;
}
.note-content {
font-size: 0.95em;
line-height: 1.5;
}
.note-content p:last-child {
margin-bottom: 0;
}
.note-divider {
margin: 8px 0;
border: 0;
border-top: 1px solid #ddd;
}
.sticky-notes-container {
display: flex;
flex-wrap: wrap;
@ -218,6 +247,9 @@
a:not(.nav-link):not(.btn):not(.page-link):hover {
color: var(--accent);
}
a.badge {
color: #fff !important;
}
.table-striped > tbody > tr:nth-of-type(odd) {
background-color: color-mix(in srgb, var(--accent) 6%, #fff);
}
@ -283,8 +315,7 @@
{% for scrobble in now_playing_list %}
<div class="now-playing">
{% if scrobble.media_obj.primary_image_url %}<div style="float:left;padding-right:10px;padding-bottom:10px;"><img src="{{scrobble.media_obj.primary_image_url}}" /></div>{% endif %}
<p><a href="{{scrobble.media_obj.get_absolute_url}}">{{scrobble.media_obj.title}}</a></p>
{% if scrobble.media_obj.subtitle %}<p><em><a href="{{scrobble.media_obj.subtitle.get_absolute_url}}">{{scrobble.media_obj.subtitle}}</a></em></p>{% endif %}
<p><a href="{{scrobble.get_absolute_url}}">{{scrobble.media_obj}}</a></p>
{% if scrobble.logdata %}{% if scrobble.logdata.title%}<p><em>{{scrobble.logdata.title}}</em></p>{% endif %}{% endif %}
<p><small>{{scrobble.local_timestamp|naturaltime}} from {{scrobble.source}}</small></p>
<div class="progress-bar" style="margin-right:5px;">

View File

@ -15,6 +15,7 @@
<a href="{% url 'charts:charts-home' %}" class="btn btn-sm btn-outline-secondary">Charts</a>
</div>
{% endif %}
{% block grid_view_button %}
<div class="btn-group me-2">
{% if view == 'grid' %}
<button type="button" class="btn btn-sm btn-outline-secondary"><a href="?{% urlreplace view='list' %}">List View</a>
@ -22,6 +23,7 @@
<button type="button" class="btn btn-sm btn-outline-secondary"><a href="?{% urlreplace view='grid' %}">Grid View</a>
{% endif %}
</div>
{% endblock %}
{% block charts_button %}{% endblock %}
</div>
</div>

View File

@ -1,4 +1,5 @@
{% extends "base_list.html" %}
{% load static %}
{% block title %}Charts{% if period_str %} - {{ period_str }}{% endif %}{% endblock %}
@ -12,11 +13,46 @@
.nav-tabs {
cursor: pointer;
}
.image-wrapper {
contain: content;
}
.image-wrapper :hover {
background: rgba(0,0,0,0.3);
}
.caption {
position: fixed;
top: 5px;
left: 5px;
padding: 3px;
font-size: 90%;
color: white;
background: rgba(0,0,0,0.4);
}
.caption-medium {
position: fixed;
top: 5px;
left: 5px;
padding: 3px;
font-size: 75%;
color: white;
background: rgba(0,0,0,0.4);
}
.caption-small {
position: fixed;
top: 5px;
left: 5px;
padding: 3px;
font-size: 60%;
color: white;
background: rgba(0,0,0,0.4);
}
</style>
{% endblock %}
{% block lists %}
{% block grid_view_button %}{% endblock %}
<div class="row mb-3">
<div class="col-12">
<a href="{% url 'charts:spotify-tracks' %}" class="btn btn-sm btn-outline-secondary">🎵 Spotify Tracks</a>
@ -24,10 +60,6 @@
</div>
</div>
{% if chart_type == "maloja" %}
{% include "scrobbles/_top_charts.html" %}
{% else %}
<div class="row">
<div class="col-12">
<div class="btn-group mb-3" role="group">
@ -88,43 +120,9 @@
</div>
{% endif %}
{% include "scrobbles/_top_charts.html" %}
<div class="row">
{% if charts.artist %}
<div class="col-md-6 col-lg-4 chart-section">
<h3>🎤 Top Artists</h3>
<ul class="list-group">
{% for chart in charts.artist|slice:":20" %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span class="me-2"><strong>#{{chart.rank}}</strong></span>
<a href="{{chart.artist.get_absolute_url}}">{{chart.artist.name}}</a>
<span class="badge bg-primary rounded-pill">{{chart.count}}</span>
</li>
{% endfor %}
<li class="list-group-item">
<a href="{% url 'charts:chart-detail' 'artist' %}">View all &raquo;</a>
</li>
</ul>
</div>
{% endif %}
{% if charts.album %}
<div class="col-md-6 col-lg-4 chart-section">
<h3>💿 Top Albums</h3>
<ul class="list-group">
{% for chart in charts.album|slice:":20" %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span class="me-2"><strong>#{{chart.rank}}</strong></span>
<a href="{{chart.album.get_absolute_url}}">{{chart.album.name}}</a>
<span class="badge bg-primary rounded-pill">{{chart.count}}</span>
</li>
{% endfor %}
<li class="list-group-item">
<a href="{% url 'charts:chart-detail' 'album' %}">View all &raquo;</a>
</li>
</ul>
</div>
{% endif %}
{% if charts.track %}
<div class="col-md-6 col-lg-4 chart-section">
<h3>🎵 Top Tracks</h3>
@ -143,24 +141,6 @@
</div>
{% endif %}
{% if charts.tv_series %}
<div class="col-md-6 col-lg-4 chart-section">
<h3>📺 Top TV Series</h3>
<ul class="list-group">
{% for chart in charts.tv_series|slice:":20" %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span class="me-2"><strong>#{{chart.rank}}</strong></span>
<a href="{{chart.tv_series.get_absolute_url}}">{{chart.tv_series.name}}</a>
<span class="badge bg-primary rounded-pill">{{chart.count}}</span>
</li>
{% endfor %}
<li class="list-group-item">
<a href="{% url 'charts:chart-detail' 'tv_series' %}">View all &raquo;</a>
</li>
</ul>
</div>
{% endif %}
{% if charts.video %}
<div class="col-md-6 col-lg-4 chart-section">
<h3>🎬 Top Videos</h3>
@ -258,7 +238,7 @@
{% for chart in charts.trail|slice:":20" %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span class="me-2"><strong>#{{chart.rank}}</strong></span>
<a href="{{chart.trail.get_absolute_url}}">{{chart.trail.name}}</a>
<a href="{{chart.trail.get_absolute_url}}">{{chart.trail.title}}</a>
<span class="badge bg-primary rounded-pill">{{chart.count}}</span>
</li>
{% endfor %}
@ -314,6 +294,4 @@
</div>
{% endif %}
{% endif %}
{% endblock %}

View File

@ -53,13 +53,15 @@
<tr>
<th scope="col">Artist</th>
<th scope="col">Score</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{% for sa in similar_artists|slice:":10" %}
{% for sa in similar_artists %}
<tr>
<td>{% if sa.local_url %}<a href="{{sa.local_url}}">{{sa.name}}</a>{% else %}{{sa.name}}{% endif %}</td>
<td>{{sa.score}}</td>
<td>{% if sa.musicbrainz_url %}<a href="{{sa.musicbrainz_url}}">musicbrainz</a>{% endif %}</td>
</tr>
{% endfor %}
</tbody>

View File

@ -106,6 +106,7 @@
{% block title %}Settings{% endblock %}
{% block details %}
<p class="settings-link"><a href="{% url 'people:person_form' %}">Manage People</a></p>
<p class="settings-link"><a href="{% url 'profiles:bulk_visibility' %}">Scrobble Visibility Settings</a></p>
<form method="post" class="settings-form">{% csrf_token %}
{% for field in form %}
{% if field.name == "enable_public_widgets" %}

View File

@ -0,0 +1,117 @@
{% extends "base_detail.html" %}
{% block title %}Scrobble Visibility Settings{% endblock %}
{% block head_extra %}
<style>
.vis-form {
max-width: 700px;
}
.vis-form h3 {
margin-top: 24px;
margin-bottom: 12px;
}
.vis-form h4 {
margin-top: 20px;
margin-bottom: 8px;
font-size: 1.1rem;
}
.vis-form label {
font-weight: 600;
margin-right: 12px;
}
.vis-form .bulk-radio-group {
margin: 8px 0 16px 0;
}
.vis-form .bulk-radio-group label {
font-weight: normal;
display: block;
margin: 4px 0;
}
.vis-form .media-type-row {
display: flex;
align-items: center;
gap: 12px;
margin: 6px 0;
padding: 4px 8px;
border-radius: 4px;
}
.vis-form .media-type-row:nth-child(even) {
background: #f8f9fa;
}
.vis-form .media-type-row label {
font-weight: normal;
min-width: 130px;
}
.vis-form select {
padding: 4px 8px;
border: 1px solid #ccc;
border-radius: 4px;
}
.vis-form input[type="submit"] {
margin-top: 20px;
padding: 10px 24px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.vis-form input[type="submit"]:hover {
background: #0056b3;
}
.vis-stats {
margin: 16px 0;
padding: 12px;
background: #f8f9fa;
border-radius: 4px;
}
.vis-stats strong {
display: block;
margin-bottom: 6px;
}
.vis-stats ul {
margin: 0;
padding-left: 18px;
}
</style>
{% endblock %}
{% block details %}
<p><a href="{% url 'profiles:profile_settings' %}">&laquo; Back to settings</a></p>
<div class="vis-stats">
<strong>Current scrobble visibility ({{ scrobble_count }} total)</strong>
<ul>
{% for item in visibility_counts %}
<li><a href="{% url 'scrobbles:scrobble-list' %}?visibility={{ item.visibility }}">{{ item.visibility|title }}</a>: {{ item.count }}</li>
{% endfor %}
</ul>
</div>
<form method="post" class="vis-form">{% csrf_token %}
<h3>Bulk Update</h3>
<p>Choose a visibility to apply to all scrobbles that are <strong>not</strong> currently "Shared".</p>
<div class="bulk-radio-group">
{% for radio in form.bulk_action %}
<label>{{ radio.tag }} {{ radio.choice_label }}</label>
{% endfor %}
</div>
<details>
<summary><h3 style="display:inline">Per-Media-Type Defaults</h3></summary>
<p>Override default visibility for new scrobbles of specific types.</p>
{% for field in form %}
{% if field.name != "bulk_action" %}
<div class="media-type-row">
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
</div>
{% endif %}
{% endfor %}
</details>
<input type="submit" value="Save Visibility Settings">
</form>
{% endblock %}

View File

@ -76,44 +76,44 @@
</div>
<div class="row">
<h2>🎵 Top Tracks</h2>
<ul class="nav nav-tabs" id="trackTab" role="tablist">
<h2>💿 Top Albums</h2>
<ul class="nav nav-tabs" id="albumTab" role="tablist">
{% for key, name in chart_keys.items %}
<li class="nav-item" role="presentation">
<button class="nav-link {% if forloop.counter == 2 %}active{% endif %}"
id="track-{{key}}-tab" data-bs-toggle="tab" data-bs-target="#track-{{key}}"
id="album-{{key}}-tab" data-bs-toggle="tab" data-bs-target="#album-{{key}}"
type="button" role="tab">{{name}}</button>
</li>
{% endfor %}
</ul>
<div class="tab-content" id="trackTabContent" class="maloja-chart">
<div class="tab-content" id="albumTabContent" class="maloja-chart">
{% for key, name in chart_keys.items %}
{% with maloja_charts.track|get_item:key as tracks %}
<div class="tab-pane fade {% if forloop.counter == 2 %}show active{% endif %}" id="track-{{key}}" role="tabpanel">
{% if tracks.0 %}
{% with maloja_charts.album|get_item:key as albums %}
<div class="tab-pane fade {% if forloop.counter == 2 %}show active{% endif %}" id="album-{{key}}" role="tabpanel">
{% if albums.0 %}
<div style="display:block">
<div style="float:left;">
<div class="image-wrapper" style="display:flex; flex-wrap: wrap; margin:0">
<div class="caption">#1 {{tracks.0.track.title}}</div>
{% if tracks.0.track.album.cover_image %}
<a href="{{tracks.0.track.get_absolute_url}}"><img src="{{tracks.0.track.album.cover_image_medium.url}}" width="300px"></a>
<div class="caption">#1 {{albums.0.album.title}}</div>
{% if albums.0.album.cover_image %}
<a href="{{albums.0.album.get_absolute_url}}"><img src="{{albums.0.album.cover_image_medium.url}}" width="300px"></a>
{% else %}
<a href="{{tracks.0.track.get_absolute_url}}"><img src="{% static 'images/not-found.jpg' %}" width="300px"></a>
<a href="{{albums.0.album.get_absolute_url}}"><img src="{% static 'images/not-found.jpg' %}" width="300px"></a>
{% endif %}
</div>
</div>
<div style="float:left; width:300px;">
<div style="display:flex; flex-wrap: wrap;">
{% for i in "2345" %}
{% with tracks|get_item:forloop.counter as track %}
{% if track %}
{% with albums|get_item:forloop.counter as album %}
{% if album %}
<div class="image-wrapper" style="width:50%">
<div class="caption-medium">#{{forloop.counter|add:1}} {{track.track.title}}</div>
{% if track.track.album.cover_image %}
<a href="{{track.track.get_absolute_url}}"><img src="{{track.track.album.cover_image_medium.url}}" width="150px"></a>
<div class="caption-medium">#{{forloop.counter|add:1}} {{album.album.title}}</div>
{% if album.album.cover_image %}
<a href="{{album.album.get_absolute_url}}"><img src="{{album.album.cover_image_medium.url}}" width="150px"></a>
{% else %}
<a href="{{track.track.get_absolute_url}}"><img src="{% static 'images/not-found.jpg' %}" width="150px"></a>
<a href="{{album.album.get_absolute_url}}"><img src="{% static 'images/not-found.jpg' %}" width="150px"></a>
{% endif %}
</div>
{% endif %}
@ -124,14 +124,14 @@
<div style="float:left; width:300px;">
<div style="display:flex; flex-wrap: wrap;">
{% for i in "67891011121314" %}
{% with tracks|get_item:forloop.counter|add:5 as track %}
{% if track %}
{% with albums|get_item:forloop.counter|add:5 as album %}
{% if album %}
<div class="image-wrapper" style="width:33%">
<div class="caption-small">#{{forloop.counter|add:6}} {{track.track.title}}</div>
{% if track.track.album.cover_image %}
<a href="{{track.track.get_absolute_url}}"><img src="{{track.track.album.cover_image_medium.url}}" width="100px"></a>
<div class="caption-small">#{{forloop.counter|add:6}} {{album.album.title}}</div>
{% if album.album.cover_image %}
<a href="{{album.album.get_absolute_url}}"><img src="{{album.album.cover_image_medium.url}}" width="100px"></a>
{% else %}
<a href="{{track.track.get_absolute_url}}"><img src="{% static 'images/not-found.jpg' %}" width="100px"></a>
<a href="{{album.album.get_absolute_url}}"><img src="{% static 'images/not-found.jpg' %}" width="100px"></a>
{% endif %}
</div>
{% endif %}

View File

@ -9,6 +9,12 @@
<h1 class="h2">All Scrobbles</h1>
{% if tag_list %}
<h6 class="text-muted">Tagged {{ tag_list|join:", " }}</h6>
{% if total_time_seconds %}
<p class="text-muted small mb-0">Total time: {{ total_time_seconds|natural_duration }}</p>
{% endif %}
{% endif %}
{% if request.GET.visibility %}
<h6 class="text-muted">Filter: {{ request.GET.visibility|title }} scrobbles only</h6>
{% endif %}
</div>
</div>

View File

@ -26,6 +26,21 @@
height: 400px;
border-radius: 4px;
}
.heart-icon {
cursor: pointer;
transition: fill 0.2s, stroke 0.2s;
}
.heart-icon.favorited {
fill: #e74c3c;
stroke: #e74c3c;
}
.heart-icon:not(.favorited) {
fill: none;
stroke: #888;
}
.heart-icon:not(.favorited):hover {
stroke: #e74c3c;
}
</style>
{% endblock %}
@ -33,13 +48,82 @@
<div class="row">
<h1>
<h1 class="d-flex align-items-center gap-2">
{% if object.media_type == "Video" %}🎬{% elif object.media_type == "Track" %}🎵{% elif object.media_type == "PodcastEpisode" %}🎙️{% elif object.media_type == "SportEvent" %}⚽{% elif object.media_type == "Book" %}📚{% elif object.media_type == "Paper" %}📄{% elif object.media_type == "VideoGame" %}🎮{% elif object.media_type == "BoardGame" %}🎲{% elif object.media_type == "GeoLocation" %}📍{% elif object.media_type == "Trail" %}🥾{% elif object.media_type == "Beer" %}🍺{% elif object.media_type == "Puzzle" %}🧩{% elif object.media_type == "Food" %}🍔{% elif object.media_type == "Task" %}✅{% elif object.media_type == "WebPage" %}🌐{% elif object.media_type == "LifeEvent" %}🎉{% elif object.media_type == "Mood" %}😊{% elif object.media_type == "BrickSet" %}🧱{% elif object.media_type == "Channel" %}📺{% endif %}
{% if object.media_obj.get_absolute_url %}<a href="{{ object.media_obj.get_absolute_url }}">{% endif %}{{ object.media_obj }}{% if object.media_obj.get_absolute_url %}</a>{% endif %}</h1>
{% if object.media_obj.get_absolute_url %}
<a href="{{ object.media_obj.get_absolute_url }}">{% endif %}
{{ object.media_obj.title }}
{% if object.media_obj.get_absolute_url %}</a>
{% endif %}
{% if user.is_authenticated and object.media_obj %}
<button id="favorite-btn"
data-url="{% url 'scrobbles:toggle-favorite' object.media_type object.media_obj.id %}"
data-favorited="{{ is_favorited|yesno:'true,false' }}"
class="btn btn-sm p-0 border-0 bg-transparent">
<svg width="20" height="20" viewBox="0 0 24 24" class="heart-icon{% if is_favorited %} favorited{% endif %}" id="heart-svg">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
</svg>
</button>
{% endif %}
</h1>
<h2>{{ object.media_obj.subtitle }}</h2>
<p>
{% if object.media_type == "SportEvent" %}
{% for team in object.media_obj.teams.all %}
<img src="{{team.logo.url}}" width=150 />
{% endfor %}
{% endif %}
{% if object.media_type == "Task" and object.logdata.title %}
</p>
<h2>{{ object.logdata.title }}</h2>
{% endif %}
<h3 class="text-muted">{{ object.local_timestamp }}</h3>
{% if user.is_authenticated and object.user == user %}
<div class="mb-3 d-flex align-items-center gap-2 flex-wrap">
<span class="badge
{% if object.visibility == 'public' %}bg-success
{% elif object.visibility == 'shared' %}bg-warning text-dark
{% else %}bg-secondary
{% endif %}">
{{ object.get_visibility_display }}
</span>
<form method="post" action="{% url 'scrobbles:change-visibility' object.uuid %}" class="d-inline-flex align-items-center gap-1">
{% csrf_token %}
<select name="visibility" class="form-select form-select-sm" style="width:auto;" onchange="this.form.submit()">
<option value="private" {% if object.visibility == 'private' %}selected{% endif %}>Private</option>
<option value="shared" {% if object.visibility == 'shared' %}selected{% endif %}>Shared (link)</option>
<option value="public" {% if object.visibility == 'public' %}selected{% endif %}>Public (explore)</option>
</select>
</form>
{% if object.visibility == 'shared' and object.get_share_url %}
<span class="small text-muted">Share link:</span>
<code class="small" id="share-link">{{ request.scheme }}://{{ request.get_host }}{{ object.get_share_url }}</code>
<button class="btn btn-sm btn-outline-secondary" onclick="navigator.clipboard.writeText(document.getElementById('share-link').textContent.trim())">Copy</button>
<form method="post" action="{% url 'scrobbles:regenerate-share-token' object.uuid %}" class="d-inline">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-outline-warning">Regenerate</button>
</form>
{% if object.share_view_count %}
<span class="text-muted small ms-2">{{ object.share_view_count }} view{{ object.share_view_count|pluralize }}</span>
{% endif %}
<a href="{% url 'scrobbles:share-analytics' object.uuid %}" class="btn btn-sm btn-outline-info ms-2">Analytics</a>
{% endif %}
</div>
{% endif %}
{% if object.media_type == "Track" and has_mopidy_uri and user.profile.mopidy_api_url %}
<form method="post" action="{% url 'scrobbles:add-to-mopidy-queue' object.uuid %}" class="mb-1">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-outline-secondary">add to mopidy queue</button>
</form>
{% if user.profile.monthly_mopidy_playlist_pattern %}
<form method="post" action="{% url 'scrobbles:add-to-mopidy-monthly-playlist' object.uuid %}" class="mb-1">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-outline-secondary">add to monthly playlist</button>
</form>
{% endif %}
{% endif %}
{% if object.media_type == "Track" %}
<p class="text-muted small">Source: {{ object.source }}{% if object.log.mopidy_source %} ({{ object.log.mopidy_source|capfirst }}){% endif %}</p>
{% endif %}
@ -115,14 +199,35 @@
{% with notes_html=object.logdata.notes_as_html %}
{% if notes_html %}
<div class="mb-3">
<h5>Notes</h5>
<div class="sticky-notes-container">
<h4>Notes</h4>
<span class="badge fs-8
{% if sentiment.compound >= 0.5 %}bg-success
{% elif sentiment.compound >= 0.05 %}bg-info text-dark
{% elif sentiment.compound > -0.05 %}bg-secondary
{% elif sentiment.compound > -0.5 %}bg-warning text-dark
{% else %}bg-danger
{% endif %}">
{% if sentiment.compound >= 0.5 %}Positive
{% elif sentiment.compound >= 0.05 %}Slightly positive
{% elif sentiment.compound > -0.05 %}Neutral
{% elif sentiment.compound > -0.5 %}Slightly negative
{% else %}Negative
{% endif %}
</span>
<div class="notes-list">
{{ notes_html|safe }}
</div>
</div>
{% endif %}
{% endwith %}
{% with sentiment=object.log.sentiment %}
{% if sentiment %}
<div class="mb-3">
</div>
{% endif %}
{% endwith %}
{% if object.logdata.avg_seconds_per_page %}
<p>Rate: {{object.logdata.avg_seconds_per_page}}s per page</p>
{% endif %}
@ -220,6 +325,30 @@
{% block extra_js %}
{{ block.super }}
{{ log_form.media }}
<script>
function getCookie(name) {
let value = "; " + document.cookie;
let parts = value.split("; " + name + "=");
if (parts.length === 2) return parts.pop().split(";").shift();
}
document.getElementById("favorite-btn")?.addEventListener("click", function() {
var btn = this;
var url = btn.dataset.url;
fetch(url, {
method: "POST",
headers: {
"X-CSRFToken": getCookie("csrftoken"),
"X-Requested-With": "XMLHttpRequest",
},
})
.then(function(r) { return r.json(); })
.then(function(data) {
var heart = document.getElementById("heart-svg");
btn.dataset.favorited = data.is_favorited ? "true" : "false";
heart.classList.toggle("favorited", data.is_favorited);
});
});
</script>
{% if object.media_type == "Trail" and object.gpx_file %}
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdn.jsdelivr.net/npm/leaflet-gpx@2.2.0/gpx.min.js"></script>

View File

@ -0,0 +1,140 @@
{% extends "base.html" %}
{% load humanize %}
{% load naturalduration %}
{% block content %}
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4">
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">Explore Public Scrobbles</h1>
</div>
<p class="text-muted">Recent public scrobbles from all users.</p>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">Type</th>
<th scope="col">Title</th>
<th scope="col">User</th>
<th scope="col">Time</th>
</tr>
</thead>
<tbody>
{% for scrobble in object_list %}
<tr>
<td>
<a href="{{scrobble.get_absolute_url}}">{{ scrobble.timestamp|naturaltime }}</a>
</td>
<td>
{% if scrobble.video %}
🎬 Video
{% elif scrobble.track %}
🎵 Track
{% elif scrobble.podcast_episode %}
🎙️ Podcast episode
{% elif scrobble.sport_event %}
⚽ Sport event
{% elif scrobble.book %}
📖 Book
{% elif scrobble.paper %}
📄 Paper
{% elif scrobble.video_game %}
🎮 Video game
{% elif scrobble.board_game %}
🎲 Board game
{% elif scrobble.geo_location %}
📍 GeoLocation
{% elif scrobble.trail %}
🥾 Trail
{% elif scrobble.beer %}
🍺 Beer
{% elif scrobble.puzzle %}
🧩 Puzzle
{% elif scrobble.food %}
🍔 Food
{% elif scrobble.task %}
✅ Task
{% elif scrobble.web_page %}
🌐 Web Page
{% elif scrobble.life_event %}
🎉 Life event
{% elif scrobble.mood %}
😊 Mood
{% elif scrobble.brick_set %}
🧱 Brick set
{% elif scrobble.channel %}
📺 Channel
{% else %}
Unknown
{% endif %}
</td>
<td>
{% if scrobble.video %}
<a href="{% url 'videos:video_detail' scrobble.video.uuid %}">{{ scrobble.video.title }}</a>
{% elif scrobble.track %}
<a href="{% url 'music:track_detail' scrobble.track.uuid %}">{{ scrobble.track.title }}</a>
{% elif scrobble.video_game %}
<a href="{% url 'videogames:videogame_detail' scrobble.video_game.uuid %}">{{ scrobble.video_game.title }}</a>
{% elif scrobble.book %}
<a href="{% url 'books:book_detail' scrobble.book.uuid %}">{{ scrobble.book.title }}</a>
{% elif scrobble.food %}
<a href="{% url 'foods:food_detail' scrobble.food.uuid %}">{{ scrobble.food.title }}</a>
{% elif scrobble.beer %}
<a href="{% url 'beers:beer_detail' scrobble.beer.uuid %}">{{ scrobble.beer.title }}</a>
{% elif scrobble.web_page %}
<a href="{% url 'webpages:webpage_detail' scrobble.web_page.uuid %}">{{ scrobble.web_page.title }}</a>
{% elif scrobble.podcast_episode %}
<a href="{% url 'podcasts:podcast_detail' scrobble.podcast_episode.podcast.id %}">{{ scrobble.podcast_episode.title }}</a>
{% elif scrobble.board_game %}
<a href="{% url 'boardgames:boardgame_detail' scrobble.board_game.uuid %}">{{ scrobble.board_game.title }}</a>
{% elif scrobble.trail %}
<a href="{% url 'trails:trail_detail' scrobble.trail.uuid %}">{{ scrobble.trail.title }}</a>
{% elif scrobble.puzzle %}
<a href="{% url 'puzzles:puzzle_detail' scrobble.puzzle.uuid %}">{{ scrobble.puzzle.title }}</a>
{% elif scrobble.brick_set %}
<a href="{% url 'bricksets:brickset_detail' scrobble.brick_set.uuid %}">{{ scrobble.brick_set.title }}</a>
{% elif scrobble.task %}
<a href="{% url 'tasks:task_detail' scrobble.task.uuid %}">{{scrobble.media_obj}}{% if scrobble.log.title %} - {{ scrobble.log.title }}{% endif %}</a>
{% elif scrobble.life_event %}
<a href="{% url 'lifeevents:lifeevent_detail' scrobble.life_event.uuid %}">{{ scrobble.life_event.title }}</a>
{% elif scrobble.mood %}
<a href="{% url 'moods:mood_detail' scrobble.mood.uuid %}">{{ scrobble.mood.title}}</a>
{% elif scrobble.geo_location %}
<a href="{% url 'locations:geolocation_detail' scrobble.geo_location.uuid %}">{{ scrobble.geo_location.title }}</a>
{% else %}
Unknown
{% endif %}
</td>
<td>{{ scrobble.user.username }}</td>
<td>
{% if scrobble.playback_position_seconds %}
{{ scrobble.playback_position_seconds|natural_duration }}
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="5">No public scrobbles found.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if page_obj.has_previous or page_obj.has_next %}
<nav>
<ul class="pagination">
{% if page_obj.has_previous %}
<li class="page-item"><a class="page-link" href="?page={{ page_obj.previous_page_number }}">Previous</a></li>
{% endif %}
<li class="page-item"><span class="page-link">Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span></li>
{% if page_obj.has_next %}
<li class="page-item"><a class="page-link" href="?page={{ page_obj.next_page_number }}">Next</a></li>
{% endif %}
</ul>
</nav>
{% endif %}
</main>
{% endblock %}

View File

@ -0,0 +1,200 @@
{% extends "base_list.html" %}
{% load form_tags %}
{% load mathfilters %}
{% load naturalduration %}
{% load static %}
{% block title %}{{object.name}}{% endblock %}
{% block head_extra %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
dl { border:none; }
dt {
background: #333;
color: #fff;
}
dd {
float:left;
margin: 2px;
padding: 4px;
min-height: 1em;
border: none;
}
#map {
height: 400px;
border-radius: 4px;
}
</style>
{% endblock %}
{% block lists %}
<div class="row">
<div class="alert alert-info">
Shared via link
</div>
<h1 class="d-flex align-items-center gap-2">
{% if object.media_type == "Video" %}🎬{% elif object.media_type == "Track" %}🎵{% elif object.media_type == "PodcastEpisode" %}🎙️{% elif object.media_type == "SportEvent" %}⚽{% elif object.media_type == "Book" %}📚{% elif object.media_type == "Paper" %}📄{% elif object.media_type == "VideoGame" %}🎮{% elif object.media_type == "BoardGame" %}🎲{% elif object.media_type == "GeoLocation" %}📍{% elif object.media_type == "Trail" %}🥾{% elif object.media_type == "Beer" %}🍺{% elif object.media_type == "Puzzle" %}🧩{% elif object.media_type == "Food" %}🍔{% elif object.media_type == "Task" %}✅{% elif object.media_type == "WebPage" %}🌐{% elif object.media_type == "LifeEvent" %}🎉{% elif object.media_type == "Mood" %}😊{% elif object.media_type == "BrickSet" %}🧱{% elif object.media_type == "Channel" %}📺{% endif %}
{% if object.media_obj.get_absolute_url %}
<a href="{{ object.media_obj.get_absolute_url }}">{% endif %}
{{ object.media_obj.title }}
{% if object.media_obj.get_absolute_url %}</a>
{% endif %}
</h1>
<h2>{{ object.media_obj.subtitle }}</h2>
<p>
{% if object.media_type == "SportEvent" %}
{% for team in object.media_obj.teams.all %}
<img src="{{team.logo.url}}" width=150 />
{% endfor %}
{% endif %}
{% if object.media_type == "Task" and object.logdata.title %}
</p>
<h2>{{ object.logdata.title }}</h2>
{% endif %}
<h3 class="text-muted">{{ object.local_timestamp }}</h3>
{% if object.media_type == "Track" %}
<p class="text-muted small">Source: {{ object.source }}{% if object.log.mopidy_source %} ({{ object.log.mopidy_source|capfirst }}){% endif %}</p>
{% endif %}
{% if object.media_type == "Task" and object.log.weight %}
<div class="mb-3">
<dl class="row" style="max-width: 400px;">
<dt class="col-sm-5">Weight</dt>
<dd class="col-sm-7">{{ object.log.weight }} {% if object.log.unit_type == "imperial" %}lbs{% else %}kg{% endif %}</dd>
{% if object.log.body_fat %}
<dt class="col-sm-5">Body Fat</dt>
<dd class="col-sm-7">{{ object.log.body_fat }}%</dd>
{% endif %}
{% if object.log.bmi %}
<dt class="col-sm-5">BMI</dt>
<dd class="col-sm-7">{{ object.log.bmi }}</dd>
{% endif %}
{% if object.log.muscle %}
<dt class="col-sm-5">Muscle</dt>
<dd class="col-sm-7">{{ object.log.muscle }} {% if object.log.unit_type == "imperial" %}lbs{% else %}kg{% endif %}</dd>
{% endif %}
{% if object.log.bone %}
<dt class="col-sm-5">Bone</dt>
<dd class="col-sm-7">{{ object.log.bone }} {% if object.log.unit_type == "imperial" %}lbs{% else %}kg{% endif %}</dd>
{% endif %}
{% if object.log.water %}
<dt class="col-sm-5">Water</dt>
<dd class="col-sm-7">{{ object.log.water }}%</dd>
{% endif %}
{% if object.log.visceral_fat %}
<dt class="col-sm-5">Visceral Fat</dt>
<dd class="col-sm-7">{{ object.log.visceral_fat }}</dd>
{% endif %}
{% if object.log.waist %}
<dt class="col-sm-5">Waist</dt>
<dd class="col-sm-7">{{ object.log.waist }} {% if object.log.unit_type == "imperial" %}in{% else %}cm{% endif %}</dd>
{% endif %}
{% if object.log.lbm %}
<dt class="col-sm-5">Lean Mass</dt>
<dd class="col-sm-7">{{ object.log.lbm }} {% if object.log.unit_type == "imperial" %}lbs{% else %}kg{% endif %}</dd>
{% endif %}
{% if object.log.calories %}
<dt class="col-sm-5">Calories</dt>
<dd class="col-sm-7">{{ object.log.calories }}</dd>
{% endif %}
{% if object.log.comment %}
<dt class="col-sm-5">Comment</dt>
<dd class="col-sm-7">{{ object.log.comment }}</dd>
{% endif %}
</dl>
</div>
{% endif %}
{% if object.media_type == "Task" and object.logdata.description %}
<p>{{ object.logdata.description }}</p>
{% endif %}
{% if object.media_type == "Trail" and object.gpx_file %}
<div class="mb-3">
<div id="map"></div>
</div>
{% endif %}
<p>
Tags:
{% if object.tags.all %}
{% for tag in object.tags.all %}
<span class="badge bg-secondary">{{ tag.name }}</span>
{% endfor %}
{% else %}
untagged
{% endif %}
</p>
{% with notes_html=object.logdata.notes_as_html %}
{% if notes_html %}
<div class="mb-3">
<h4>Notes</h4>
<span class="badge fs-8
{% if sentiment.compound >= 0.5 %}bg-success
{% elif sentiment.compound >= 0.05 %}bg-info text-dark
{% elif sentiment.compound > -0.05 %}bg-secondary
{% elif sentiment.compound > -0.5 %}bg-warning text-dark
{% else %}bg-danger
{% endif %}">
{% if sentiment.compound >= 0.5 %}Positive
{% elif sentiment.compound >= 0.05 %}Slightly positive
{% elif sentiment.compound > -0.05 %}Neutral
{% elif sentiment.compound > -0.5 %}Slightly negative
{% else %}Negative
{% endif %}
</span>
<div class="notes-list">
{{ notes_html|safe }}
</div>
</div>
{% endif %}
{% endwith %}
{% with sentiment=object.log.sentiment %}
{% if sentiment %}
<div class="mb-3">
</div>
{% endif %}
{% endwith %}
{% if object.logdata.avg_seconds_per_page %}
<p>Rate: {{object.logdata.avg_seconds_per_page}}s per page</p>
{% endif %}
{% if object.media_type == "BoardGame" and object.logdata.as_html %}
<div class="mb-3">
<h5>Game Details</h5>
{{ object.logdata.as_html|safe }}
</div>
{% endif %}
</div>
{% endblock %}
{% block extra_js %}
{{ block.super }}
{% if object.media_type == "Trail" and object.gpx_file %}
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdn.jsdelivr.net/npm/leaflet-gpx@2.2.0/gpx.min.js"></script>
<script>
var map = L.map('map');
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>',
referrerPolicy: 'origin'
}).addTo(map);
var gpx = new L.GPX("{{ object.gpx_file.url|escapejs }}", {
async: true,
polyline_options: { color: '#e74c3c' }
});
gpx.on('loaded', function(e) {
map.fitBounds(e.target.getBounds());
});
gpx.addTo(map);
</script>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,68 @@
{% extends "base_list.html" %}
{% load naturalduration %}
{% block title %}Share Analytics for {{ object.media_obj.title }}{% endblock %}
{% block lists %}
<div class="row">
<h1>Share Analytics</h1>
<h2 class="text-muted">{{ object.media_obj.title }}</h2>
<div class="mb-3">
<span class="badge
{% if object.visibility == 'public' %}bg-success
{% elif object.visibility == 'shared' %}bg-warning text-dark
{% else %}bg-secondary
{% endif %}">
{{ object.get_visibility_display }}
</span>
<span class="ms-2"><strong>{{ object.share_view_count }}</strong> view{{ object.share_view_count|pluralize }}</span>
</div>
{% if object.get_share_url %}
<div class="mb-3">
<span class="text-muted">Share link:</span>
<code class="small">{{ request.scheme }}://{{ request.get_host }}{{ object.get_share_url }}</code>
</div>
{% endif %}
<h3 class="mt-4">View History</h3>
{% if share_views %}
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Time</th>
<th scope="col">IP Address</th>
<th scope="col">Referrer</th>
<th scope="col">User Agent</th>
</tr>
</thead>
<tbody>
{% for view in share_views %}
<tr>
<td>{{ view.created|date:"M d, Y H:i" }}</td>
<td><code>{{ view.ip_address|default:"-" }}</code></td>
<td class="text-truncate" style="max-width: 200px;">
{% if view.referrer %}
<a href="{{ view.referrer }}" target="_blank" rel="noopener">{{ view.referrer }}</a>
{% else %}-{% endif %}
</td>
<td class="text-truncate" style="max-width: 300px;">
<span title="{{ view.user_agent }}">{{ view.user_agent|default:"-"|truncatechars:60 }}</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-muted">No views yet. Share the link to see who visits.</p>
{% endif %}
<a href="{{ object.get_absolute_url }}" class="btn btn-secondary">Back to scrobble</a>
</div>
{% endblock %}

View File

@ -110,7 +110,7 @@
{% if sporting %}
<div class="titles">
<p><a href="{{sporting.media_obj.get_absolute_url}}">{{sporting.media_obj}}</a></p>
{% if sporting.media_obj.subtitle %}<p><em><a href="{{sporting.media_obj.subtitle.get_absolute_url}}">{{sporting.media_obj.subtitle}}</a></em></p>{% endif %}
{% if sporting.media_obj.subtitle %}<p><em><a href="{{sporting.get_absolute_url}}">{{sporting.media_obj.subtitle}}</a></em></p>{% endif %}
</div>
<p><small>{{sporting.timestamp|naturaltime}} from {{sporting.source}}</small></p>
{% else %}

View File

@ -1,12 +1,16 @@
{% extends "base_detail.html" %}
{% block title %}{{object.title}} - {{object.round.season.league}}{% endblock %}
{% block title %}{{object.title}}{% endblock %}
{% block details %}
<h2>{{object.league}} {{object.get_event_type_display}}</h2>
<div class="row">
<h2>{{object.tv_series}}</h2>
<div class="col-md">
{% for team in object.teams.all %}
<img src="{{team.logo.url}}" width=150 />
{% endfor %}
<hr />
<h3>Last scrobbles</h3>
<div class="table-responsive">
<table class="table table-striped table-sm">

View File

@ -2,6 +2,7 @@
{% load mathfilters %}
{% load static %}
{% load naturalduration %}
{% load l10n %}
{% block title %}{{object.title}}{% endblock %}
@ -39,6 +40,15 @@
</p>
</div>
</div>
{% if weighin_chart %}
<div class="row mb-4">
<div class="col-md-8">
<canvas id="weighinChart" width="800" height="300"></canvas>
</div>
</div>
{% endif %}
<div class="row">
<p>{{scrobbles.count}} scrobbles</p>
<p>
@ -94,3 +104,61 @@
{% endif %}
</div>
{% endblock %}
{% block extra_js %}
{% if weighin_chart %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@2.9.4/dist/Chart.min.js"></script>
<script>
var ctx = document.getElementById('weighinChart').getContext('2d');
new Chart(ctx, {
type: 'line',
data: {
labels: {{ weighin_chart.labels|safe }},
datasets: [
{
label: 'Weight',
data: {{ weighin_chart.datasets.0.data|safe }},
borderColor: '{{ weighin_chart.datasets.0.borderColor }}',
backgroundColor: 'transparent',
pointRadius: 2,
fill: false,
yAxisID: 'y',
},
{
label: 'Body Fat %',
data: {{ weighin_chart.datasets.1.data|safe }},
borderColor: '{{ weighin_chart.datasets.1.borderColor }}',
backgroundColor: 'transparent',
pointRadius: 2,
fill: false,
yAxisID: 'y1',
},
{
label: 'BMI',
data: {{ weighin_chart.datasets.2.data|safe }},
borderColor: '{{ weighin_chart.datasets.2.borderColor }}',
backgroundColor: 'transparent',
pointRadius: 2,
fill: false,
yAxisID: 'y2',
},
]
},
options: {
responsive: true,
scales: {
xAxes: [{
ticks: { maxTicksLimit: 25, maxRotation: 45 },
}],
yAxes: [
{ id: 'y', type: 'linear', position: 'left', scaleLabel: { display: true, labelString: 'Weight' } },
{ id: 'y1', type: 'linear', position: 'right', scaleLabel: { display: true, labelString: 'Body Fat %' }, gridLines: { display: false } },
{ id: 'y2', type: 'linear', position: 'right', scaleLabel: { display: true, labelString: 'BMI' }, gridLines: { display: false } },
]
},
legend: { position: 'bottom' },
}
});
</script>
{% endif %}
{% endblock %}