Compare commits
322 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ab7745151 | |||
| 8b062a6c1d | |||
| cd48e7a402 | |||
| 22830b0cea | |||
| fd36034f6d | |||
| edf9fbd9c1 | |||
| e8e989bb63 | |||
| 69401d11c8 | |||
| 759caef45d | |||
| 9514861b32 | |||
| aa644aa9cf | |||
| 94820b1d9c | |||
| 4db8793d5c | |||
| 7c6e895ae4 | |||
| b1b67528bf | |||
| dd54a33159 | |||
| 92c4f91e5a | |||
| 838b19e996 | |||
| 3808277025 | |||
| f64863f2bc | |||
| 2c199c0e93 | |||
| 4924ef316f | |||
| 64cb17e91f | |||
| 1fd325823b | |||
| 1590ce5f18 | |||
| 3548c29f97 | |||
| 0fa831fa42 | |||
| a2f64a98c3 | |||
| 872ca17432 | |||
| 224c165d72 | |||
| bf7d2514f2 | |||
| 4e37bc5ab9 | |||
| 125da84f4e | |||
| 36ceb4c7fe | |||
| 88a3831975 | |||
| 63361964ca | |||
| 40b54b27f4 | |||
| a7eca4b9a7 | |||
| d152412e99 | |||
| 3ba6c6b6e4 | |||
| bffbf47c2f | |||
| f4e81da533 | |||
| 4b7f5459be | |||
| c68b0e9d7e | |||
| 32ec65116b | |||
| da8d26fcd9 | |||
| d33954e494 | |||
| 1b306d6493 | |||
| c881143e1b | |||
| 141700fcb3 | |||
| 7357b5bfec | |||
| 99cabd0007 | |||
| cf77e12cc3 | |||
| 3f2cbbb34a | |||
| 650ecf12c6 | |||
| d7cc009d07 | |||
| a872cf3611 | |||
| 1f9713312b | |||
| 159e555d7c | |||
| 981f4f9c9a | |||
| ddd5ce1392 | |||
| 7a75b31b56 | |||
| 24ac545f55 | |||
| d5da8ae701 | |||
| 53a04d064d | |||
| c0871a3b9e | |||
| d917dd8b2c | |||
| 6fc8084f2d | |||
| 41d6fe8ff6 | |||
| 73838312cd | |||
| eb2dd4c839 | |||
| 1a0c4f69f0 | |||
| 356e579558 | |||
| e980e3c5c9 | |||
| 8773542099 | |||
| 70378c9968 | |||
| c871087496 | |||
| 059d7780a0 | |||
| db36329011 | |||
| 69aa80e6c1 | |||
| 99a6e5107b | |||
| 39e2fdce27 | |||
| b2f98d780b | |||
| 07b5dc6a2c | |||
| 7207ca385e | |||
| 2a254e28d0 | |||
| d4377e49ac | |||
| 9dc0a818ff | |||
| 5e672fc9ed | |||
| 6d194d227e | |||
| ed217cbad2 | |||
| 3cca30dc70 | |||
| d3a15d5e7b | |||
| deeaa0af4b | |||
| 159459e1b9 | |||
| 89b6d8de06 | |||
| 3c725de2ac | |||
| dd71bdd38c | |||
| d066a98282 | |||
| 73bc4a1cd1 | |||
| 79d58e6390 | |||
| 69e9caf477 | |||
| 776e839ca4 | |||
| 97792898df | |||
| 9986163d20 | |||
| d1229585a1 | |||
| b8c5c3f3e9 | |||
| 6d9e237f9b | |||
| e65a2d300d | |||
| f45541de6d | |||
| 391f0cc335 | |||
| 4b5281bdd8 | |||
| 484be0a64e | |||
| 257e6899d3 | |||
| 4767cc7e52 | |||
| bcc3f46806 | |||
| 6c461ed55f | |||
| 28cf57c6dd | |||
| 0bb874f1db | |||
| 27f50baf5d | |||
| 499c3d6859 | |||
| b0e9f13e11 | |||
| b2ee79b3ea | |||
| 3ddd3b1684 | |||
| 4f8a359ab9 | |||
| 48114aee5e | |||
| 0cc87a2dbe | |||
| 23d3e19db9 | |||
| 29f5e2b940 | |||
| 3208a32ffe | |||
| 99da9b62bf | |||
| 36048d9a0a | |||
| 16091c9053 | |||
| eec00ce658 | |||
| e02010e409 | |||
| 498712e531 | |||
| 14e4432495 | |||
| 676c40176c | |||
| 0a50bca622 | |||
| ac9fc315b1 | |||
| 50d1a4a2bd | |||
| 444562235f | |||
| 760575e41d | |||
| 42699f84d2 | |||
| b660e47bc2 | |||
| 06b4ba8bcc | |||
| 1f67207f81 | |||
| baa8dbee46 | |||
| 3f9cdcac65 | |||
| 71874510a4 | |||
| 8c600d6b4b | |||
| e95b6f50dc | |||
| 93c16d80ec | |||
| b03da9ab37 | |||
| 09ca05cb4f | |||
| d6e02d241c | |||
| 8dd94e2fc4 | |||
| 9e3f714c61 | |||
| e2b0decd83 | |||
| e08db5e3ad | |||
| 1460b9ba77 | |||
| a41e0ffa5d | |||
| c51c75b6a6 | |||
| 041435bc93 | |||
| 15f27b73a5 | |||
| 25900a9911 | |||
| 4277e355e0 | |||
| 855f59b83f | |||
| 9c115c0b65 | |||
| fd726a125f | |||
| 6685669b29 | |||
| 3fa43f02d0 | |||
| 66c34942e6 | |||
| ef1fcd4026 | |||
| a36efa3b1d | |||
| 300a2ae6aa | |||
| 1d5b91b6e2 | |||
| a5c67a7fe1 | |||
| b16d0b1864 | |||
| f90a3b84a8 | |||
| 25a14ed9e7 | |||
| 84790c805c | |||
| a9499f0463 | |||
| c109ed79eb | |||
| 89e5455b29 | |||
| 647762f201 | |||
| b788cc65d1 | |||
| 50ec82213c | |||
| 51c1acd677 | |||
| fd38046113 | |||
| d294f2ecd1 | |||
| 3c7940c6c6 | |||
| 56c000154c | |||
| 8157836b42 | |||
| af0e76b29c | |||
| 39004aac0c | |||
| 8cbd746681 | |||
| dee04a47cb | |||
| 1304a27408 | |||
| 2327b1f622 | |||
| 94fed8ae38 | |||
| 042a26f148 | |||
| e606f0de01 | |||
| ed2253cb6b | |||
| 9a1508b7a6 | |||
| 39f3a31847 | |||
| f0b32961c1 | |||
| a08574b359 | |||
| 26ebb4108d | |||
| 1b95706f70 | |||
| b65ebbe397 | |||
| 0679af3029 | |||
| 8888b42adf | |||
| b8d68739cf | |||
| 4c2b838d7b | |||
| cdb3c29844 | |||
| 22c07bdb82 | |||
| 66513c5758 | |||
| f3c0d20268 | |||
| 622a30899f | |||
| 2c1e8c08ae | |||
| cc52e00d15 | |||
| e762658082 | |||
| 5111cee14b | |||
| 68a6d58339 | |||
| b91c8b27d7 | |||
| 3a91aa5903 | |||
| bfd6331be3 | |||
| 38ba474c1f | |||
| 76272b7e39 | |||
| 1f0c950b17 | |||
| f2998205e1 | |||
| 38e108c1ae | |||
| dec100f8ff | |||
| e89eb332d3 | |||
| cb5b279300 | |||
| 59d681dc00 | |||
| 82dcad569a | |||
| d8aaf3bf55 | |||
| 29b92d89b2 | |||
| 86bcdef13d | |||
| 20e6ae7421 | |||
| 4ed5117900 | |||
| 3388471685 | |||
| 58a957c98a | |||
| 2ad626cd59 | |||
| 6ce0257dc0 | |||
| 43dbd3b28b | |||
| 7671644e87 | |||
| f555a49746 | |||
| 9fe474978a | |||
| 9f8465d364 | |||
| ddfddc33f5 | |||
| 0ec7ed3a18 | |||
| 0bda3f6fd8 | |||
| 59765b14ca | |||
| 08b48371bc | |||
| d3d5b088cd | |||
| 083e931a78 | |||
| 0f0fb7cceb | |||
| f2bbb7f5d0 | |||
| 218c68dee0 | |||
| 202cf24722 | |||
| 1ddacd4454 | |||
| 2dbb091d61 | |||
| dbaf189628 | |||
| 4a0bac5b87 | |||
| 59b7e3dada | |||
| 24223ebe13 | |||
| de6e9ce2d6 | |||
| 470eb0778a | |||
| f075492554 | |||
| 8fb2fac47f | |||
| b1eac1454b | |||
| 84737e0c3b | |||
| c4359a2331 | |||
| 8fa538dbee | |||
| 26d82518fa | |||
| 04a7ba51e4 | |||
| ccf14c51bf | |||
| b97aa8936e | |||
| 98924e362e | |||
| 0384f72cbd | |||
| 0c8a486b6a | |||
| 7954765b73 | |||
| 7604327ca9 | |||
| 20542ac7e9 | |||
| 34137af815 | |||
| 0f2570e51b | |||
| ed917e16fc | |||
| 164510b7b7 | |||
| 6764023016 | |||
| 7c6c1cee6d | |||
| c251c5f413 | |||
| 342e86d7fb | |||
| 176b698f6e | |||
| ddf2ca5630 | |||
| d52061f6d8 | |||
| 3c0a75755b | |||
| 183469ebe5 | |||
| bbe8149e6c | |||
| f876caabe1 | |||
| 87c078f47d | |||
| 5a9292e10a | |||
| a5630022f5 | |||
| babc2aeb9d | |||
| 875b0f98a0 | |||
| 5d1edc71d7 | |||
| e4738e464f | |||
| 85c4963619 | |||
| 8d6707db95 | |||
| 0df3dd728d | |||
| 1fe8d8aa51 | |||
| 8f3c7beffa | |||
| aac0efbb14 | |||
| b0d4dd0899 | |||
| 16db67ea84 | |||
| 7a747268a1 | |||
| 2037ffc67a | |||
| 2136c1562a | |||
| eae169aff7 | |||
| 6c0bd2e409 |
@ -14,9 +14,9 @@ steps:
|
|||||||
# Install dependencies
|
# Install dependencies
|
||||||
- cp vrobbler.conf.test vrobbler.conf
|
- cp vrobbler.conf.test vrobbler.conf
|
||||||
- pip install poetry
|
- pip install poetry
|
||||||
- poetry install --with dev
|
- poetry install --with test
|
||||||
# Start with a fresh database (which is already running as a service from Drone)
|
# Start with a fresh database (which is already running as a service from Drone)
|
||||||
- poetry run pytest --cov-report term:skip-covered --cov=vrobbler tests
|
- poetry run pytest -n 5 --cov-report term:skip-covered --cov=vrobbler tests
|
||||||
environment:
|
environment:
|
||||||
VROBBLER_DATABASE_URL: sqlite:///test.db
|
VROBBLER_DATABASE_URL: sqlite:///test.db
|
||||||
volumes:
|
volumes:
|
||||||
@ -39,8 +39,8 @@ steps:
|
|||||||
- vrobbler collectstatic --noinput
|
- vrobbler collectstatic --noinput
|
||||||
- immortalctl restart celery && immortalctl restart vrobbler
|
- immortalctl restart celery && immortalctl restart vrobbler
|
||||||
when:
|
when:
|
||||||
branch:
|
ref:
|
||||||
- main
|
- refs/tags/*
|
||||||
- name: build success notification
|
- name: build success notification
|
||||||
image: parrazam/drone-ntfy:0.3-linux-amd64
|
image: parrazam/drone-ntfy:0.3-linux-amd64
|
||||||
when:
|
when:
|
||||||
|
|||||||
31
.github/workflows/django.yml
vendored
Normal file
31
.github/workflows/django.yml
vendored
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
name: Django CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "develop" ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ "main" ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
max-parallel: 4
|
||||||
|
matrix:
|
||||||
|
python-version: [3.9, 3.11, 3.12]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
|
uses: actions/setup-python@v3
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python -m pip install poetry
|
||||||
|
poetry install
|
||||||
|
- name: Run Tests
|
||||||
|
run: |
|
||||||
|
pytest
|
||||||
6
Makefile
Normal file
6
Makefile
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
deploy:
|
||||||
|
ssh vrobbler.service "rm -rf /usr/local/lib/python3.11/site-packages/vrobbler-0.15.4.dist-info/ && pip install git+https://code.unbl.ink/secstate/vrobbler.git@develop && immortalctl restart vrobbler && immortalctl restart vrobbler-celery && vrobbler migrate"
|
||||||
|
logs:
|
||||||
|
ssh life.unbl.ink tail -n 100 -f /var/log/vrobbler.json
|
||||||
|
test:
|
||||||
|
pytest vrobbler
|
||||||
@ -1,160 +1,167 @@
|
|||||||
#+title: TODOs
|
#+title: Vrobbler Project
|
||||||
|
|
||||||
A fun way to keep track of things in the project to fix or improve.
|
* Overview
|
||||||
|
* Features
|
||||||
* Version 1.0.0
|
** Beer
|
||||||
** TODO What to do with Youtube videos from LastFM and web-scrobbler :bug:source:lastfm:
|
*** Triggers
|
||||||
** TODO Add a user profile page with ability to change settings :profiles:improvement:
|
**** Bookmarklet
|
||||||
** DONE [#C] Consider a purge command for duplicated and stuck in-progress scrobbles :utililty:improvement:
|
**** Manual
|
||||||
CLOSED: [2023-04-06 Thu 14:09]
|
*** Metadata sources
|
||||||
** DONE Add a "stop_timestamp" so we don't rely on content length :improvement:scrobbling:
|
**** Untappd
|
||||||
CLOSED: [2023-04-02 Sun 23:58]
|
** Book
|
||||||
|
*** Triggers
|
||||||
Essentially, we currently have the timestamp as when the content began
|
**** Webdav via KoReader
|
||||||
scrobbling and then calculate the finish time from the length of the content.
|
**** Manual
|
||||||
This works pretty well because we know how long most things are.
|
*** Metadata sources
|
||||||
|
**** Google Books
|
||||||
But in some cases, sports events or long podcasts, we may start mid-way through
|
This is the preferred method at this time. Also, the Book model implements a
|
||||||
an event or finish halfway through but still want to mark it as done. In these
|
`find_or_create` classmethod which is an example of an interface we can use for
|
||||||
cases, knowing the finish time could be useful, especially when interfacing with
|
other data models to get metadata in a way that provides easy testing, bulk
|
||||||
other scrobblers which may have different definitions of when a scrobble
|
fetching and simple saving.
|
||||||
finishes or started.
|
**** OpenLibrary
|
||||||
** DONE Fix bug with Various Artist albums being labeled with first artist as album artist :scrobbling:bug:music:
|
**** ComicVine
|
||||||
CLOSED: [2023-03-27 Mon 20:18]
|
** Board Game
|
||||||
:LOGBOOK:
|
*** Triggers
|
||||||
CLOCK: [2023-03-26 Sun 22:01]--[2023-03-27 Mon 01:07] => 3:06
|
**** IMAP import
|
||||||
|
**** Bookmarklet
|
||||||
|
**** Manual
|
||||||
|
** Location
|
||||||
|
*** Triggers
|
||||||
|
**** GPSLogger (Android)
|
||||||
|
*** Metadata sources
|
||||||
|
**** User input
|
||||||
|
** Music
|
||||||
|
*** Triggers
|
||||||
|
**** Last.FM
|
||||||
|
**** Rockbox files
|
||||||
|
**** Mopidy
|
||||||
|
**** Jellyfin
|
||||||
|
*** Metadata sources
|
||||||
|
**** Musicbrainz
|
||||||
|
** Podcast
|
||||||
|
*** Triggers
|
||||||
|
**** Mopidy
|
||||||
|
*** Metadata sources
|
||||||
|
**** Google Podcasts
|
||||||
|
**** PodcastIndex
|
||||||
|
** Sport
|
||||||
|
*** Triggers
|
||||||
|
**** Bookmarklet
|
||||||
|
**** Manual
|
||||||
|
*** Metadata sources
|
||||||
|
**** Thes Sports DB
|
||||||
|
** Task
|
||||||
|
*** Triggers
|
||||||
|
**** Todoist
|
||||||
|
**** Org-mode
|
||||||
|
*** Metadata sources
|
||||||
|
**** User profile
|
||||||
|
** Trails
|
||||||
|
** Video
|
||||||
|
*** Triggers
|
||||||
|
**** Jellyfin
|
||||||
|
**** Bookmarklet
|
||||||
|
**** Manual
|
||||||
|
*** Metadata sources
|
||||||
|
**** IMDB
|
||||||
|
**** Youtube
|
||||||
|
** Web Page
|
||||||
|
*** Triggers
|
||||||
|
**** Bookmarklet
|
||||||
|
*** Metadata sources
|
||||||
|
**** Scraper
|
||||||
|
* Release history
|
||||||
|
* Chores
|
||||||
|
** DONE Document various vrobbler features :chore:personal:project:vrobbler:documentation:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 514e9285-96f1-265f-56df-118c12f60918
|
||||||
:END:
|
:END:
|
||||||
** DONE Fix bug with weekly aggregator being blank on Sundays :aggregators:music:bug:
|
|
||||||
CLOSED: [2023-03-26 Sun 13:52]
|
|
||||||
** DONE Fix KoReader scrobbling to use pages rather than time of last read :scrobbling:books:improvement:
|
|
||||||
CLOSED: [2023-03-26 Sun 13:51]
|
|
||||||
:LOGBOOK:
|
:LOGBOOK:
|
||||||
CLOCK: [2023-03-26 Sun 13:11]--[2023-03-26 Sun 13:51] => 0:40
|
CLOCK: [2025-07-09 Wed 09:55]--[2025-07-09 Wed 10:15] => 0:20
|
||||||
:END:
|
:END:
|
||||||
** DONE [#A] Add django-storage to store files on S3 :settings:improvement:
|
* Backlog [4/27]
|
||||||
CLOSED: [2023-03-24 Fri 14:46]
|
** STRT Only create a LastFM import if there are files to import :vrobbler:feature:lastfm:importers:project:personal:
|
||||||
:LOGBOOK:
|
:PROPERTIES:
|
||||||
CLOCK: [2023-03-24 Fri 10:47]--[2023-03-24 Fri 14:46] => 3:59
|
:ID: 7a1456af-c5f1-6385-b34f-0be24a6b65b0
|
||||||
CLOCK: [2023-03-24 Fri 10:36]--[2023-03-24 Fri 10:40] => 0:04
|
|
||||||
:END:
|
:END:
|
||||||
** DONE Fix vrobbler settings not using booleans :settings:bug:
|
- Note taken on [2025-07-20 Sun 16:21]
|
||||||
CLOSED: [2023-03-24 Fri 10:45]
|
|
||||||
:LOGBOOK:
|
This thing is kicking my butt. As it stands it works, but the scrobbles are not assigned to the tracks properly.
|
||||||
CLOCK: [2023-03-24 Fri 10:40]--[2023-03-24 Fri 10:46] => 0:06
|
** TODO Add timezone awarness to IMAP importer :personal:project:vrobbler:feature:importer:imap:timezones:
|
||||||
|
** DONE Track timezone changes for profiles :vrobbler:feature:profiles:personal:project:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 89ec867f-29fd-82f1-be17-b49dddc30c78
|
||||||
|
:END:
|
||||||
|
[2025-07-11 14:23]
|
||||||
|
** TODO [#A] Add classmethod for metadata fetching to tracks :vrobbler:feature:music:personal:project:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: bc4b45e5-4c65-13c5-ab7b-1937d3fbf5c2
|
||||||
:END:
|
:END:
|
||||||
** DONE Update weekly live chart to be 7-day continuous rather than weekly :views:bug:
|
|
||||||
CLOSED: [2023-03-24 Fri 00:31]
|
|
||||||
The live view will be blank every Monday, no reason to tie it to a day of the
|
|
||||||
week. It should be "the last 7 days"
|
|
||||||
** DONE [#B] Implement a detail view for TV shows :improvement:views:
|
|
||||||
CLOSED: [2023-03-22 Wed 17:05]
|
|
||||||
** DONE [#B] Implement a detail view for Movies :improvement:views:
|
|
||||||
CLOSED: [2023-03-22 Wed 17:05]
|
|
||||||
** DONE Add "service provider" to TV Series, and use that for source when available :bug:scrobbling:
|
|
||||||
CLOSED: [2023-03-22 Wed 17:04]
|
|
||||||
** DONE Add view for long-play content (books, video games) to restart them :views:improvement:
|
|
||||||
CLOSED: [2023-03-22 Wed 17:01]
|
|
||||||
** DONE Add live chart view like Maloja :improvement:views:
|
|
||||||
CLOSED: [2023-03-07 Tue 11:13]
|
|
||||||
** DONE [#C] Figure out how to add to web-scrobbler :improvement:scrobbling:
|
|
||||||
CLOSED: [2023-03-22 Wed 17:06]
|
|
||||||
|
|
||||||
An example:
|
|
||||||
https://github.com/web-scrobbler/web-scrobbler/blob/master/src/core/background/scrobbler/maloja-scrobbler.js
|
|
||||||
|
|
||||||
This is actually going to be moot because we can import from LastFM, and
|
|
||||||
web-scrobbler integrates well with LastFM. The only thing to think through here
|
|
||||||
now is what to do with all the garbage web-scrobbler sometimes pushes to LastFM
|
|
||||||
from Youtube (all videos get pushed, sigh).
|
|
||||||
|
|
||||||
* Version 0.11.4
|
|
||||||
** DONE Add rudimentary video game scrobbling :improvement:content:videogames:
|
|
||||||
CLOSED: [2023-03-07 Tue 11:11]
|
|
||||||
** DONE Add ability to scrobble from KOReader statistics files :improvement:books:content:
|
|
||||||
CLOSED: [2023-03-07 Tue 11:11]
|
|
||||||
|
|
||||||
** DONE [#A] Fix fetching artwork without release group :bug:
|
|
||||||
CLOSED: [2023-01-29 Sun 14:27]
|
|
||||||
|
|
||||||
When we get artwork from Musicbrianz, and it's not found, we should check for
|
|
||||||
release groups as well. This will stop issues with missing artwork because of
|
|
||||||
obscure MB release matches.
|
|
||||||
|
|
||||||
** DONE [#A] Fix Jellyfin music scrobbling N+1 past 90 completion percent :bug:
|
|
||||||
CLOSED: [2023-01-30 Mon 18:31]
|
|
||||||
:LOGBOOK:
|
:LOGBOOK:
|
||||||
CLOCK: [2023-01-30 Mon 18:00]--[2023-01-30 Mon 18:31] => 0:31
|
CLOCK: [2025-07-09 Wed 10:15]
|
||||||
:END:
|
:END:
|
||||||
|
|
||||||
If we play music from Jellyfin and the track reaches 90% completion, the
|
** TODO Look in comments for a timestamp for start from BG stats if the time is missing :vrobbler:feature:boardgames:project:personal:
|
||||||
scrobbling goes crazy and starts creating new scrobbles with every update.
|
** TODO Add importer class for IMAP imports :vrobbler:feature:imap:importers:project:personal:
|
||||||
|
** TODO Add youtube link in place of IMDB on video detail page :vrobbler:feature:videos:personal:project:
|
||||||
|
** TODO [#A] Tasks from org-mode should properly update notes and leave them out of the body :vrobbler:bug:tasks:
|
||||||
|
** TODO [#A] Fix small bug in views for TV series where next episode is now None :vrobbler:bug:personal:videos:
|
||||||
|
|
||||||
The cause is pretty simple, but the solution is hard. We want to mark a scrobble
|
#+begin_src python
|
||||||
as complete for the following conditions:
|
ERROR django.request:241 log_response Internal Server Error: /series/c24100d1-da45-4abe-86bf-27cfce9b1f89/
|
||||||
|
Traceback (most recent call last):
|
||||||
- Play stopped and percent played beyond 90%
|
File "/usr/local/lib/python3.11/site-packages/django/core/handlers/exception.py", line 55, in inner
|
||||||
- Play completely finished
|
response = get_response(request)
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^
|
||||||
But if we keep listening beyond 90, we should basically ignore updates (or just
|
File "/usr/local/lib/python3.11/site-packages/django/core/handlers/base.py", line 197, in _get_response
|
||||||
update the existing scrobble)
|
response = wrapped_callback(request, *callback_args, **callback_kwargs)
|
||||||
** DONE [#A] Add support for Audioscrobbler tab-separated file uploads :improvement:
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
CLOSED: [2023-02-03 Fri 16:52]
|
File "/usr/local/lib/python3.11/site-packages/django/views/generic/base.py", line 104, in view
|
||||||
|
return self.dispatch(request, *args, **kwargs)
|
||||||
An example of the format:
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
#+begin_src csv
|
File "/usr/local/lib/python3.11/site-packages/django/contrib/auth/mixins.py", line 73, in dispatch
|
||||||
,
|
return super().dispatch(request, *args, **kwargs)
|
||||||
#AUDIOSCROBBLER/1.1
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
#TZ/UNKNOWN
|
File "/usr/local/lib/python3.11/site-packages/django/views/generic/base.py", line 143, in dispatch
|
||||||
#CLIENT/Rockbox sansaclipplus $Revision$
|
return handler(request, *args, **kwargs)
|
||||||
75 Dollar Bill I Was Real I Was Real 4 1015 S 1740494944 64ff5f53-d187-4512-827e-7606c69e66ff
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
75 Dollar Bill I Was Real I Was Real 4 1015 S 1740494990 64ff5f53-d187-4512-827e-7606c69e66ff
|
File "/usr/local/lib/python3.11/site-packages/django/views/generic/detail.py", line 109, in get
|
||||||
311 311 Down 1 173 S 1740495003 00476c23-fd9e-464b-9b27-a62d69f3d4f4
|
context = self.get_context_data(object=self.object)
|
||||||
311 311 Down 1 173 L 1740495049 00476c23-fd9e-464b-9b27-a62d69f3d4f4
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
311 311 Down 1 173 L 1740495113 00476c23-fd9e-464b-9b27-a62d69f3d4f4
|
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/videos/views.py", line 33, in get_context_data
|
||||||
311 311 Random 2 187 S 1740495190 530c09f3-46fe-4d90-b11f-7b63bcb4b373
|
context_data["next_episode_id"] = "tt" + next_episode_id
|
||||||
311 311 Random 2 187 L 1740495194 530c09f3-46fe-4d90-b11f-7b63bcb4b373
|
~~~~~^~~~~~~~~~~~~~~~~
|
||||||
311 311 Jackolantern’s Weather 3 204 L 1740495382 cc3b2dec-5d99-47ea-8930-20bf258be4ea
|
TypeError: can only concatenate str (not "NoneType") to str
|
||||||
311 311 All Mixed Up 4 182 L 1740495586 980a78b5-5bdd-4f50-9e3a-e13261e2817b
|
|
||||||
311 311 Hive 5 179 L 1740495768 18f6dc98-d3a2-4f81-b967-97359d14c68c
|
|
||||||
311 311 Guns (Are for Pussies) 6 137 L 1740495948 5e97ed9f-c8cc-4282-9cbe-f8e17aee5128
|
|
||||||
311 311 Misdirected Hostility 7 179 S 1740496085 61ff2c1a-fc9c-44c3-8da1-5e50a44245af
|
|
||||||
,
|
|
||||||
#+end_src
|
#+end_src
|
||||||
** DONE [#B] Allow scrobbling music without MB IDs by grabbing them before scrobble :improvement:
|
|
||||||
CLOSED: [2023-02-17 Fri 00:10]
|
|
||||||
|
|
||||||
This would allow a few nice flows. One, you'd be able to record the play of an
|
** TODO [#A] Send periodic check notifications for board games and beer that ask if you're still scrobbling :vrobbler:personal:project:beers:boardgames:notifications:feature:
|
||||||
entire album by just dropping the muscibrainz_id in. This could be helpful for
|
** TODO [#A] Fix raw text webpage title not truncating to 254 chars :vrobbler:personal:bug:webpages:
|
||||||
offline listening. It would also mean bad metadata from mopidy would not break
|
** TODO [#A] Fix koreader scrobble imports to use DST properly :vrobbler:personal:bug:books:imports:
|
||||||
scrobbling.
|
The page data has the canonical date something was read in it, but it seems to be an hour off. I traced this back to being off during DST, so we just need the importer to be aware of whether a user is using DST or not and roll back an hour for part of the year.
|
||||||
** DONE When updating musicbrainz IDs, clear and run fetch artwrok :improvement:
|
|
||||||
CLOSED: [2023-02-17 Fri 00:11]
|
|
||||||
** DONE [#A] Add ability to manually scrobble albums or tracks from MB :improvement:
|
|
||||||
CLOSED: [2023-03-07 Tue 11:09]
|
|
||||||
|
|
||||||
Given a UUID from musicbrainz, we should be able to scrobble an album or
|
Also, we'd need to adjust any old scrobbles that took place with DST off to roll them back by an hour.
|
||||||
individual track.
|
** TODO [#A] Create small utility to clean up tracks scrobbled with wonky playback times :vrobbler:personal:bug:music:scrobbles:
|
||||||
|
** TODO [#B] Add AllTrails as a source for Trail data :vrobbler:trails:feature:personal:project:
|
||||||
** DONE [#C] Implement keeping track of week/month/year chart-toppers :improvement:
|
Pretty clear, I would love to make trails more useful. Historically I wasn't
|
||||||
CLOSED: [2023-03-07 Tue 11:10]
|
hiking a lot, which made the source for this a bit silly. But it's clear that
|
||||||
:LOGBOOK:
|
AllTrails is the best source, though having TrailForks is nice to.
|
||||||
CLOCK: [2023-01-30 Mon 16:30]--[2023-01-30 Mon 18:00] => 1:30
|
** TODO [#B] Add `garmin_activity_id` to the TrailMetadataLog class :vrobbler:trails:feature:personal:project:
|
||||||
:END:
|
Would be nice to have some loose connection to the actual event in my Garmin profile.
|
||||||
|
** TODO [#B] Explore a way to add metadata editing to scrobbles after saving :vrobbler:spike:scrobbling:personal:project:
|
||||||
Maloja does this cool thing where artists and tracks get recorded as the top
|
Could be as simple as a JSON form on the scrobble detail page (do I have have one of those yet?).
|
||||||
track of a given week, month or year. They get gold, silver or bronze stars for
|
** TODO [#B] Explore a good way to show notes and descriptions from scrobbles to users :personal:project:scrobbling:vrobbler:spike:
|
||||||
their place in the time period.
|
** TODO [#B] Add webdav syncing to retroarch imports :vrobbler:videogames:webdav:feature:project:personal:
|
||||||
|
** TODO [#B] Add CSV endpoint for book scrobbles that LibraryThing can ingest :personal:project:books:feature:export:
|
||||||
I could see this being implemented as a separate Chart table which gets
|
https://app.todoist.com/app/task/add-a-csv-endpoint-for-users-book-reads-that-library-thing-can-ingest-6X7QPMRp265xMXqg#comment-6X7QrXq6gJjMP4hg
|
||||||
populated at the end of a time period and has a start and end date that defines
|
** TODO [#C] Fix bug where podcast scrobbling creates duplicate Podcast :project:vrobbler:scrobbling:podcasts:bug:personal:
|
||||||
a period, along with a one, two, three instance.
|
Rather than pick up an existing Podcast using the podcast title in the mopidy
|
||||||
|
file name, Vrobbler creates a new podcast with no enriched data. Not a big deal
|
||||||
Of course, it could also be a data model without a table, where it runs some fun
|
for my use as the volume of podcasts I listen to makes manual fixes easy. But
|
||||||
calculations, stores it's values in Redis as a long-term lookup table and just
|
it's annoying.
|
||||||
has to re-populate when the server restarts.
|
|
||||||
* Backlog
|
|
||||||
** TODO Add Amazon scraper to look up books when OL fails :books:improvement:
|
|
||||||
** TODO [#C] Move to using more robust mopidy-webhooks pacakge form pypi :utility:improvement:
|
** TODO [#C] Move to using more robust mopidy-webhooks pacakge form pypi :utility:improvement:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: ab31fdc3-359c-1b1d-6b9d-546b476021ba
|
||||||
|
:END:
|
||||||
*** Example payloads from mopidy-webhooks
|
*** Example payloads from mopidy-webhooks
|
||||||
**** Podcast playback ended
|
**** Podcast playback ended
|
||||||
#+begin_src json
|
#+begin_src json
|
||||||
@ -445,5 +452,376 @@ has to re-populate when the server restarts.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#+end_src
|
#+end_src
|
||||||
** TODO Fix bug in Jellyfin scrobbles that spam more scrobbles after completion :scrobbling:videos:bug:
|
** TODO [#C] User should be able to enable auto trail tracking via amail reader with Garmin LiveTrack URLs :vrobbler:trails:project:feature:personal:
|
||||||
** TODO Fix bug in podcast scrobbling where a second scrobble is created after completion :scrobbling:podcasts:bug:
|
** TODO [#C] Allow users to see tasks on calendar view :vrobbler:personal:project:templates:feature:
|
||||||
|
https://codepen.io/oliviale/pen/QYqybo
|
||||||
|
** TODO [#C] Come up with a possible flow using WebDAV and super-productivity for tasks :personal:feature:project:vrobbler:tasks:
|
||||||
|
** DONE Import from BG stats a "learning" log field when "Learning to play" is in the comment :vrobbler:feature:boardgames:project:personal:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: fda59fab-4349-e99e-54c6-9f1392a1c474
|
||||||
|
:END:
|
||||||
|
** DONE [#A] Add email importer for BG stats file uploads :vrobbler:feature:boardgames:personal:project:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 116fe738-7966-615c-d195-ccff0337b101
|
||||||
|
:END:
|
||||||
|
#+begin_src json example of a file
|
||||||
|
{
|
||||||
|
"about": "This is a Play file that can be read by Board Game Stats. If you see this text, try to use a share, export or open-in function to open it with Board Game Stats.",
|
||||||
|
"players": [
|
||||||
|
{
|
||||||
|
"uuid": "31f8b92e-11d8-4162-88b1-fd9c79eea249",
|
||||||
|
"id": 2,
|
||||||
|
"name": "Colin",
|
||||||
|
"isAnonymous": false,
|
||||||
|
"modificationDate": "2025-07-01 18:10:32",
|
||||||
|
"metaData": "{\"isNpc\":0}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uuid": "00074700-cf4e-4ad3-b334-d35805bb0d90",
|
||||||
|
"id": 4,
|
||||||
|
"name": "Asa Sewell",
|
||||||
|
"isAnonymous": false,
|
||||||
|
"modificationDate": "2025-07-01 18:03:37"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"uuid": "14f7389c-767f-4725-9b35-906c407b293c",
|
||||||
|
"id": 3,
|
||||||
|
"name": "Timberwyck Farm",
|
||||||
|
"modificationDate": "2025-07-01 18:03:38"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"games": [
|
||||||
|
{
|
||||||
|
"uuid": "043a2851-f201-467a-a60c-0b0a7e9c33d2",
|
||||||
|
"id": 333,
|
||||||
|
"name": "Ghost Fightin' Treasure Hunters: Anniversary Edition",
|
||||||
|
"modificationDate": "2025-07-02 01:37:14",
|
||||||
|
"cooperative": true,
|
||||||
|
"highestWins": true,
|
||||||
|
"noPoints": false,
|
||||||
|
"usesTeams": false,
|
||||||
|
"urlThumb": "https://cf.geekdo-images.com/DHA-mcH3zzw_OjfDxOPj1A__thumb/img/UhaIm4KIDIiraUc44QIvSAbMUXI=/fit-in/200x150/filters:strip_icc()/pic8266874.jpg",
|
||||||
|
"urlImage": "https://cf.geekdo-images.com/DHA-mcH3zzw_OjfDxOPj1A__original/img/2-Lb6nLePhn0I0Hh2j1pOtbO4rg=/0x0/filters:format(jpeg)/pic8266874.jpg",
|
||||||
|
"bggName": "Ghost Fightin' Treasure Hunters: Anniversary Edition",
|
||||||
|
"bggYear": 2024,
|
||||||
|
"bggId": 422668,
|
||||||
|
"designers": "Brian Yu",
|
||||||
|
"isBaseGame": 1,
|
||||||
|
"isExpansion": 0,
|
||||||
|
"rating": 75,
|
||||||
|
"minPlayerCount": 2,
|
||||||
|
"maxPlayerCount": 5,
|
||||||
|
"minPlayTime": 30,
|
||||||
|
"maxPlayTime": 0,
|
||||||
|
"minAge": 8
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"plays": [
|
||||||
|
{
|
||||||
|
"uuid": "bae3f29e-5e1e-45d8-b409-47a665c8d5b5",
|
||||||
|
"modificationDate": "2025-07-02 01:37:59",
|
||||||
|
"entryDate": "2025-07-02 01:31:38",
|
||||||
|
"playDate": "2025-07-02 01:31:38",
|
||||||
|
"usesTeams": false,
|
||||||
|
"durationMin": 23,
|
||||||
|
"ignored": false,
|
||||||
|
"manualWinner": true,
|
||||||
|
"rounds": 3,
|
||||||
|
"scoresheet": "{\"bggId\":244711,\"version\":1,\"langCode\":\"en\",\"scoreType\":\"bestTotalWins\",\"groups\":[{\"templateId\":\"1\",\"maxRepeat\":-1,\"repetition\":1,\"hasSubTotal\":false,\"hideSingleGroupLabel\":false,\"isExtra\":false,\"rows\":[{\"templateId\":\"vptrack\",\"label\":\"VP track\",\"repetition\":1,\"repeatable\":false,\"negative\":false,\"isExtra\":false,\"scores\":{}},{\"templateId\":\"objectives\",\"label\":\"Objectives\",\"repetition\":1,\"repeatable\":false,\"negative\":false,\"isExtra\":false,\"scores\":{}},{\"templateId\":\"mastercards\",\"label\":\"Master cards\",\"repetition\":1,\"repeatable\":false,\"negative\":false,\"isExtra\":false,\"scores\":{}}]}]}",
|
||||||
|
"locationRefId": 3,
|
||||||
|
"gameRefId": 333,
|
||||||
|
"board": "",
|
||||||
|
"scoringSetting": 4,
|
||||||
|
"metaData": "{\"playUsedGameCopy\":2}",
|
||||||
|
"playerScores": [
|
||||||
|
{
|
||||||
|
"score": "",
|
||||||
|
"winner": true,
|
||||||
|
"newPlayer": true,
|
||||||
|
"startPlayer": false,
|
||||||
|
"playerRefId": 4,
|
||||||
|
"role": "",
|
||||||
|
"rank": 0,
|
||||||
|
"seatOrder": 0,
|
||||||
|
"metaData": "{\"scoreUuid\":\"00074700-cf4e-4ad3-b334-d35805bb0d90\"}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"score": "",
|
||||||
|
"winner": true,
|
||||||
|
"newPlayer": true,
|
||||||
|
"startPlayer": false,
|
||||||
|
"playerRefId": 2,
|
||||||
|
"role": "",
|
||||||
|
"rank": 0,
|
||||||
|
"seatOrder": 0,
|
||||||
|
"metaData": "{\"scoreUuid\":\"31f8b92e-11d8-4162-88b1-fd9c79eea249\"}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"expansionPlays": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"userInfo": {
|
||||||
|
"meRefId": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#+end_src
|
||||||
|
** DONE [#B] Fix task app to only use one tag for the context a task was done in and allow configurable contexts by user profile :personal:vrobbler:feature:tasks:project:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 23f485e3-988c-6198-c79d-91fdf92f001c
|
||||||
|
:END:
|
||||||
|
* Version 17.0
|
||||||
|
** DONE [#A] Fix bug in new task label lookup for Emacs/Org-mode :vrobbler:bug:tasks:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 683fb109-dfc4-85e4-80f0-ea618434f61e
|
||||||
|
:END:
|
||||||
|
** DONE [#C] Replace commas in the bandcamp URL for artists with nothing :vrobbler:music:bug:personal:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 9b30d67b-91f0-a480-dfaa-5d9dc090e76c
|
||||||
|
:END:
|
||||||
|
|
||||||
|
- Note taken on [2025-06-16 Mon 09:36]
|
||||||
|
|
||||||
|
This firt appeared with Black Country, New Road, where the RYM slug generator
|
||||||
|
leaves commas in and ends up sending you to a 404. I suspect this wont be the
|
||||||
|
first tweak we'll need to this, as the RYM link creator is really just
|
||||||
|
guessing based on the artist name at the path.
|
||||||
|
|
||||||
|
** DONE [#A] Investigate new source of video metadata :personal:project:video:imdb:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: df2b486c-1170-5199-c312-9bc87760d962
|
||||||
|
:END:
|
||||||
|
|
||||||
|
Cinemagoer broke and I probably should find a more reilable source of video data.
|
||||||
|
|
||||||
|
- Note taken on [2025-06-13 Fri 11:19]
|
||||||
|
|
||||||
|
TMDB is much more reliable, but does require an API key. That's all setup now,
|
||||||
|
so hopefully this breaking IMDB crap is over.
|
||||||
|
|
||||||
|
** DONE [#A] IMDB video lookups are failing :personal:bug:video:imdb:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 38f1081f-37b4-f4f2-79aa-c1e87eca4b69
|
||||||
|
:END:
|
||||||
|
<2025-06-13 Fri>
|
||||||
|
|
||||||
|
- Note taken on [2025-06-13 Fri 08:24]
|
||||||
|
|
||||||
|
Looks like Cinemagoer is broken: https://github.com/cinemagoer/cinemagoer/issues/537
|
||||||
|
|
||||||
|
** DONE [#A] Emacs is not syncing notes :personal:scrobbling:emacs:bug:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: c79cd491-b30f-0945-d84b-b8cac7562791
|
||||||
|
:END:
|
||||||
|
<2025-06-12 Thu 9:30>
|
||||||
|
|
||||||
|
Not sure if the problem is in my Emacs hook sending or Vrobbler itself.
|
||||||
|
|
||||||
|
- Note taken on [2025-06-12 Thu 09:47]
|
||||||
|
|
||||||
|
Adding a quick note to check on it
|
||||||
|
|
||||||
|
- Note taken on [2025-06-12 Thu 09:50]
|
||||||
|
|
||||||
|
Ah ha. All the messing about with the source field meant that I was looking
|
||||||
|
for `emacs` as a source but the hook was initially setting sources to
|
||||||
|
`orgmode` I think I prefer `orgmode` as the source, so updating it thusly.
|
||||||
|
|
||||||
|
Fixed in `490d60cbbb1f8bf90b5fc47d8685b15bdc1d485b`
|
||||||
|
|
||||||
|
** DONE [#A] Show the description of a task in the string rep for a scrobble of a Task :personal:project:scrobbling:vrobbler:feature:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: df58f8d0-fa4a-2037-c7d7-e5388c239042
|
||||||
|
:END:
|
||||||
|
* Version 0.16.0
|
||||||
|
** DONE [#A] Jellyfin, bandcamp tracks from Mopidy create duplicate music tracks :bug:scrobbling:music:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 670e8634-49b5-dce9-1684-14f2ffb797f1
|
||||||
|
:END:
|
||||||
|
Effectively, any track that comes in without a MusicBrainz ID does some funky
|
||||||
|
lookup where it doesn't find a track without an MB id and the track title /
|
||||||
|
artist combination and creates a new track every time. This has to be cleaned up
|
||||||
|
by condensing the duplicated tracks into the original proper track.
|
||||||
|
|
||||||
|
But it opens a bigger question about how much MB id should the drive the app
|
||||||
|
lookup. If it can't be depended on to exist from all sources, it really can't be
|
||||||
|
canonical. Instead, the combination of track title / artist is really the best
|
||||||
|
we can do. Last.fm also has this problem, where it doesn't know about albums and
|
||||||
|
definitely does not know or care about MB ids.
|
||||||
|
|
||||||
|
** DONE Add a user profile page with ability to change settings :profiles:improvement:
|
||||||
|
- Note taken on [2025-04-04 Fri 10:51]
|
||||||
|
[[orgit-rev:~/src/code.unbl.ink/secstate/vrobbler/::93c16d80ecff4cd1663cf9ec40fbe6d8f58c3e44][~/src/code.unbl.ink/secstate/vrobbler/ (magit-rev 93c16d8)]]
|
||||||
|
|
||||||
|
https://code.unbl.ink/secstate/vrobbler/commit/93c16d80ecff4cd1663cf9ec40fbe6d8f58c3e44
|
||||||
|
|
||||||
|
** DONE What to do with Youtube videos from LastFM and web-scrobbler :bug:source:lastfm:
|
||||||
|
- Note taken on [2025-04-04 Fri 10:46]
|
||||||
|
|
||||||
|
Nothing. Over the last few months I built out a youtube model in videos and
|
||||||
|
use a bookmarklet scrobbling pattern. Now web-scrobbler is just disabled for
|
||||||
|
Youtube.
|
||||||
|
|
||||||
|
May want to revisit this at some point and only scrobble tracks from Youtube,
|
||||||
|
because many people use YT for music listening.
|
||||||
|
|
||||||
|
** DONE [#C] Consider a purge command for duplicated and stuck in-progress scrobbles :utililty:improvement:
|
||||||
|
CLOSED: [2023-04-06 Thu 14:09]
|
||||||
|
** DONE Add a "stop_timestamp" so we don't rely on content length :improvement:scrobbling:
|
||||||
|
CLOSED: [2023-04-02 Sun 23:58]
|
||||||
|
|
||||||
|
Essentially, we currently have the timestamp as when the content began
|
||||||
|
scrobbling and then calculate the finish time from the length of the content.
|
||||||
|
This works pretty well because we know how long most things are.
|
||||||
|
|
||||||
|
But in some cases, sports events or long podcasts, we may start mid-way through
|
||||||
|
an event or finish halfway through but still want to mark it as done. In these
|
||||||
|
cases, knowing the finish time could be useful, especially when interfacing with
|
||||||
|
other scrobblers which may have different definitions of when a scrobble
|
||||||
|
finishes or started.
|
||||||
|
** DONE Fix bug with Various Artist albums being labeled with first artist as album artist :scrobbling:bug:music:
|
||||||
|
CLOSED: [2023-03-27 Mon 20:18]
|
||||||
|
:LOGBOOK:
|
||||||
|
CLOCK: [2023-03-26 Sun 22:01]--[2023-03-27 Mon 01:07] => 3:06
|
||||||
|
:END:
|
||||||
|
** DONE Fix bug with weekly aggregator being blank on Sundays :aggregators:music:bug:
|
||||||
|
CLOSED: [2023-03-26 Sun 13:52]
|
||||||
|
** DONE Fix KoReader scrobbling to use pages rather than time of last read :scrobbling:books:improvement:
|
||||||
|
CLOSED: [2023-03-26 Sun 13:51]
|
||||||
|
:LOGBOOK:
|
||||||
|
CLOCK: [2023-03-26 Sun 13:11]--[2023-03-26 Sun 13:51] => 0:40
|
||||||
|
:END:
|
||||||
|
** DONE [#A] Add django-storage to store files on S3 :settings:improvement:
|
||||||
|
CLOSED: [2023-03-24 Fri 14:46]
|
||||||
|
:LOGBOOK:
|
||||||
|
CLOCK: [2023-03-24 Fri 10:47]--[2023-03-24 Fri 14:46] => 3:59
|
||||||
|
CLOCK: [2023-03-24 Fri 10:36]--[2023-03-24 Fri 10:40] => 0:04
|
||||||
|
:END:
|
||||||
|
** DONE Fix vrobbler settings not using booleans :settings:bug:
|
||||||
|
CLOSED: [2023-03-24 Fri 10:45]
|
||||||
|
:LOGBOOK:
|
||||||
|
CLOCK: [2023-03-24 Fri 10:40]--[2023-03-24 Fri 10:46] => 0:06
|
||||||
|
:END:
|
||||||
|
** DONE Update weekly live chart to be 7-day continuous rather than weekly :views:bug:
|
||||||
|
CLOSED: [2023-03-24 Fri 00:31]
|
||||||
|
The live view will be blank every Monday, no reason to tie it to a day of the
|
||||||
|
week. It should be "the last 7 days"
|
||||||
|
** DONE [#B] Implement a detail view for TV shows :improvement:views:
|
||||||
|
CLOSED: [2023-03-22 Wed 17:05]
|
||||||
|
** DONE [#B] Implement a detail view for Movies :improvement:views:
|
||||||
|
CLOSED: [2023-03-22 Wed 17:05]
|
||||||
|
** DONE Add "service provider" to TV Series, and use that for source when available :bug:scrobbling:
|
||||||
|
CLOSED: [2023-03-22 Wed 17:04]
|
||||||
|
** DONE Add view for long-play content (books, video games) to restart them :views:improvement:
|
||||||
|
CLOSED: [2023-03-22 Wed 17:01]
|
||||||
|
** DONE Add live chart view like Maloja :improvement:views:
|
||||||
|
CLOSED: [2023-03-07 Tue 11:13]
|
||||||
|
** DONE [#C] Figure out how to add to web-scrobbler :improvement:scrobbling:
|
||||||
|
CLOSED: [2023-03-22 Wed 17:06]
|
||||||
|
|
||||||
|
An example:
|
||||||
|
https://github.com/web-scrobbler/web-scrobbler/blob/master/src/core/background/scrobbler/maloja-scrobbler.js
|
||||||
|
|
||||||
|
This is actually going to be moot because we can import from LastFM, and
|
||||||
|
web-scrobbler integrates well with LastFM. The only thing to think through here
|
||||||
|
now is what to do with all the garbage web-scrobbler sometimes pushes to LastFM
|
||||||
|
from Youtube (all videos get pushed, sigh).
|
||||||
|
|
||||||
|
** DONE Add Amazon scraper to look up books when OL fails :books:improvement:
|
||||||
|
This turned out to be a non-starter ... Amazon is aggressive at disallowing
|
||||||
|
scraping quality. And all the OSS tools out there are stuck in an arms race
|
||||||
|
trying to keep them from breaking.
|
||||||
|
|
||||||
|
That said, Google Books actually has a decent API (for now), and I've built this
|
||||||
|
out using that.
|
||||||
|
|
||||||
|
** DONE Fix bug in Jellyfin scrobbles that spam more scrobbles after completion :scrobbling:videos:bug:
|
||||||
|
This was fixed a while ago, but there's a new manifested bug. Going to create a
|
||||||
|
separate bug tracking ticket for that.
|
||||||
|
* Version 0.11.4
|
||||||
|
** DONE Add rudimentary video game scrobbling :improvement:content:videogames:
|
||||||
|
CLOSED: [2023-03-07 Tue 11:11]
|
||||||
|
** DONE Add ability to scrobble from KOReader statistics files :improvement:books:content:
|
||||||
|
CLOSED: [2023-03-07 Tue 11:11]
|
||||||
|
|
||||||
|
** DONE [#A] Fix fetching artwork without release group :bug:
|
||||||
|
CLOSED: [2023-01-29 Sun 14:27]
|
||||||
|
|
||||||
|
When we get artwork from Musicbrianz, and it's not found, we should check for
|
||||||
|
release groups as well. This will stop issues with missing artwork because of
|
||||||
|
obscure MB release matches.
|
||||||
|
|
||||||
|
** DONE [#A] Fix Jellyfin music scrobbling N+1 past 90 completion percent :bug:
|
||||||
|
CLOSED: [2023-01-30 Mon 18:31]
|
||||||
|
:LOGBOOK:
|
||||||
|
CLOCK: [2023-01-30 Mon 18:00]--[2023-01-30 Mon 18:31] => 0:31
|
||||||
|
:END:
|
||||||
|
|
||||||
|
If we play music from Jellyfin and the track reaches 90% completion, the
|
||||||
|
scrobbling goes crazy and starts creating new scrobbles with every update.
|
||||||
|
|
||||||
|
The cause is pretty simple, but the solution is hard. We want to mark a scrobble
|
||||||
|
as complete for the following conditions:
|
||||||
|
|
||||||
|
- Play stopped and percent played beyond 90%
|
||||||
|
- Play completely finished
|
||||||
|
|
||||||
|
But if we keep listening beyond 90, we should basically ignore updates (or just
|
||||||
|
update the existing scrobble)
|
||||||
|
** DONE [#A] Add support for Audioscrobbler tab-separated file uploads :improvement:
|
||||||
|
CLOSED: [2023-02-03 Fri 16:52]
|
||||||
|
|
||||||
|
An example of the format:
|
||||||
|
#+begin_src csv
|
||||||
|
,
|
||||||
|
#AUDIOSCROBBLER/1.1
|
||||||
|
#TZ/UNKNOWN
|
||||||
|
#CLIENT/Rockbox sansaclipplus $Revision$
|
||||||
|
75 Dollar Bill I Was Real I Was Real 4 1015 S 1740494944 64ff5f53-d187-4512-827e-7606c69e66ff
|
||||||
|
75 Dollar Bill I Was Real I Was Real 4 1015 S 1740494990 64ff5f53-d187-4512-827e-7606c69e66ff
|
||||||
|
311 311 Down 1 173 S 1740495003 00476c23-fd9e-464b-9b27-a62d69f3d4f4
|
||||||
|
311 311 Down 1 173 L 1740495049 00476c23-fd9e-464b-9b27-a62d69f3d4f4
|
||||||
|
311 311 Down 1 173 L 1740495113 00476c23-fd9e-464b-9b27-a62d69f3d4f4
|
||||||
|
311 311 Random 2 187 S 1740495190 530c09f3-46fe-4d90-b11f-7b63bcb4b373
|
||||||
|
311 311 Random 2 187 L 1740495194 530c09f3-46fe-4d90-b11f-7b63bcb4b373
|
||||||
|
311 311 Jackolantern’s Weather 3 204 L 1740495382 cc3b2dec-5d99-47ea-8930-20bf258be4ea
|
||||||
|
311 311 All Mixed Up 4 182 L 1740495586 980a78b5-5bdd-4f50-9e3a-e13261e2817b
|
||||||
|
311 311 Hive 5 179 L 1740495768 18f6dc98-d3a2-4f81-b967-97359d14c68c
|
||||||
|
311 311 Guns (Are for Pussies) 6 137 L 1740495948 5e97ed9f-c8cc-4282-9cbe-f8e17aee5128
|
||||||
|
311 311 Misdirected Hostility 7 179 S 1740496085 61ff2c1a-fc9c-44c3-8da1-5e50a44245af
|
||||||
|
,
|
||||||
|
#+end_src
|
||||||
|
** DONE [#B] Allow scrobbling music without MB IDs by grabbing them before scrobble :improvement:
|
||||||
|
CLOSED: [2023-02-17 Fri 00:10]
|
||||||
|
|
||||||
|
This would allow a few nice flows. One, you'd be able to record the play of an
|
||||||
|
entire album by just dropping the muscibrainz_id in. This could be helpful for
|
||||||
|
offline listening. It would also mean bad metadata from mopidy would not break
|
||||||
|
scrobbling.
|
||||||
|
** DONE When updating musicbrainz IDs, clear and run fetch artwrok :improvement:
|
||||||
|
CLOSED: [2023-02-17 Fri 00:11]
|
||||||
|
** DONE [#A] Add ability to manually scrobble albums or tracks from MB :improvement:
|
||||||
|
CLOSED: [2023-03-07 Tue 11:09]
|
||||||
|
|
||||||
|
Given a UUID from musicbrainz, we should be able to scrobble an album or
|
||||||
|
individual track.
|
||||||
|
|
||||||
|
** DONE [#C] Implement keeping track of week/month/year chart-toppers :improvement:
|
||||||
|
CLOSED: [2023-03-07 Tue 11:10]
|
||||||
|
:LOGBOOK:
|
||||||
|
CLOCK: [2023-01-30 Mon 16:30]--[2023-01-30 Mon 18:00] => 1:30
|
||||||
|
:END:
|
||||||
|
|
||||||
|
Maloja does this cool thing where artists and tracks get recorded as the top
|
||||||
|
track of a given week, month or year. They get gold, silver or bronze stars for
|
||||||
|
their place in the time period.
|
||||||
|
|
||||||
|
I could see this being implemented as a separate Chart table which gets
|
||||||
|
populated at the end of a time period and has a start and end date that defines
|
||||||
|
a period, along with a one, two, three instance.
|
||||||
|
|
||||||
|
Of course, it could also be a data model without a table, where it runs some fun
|
||||||
|
calculations, stores it's values in Redis as a long-term lookup table and just
|
||||||
|
has to re-populate when the server restarts.
|
||||||
@ -1,4 +1,4 @@
|
|||||||
export ENV_PATH=$(poetry env info --path)
|
export ENV_PATH=$(poetry env info --path)
|
||||||
source "${ENV_PATH}/bin/activate"
|
source "${ENV_PATH}/bin/activate"
|
||||||
|
|
||||||
export PYPI_PASSWORD="$(pass personal/apikey/pypi)"
|
#export PYPI_PASSWORD="$(pass personal/apikey/pypi)"
|
||||||
|
|||||||
4724
poetry.lock
generated
4724
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,17 +1,18 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "vrobbler"
|
name = "vrobbler"
|
||||||
version = "0.15.2"
|
version = "0.16.1"
|
||||||
description = ""
|
description = ""
|
||||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = ">=3.9,<4.0"
|
python = ">=3.9,<3.12"
|
||||||
Django = "^4.0.3"
|
Django = "^4.0.3"
|
||||||
django-extensions = "^3.1.5"
|
django-extensions = "^3.1.5"
|
||||||
python-dateutil = "^2.8.2"
|
python-dateutil = "^2.8.2"
|
||||||
python-dotenv = "^0.20.0"
|
python-dotenv = "^0.20.0"
|
||||||
python-json-logger = "^2.0.2"
|
python-json-logger = "^2.0.2"
|
||||||
colorlog = "^6.6.0"
|
colorlog = "^6.6.0"
|
||||||
|
httpx = "<=0.27.2"
|
||||||
djangorestframework = "^3.13.1"
|
djangorestframework = "^3.13.1"
|
||||||
Markdown = "^3.3.6"
|
Markdown = "^3.3.6"
|
||||||
django-filter = "^21.1"
|
django-filter = "^21.1"
|
||||||
@ -38,7 +39,6 @@ honcho = "^1.1.0"
|
|||||||
howlongtobeatpy = "^1.0.5"
|
howlongtobeatpy = "^1.0.5"
|
||||||
beautifulsoup4 = "^4.11.2"
|
beautifulsoup4 = "^4.11.2"
|
||||||
django-storages = "^1.13.2"
|
django-storages = "^1.13.2"
|
||||||
boto3 = "^1.26.98"
|
|
||||||
stream-sqlite = "^0.0.41"
|
stream-sqlite = "^0.0.41"
|
||||||
ipython = "^8.14.0"
|
ipython = "^8.14.0"
|
||||||
pendulum = "^2.1.2"
|
pendulum = "^2.1.2"
|
||||||
@ -46,11 +46,21 @@ trafilatura = "^1.6.3"
|
|||||||
django-imagekit = "^5.0.0"
|
django-imagekit = "^5.0.0"
|
||||||
thefuzz = "^0.22.1"
|
thefuzz = "^0.22.1"
|
||||||
dataclass-wizard = "0.22.0"
|
dataclass-wizard = "0.22.0"
|
||||||
|
webdavclient3 = "^3.14.6"
|
||||||
|
boto3 = "^1.35.37"
|
||||||
|
urllib3 = "<2"
|
||||||
|
django-oauth-toolkit = "^3.0.1"
|
||||||
|
meta-yt = "^0.1.9"
|
||||||
|
berserk = "^0.13.2"
|
||||||
|
poetry-bumpversion = "^0.3.3"
|
||||||
|
orgparse = "^0.4.20250520"
|
||||||
|
tmdbv3api = "^1.9.0"
|
||||||
|
themoviedb = "^1.0.2"
|
||||||
|
|
||||||
[tool.poetry.group.dev]
|
[tool.poetry.group.test]
|
||||||
optional = true
|
optional = true
|
||||||
|
|
||||||
[tool.poetry.group.dev.dependencies]
|
[tool.poetry.group.test.dependencies]
|
||||||
Werkzeug = "2.0.3"
|
Werkzeug = "2.0.3"
|
||||||
black = "^22.3"
|
black = "^22.3"
|
||||||
coverage = "^7.0.5"
|
coverage = "^7.0.5"
|
||||||
@ -59,6 +69,7 @@ pytest = "^7.1"
|
|||||||
pytest-black = "^0.3.12"
|
pytest-black = "^0.3.12"
|
||||||
pytest-cov = "^3.0"
|
pytest-cov = "^3.0"
|
||||||
pytest-django = "^4.5.2"
|
pytest-django = "^4.5.2"
|
||||||
|
pytest-xdist= "^1.0.0"
|
||||||
pytest-flake8 = "^1.1"
|
pytest-flake8 = "^1.1"
|
||||||
pytest-isort = "^3.0"
|
pytest-isort = "^3.0"
|
||||||
pytest-runner = "^6.0"
|
pytest-runner = "^6.0"
|
||||||
|
|||||||
@ -4,8 +4,9 @@ import pytest
|
|||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from rest_framework.authtoken.models import Token
|
from rest_framework.authtoken.models import Token
|
||||||
|
|
||||||
from scrobbles.models import Scrobble
|
|
||||||
from boardgames.models import BoardGame
|
from boardgames.models import BoardGame
|
||||||
|
from music.models import Track, Artist
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
@ -27,6 +28,15 @@ def boardgame_scrobble():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_track():
|
||||||
|
Track.objects.create(
|
||||||
|
title="Emotion",
|
||||||
|
artist=Artist.objects.create(name="Carly Rae Jepsen"),
|
||||||
|
run_time_seconds=60,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class MopidyRequest:
|
class MopidyRequest:
|
||||||
name = "Same in the End"
|
name = "Same in the End"
|
||||||
artist = "Sublime"
|
artist = "Sublime"
|
||||||
@ -98,9 +108,11 @@ def mopidy_track_diff_album_request_data(**kwargs):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mopidy_podcast():
|
def mopidy_podcast_request_data():
|
||||||
mopidy_uri = "local:podcast:Up%20First/2022-01-01%20Up%20First.mp3"
|
mopidy_uri = "local:podcast:Up%20First/2022-01-01%20Up%20First.mp3"
|
||||||
return MopidyRequest(mopidy_uri=mopidy_uri)
|
return MopidyRequest(
|
||||||
|
mopidy_uri=mopidy_uri, artist="NPR", album="Up First"
|
||||||
|
).request_json
|
||||||
|
|
||||||
|
|
||||||
class JellyfinTrackRequest:
|
class JellyfinTrackRequest:
|
||||||
|
|||||||
@ -30,55 +30,26 @@ def test_bad_mopidy_request_data(client, valid_auth_token):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.skip("Need to refactor")
|
||||||
"seconds, expected_percent_played, expected_scrobble_id",
|
@pytest.mark.django_db
|
||||||
[
|
@patch("music.utils.lookup_artist_from_mb", return_value={})
|
||||||
(1, 1, 1),
|
@patch(
|
||||||
(58, 96, 1),
|
"music.utils.lookup_album_dict_from_mb",
|
||||||
(59, 98, 1),
|
return_value={"year": "1999", "mb_group_id": 1},
|
||||||
(60, 100, 1),
|
|
||||||
(1, 1, 2),
|
|
||||||
(1, 1, 3),
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
@pytest.mark.django_db
|
@patch("music.utils.lookup_track_from_mb", return_value={})
|
||||||
def test_scrobble_mopidy_track(
|
@patch("music.models.lookup_artist_from_tadb", return_value={})
|
||||||
client,
|
@patch("music.models.lookup_album_from_tadb", return_value={"year": "1999"})
|
||||||
mopidy_track,
|
@patch("music.models.Album.fetch_artwork", return_value=None)
|
||||||
valid_auth_token,
|
@patch("music.models.Album.scrape_allmusic", return_value=None)
|
||||||
seconds,
|
|
||||||
expected_percent_played,
|
|
||||||
expected_scrobble_id,
|
|
||||||
):
|
|
||||||
url = reverse("scrobbles:mopidy-webhook")
|
|
||||||
headers = {"Authorization": f"Token {valid_auth_token}"}
|
|
||||||
|
|
||||||
# Start new scrobble
|
|
||||||
minutes = 0
|
|
||||||
calc_seconds = seconds
|
|
||||||
if seconds >= 60:
|
|
||||||
minutes = 1
|
|
||||||
calc_seconds = calc_seconds % 10
|
|
||||||
with time_machine.travel(datetime(2024, 1, 14, 12, minutes, calc_seconds)):
|
|
||||||
mopidy_track.request_data["playback_time_ticks"] = seconds * 1000
|
|
||||||
response = client.post(
|
|
||||||
url,
|
|
||||||
mopidy_track.request_json,
|
|
||||||
content_type="application/json",
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.data == {"scrobble_id": expected_scrobble_id}
|
|
||||||
|
|
||||||
scrobble = Scrobble.objects.get(id=1)
|
|
||||||
assert scrobble.percent_played == expected_percent_played
|
|
||||||
assert scrobble.media_obj.__class__ == Track
|
|
||||||
assert scrobble.media_obj.title == "Same in the End"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skip(reason="Allmusic API is unstable")
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_scrobble_mopidy_same_track_different_album(
|
def test_scrobble_mopidy_same_track_different_album(
|
||||||
|
mock_lookup_artist,
|
||||||
|
mock_lookup_album,
|
||||||
|
mock_lookup_track,
|
||||||
|
mock_lookup_artist_tadb,
|
||||||
|
mock_lookup_album_tadb,
|
||||||
|
mock_fetch_artwork,
|
||||||
|
mock_scrape_allmusic,
|
||||||
client,
|
client,
|
||||||
mopidy_track,
|
mopidy_track,
|
||||||
mopidy_track_diff_album_request_data,
|
mopidy_track_diff_album_request_data,
|
||||||
@ -107,13 +78,17 @@ def test_scrobble_mopidy_same_track_different_album(
|
|||||||
assert response.data == {"scrobble_id": 2}
|
assert response.data == {"scrobble_id": 2}
|
||||||
scrobble = Scrobble.objects.last()
|
scrobble = Scrobble.objects.last()
|
||||||
assert scrobble.media_obj.__class__ == Track
|
assert scrobble.media_obj.__class__ == Track
|
||||||
assert scrobble.media_obj.album.name == "Gold"
|
assert scrobble.media_obj.album.name == "Sublime"
|
||||||
assert scrobble.media_obj.title == "Same in the End"
|
assert scrobble.media_obj.title == "Same in the End"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
|
@patch(
|
||||||
|
"podcasts.sources.podcastindex.lookup_podcast_from_podcastindex",
|
||||||
|
return_value={},
|
||||||
|
)
|
||||||
def test_scrobble_mopidy_podcast(
|
def test_scrobble_mopidy_podcast(
|
||||||
client, mopidy_podcast_request_data, valid_auth_token
|
mock_lookup_podcast, client, mopidy_podcast_request_data, valid_auth_token
|
||||||
):
|
):
|
||||||
url = reverse("scrobbles:mopidy-webhook")
|
url = reverse("scrobbles:mopidy-webhook")
|
||||||
headers = {"Authorization": f"Token {valid_auth_token}"}
|
headers = {"Authorization": f"Token {valid_auth_token}"}
|
||||||
@ -131,6 +106,7 @@ def test_scrobble_mopidy_podcast(
|
|||||||
assert scrobble.media_obj.title == "Up First"
|
assert scrobble.media_obj.title == "Up First"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip("Need to refactor")
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@patch("music.utils.lookup_artist_from_mb", return_value={})
|
@patch("music.utils.lookup_artist_from_mb", return_value={})
|
||||||
@patch(
|
@patch(
|
||||||
@ -174,38 +150,103 @@ def test_scrobble_jellyfin_track(
|
|||||||
assert scrobble.media_obj.__class__ == Track
|
assert scrobble.media_obj.__class__ == Track
|
||||||
assert scrobble.media_obj.title == "Emotion"
|
assert scrobble.media_obj.title == "Emotion"
|
||||||
|
|
||||||
with time_machine.travel(datetime(2024, 1, 14, 12, 0, 58)):
|
|
||||||
jellyfin_track.request_data["UtcTimestamp"] = timezone.now().strftime(
|
|
||||||
"%Y-%m-%d %H:%M:%S"
|
|
||||||
)
|
|
||||||
response = client.post(
|
|
||||||
url,
|
|
||||||
jellyfin_track.request_json,
|
|
||||||
content_type="application/json",
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
@pytest.mark.skip("Need to refactor")
|
||||||
assert response.data == {"scrobble_id": 1}
|
@pytest.mark.django_db
|
||||||
|
@patch("music.utils.lookup_artist_from_mb", return_value={})
|
||||||
|
@patch(
|
||||||
|
"music.utils.lookup_album_dict_from_mb",
|
||||||
|
return_value={"year": "1999", "mb_group_id": 1},
|
||||||
|
)
|
||||||
|
@patch("music.utils.lookup_track_from_mb", return_value={})
|
||||||
|
@patch("music.models.lookup_artist_from_tadb", return_value={})
|
||||||
|
@patch("music.models.lookup_album_from_tadb", return_value={"year": "1999"})
|
||||||
|
@patch("music.models.Album.fetch_artwork", return_value=None)
|
||||||
|
@patch("music.models.Album.scrape_allmusic", return_value=None)
|
||||||
|
def test_scrobble_jellyfin_track_update(
|
||||||
|
mock_lookup_artist,
|
||||||
|
mock_lookup_album,
|
||||||
|
mock_lookup_track,
|
||||||
|
mock_lookup_artist_tadb,
|
||||||
|
mock_lookup_album_tadb,
|
||||||
|
mock_fetch_artwork,
|
||||||
|
mock_scrape_allmusic,
|
||||||
|
test_track,
|
||||||
|
client,
|
||||||
|
jellyfin_track,
|
||||||
|
valid_auth_token,
|
||||||
|
):
|
||||||
|
Scrobble.objects.create(
|
||||||
|
timestamp=timezone.now() - timedelta(minutes=0.5),
|
||||||
|
track=Track.objects.first(),
|
||||||
|
user_id=1,
|
||||||
|
)
|
||||||
|
url = reverse("scrobbles:jellyfin-webhook")
|
||||||
|
headers = {"Authorization": f"Token {valid_auth_token}"}
|
||||||
|
|
||||||
scrobble = Scrobble.objects.get(id=1)
|
jellyfin_track.request_data["UtcTimestamp"] = timezone.now().strftime(
|
||||||
assert scrobble.media_obj.__class__ == Track
|
"%Y-%m-%d %H:%M:%S"
|
||||||
assert scrobble.media_obj.title == "Emotion"
|
)
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
jellyfin_track.request_json,
|
||||||
|
content_type="application/json",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
with time_machine.travel(datetime(2024, 1, 14, 12, 1, 1)):
|
assert response.status_code == 200
|
||||||
jellyfin_track.request_data["UtcTimestamp"] = timezone.now().strftime(
|
assert response.data == {"scrobble_id": 1}
|
||||||
"%Y-%m-%d %H:%M:%S"
|
|
||||||
)
|
|
||||||
response = client.post(
|
|
||||||
url,
|
|
||||||
jellyfin_track.request_json,
|
|
||||||
content_type="application/json",
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
scrobble = Scrobble.objects.get(id=1)
|
||||||
assert response.data == {"scrobble_id": 2}
|
assert scrobble.media_obj.__class__ == Track
|
||||||
|
assert scrobble.media_obj.title == "Emotion"
|
||||||
|
|
||||||
scrobble = Scrobble.objects.get(id=1)
|
|
||||||
assert scrobble.media_obj.__class__ == Track
|
@pytest.mark.skip("Need to refactor")
|
||||||
assert scrobble.media_obj.title == "Emotion"
|
@pytest.mark.django_db
|
||||||
|
@patch("music.utils.lookup_artist_from_mb", return_value={})
|
||||||
|
@patch(
|
||||||
|
"music.utils.lookup_album_dict_from_mb",
|
||||||
|
return_value={"year": "1999", "mb_group_id": 1},
|
||||||
|
)
|
||||||
|
@patch("music.utils.lookup_track_from_mb", return_value={})
|
||||||
|
@patch("music.models.lookup_artist_from_tadb", return_value={})
|
||||||
|
@patch("music.models.lookup_album_from_tadb", return_value={"year": "1999"})
|
||||||
|
@patch("music.models.Album.fetch_artwork", return_value=None)
|
||||||
|
@patch("music.models.Album.scrape_allmusic", return_value=None)
|
||||||
|
def test_scrobble_jellyfin_track_create_new(
|
||||||
|
mock_lookup_artist,
|
||||||
|
mock_lookup_album,
|
||||||
|
mock_lookup_track,
|
||||||
|
mock_lookup_artist_tadb,
|
||||||
|
mock_lookup_album_tadb,
|
||||||
|
mock_fetch_artwork,
|
||||||
|
mock_scrape_allmusic,
|
||||||
|
test_track,
|
||||||
|
client,
|
||||||
|
jellyfin_track,
|
||||||
|
valid_auth_token,
|
||||||
|
):
|
||||||
|
url = reverse("scrobbles:jellyfin-webhook")
|
||||||
|
headers = {"Authorization": f"Token {valid_auth_token}"}
|
||||||
|
Scrobble.objects.create(
|
||||||
|
timestamp=timezone.now() - timedelta(minutes=1),
|
||||||
|
track=Track.objects.first(),
|
||||||
|
user_id=1,
|
||||||
|
)
|
||||||
|
jellyfin_track.request_data["UtcTimestamp"] = timezone.now().strftime(
|
||||||
|
"%Y-%m-%d %H:%M:%S"
|
||||||
|
)
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
jellyfin_track.request_json,
|
||||||
|
content_type="application/json",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data == {"scrobble_id": 2}
|
||||||
|
|
||||||
|
scrobble = Scrobble.objects.get(id=1)
|
||||||
|
assert scrobble.media_obj.__class__ == Track
|
||||||
|
assert scrobble.media_obj.title == "Emotion"
|
||||||
|
|||||||
@ -1,11 +1,6 @@
|
|||||||
import pytest
|
from videos.sources.imdb import lookup_video_from_imdb
|
||||||
|
|
||||||
from videos.imdb import lookup_video_from_imdb
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skip(reason="Need to sort out third party API testing")
|
def test_lookup_imdb():
|
||||||
def test_lookup_imdb_bad_id(caplog):
|
metadata = lookup_video_from_imdb("8946378")
|
||||||
data = lookup_video_from_imdb("3409324")
|
assert metadata.title == "Knives Out"
|
||||||
assert data is None
|
|
||||||
assert caplog.records[0].levelname == "WARNING"
|
|
||||||
assert caplog.records[0].msg == "IMDB ID should begin with 'tt' 3409324"
|
|
||||||
|
|||||||
9
tests/videos_tests/test_youtube.py
Normal file
9
tests/videos_tests/test_youtube.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import pytest
|
||||||
|
from videos.sources.youtube import lookup_video_from_youtube
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="Need to configure Youtube API stuffs in CI")
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_lookup_youtube_id():
|
||||||
|
metadata = lookup_video_from_youtube("RZxs9pAv99Y")
|
||||||
|
assert metadata.title == "No Pun Included's Board Game of the Year 2024"
|
||||||
@ -21,6 +21,10 @@ VROBBLER_THEAUDIODB_API_KEY="<key>"
|
|||||||
VROBBLER_IGDB_CLIENT_ID="<id>"
|
VROBBLER_IGDB_CLIENT_ID="<id>"
|
||||||
VROBBLER_IGDB_CLIENT_SECRET="<key>"
|
VROBBLER_IGDB_CLIENT_SECRET="<key>"
|
||||||
VROBBLER_COMICVINE_API_KEY="<key>"
|
VROBBLER_COMICVINE_API_KEY="<key>"
|
||||||
|
VROBBLER_TODOIST_CLIENT_ID="<id>"
|
||||||
|
VROBBLER_TODOIST_CLIENT_SECRET="<key>"
|
||||||
|
VROBBLER_GOOGLE_API_KEY="<key>"
|
||||||
|
VROBBLER_LICHESS_API_KEY = "<key>"
|
||||||
|
|
||||||
# Storages
|
# Storages
|
||||||
# VROBBLER_DATABASE_URL="postgres://USER:PASSWORD@HOST:PORT/NAME"
|
# VROBBLER_DATABASE_URL="postgres://USER:PASSWORD@HOST:PORT/NAME"
|
||||||
|
|||||||
34
vrobbler/apps/beers/admin.py
Normal file
34
vrobbler/apps/beers/admin.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
from beers.models import Beer, BeerProducer, BeerStyle
|
||||||
|
from django.contrib import admin
|
||||||
|
from scrobbles.admin import ScrobbleInline
|
||||||
|
|
||||||
|
|
||||||
|
class BeerInline(admin.TabularInline):
|
||||||
|
model = Beer
|
||||||
|
extra = 0
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(BeerStyle)
|
||||||
|
class BeerStyle(admin.ModelAdmin):
|
||||||
|
date_hierarchy = "created"
|
||||||
|
search_fields = ("name",)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(BeerProducer)
|
||||||
|
class BeerProducer(admin.ModelAdmin):
|
||||||
|
date_hierarchy = "created"
|
||||||
|
search_fields = ("name",)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Beer)
|
||||||
|
class BeerAdmin(admin.ModelAdmin):
|
||||||
|
date_hierarchy = "created"
|
||||||
|
list_display = (
|
||||||
|
"uuid",
|
||||||
|
"title",
|
||||||
|
)
|
||||||
|
ordering = ("-created",)
|
||||||
|
search_fields = ("title",)
|
||||||
|
inlines = [
|
||||||
|
ScrobbleInline,
|
||||||
|
]
|
||||||
5
vrobbler/apps/beers/apps.py
Normal file
5
vrobbler/apps/beers/apps.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class BeersConfig(AppConfig):
|
||||||
|
name = "beers"
|
||||||
133
vrobbler/apps/beers/migrations/0001_initial.py
Normal file
133
vrobbler/apps/beers/migrations/0001_initial.py
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
# Generated by Django 4.2.16 on 2024-10-22 21:26
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django_extensions.db.fields
|
||||||
|
import taggit.managers
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("scrobbles", "0065_alter_scrobble_log"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="BeerProducer",
|
||||||
|
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"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("description", models.TextField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"location",
|
||||||
|
models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"get_latest_by": "modified",
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Beer",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"created",
|
||||||
|
django_extensions.db.fields.CreationDateTimeField(
|
||||||
|
auto_now_add=True, verbose_name="created"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"modified",
|
||||||
|
django_extensions.db.fields.ModificationDateTimeField(
|
||||||
|
auto_now=True, verbose_name="modified"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"uuid",
|
||||||
|
models.UUIDField(
|
||||||
|
blank=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"title",
|
||||||
|
models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"run_time_seconds",
|
||||||
|
models.IntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"run_time_ticks",
|
||||||
|
models.PositiveBigIntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
("description", models.TextField(blank=True, null=True)),
|
||||||
|
("ibu", models.SmallIntegerField(blank=True, null=True)),
|
||||||
|
("abv", models.FloatField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"style",
|
||||||
|
models.CharField(blank=True, max_length=100, null=True),
|
||||||
|
),
|
||||||
|
("non_alcoholic", models.BooleanField(default=False)),
|
||||||
|
(
|
||||||
|
"beeradvocate_id",
|
||||||
|
models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"beeradvocate_score",
|
||||||
|
models.SmallIntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"untappd_id",
|
||||||
|
models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"genre",
|
||||||
|
taggit.managers.TaggableManager(
|
||||||
|
blank=True,
|
||||||
|
help_text="A comma-separated list of tags.",
|
||||||
|
through="scrobbles.ObjectWithGenres",
|
||||||
|
to="scrobbles.Genre",
|
||||||
|
verbose_name="Tags",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
# Generated by Django 4.2.16 on 2024-10-22 21:34
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("beers", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="beer",
|
||||||
|
name="beeradvocate_image",
|
||||||
|
field=models.ImageField(
|
||||||
|
blank=True, null=True, upload_to="beers/beeradvcoate/"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="beer",
|
||||||
|
name="producer",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||||
|
to="beers.beerproducer",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="beerproducer",
|
||||||
|
name="beeradvocate_id",
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
# Generated by Django 4.2.16 on 2024-10-22 21:47
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django_extensions.db.fields
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("beers", "0002_beer_beeradvocate_image_beer_producer_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="BeerStyle",
|
||||||
|
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"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("description", models.TextField(blank=True, null=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"get_latest_by": "modified",
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="beer",
|
||||||
|
name="beeradvocate_image",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="beer",
|
||||||
|
name="style",
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="beer",
|
||||||
|
name="untappd_image",
|
||||||
|
field=models.ImageField(
|
||||||
|
blank=True, null=True, upload_to="beers/untappd/"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="beer",
|
||||||
|
name="untappd_rating",
|
||||||
|
field=models.FloatField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="beerproducer",
|
||||||
|
name="untappd_id",
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="beer",
|
||||||
|
name="styles",
|
||||||
|
field=models.ManyToManyField(to="beers.beerstyle"),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
# Generated by Django 4.2.16 on 2024-10-22 21:52
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("beers", "0003_beerstyle_remove_beer_beeradvocate_image_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="beerproducer",
|
||||||
|
name="name",
|
||||||
|
field=models.CharField(default="Untitled", max_length=255),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="beerproducer",
|
||||||
|
name="uuid",
|
||||||
|
field=models.UUIDField(
|
||||||
|
blank=True, default=uuid.uuid4, editable=False, null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="beerstyle",
|
||||||
|
name="name",
|
||||||
|
field=models.CharField(default="Untitled", max_length=255),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="beerstyle",
|
||||||
|
name="uuid",
|
||||||
|
field=models.UUIDField(
|
||||||
|
blank=True, default=uuid.uuid4, editable=False, null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="beer",
|
||||||
|
name="styles",
|
||||||
|
field=models.ManyToManyField(
|
||||||
|
related_name="styles", to="beers.beerstyle"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
# Generated by Django 4.2.16 on 2025-01-22 03:55
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
(
|
||||||
|
"beers",
|
||||||
|
"0004_beerproducer_name_beerproducer_uuid_beerstyle_name_and_more",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="beer",
|
||||||
|
name="run_time_seconds",
|
||||||
|
field=models.IntegerField(default=900),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
vrobbler/apps/beers/migrations/__init__.py
Normal file
0
vrobbler/apps/beers/migrations/__init__.py
Normal file
140
vrobbler/apps/beers/models.py
Normal file
140
vrobbler/apps/beers/models.py
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from beers.untappd import get_beer_from_untappd_id, get_rating_from_soup
|
||||||
|
from django.apps import apps
|
||||||
|
from django.db import models
|
||||||
|
from django.urls import reverse
|
||||||
|
from django_extensions.db.models import TimeStampedModel
|
||||||
|
from imagekit.models import ImageSpecField
|
||||||
|
from imagekit.processors import ResizeToFit
|
||||||
|
from scrobbles.dataclasses import BeerLogData
|
||||||
|
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
||||||
|
|
||||||
|
BNULL = {"blank": True, "null": True}
|
||||||
|
|
||||||
|
|
||||||
|
class BeerStyle(TimeStampedModel):
|
||||||
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
description = models.TextField(**BNULL)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class BeerProducer(TimeStampedModel):
|
||||||
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
description = models.TextField(**BNULL)
|
||||||
|
location = models.CharField(max_length=255, **BNULL)
|
||||||
|
beeradvocate_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
untappd_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
|
||||||
|
def find_or_create(cls, title: str) -> "BeerProducer":
|
||||||
|
return cls.objects.filter(title=title).first()
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class Beer(ScrobblableMixin):
|
||||||
|
description = models.TextField(**BNULL)
|
||||||
|
ibu = models.SmallIntegerField(**BNULL)
|
||||||
|
abv = models.FloatField(**BNULL)
|
||||||
|
styles = models.ManyToManyField(BeerStyle, related_name="styles")
|
||||||
|
non_alcoholic = models.BooleanField(default=False)
|
||||||
|
beeradvocate_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
beeradvocate_score = models.SmallIntegerField(**BNULL)
|
||||||
|
untappd_image = models.ImageField(upload_to="beers/untappd/", **BNULL)
|
||||||
|
untappd_image_small = ImageSpecField(
|
||||||
|
source="untappd_image",
|
||||||
|
processors=[ResizeToFit(100, 100)],
|
||||||
|
format="JPEG",
|
||||||
|
options={"quality": 60},
|
||||||
|
)
|
||||||
|
untappd_image_medium = ImageSpecField(
|
||||||
|
source="untappd_image",
|
||||||
|
processors=[ResizeToFit(300, 300)],
|
||||||
|
format="JPEG",
|
||||||
|
options={"quality": 75},
|
||||||
|
)
|
||||||
|
untappd_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
untappd_rating = models.FloatField(**BNULL)
|
||||||
|
producer = models.ForeignKey(
|
||||||
|
BeerProducer, on_delete=models.DO_NOTHING, **BNULL
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_absolute_url(self) -> str:
|
||||||
|
return reverse("beers:beer_detail", kwargs={"slug": self.uuid})
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.title} by {self.producer}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def subtitle(self):
|
||||||
|
return self.producer.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def strings(self) -> ScrobblableConstants:
|
||||||
|
return ScrobblableConstants(verb="Drinking", tags="beer")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def beeradvocate_link(self) -> str:
|
||||||
|
link = ""
|
||||||
|
if self.producer and self.beeradvocate_id:
|
||||||
|
if self.beeradvocate_id:
|
||||||
|
link = f"https://www.beeradvocate.com/beer/profile/{self.producer.beeradvocate_id}/{self.beeradvocate_id}/"
|
||||||
|
return link
|
||||||
|
|
||||||
|
@property
|
||||||
|
def untappd_link(self) -> str:
|
||||||
|
link = ""
|
||||||
|
if self.untappd_id:
|
||||||
|
link = f"https://www.untappd.com/beer/{self.untappd_id}/"
|
||||||
|
return link
|
||||||
|
|
||||||
|
@property
|
||||||
|
def primary_image_url(self) -> str:
|
||||||
|
url = ""
|
||||||
|
if self.untappd_image:
|
||||||
|
url = self.untappd_image.url
|
||||||
|
return url
|
||||||
|
|
||||||
|
@property
|
||||||
|
def logdata_cls(self):
|
||||||
|
return BeerLogData
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def find_or_create(cls, untappd_id: str) -> "Beer":
|
||||||
|
beer = cls.objects.filter(untappd_id=untappd_id).first()
|
||||||
|
|
||||||
|
if not beer:
|
||||||
|
beer_dict = get_beer_from_untappd_id(untappd_id)
|
||||||
|
producer_dict = {}
|
||||||
|
style_ids = []
|
||||||
|
for key in list(beer_dict.keys()):
|
||||||
|
if "producer__" in key:
|
||||||
|
pkey = key.replace("producer__", "")
|
||||||
|
producer_dict[pkey] = beer_dict.pop(key)
|
||||||
|
if "styles" in key:
|
||||||
|
for style in beer_dict.pop("styles"):
|
||||||
|
style_inst, created = BeerStyle.objects.get_or_create(
|
||||||
|
name=style
|
||||||
|
)
|
||||||
|
style_ids.append(style_inst.id)
|
||||||
|
|
||||||
|
producer, _created = BeerProducer.objects.get_or_create(
|
||||||
|
**producer_dict
|
||||||
|
)
|
||||||
|
beer_dict["producer_id"] = producer.id
|
||||||
|
beer = Beer.objects.create(**beer_dict)
|
||||||
|
for style_id in style_ids:
|
||||||
|
beer.styles.add(style_id)
|
||||||
|
|
||||||
|
return beer
|
||||||
|
|
||||||
|
def scrobbles(self, user_id):
|
||||||
|
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
||||||
|
return Scrobble.objects.filter(user_id=user_id, beer=self).order_by(
|
||||||
|
"-timestamp"
|
||||||
|
)
|
||||||
142
vrobbler/apps/beers/untappd.py
Normal file
142
vrobbler/apps/beers/untappd.py
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
UNTAPPD_URL = "https://untappd.com/beer/{id}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_first(key: str, result: dict) -> str:
|
||||||
|
obj = ""
|
||||||
|
if obj_list := result.get(key):
|
||||||
|
obj = obj_list[0]
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def get_title_from_soup(soup) -> str:
|
||||||
|
title = ""
|
||||||
|
try:
|
||||||
|
title = soup.find("h1").get_text()
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return title
|
||||||
|
|
||||||
|
|
||||||
|
def get_description_from_soup(soup) -> str:
|
||||||
|
desc = ""
|
||||||
|
try:
|
||||||
|
desc = (
|
||||||
|
soup.find(class_="beer-descrption-read-less")
|
||||||
|
.get_text()
|
||||||
|
.replace("Show Less", "")
|
||||||
|
.strip()
|
||||||
|
)
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return desc
|
||||||
|
|
||||||
|
|
||||||
|
def get_styles_from_soup(soup) -> list[str]:
|
||||||
|
styles = []
|
||||||
|
try:
|
||||||
|
styles = soup.find("p", class_="style").get_text().split(" - ")
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return styles
|
||||||
|
|
||||||
|
|
||||||
|
def get_abv_from_soup(soup) -> Optional[float]:
|
||||||
|
abv = None
|
||||||
|
try:
|
||||||
|
abv = soup.find(class_="abv").get_text()
|
||||||
|
if abv:
|
||||||
|
abv = float(abv.strip("\n").strip("% ABV").strip())
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
except TypeError:
|
||||||
|
pass
|
||||||
|
return abv
|
||||||
|
|
||||||
|
|
||||||
|
def get_ibu_from_soup(soup) -> Optional[int]:
|
||||||
|
ibu = None
|
||||||
|
try:
|
||||||
|
ibu = soup.find(class_="ibu").get_text()
|
||||||
|
if ibu:
|
||||||
|
ibu = int(ibu.strip("\n").strip(" IBU").strip())
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
except ValueError:
|
||||||
|
ibu = None
|
||||||
|
return ibu
|
||||||
|
|
||||||
|
|
||||||
|
def get_rating_from_soup(soup) -> str:
|
||||||
|
rating = ""
|
||||||
|
try:
|
||||||
|
rating = float(
|
||||||
|
soup.find(class_="num").get_text().strip("(").strip(")")
|
||||||
|
)
|
||||||
|
except AttributeError:
|
||||||
|
rating = None
|
||||||
|
except ValueError:
|
||||||
|
rating = None
|
||||||
|
return rating
|
||||||
|
|
||||||
|
|
||||||
|
def get_producer_id_from_soup(soup) -> str:
|
||||||
|
id = ""
|
||||||
|
try:
|
||||||
|
id = soup.find(class_="brewery").find("a")["href"].strip("/")
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
except IndexError:
|
||||||
|
pass
|
||||||
|
return id
|
||||||
|
|
||||||
|
|
||||||
|
def get_producer_name_from_soup(soup) -> str:
|
||||||
|
name = ""
|
||||||
|
try:
|
||||||
|
name = soup.find(class_="brewery").find("a").get_text()
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def get_beer_from_untappd_id(untappd_id: str) -> dict:
|
||||||
|
beer_url = UNTAPPD_URL.format(id=untappd_id)
|
||||||
|
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||||
|
response = requests.get(beer_url, headers=headers)
|
||||||
|
beer_dict = {"untappd_id": untappd_id}
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.warn(
|
||||||
|
"Bad response from untappd.com", extra={"response": response}
|
||||||
|
)
|
||||||
|
return beer_dict
|
||||||
|
|
||||||
|
soup = BeautifulSoup(response.text, "html.parser")
|
||||||
|
beer_dict["title"] = get_title_from_soup(soup)
|
||||||
|
beer_dict["description"] = get_description_from_soup(soup)
|
||||||
|
beer_dict["styles"] = get_styles_from_soup(soup)
|
||||||
|
beer_dict["abv"] = get_abv_from_soup(soup)
|
||||||
|
beer_dict["ibu"] = get_ibu_from_soup(soup)
|
||||||
|
beer_dict["untappd_rating"] = get_rating_from_soup(soup)
|
||||||
|
beer_dict["producer__untappd_id"] = get_producer_id_from_soup(soup)
|
||||||
|
beer_dict["producer__name"] = get_producer_name_from_soup(soup)
|
||||||
|
|
||||||
|
return beer_dict
|
||||||
14
vrobbler/apps/beers/urls.py
Normal file
14
vrobbler/apps/beers/urls.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from beers import views
|
||||||
|
|
||||||
|
app_name = "beers"
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("beers/", views.BeerListView.as_view(), name="beer_list"),
|
||||||
|
path(
|
||||||
|
"beers/<slug:slug>/",
|
||||||
|
views.BeerDetailView.as_view(),
|
||||||
|
name="beer_detail",
|
||||||
|
),
|
||||||
|
]
|
||||||
11
vrobbler/apps/beers/views.py
Normal file
11
vrobbler/apps/beers/views.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
from beers.models import Beer
|
||||||
|
|
||||||
|
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
|
||||||
|
|
||||||
|
|
||||||
|
class BeerListView(ScrobbleableListView):
|
||||||
|
model = Beer
|
||||||
|
|
||||||
|
|
||||||
|
class BeerDetailView(ScrobbleableDetailView):
|
||||||
|
model = Beer
|
||||||
@ -1,6 +1,11 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from boardgames.models import BoardGame, BoardGamePublisher
|
from boardgames.models import (
|
||||||
|
BoardGame,
|
||||||
|
BoardGameLocation,
|
||||||
|
BoardGamePublisher,
|
||||||
|
BoardGameDesigner,
|
||||||
|
)
|
||||||
|
|
||||||
from scrobbles.admin import ScrobbleInline
|
from scrobbles.admin import ScrobbleInline
|
||||||
|
|
||||||
@ -15,13 +20,34 @@ class BoardGamePublisherAdmin(admin.ModelAdmin):
|
|||||||
ordering = ("-created",)
|
ordering = ("-created",)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(BoardGameDesigner)
|
||||||
|
class BoardGameDesignerAdmin(admin.ModelAdmin):
|
||||||
|
date_hierarchy = "created"
|
||||||
|
list_display = (
|
||||||
|
"name",
|
||||||
|
"uuid",
|
||||||
|
)
|
||||||
|
ordering = ("-created",)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(BoardGameLocation)
|
||||||
|
class BoardGameLocationAdmin(admin.ModelAdmin):
|
||||||
|
date_hierarchy = "created"
|
||||||
|
list_display = (
|
||||||
|
"name",
|
||||||
|
"uuid",
|
||||||
|
"geo_location",
|
||||||
|
)
|
||||||
|
ordering = ("-created",)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(BoardGame)
|
@admin.register(BoardGame)
|
||||||
class GameAdmin(admin.ModelAdmin):
|
class BoardGameAdmin(admin.ModelAdmin):
|
||||||
date_hierarchy = "created"
|
date_hierarchy = "created"
|
||||||
list_display = (
|
list_display = (
|
||||||
"bggeek_id",
|
"bggeek_id",
|
||||||
"title",
|
"title",
|
||||||
"published_date",
|
"published_year",
|
||||||
)
|
)
|
||||||
search_fields = ("title",)
|
search_fields = ("title",)
|
||||||
ordering = ("-created",)
|
ordering = ("-created",)
|
||||||
|
|||||||
@ -1,8 +1,15 @@
|
|||||||
|
import csv
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -90,3 +97,60 @@ def lookup_boardgame_from_bgg(lookup_id: str) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
return game_dict
|
return game_dict
|
||||||
|
|
||||||
|
|
||||||
|
def push_scrobble_to_bgg(scrobble: "Scrobble", user: User) -> Optional[bool]:
|
||||||
|
bgg_username = "secstate" # user.profile.bgg_username
|
||||||
|
bgg_password = "yYFCKnfo8AK89lc68q0S"
|
||||||
|
|
||||||
|
if not bgg_username or bgg_password:
|
||||||
|
return
|
||||||
|
|
||||||
|
login_payload = {
|
||||||
|
"credentials": {"username": bgg_username, "password": bgg_password}
|
||||||
|
}
|
||||||
|
headers = {"content-type": "application/json"}
|
||||||
|
|
||||||
|
# TODO Look up past plays for scrobble.media_obj.bggeek_id, and make sure we haven't scrobbled this before
|
||||||
|
|
||||||
|
with requests.Session() as s:
|
||||||
|
p = s.post(
|
||||||
|
"https://boardgamegeek.com/login/api/v1",
|
||||||
|
data=json.dumps(login_payload),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
print(p)
|
||||||
|
|
||||||
|
players = []
|
||||||
|
if scrobble.log:
|
||||||
|
for player in scrobble.log.get("players"):
|
||||||
|
player_person = Person.objects.filter(
|
||||||
|
id=player.get("person_id")
|
||||||
|
).first()
|
||||||
|
if player_person.get("bgg_username"):
|
||||||
|
player["username"] = player_person.get("bgg_username")
|
||||||
|
player["name"] = player_person.get("name")
|
||||||
|
player["win"] = player.get("win")
|
||||||
|
# player["role"] = player.get("role")
|
||||||
|
player["new"] = player.get("new")
|
||||||
|
player["score"] = player.get("score")
|
||||||
|
players.append(player)
|
||||||
|
|
||||||
|
play_payload = {
|
||||||
|
"playdate": scrobble.timestamp.date.strftime("%Y-%m-%d"),
|
||||||
|
"length": scrobble.playback_position_seconds / 60,
|
||||||
|
"comments": "Uploaded from Vrobbler",
|
||||||
|
"location": scrobble.log.location or None,
|
||||||
|
"objectid": scrobble.media_obj.bggeek_id,
|
||||||
|
"quantity": "1",
|
||||||
|
"action": "save",
|
||||||
|
"players": players,
|
||||||
|
"objecttype": "thing",
|
||||||
|
"ajax": 1,
|
||||||
|
}
|
||||||
|
r = s.post(
|
||||||
|
"https://boardgamegeek.com/geekplay.php",
|
||||||
|
data=json.dumps(play_payload),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
print(r)
|
||||||
|
|||||||
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.16 on 2025-01-22 03:55
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("boardgames", "0006_alter_boardgame_genre"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="boardgame",
|
||||||
|
name="run_time_seconds",
|
||||||
|
field=models.IntegerField(default=900),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,167 @@
|
|||||||
|
# Generated by Django 4.2.19 on 2025-07-03 01:57
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django_extensions.db.fields
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("locations", "0007_alter_geolocation_run_time_seconds"),
|
||||||
|
("boardgames", "0007_alter_boardgame_run_time_seconds"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="BoardGameDesigner",
|
||||||
|
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"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(max_length=255)),
|
||||||
|
(
|
||||||
|
"uuid",
|
||||||
|
models.UUIDField(
|
||||||
|
blank=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("bgg_id", models.IntegerField(blank=True, null=True)),
|
||||||
|
("bio", models.TextField(blank=True, null=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"get_latest_by": "modified",
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="boardgamepublisher",
|
||||||
|
old_name="igdb_id",
|
||||||
|
new_name="bgg_id",
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="boardgame",
|
||||||
|
name="bgstats_id",
|
||||||
|
field=models.UUIDField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="boardgame",
|
||||||
|
name="cooperative",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="boardgame",
|
||||||
|
name="expansion_for_boardgame",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||||
|
to="boardgames.boardgame",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="boardgame",
|
||||||
|
name="highest_wins",
|
||||||
|
field=models.BooleanField(default=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="boardgame",
|
||||||
|
name="max_play_time",
|
||||||
|
field=models.IntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="boardgame",
|
||||||
|
name="min_play_time",
|
||||||
|
field=models.IntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="boardgame",
|
||||||
|
name="no_points",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="boardgame",
|
||||||
|
name="uses_teams",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="BoardGameLocation",
|
||||||
|
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"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(max_length=255)),
|
||||||
|
(
|
||||||
|
"uuid",
|
||||||
|
models.UUIDField(
|
||||||
|
blank=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("bgstats_id", models.UUIDField(blank=True, null=True)),
|
||||||
|
("description", models.TextField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"geo_location",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||||
|
to="locations.geolocation",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"get_latest_by": "modified",
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="boardgame",
|
||||||
|
name="designers",
|
||||||
|
field=models.ManyToManyField(
|
||||||
|
related_name="board_games", to="boardgames.boardgamedesigner"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 4.2.19 on 2025-07-03 02:21
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("boardgames", "0008_boardgamedesigner_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="boardgame",
|
||||||
|
name="cooperative",
|
||||||
|
field=models.BooleanField(blank=True, default=False, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="boardgame",
|
||||||
|
name="highest_wins",
|
||||||
|
field=models.BooleanField(blank=True, default=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="boardgame",
|
||||||
|
name="no_points",
|
||||||
|
field=models.BooleanField(blank=True, default=False, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="boardgame",
|
||||||
|
name="uses_teams",
|
||||||
|
field=models.BooleanField(blank=True, default=False, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.19 on 2025-07-03 04:05
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("boardgames", "0009_alter_boardgame_cooperative_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="boardgame",
|
||||||
|
name="published_year",
|
||||||
|
field=models.IntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -13,7 +13,8 @@ from django_extensions.db.models import TimeStampedModel
|
|||||||
from imagekit.models import ImageSpecField
|
from imagekit.models import ImageSpecField
|
||||||
from imagekit.processors import ResizeToFit
|
from imagekit.processors import ResizeToFit
|
||||||
from scrobbles.dataclasses import BoardGameLogData
|
from scrobbles.dataclasses import BoardGameLogData
|
||||||
from scrobbles.mixins import ScrobblableMixin
|
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
||||||
|
from locations.models import GeoLocation
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
BNULL = {"blank": True, "null": True}
|
BNULL = {"blank": True, "null": True}
|
||||||
@ -23,7 +24,7 @@ class BoardGamePublisher(TimeStampedModel):
|
|||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||||
logo = models.ImageField(upload_to="games/platform-logos/", **BNULL)
|
logo = models.ImageField(upload_to="games/platform-logos/", **BNULL)
|
||||||
igdb_id = models.IntegerField(**BNULL)
|
bgg_id = models.IntegerField(**BNULL)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
@ -34,6 +35,39 @@ class BoardGamePublisher(TimeStampedModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BoardGameDesigner(TimeStampedModel):
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||||
|
bgg_id = models.IntegerField(**BNULL)
|
||||||
|
bio = models.TextField(**BNULL)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return str(self.name)
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse(
|
||||||
|
"boardgames:designer_detail", kwargs={"slug": self.uuid}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BoardGameLocation(TimeStampedModel):
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||||
|
bgstats_id = models.UUIDField(**BNULL)
|
||||||
|
description = models.TextField(**BNULL)
|
||||||
|
geo_location = models.ForeignKey(
|
||||||
|
GeoLocation, **BNULL, on_delete=models.DO_NOTHING
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return str(self.name)
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse(
|
||||||
|
"boardgames:location_detail", kwargs={"slug": self.uuid}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BoardGame(ScrobblableMixin):
|
class BoardGame(ScrobblableMixin):
|
||||||
COMPLETION_PERCENT = getattr(
|
COMPLETION_PERCENT = getattr(
|
||||||
settings, "BOARD_GAME_COMPLETION_PERCENT", 100
|
settings, "BOARD_GAME_COMPLETION_PERCENT", 100
|
||||||
@ -53,6 +87,10 @@ class BoardGame(ScrobblableMixin):
|
|||||||
publisher = models.ForeignKey(
|
publisher = models.ForeignKey(
|
||||||
BoardGamePublisher, **BNULL, on_delete=models.DO_NOTHING
|
BoardGamePublisher, **BNULL, on_delete=models.DO_NOTHING
|
||||||
)
|
)
|
||||||
|
designers = models.ManyToManyField(
|
||||||
|
BoardGameDesigner,
|
||||||
|
related_name="board_games",
|
||||||
|
)
|
||||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||||
description = models.TextField(**BNULL)
|
description = models.TextField(**BNULL)
|
||||||
cover = models.ImageField(upload_to="boardgames/covers/", **BNULL)
|
cover = models.ImageField(upload_to="boardgames/covers/", **BNULL)
|
||||||
@ -85,8 +123,19 @@ class BoardGame(ScrobblableMixin):
|
|||||||
max_players = models.PositiveSmallIntegerField(**BNULL)
|
max_players = models.PositiveSmallIntegerField(**BNULL)
|
||||||
min_players = models.PositiveSmallIntegerField(**BNULL)
|
min_players = models.PositiveSmallIntegerField(**BNULL)
|
||||||
published_date = models.DateField(**BNULL)
|
published_date = models.DateField(**BNULL)
|
||||||
|
published_year = models.IntegerField(**BNULL)
|
||||||
recommended_age = models.PositiveSmallIntegerField(**BNULL)
|
recommended_age = models.PositiveSmallIntegerField(**BNULL)
|
||||||
bggeek_id = models.CharField(max_length=255, **BNULL)
|
bggeek_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
bgstats_id = models.UUIDField(**BNULL)
|
||||||
|
uses_teams = models.BooleanField(default=False, **BNULL)
|
||||||
|
cooperative = models.BooleanField(default=False, **BNULL)
|
||||||
|
highest_wins = models.BooleanField(default=True, **BNULL)
|
||||||
|
no_points = models.BooleanField(default=False, **BNULL)
|
||||||
|
min_play_time = models.IntegerField(**BNULL)
|
||||||
|
max_play_time = models.IntegerField(**BNULL)
|
||||||
|
expansion_for_boardgame = models.ForeignKey(
|
||||||
|
"self", **BNULL, on_delete=models.DO_NOTHING
|
||||||
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.title
|
return self.title
|
||||||
@ -100,6 +149,10 @@ class BoardGame(ScrobblableMixin):
|
|||||||
def logdata_cls(self):
|
def logdata_cls(self):
|
||||||
return BoardGameLogData
|
return BoardGameLogData
|
||||||
|
|
||||||
|
@property
|
||||||
|
def strings(self) -> ScrobblableConstants:
|
||||||
|
return ScrobblableConstants(verb="Playing", tags="game_die")
|
||||||
|
|
||||||
def primary_image_url(self) -> str:
|
def primary_image_url(self) -> str:
|
||||||
url = ""
|
url = ""
|
||||||
if self.cover:
|
if self.cover:
|
||||||
@ -112,9 +165,6 @@ class BoardGame(ScrobblableMixin):
|
|||||||
link = f"https://boardgamegeek.com/boardgame/{self.bggeek_id}"
|
link = f"https://boardgamegeek.com/boardgame/{self.bggeek_id}"
|
||||||
return link
|
return link
|
||||||
|
|
||||||
def get_start_url(self):
|
|
||||||
return reverse("scrobbles:start", kwargs={"uuid": self.uuid})
|
|
||||||
|
|
||||||
def fix_metadata(self, data: dict = {}, force_update=False) -> None:
|
def fix_metadata(self, data: dict = {}, force_update=False) -> None:
|
||||||
|
|
||||||
if not self.published_date or force_update:
|
if not self.published_date or force_update:
|
||||||
@ -127,7 +177,12 @@ class BoardGame(ScrobblableMixin):
|
|||||||
publisher_name = data.pop("publisher_name")
|
publisher_name = data.pop("publisher_name")
|
||||||
|
|
||||||
if year:
|
if year:
|
||||||
data["published_date"] = datetime(int(year), 1, 1)
|
data["published_year"] = int(year)
|
||||||
|
|
||||||
|
if not data["min_players"]:
|
||||||
|
data.pop("min_players")
|
||||||
|
if not data["min_players"]:
|
||||||
|
data.pop("max_players")
|
||||||
|
|
||||||
# Fun trick for updating all fields at once
|
# Fun trick for updating all fields at once
|
||||||
BoardGame.objects.filter(pk=self.id).update(**data)
|
BoardGame.objects.filter(pk=self.id).update(**data)
|
||||||
|
|||||||
128
vrobbler/apps/boardgames/sources/lichess.py
Normal file
128
vrobbler/apps/boardgames/sources/lichess.py
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import berserk
|
||||||
|
from boardgames.models import BoardGame
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
from scrobbles.notifications import NtfyNotification
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
def import_chess_games_for_user_id(user_id: int, commit: bool = False) -> dict:
|
||||||
|
user = User.objects.get(id=user_id)
|
||||||
|
|
||||||
|
client = berserk.Client(
|
||||||
|
session=berserk.TokenSession(settings.LICHESS_API_KEY)
|
||||||
|
)
|
||||||
|
games = client.games.export_by_player(user.profile.lichess_username)
|
||||||
|
for game_dict in games:
|
||||||
|
chess, created = BoardGame.objects.get_or_create(title="Chess")
|
||||||
|
if created:
|
||||||
|
chess.run_time_seconds = 1800
|
||||||
|
chess.bggeek_id = 171
|
||||||
|
chess.save(update_fields=["run_time_seconds", "bggeek_id"])
|
||||||
|
scrobble = Scrobble.objects.filter(
|
||||||
|
user_id=user.id,
|
||||||
|
timestamp=game_dict.get("createdAt"),
|
||||||
|
board_game_id=chess.id,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if scrobble:
|
||||||
|
continue
|
||||||
|
|
||||||
|
log_data = {
|
||||||
|
"variant": game_dict.get("variant"),
|
||||||
|
"lichess_id": game_dict.get("id"),
|
||||||
|
"rated": game_dict.get("rated"),
|
||||||
|
"speed": game_dict.get("speed"),
|
||||||
|
"moves": game_dict.get("moves"),
|
||||||
|
"players": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
winner = game_dict.get("winner")
|
||||||
|
black_player = game_dict.get("players", {}).get("black", {})
|
||||||
|
white_player = game_dict.get("players", {}).get("white", {})
|
||||||
|
|
||||||
|
user_player = {
|
||||||
|
"user_id": user.id,
|
||||||
|
"lichess_username": user.profile.lichess_username,
|
||||||
|
"bgg_username": user.profile.bgg_username,
|
||||||
|
"color": "",
|
||||||
|
"win": False,
|
||||||
|
}
|
||||||
|
other_player = {"name_str": "", "color": "", "win": False}
|
||||||
|
|
||||||
|
if (
|
||||||
|
black_player.get("user", {}).get("name", "")
|
||||||
|
== user.profile.lichess_username
|
||||||
|
):
|
||||||
|
user_player["color"] = "black"
|
||||||
|
if "aiLevel" in white_player.keys():
|
||||||
|
other_player["name_str"] = "aiLevel_" + str(
|
||||||
|
white_player.get("aiLevel", "")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
other_player["name_str"] = white_player.get("user", {}).get(
|
||||||
|
"name", ""
|
||||||
|
)
|
||||||
|
other_player["lichess_username"] = other_player["name_str"]
|
||||||
|
|
||||||
|
other_player["color"] = "white"
|
||||||
|
if winner == "black":
|
||||||
|
user_player["win"] = True
|
||||||
|
else:
|
||||||
|
other_player["win"] = True
|
||||||
|
if (
|
||||||
|
white_player.get("user", {}).get("name", "")
|
||||||
|
== user.profile.lichess_username
|
||||||
|
):
|
||||||
|
user_player["color"] = "white"
|
||||||
|
if "aiLevel" in black_player.keys():
|
||||||
|
other_player["name_str"] = "aiLevel_" + str(
|
||||||
|
black_player.get("aiLevel", "")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
other_player["name_str"] = black_player.get("user", {}).get(
|
||||||
|
"name", ""
|
||||||
|
)
|
||||||
|
other_player["lichess_username"] = other_player["name_str"]
|
||||||
|
other_player["color"] = "black"
|
||||||
|
if winner == "white":
|
||||||
|
user_player["win"] = True
|
||||||
|
else:
|
||||||
|
other_player["win"] = True
|
||||||
|
|
||||||
|
log_data["players"].append(user_player)
|
||||||
|
log_data["players"].append(other_player)
|
||||||
|
|
||||||
|
scrobble_dict = {
|
||||||
|
"media_type": Scrobble.MediaType.BOARD_GAME,
|
||||||
|
"user_id": user.id,
|
||||||
|
"playback_position_seconds": (
|
||||||
|
game_dict.get("lastMoveAt") - game_dict.get("createdAt")
|
||||||
|
).seconds,
|
||||||
|
"in_progress": False,
|
||||||
|
"played_to_completion": True,
|
||||||
|
"timestamp": game_dict.get("createdAt"),
|
||||||
|
"stop_timestamp": game_dict.get("lastMoveAt"),
|
||||||
|
"board_game_id": chess.id,
|
||||||
|
"source": "Lichess",
|
||||||
|
"timezone": user.profile.timezone,
|
||||||
|
"log": log_data,
|
||||||
|
}
|
||||||
|
if commit:
|
||||||
|
Scrobble.objects.create(**scrobble_dict)
|
||||||
|
return scrobble_dict
|
||||||
|
|
||||||
|
|
||||||
|
def import_chess_games_for_all_users():
|
||||||
|
scrobbles_to_create = []
|
||||||
|
for user in User.objects.filter(profile__lichess_username__isnull=False):
|
||||||
|
scrobble_dict = import_chess_games_for_user_id(user.id)
|
||||||
|
scrobbles_to_create.append(Scrobble(**scrobble_dict))
|
||||||
|
|
||||||
|
if scrobbles_to_create:
|
||||||
|
created = Scrobble.objects.bulk_create(scrobbles_to_create)
|
||||||
|
for scrobble in created:
|
||||||
|
NtfyNotification(scrobble).send()
|
||||||
|
return scrobbles_to_create
|
||||||
@ -6,10 +6,12 @@ app_name = "boardgames"
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path(
|
path(
|
||||||
"board-game/", views.BoardGameListView.as_view(), name="boardgame_list"
|
"board-games/",
|
||||||
|
views.BoardGameListView.as_view(),
|
||||||
|
name="boardgame_list",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"board-game/<slug:slug>/",
|
"board-games/<slug:slug>/",
|
||||||
views.BoardGameDetailView.as_view(),
|
views.BoardGameDetailView.as_view(),
|
||||||
name="boardgame_detail",
|
name="boardgame_detail",
|
||||||
),
|
),
|
||||||
|
|||||||
@ -1,15 +1,14 @@
|
|||||||
from django.views import generic
|
from django.views import generic
|
||||||
from boardgames.models import BoardGame, BoardGamePublisher
|
from boardgames.models import BoardGame, BoardGamePublisher
|
||||||
|
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
|
||||||
|
|
||||||
|
|
||||||
class BoardGameListView(generic.ListView):
|
class BoardGameListView(ScrobbleableListView):
|
||||||
model = BoardGame
|
model = BoardGame
|
||||||
paginate_by = 20
|
|
||||||
|
|
||||||
|
|
||||||
class BoardGameDetailView(generic.DetailView):
|
class BoardGameDetailView(ScrobbleableDetailView):
|
||||||
model = BoardGame
|
model = BoardGame
|
||||||
slug_field = "uuid"
|
|
||||||
|
|
||||||
|
|
||||||
class BoardGamePublisherDetailView(generic.DetailView):
|
class BoardGamePublisherDetailView(generic.DetailView):
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
|
from books.models import Author, Book, Paper
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from books.models import Author, Book, Page
|
|
||||||
|
|
||||||
from scrobbles.admin import ScrobbleInline
|
from scrobbles.admin import ScrobbleInline
|
||||||
|
|
||||||
|
|
||||||
@ -18,22 +16,32 @@ class AuthorAdmin(admin.ModelAdmin):
|
|||||||
search_fields = ("name",)
|
search_fields = ("name",)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Page)
|
|
||||||
class PageAdmin(admin.ModelAdmin):
|
|
||||||
date_hierarchy = "created"
|
|
||||||
list_filter = ("book",)
|
|
||||||
ordering = ("book", "number")
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Book)
|
@admin.register(Book)
|
||||||
class BookAdmin(admin.ModelAdmin):
|
class BookAdmin(admin.ModelAdmin):
|
||||||
date_hierarchy = "created"
|
date_hierarchy = "created"
|
||||||
list_display = (
|
list_display = (
|
||||||
"title",
|
"title",
|
||||||
"isbn",
|
"subtitle",
|
||||||
|
"isbn_13",
|
||||||
|
"first_publish_year",
|
||||||
|
"pages",
|
||||||
|
)
|
||||||
|
search_fields = ("name",)
|
||||||
|
ordering = ("-created",)
|
||||||
|
inlines = [
|
||||||
|
ScrobbleInline,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Paper)
|
||||||
|
class BookAdmin(admin.ModelAdmin):
|
||||||
|
date_hierarchy = "created"
|
||||||
|
list_display = (
|
||||||
|
"title",
|
||||||
|
"subtitle",
|
||||||
|
"arxiv_id",
|
||||||
"first_publish_year",
|
"first_publish_year",
|
||||||
"pages",
|
"pages",
|
||||||
"openlibrary_id",
|
|
||||||
)
|
)
|
||||||
search_fields = ("name",)
|
search_fields = ("name",)
|
||||||
ordering = ("-created",)
|
ordering = ("-created",)
|
||||||
|
|||||||
7
vrobbler/apps/books/constants.py
Normal file
7
vrobbler/apps/books/constants.py
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
BOOKS_TITLES_TO_IGNORE = [
|
||||||
|
"KOReader Quickstart Guide",
|
||||||
|
"zb2rhkSwygt9vjkAEBj7tP5KVgFqejJqsJ2W3bYsrgiiKK8XL",
|
||||||
|
"zb2rhchGpo7P27mofV9hYjT63d9ZaQnbQ6LSfzmkvsYzvARif",
|
||||||
|
]
|
||||||
@ -1,17 +1,19 @@
|
|||||||
from collections import OrderedDict
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
import pytz
|
import pendulum
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
import requests
|
import requests
|
||||||
from books.models import Author, Book
|
from books.constants import BOOKS_TITLES_TO_IGNORE
|
||||||
from books.openlibrary import get_author_openlibrary_id
|
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from stream_sqlite import stream_sqlite
|
from stream_sqlite import stream_sqlite
|
||||||
|
from scrobbles.notifications import NtfyNotification
|
||||||
|
from vrobbler.apps.profiles.utils import one_off_fix_colins_profile
|
||||||
|
from webdav.client import get_webdav_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
@ -62,6 +64,8 @@ def lookup_or_create_authors_from_author_str(ko_author_str: str) -> list:
|
|||||||
"""Takes a string of authors from KoReader and returns a list
|
"""Takes a string of authors from KoReader and returns a list
|
||||||
of Authors from our database
|
of Authors from our database
|
||||||
"""
|
"""
|
||||||
|
from books.models import Author
|
||||||
|
|
||||||
author_str_list = ko_author_str.split(", ")
|
author_str_list = ko_author_str.split(", ")
|
||||||
author_list = []
|
author_list = []
|
||||||
for author_str in author_str_list:
|
for author_str in author_str_list:
|
||||||
@ -73,36 +77,49 @@ def lookup_or_create_authors_from_author_str(ko_author_str: str) -> list:
|
|||||||
|
|
||||||
author = Author.objects.filter(name=author_str).first()
|
author = Author.objects.filter(name=author_str).first()
|
||||||
if not author:
|
if not author:
|
||||||
author = Author.objects.create(
|
author = Author.objects.create(name=author_str)
|
||||||
name=author_str,
|
# TODO Move these to async processes after importing
|
||||||
openlibrary_id=get_author_openlibrary_id(author_str),
|
# author.fix_metadata()
|
||||||
)
|
|
||||||
author.fix_metadata()
|
|
||||||
logger.debug(f"Created author {author}")
|
logger.debug(f"Created author {author}")
|
||||||
author_list.append(author)
|
author_list.append(author)
|
||||||
return author_list
|
return author_list
|
||||||
|
|
||||||
|
|
||||||
def create_book_from_row(row: list):
|
def create_book_from_row(row: list):
|
||||||
|
from books.models import Book
|
||||||
|
|
||||||
# No KoReader book yet, create it
|
# No KoReader book yet, create it
|
||||||
author_str = get_author_str_from_row(row)
|
author_str = get_author_str_from_row(row).replace("\x00", "")
|
||||||
total_pages = row[KoReaderBookColumn.PAGES.value]
|
total_pages = row[KoReaderBookColumn.PAGES.value]
|
||||||
run_time = total_pages * Book.AVG_PAGE_READING_SECONDS
|
run_time = total_pages * Book.AVG_PAGE_READING_SECONDS
|
||||||
|
book_title = row[KoReaderBookColumn.TITLE.value].replace("\x00", "")
|
||||||
|
if " - " in book_title:
|
||||||
|
split_title = book_title.split(" - ")
|
||||||
|
book_title = split_title[0]
|
||||||
|
if (not author_str or author_str == "N/A") and len(split_title) > 1:
|
||||||
|
author_str = split_title[1].split("_")[0]
|
||||||
|
|
||||||
|
clean_row = []
|
||||||
|
for value in row:
|
||||||
|
if isinstance(value, str):
|
||||||
|
value = value.replace("\x00", "")
|
||||||
|
clean_row.append(value)
|
||||||
|
|
||||||
book = Book.objects.create(
|
book = Book.objects.create(
|
||||||
title=row[KoReaderBookColumn.TITLE.value],
|
title=book_title.replace("_", ":"),
|
||||||
pages=total_pages,
|
pages=total_pages,
|
||||||
koreader_data_by_hash={
|
koreader_data_by_hash={
|
||||||
str(row[KoReaderBookColumn.MD5.value]): {
|
str(row[KoReaderBookColumn.MD5.value]): {
|
||||||
"title": row[KoReaderBookColumn.TITLE.value],
|
"title": book_title,
|
||||||
"author_str": author_str,
|
"author_str": author_str,
|
||||||
"book_id": row[KoReaderBookColumn.ID.value],
|
"book_id": row[KoReaderBookColumn.ID.value],
|
||||||
"pages": total_pages,
|
"raw_row_data": clean_row,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
run_time_seconds=run_time,
|
run_time_seconds=run_time,
|
||||||
)
|
)
|
||||||
book.fix_metadata()
|
# TODO Move these to async processes after importing
|
||||||
|
# book.fix_metadata()
|
||||||
|
|
||||||
# Add authors
|
# Add authors
|
||||||
author_list = lookup_or_create_authors_from_author_str(author_str)
|
author_list = lookup_or_create_authors_from_author_str(author_str)
|
||||||
@ -119,15 +136,16 @@ def build_book_map(rows) -> dict:
|
|||||||
primary key IDs for page creation.
|
primary key IDs for page creation.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
from books.models import Book
|
||||||
|
|
||||||
book_id_map = {}
|
book_id_map = {}
|
||||||
|
|
||||||
for book_row in rows:
|
for book_row in rows:
|
||||||
if (
|
|
||||||
book_row[KoReaderBookColumn.TITLE.value]
|
if book_row[KoReaderBookColumn.TITLE.value] in BOOKS_TITLES_TO_IGNORE:
|
||||||
== "KOReader Quickstart Guide"
|
|
||||||
):
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Ignoring the KOReader quickstart guide. No on wants that."
|
"[build_book_map] Ignoring book title that is likely garbage",
|
||||||
|
extra={"book_row": book_row, "media_type": "Book"},
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
book = Book.objects.filter(
|
book = Book.objects.filter(
|
||||||
@ -136,6 +154,15 @@ def build_book_map(rows) -> dict:
|
|||||||
]
|
]
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
|
if not book:
|
||||||
|
title = (
|
||||||
|
book_row[KoReaderBookColumn.TITLE.value]
|
||||||
|
.split(" - ")[0]
|
||||||
|
.lower()
|
||||||
|
.replace("\x00", "")
|
||||||
|
)
|
||||||
|
book = Book.objects.filter(title=title).first()
|
||||||
|
|
||||||
if not book:
|
if not book:
|
||||||
book = create_book_from_row(book_row)
|
book = create_book_from_row(book_row)
|
||||||
|
|
||||||
@ -253,35 +280,24 @@ def build_scrobbles_from_book_map(
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
timezone = user.profile.timezone
|
timestamp = user.profile.get_timestamp_with_tz(
|
||||||
|
datetime.fromtimestamp(int(first_page.get("start_ts")))
|
||||||
|
)
|
||||||
|
stop_timestamp = user.profile.get_timestamp_with_tz(
|
||||||
|
datetime.fromtimestamp(int(last_page.get("end_ts")))
|
||||||
|
)
|
||||||
|
|
||||||
timestamp = datetime.fromtimestamp(
|
if user.id == 1 and not user.profile.timezone_change_log:
|
||||||
int(first_page.get("start_ts"))
|
one_off_fix_colins_profile(user.profile)
|
||||||
).replace(tzinfo=pytz.timezone(timezone))
|
|
||||||
|
|
||||||
# Add a shim here temporarily to fix imports while we were in France
|
# Adjust for Daylight Saving Time
|
||||||
# if date is between 10/15 and 12/15, cast it to Europe/Central
|
if timestamp.dst() == timedelta(
|
||||||
if (
|
0
|
||||||
datetime(2023, 10, 15).replace(
|
) or stop_timestamp.dst() == timedelta(0):
|
||||||
tzinfo=pytz.timezone("Europe/Paris")
|
|
||||||
)
|
|
||||||
<= timestamp
|
|
||||||
<= datetime(2023, 12, 15).replace(
|
|
||||||
tzinfo=pytz.timezone("Europe/Paris")
|
|
||||||
)
|
|
||||||
):
|
|
||||||
timezone = "Europe/Paris"
|
|
||||||
|
|
||||||
stop_timestamp = datetime.fromtimestamp(
|
|
||||||
int(last_page.get("end_ts"))
|
|
||||||
).replace(tzinfo=pytz.timezone(timezone))
|
|
||||||
|
|
||||||
if (
|
|
||||||
timestamp.tzinfo._dst.seconds == 0
|
|
||||||
or stop_timestamp.tzinfo._dst.seconds == 0
|
|
||||||
):
|
|
||||||
timestamp = timestamp - timedelta(hours=1)
|
timestamp = timestamp - timedelta(hours=1)
|
||||||
stop_timestamp = stop_timestamp - timedelta(hours=1)
|
stop_timestamp = stop_timestamp - timedelta(hours=1)
|
||||||
|
else:
|
||||||
|
print("In DST! ", timestamp)
|
||||||
|
|
||||||
scrobble = Scrobble.objects.filter(
|
scrobble = Scrobble.objects.filter(
|
||||||
timestamp=timestamp,
|
timestamp=timestamp,
|
||||||
@ -296,7 +312,7 @@ def build_scrobbles_from_book_map(
|
|||||||
log_data = {
|
log_data = {
|
||||||
"koreader_hash": book_dict.get("hash"),
|
"koreader_hash": book_dict.get("hash"),
|
||||||
"page_data": scrobble_page_data,
|
"page_data": scrobble_page_data,
|
||||||
"pages_read": cur_page_number,
|
"pages_read": len(scrobble_page_data.keys()),
|
||||||
}
|
}
|
||||||
scrobbles_to_create.append(
|
scrobbles_to_create.append(
|
||||||
Scrobble(
|
Scrobble(
|
||||||
@ -311,13 +327,13 @@ def build_scrobbles_from_book_map(
|
|||||||
in_progress=False,
|
in_progress=False,
|
||||||
played_to_completion=True,
|
played_to_completion=True,
|
||||||
long_play_complete=False,
|
long_play_complete=False,
|
||||||
timezone=timezone,
|
timezone=timestamp.tzinfo.name,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# Then start over
|
# Then start over
|
||||||
should_create_scrobble = False
|
should_create_scrobble = False
|
||||||
playback_position_seconds = 0
|
playback_position_seconds = 0
|
||||||
scrobble_page_data = {}
|
scrobble_page_data = {}
|
||||||
|
|
||||||
# We accumulate pages for the scrobble until we should create a new one
|
# We accumulate pages for the scrobble until we should create a new one
|
||||||
scrobble_page_data[cur_page_number] = stats
|
scrobble_page_data[cur_page_number] = stats
|
||||||
@ -353,9 +369,9 @@ def process_koreader_sqlite_file(file_path, user_id) -> list:
|
|||||||
|
|
||||||
new_scrobbles = []
|
new_scrobbles = []
|
||||||
user = User.objects.filter(id=user_id).first()
|
user = User.objects.filter(id=user_id).first()
|
||||||
tz = pytz.utc
|
tz = ZoneInfo("UTC")
|
||||||
if user:
|
if user:
|
||||||
tz = user.profile.timezone
|
tz = user.profile.tzinfo
|
||||||
|
|
||||||
is_os_file = "https://" not in file_path
|
is_os_file = "https://" not in file_path
|
||||||
if is_os_file:
|
if is_os_file:
|
||||||
@ -397,9 +413,27 @@ def process_koreader_sqlite_file(file_path, user_id) -> list:
|
|||||||
created = []
|
created = []
|
||||||
if new_scrobbles:
|
if new_scrobbles:
|
||||||
created = Scrobble.objects.bulk_create(new_scrobbles)
|
created = Scrobble.objects.bulk_create(new_scrobbles)
|
||||||
|
if created:
|
||||||
|
NtfyNotification(created[-1]).send()
|
||||||
fix_long_play_stats_for_scrobbles(created)
|
fix_long_play_stats_for_scrobbles(created)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Created {len(created)} scrobbles",
|
f"Created {len(created)} scrobbles",
|
||||||
extra={"created_scrobbles": created},
|
extra={"created_scrobbles": created},
|
||||||
)
|
)
|
||||||
return created
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_file_from_webdav(user_id: int) -> str:
|
||||||
|
file_path = f"/tmp/{user_id}-koreader-import.sqlite3"
|
||||||
|
client = get_webdav_client(user_id)
|
||||||
|
|
||||||
|
if not client:
|
||||||
|
logger.warning("could not get webdav client for user")
|
||||||
|
# TODO maybe we raise an exception here?
|
||||||
|
return ""
|
||||||
|
|
||||||
|
client.download_sync(
|
||||||
|
remote_path="var/koreader/statistics.sqlite3",
|
||||||
|
local_path=file_path,
|
||||||
|
)
|
||||||
|
return file_path
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
from enum import Enum
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
import requests
|
import requests
|
||||||
|
|||||||
40
vrobbler/apps/books/metadata.py
Normal file
40
vrobbler/apps/books/metadata.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
from enum import Enum
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import pendulum
|
||||||
|
|
||||||
|
YOUTUBE_VIDEO_URL = "https://www.youtube.com/watch?v="
|
||||||
|
IMDB_VIDEO_URL = "https://www.imdb.com/title/tt"
|
||||||
|
|
||||||
|
|
||||||
|
class BookType:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class BookMetadata:
|
||||||
|
title: str
|
||||||
|
run_time_seconds: Optional[int]
|
||||||
|
authors = Optional[str]
|
||||||
|
goodreads_id = Optional[str]
|
||||||
|
koreader_data_by_hash = Optional[dict]
|
||||||
|
isbn = Optional[str]
|
||||||
|
# isbn_13 = Optional[str]
|
||||||
|
# isbn_10 = Optional[str]
|
||||||
|
pages = Optional[int]
|
||||||
|
language = Optional[str]
|
||||||
|
first_publish_year = Optional[int]
|
||||||
|
summary = Optional[str]
|
||||||
|
|
||||||
|
# General
|
||||||
|
cover_url: Optional[str]
|
||||||
|
genres: list[str]
|
||||||
|
|
||||||
|
def __init__(self, title: Optional[str] = ""):
|
||||||
|
self.title = title
|
||||||
|
|
||||||
|
def as_dict_with_authors_cover_and_genres(self) -> tuple:
|
||||||
|
book_dict = vars(self)
|
||||||
|
authors = book_dict.pop("authors")
|
||||||
|
cover = book_dict.pop("cover_url")
|
||||||
|
genres = book_dict.pop("genres")
|
||||||
|
return book_dict, authors, cover, genres
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
# Generated by Django 4.2.16 on 2024-10-17 20:08
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("books", "0020_author_comicvine_data_book_comicvine_data_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="book",
|
||||||
|
name="koreader_authors",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="book",
|
||||||
|
name="koreader_id",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="book",
|
||||||
|
name="koreader_md5",
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
# Generated by Django 4.2.16 on 2025-01-22 03:55
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
(
|
||||||
|
"books",
|
||||||
|
"0021_remove_book_koreader_authors_remove_book_koreader_id_and_more",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="book",
|
||||||
|
name="run_time_seconds",
|
||||||
|
field=models.IntegerField(default=900),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
# Generated by Django 4.2.18 on 2025-01-27 04:20
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("books", "0022_alter_book_run_time_seconds"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="book",
|
||||||
|
old_name="isbn",
|
||||||
|
new_name="isbn_13",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="book",
|
||||||
|
name="comicvine_data",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="book",
|
||||||
|
name="goodreads_id",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="book",
|
||||||
|
name="locg_slug",
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="book",
|
||||||
|
name="isbn_10",
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="book",
|
||||||
|
name="publish_date",
|
||||||
|
field=models.DateField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
21
vrobbler/apps/books/migrations/0024_book_publisher.py
Normal file
21
vrobbler/apps/books/migrations/0024_book_publisher.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# Generated by Django 4.2.18 on 2025-01-27 04:31
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
(
|
||||||
|
"books",
|
||||||
|
"0023_rename_isbn_book_isbn_13_remove_book_comicvine_data_and_more",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="book",
|
||||||
|
name="publisher",
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,136 @@
|
|||||||
|
# Generated by Django 4.2.19 on 2025-02-18 05:11
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django_extensions.db.fields
|
||||||
|
import taggit.managers
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("scrobbles", "0067_scrobble_food_alter_scrobble_media_type"),
|
||||||
|
("books", "0024_book_publisher"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="author",
|
||||||
|
name="amazon_id",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="author",
|
||||||
|
name="librarything_id",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="author",
|
||||||
|
name="locg_slug",
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="author",
|
||||||
|
name="semantic_id",
|
||||||
|
field=models.CharField(blank=True, max_length=50, null=True),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Paper",
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("run_time_seconds", models.IntegerField(default=900)),
|
||||||
|
(
|
||||||
|
"run_time_ticks",
|
||||||
|
models.PositiveBigIntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
("title", models.CharField(max_length=255)),
|
||||||
|
("semantic_title", models.CharField(max_length=255)),
|
||||||
|
(
|
||||||
|
"koreader_data_by_hash",
|
||||||
|
models.JSONField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"semantic_id",
|
||||||
|
models.CharField(blank=True, max_length=50, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"arxiv_id",
|
||||||
|
models.CharField(blank=True, max_length=50, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"corpus_id",
|
||||||
|
models.CharField(blank=True, max_length=50, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"doi_id",
|
||||||
|
models.CharField(blank=True, max_length=50, null=True),
|
||||||
|
),
|
||||||
|
("pages", models.IntegerField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"language",
|
||||||
|
models.CharField(blank=True, max_length=4, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"first_publish_year",
|
||||||
|
models.IntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
("publish_date", models.DateField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"journal",
|
||||||
|
models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"journal_volume",
|
||||||
|
models.CharField(blank=True, max_length=50, null=True),
|
||||||
|
),
|
||||||
|
("abstract", models.TextField(blank=True, null=True)),
|
||||||
|
("num_citations", models.IntegerField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"openaccess_pdf_url",
|
||||||
|
models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"authors",
|
||||||
|
models.ManyToManyField(blank=True, to="books.author"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"genre",
|
||||||
|
taggit.managers.TaggableManager(
|
||||||
|
help_text="A comma-separated list of tags.",
|
||||||
|
through="scrobbles.ObjectWithGenres",
|
||||||
|
to="scrobbles.Genre",
|
||||||
|
verbose_name="Tags",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.19 on 2025-02-18 05:28
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("books", "0025_remove_author_amazon_id_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="paper",
|
||||||
|
name="semantic_title",
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
# Generated by Django 4.2.19 on 2025-02-18 05:33
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("books", "0026_alter_paper_semantic_title"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="paper",
|
||||||
|
name="num_citations",
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="paper",
|
||||||
|
name="tldr",
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
16
vrobbler/apps/books/migrations/0028_delete_page.py
Normal file
16
vrobbler/apps/books/migrations/0028_delete_page.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# Generated by Django 4.2.19 on 2025-04-07 17:16
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("books", "0027_remove_paper_num_citations_paper_tldr"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name="Page",
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -19,6 +19,7 @@ from imagekit.processors import ResizeToFit
|
|||||||
from scrobbles.mixins import (
|
from scrobbles.mixins import (
|
||||||
LongPlayScrobblableMixin,
|
LongPlayScrobblableMixin,
|
||||||
ObjectWithGenres,
|
ObjectWithGenres,
|
||||||
|
ScrobblableConstants,
|
||||||
ScrobblableMixin,
|
ScrobblableMixin,
|
||||||
)
|
)
|
||||||
from scrobbles.utils import get_scrobbles_for_media
|
from scrobbles.utils import get_scrobbles_for_media
|
||||||
@ -34,6 +35,8 @@ from vrobbler.apps.books.locg import (
|
|||||||
lookup_comic_from_locg,
|
lookup_comic_from_locg,
|
||||||
lookup_comic_writer_by_locg_slug,
|
lookup_comic_writer_by_locg_slug,
|
||||||
)
|
)
|
||||||
|
from vrobbler.apps.books.sources.google import lookup_book_from_google
|
||||||
|
from vrobbler.apps.books.sources.semantic import lookup_paper_from_semantic
|
||||||
from vrobbler.apps.scrobbles.dataclasses import BookLogData
|
from vrobbler.apps.scrobbles.dataclasses import BookLogData
|
||||||
|
|
||||||
COMICVINE_API_KEY = getattr(settings, "COMICVINE_API_KEY", "")
|
COMICVINE_API_KEY = getattr(settings, "COMICVINE_API_KEY", "")
|
||||||
@ -62,22 +65,27 @@ class Author(TimeStampedModel):
|
|||||||
)
|
)
|
||||||
bio = models.TextField(**BNULL)
|
bio = models.TextField(**BNULL)
|
||||||
wikipedia_url = models.CharField(max_length=255, **BNULL)
|
wikipedia_url = models.CharField(max_length=255, **BNULL)
|
||||||
isni = models.CharField(max_length=255, **BNULL)
|
|
||||||
locg_slug = models.CharField(max_length=255, **BNULL)
|
|
||||||
wikidata_id = models.CharField(max_length=255, **BNULL)
|
wikidata_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
isni = models.CharField(max_length=255, **BNULL)
|
||||||
goodreads_id = models.CharField(max_length=255, **BNULL)
|
goodreads_id = models.CharField(max_length=255, **BNULL)
|
||||||
librarything_id = models.CharField(max_length=255, **BNULL)
|
|
||||||
comicvine_data = models.JSONField(**BNULL)
|
comicvine_data = models.JSONField(**BNULL)
|
||||||
amazon_id = models.CharField(max_length=255, **BNULL)
|
|
||||||
|
semantic_id = models.CharField(max_length=50, **BNULL)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.name}"
|
return f"{self.name}"
|
||||||
|
|
||||||
def fix_metadata(self, data_dict: dict = {}):
|
def enrich_from_semantic(self, overwrite=False):
|
||||||
if not data_dict and self.openlibrary_id:
|
...
|
||||||
data_dict = lookup_author_from_openlibrary(self.openlibrary_id)
|
|
||||||
|
def enrich_from_google_books(self, overwrite=False):
|
||||||
|
...
|
||||||
|
|
||||||
|
def enrich_from_openlibrary(self, overwrite=False):
|
||||||
|
data_dict = lookup_author_from_openlibrary(self.openlibrary_id)
|
||||||
|
|
||||||
if not data_dict or not data_dict.get("name"):
|
if not data_dict or not data_dict.get("name"):
|
||||||
|
logger.warning("Could not find author on openlibrary")
|
||||||
return
|
return
|
||||||
|
|
||||||
headshot_url = data_dict.pop("author_headshot_url", "")
|
headshot_url = data_dict.pop("author_headshot_url", "")
|
||||||
@ -100,20 +108,16 @@ class Book(LongPlayScrobblableMixin):
|
|||||||
|
|
||||||
title = models.CharField(max_length=255)
|
title = models.CharField(max_length=255)
|
||||||
authors = models.ManyToManyField(Author, blank=True)
|
authors = models.ManyToManyField(Author, blank=True)
|
||||||
goodreads_id = models.CharField(max_length=255, **BNULL)
|
|
||||||
# All individual koreader fields are deprecated
|
|
||||||
koreader_id = models.IntegerField(**BNULL)
|
|
||||||
koreader_authors = models.CharField(max_length=255, **BNULL)
|
|
||||||
koreader_md5 = models.CharField(max_length=255, **BNULL)
|
|
||||||
koreader_data_by_hash = models.JSONField(**BNULL)
|
koreader_data_by_hash = models.JSONField(**BNULL)
|
||||||
isbn = models.CharField(max_length=255, **BNULL)
|
isbn_13 = models.CharField(max_length=255, **BNULL)
|
||||||
|
isbn_10 = models.CharField(max_length=255, **BNULL)
|
||||||
pages = models.IntegerField(**BNULL)
|
pages = models.IntegerField(**BNULL)
|
||||||
language = models.CharField(max_length=4, **BNULL)
|
language = models.CharField(max_length=4, **BNULL)
|
||||||
first_publish_year = models.IntegerField(**BNULL)
|
first_publish_year = models.IntegerField(**BNULL)
|
||||||
|
publish_date = models.DateField(**BNULL)
|
||||||
|
publisher = models.CharField(max_length=255, **BNULL)
|
||||||
first_sentence = models.TextField(**BNULL)
|
first_sentence = models.TextField(**BNULL)
|
||||||
openlibrary_id = models.CharField(max_length=255, **BNULL)
|
openlibrary_id = models.CharField(max_length=255, **BNULL)
|
||||||
locg_slug = models.CharField(max_length=255, **BNULL)
|
|
||||||
comicvine_data = models.JSONField(**BNULL)
|
|
||||||
cover = models.ImageField(upload_to="books/covers/", **BNULL)
|
cover = models.ImageField(upload_to="books/covers/", **BNULL)
|
||||||
cover_small = ImageSpecField(
|
cover_small = ImageSpecField(
|
||||||
source="cover",
|
source="cover",
|
||||||
@ -138,6 +142,10 @@ class Book(LongPlayScrobblableMixin):
|
|||||||
def subtitle(self):
|
def subtitle(self):
|
||||||
return f" by {self.author}"
|
return f" by {self.author}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def strings(self) -> ScrobblableConstants:
|
||||||
|
return ScrobblableConstants(verb="Reading", tags="book")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def logdata_cls(self):
|
def logdata_cls(self):
|
||||||
return BookLogData
|
return BookLogData
|
||||||
@ -149,12 +157,68 @@ class Book(LongPlayScrobblableMixin):
|
|||||||
url = self.cover_medium.url
|
url = self.cover_medium.url
|
||||||
return url
|
return url
|
||||||
|
|
||||||
def get_start_url(self):
|
|
||||||
return reverse("scrobbles:start", kwargs={"uuid": self.uuid})
|
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse("books:book_detail", kwargs={"slug": self.uuid})
|
return reverse("books:book_detail", kwargs={"slug": self.uuid})
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def find_or_create(cls, title: str, enrich: bool = False, commit: bool = True):
|
||||||
|
"""Given a title, get a Book instance.
|
||||||
|
|
||||||
|
If the book is not already in our database, or overwrite is True,
|
||||||
|
this method will enrich the Book with data from Google.
|
||||||
|
|
||||||
|
By default this method will also save the data back to the model. If you'd
|
||||||
|
like to batch create, use commit=False and you'll get an unsaved but enriched
|
||||||
|
instance back which you can then save at your convenience."""
|
||||||
|
# TODO use either a Google Books id identifier or author name like for tracks
|
||||||
|
book, created = cls.objects.get_or_create(title=title)
|
||||||
|
if not created:
|
||||||
|
logger.info("Found exact match for book by title", extra={"title": title})
|
||||||
|
|
||||||
|
if not enrich:
|
||||||
|
logger.info("Found book by title, but not enriching", extra={"title": title})
|
||||||
|
return book
|
||||||
|
|
||||||
|
book_dict = lookup_book_from_google(title)
|
||||||
|
|
||||||
|
author_list = []
|
||||||
|
authors = book_dict.pop("authors")
|
||||||
|
cover_url = book_dict.pop("cover_url")
|
||||||
|
try:
|
||||||
|
genres = book_dict.pop("generes")
|
||||||
|
except:
|
||||||
|
genres = []
|
||||||
|
|
||||||
|
if authors:
|
||||||
|
for author_str in authors:
|
||||||
|
if author_str:
|
||||||
|
author, a_created = Author.objects.get_or_create(
|
||||||
|
name=author_str
|
||||||
|
)
|
||||||
|
author_list.append(author)
|
||||||
|
if a_created:
|
||||||
|
# TODO enrich author
|
||||||
|
...
|
||||||
|
|
||||||
|
for k, v in book_dict.items():
|
||||||
|
setattr(book, k, v)
|
||||||
|
|
||||||
|
if commit:
|
||||||
|
book.save()
|
||||||
|
|
||||||
|
book.save_image_from_url(cover_url)
|
||||||
|
book.genre.add(*genres)
|
||||||
|
book.authors.add(*author_list)
|
||||||
|
|
||||||
|
return book
|
||||||
|
|
||||||
|
def save_image_from_url(self, url: str, force_update: bool = False):
|
||||||
|
if not self.cover or (force_update and url):
|
||||||
|
r = requests.get(url)
|
||||||
|
if r.status_code == 200:
|
||||||
|
fname = f"{self.title}_{self.uuid}.jpg"
|
||||||
|
self.cover.save(fname, ContentFile(r.content), save=True)
|
||||||
|
|
||||||
def fix_metadata(self, data: dict = {}, force_update=False):
|
def fix_metadata(self, data: dict = {}, force_update=False):
|
||||||
if (not self.openlibrary_id or not self.locg_slug) or force_update:
|
if (not self.openlibrary_id or not self.locg_slug) or force_update:
|
||||||
author_name = ""
|
author_name = ""
|
||||||
@ -329,112 +393,67 @@ class Book(LongPlayScrobblableMixin):
|
|||||||
progress = int((last_scrobble.last_page_read / self.pages) * 100)
|
progress = int((last_scrobble.last_page_read / self.pages) * 100)
|
||||||
return progress
|
return progress
|
||||||
|
|
||||||
|
|
||||||
|
class Paper(LongPlayScrobblableMixin):
|
||||||
|
"""Keeps track of Academic Papers"""
|
||||||
|
|
||||||
|
COMPLETION_PERCENT = getattr(settings, "PAPER_COMPLETION_PERCENT", 60)
|
||||||
|
AVG_PAGE_READING_SECONDS = getattr(
|
||||||
|
settings, "AVERAGE_PAGE_READING_SECONDS", 60
|
||||||
|
)
|
||||||
|
|
||||||
|
title = models.CharField(max_length=255)
|
||||||
|
semantic_title = models.CharField(max_length=255, **BNULL)
|
||||||
|
authors = models.ManyToManyField(Author, blank=True)
|
||||||
|
koreader_data_by_hash = models.JSONField(**BNULL)
|
||||||
|
arxiv_id = models.CharField(max_length=50, **BNULL)
|
||||||
|
semantic_id = models.CharField(max_length=50, **BNULL)
|
||||||
|
arxiv_id = models.CharField(max_length=50, **BNULL)
|
||||||
|
corpus_id = models.CharField(max_length=50, **BNULL)
|
||||||
|
doi_id = models.CharField(max_length=50, **BNULL)
|
||||||
|
pages = models.IntegerField(**BNULL)
|
||||||
|
language = models.CharField(max_length=4, **BNULL)
|
||||||
|
first_publish_year = models.IntegerField(**BNULL)
|
||||||
|
publish_date = models.DateField(**BNULL)
|
||||||
|
journal = models.CharField(max_length=255, **BNULL)
|
||||||
|
journal_volume = models.CharField(max_length=50, **BNULL)
|
||||||
|
abstract = models.TextField(**BNULL)
|
||||||
|
tldr = models.CharField(max_length=255, **BNULL)
|
||||||
|
openaccess_pdf_url = models.CharField(max_length=255, **BNULL)
|
||||||
|
|
||||||
|
genre = TaggableManager(through=ObjectWithGenres)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def find_or_create(cls, lookup_id: str, author: str = "") -> "Book":
|
def get_from_semantic(cls, title: str, overwrite: bool = False) -> "Paper":
|
||||||
book = cls.objects.filter(openlibrary_id=lookup_id).first()
|
paper, created = cls.objects.get_or_create(title=title)
|
||||||
|
if not created and not overwrite:
|
||||||
|
return paper
|
||||||
|
|
||||||
if not book:
|
paper_dict = lookup_paper_from_semantic(title)
|
||||||
data = lookup_book_from_openlibrary(lookup_id, author)
|
|
||||||
|
|
||||||
if not data:
|
if created or overwrite:
|
||||||
logger.error(
|
author_list = []
|
||||||
f"No book found on openlibrary, or in our database for {lookup_id}"
|
author_dicts = paper_dict.pop("author_dicts")
|
||||||
)
|
if author_dicts:
|
||||||
return book
|
for author_dict in author_dicts:
|
||||||
|
if author_dict.get("authorId"):
|
||||||
|
author, a_created = Author.objects.get_or_create(
|
||||||
|
semantic_id=author_dict.get("authorId")
|
||||||
|
)
|
||||||
|
author_list.append(author)
|
||||||
|
if a_created:
|
||||||
|
author.name = author_dict.get("name")
|
||||||
|
author.save()
|
||||||
|
# TODO enrich author?
|
||||||
|
...
|
||||||
|
|
||||||
book, book_created = cls.objects.get_or_create(isbn=data["isbn"])
|
for k, v in paper_dict.items():
|
||||||
if book_created:
|
setattr(paper, k, v)
|
||||||
book.fix_metadata(data=data)
|
paper.save()
|
||||||
|
|
||||||
return book
|
if author_list:
|
||||||
|
paper.authors.add(*author_list)
|
||||||
|
genres = paper_dict.pop("genres", [])
|
||||||
class Page(TimeStampedModel):
|
if genres:
|
||||||
"""DEPRECATED, we need to migrate pages into page_data on scrobbles and move on"""
|
paper.genre.add(*genres)
|
||||||
|
return paper
|
||||||
book = models.ForeignKey(Book, on_delete=models.CASCADE)
|
|
||||||
number = models.IntegerField()
|
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
|
||||||
start_time = models.DateTimeField(**BNULL)
|
|
||||||
end_time = models.DateTimeField(**BNULL)
|
|
||||||
duration_seconds = models.IntegerField(**BNULL)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
unique_together = (
|
|
||||||
"book",
|
|
||||||
"number",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"Page {self.number} of {self.book.pages} in {self.book.title}"
|
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
if not self.end_time and self.duration_seconds:
|
|
||||||
self._set_end_time()
|
|
||||||
|
|
||||||
return super(Page, self).save(*args, **kwargs)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def next(self):
|
|
||||||
page = self.book.page_set.filter(number=self.number + 1).first()
|
|
||||||
if not page:
|
|
||||||
page = (
|
|
||||||
self.book.page_set.filter(created__gt=self.created)
|
|
||||||
.order_by("created")
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
return page
|
|
||||||
|
|
||||||
@property
|
|
||||||
def previous(self):
|
|
||||||
page = self.book.page_set.filter(number=self.number - 1).first()
|
|
||||||
if not page:
|
|
||||||
page = (
|
|
||||||
self.book.page_set.filter(created__lt=self.created)
|
|
||||||
.order_by("-created")
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
return page
|
|
||||||
|
|
||||||
@property
|
|
||||||
def seconds_to_next_page(self) -> int:
|
|
||||||
seconds = 999999 # Effectively infnity time as we have no next
|
|
||||||
if not self.end_time:
|
|
||||||
self._set_end_time()
|
|
||||||
if self.next:
|
|
||||||
seconds = (self.next.start_time - self.end_time).seconds
|
|
||||||
return seconds
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_scrobblable(self) -> bool:
|
|
||||||
"""A page defines the start of a scrobble if the seconds to next page
|
|
||||||
are greater than an hour, or 3600 seconds, and it's not a single page,
|
|
||||||
so the next seconds to next_page is less than an hour as well.
|
|
||||||
|
|
||||||
As a special case, the first recorded page is a scrobble, so we establish
|
|
||||||
when the book was started.
|
|
||||||
|
|
||||||
"""
|
|
||||||
is_scrobblable = False
|
|
||||||
over_an_hour_since_last_page = False
|
|
||||||
if not self.previous:
|
|
||||||
is_scrobblable = True
|
|
||||||
|
|
||||||
if self.previous:
|
|
||||||
over_an_hour_since_last_page = (
|
|
||||||
self.previous.seconds_to_next_page >= 3600
|
|
||||||
)
|
|
||||||
blip = self.seconds_to_next_page >= 3600
|
|
||||||
|
|
||||||
if over_an_hour_since_last_page and not blip:
|
|
||||||
is_scrobblable = True
|
|
||||||
return is_scrobblable
|
|
||||||
|
|
||||||
def _set_end_time(self) -> None:
|
|
||||||
if self.end_time:
|
|
||||||
return
|
|
||||||
|
|
||||||
self.end_time = self.start_time + timedelta(
|
|
||||||
seconds=self.duration_seconds
|
|
||||||
)
|
|
||||||
self.save(update_fields=["end_time"])
|
|
||||||
|
|||||||
0
vrobbler/apps/books/sources/__init__.py
Normal file
0
vrobbler/apps/books/sources/__init__.py
Normal file
71
vrobbler/apps/books/sources/google.py
Normal file
71
vrobbler/apps/books/sources/google.py
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import pendulum
|
||||||
|
import requests
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
API_KEY = settings.GOOGLE_API_KEY
|
||||||
|
GOOGLE_BOOKS_URL = (
|
||||||
|
"https://www.googleapis.com/books/v1/volumes?q=\"{title}\"&key={key}"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_book_from_google(title: str) -> dict:
|
||||||
|
book_dict = {"title": title}
|
||||||
|
|
||||||
|
url = GOOGLE_BOOKS_URL.format(title=title, key=API_KEY)
|
||||||
|
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||||
|
response = requests.get(url, headers=headers)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.warning(
|
||||||
|
"Bad response from Google", extra={"response": response}
|
||||||
|
)
|
||||||
|
return book_dict
|
||||||
|
|
||||||
|
google_result = (
|
||||||
|
json.loads(response.content).get("items", [{}])[0].get("volumeInfo")
|
||||||
|
)
|
||||||
|
publish_date = pendulum.parse(google_result.get("publishedDate"))
|
||||||
|
|
||||||
|
isbn_13 = ""
|
||||||
|
isbn_10 = ""
|
||||||
|
for ident in google_result.get("industryIdentifiers", []):
|
||||||
|
if ident.get("type") == "ISBN_13":
|
||||||
|
isbn_13 = ident.get("identifier")
|
||||||
|
if ident.get("type") == "ISBN_10":
|
||||||
|
isbn_10 = ident.get("identifier")
|
||||||
|
# TODO this may lead to issues with the first get if Google changes our title
|
||||||
|
# book_metadata.title = google_result.get("title")
|
||||||
|
# if google_result.get("subtitle"):
|
||||||
|
# book_metadata["title"] = ": ".join(
|
||||||
|
# [google_result.get("title"), google_result.get("subtitle")]
|
||||||
|
# )
|
||||||
|
# book_dict["subtitle"] = google_result.get("subtitle")
|
||||||
|
book_dict["authors"] = google_result.get("authors")
|
||||||
|
book_dict["publisher"] = google_result.get("publisher")
|
||||||
|
book_dict["first_publish_year"] = publish_date.year
|
||||||
|
book_dict["pages"] = google_result.get("pageCount")
|
||||||
|
book_dict["isbn_13"] = isbn_13
|
||||||
|
book_dict["isbn_10"] = isbn_10
|
||||||
|
book_dict["publish_date"] = google_result.get("publishedDate")
|
||||||
|
if len(book_dict["publish_date"]) == 4:
|
||||||
|
book_dict["publish_date"] = f"{book_dict['publish_date']}-1-1"
|
||||||
|
book_dict["language"] = google_result.get("language")
|
||||||
|
book_dict["summary"] = google_result.get("description")
|
||||||
|
book_dict["genres"] = google_result.get("categories")
|
||||||
|
book_dict["cover_url"] = (
|
||||||
|
google_result.get("imageLinks", {})
|
||||||
|
.get("thumbnail")
|
||||||
|
.replace("zoom=1", "zoom=15")
|
||||||
|
.replace("&edge=curl", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
book_dict["run_time_seconds"] = book_dict.get("pages", 10) * getattr(
|
||||||
|
settings, "AVERAGE_PAGE_READING_SECONDS", 60
|
||||||
|
)
|
||||||
|
|
||||||
|
return book_dict
|
||||||
76
vrobbler/apps/books/sources/semantic.py
Normal file
76
vrobbler/apps/books/sources/semantic.py
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
PAPER_SEARCH_URL = (
|
||||||
|
"https://api.semanticscholar.org/graph/v1/paper/search/match?query={}"
|
||||||
|
)
|
||||||
|
PAPER_DETAIL_URL = "https://api.semanticscholar.org/graph/v1/paper/{}?fields=title,authors,url,year,abstract,externalIds,citationCount,referenceCount,journal,fieldsOfStudy,publicationDate,openAccessPdf"
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_api_result(url):
|
||||||
|
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||||
|
response = requests.get(url, headers=headers)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.warning(
|
||||||
|
"Bad response from Semantic", extra={"response": response}
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_paper_from_semantic(title: str) -> dict:
|
||||||
|
paper_dict = {"title": title}
|
||||||
|
|
||||||
|
response = get_api_result(PAPER_SEARCH_URL.format(title))
|
||||||
|
if not response:
|
||||||
|
return paper_dict
|
||||||
|
|
||||||
|
semantic_id = json.loads(response.content).get("data")[0].get("paperId")
|
||||||
|
response = get_api_result(PAPER_DETAIL_URL.format(semantic_id))
|
||||||
|
|
||||||
|
result = json.loads(response.content)
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
return paper_dict
|
||||||
|
|
||||||
|
page_str = result.get("journal", {}).get("pages")
|
||||||
|
if page_str:
|
||||||
|
try:
|
||||||
|
start_page = page_str.split(" - ")[0]
|
||||||
|
end_page = page_str.split(" - ")[1]
|
||||||
|
paper_dict["pages"] = int(end_page) - int(start_page)
|
||||||
|
except IndexError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
paper_dict["semantic_id"] = result.get("paperId")
|
||||||
|
paper_dict["doi_id"] = result.get("externalIds", {}).get("DOI")
|
||||||
|
paper_dict["arxiv_id"] = result.get("externalIds", {}).get("ArXiv")
|
||||||
|
paper_dict["pubmed_id"] = result.get("externalIds", {}).get("PubMed")
|
||||||
|
paper_dict["corpus_id"] = result.get("externalIds", {}).get("CorpusId")
|
||||||
|
paper_dict["semantic_title"] = result.get("title")
|
||||||
|
paper_dict["first_publish_year"] = result.get("year")
|
||||||
|
paper_dict["publish_date"] = datetime.strptime(
|
||||||
|
result.get("publicationDate", "1950-01-01"), "%Y-%m-%d"
|
||||||
|
)
|
||||||
|
paper_dict["abstract"] = result.get("abstract")
|
||||||
|
paper_dict["tldr"] = result.get("bib", {}).get("abstract")
|
||||||
|
paper_dict["journal"] = result.get("journal", {}).get("name")
|
||||||
|
paper_dict["journal_volume"] = result.get("journal", {}).get("volume")
|
||||||
|
paper_dict["openaccess_pdf_url"] = result.get("openAccessPdf", {}).get(
|
||||||
|
"url"
|
||||||
|
)
|
||||||
|
paper_dict["run_time_seconds"] = paper_dict.get("pages", 10) * getattr(
|
||||||
|
settings, "AVERAGE_PAGE_READING_SECONDS", 60
|
||||||
|
)
|
||||||
|
paper_dict["author_dicts"] = result.get("authors")
|
||||||
|
paper_dict["genres"] = result.get("fieldsOfStudy")
|
||||||
|
|
||||||
|
return paper_dict
|
||||||
@ -5,6 +5,7 @@ import pytest
|
|||||||
from books.openlibrary import lookup_book_from_openlibrary
|
from books.openlibrary import lookup_book_from_openlibrary
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip()
|
||||||
def test_lookup_modern_book():
|
def test_lookup_modern_book():
|
||||||
book = lookup_book_from_openlibrary("Matrix", "Lauren Groff")
|
book = lookup_book_from_openlibrary("Matrix", "Lauren Groff")
|
||||||
assert book.get("title") == "Matrix"
|
assert book.get("title") == "Matrix"
|
||||||
@ -12,6 +13,7 @@ def test_lookup_modern_book():
|
|||||||
assert book.get("ol_author_id") == "OL3675729A"
|
assert book.get("ol_author_id") == "OL3675729A"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip()
|
||||||
def test_lookup_classic_book():
|
def test_lookup_classic_book():
|
||||||
book = lookup_book_from_openlibrary(
|
book = lookup_book_from_openlibrary(
|
||||||
"The Life of Castruccio Castracani", "Machiavelli"
|
"The Life of Castruccio Castracani", "Machiavelli"
|
||||||
@ -21,6 +23,7 @@ def test_lookup_classic_book():
|
|||||||
assert book.get("ol_author_id") == "OL23135A"
|
assert book.get("ol_author_id") == "OL23135A"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip()
|
||||||
def test_lookup_foreign_book():
|
def test_lookup_foreign_book():
|
||||||
book = lookup_book_from_openlibrary("Ravagé", "René Barjavel")
|
book = lookup_book_from_openlibrary("Ravagé", "René Barjavel")
|
||||||
assert book.get("title") == "Ravage"
|
assert book.get("title") == "Ravage"
|
||||||
|
|||||||
@ -5,14 +5,14 @@ app_name = "books"
|
|||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("book/", views.BookListView.as_view(), name="book_list"),
|
path("books/", views.BookListView.as_view(), name="book_list"),
|
||||||
path(
|
path(
|
||||||
"book/<slug:slug>/",
|
"books/<slug:slug>/",
|
||||||
views.BookDetailView.as_view(),
|
views.BookDetailView.as_view(),
|
||||||
name="book_detail",
|
name="book_detail",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"author/<slug:slug>/",
|
"authors/<slug:slug>/",
|
||||||
views.AuthorDetailView.as_view(),
|
views.AuthorDetailView.as_view(),
|
||||||
name="author_detail",
|
name="author_detail",
|
||||||
),
|
),
|
||||||
|
|||||||
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.16 on 2025-01-22 03:55
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bricksets", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="brickset",
|
||||||
|
name="run_time_seconds",
|
||||||
|
field=models.IntegerField(default=900),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -16,7 +16,9 @@ class BrickSet(LongPlayScrobblableMixin):
|
|||||||
number = models.CharField(max_length=10, **BNULL)
|
number = models.CharField(max_length=10, **BNULL)
|
||||||
release_year = models.IntegerField(**BNULL)
|
release_year = models.IntegerField(**BNULL)
|
||||||
piece_count = models.IntegerField(**BNULL)
|
piece_count = models.IntegerField(**BNULL)
|
||||||
brickset_rating = models.DecimalField(max_digits=3, decimal_places=1, **BNULL)
|
brickset_rating = models.DecimalField(
|
||||||
|
max_digits=3, decimal_places=1, **BNULL
|
||||||
|
)
|
||||||
lego_item_number = models.CharField(max_length=10, **BNULL)
|
lego_item_number = models.CharField(max_length=10, **BNULL)
|
||||||
box_image = models.ImageField(upload_to="brickset/boxes/", **BNULL)
|
box_image = models.ImageField(upload_to="brickset/boxes/", **BNULL)
|
||||||
box_image_small = ImageSpecField(
|
box_image_small = ImageSpecField(
|
||||||
@ -48,13 +50,23 @@ class BrickSet(LongPlayScrobblableMixin):
|
|||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse("bricksets:brickset_detail", kwargs={"slug": self.uuid})
|
return reverse("bricksets:brickset_detail", kwargs={"slug": self.uuid})
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
name = str(self.title)
|
||||||
|
if not self.title:
|
||||||
|
name = str(self.number)
|
||||||
|
return name
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def logdata_cls(self):
|
def logdata_cls(self):
|
||||||
return BrickSetLogData
|
return BrickSetLogData
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def find_or_create(cls, title: str) -> "BrickSet":
|
def find_or_create(cls, brickset_id: str) -> "BrickSet":
|
||||||
return cls.objects.filter(title=title).first()
|
brickset = cls.objects.filter(number=brickset_id).first()
|
||||||
|
if not brickset:
|
||||||
|
# TODO: enrich this from the website
|
||||||
|
brickset = cls.objects.create(number=brickset_id)
|
||||||
|
return brickset
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def primary_image_url(self) -> str:
|
def primary_image_url(self) -> str:
|
||||||
|
|||||||
28
vrobbler/apps/foods/admin.py
Normal file
28
vrobbler/apps/foods/admin.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
from foods.models import Food, FoodCategory
|
||||||
|
from django.contrib import admin
|
||||||
|
from scrobbles.admin import ScrobbleInline
|
||||||
|
|
||||||
|
|
||||||
|
class FoodInline(admin.TabularInline):
|
||||||
|
model = Food
|
||||||
|
extra = 0
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(FoodCategory)
|
||||||
|
class FoodCategoryAdmin(admin.ModelAdmin):
|
||||||
|
date_hierarchy = "created"
|
||||||
|
search_fields = ("name",)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Food)
|
||||||
|
class FoodAdmin(admin.ModelAdmin):
|
||||||
|
date_hierarchy = "created"
|
||||||
|
list_display = (
|
||||||
|
"uuid",
|
||||||
|
"title",
|
||||||
|
)
|
||||||
|
ordering = ("-created",)
|
||||||
|
search_fields = ("title",)
|
||||||
|
inlines = [
|
||||||
|
ScrobbleInline,
|
||||||
|
]
|
||||||
143
vrobbler/apps/foods/allrecipe.py
Normal file
143
vrobbler/apps/foods/allrecipe.py
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import urllib
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ALLRECIPE_URL = "https://allrecipe.com/{id}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_first(key: str, result: dict) -> str:
|
||||||
|
obj = ""
|
||||||
|
if obj_list := result.get(key):
|
||||||
|
obj = obj_list[0]
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def get_title_from_soup(soup) -> str:
|
||||||
|
title = ""
|
||||||
|
try:
|
||||||
|
title = soup.find("h1").get_text()
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return title
|
||||||
|
|
||||||
|
|
||||||
|
def get_description_from_soup(soup) -> str:
|
||||||
|
desc = ""
|
||||||
|
try:
|
||||||
|
desc = (
|
||||||
|
soup.find(class_="beer-descrption-read-less")
|
||||||
|
.get_text()
|
||||||
|
.replace("Show Less", "")
|
||||||
|
.strip()
|
||||||
|
)
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return desc
|
||||||
|
|
||||||
|
|
||||||
|
def get_styles_from_soup(soup) -> list[str]:
|
||||||
|
styles = []
|
||||||
|
try:
|
||||||
|
styles = soup.find("p", class_="style").get_text().split(" - ")
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return styles
|
||||||
|
|
||||||
|
|
||||||
|
def get_abv_from_soup(soup) -> Optional[float]:
|
||||||
|
abv = None
|
||||||
|
try:
|
||||||
|
abv = soup.find(class_="abv").get_text()
|
||||||
|
if abv:
|
||||||
|
abv = float(abv.strip("\n").strip("% ABV").strip())
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
except TypeError:
|
||||||
|
pass
|
||||||
|
return abv
|
||||||
|
|
||||||
|
|
||||||
|
def get_ibu_from_soup(soup) -> Optional[int]:
|
||||||
|
ibu = None
|
||||||
|
try:
|
||||||
|
ibu = soup.find(class_="ibu").get_text()
|
||||||
|
if ibu:
|
||||||
|
ibu = int(ibu.strip("\n").strip(" IBU").strip())
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
except ValueError:
|
||||||
|
ibu = None
|
||||||
|
return ibu
|
||||||
|
|
||||||
|
|
||||||
|
def get_rating_from_soup(soup) -> str:
|
||||||
|
rating = ""
|
||||||
|
try:
|
||||||
|
rating = float(
|
||||||
|
soup.find(class_="num").get_text().strip("(").strip(")")
|
||||||
|
)
|
||||||
|
except AttributeError:
|
||||||
|
rating = None
|
||||||
|
except ValueError:
|
||||||
|
rating = None
|
||||||
|
return rating
|
||||||
|
|
||||||
|
|
||||||
|
def get_producer_id_from_soup(soup) -> str:
|
||||||
|
id = ""
|
||||||
|
try:
|
||||||
|
id = soup.find(class_="brewery").find("a")["href"].strip("/")
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
except IndexError:
|
||||||
|
pass
|
||||||
|
return id
|
||||||
|
|
||||||
|
|
||||||
|
def get_producer_name_from_soup(soup) -> str:
|
||||||
|
name = ""
|
||||||
|
try:
|
||||||
|
name = soup.find(class_="brewery").find("a").get_text()
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def get_food_from_allrecipe_id(allrecipe_id: str) -> dict:
|
||||||
|
url = ALLRECIPE_URL.format(id=allrecipe_id)
|
||||||
|
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||||
|
response = requests.get(url, headers=headers)
|
||||||
|
|
||||||
|
food_dict = {"allrecipe_id": allrecipe_id}
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.warn(
|
||||||
|
"Bad response from allrecipe", extra={"response": response}
|
||||||
|
)
|
||||||
|
return food_dict
|
||||||
|
|
||||||
|
import pdb
|
||||||
|
|
||||||
|
pdb.set_trace()
|
||||||
|
soup = BeautifulSoup(response.text, "html.parser")
|
||||||
|
food_dict["title"] = get_title_from_soup(soup)
|
||||||
|
food_dict["description"] = get_description_from_soup(soup)
|
||||||
|
food_dict["allrecipe_rating"] = get_rating_from_soup(soup)
|
||||||
|
|
||||||
|
return food_dict
|
||||||
5
vrobbler/apps/foods/apps.py
Normal file
5
vrobbler/apps/foods/apps.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class FoodsConfig(AppConfig):
|
||||||
|
name = "foods"
|
||||||
151
vrobbler/apps/foods/migrations/0001_initial.py
Normal file
151
vrobbler/apps/foods/migrations/0001_initial.py
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
# Generated by Django 4.2.16 on 2024-11-24 14:45
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django_extensions.db.fields
|
||||||
|
import taggit.managers
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("scrobbles", "0066_scrobble_beer_alter_scrobble_media_type"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="FoodCategory",
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(max_length=255)),
|
||||||
|
(
|
||||||
|
"allrecipe_image",
|
||||||
|
models.ImageField(
|
||||||
|
blank=True, null=True, upload_to="food/recipe/"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"allrecipe_id",
|
||||||
|
models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
("description", models.TextField(blank=True, null=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"get_latest_by": "modified",
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Food",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"created",
|
||||||
|
django_extensions.db.fields.CreationDateTimeField(
|
||||||
|
auto_now_add=True, verbose_name="created"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"modified",
|
||||||
|
django_extensions.db.fields.ModificationDateTimeField(
|
||||||
|
auto_now=True, verbose_name="modified"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"uuid",
|
||||||
|
models.UUIDField(
|
||||||
|
blank=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"title",
|
||||||
|
models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"run_time_seconds",
|
||||||
|
models.IntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"run_time_ticks",
|
||||||
|
models.PositiveBigIntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
("description", models.TextField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"allrecipe_image",
|
||||||
|
models.ImageField(
|
||||||
|
blank=True, null=True, upload_to="food/recipe/"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"allrecipe_id",
|
||||||
|
models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
("allrecipe_rating", models.FloatField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"category",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||||
|
to="foods.foodcategory",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"genre",
|
||||||
|
taggit.managers.TaggableManager(
|
||||||
|
blank=True,
|
||||||
|
help_text="A comma-separated list of tags.",
|
||||||
|
through="scrobbles.ObjectWithGenres",
|
||||||
|
to="scrobbles.Genre",
|
||||||
|
verbose_name="Tags",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.16 on 2025-01-22 03:55
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("foods", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="food",
|
||||||
|
name="run_time_seconds",
|
||||||
|
field=models.IntegerField(default=900),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
vrobbler/apps/foods/migrations/__init__.py
Normal file
0
vrobbler/apps/foods/migrations/__init__.py
Normal file
113
vrobbler/apps/foods/models.py
Normal file
113
vrobbler/apps/foods/models.py
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from django.apps import apps
|
||||||
|
from django.db import models
|
||||||
|
from django.urls import reverse
|
||||||
|
from django_extensions.db.models import TimeStampedModel
|
||||||
|
from imagekit.models import ImageSpecField
|
||||||
|
from imagekit.processors import ResizeToFit
|
||||||
|
from scrobbles.dataclasses import FoodLogData
|
||||||
|
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
||||||
|
|
||||||
|
BNULL = {"blank": True, "null": True}
|
||||||
|
|
||||||
|
|
||||||
|
class FoodCategory(TimeStampedModel):
|
||||||
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
allrecipe_image = models.ImageField(upload_to="food/recipe/", **BNULL)
|
||||||
|
allrecipe_image_small = ImageSpecField(
|
||||||
|
source="recipe_image",
|
||||||
|
processors=[ResizeToFit(100, 100)],
|
||||||
|
format="JPEG",
|
||||||
|
options={"quality": 60},
|
||||||
|
)
|
||||||
|
allrecipe_image_medium = ImageSpecField(
|
||||||
|
source="recipe_image",
|
||||||
|
processors=[ResizeToFit(300, 300)],
|
||||||
|
format="JPEG",
|
||||||
|
options={"quality": 75},
|
||||||
|
)
|
||||||
|
allrecipe_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
description = models.TextField(**BNULL)
|
||||||
|
|
||||||
|
def find_or_create(cls, title: str) -> "FoodCategory":
|
||||||
|
return cls.objects.filter(title=title).first()
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class Food(ScrobblableMixin):
|
||||||
|
description = models.TextField(**BNULL)
|
||||||
|
allrecipe_image = models.ImageField(upload_to="food/recipe/", **BNULL)
|
||||||
|
allrecipe_image_small = ImageSpecField(
|
||||||
|
source="allrecipe_image",
|
||||||
|
processors=[ResizeToFit(100, 100)],
|
||||||
|
format="JPEG",
|
||||||
|
options={"quality": 60},
|
||||||
|
)
|
||||||
|
allrecipe_image_medium = ImageSpecField(
|
||||||
|
source="allrecipe_image",
|
||||||
|
processors=[ResizeToFit(300, 300)],
|
||||||
|
format="JPEG",
|
||||||
|
options={"quality": 75},
|
||||||
|
)
|
||||||
|
allrecipe_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
allrecipe_rating = models.FloatField(**BNULL)
|
||||||
|
category = models.ForeignKey(
|
||||||
|
FoodCategory, on_delete=models.DO_NOTHING, **BNULL
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_absolute_url(self) -> str:
|
||||||
|
return reverse("foods:food_detail", kwargs={"slug": self.uuid})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def subtitle(self):
|
||||||
|
return self.category.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def strings(self) -> ScrobblableConstants:
|
||||||
|
return ScrobblableConstants(verb="Eating", tags="food")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def allrecipe_link(self) -> str:
|
||||||
|
link = ""
|
||||||
|
if self.producer and self.allrecipe_id:
|
||||||
|
if self.allrecipe_id:
|
||||||
|
link = f"https://www.allrecipe.com/{self.allrecipe_id}/"
|
||||||
|
return link
|
||||||
|
|
||||||
|
@property
|
||||||
|
def primary_image_url(self) -> str:
|
||||||
|
url = ""
|
||||||
|
if self.allrecipe_image:
|
||||||
|
url = self.allrecipe_image.url
|
||||||
|
return url
|
||||||
|
|
||||||
|
@property
|
||||||
|
def logdata_cls(self):
|
||||||
|
return FoodLogData
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def find_or_create(cls, allrecipe_id: str) -> "Food":
|
||||||
|
food = cls.objects.filter(allrecipe_id=allrecipe_id).first()
|
||||||
|
|
||||||
|
if not food:
|
||||||
|
food_dict = get_food_from_allrecipe_id(allrecipe_id)
|
||||||
|
# category_dict = {}
|
||||||
|
|
||||||
|
# category, _created = FoodCategory.objects.get_or_create(
|
||||||
|
# **category_dict
|
||||||
|
# )
|
||||||
|
food = Food.objects.create(**food_dict)
|
||||||
|
# for category_id in category_ids:
|
||||||
|
# food.category.add(category_id)
|
||||||
|
|
||||||
|
return food
|
||||||
|
|
||||||
|
def scrobbles(self, user_id):
|
||||||
|
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
||||||
|
return Scrobble.objects.filter(user_id=user_id, food=self).order_by(
|
||||||
|
"-timestamp"
|
||||||
|
)
|
||||||
14
vrobbler/apps/foods/urls.py
Normal file
14
vrobbler/apps/foods/urls.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from foods import views
|
||||||
|
|
||||||
|
app_name = "foods"
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("foods/", views.FoodListView.as_view(), name="food_list"),
|
||||||
|
path(
|
||||||
|
"foods/<slug:slug>/",
|
||||||
|
views.FoodDetailView.as_view(),
|
||||||
|
name="food_detail",
|
||||||
|
),
|
||||||
|
]
|
||||||
11
vrobbler/apps/foods/views.py
Normal file
11
vrobbler/apps/foods/views.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
from foods.models import Food
|
||||||
|
|
||||||
|
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
|
||||||
|
|
||||||
|
|
||||||
|
class FoodListView(ScrobbleableListView):
|
||||||
|
model = Food
|
||||||
|
|
||||||
|
|
||||||
|
class FoodDetailView(ScrobbleableDetailView):
|
||||||
|
model = Food
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.16 on 2025-01-22 03:55
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("lifeevents", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="lifeevent",
|
||||||
|
name="run_time_seconds",
|
||||||
|
field=models.IntegerField(default=900),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -1,16 +1,13 @@
|
|||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
import pendulum
|
|
||||||
from scrobbles.dataclasses import LifeEventLogData
|
from scrobbles.dataclasses import LifeEventLogData
|
||||||
from scrobbles.mixins import ScrobblableMixin
|
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
||||||
|
|
||||||
BNULL = {"blank": True, "null": True}
|
BNULL = {"blank": True, "null": True}
|
||||||
|
|
||||||
|
|
||||||
class LifeEvent(ScrobblableMixin):
|
class LifeEvent(ScrobblableMixin):
|
||||||
COMPLETION_PERCENT = 100
|
|
||||||
|
|
||||||
description = models.TextField(**BNULL)
|
description = models.TextField(**BNULL)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
@ -25,6 +22,10 @@ class LifeEvent(ScrobblableMixin):
|
|||||||
def logdata_cls(self):
|
def logdata_cls(self):
|
||||||
return LifeEventLogData
|
return LifeEventLogData
|
||||||
|
|
||||||
|
@property
|
||||||
|
def strings(self) -> ScrobblableConstants:
|
||||||
|
return ScrobblableConstants(verb="Experiencing", tags="camping")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def find_or_create(cls, title: str) -> "LifeEvent":
|
def find_or_create(cls, title: str) -> "LifeEvent":
|
||||||
return cls.objects.filter(title=title).first()
|
return cls.objects.filter(title=title).first()
|
||||||
|
|||||||
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.16 on 2025-01-22 03:55
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("locations", "0006_delete_rawgeolocation"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="geolocation",
|
||||||
|
name="run_time_seconds",
|
||||||
|
field=models.IntegerField(default=900),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -1,14 +1,12 @@
|
|||||||
from decimal import Decimal, getcontext
|
from decimal import Decimal, getcontext
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django_extensions.db.models import TimeStampedModel
|
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
||||||
from scrobbles.mixins import ScrobblableMixin
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
BNULL = {"blank": True, "null": True}
|
BNULL = {"blank": True, "null": True}
|
||||||
@ -90,6 +88,12 @@ class GeoLocation(ScrobblableMixin):
|
|||||||
return f"{self.lat} x {self.lon}"
|
return f"{self.lat} x {self.lon}"
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def strings(self) -> ScrobblableConstants:
|
||||||
|
return ScrobblableConstants(
|
||||||
|
verb="Going", tags="world_map", priority="low"
|
||||||
|
)
|
||||||
|
|
||||||
def loc_diff(self, old_lat_lon: tuple) -> tuple:
|
def loc_diff(self, old_lat_lon: tuple) -> tuple:
|
||||||
return (
|
return (
|
||||||
abs(Decimal(old_lat_lon[0]) - Decimal(self.lat)),
|
abs(Decimal(old_lat_lon[0]) - Decimal(self.lat)),
|
||||||
|
|||||||
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.16 on 2025-01-22 03:55
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("moods", "0002_mood_image"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="mood",
|
||||||
|
name="run_time_seconds",
|
||||||
|
field=models.IntegerField(default=900),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -5,7 +5,7 @@ from django.db import models
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from imagekit.models import ImageSpecField
|
from imagekit.models import ImageSpecField
|
||||||
from imagekit.processors import ResizeToFit
|
from imagekit.processors import ResizeToFit
|
||||||
from scrobbles.mixins import ScrobblableMixin
|
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
||||||
|
|
||||||
from vrobbler.apps.scrobbles.dataclasses import MoodLogData
|
from vrobbler.apps.scrobbles.dataclasses import MoodLogData
|
||||||
|
|
||||||
@ -38,13 +38,14 @@ class Mood(ScrobblableMixin):
|
|||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse("moods:mood-detail", kwargs={"slug": self.uuid})
|
return reverse("moods:mood-detail", kwargs={"slug": self.uuid})
|
||||||
|
|
||||||
def get_start_url(self):
|
|
||||||
return reverse("scrobbles:start", kwargs={"uuid": self.uuid})
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def subtitle(self) -> str:
|
def subtitle(self) -> str:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def strings(self) -> ScrobblableConstants:
|
||||||
|
return ScrobblableConstants(verb="Feeling", tags="thinking")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def logdata_cls(self):
|
def logdata_cls(self):
|
||||||
return MoodLogData
|
return MoodLogData
|
||||||
|
|||||||
@ -5,9 +5,9 @@ app_name = "moods"
|
|||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("mood/", views.MoodListView.as_view(), name="mood-list"),
|
path("moods/", views.MoodListView.as_view(), name="mood-list"),
|
||||||
path(
|
path(
|
||||||
"mood/<slug:slug>/",
|
"moods/<slug:slug>/",
|
||||||
views.MoodDetailView.as_view(),
|
views.MoodDetailView.as_view(),
|
||||||
name="mood-detail",
|
name="mood-detail",
|
||||||
),
|
),
|
||||||
|
|||||||
@ -16,6 +16,7 @@ class AlbumAdmin(admin.ModelAdmin):
|
|||||||
"theaudiodb_mood",
|
"theaudiodb_mood",
|
||||||
"musicbrainz_id",
|
"musicbrainz_id",
|
||||||
)
|
)
|
||||||
|
raw_id_fields = ("album_artist",)
|
||||||
list_filter = (
|
list_filter = (
|
||||||
"theaudiodb_score",
|
"theaudiodb_score",
|
||||||
"theaudiodb_genre",
|
"theaudiodb_genre",
|
||||||
@ -49,13 +50,17 @@ class TrackAdmin(admin.ModelAdmin):
|
|||||||
date_hierarchy = "created"
|
date_hierarchy = "created"
|
||||||
list_display = (
|
list_display = (
|
||||||
"title",
|
"title",
|
||||||
"album",
|
"primary_album",
|
||||||
"artist",
|
"artist",
|
||||||
"musicbrainz_id",
|
"musicbrainz_id",
|
||||||
)
|
)
|
||||||
|
raw_id_fields = ("artist", "albums", "album")
|
||||||
list_filter = ("album", "artist")
|
list_filter = ("album", "artist")
|
||||||
search_fields = ("title",)
|
search_fields = ("title",)
|
||||||
ordering = ("-created",)
|
ordering = ("-created",)
|
||||||
|
filter_horizontal = [
|
||||||
|
"albums",
|
||||||
|
]
|
||||||
inlines = [
|
inlines = [
|
||||||
ScrobbleInline,
|
ScrobbleInline,
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,153 +0,0 @@
|
|||||||
import logging
|
|
||||||
import time
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
import pylast
|
|
||||||
import pytz
|
|
||||||
from django.conf import settings
|
|
||||||
from django.utils import timezone
|
|
||||||
from music.utils import (
|
|
||||||
get_or_create_album,
|
|
||||||
get_or_create_artist,
|
|
||||||
get_or_create_track,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
PYLAST_ERRORS = tuple(
|
|
||||||
getattr(pylast, exc_name)
|
|
||||||
for exc_name in (
|
|
||||||
"ScrobblingError",
|
|
||||||
"NetworkError",
|
|
||||||
"MalformedResponseError",
|
|
||||||
"WSError",
|
|
||||||
)
|
|
||||||
if hasattr(pylast, exc_name)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class LastFM:
|
|
||||||
def __init__(self, user):
|
|
||||||
try:
|
|
||||||
self.client = pylast.LastFMNetwork(
|
|
||||||
api_key=getattr(settings, "LASTFM_API_KEY"),
|
|
||||||
api_secret=getattr(settings, "LASTFM_SECRET_KEY"),
|
|
||||||
username=user.profile.lastfm_username,
|
|
||||||
password_hash=pylast.md5(user.profile.lastfm_password),
|
|
||||||
)
|
|
||||||
self.user = self.client.get_user(user.profile.lastfm_username)
|
|
||||||
self.vrobbler_user = user
|
|
||||||
except PYLAST_ERRORS as e:
|
|
||||||
logger.error(f"Error during Last.fm setup: {e}")
|
|
||||||
|
|
||||||
def import_from_lastfm(self, last_processed=None):
|
|
||||||
"""Given a last processed time, import all scrobbles from LastFM since then"""
|
|
||||||
from scrobbles.models import Scrobble
|
|
||||||
|
|
||||||
new_scrobbles = []
|
|
||||||
source = "Last.fm"
|
|
||||||
lastfm_scrobbles = self.get_last_scrobbles(time_from=last_processed)
|
|
||||||
|
|
||||||
for lfm_scrobble in lastfm_scrobbles:
|
|
||||||
timestamp = lfm_scrobble.pop("timestamp")
|
|
||||||
|
|
||||||
artist = get_or_create_artist(lfm_scrobble.pop("artist"))
|
|
||||||
album = get_or_create_album(lfm_scrobble.pop("album"), artist)
|
|
||||||
|
|
||||||
lfm_scrobble["artist"] = artist
|
|
||||||
if album:
|
|
||||||
lfm_scrobble["album"] = album
|
|
||||||
track = get_or_create_track(**lfm_scrobble)
|
|
||||||
|
|
||||||
timezone = settings.TIME_ZONE
|
|
||||||
if self.vrobbler_user.profile:
|
|
||||||
timezone = self.vrobbler_user.profile.timezone
|
|
||||||
|
|
||||||
new_scrobble = Scrobble(
|
|
||||||
user=self.vrobbler_user,
|
|
||||||
timestamp=timestamp,
|
|
||||||
source=source,
|
|
||||||
track=track,
|
|
||||||
timezone=timezone,
|
|
||||||
played_to_completion=True,
|
|
||||||
in_progress=False,
|
|
||||||
media_type=Scrobble.MediaType.TRACK,
|
|
||||||
)
|
|
||||||
# Vrobbler scrobbles on finish, LastFM scrobbles on start
|
|
||||||
seconds_eariler = timestamp - timedelta(seconds=20)
|
|
||||||
seconds_later = timestamp + timedelta(seconds=20)
|
|
||||||
existing = Scrobble.objects.filter(
|
|
||||||
created__gte=seconds_eariler,
|
|
||||||
created__lte=seconds_later,
|
|
||||||
track=track,
|
|
||||||
).first()
|
|
||||||
if existing:
|
|
||||||
logger.debug(f"Skipping existing scrobble {new_scrobble}")
|
|
||||||
continue
|
|
||||||
logger.debug(f"Queued scrobble {new_scrobble} for creation")
|
|
||||||
new_scrobbles.append(new_scrobble)
|
|
||||||
|
|
||||||
created = Scrobble.objects.bulk_create(new_scrobbles)
|
|
||||||
logger.info(
|
|
||||||
f"Created {len(created)} scrobbles",
|
|
||||||
extra={"created_scrobbles": created},
|
|
||||||
)
|
|
||||||
return created
|
|
||||||
|
|
||||||
def get_last_scrobbles(self, time_from=None, time_to=None):
|
|
||||||
"""Given a user, Last.fm api key, and secret key, grab a list of scrobbled
|
|
||||||
tracks"""
|
|
||||||
lfm_params = {}
|
|
||||||
scrobbles = []
|
|
||||||
if time_from:
|
|
||||||
lfm_params["time_from"] = int(time_from.timestamp())
|
|
||||||
if time_to:
|
|
||||||
lfm_params["time_to"] = int(time_to.timestamp())
|
|
||||||
|
|
||||||
# if not time_from and not time_to:
|
|
||||||
lfm_params["limit"] = None
|
|
||||||
|
|
||||||
found_scrobbles = self.user.get_recent_tracks(**lfm_params)
|
|
||||||
# TOOD spin this out into a celery task over certain threshold of found scrobbles?
|
|
||||||
|
|
||||||
for scrobble in found_scrobbles:
|
|
||||||
logger.debug(f"Processing {scrobble}")
|
|
||||||
run_time = None
|
|
||||||
mbid = None
|
|
||||||
artist = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
run_time = int(scrobble.track.get_duration() / 1000)
|
|
||||||
mbid = scrobble.track.get_mbid()
|
|
||||||
artist = scrobble.track.get_artist().name
|
|
||||||
except pylast.MalformedResponseError as e:
|
|
||||||
logger.warn(e)
|
|
||||||
except pylast.WSError as e:
|
|
||||||
logger.warn(
|
|
||||||
"LastFM barfed trying to get the track for {scrobble.track}"
|
|
||||||
)
|
|
||||||
except pylast.NetworkError as e:
|
|
||||||
logger.warn(
|
|
||||||
"LastFM barfed trying to get the track for {scrobble.track}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not artist:
|
|
||||||
logger.warn(f"Silly LastFM, no artist found for {scrobble}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
timestamp = datetime.utcfromtimestamp(
|
|
||||||
int(scrobble.timestamp)
|
|
||||||
).replace(tzinfo=pytz.utc)
|
|
||||||
|
|
||||||
logger.info(f"{artist},{scrobble.track.title},{timestamp}")
|
|
||||||
scrobbles.append(
|
|
||||||
{
|
|
||||||
"artist": artist,
|
|
||||||
"album": scrobble.album,
|
|
||||||
"title": scrobble.track.title,
|
|
||||||
"mbid": mbid,
|
|
||||||
"run_time_seconds": run_time,
|
|
||||||
"timestamp": timestamp,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return scrobbles
|
|
||||||
|
|||||||
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.16 on 2025-01-22 03:55
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("music", "0023_alter_track_genre"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="track",
|
||||||
|
name="run_time_seconds",
|
||||||
|
field=models.IntegerField(default=900),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
vrobbler/apps/music/migrations/0025_artist_alt_names.py
Normal file
18
vrobbler/apps/music/migrations/0025_artist_alt_names.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.19 on 2025-04-07 00:23
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("music", "0024_alter_track_run_time_seconds"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="artist",
|
||||||
|
name="alt_names",
|
||||||
|
field=models.TextField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
vrobbler/apps/music/migrations/0026_album_alt_names.py
Normal file
18
vrobbler/apps/music/migrations/0026_album_alt_names.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.19 on 2025-04-07 00:34
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("music", "0025_artist_alt_names"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="album",
|
||||||
|
name="alt_names",
|
||||||
|
field=models.TextField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
20
vrobbler/apps/music/migrations/0027_track_albums.py
Normal file
20
vrobbler/apps/music/migrations/0027_track_albums.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
# Generated by Django 4.2.19 on 2025-07-20 20:54
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("music", "0026_album_alt_names"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="track",
|
||||||
|
name="albums",
|
||||||
|
field=models.ManyToManyField(
|
||||||
|
blank=True, null=True, related_name="tracks", to="music.album"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
20
vrobbler/apps/music/migrations/0028_alter_track_albums.py
Normal file
20
vrobbler/apps/music/migrations/0028_alter_track_albums.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
# Generated by Django 4.2.19 on 2025-07-25 14:41
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("music", "0027_track_albums"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="track",
|
||||||
|
name="albums",
|
||||||
|
field=models.ManyToManyField(
|
||||||
|
related_name="tracks", to="music.album"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -1,13 +1,11 @@
|
|||||||
import logging
|
import logging
|
||||||
from tempfile import NamedTemporaryFile
|
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
from urllib.request import urlopen
|
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import musicbrainzngs
|
import musicbrainzngs
|
||||||
import requests
|
import requests
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.files.base import ContentFile, File
|
from django.core.files.base import ContentFile
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@ -16,14 +14,36 @@ from imagekit.models import ImageSpecField
|
|||||||
from imagekit.processors import ResizeToFit
|
from imagekit.processors import ResizeToFit
|
||||||
from music.allmusic import get_allmusic_slug, scrape_data_from_allmusic
|
from music.allmusic import get_allmusic_slug, scrape_data_from_allmusic
|
||||||
from music.bandcamp import get_bandcamp_slug
|
from music.bandcamp import get_bandcamp_slug
|
||||||
|
from music.musicbrainz import (
|
||||||
|
get_album_metadata,
|
||||||
|
get_album_metadata_with_artist,
|
||||||
|
get_artist_metadata_extended,
|
||||||
|
get_recording_mbid_exact,
|
||||||
|
get_track_metadata_with_artist,
|
||||||
|
lookup_album_dict_from_mb,
|
||||||
|
lookup_album_from_mb,
|
||||||
|
lookup_track_from_mb,
|
||||||
|
lookup_artist_from_mb,
|
||||||
|
)
|
||||||
from music.theaudiodb import lookup_album_from_tadb, lookup_artist_from_tadb
|
from music.theaudiodb import lookup_album_from_tadb, lookup_artist_from_tadb
|
||||||
from scrobbles.mixins import ScrobblableMixin
|
from music.utils import clean_artist_name
|
||||||
|
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
BNULL = {"blank": True, "null": True}
|
BNULL = {"blank": True, "null": True}
|
||||||
|
|
||||||
|
|
||||||
class Artist(TimeStampedModel):
|
class Artist(TimeStampedModel):
|
||||||
|
"""Represents a music artist.
|
||||||
|
|
||||||
|
# Lookup or create by title alone
|
||||||
|
>>> Artist.find_or_create(name="Bon Iver")
|
||||||
|
|
||||||
|
# Lookup or create by MB id alone
|
||||||
|
>>> Artist.find_or_create(musicbrainz_id="0307edfc-437c-4b48-8700-80680e66a228")
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
biography = models.TextField(**BNULL)
|
biography = models.TextField(**BNULL)
|
||||||
@ -46,6 +66,7 @@ class Artist(TimeStampedModel):
|
|||||||
format="JPEG",
|
format="JPEG",
|
||||||
options={"quality": 75},
|
options={"quality": 75},
|
||||||
)
|
)
|
||||||
|
alt_names = models.TextField(**BNULL)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = [["name", "musicbrainz_id"]]
|
unique_together = [["name", "musicbrainz_id"]]
|
||||||
@ -62,8 +83,10 @@ class Artist(TimeStampedModel):
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def mb_link(self):
|
def mb_link(self) -> str:
|
||||||
return f"https://musicbrainz.org/artist/{self.musicbrainz_id}"
|
if self.musicbrainz_id:
|
||||||
|
return f"https://musicbrainz.org/artist/{self.musicbrainz_id}"
|
||||||
|
return ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def allmusic_link(self):
|
def allmusic_link(self):
|
||||||
@ -104,7 +127,9 @@ class Artist(TimeStampedModel):
|
|||||||
if not self.allmusic_id or force:
|
if not self.allmusic_id or force:
|
||||||
slug = get_allmusic_slug(self.name)
|
slug = get_allmusic_slug(self.name)
|
||||||
if not slug:
|
if not slug:
|
||||||
logger.info(f"No allmsuic link for {self}")
|
logger.info(
|
||||||
|
"No allmusic link found", extra={"track_id": self.id}
|
||||||
|
)
|
||||||
return
|
return
|
||||||
self.allmusic_id = slug
|
self.allmusic_id = slug
|
||||||
self.save(update_fields=["allmusic_id"])
|
self.save(update_fields=["allmusic_id"])
|
||||||
@ -113,7 +138,9 @@ class Artist(TimeStampedModel):
|
|||||||
if not self.bandcamp_id or force:
|
if not self.bandcamp_id or force:
|
||||||
slug = get_bandcamp_slug(self.name)
|
slug = get_bandcamp_slug(self.name)
|
||||||
if not slug:
|
if not slug:
|
||||||
logger.info(f"No bandcamp link for {self}")
|
logger.info(
|
||||||
|
"No bandcamp link found", extra={"track_id": self.id}
|
||||||
|
)
|
||||||
return
|
return
|
||||||
self.bandcamp_id = slug
|
self.bandcamp_id = slug
|
||||||
self.save(update_fields=["bandcamp_id"])
|
self.save(update_fields=["bandcamp_id"])
|
||||||
@ -145,7 +172,7 @@ class Artist(TimeStampedModel):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def rym_link(self):
|
def rym_link(self):
|
||||||
artist_slug = self.name.lower().replace(" ", "-")
|
artist_slug = self.name.lower().replace(" ", "-").replace(",", "")
|
||||||
return f"https://rateyourmusic.com/artist/{artist_slug}/"
|
return f"https://rateyourmusic.com/artist/{artist_slug}/"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -153,6 +180,79 @@ class Artist(TimeStampedModel):
|
|||||||
artist = self.name.lower()
|
artist = self.name.lower()
|
||||||
return f"https://bandcamp.com/search?q={artist}&item_type=b"
|
return f"https://bandcamp.com/search?q={artist}&item_type=b"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def find_or_create(
|
||||||
|
cls, name: str, album_name: str = "", track_name: str = ""
|
||||||
|
) -> "Artist":
|
||||||
|
"""The biggest challenge to finding artists is that the search often
|
||||||
|
fails miserably unless you can look it up along with an album or a track name.
|
||||||
|
|
||||||
|
Thus, when we find or create an artist, we should always provide an optional
|
||||||
|
album name or track name, but probably not both."""
|
||||||
|
if album_name:
|
||||||
|
logger.info(
|
||||||
|
f"Looking for artist with name {name} and album {album_name}"
|
||||||
|
)
|
||||||
|
if track_name:
|
||||||
|
logger.info(
|
||||||
|
f"Looking for artist with name {name} and track {track_name}"
|
||||||
|
)
|
||||||
|
keys = {}
|
||||||
|
|
||||||
|
name = clean_artist_name(name)
|
||||||
|
keys["name"] = name
|
||||||
|
artist = cls.objects.filter(name=name).first()
|
||||||
|
|
||||||
|
if artist:
|
||||||
|
return artist
|
||||||
|
|
||||||
|
# alt_name = None
|
||||||
|
artist_dict = {}
|
||||||
|
if album_name:
|
||||||
|
album_dict = get_album_metadata_with_artist(album_name, name)
|
||||||
|
if album_dict:
|
||||||
|
artist_dict = album_dict.get("primary_artist")
|
||||||
|
if track_name:
|
||||||
|
track_dict = get_track_metadata_with_artist(track_name, name)
|
||||||
|
if track_dict:
|
||||||
|
artist_dict = track_dict.get("primary_artist")
|
||||||
|
|
||||||
|
if not artist_dict:
|
||||||
|
artist, created = cls.objects.get_or_create(name=name)
|
||||||
|
if created:
|
||||||
|
artist.fix_metadata()
|
||||||
|
return artist
|
||||||
|
|
||||||
|
musicbrainz_id = artist_dict.get("mbid")
|
||||||
|
found_name = artist_dict.get("name", name)
|
||||||
|
if found_name and name != found_name:
|
||||||
|
alt_name = found_name
|
||||||
|
|
||||||
|
artist = cls.objects.filter(
|
||||||
|
name=found_name, musicbrainz_id=musicbrainz_id
|
||||||
|
).first()
|
||||||
|
if not artist:
|
||||||
|
artist = cls.objects.create(
|
||||||
|
name=found_name,
|
||||||
|
musicbrainz_id=musicbrainz_id,
|
||||||
|
)
|
||||||
|
artist.fix_metadata()
|
||||||
|
|
||||||
|
# TODO: See if this alt_names stuff actually works or causes hard to debug problems
|
||||||
|
# If we did find our artist, but the found name is slightly differnt, record that
|
||||||
|
# if artist and alt_name:
|
||||||
|
# if not artist.alt_names:
|
||||||
|
# artist.alt_names = alt_name
|
||||||
|
# else:
|
||||||
|
# artist.alt_names += f"\\{alt_name}"
|
||||||
|
# logger.info(
|
||||||
|
# f"Add alt_name {alt_name} to artist {artist}",
|
||||||
|
# extra={"alt_name": alt_name, "artist_id": artist.id},
|
||||||
|
# )
|
||||||
|
# artist.save(update_fields=["alt_names"])
|
||||||
|
|
||||||
|
return artist
|
||||||
|
|
||||||
|
|
||||||
class Album(TimeStampedModel):
|
class Album(TimeStampedModel):
|
||||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||||
@ -196,9 +296,10 @@ class Album(TimeStampedModel):
|
|||||||
wikipedia_slug = models.CharField(max_length=255, **BNULL)
|
wikipedia_slug = models.CharField(max_length=255, **BNULL)
|
||||||
discogs_id = models.CharField(max_length=255, **BNULL)
|
discogs_id = models.CharField(max_length=255, **BNULL)
|
||||||
wikidata_id = models.CharField(max_length=255, **BNULL)
|
wikidata_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
alt_names = models.TextField(**BNULL)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return self.name
|
return "{} by {}".format(self.name, self.album_artist)
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse("music:album_detail", kwargs={"slug": self.uuid})
|
return reverse("music:album_detail", kwargs={"slug": self.uuid})
|
||||||
@ -235,8 +336,15 @@ class Album(TimeStampedModel):
|
|||||||
self.save(update_fields=["album_artist"])
|
self.save(update_fields=["album_artist"])
|
||||||
|
|
||||||
def scrape_allmusic(self, force=False) -> None:
|
def scrape_allmusic(self, force=False) -> None:
|
||||||
if not self.allmusic_id or force:
|
if not self.name:
|
||||||
slug = get_allmusic_slug(self.name, self.album_artist.name)
|
logger.warning(
|
||||||
|
"Album without a name cannot be scraped",
|
||||||
|
extra={"album_id": self.id},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.album_artist and (not self.allmusic_id or force):
|
||||||
|
slug = get_allmusic_slug(self.album_artist.name, self.name)
|
||||||
if not slug:
|
if not slug:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"No allmsuic link for {self} by {self.album_artist}"
|
f"No allmsuic link for {self} by {self.album_artist}"
|
||||||
@ -245,7 +353,9 @@ class Album(TimeStampedModel):
|
|||||||
self.allmusic_id = slug
|
self.allmusic_id = slug
|
||||||
self.save(update_fields=["allmusic_id"])
|
self.save(update_fields=["allmusic_id"])
|
||||||
|
|
||||||
allmusic_data = scrape_data_from_allmusic(self.allmusic_link)
|
allmusic_data = None
|
||||||
|
if self.allmusic_link:
|
||||||
|
allmusic_data = scrape_data_from_allmusic(self.allmusic_link)
|
||||||
|
|
||||||
if not allmusic_data:
|
if not allmusic_data:
|
||||||
logger.info(f"No allmsuic data for {self} by {self.album_artist}")
|
logger.info(f"No allmsuic data for {self} by {self.album_artist}")
|
||||||
@ -264,7 +374,12 @@ class Album(TimeStampedModel):
|
|||||||
logger.info(f"No data for {self} found in TheAudioDB")
|
logger.info(f"No data for {self} found in TheAudioDB")
|
||||||
return
|
return
|
||||||
|
|
||||||
Album.objects.filter(pk=self.pk).update(**album_data)
|
try:
|
||||||
|
Album.objects.filter(pk=self.pk).update(**album_data)
|
||||||
|
except:
|
||||||
|
logger.info(
|
||||||
|
f"Could not save info for album {self} with data {album_data}"
|
||||||
|
)
|
||||||
|
|
||||||
def scrape_bandcamp(self, force=False) -> None:
|
def scrape_bandcamp(self, force=False) -> None:
|
||||||
if not self.bandcamp_id or force:
|
if not self.bandcamp_id or force:
|
||||||
@ -402,16 +517,85 @@ class Album(TimeStampedModel):
|
|||||||
album = self.name.lower()
|
album = self.name.lower()
|
||||||
return f"https://bandcamp.com/search?q={album} {artist}&item_type=a"
|
return f"https://bandcamp.com/search?q={album} {artist}&item_type=a"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def find_or_create(cls, name: str, artist_name: str) -> "Album":
|
||||||
|
logger.info(
|
||||||
|
f"Looking for album with name {name} and artist_name {artist_name}"
|
||||||
|
)
|
||||||
|
artist = Artist.find_or_create(artist_name, album_name=name)
|
||||||
|
album_dict = get_album_metadata_with_artist(name, artist.name)
|
||||||
|
|
||||||
|
if not album_dict:
|
||||||
|
logger.info(
|
||||||
|
f"Could not find album {name} with artist {artist.name} on musicbrainz"
|
||||||
|
)
|
||||||
|
album, created = Album.objects.get_or_create(
|
||||||
|
name=name,
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
# album.fix_metadata()
|
||||||
|
# album.fetch_artwork()
|
||||||
|
...
|
||||||
|
return album
|
||||||
|
|
||||||
|
if not artist:
|
||||||
|
artist_dict = album_dict.get("primary_artist", {})
|
||||||
|
if artist_dict:
|
||||||
|
artist = Artist.objects.filter(
|
||||||
|
musicbrainz_id=artist_dict.get("mbid"),
|
||||||
|
).first()
|
||||||
|
if not artist:
|
||||||
|
artist = Artist.objects.create(
|
||||||
|
musicbrainz_id=artist_dict.get("mbid"),
|
||||||
|
)
|
||||||
|
|
||||||
|
extra_artists = []
|
||||||
|
if not artist and len(album_dict.get("all_artists")) > 1:
|
||||||
|
artist = Artist.objects.filter(name="Various Artists").first()
|
||||||
|
extra_artists.append(artist)
|
||||||
|
|
||||||
|
if not artist:
|
||||||
|
raise Exception("No album artist found, and not a compliation")
|
||||||
|
|
||||||
|
album = cls.objects.filter(
|
||||||
|
models.Q(name=name) | models.Q(alt_names__icontains=name),
|
||||||
|
album_artist=artist,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
alt_name = None
|
||||||
|
found_name = album_dict.get("album_title", name)
|
||||||
|
if found_name and name != found_name:
|
||||||
|
alt_name = name
|
||||||
|
|
||||||
|
album = Album.objects.filter(
|
||||||
|
name=found_name, musicbrainz_id=album_dict.get("mbid")
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not album:
|
||||||
|
year = None
|
||||||
|
if album_dict.get("release_date"):
|
||||||
|
year = album_dict.get("release_date", "").split("-")[0]
|
||||||
|
album = Album.objects.create(
|
||||||
|
name=found_name,
|
||||||
|
musicbrainz_id=album_dict.get("mbid"),
|
||||||
|
musicbrainz_releasegroup_id=album_dict.get(
|
||||||
|
"release_group_mbid"
|
||||||
|
),
|
||||||
|
year=year,
|
||||||
|
album_artist=artist,
|
||||||
|
alt_names=alt_name,
|
||||||
|
)
|
||||||
|
album.artists.add(*extra_artists)
|
||||||
|
album.fetch_artwork()
|
||||||
|
|
||||||
|
return album
|
||||||
|
|
||||||
|
|
||||||
class Track(ScrobblableMixin):
|
class Track(ScrobblableMixin):
|
||||||
COMPLETION_PERCENT = getattr(settings, "MUSIC_COMPLETION_PERCENT", 100)
|
COMPLETION_PERCENT = getattr(settings, "MUSIC_COMPLETION_PERCENT", 100)
|
||||||
|
|
||||||
class Opinion(models.IntegerChoices):
|
|
||||||
DOWN = -1, "Thumbs down"
|
|
||||||
NEUTRAL = 0, "No opinion"
|
|
||||||
UP = 1, "Thumbs up"
|
|
||||||
|
|
||||||
artist = models.ForeignKey(Artist, on_delete=models.DO_NOTHING)
|
artist = models.ForeignKey(Artist, on_delete=models.DO_NOTHING)
|
||||||
|
albums = models.ManyToManyField(Album, related_name="tracks")
|
||||||
album = models.ForeignKey(Album, on_delete=models.DO_NOTHING, **BNULL)
|
album = models.ForeignKey(Album, on_delete=models.DO_NOTHING, **BNULL)
|
||||||
musicbrainz_id = models.CharField(max_length=255, **BNULL)
|
musicbrainz_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
|
||||||
@ -421,12 +605,22 @@ class Track(ScrobblableMixin):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.title} by {self.artist}"
|
return f"{self.title} by {self.artist}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def primary_album(self):
|
||||||
|
if self.album:
|
||||||
|
return self.album
|
||||||
|
return self.albums.order_by("year").first()
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse("music:track_detail", kwargs={"slug": self.uuid})
|
return reverse("music:track_detail", kwargs={"slug": self.uuid})
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def subtitle(self):
|
def subtitle(self) -> str:
|
||||||
return self.artist
|
return str(self.artist)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def strings(self) -> ScrobblableConstants:
|
||||||
|
return ScrobblableConstants(verb="Listening", tags="notes")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def mb_link(self):
|
def mb_link(self):
|
||||||
@ -441,37 +635,85 @@ class Track(ScrobblableMixin):
|
|||||||
url = ""
|
url = ""
|
||||||
if self.artist.thumbnail:
|
if self.artist.thumbnail:
|
||||||
url = self.artist.thumbnail_medium.url
|
url = self.artist.thumbnail_medium.url
|
||||||
if self.album and self.album.cover_image:
|
if self.primary_album and self.primary_album.cover_image:
|
||||||
url = self.album.cover_image_medium.url
|
url = self.primary_album.cover_image_medium.url
|
||||||
return url
|
return url
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def find_or_create(
|
def find_or_create(
|
||||||
cls, artist_dict: Dict, album_dict: Dict, track_dict: Dict
|
cls,
|
||||||
) -> Optional["Track"]:
|
title: str = "",
|
||||||
"""Given a data dict from Jellyfin, does the heavy lifting of looking up
|
artist_name: str = "",
|
||||||
the video and, if need, TV Series, creating both if they don't yet
|
album_name: str = "",
|
||||||
exist.
|
run_time_seconds: int | None = None,
|
||||||
|
enrich: bool = False,
|
||||||
|
commit: bool = True,
|
||||||
|
) -> "Track":
|
||||||
|
"""Given a name, try to find the track by the artist from Musicbrainz.
|
||||||
|
|
||||||
"""
|
As a basic conceit we trust the source for giving us the track and artist
|
||||||
if not artist_dict.get("name") or not artist_dict.get(
|
name
|
||||||
"musicbrainz_id"
|
|
||||||
):
|
Optionally, we can update any found artists with overwrite."""
|
||||||
logger.warning(
|
album = None
|
||||||
f"No artist or artist musicbrainz ID found in message from source, not scrobbling"
|
if album_name:
|
||||||
|
logger.info("Looking up album for: {album_name}")
|
||||||
|
album = Album.find_or_create(
|
||||||
|
name=album_name, artist_name=artist_name
|
||||||
)
|
)
|
||||||
return
|
artist = album.album_artist
|
||||||
|
else:
|
||||||
|
artist = Artist.find_or_create(artist_name, track_name=title)
|
||||||
|
if not artist:
|
||||||
|
artist = Artist.find_or_create(artist_name)
|
||||||
|
|
||||||
artist, artist_created = Artist.objects.get_or_create(**artist_dict)
|
lookup_keys = {"title": title, "artist": artist}
|
||||||
album, album_created = Album.objects.get_or_create(**album_dict)
|
if run_time_seconds:
|
||||||
|
lookup_keys["run_time_seconds"] = run_time_seconds
|
||||||
|
logger.info(f"Looking up track using: {lookup_keys}")
|
||||||
|
track = cls.objects.filter(**lookup_keys).first()
|
||||||
|
if track:
|
||||||
|
logger.info(
|
||||||
|
"Found match for track by name and artist, not going to musicbrainz ",
|
||||||
|
extra={
|
||||||
|
"track_id": track.id,
|
||||||
|
"title": title,
|
||||||
|
"artist_name": artist_name,
|
||||||
|
"run_time_seconds": run_time_seconds,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return track
|
||||||
|
|
||||||
album.fix_metadata()
|
track = cls.objects.filter(title=title, artist=artist).first()
|
||||||
if not album.cover_image:
|
if not track:
|
||||||
album.fetch_artwork()
|
track, _ = cls.objects.get_or_create(title=title, artist=artist)
|
||||||
|
|
||||||
track_dict["album_id"] = getattr(album, "id", None)
|
if album:
|
||||||
track_dict["artist_id"] = artist.id
|
track.albums.add(album)
|
||||||
|
|
||||||
track, created = cls.objects.get_or_create(**track_dict)
|
if enrich or not track.run_time_seconds:
|
||||||
|
logger.info(
|
||||||
|
f"Enriching track {track}",
|
||||||
|
extra={
|
||||||
|
"title": title,
|
||||||
|
"artist_name": artist_name,
|
||||||
|
"track_id": track.id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
mbid, length = get_recording_mbid_exact(
|
||||||
|
title, artist_name, album_name
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
print("No musicbrainz result found, cannot enrich")
|
||||||
|
return track
|
||||||
|
track.run_time_seconds = run_time_seconds or int(length / 1000)
|
||||||
|
track.musicbrainz_id = mbid
|
||||||
|
if commit:
|
||||||
|
track.save()
|
||||||
|
|
||||||
return track
|
return track
|
||||||
|
|
||||||
|
def fix_metadata(self, force_update=False):
|
||||||
|
|
||||||
|
...
|
||||||
|
|||||||
@ -1,16 +1,17 @@
|
|||||||
|
from datetime import datetime
|
||||||
import logging
|
import logging
|
||||||
from typing import Iterable
|
|
||||||
|
|
||||||
import musicbrainzngs
|
import musicbrainzngs
|
||||||
from dateutil.parser import parse
|
from dateutil.parser import parse
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
musicbrainzngs.set_useragent("Vrobbler", "1.0", "help@unbl.ink")
|
||||||
|
|
||||||
|
|
||||||
def lookup_album_from_mb(musicbrainz_id: str) -> dict:
|
def lookup_album_from_mb(musicbrainz_id: str) -> dict:
|
||||||
release_dict = {}
|
release_dict = {}
|
||||||
|
|
||||||
musicbrainzngs.set_useragent("vrobbler", "0.3.0")
|
|
||||||
release_data = musicbrainzngs.get_release_by_id(
|
release_data = musicbrainzngs.get_release_by_id(
|
||||||
musicbrainz_id,
|
musicbrainz_id,
|
||||||
includes=["artists", "release-groups", "recordings"],
|
includes=["artists", "release-groups", "recordings"],
|
||||||
@ -51,7 +52,6 @@ def lookup_album_from_mb(musicbrainz_id: str) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def lookup_album_dict_from_mb(release_name: str, artist_name: str) -> dict:
|
def lookup_album_dict_from_mb(release_name: str, artist_name: str) -> dict:
|
||||||
musicbrainzngs.set_useragent("vrobbler", "0.3.0")
|
|
||||||
|
|
||||||
top_result = {}
|
top_result = {}
|
||||||
|
|
||||||
@ -84,7 +84,6 @@ def lookup_album_dict_from_mb(release_name: str, artist_name: str) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def lookup_artist_from_mb(artist_name: str) -> dict:
|
def lookup_artist_from_mb(artist_name: str) -> dict:
|
||||||
musicbrainzngs.set_useragent("vrobbler", "0.3.0")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
top_result = musicbrainzngs.search_artists(artist=artist_name)[
|
top_result = musicbrainzngs.search_artists(artist=artist_name)[
|
||||||
@ -104,7 +103,7 @@ def lookup_artist_from_mb(artist_name: str) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def lookup_track_from_mb(
|
def lookup_track_from_mb(
|
||||||
track_name: str, artist_mb_id: str, album_mb_id: str
|
track_name: str, artist_mb_id: str, album_mb_id: str = ""
|
||||||
) -> dict:
|
) -> dict:
|
||||||
logger.info(
|
logger.info(
|
||||||
"[lookup_track_from_mb] called",
|
"[lookup_track_from_mb] called",
|
||||||
@ -114,7 +113,6 @@ def lookup_track_from_mb(
|
|||||||
"album_mb_id": album_mb_id,
|
"album_mb_id": album_mb_id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
musicbrainzngs.set_useragent("vrobbler", "0.3.0")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
results = musicbrainzngs.search_recordings(
|
results = musicbrainzngs.search_recordings(
|
||||||
@ -138,3 +136,352 @@ def lookup_track_from_mb(
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
return top_result
|
return top_result
|
||||||
|
|
||||||
|
|
||||||
|
def get_album_metadata(album_name, artist_name, strict=True) -> dict:
|
||||||
|
"""
|
||||||
|
Get detailed metadata for an album from MusicBrainz.
|
||||||
|
|
||||||
|
:param album_name: Name of the album
|
||||||
|
:param artist_name: Name of the artist
|
||||||
|
:param strict: If True, only exact matches on album and artist (case-insensitive)
|
||||||
|
:return: dict with album metadata, or None if not found
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = musicbrainzngs.search_releases(
|
||||||
|
release=album_name, artist=artist_name, limit=5
|
||||||
|
)
|
||||||
|
|
||||||
|
for release in result.get("release-list", []):
|
||||||
|
title = release["title"]
|
||||||
|
primary_artist = release["artist-credit"][0]["artist"]["name"]
|
||||||
|
|
||||||
|
title_match = title.lower() == album_name.lower()
|
||||||
|
artist_match = primary_artist.lower() == artist_name.lower()
|
||||||
|
|
||||||
|
if not strict or (title_match and artist_match):
|
||||||
|
all_artists = [
|
||||||
|
ac["artist"]["name"]
|
||||||
|
for ac in release["artist-credit"]
|
||||||
|
if isinstance(ac, dict) and "artist" in ac
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"album_title": title,
|
||||||
|
"primary_artist": primary_artist,
|
||||||
|
"all_artists": all_artists,
|
||||||
|
"mbid": release["id"],
|
||||||
|
"release_date": release.get(
|
||||||
|
"date"
|
||||||
|
), # May be partial (e.g., just year)
|
||||||
|
"release_group_mbid": release["release-group"]["id"],
|
||||||
|
}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
|
||||||
|
except musicbrainzngs.WebServiceError as e:
|
||||||
|
print("MusicBrainz error:", e)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_recording_mbid_exact(
|
||||||
|
track_title: str, artist_name: str, album_name: str
|
||||||
|
) -> tuple[str, int]:
|
||||||
|
try:
|
||||||
|
result = musicbrainzngs.search_releases(
|
||||||
|
artist=artist_name, release=album_name, limit=1
|
||||||
|
)
|
||||||
|
releases = result.get("release-list", [])
|
||||||
|
if not releases:
|
||||||
|
raise Exception("No releases found")
|
||||||
|
|
||||||
|
release_id = releases[0]["id"]
|
||||||
|
|
||||||
|
release_data = musicbrainzngs.get_release_by_id(
|
||||||
|
release_id, includes=["recordings"]
|
||||||
|
)
|
||||||
|
tracks = release_data["release"]["medium-list"][0]["track-list"]
|
||||||
|
|
||||||
|
for track in tracks:
|
||||||
|
if track["recording"]["title"].lower() == track_title.lower():
|
||||||
|
return track["recording"]["id"], int(
|
||||||
|
track["recording"]["length"]
|
||||||
|
)
|
||||||
|
|
||||||
|
raise Exception("No recording found")
|
||||||
|
except musicbrainzngs.WebServiceError as e:
|
||||||
|
print(f"MusicBrainz error: {e}")
|
||||||
|
raise Exception(e)
|
||||||
|
|
||||||
|
|
||||||
|
def get_artist_metadata_extended(artist_name, strict=True):
|
||||||
|
"""
|
||||||
|
Fetch artist metadata including MBID, name, origin, tags, and description.
|
||||||
|
|
||||||
|
:param artist_name: The artist's name
|
||||||
|
:param strict: If True, only return exact name match
|
||||||
|
:return: dict with metadata, or None if not found
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Step 1: Search for artist
|
||||||
|
search_results = musicbrainzngs.search_artists(
|
||||||
|
artist=artist_name, limit=5
|
||||||
|
)
|
||||||
|
for artist in search_results.get("artist-list", []):
|
||||||
|
if not strict or artist["name"].lower() == artist_name.lower():
|
||||||
|
mbid = artist["id"]
|
||||||
|
|
||||||
|
# Step 2: Get detailed info about the artist
|
||||||
|
details = musicbrainzngs.get_artist_by_id(
|
||||||
|
mbid, includes=["tags", "url-rels"]
|
||||||
|
)["artist"]
|
||||||
|
|
||||||
|
begin_date = details.get("life-span", {}).get("begin")
|
||||||
|
area = details.get("area", {}).get("name")
|
||||||
|
disambiguation = details.get("disambiguation")
|
||||||
|
tags = [t["name"] for t in details.get("tag-list", [])]
|
||||||
|
|
||||||
|
# Step 3: Try to find a Wikipedia or Wikidata link
|
||||||
|
description_url = None
|
||||||
|
for rel in details.get("url-relation-list", []):
|
||||||
|
if rel["type"] == "wikipedia":
|
||||||
|
description_url = rel["target"]
|
||||||
|
break
|
||||||
|
elif rel["type"] == "wikidata":
|
||||||
|
description_url = rel["target"]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"mbid": mbid,
|
||||||
|
"name": details["name"],
|
||||||
|
"disambiguation": disambiguation,
|
||||||
|
"begin_date": begin_date,
|
||||||
|
"area": area,
|
||||||
|
"tags": tags,
|
||||||
|
"description_url": description_url, # user can fetch summary if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
return None
|
||||||
|
except musicbrainzngs.WebServiceError as e:
|
||||||
|
print("MusicBrainz error:", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_artist_metadata_brief(artist_id):
|
||||||
|
"""Fetch basic artist metadata by MBID."""
|
||||||
|
try:
|
||||||
|
details = musicbrainzngs.get_artist_by_id(
|
||||||
|
artist_id, includes=["tags", "aliases", "url-rels"]
|
||||||
|
)["artist"]
|
||||||
|
|
||||||
|
begin_date = details.get("life-span", {}).get("begin")
|
||||||
|
area = details.get("area", {}).get("name")
|
||||||
|
disambiguation = details.get("disambiguation")
|
||||||
|
tags = [t["name"] for t in details.get("tag-list", [])]
|
||||||
|
|
||||||
|
description_url = None
|
||||||
|
for rel in details.get("url-relation-list", []):
|
||||||
|
if rel["type"] == "wikipedia":
|
||||||
|
description_url = rel["target"]
|
||||||
|
break
|
||||||
|
elif rel["type"] == "wikidata" and not description_url:
|
||||||
|
description_url = rel["target"]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"mbid": artist_id,
|
||||||
|
"name": details["name"],
|
||||||
|
"disambiguation": disambiguation,
|
||||||
|
"begin_date": begin_date,
|
||||||
|
"area": area,
|
||||||
|
"tags": tags,
|
||||||
|
"description_url": description_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
except musicbrainzngs.WebServiceError as e:
|
||||||
|
print("MusicBrainz error (artist lookup):", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_date(date_str):
|
||||||
|
"""Parse MusicBrainz date format into sortable datetime object."""
|
||||||
|
if not date_str:
|
||||||
|
return None
|
||||||
|
for fmt in ("%Y-%m-%d", "%Y-%m", "%Y"):
|
||||||
|
try:
|
||||||
|
return datetime.strptime(date_str, fmt)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_album_metadata_with_artist(album_name, artist_name, strict=True):
|
||||||
|
"""
|
||||||
|
Get metadata for the earliest release of an album and its primary artist.
|
||||||
|
|
||||||
|
:param album_name: Album title
|
||||||
|
:param artist_name: Name of the artist
|
||||||
|
:param strict: If True, enforce exact match for album and artist
|
||||||
|
:return: dict with album and primary artist metadata
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = musicbrainzngs.search_releases(
|
||||||
|
release=album_name, artist=artist_name, limit=100
|
||||||
|
)
|
||||||
|
|
||||||
|
query_album = album_name.strip().casefold()
|
||||||
|
query_artist = artist_name.strip().casefold()
|
||||||
|
|
||||||
|
valid_releases = []
|
||||||
|
for release in result.get("release-list", []):
|
||||||
|
release_title = release["title"].strip()
|
||||||
|
primary_artist = release["artist-credit"][0]["artist"]
|
||||||
|
artist_name_actual = primary_artist["name"].strip()
|
||||||
|
|
||||||
|
if strict:
|
||||||
|
if release_title.casefold() != query_album:
|
||||||
|
continue
|
||||||
|
if artist_name_actual.casefold() != query_artist:
|
||||||
|
continue
|
||||||
|
|
||||||
|
release_date = parse_date(release.get("date"))
|
||||||
|
valid_releases.append((release, release_date))
|
||||||
|
|
||||||
|
if not valid_releases:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Sort releases by earliest release date
|
||||||
|
valid_releases.sort(key=lambda x: x[1] or datetime.max)
|
||||||
|
release, _ = valid_releases[0]
|
||||||
|
|
||||||
|
primary_artist = release["artist-credit"][0]["artist"]
|
||||||
|
all_artists = [
|
||||||
|
ac["artist"]["name"]
|
||||||
|
for ac in release["artist-credit"]
|
||||||
|
if "artist" in ac
|
||||||
|
]
|
||||||
|
|
||||||
|
artist_metadata = get_artist_metadata_brief(primary_artist["id"])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"album_title": release["title"],
|
||||||
|
"primary_artist_name": primary_artist["name"],
|
||||||
|
"all_artists": all_artists,
|
||||||
|
"mbid": release["id"],
|
||||||
|
"release_group_mbid": release["release-group"]["id"],
|
||||||
|
"release_date": release.get("date"),
|
||||||
|
"primary_artist": artist_metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
except musicbrainzngs.WebServiceError as e:
|
||||||
|
print("MusicBrainz error (album lookup):", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_artist_metadata_brief(artist_id):
|
||||||
|
try:
|
||||||
|
details = musicbrainzngs.get_artist_by_id(
|
||||||
|
artist_id, includes=["tags", "aliases", "url-rels"]
|
||||||
|
)["artist"]
|
||||||
|
|
||||||
|
begin_date = details.get("life-span", {}).get("begin")
|
||||||
|
area = details.get("area", {}).get("name")
|
||||||
|
disambiguation = details.get("disambiguation")
|
||||||
|
tags = [t["name"] for t in details.get("tag-list", [])]
|
||||||
|
|
||||||
|
description_url = None
|
||||||
|
for rel in details.get("url-relation-list", []):
|
||||||
|
if rel["type"] == "wikipedia":
|
||||||
|
description_url = rel["target"]
|
||||||
|
break
|
||||||
|
elif rel["type"] == "wikidata" and not description_url:
|
||||||
|
description_url = rel["target"]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"mbid": artist_id,
|
||||||
|
"name": details["name"],
|
||||||
|
"disambiguation": disambiguation,
|
||||||
|
"begin_date": begin_date,
|
||||||
|
"area": area,
|
||||||
|
"tags": tags,
|
||||||
|
"description_url": description_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
except musicbrainzngs.WebServiceError as e:
|
||||||
|
print("MusicBrainz error (artist lookup):", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_track_metadata_with_artist(track_title, artist_name, strict=True):
|
||||||
|
"""
|
||||||
|
Get metadata for the earliest-known recording of a track, including artist info.
|
||||||
|
|
||||||
|
:param track_title: Track title
|
||||||
|
:param artist_name: Artist name
|
||||||
|
:param strict: If True, match exactly (case-insensitive)
|
||||||
|
:return: dict with track + release + artist metadata
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = musicbrainzngs.search_recordings(
|
||||||
|
recording=track_title, artist=artist_name, limit=100
|
||||||
|
)
|
||||||
|
|
||||||
|
query_track = track_title.strip().casefold()
|
||||||
|
query_artist = artist_name.strip().casefold()
|
||||||
|
|
||||||
|
valid_candidates = []
|
||||||
|
|
||||||
|
for recording in result.get("recording-list", []):
|
||||||
|
rec_title = recording["title"].strip()
|
||||||
|
artist_credit = recording["artist-credit"][0]["artist"]
|
||||||
|
artist_name_actual = artist_credit["name"].strip()
|
||||||
|
|
||||||
|
if strict:
|
||||||
|
if rec_title.casefold() != query_track:
|
||||||
|
continue
|
||||||
|
if artist_name_actual.casefold() != query_artist:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if "release-list" not in recording:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for release in recording["release-list"]:
|
||||||
|
release_date = parse_date(release.get("date"))
|
||||||
|
if release_date:
|
||||||
|
valid_candidates.append(
|
||||||
|
(recording["id"], release, release_date)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not valid_candidates:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Pick the earliest release
|
||||||
|
valid_candidates.sort(key=lambda x: x[2])
|
||||||
|
recording_id, release, _ = valid_candidates[0]
|
||||||
|
|
||||||
|
# Fetch full recording info
|
||||||
|
full_recording = musicbrainzngs.get_recording_by_id(
|
||||||
|
recording_id, includes=["artists", "releases"]
|
||||||
|
)["recording"]
|
||||||
|
|
||||||
|
primary_artist = full_recording["artist-credit"][0]["artist"]
|
||||||
|
all_artists = [
|
||||||
|
ac["artist"]["name"]
|
||||||
|
for ac in full_recording["artist-credit"]
|
||||||
|
if "artist" in ac
|
||||||
|
]
|
||||||
|
artist_metadata = get_artist_metadata_brief(primary_artist["id"])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"track_title": full_recording["title"],
|
||||||
|
"length_ms": full_recording.get("length"),
|
||||||
|
"recording_mbid": recording_id,
|
||||||
|
"release_title": release["title"],
|
||||||
|
"release_date": release.get("date"),
|
||||||
|
"release_group_mbid": release["release-group"]["id"],
|
||||||
|
"primary_artist_name": primary_artist["name"],
|
||||||
|
"all_artists": all_artists,
|
||||||
|
"primary_artist": artist_metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
except musicbrainzngs.WebServiceError as e:
|
||||||
|
print("MusicBrainz error (track lookup):", e)
|
||||||
|
return None
|
||||||
|
|||||||
@ -1,150 +1,113 @@
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from music.musicbrainz import (
|
from django.db import IntegrityError, models, transaction
|
||||||
lookup_album_dict_from_mb,
|
|
||||||
lookup_artist_from_mb,
|
|
||||||
lookup_track_from_mb,
|
|
||||||
)
|
|
||||||
from music.constants import VARIOUS_ARTIST_DICT
|
from music.constants import VARIOUS_ARTIST_DICT
|
||||||
from scrobbles.utils import convert_to_seconds
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
from music.models import Album, Artist, Track
|
def clean_artist_name(name: str) -> str:
|
||||||
|
"""Remove featured names from artist string."""
|
||||||
|
if " feat. " in name.lower():
|
||||||
def get_or_create_artist(name: str, mbid: str = None) -> Artist:
|
|
||||||
artist = None
|
|
||||||
|
|
||||||
if "feat." in name.lower():
|
|
||||||
name = re.split("feat.", name, flags=re.IGNORECASE)[0].strip()
|
name = re.split("feat.", name, flags=re.IGNORECASE)[0].strip()
|
||||||
if "featuring" in name.lower():
|
if " w. " in name.lower():
|
||||||
|
name = re.split("feat.", name, flags=re.IGNORECASE)[0].strip()
|
||||||
|
if " featuring " in name.lower():
|
||||||
name = re.split("featuring", name, flags=re.IGNORECASE)[0].strip()
|
name = re.split("featuring", name, flags=re.IGNORECASE)[0].strip()
|
||||||
if "&" in name.lower():
|
# if " & " in name.lower() and "of the wand" not in name.lower():
|
||||||
name = re.split("&", name, flags=re.IGNORECASE)[0].strip()
|
# name = re.split("&", name, flags=re.IGNORECASE)[0].strip()
|
||||||
|
|
||||||
artist_dict = lookup_artist_from_mb(name)
|
return name
|
||||||
mbid = mbid or artist_dict.get("id", None)
|
|
||||||
|
|
||||||
if mbid:
|
|
||||||
artist = Artist.objects.filter(musicbrainz_id=mbid).first()
|
|
||||||
if not artist:
|
|
||||||
artist = Artist.objects.create(name=name, musicbrainz_id=mbid)
|
|
||||||
artist.fix_metadata()
|
|
||||||
|
|
||||||
return artist
|
|
||||||
|
|
||||||
|
|
||||||
def get_or_create_album(
|
def get_or_create_various_artists() -> "Artist":
|
||||||
name: str, artist: Artist, mbid: str = None
|
from music.models import Artist
|
||||||
) -> Optional[Album]:
|
|
||||||
album = None
|
|
||||||
album_dict = lookup_album_dict_from_mb(name, artist_name=artist.name)
|
|
||||||
|
|
||||||
name = name or album_dict.get("title", None)
|
|
||||||
if not name:
|
|
||||||
logger.debug(
|
|
||||||
f"Cannot get or create album by {artist} with no name ({name})"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
album = Album.objects.filter(
|
|
||||||
musicbrainz_id=mbid, name=name, artists__in=[artist]
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if not album:
|
|
||||||
mbid_group = album_dict.get("mb_group_id")
|
|
||||||
album = Album.objects.filter(
|
|
||||||
musicbrainz_releasegroup_id=mbid_group
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if not album and name:
|
|
||||||
mbid = mbid or album_dict["mb_id"]
|
|
||||||
album, album_created = Album.objects.get_or_create(musicbrainz_id=mbid)
|
|
||||||
if album_created:
|
|
||||||
album.name = name
|
|
||||||
album.year = album_dict["year"]
|
|
||||||
album.musicbrainz_releasegroup_id = album_dict["mb_group_id"]
|
|
||||||
album.musicbrainz_albumartist_id = artist.musicbrainz_id
|
|
||||||
album.save(
|
|
||||||
update_fields=[
|
|
||||||
"name",
|
|
||||||
"musicbrainz_id",
|
|
||||||
"year",
|
|
||||||
"musicbrainz_releasegroup_id",
|
|
||||||
"musicbrainz_albumartist_id",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
album.artists.add(artist)
|
|
||||||
album.fix_album_artist()
|
|
||||||
album.fetch_artwork()
|
|
||||||
album.scrape_allmusic()
|
|
||||||
|
|
||||||
if not album:
|
|
||||||
logger.warn(f"No album found for {name} and {mbid}")
|
|
||||||
|
|
||||||
album.fix_album_artist()
|
|
||||||
return album
|
|
||||||
|
|
||||||
|
|
||||||
def get_or_create_track(post_data: dict, post_keys: dict) -> Track:
|
|
||||||
try:
|
|
||||||
track_run_time_seconds = int(
|
|
||||||
post_data.get(post_keys.get("RUN_TIME"), 0)
|
|
||||||
)
|
|
||||||
except ValueError: # Sometimes we get run time as a string like "01:35"
|
|
||||||
track_run_time_seconds = convert_to_seconds(
|
|
||||||
post_data.get(post_keys.get("RUN_TIME"), 0)
|
|
||||||
)
|
|
||||||
|
|
||||||
artist_name = post_data.get(post_keys.get("ARTIST_NAME"), "")
|
|
||||||
artist_mb_id = post_data.get(post_keys.get("ARTIST_MB_ID"), "")
|
|
||||||
album_title = post_data.get(post_keys.get("ALBUM_NAME"), "")
|
|
||||||
album_mb_id = post_data.get(post_keys.get("ALBUM_MB_ID"), "")
|
|
||||||
track_title = post_data.get(post_keys.get("TRACK_TITLE"), "")
|
|
||||||
track_mb_id = post_data.get(post_keys.get("TRACK_MB_ID"), "")
|
|
||||||
|
|
||||||
artist = get_or_create_artist(
|
|
||||||
artist_name,
|
|
||||||
mbid=artist_mb_id,
|
|
||||||
)
|
|
||||||
album = get_or_create_album(
|
|
||||||
album_title,
|
|
||||||
artist=artist,
|
|
||||||
mbid=album_mb_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
track = None
|
|
||||||
if not track_mb_id and album:
|
|
||||||
try:
|
|
||||||
track_mb_id = lookup_track_from_mb(
|
|
||||||
track_title,
|
|
||||||
artist.musicbrainz_id,
|
|
||||||
album.musicbrainz_id,
|
|
||||||
).get("id", 0)
|
|
||||||
except TypeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if track_mb_id:
|
|
||||||
track = Track.objects.filter(musicbrainz_id=track_mb_id).first()
|
|
||||||
|
|
||||||
if not track:
|
|
||||||
track = Track.objects.create(
|
|
||||||
title=track_title,
|
|
||||||
artist=artist,
|
|
||||||
album=album,
|
|
||||||
musicbrainz_id=track_mb_id,
|
|
||||||
run_time_seconds=track_run_time_seconds,
|
|
||||||
)
|
|
||||||
return track
|
|
||||||
|
|
||||||
|
|
||||||
def get_or_create_various_artists():
|
|
||||||
artist = Artist.objects.filter(name="Various Artists").first()
|
artist = Artist.objects.filter(name="Various Artists").first()
|
||||||
if not artist:
|
if not artist:
|
||||||
artist = Artist.objects.create(**VARIOUS_ARTIST_DICT)
|
artist = Artist.objects.create(**VARIOUS_ARTIST_DICT)
|
||||||
logger.info("Created Various Artists placeholder")
|
logger.info("Created Various Artists placeholder")
|
||||||
return artist
|
return artist
|
||||||
|
|
||||||
|
|
||||||
|
def deduplicate_tracks(commit=False) -> int:
|
||||||
|
from music.models import Track
|
||||||
|
|
||||||
|
duplicates = (
|
||||||
|
Track.objects.values("artist", "title")
|
||||||
|
.annotate(dup_count=models.Count("id"))
|
||||||
|
.filter(dup_count__gt=1)
|
||||||
|
)
|
||||||
|
|
||||||
|
query = models.Q()
|
||||||
|
for dup in duplicates:
|
||||||
|
query |= models.Q(artist=dup["artist"], title=dup["title"])
|
||||||
|
|
||||||
|
duplicate_tracks = Track.objects.filter(query)
|
||||||
|
|
||||||
|
for b in duplicate_tracks:
|
||||||
|
tracks = Track.objects.filter(artist=b.artist, title=b.title)
|
||||||
|
first = tracks.first()
|
||||||
|
for other in tracks.exclude(id=first.id):
|
||||||
|
print("Moving scrobbles for", other.id, " to ", first.id)
|
||||||
|
if commit:
|
||||||
|
with transaction.atomic():
|
||||||
|
other.scrobble_set.update(track=first)
|
||||||
|
print("deleting ", other.id, " - ", other)
|
||||||
|
try:
|
||||||
|
other.delete()
|
||||||
|
except IntegrityError as e:
|
||||||
|
print(
|
||||||
|
"could not delete ",
|
||||||
|
other.id,
|
||||||
|
f": IntegrityError {e}",
|
||||||
|
)
|
||||||
|
return len(duplicate_tracks)
|
||||||
|
|
||||||
|
|
||||||
|
def condense_albums(commit: bool = False):
|
||||||
|
from music.models import Track
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
|
processed_ids = []
|
||||||
|
for track in Track.objects.all():
|
||||||
|
albums_to_add = []
|
||||||
|
duplicates = (
|
||||||
|
Track.objects.filter(title=track.title, artist=track.artist)
|
||||||
|
.exclude(id=track.id)
|
||||||
|
.exclude(id__in=processed_ids)
|
||||||
|
)
|
||||||
|
|
||||||
|
if commit and track.album:
|
||||||
|
albums_to_add.append(track.album)
|
||||||
|
|
||||||
|
for dup_track in duplicates:
|
||||||
|
logger.info(f"Adding {dup_track.album} to {track} albums")
|
||||||
|
if commit and dup_track.album:
|
||||||
|
track.albums.add(dup_track.album)
|
||||||
|
|
||||||
|
# Find out if this track appears more than once
|
||||||
|
duplicates = Track.objects.filter(
|
||||||
|
title=track.title, artist=track.artist
|
||||||
|
)
|
||||||
|
if duplicates.count() > 1:
|
||||||
|
logger.info(f"Track appears more than once, condensing: {track}")
|
||||||
|
|
||||||
|
albums_to_add.extend([d.album for d in duplicates])
|
||||||
|
# Find all scrobbles
|
||||||
|
duplicate_ids = duplicates.values_list("id", flat=True)
|
||||||
|
scrobbles = Scrobble.objects.filter(track_id__in=duplicate_ids)
|
||||||
|
logger.info(
|
||||||
|
f"Found {scrobbles.count()} scrobbles to merge onto {track}"
|
||||||
|
)
|
||||||
|
if commit:
|
||||||
|
scrobbles.update(track=track)
|
||||||
|
track.albums.add(*list(set(albums_to_add)))
|
||||||
|
|
||||||
|
processed_ids.extend(duplicate_ids)
|
||||||
|
|
||||||
|
if commit:
|
||||||
|
Track.objects.filter(scrobble__isnull=True).delete()
|
||||||
|
|
||||||
|
return len(set(processed_ids))
|
||||||
|
|||||||
0
vrobbler/apps/people/__init__.py
Normal file
0
vrobbler/apps/people/__init__.py
Normal file
10
vrobbler/apps/people/admin.py
Normal file
10
vrobbler/apps/people/admin.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from people.models import Person
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Person)
|
||||||
|
class PersonAdmin(admin.ModelAdmin):
|
||||||
|
date_hierarchy = "created"
|
||||||
|
list_display = ("name", "bgg_username", "bgstats_id")
|
||||||
|
ordering = ("-created",)
|
||||||
|
search_fields = ("name",)
|
||||||
74
vrobbler/apps/people/migrations/0001_initial.py
Normal file
74
vrobbler/apps/people/migrations/0001_initial.py
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
# Generated by Django 4.2.19 on 2025-07-02 14:59
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django_extensions.db.fields
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Person",
|
||||||
|
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"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"name",
|
||||||
|
models.CharField(blank=True, max_length=100, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"bgstat_id",
|
||||||
|
models.CharField(blank=True, max_length=100, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"bgg_username",
|
||||||
|
models.CharField(blank=True, max_length=100, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"lichess_username",
|
||||||
|
models.CharField(blank=True, max_length=100, null=True),
|
||||||
|
),
|
||||||
|
("bio", models.TextField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"get_latest_by": "modified",
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
# Generated by Django 4.2.19 on 2025-07-03 02:25
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("people", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="person",
|
||||||
|
name="bgstat_id",
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="person",
|
||||||
|
name="bgstats_id",
|
||||||
|
field=models.UUIDField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
vrobbler/apps/people/migrations/__init__.py
Normal file
0
vrobbler/apps/people/migrations/__init__.py
Normal file
17
vrobbler/apps/people/models.py
Normal file
17
vrobbler/apps/people/models.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db import models
|
||||||
|
from django_extensions.db.models import TimeStampedModel
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
BNULL = {"blank": True, "null": True}
|
||||||
|
|
||||||
|
|
||||||
|
class Person(TimeStampedModel):
|
||||||
|
"""A non-system user model that can be optionally associated with a User."""
|
||||||
|
|
||||||
|
user = models.ForeignKey(User, on_delete=models.DO_NOTHING, **BNULL)
|
||||||
|
name = models.CharField(max_length=100, **BNULL)
|
||||||
|
bgstats_id = models.UUIDField(**BNULL)
|
||||||
|
bgg_username = models.CharField(max_length=100, **BNULL)
|
||||||
|
lichess_username = models.CharField(max_length=100, **BNULL)
|
||||||
|
bio = models.TextField(**BNULL)
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.16 on 2025-01-22 03:55
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("podcasts", "0013_rename_episode_podcastepisode"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="podcastepisode",
|
||||||
|
name="run_time_seconds",
|
||||||
|
field=models.IntegerField(default=900),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
# Generated by Django 4.2.19 on 2025-04-07 17:16
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("podcasts", "0014_alter_podcastepisode_run_time_seconds"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="podcast",
|
||||||
|
name="google_podcasts_url",
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="podcast",
|
||||||
|
name="dead_date",
|
||||||
|
field=models.DateField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="podcast",
|
||||||
|
name="itunes_id",
|
||||||
|
field=models.TextField(blank=True, max_length=15, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="podcast",
|
||||||
|
name="null",
|
||||||
|
field=models.CharField(
|
||||||
|
default="", max_length=150, verbose_name="blank"
|
||||||
|
),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="podcast",
|
||||||
|
name="site_link",
|
||||||
|
field=models.URLField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
28
vrobbler/apps/podcasts/migrations/0016_podcast_genre.py
Normal file
28
vrobbler/apps/podcasts/migrations/0016_podcast_genre.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 4.2.19 on 2025-04-07 17:18
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
import taggit.managers
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("scrobbles", "0068_scrobble_paper_alter_scrobble_media_type"),
|
||||||
|
(
|
||||||
|
"podcasts",
|
||||||
|
"0015_remove_podcast_google_podcasts_url_podcast_dead_date_and_more",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="podcast",
|
||||||
|
name="genre",
|
||||||
|
field=taggit.managers.TaggableManager(
|
||||||
|
help_text="A comma-separated list of tags.",
|
||||||
|
through="scrobbles.ObjectWithGenres",
|
||||||
|
to="scrobbles.Genre",
|
||||||
|
verbose_name="Tags",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.19 on 2025-04-07 17:29
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("podcasts", "0016_podcast_genre"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="podcast",
|
||||||
|
name="podcastindex_id",
|
||||||
|
field=models.CharField(blank=True, max_length=100, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -1,5 +1,4 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Dict, Optional
|
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
@ -10,8 +9,13 @@ from django.db import models
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_extensions.db.models import TimeStampedModel
|
from django_extensions.db.models import TimeStampedModel
|
||||||
from podcasts.scrapers import scrape_data_from_google_podcasts
|
from podcasts.sources.podcastindex import lookup_podcast_from_podcastindex
|
||||||
from scrobbles.mixins import ScrobblableMixin
|
from scrobbles.mixins import (
|
||||||
|
ObjectWithGenres,
|
||||||
|
ScrobblableConstants,
|
||||||
|
ScrobblableMixin,
|
||||||
|
)
|
||||||
|
from taggit.managers import TaggableManager
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
BNULL = {"blank": True, "null": True}
|
BNULL = {"blank": True, "null": True}
|
||||||
@ -24,6 +28,13 @@ class Producer(TimeStampedModel):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.name}"
|
return f"{self.name}"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def find_or_create(cls, name):
|
||||||
|
producer = cls.objects.filter(name__iexact=name).first()
|
||||||
|
if not producer:
|
||||||
|
producer = cls.objects.create(name=name)
|
||||||
|
return producer
|
||||||
|
|
||||||
|
|
||||||
class Podcast(TimeStampedModel):
|
class Podcast(TimeStampedModel):
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
@ -31,11 +42,17 @@ class Podcast(TimeStampedModel):
|
|||||||
producer = models.ForeignKey(
|
producer = models.ForeignKey(
|
||||||
Producer, on_delete=models.DO_NOTHING, **BNULL
|
Producer, on_delete=models.DO_NOTHING, **BNULL
|
||||||
)
|
)
|
||||||
|
podcastindex_id = models.CharField(max_length=100, **BNULL)
|
||||||
|
owner = models.CharField(max_length=150, *BNULL)
|
||||||
description = models.TextField(**BNULL)
|
description = models.TextField(**BNULL)
|
||||||
active = models.BooleanField(default=True)
|
active = models.BooleanField(default=True)
|
||||||
feed_url = models.URLField(**BNULL)
|
feed_url = models.URLField(**BNULL)
|
||||||
google_podcasts_url = models.URLField(**BNULL)
|
site_link = models.URLField(**BNULL)
|
||||||
|
description = models.TextField(**BNULL)
|
||||||
cover_image = models.ImageField(upload_to="podcasts/covers/", **BNULL)
|
cover_image = models.ImageField(upload_to="podcasts/covers/", **BNULL)
|
||||||
|
itunes_id = models.TextField(max_length=15, **BNULL)
|
||||||
|
dead_date = models.DateField(**BNULL)
|
||||||
|
genre = TaggableManager(through=ObjectWithGenres)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.name}"
|
return f"{self.name}"
|
||||||
@ -49,32 +66,43 @@ class Podcast(TimeStampedModel):
|
|||||||
user=user, podcast_episode__podcast=self
|
user=user, podcast_episode__podcast=self
|
||||||
).order_by("-timestamp")
|
).order_by("-timestamp")
|
||||||
|
|
||||||
def scrape_google_podcasts(self, force=False):
|
@property
|
||||||
podcast_dict = {}
|
def itunes_link(self) -> str:
|
||||||
if not self.cover_image or force:
|
if not self.itunes_id:
|
||||||
podcast_dict = scrape_data_from_google_podcasts(self.name)
|
return ""
|
||||||
if podcast_dict:
|
return f"https://podcasts.apple.com/us/podcast/id{self.itunes_id}"
|
||||||
if not self.producer:
|
|
||||||
self.producer, created = Producer.objects.get_or_create(
|
def fix_metadata(self, force=False):
|
||||||
name=podcast_dict["producer"]
|
if self.podcastindex_id and not force:
|
||||||
)
|
logger.warning(
|
||||||
self.description = podcast_dict.get("description")
|
"Podcast already has PodcastIndex ID, use force=True to overwrite"
|
||||||
self.google_podcasts_url = podcast_dict.get("google_url")
|
)
|
||||||
self.save(
|
return
|
||||||
update_fields=[
|
|
||||||
"description",
|
podcast_dict = lookup_podcast_from_podcastindex(self.name)
|
||||||
"producer",
|
|
||||||
"google_podcasts_url",
|
if not podcast_dict:
|
||||||
]
|
logger.info(
|
||||||
)
|
"No podcast data found from PodcastIndex. Are credentials setup?"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
genres = podcast_dict.pop("genres")
|
||||||
|
if genres:
|
||||||
|
self.genre.add(*genres)
|
||||||
|
|
||||||
|
cover_url = podcast_dict.pop("image_url")
|
||||||
|
|
||||||
cover_url = podcast_dict.get("image_url")
|
|
||||||
if (not self.cover_image or force) and cover_url:
|
if (not self.cover_image or force) and cover_url:
|
||||||
r = requests.get(cover_url)
|
r = requests.get(cover_url)
|
||||||
if r.status_code == 200:
|
if r.status_code == 200:
|
||||||
fname = f"{self.name}_{self.uuid}.jpg"
|
fname = f"{self.name}_{self.uuid}.jpg"
|
||||||
self.cover_image.save(fname, ContentFile(r.content), save=True)
|
self.cover_image.save(fname, ContentFile(r.content), save=True)
|
||||||
|
|
||||||
|
for attr, value in podcast_dict.items():
|
||||||
|
setattr(self, attr, value)
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
|
||||||
class PodcastEpisode(ScrobblableMixin):
|
class PodcastEpisode(ScrobblableMixin):
|
||||||
COMPLETION_PERCENT = getattr(settings, "PODCAST_COMPLETION_PERCENT", 90)
|
COMPLETION_PERCENT = getattr(settings, "PODCAST_COMPLETION_PERCENT", 90)
|
||||||
@ -84,6 +112,11 @@ class PodcastEpisode(ScrobblableMixin):
|
|||||||
pub_date = models.DateField(**BNULL)
|
pub_date = models.DateField(**BNULL)
|
||||||
mopidy_uri = models.CharField(max_length=255, **BNULL)
|
mopidy_uri = models.CharField(max_length=255, **BNULL)
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse(
|
||||||
|
"podcasts:podcast_detail", kwargs={"slug": self.podcast.uuid}
|
||||||
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.title}"
|
return f"{self.title}"
|
||||||
|
|
||||||
@ -91,6 +124,10 @@ class PodcastEpisode(ScrobblableMixin):
|
|||||||
def subtitle(self):
|
def subtitle(self):
|
||||||
return self.podcast
|
return self.podcast
|
||||||
|
|
||||||
|
@property
|
||||||
|
def strings(self) -> ScrobblableConstants:
|
||||||
|
return ScrobblableConstants(verb="Listening", tags="microphone")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def info_link(self):
|
def info_link(self):
|
||||||
return ""
|
return ""
|
||||||
@ -104,42 +141,45 @@ class PodcastEpisode(ScrobblableMixin):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def find_or_create(
|
def find_or_create(
|
||||||
cls, podcast_dict: Dict, producer_dict: Dict, episode_dict: Dict
|
cls,
|
||||||
) -> Optional["Episode"]:
|
title: str,
|
||||||
|
podcast_name: str,
|
||||||
|
pub_date: str,
|
||||||
|
number: int = 0,
|
||||||
|
mopidy_uri: str = "",
|
||||||
|
producer_name: str = "",
|
||||||
|
run_time_seconds: int = 1800,
|
||||||
|
enrich: bool = True,
|
||||||
|
) -> "PodcastEpisode":
|
||||||
"""Given a data dict from Mopidy, finds or creates a podcast and
|
"""Given a data dict from Mopidy, finds or creates a podcast and
|
||||||
producer before saving the epsiode so it can be scrobbled.
|
producer before saving the epsiode so it can be scrobbled.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if not podcast_dict.get("name"):
|
|
||||||
logger.warning(f"No name from source for podcast, not scrobbling")
|
|
||||||
return
|
|
||||||
|
|
||||||
producer = None
|
producer = None
|
||||||
if producer_dict.get("name"):
|
if producer_name:
|
||||||
producer, producer_created = Producer.objects.get_or_create(
|
producer = Producer.find_or_create(producer_name)
|
||||||
**producer_dict
|
|
||||||
|
podcast = Podcast.objects.filter(
|
||||||
|
name__iexact=podcast_name,
|
||||||
|
).first()
|
||||||
|
if not podcast:
|
||||||
|
podcast = Podcast.objects.create(
|
||||||
|
name=podcast_name, producer=producer
|
||||||
)
|
)
|
||||||
if producer_created:
|
if enrich:
|
||||||
logger.debug(f"Created new producer {producer}")
|
podcast.fix_metadata()
|
||||||
else:
|
|
||||||
logger.debug(f"Found producer {producer}")
|
|
||||||
|
|
||||||
if producer:
|
episode = cls.objects.filter(
|
||||||
podcast_dict["producer_id"] = producer.id
|
title__iexact=title, podcast=podcast
|
||||||
podcast, podcast_created = Podcast.objects.get_or_create(
|
).first()
|
||||||
**podcast_dict
|
if not episode:
|
||||||
)
|
episode = cls.objects.create(
|
||||||
if podcast_created:
|
title=title,
|
||||||
logger.debug(f"Created new podcast {podcast}")
|
podcast=podcast,
|
||||||
else:
|
run_time_seconds=run_time_seconds,
|
||||||
logger.debug(f"Found podcast {podcast}")
|
number=number,
|
||||||
|
pub_date=pub_date,
|
||||||
episode_dict["podcast_id"] = podcast.id
|
mopidy_uri=mopidy_uri,
|
||||||
|
)
|
||||||
episode, created = cls.objects.get_or_create(**episode_dict)
|
|
||||||
if created:
|
|
||||||
logger.debug(f"Created new episode: {episode}")
|
|
||||||
else:
|
|
||||||
logger.debug(f"Found episode {episode}")
|
|
||||||
|
|
||||||
return episode
|
return episode
|
||||||
|
|||||||
75
vrobbler/apps/podcasts/sources/podcastindex.py
Normal file
75
vrobbler/apps/podcasts/sources/podcastindex.py
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import hashlib
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
import requests
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils import timezone
|
||||||
|
from scrobbles.utils import timestamp_user_tz_to_utc
|
||||||
|
|
||||||
|
PODCASTINDEX_API_KEY = getattr(settings, "PODCASTINDEX_API_KEY")
|
||||||
|
PODCASTINDEX_API_SECRET = getattr(settings, "PODCASTINDEX_API_SECRET")
|
||||||
|
|
||||||
|
|
||||||
|
def get_auth_headers():
|
||||||
|
now = int(time.time())
|
||||||
|
hash_data = hashlib.sha1(
|
||||||
|
(PODCASTINDEX_API_KEY + PODCASTINDEX_API_SECRET + str(now)).encode(
|
||||||
|
"utf-8"
|
||||||
|
)
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"User-Agent": "MyPodcastApp/1.0",
|
||||||
|
"X-Auth-Date": str(now),
|
||||||
|
"X-Auth-Key": PODCASTINDEX_API_KEY,
|
||||||
|
"Authorization": hash_data,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_podcast_from_podcastindex(
|
||||||
|
podcast_name: str, dump_raw_response: bool = False
|
||||||
|
) -> dict:
|
||||||
|
url = "https://api.podcastindex.org/api/1.0/search/byterm"
|
||||||
|
headers = get_auth_headers()
|
||||||
|
params = {"q": podcast_name}
|
||||||
|
|
||||||
|
response = requests.get(url, headers=headers, params=params)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
if dump_raw_response:
|
||||||
|
return data.get("feeds")
|
||||||
|
if data.get("feeds"):
|
||||||
|
try:
|
||||||
|
top_feed_dict = data["feeds"][0]
|
||||||
|
|
||||||
|
newest_episode_date = timestamp_user_tz_to_utc(
|
||||||
|
top_feed_dict.get("newestItemPubdate"), pytz.UTC
|
||||||
|
)
|
||||||
|
days_since_last_episode = ()
|
||||||
|
dead_date = None
|
||||||
|
if (timezone.now() - newest_episode_date).days > 180:
|
||||||
|
dead_date = newest_episode_date
|
||||||
|
|
||||||
|
return {
|
||||||
|
"podcastindex_id": top_feed_dict.get("id"),
|
||||||
|
"title": top_feed_dict.get("title"),
|
||||||
|
"site_link": top_feed_dict.get("link"),
|
||||||
|
"description": top_feed_dict.get("description"),
|
||||||
|
"owner": top_feed_dict.get("ownerName"),
|
||||||
|
"image_url": top_feed_dict.get("artwork"),
|
||||||
|
"feed_url": top_feed_dict.get("url"),
|
||||||
|
"itunes_id": top_feed_dict.get("itunesId"),
|
||||||
|
"genres": list(top_feed_dict.get("categories").values()),
|
||||||
|
"dead_date": dead_date,
|
||||||
|
}
|
||||||
|
except IndexError:
|
||||||
|
return {}
|
||||||
|
else:
|
||||||
|
print("No podcasts found.")
|
||||||
|
return {}
|
||||||
|
else:
|
||||||
|
print("Failed to fetch data:", response.status_code, response.text)
|
||||||
|
return {}
|
||||||
@ -11,5 +11,9 @@ class UserProfileAdmin(admin.ModelAdmin):
|
|||||||
"twitch_token",
|
"twitch_token",
|
||||||
"twitch_client_secret",
|
"twitch_client_secret",
|
||||||
"lastfm_password",
|
"lastfm_password",
|
||||||
|
"webdav_pass",
|
||||||
|
"imap_pass",
|
||||||
"archivebox_password",
|
"archivebox_password",
|
||||||
|
"todoist_auth_key",
|
||||||
|
"todoist_state",
|
||||||
)
|
)
|
||||||
|
|||||||
44
vrobbler/apps/profiles/forms.py
Normal file
44
vrobbler/apps/profiles/forms.py
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
from django import forms
|
||||||
|
|
||||||
|
from profiles.models import UserProfile
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileForm(forms.ModelForm):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.request = kwargs.pop("request")
|
||||||
|
self.profile = UserProfile.objects.filter(
|
||||||
|
user=self.request.user
|
||||||
|
).first()
|
||||||
|
if not self.profile:
|
||||||
|
raise Exception
|
||||||
|
super(UserProfileForm, self).__init__(
|
||||||
|
*args, **kwargs, instance=self.profile
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = UserProfile
|
||||||
|
fields = [
|
||||||
|
"timezone",
|
||||||
|
"lastfm_username",
|
||||||
|
"lastfm_password",
|
||||||
|
"lastfm_auto_import",
|
||||||
|
"retroarch_path",
|
||||||
|
"retroarch_auto_import",
|
||||||
|
"archivebox_username",
|
||||||
|
"archivebox_password",
|
||||||
|
"archivebox_url",
|
||||||
|
"bgg_username",
|
||||||
|
"lichess_username",
|
||||||
|
"webdav_url",
|
||||||
|
"webdav_user",
|
||||||
|
"webdav_pass",
|
||||||
|
"webdav_auto_import",
|
||||||
|
"ntfy_url",
|
||||||
|
"ntfy_enabled",
|
||||||
|
"redirect_to_webpage",
|
||||||
|
]
|
||||||
|
widgets = {
|
||||||
|
"lastfm_password": forms.PasswordInput(render_value=True),
|
||||||
|
"archivebox_password": forms.PasswordInput(render_value=True),
|
||||||
|
"webdav_pass": forms.PasswordInput(render_value=True),
|
||||||
|
}
|
||||||
@ -0,0 +1,601 @@
|
|||||||
|
# Generated by Django 4.2.16 on 2024-09-09 16:09
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("profiles", "0015_userprofile_bgg_username"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="timezone",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("Pacific/Midway", "(GMT-1100) Pacific/Midway"),
|
||||||
|
("Pacific/Niue", "(GMT-1100) Pacific/Niue"),
|
||||||
|
("Pacific/Pago_Pago", "(GMT-1100) Pacific/Pago_Pago"),
|
||||||
|
("Pacific/Honolulu", "(GMT-1000) Pacific/Honolulu"),
|
||||||
|
("Pacific/Rarotonga", "(GMT-1000) Pacific/Rarotonga"),
|
||||||
|
("Pacific/Tahiti", "(GMT-1000) Pacific/Tahiti"),
|
||||||
|
("US/Hawaii", "(GMT-1000) US/Hawaii"),
|
||||||
|
("Pacific/Marquesas", "(GMT-0930) Pacific/Marquesas"),
|
||||||
|
("America/Adak", "(GMT-0900) America/Adak"),
|
||||||
|
("Pacific/Gambier", "(GMT-0900) Pacific/Gambier"),
|
||||||
|
("America/Anchorage", "(GMT-0800) America/Anchorage"),
|
||||||
|
("America/Juneau", "(GMT-0800) America/Juneau"),
|
||||||
|
("America/Metlakatla", "(GMT-0800) America/Metlakatla"),
|
||||||
|
("America/Nome", "(GMT-0800) America/Nome"),
|
||||||
|
("America/Sitka", "(GMT-0800) America/Sitka"),
|
||||||
|
("America/Yakutat", "(GMT-0800) America/Yakutat"),
|
||||||
|
("Pacific/Pitcairn", "(GMT-0800) Pacific/Pitcairn"),
|
||||||
|
("US/Alaska", "(GMT-0800) US/Alaska"),
|
||||||
|
("America/Creston", "(GMT-0700) America/Creston"),
|
||||||
|
("America/Dawson", "(GMT-0700) America/Dawson"),
|
||||||
|
(
|
||||||
|
"America/Dawson_Creek",
|
||||||
|
"(GMT-0700) America/Dawson_Creek",
|
||||||
|
),
|
||||||
|
("America/Fort_Nelson", "(GMT-0700) America/Fort_Nelson"),
|
||||||
|
("America/Hermosillo", "(GMT-0700) America/Hermosillo"),
|
||||||
|
("America/Los_Angeles", "(GMT-0700) America/Los_Angeles"),
|
||||||
|
("America/Mazatlan", "(GMT-0700) America/Mazatlan"),
|
||||||
|
("America/Phoenix", "(GMT-0700) America/Phoenix"),
|
||||||
|
("America/Tijuana", "(GMT-0700) America/Tijuana"),
|
||||||
|
("America/Vancouver", "(GMT-0700) America/Vancouver"),
|
||||||
|
("America/Whitehorse", "(GMT-0700) America/Whitehorse"),
|
||||||
|
("Canada/Pacific", "(GMT-0700) Canada/Pacific"),
|
||||||
|
("US/Arizona", "(GMT-0700) US/Arizona"),
|
||||||
|
("US/Pacific", "(GMT-0700) US/Pacific"),
|
||||||
|
(
|
||||||
|
"America/Bahia_Banderas",
|
||||||
|
"(GMT-0600) America/Bahia_Banderas",
|
||||||
|
),
|
||||||
|
("America/Belize", "(GMT-0600) America/Belize"),
|
||||||
|
("America/Boise", "(GMT-0600) America/Boise"),
|
||||||
|
(
|
||||||
|
"America/Cambridge_Bay",
|
||||||
|
"(GMT-0600) America/Cambridge_Bay",
|
||||||
|
),
|
||||||
|
("America/Chihuahua", "(GMT-0600) America/Chihuahua"),
|
||||||
|
(
|
||||||
|
"America/Ciudad_Juarez",
|
||||||
|
"(GMT-0600) America/Ciudad_Juarez",
|
||||||
|
),
|
||||||
|
("America/Costa_Rica", "(GMT-0600) America/Costa_Rica"),
|
||||||
|
("America/Denver", "(GMT-0600) America/Denver"),
|
||||||
|
("America/Edmonton", "(GMT-0600) America/Edmonton"),
|
||||||
|
("America/El_Salvador", "(GMT-0600) America/El_Salvador"),
|
||||||
|
("America/Guatemala", "(GMT-0600) America/Guatemala"),
|
||||||
|
("America/Inuvik", "(GMT-0600) America/Inuvik"),
|
||||||
|
("America/Managua", "(GMT-0600) America/Managua"),
|
||||||
|
("America/Merida", "(GMT-0600) America/Merida"),
|
||||||
|
("America/Mexico_City", "(GMT-0600) America/Mexico_City"),
|
||||||
|
("America/Monterrey", "(GMT-0600) America/Monterrey"),
|
||||||
|
("America/Regina", "(GMT-0600) America/Regina"),
|
||||||
|
(
|
||||||
|
"America/Swift_Current",
|
||||||
|
"(GMT-0600) America/Swift_Current",
|
||||||
|
),
|
||||||
|
("America/Tegucigalpa", "(GMT-0600) America/Tegucigalpa"),
|
||||||
|
("America/Yellowknife", "(GMT-0600) America/Yellowknife"),
|
||||||
|
("Canada/Mountain", "(GMT-0600) Canada/Mountain"),
|
||||||
|
("Pacific/Galapagos", "(GMT-0600) Pacific/Galapagos"),
|
||||||
|
("US/Mountain", "(GMT-0600) US/Mountain"),
|
||||||
|
("America/Atikokan", "(GMT-0500) America/Atikokan"),
|
||||||
|
("America/Bogota", "(GMT-0500) America/Bogota"),
|
||||||
|
("America/Cancun", "(GMT-0500) America/Cancun"),
|
||||||
|
("America/Cayman", "(GMT-0500) America/Cayman"),
|
||||||
|
("America/Chicago", "(GMT-0500) America/Chicago"),
|
||||||
|
("America/Eirunepe", "(GMT-0500) America/Eirunepe"),
|
||||||
|
("America/Guayaquil", "(GMT-0500) America/Guayaquil"),
|
||||||
|
(
|
||||||
|
"America/Indiana/Knox",
|
||||||
|
"(GMT-0500) America/Indiana/Knox",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Indiana/Tell_City",
|
||||||
|
"(GMT-0500) America/Indiana/Tell_City",
|
||||||
|
),
|
||||||
|
("America/Jamaica", "(GMT-0500) America/Jamaica"),
|
||||||
|
("America/Lima", "(GMT-0500) America/Lima"),
|
||||||
|
("America/Matamoros", "(GMT-0500) America/Matamoros"),
|
||||||
|
("America/Menominee", "(GMT-0500) America/Menominee"),
|
||||||
|
(
|
||||||
|
"America/North_Dakota/Beulah",
|
||||||
|
"(GMT-0500) America/North_Dakota/Beulah",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/North_Dakota/Center",
|
||||||
|
"(GMT-0500) America/North_Dakota/Center",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/North_Dakota/New_Salem",
|
||||||
|
"(GMT-0500) America/North_Dakota/New_Salem",
|
||||||
|
),
|
||||||
|
("America/Ojinaga", "(GMT-0500) America/Ojinaga"),
|
||||||
|
("America/Panama", "(GMT-0500) America/Panama"),
|
||||||
|
(
|
||||||
|
"America/Rankin_Inlet",
|
||||||
|
"(GMT-0500) America/Rankin_Inlet",
|
||||||
|
),
|
||||||
|
("America/Resolute", "(GMT-0500) America/Resolute"),
|
||||||
|
("America/Rio_Branco", "(GMT-0500) America/Rio_Branco"),
|
||||||
|
("America/Winnipeg", "(GMT-0500) America/Winnipeg"),
|
||||||
|
("Canada/Central", "(GMT-0500) Canada/Central"),
|
||||||
|
("Pacific/Easter", "(GMT-0500) Pacific/Easter"),
|
||||||
|
("US/Central", "(GMT-0500) US/Central"),
|
||||||
|
("America/Anguilla", "(GMT-0400) America/Anguilla"),
|
||||||
|
("America/Antigua", "(GMT-0400) America/Antigua"),
|
||||||
|
("America/Aruba", "(GMT-0400) America/Aruba"),
|
||||||
|
("America/Asuncion", "(GMT-0400) America/Asuncion"),
|
||||||
|
("America/Barbados", "(GMT-0400) America/Barbados"),
|
||||||
|
(
|
||||||
|
"America/Blanc-Sablon",
|
||||||
|
"(GMT-0400) America/Blanc-Sablon",
|
||||||
|
),
|
||||||
|
("America/Boa_Vista", "(GMT-0400) America/Boa_Vista"),
|
||||||
|
(
|
||||||
|
"America/Campo_Grande",
|
||||||
|
"(GMT-0400) America/Campo_Grande",
|
||||||
|
),
|
||||||
|
("America/Caracas", "(GMT-0400) America/Caracas"),
|
||||||
|
("America/Cuiaba", "(GMT-0400) America/Cuiaba"),
|
||||||
|
("America/Curacao", "(GMT-0400) America/Curacao"),
|
||||||
|
("America/Detroit", "(GMT-0400) America/Detroit"),
|
||||||
|
("America/Dominica", "(GMT-0400) America/Dominica"),
|
||||||
|
("America/Grand_Turk", "(GMT-0400) America/Grand_Turk"),
|
||||||
|
("America/Grenada", "(GMT-0400) America/Grenada"),
|
||||||
|
("America/Guadeloupe", "(GMT-0400) America/Guadeloupe"),
|
||||||
|
("America/Guyana", "(GMT-0400) America/Guyana"),
|
||||||
|
("America/Havana", "(GMT-0400) America/Havana"),
|
||||||
|
(
|
||||||
|
"America/Indiana/Indianapolis",
|
||||||
|
"(GMT-0400) America/Indiana/Indianapolis",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Indiana/Marengo",
|
||||||
|
"(GMT-0400) America/Indiana/Marengo",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Indiana/Petersburg",
|
||||||
|
"(GMT-0400) America/Indiana/Petersburg",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Indiana/Vevay",
|
||||||
|
"(GMT-0400) America/Indiana/Vevay",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Indiana/Vincennes",
|
||||||
|
"(GMT-0400) America/Indiana/Vincennes",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Indiana/Winamac",
|
||||||
|
"(GMT-0400) America/Indiana/Winamac",
|
||||||
|
),
|
||||||
|
("America/Iqaluit", "(GMT-0400) America/Iqaluit"),
|
||||||
|
(
|
||||||
|
"America/Kentucky/Louisville",
|
||||||
|
"(GMT-0400) America/Kentucky/Louisville",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Kentucky/Monticello",
|
||||||
|
"(GMT-0400) America/Kentucky/Monticello",
|
||||||
|
),
|
||||||
|
("America/Kralendijk", "(GMT-0400) America/Kralendijk"),
|
||||||
|
("America/La_Paz", "(GMT-0400) America/La_Paz"),
|
||||||
|
(
|
||||||
|
"America/Lower_Princes",
|
||||||
|
"(GMT-0400) America/Lower_Princes",
|
||||||
|
),
|
||||||
|
("America/Manaus", "(GMT-0400) America/Manaus"),
|
||||||
|
("America/Marigot", "(GMT-0400) America/Marigot"),
|
||||||
|
("America/Martinique", "(GMT-0400) America/Martinique"),
|
||||||
|
("America/Montserrat", "(GMT-0400) America/Montserrat"),
|
||||||
|
("America/Nassau", "(GMT-0400) America/Nassau"),
|
||||||
|
("America/New_York", "(GMT-0400) America/New_York"),
|
||||||
|
(
|
||||||
|
"America/Port-au-Prince",
|
||||||
|
"(GMT-0400) America/Port-au-Prince",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Port_of_Spain",
|
||||||
|
"(GMT-0400) America/Port_of_Spain",
|
||||||
|
),
|
||||||
|
("America/Porto_Velho", "(GMT-0400) America/Porto_Velho"),
|
||||||
|
("America/Puerto_Rico", "(GMT-0400) America/Puerto_Rico"),
|
||||||
|
(
|
||||||
|
"America/Santo_Domingo",
|
||||||
|
"(GMT-0400) America/Santo_Domingo",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/St_Barthelemy",
|
||||||
|
"(GMT-0400) America/St_Barthelemy",
|
||||||
|
),
|
||||||
|
("America/St_Kitts", "(GMT-0400) America/St_Kitts"),
|
||||||
|
("America/St_Lucia", "(GMT-0400) America/St_Lucia"),
|
||||||
|
("America/St_Thomas", "(GMT-0400) America/St_Thomas"),
|
||||||
|
("America/St_Vincent", "(GMT-0400) America/St_Vincent"),
|
||||||
|
("America/Toronto", "(GMT-0400) America/Toronto"),
|
||||||
|
("America/Tortola", "(GMT-0400) America/Tortola"),
|
||||||
|
("Canada/Eastern", "(GMT-0400) Canada/Eastern"),
|
||||||
|
("US/Eastern", "(GMT-0400) US/Eastern"),
|
||||||
|
("America/Araguaina", "(GMT-0300) America/Araguaina"),
|
||||||
|
(
|
||||||
|
"America/Argentina/Buenos_Aires",
|
||||||
|
"(GMT-0300) America/Argentina/Buenos_Aires",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Argentina/Catamarca",
|
||||||
|
"(GMT-0300) America/Argentina/Catamarca",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Argentina/Cordoba",
|
||||||
|
"(GMT-0300) America/Argentina/Cordoba",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Argentina/Jujuy",
|
||||||
|
"(GMT-0300) America/Argentina/Jujuy",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Argentina/La_Rioja",
|
||||||
|
"(GMT-0300) America/Argentina/La_Rioja",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Argentina/Mendoza",
|
||||||
|
"(GMT-0300) America/Argentina/Mendoza",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Argentina/Rio_Gallegos",
|
||||||
|
"(GMT-0300) America/Argentina/Rio_Gallegos",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Argentina/Salta",
|
||||||
|
"(GMT-0300) America/Argentina/Salta",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Argentina/San_Juan",
|
||||||
|
"(GMT-0300) America/Argentina/San_Juan",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Argentina/San_Luis",
|
||||||
|
"(GMT-0300) America/Argentina/San_Luis",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Argentina/Tucuman",
|
||||||
|
"(GMT-0300) America/Argentina/Tucuman",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Argentina/Ushuaia",
|
||||||
|
"(GMT-0300) America/Argentina/Ushuaia",
|
||||||
|
),
|
||||||
|
("America/Bahia", "(GMT-0300) America/Bahia"),
|
||||||
|
("America/Belem", "(GMT-0300) America/Belem"),
|
||||||
|
("America/Cayenne", "(GMT-0300) America/Cayenne"),
|
||||||
|
("America/Fortaleza", "(GMT-0300) America/Fortaleza"),
|
||||||
|
("America/Glace_Bay", "(GMT-0300) America/Glace_Bay"),
|
||||||
|
("America/Goose_Bay", "(GMT-0300) America/Goose_Bay"),
|
||||||
|
("America/Halifax", "(GMT-0300) America/Halifax"),
|
||||||
|
("America/Maceio", "(GMT-0300) America/Maceio"),
|
||||||
|
("America/Moncton", "(GMT-0300) America/Moncton"),
|
||||||
|
("America/Montevideo", "(GMT-0300) America/Montevideo"),
|
||||||
|
("America/Paramaribo", "(GMT-0300) America/Paramaribo"),
|
||||||
|
(
|
||||||
|
"America/Punta_Arenas",
|
||||||
|
"(GMT-0300) America/Punta_Arenas",
|
||||||
|
),
|
||||||
|
("America/Recife", "(GMT-0300) America/Recife"),
|
||||||
|
("America/Santarem", "(GMT-0300) America/Santarem"),
|
||||||
|
("America/Santiago", "(GMT-0300) America/Santiago"),
|
||||||
|
("America/Sao_Paulo", "(GMT-0300) America/Sao_Paulo"),
|
||||||
|
("America/Thule", "(GMT-0300) America/Thule"),
|
||||||
|
("Antarctica/Palmer", "(GMT-0300) Antarctica/Palmer"),
|
||||||
|
("Antarctica/Rothera", "(GMT-0300) Antarctica/Rothera"),
|
||||||
|
("Atlantic/Bermuda", "(GMT-0300) Atlantic/Bermuda"),
|
||||||
|
("Atlantic/Stanley", "(GMT-0300) Atlantic/Stanley"),
|
||||||
|
("Canada/Atlantic", "(GMT-0300) Canada/Atlantic"),
|
||||||
|
("America/St_Johns", "(GMT-0230) America/St_Johns"),
|
||||||
|
("Canada/Newfoundland", "(GMT-0230) Canada/Newfoundland"),
|
||||||
|
("America/Miquelon", "(GMT-0200) America/Miquelon"),
|
||||||
|
("America/Noronha", "(GMT-0200) America/Noronha"),
|
||||||
|
("America/Nuuk", "(GMT-0200) America/Nuuk"),
|
||||||
|
(
|
||||||
|
"Atlantic/South_Georgia",
|
||||||
|
"(GMT-0200) Atlantic/South_Georgia",
|
||||||
|
),
|
||||||
|
("Atlantic/Cape_Verde", "(GMT-0100) Atlantic/Cape_Verde"),
|
||||||
|
("Africa/Abidjan", "(GMT+0000) Africa/Abidjan"),
|
||||||
|
("Africa/Accra", "(GMT+0000) Africa/Accra"),
|
||||||
|
("Africa/Bamako", "(GMT+0000) Africa/Bamako"),
|
||||||
|
("Africa/Banjul", "(GMT+0000) Africa/Banjul"),
|
||||||
|
("Africa/Bissau", "(GMT+0000) Africa/Bissau"),
|
||||||
|
("Africa/Conakry", "(GMT+0000) Africa/Conakry"),
|
||||||
|
("Africa/Dakar", "(GMT+0000) Africa/Dakar"),
|
||||||
|
("Africa/Freetown", "(GMT+0000) Africa/Freetown"),
|
||||||
|
("Africa/Lome", "(GMT+0000) Africa/Lome"),
|
||||||
|
("Africa/Monrovia", "(GMT+0000) Africa/Monrovia"),
|
||||||
|
("Africa/Nouakchott", "(GMT+0000) Africa/Nouakchott"),
|
||||||
|
("Africa/Ouagadougou", "(GMT+0000) Africa/Ouagadougou"),
|
||||||
|
("Africa/Sao_Tome", "(GMT+0000) Africa/Sao_Tome"),
|
||||||
|
(
|
||||||
|
"America/Danmarkshavn",
|
||||||
|
"(GMT+0000) America/Danmarkshavn",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Scoresbysund",
|
||||||
|
"(GMT+0000) America/Scoresbysund",
|
||||||
|
),
|
||||||
|
("Atlantic/Azores", "(GMT+0000) Atlantic/Azores"),
|
||||||
|
("Atlantic/Reykjavik", "(GMT+0000) Atlantic/Reykjavik"),
|
||||||
|
("Atlantic/St_Helena", "(GMT+0000) Atlantic/St_Helena"),
|
||||||
|
("GMT", "(GMT+0000) GMT"),
|
||||||
|
("UTC", "(GMT+0000) UTC"),
|
||||||
|
("Africa/Algiers", "(GMT+0100) Africa/Algiers"),
|
||||||
|
("Africa/Bangui", "(GMT+0100) Africa/Bangui"),
|
||||||
|
("Africa/Brazzaville", "(GMT+0100) Africa/Brazzaville"),
|
||||||
|
("Africa/Casablanca", "(GMT+0100) Africa/Casablanca"),
|
||||||
|
("Africa/Douala", "(GMT+0100) Africa/Douala"),
|
||||||
|
("Africa/El_Aaiun", "(GMT+0100) Africa/El_Aaiun"),
|
||||||
|
("Africa/Kinshasa", "(GMT+0100) Africa/Kinshasa"),
|
||||||
|
("Africa/Lagos", "(GMT+0100) Africa/Lagos"),
|
||||||
|
("Africa/Libreville", "(GMT+0100) Africa/Libreville"),
|
||||||
|
("Africa/Luanda", "(GMT+0100) Africa/Luanda"),
|
||||||
|
("Africa/Malabo", "(GMT+0100) Africa/Malabo"),
|
||||||
|
("Africa/Ndjamena", "(GMT+0100) Africa/Ndjamena"),
|
||||||
|
("Africa/Niamey", "(GMT+0100) Africa/Niamey"),
|
||||||
|
("Africa/Porto-Novo", "(GMT+0100) Africa/Porto-Novo"),
|
||||||
|
("Africa/Tunis", "(GMT+0100) Africa/Tunis"),
|
||||||
|
("Atlantic/Canary", "(GMT+0100) Atlantic/Canary"),
|
||||||
|
("Atlantic/Faroe", "(GMT+0100) Atlantic/Faroe"),
|
||||||
|
("Atlantic/Madeira", "(GMT+0100) Atlantic/Madeira"),
|
||||||
|
("Europe/Dublin", "(GMT+0100) Europe/Dublin"),
|
||||||
|
("Europe/Guernsey", "(GMT+0100) Europe/Guernsey"),
|
||||||
|
("Europe/Isle_of_Man", "(GMT+0100) Europe/Isle_of_Man"),
|
||||||
|
("Europe/Jersey", "(GMT+0100) Europe/Jersey"),
|
||||||
|
("Europe/Lisbon", "(GMT+0100) Europe/Lisbon"),
|
||||||
|
("Europe/London", "(GMT+0100) Europe/London"),
|
||||||
|
("Africa/Blantyre", "(GMT+0200) Africa/Blantyre"),
|
||||||
|
("Africa/Bujumbura", "(GMT+0200) Africa/Bujumbura"),
|
||||||
|
("Africa/Cairo", "(GMT+0200) Africa/Cairo"),
|
||||||
|
("Africa/Ceuta", "(GMT+0200) Africa/Ceuta"),
|
||||||
|
("Africa/Gaborone", "(GMT+0200) Africa/Gaborone"),
|
||||||
|
("Africa/Harare", "(GMT+0200) Africa/Harare"),
|
||||||
|
("Africa/Johannesburg", "(GMT+0200) Africa/Johannesburg"),
|
||||||
|
("Africa/Juba", "(GMT+0200) Africa/Juba"),
|
||||||
|
("Africa/Khartoum", "(GMT+0200) Africa/Khartoum"),
|
||||||
|
("Africa/Kigali", "(GMT+0200) Africa/Kigali"),
|
||||||
|
("Africa/Lubumbashi", "(GMT+0200) Africa/Lubumbashi"),
|
||||||
|
("Africa/Lusaka", "(GMT+0200) Africa/Lusaka"),
|
||||||
|
("Africa/Maputo", "(GMT+0200) Africa/Maputo"),
|
||||||
|
("Africa/Maseru", "(GMT+0200) Africa/Maseru"),
|
||||||
|
("Africa/Mbabane", "(GMT+0200) Africa/Mbabane"),
|
||||||
|
("Africa/Tripoli", "(GMT+0200) Africa/Tripoli"),
|
||||||
|
("Africa/Windhoek", "(GMT+0200) Africa/Windhoek"),
|
||||||
|
("Antarctica/Troll", "(GMT+0200) Antarctica/Troll"),
|
||||||
|
("Arctic/Longyearbyen", "(GMT+0200) Arctic/Longyearbyen"),
|
||||||
|
("Europe/Amsterdam", "(GMT+0200) Europe/Amsterdam"),
|
||||||
|
("Europe/Andorra", "(GMT+0200) Europe/Andorra"),
|
||||||
|
("Europe/Belgrade", "(GMT+0200) Europe/Belgrade"),
|
||||||
|
("Europe/Berlin", "(GMT+0200) Europe/Berlin"),
|
||||||
|
("Europe/Bratislava", "(GMT+0200) Europe/Bratislava"),
|
||||||
|
("Europe/Brussels", "(GMT+0200) Europe/Brussels"),
|
||||||
|
("Europe/Budapest", "(GMT+0200) Europe/Budapest"),
|
||||||
|
("Europe/Busingen", "(GMT+0200) Europe/Busingen"),
|
||||||
|
("Europe/Copenhagen", "(GMT+0200) Europe/Copenhagen"),
|
||||||
|
("Europe/Gibraltar", "(GMT+0200) Europe/Gibraltar"),
|
||||||
|
("Europe/Kaliningrad", "(GMT+0200) Europe/Kaliningrad"),
|
||||||
|
("Europe/Ljubljana", "(GMT+0200) Europe/Ljubljana"),
|
||||||
|
("Europe/Luxembourg", "(GMT+0200) Europe/Luxembourg"),
|
||||||
|
("Europe/Madrid", "(GMT+0200) Europe/Madrid"),
|
||||||
|
("Europe/Malta", "(GMT+0200) Europe/Malta"),
|
||||||
|
("Europe/Monaco", "(GMT+0200) Europe/Monaco"),
|
||||||
|
("Europe/Oslo", "(GMT+0200) Europe/Oslo"),
|
||||||
|
("Europe/Paris", "(GMT+0200) Europe/Paris"),
|
||||||
|
("Europe/Podgorica", "(GMT+0200) Europe/Podgorica"),
|
||||||
|
("Europe/Prague", "(GMT+0200) Europe/Prague"),
|
||||||
|
("Europe/Rome", "(GMT+0200) Europe/Rome"),
|
||||||
|
("Europe/San_Marino", "(GMT+0200) Europe/San_Marino"),
|
||||||
|
("Europe/Sarajevo", "(GMT+0200) Europe/Sarajevo"),
|
||||||
|
("Europe/Skopje", "(GMT+0200) Europe/Skopje"),
|
||||||
|
("Europe/Stockholm", "(GMT+0200) Europe/Stockholm"),
|
||||||
|
("Europe/Tirane", "(GMT+0200) Europe/Tirane"),
|
||||||
|
("Europe/Vaduz", "(GMT+0200) Europe/Vaduz"),
|
||||||
|
("Europe/Vatican", "(GMT+0200) Europe/Vatican"),
|
||||||
|
("Europe/Vienna", "(GMT+0200) Europe/Vienna"),
|
||||||
|
("Europe/Warsaw", "(GMT+0200) Europe/Warsaw"),
|
||||||
|
("Europe/Zagreb", "(GMT+0200) Europe/Zagreb"),
|
||||||
|
("Europe/Zurich", "(GMT+0200) Europe/Zurich"),
|
||||||
|
("Africa/Addis_Ababa", "(GMT+0300) Africa/Addis_Ababa"),
|
||||||
|
("Africa/Asmara", "(GMT+0300) Africa/Asmara"),
|
||||||
|
(
|
||||||
|
"Africa/Dar_es_Salaam",
|
||||||
|
"(GMT+0300) Africa/Dar_es_Salaam",
|
||||||
|
),
|
||||||
|
("Africa/Djibouti", "(GMT+0300) Africa/Djibouti"),
|
||||||
|
("Africa/Kampala", "(GMT+0300) Africa/Kampala"),
|
||||||
|
("Africa/Mogadishu", "(GMT+0300) Africa/Mogadishu"),
|
||||||
|
("Africa/Nairobi", "(GMT+0300) Africa/Nairobi"),
|
||||||
|
("Antarctica/Syowa", "(GMT+0300) Antarctica/Syowa"),
|
||||||
|
("Asia/Aden", "(GMT+0300) Asia/Aden"),
|
||||||
|
("Asia/Amman", "(GMT+0300) Asia/Amman"),
|
||||||
|
("Asia/Baghdad", "(GMT+0300) Asia/Baghdad"),
|
||||||
|
("Asia/Bahrain", "(GMT+0300) Asia/Bahrain"),
|
||||||
|
("Asia/Beirut", "(GMT+0300) Asia/Beirut"),
|
||||||
|
("Asia/Damascus", "(GMT+0300) Asia/Damascus"),
|
||||||
|
("Asia/Famagusta", "(GMT+0300) Asia/Famagusta"),
|
||||||
|
("Asia/Gaza", "(GMT+0300) Asia/Gaza"),
|
||||||
|
("Asia/Hebron", "(GMT+0300) Asia/Hebron"),
|
||||||
|
("Asia/Jerusalem", "(GMT+0300) Asia/Jerusalem"),
|
||||||
|
("Asia/Kuwait", "(GMT+0300) Asia/Kuwait"),
|
||||||
|
("Asia/Nicosia", "(GMT+0300) Asia/Nicosia"),
|
||||||
|
("Asia/Qatar", "(GMT+0300) Asia/Qatar"),
|
||||||
|
("Asia/Riyadh", "(GMT+0300) Asia/Riyadh"),
|
||||||
|
("Europe/Athens", "(GMT+0300) Europe/Athens"),
|
||||||
|
("Europe/Bucharest", "(GMT+0300) Europe/Bucharest"),
|
||||||
|
("Europe/Chisinau", "(GMT+0300) Europe/Chisinau"),
|
||||||
|
("Europe/Helsinki", "(GMT+0300) Europe/Helsinki"),
|
||||||
|
("Europe/Istanbul", "(GMT+0300) Europe/Istanbul"),
|
||||||
|
("Europe/Kirov", "(GMT+0300) Europe/Kirov"),
|
||||||
|
("Europe/Kyiv", "(GMT+0300) Europe/Kyiv"),
|
||||||
|
("Europe/Mariehamn", "(GMT+0300) Europe/Mariehamn"),
|
||||||
|
("Europe/Minsk", "(GMT+0300) Europe/Minsk"),
|
||||||
|
("Europe/Moscow", "(GMT+0300) Europe/Moscow"),
|
||||||
|
("Europe/Riga", "(GMT+0300) Europe/Riga"),
|
||||||
|
("Europe/Simferopol", "(GMT+0300) Europe/Simferopol"),
|
||||||
|
("Europe/Sofia", "(GMT+0300) Europe/Sofia"),
|
||||||
|
("Europe/Tallinn", "(GMT+0300) Europe/Tallinn"),
|
||||||
|
("Europe/Vilnius", "(GMT+0300) Europe/Vilnius"),
|
||||||
|
("Europe/Volgograd", "(GMT+0300) Europe/Volgograd"),
|
||||||
|
("Indian/Antananarivo", "(GMT+0300) Indian/Antananarivo"),
|
||||||
|
("Indian/Comoro", "(GMT+0300) Indian/Comoro"),
|
||||||
|
("Indian/Mayotte", "(GMT+0300) Indian/Mayotte"),
|
||||||
|
("Asia/Tehran", "(GMT+0330) Asia/Tehran"),
|
||||||
|
("Asia/Baku", "(GMT+0400) Asia/Baku"),
|
||||||
|
("Asia/Dubai", "(GMT+0400) Asia/Dubai"),
|
||||||
|
("Asia/Muscat", "(GMT+0400) Asia/Muscat"),
|
||||||
|
("Asia/Tbilisi", "(GMT+0400) Asia/Tbilisi"),
|
||||||
|
("Asia/Yerevan", "(GMT+0400) Asia/Yerevan"),
|
||||||
|
("Europe/Astrakhan", "(GMT+0400) Europe/Astrakhan"),
|
||||||
|
("Europe/Samara", "(GMT+0400) Europe/Samara"),
|
||||||
|
("Europe/Saratov", "(GMT+0400) Europe/Saratov"),
|
||||||
|
("Europe/Ulyanovsk", "(GMT+0400) Europe/Ulyanovsk"),
|
||||||
|
("Indian/Mahe", "(GMT+0400) Indian/Mahe"),
|
||||||
|
("Indian/Mauritius", "(GMT+0400) Indian/Mauritius"),
|
||||||
|
("Indian/Reunion", "(GMT+0400) Indian/Reunion"),
|
||||||
|
("Asia/Kabul", "(GMT+0430) Asia/Kabul"),
|
||||||
|
("Antarctica/Mawson", "(GMT+0500) Antarctica/Mawson"),
|
||||||
|
("Asia/Aqtau", "(GMT+0500) Asia/Aqtau"),
|
||||||
|
("Asia/Aqtobe", "(GMT+0500) Asia/Aqtobe"),
|
||||||
|
("Asia/Ashgabat", "(GMT+0500) Asia/Ashgabat"),
|
||||||
|
("Asia/Atyrau", "(GMT+0500) Asia/Atyrau"),
|
||||||
|
("Asia/Dushanbe", "(GMT+0500) Asia/Dushanbe"),
|
||||||
|
("Asia/Karachi", "(GMT+0500) Asia/Karachi"),
|
||||||
|
("Asia/Oral", "(GMT+0500) Asia/Oral"),
|
||||||
|
("Asia/Qyzylorda", "(GMT+0500) Asia/Qyzylorda"),
|
||||||
|
("Asia/Samarkand", "(GMT+0500) Asia/Samarkand"),
|
||||||
|
("Asia/Tashkent", "(GMT+0500) Asia/Tashkent"),
|
||||||
|
("Asia/Yekaterinburg", "(GMT+0500) Asia/Yekaterinburg"),
|
||||||
|
("Indian/Kerguelen", "(GMT+0500) Indian/Kerguelen"),
|
||||||
|
("Indian/Maldives", "(GMT+0500) Indian/Maldives"),
|
||||||
|
("Asia/Colombo", "(GMT+0530) Asia/Colombo"),
|
||||||
|
("Asia/Kolkata", "(GMT+0530) Asia/Kolkata"),
|
||||||
|
("Asia/Kathmandu", "(GMT+0545) Asia/Kathmandu"),
|
||||||
|
("Antarctica/Vostok", "(GMT+0600) Antarctica/Vostok"),
|
||||||
|
("Asia/Almaty", "(GMT+0600) Asia/Almaty"),
|
||||||
|
("Asia/Bishkek", "(GMT+0600) Asia/Bishkek"),
|
||||||
|
("Asia/Dhaka", "(GMT+0600) Asia/Dhaka"),
|
||||||
|
("Asia/Omsk", "(GMT+0600) Asia/Omsk"),
|
||||||
|
("Asia/Qostanay", "(GMT+0600) Asia/Qostanay"),
|
||||||
|
("Asia/Thimphu", "(GMT+0600) Asia/Thimphu"),
|
||||||
|
("Asia/Urumqi", "(GMT+0600) Asia/Urumqi"),
|
||||||
|
("Indian/Chagos", "(GMT+0600) Indian/Chagos"),
|
||||||
|
("Asia/Yangon", "(GMT+0630) Asia/Yangon"),
|
||||||
|
("Indian/Cocos", "(GMT+0630) Indian/Cocos"),
|
||||||
|
("Antarctica/Davis", "(GMT+0700) Antarctica/Davis"),
|
||||||
|
("Asia/Bangkok", "(GMT+0700) Asia/Bangkok"),
|
||||||
|
("Asia/Barnaul", "(GMT+0700) Asia/Barnaul"),
|
||||||
|
("Asia/Ho_Chi_Minh", "(GMT+0700) Asia/Ho_Chi_Minh"),
|
||||||
|
("Asia/Hovd", "(GMT+0700) Asia/Hovd"),
|
||||||
|
("Asia/Jakarta", "(GMT+0700) Asia/Jakarta"),
|
||||||
|
("Asia/Krasnoyarsk", "(GMT+0700) Asia/Krasnoyarsk"),
|
||||||
|
("Asia/Novokuznetsk", "(GMT+0700) Asia/Novokuznetsk"),
|
||||||
|
("Asia/Novosibirsk", "(GMT+0700) Asia/Novosibirsk"),
|
||||||
|
("Asia/Phnom_Penh", "(GMT+0700) Asia/Phnom_Penh"),
|
||||||
|
("Asia/Pontianak", "(GMT+0700) Asia/Pontianak"),
|
||||||
|
("Asia/Tomsk", "(GMT+0700) Asia/Tomsk"),
|
||||||
|
("Asia/Vientiane", "(GMT+0700) Asia/Vientiane"),
|
||||||
|
("Indian/Christmas", "(GMT+0700) Indian/Christmas"),
|
||||||
|
("Asia/Brunei", "(GMT+0800) Asia/Brunei"),
|
||||||
|
("Asia/Choibalsan", "(GMT+0800) Asia/Choibalsan"),
|
||||||
|
("Asia/Hong_Kong", "(GMT+0800) Asia/Hong_Kong"),
|
||||||
|
("Asia/Irkutsk", "(GMT+0800) Asia/Irkutsk"),
|
||||||
|
("Asia/Kuala_Lumpur", "(GMT+0800) Asia/Kuala_Lumpur"),
|
||||||
|
("Asia/Kuching", "(GMT+0800) Asia/Kuching"),
|
||||||
|
("Asia/Macau", "(GMT+0800) Asia/Macau"),
|
||||||
|
("Asia/Makassar", "(GMT+0800) Asia/Makassar"),
|
||||||
|
("Asia/Manila", "(GMT+0800) Asia/Manila"),
|
||||||
|
("Asia/Shanghai", "(GMT+0800) Asia/Shanghai"),
|
||||||
|
("Asia/Singapore", "(GMT+0800) Asia/Singapore"),
|
||||||
|
("Asia/Taipei", "(GMT+0800) Asia/Taipei"),
|
||||||
|
("Asia/Ulaanbaatar", "(GMT+0800) Asia/Ulaanbaatar"),
|
||||||
|
("Australia/Perth", "(GMT+0800) Australia/Perth"),
|
||||||
|
("Australia/Eucla", "(GMT+0845) Australia/Eucla"),
|
||||||
|
("Asia/Chita", "(GMT+0900) Asia/Chita"),
|
||||||
|
("Asia/Dili", "(GMT+0900) Asia/Dili"),
|
||||||
|
("Asia/Jayapura", "(GMT+0900) Asia/Jayapura"),
|
||||||
|
("Asia/Khandyga", "(GMT+0900) Asia/Khandyga"),
|
||||||
|
("Asia/Pyongyang", "(GMT+0900) Asia/Pyongyang"),
|
||||||
|
("Asia/Seoul", "(GMT+0900) Asia/Seoul"),
|
||||||
|
("Asia/Tokyo", "(GMT+0900) Asia/Tokyo"),
|
||||||
|
("Asia/Yakutsk", "(GMT+0900) Asia/Yakutsk"),
|
||||||
|
("Pacific/Palau", "(GMT+0900) Pacific/Palau"),
|
||||||
|
("Australia/Adelaide", "(GMT+0930) Australia/Adelaide"),
|
||||||
|
(
|
||||||
|
"Australia/Broken_Hill",
|
||||||
|
"(GMT+0930) Australia/Broken_Hill",
|
||||||
|
),
|
||||||
|
("Australia/Darwin", "(GMT+0930) Australia/Darwin"),
|
||||||
|
(
|
||||||
|
"Antarctica/DumontDUrville",
|
||||||
|
"(GMT+1000) Antarctica/DumontDUrville",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Antarctica/Macquarie",
|
||||||
|
"(GMT+1000) Antarctica/Macquarie",
|
||||||
|
),
|
||||||
|
("Asia/Ust-Nera", "(GMT+1000) Asia/Ust-Nera"),
|
||||||
|
("Asia/Vladivostok", "(GMT+1000) Asia/Vladivostok"),
|
||||||
|
("Australia/Brisbane", "(GMT+1000) Australia/Brisbane"),
|
||||||
|
("Australia/Hobart", "(GMT+1000) Australia/Hobart"),
|
||||||
|
("Australia/Lindeman", "(GMT+1000) Australia/Lindeman"),
|
||||||
|
("Australia/Melbourne", "(GMT+1000) Australia/Melbourne"),
|
||||||
|
("Australia/Sydney", "(GMT+1000) Australia/Sydney"),
|
||||||
|
("Pacific/Chuuk", "(GMT+1000) Pacific/Chuuk"),
|
||||||
|
("Pacific/Guam", "(GMT+1000) Pacific/Guam"),
|
||||||
|
(
|
||||||
|
"Pacific/Port_Moresby",
|
||||||
|
"(GMT+1000) Pacific/Port_Moresby",
|
||||||
|
),
|
||||||
|
("Pacific/Saipan", "(GMT+1000) Pacific/Saipan"),
|
||||||
|
("Australia/Lord_Howe", "(GMT+1030) Australia/Lord_Howe"),
|
||||||
|
("Antarctica/Casey", "(GMT+1100) Antarctica/Casey"),
|
||||||
|
("Asia/Magadan", "(GMT+1100) Asia/Magadan"),
|
||||||
|
("Asia/Sakhalin", "(GMT+1100) Asia/Sakhalin"),
|
||||||
|
("Asia/Srednekolymsk", "(GMT+1100) Asia/Srednekolymsk"),
|
||||||
|
(
|
||||||
|
"Pacific/Bougainville",
|
||||||
|
"(GMT+1100) Pacific/Bougainville",
|
||||||
|
),
|
||||||
|
("Pacific/Efate", "(GMT+1100) Pacific/Efate"),
|
||||||
|
("Pacific/Guadalcanal", "(GMT+1100) Pacific/Guadalcanal"),
|
||||||
|
("Pacific/Kosrae", "(GMT+1100) Pacific/Kosrae"),
|
||||||
|
("Pacific/Norfolk", "(GMT+1100) Pacific/Norfolk"),
|
||||||
|
("Pacific/Noumea", "(GMT+1100) Pacific/Noumea"),
|
||||||
|
("Pacific/Pohnpei", "(GMT+1100) Pacific/Pohnpei"),
|
||||||
|
("Antarctica/McMurdo", "(GMT+1200) Antarctica/McMurdo"),
|
||||||
|
("Asia/Anadyr", "(GMT+1200) Asia/Anadyr"),
|
||||||
|
("Asia/Kamchatka", "(GMT+1200) Asia/Kamchatka"),
|
||||||
|
("Pacific/Auckland", "(GMT+1200) Pacific/Auckland"),
|
||||||
|
("Pacific/Fiji", "(GMT+1200) Pacific/Fiji"),
|
||||||
|
("Pacific/Funafuti", "(GMT+1200) Pacific/Funafuti"),
|
||||||
|
("Pacific/Kwajalein", "(GMT+1200) Pacific/Kwajalein"),
|
||||||
|
("Pacific/Majuro", "(GMT+1200) Pacific/Majuro"),
|
||||||
|
("Pacific/Nauru", "(GMT+1200) Pacific/Nauru"),
|
||||||
|
("Pacific/Tarawa", "(GMT+1200) Pacific/Tarawa"),
|
||||||
|
("Pacific/Wake", "(GMT+1200) Pacific/Wake"),
|
||||||
|
("Pacific/Wallis", "(GMT+1200) Pacific/Wallis"),
|
||||||
|
("Pacific/Chatham", "(GMT+1245) Pacific/Chatham"),
|
||||||
|
("Pacific/Apia", "(GMT+1300) Pacific/Apia"),
|
||||||
|
("Pacific/Fakaofo", "(GMT+1300) Pacific/Fakaofo"),
|
||||||
|
("Pacific/Kanton", "(GMT+1300) Pacific/Kanton"),
|
||||||
|
("Pacific/Tongatapu", "(GMT+1300) Pacific/Tongatapu"),
|
||||||
|
("Pacific/Kiritimati", "(GMT+1400) Pacific/Kiritimati"),
|
||||||
|
],
|
||||||
|
default="UTC",
|
||||||
|
max_length=255,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,612 @@
|
|||||||
|
# Generated by Django 4.2.16 on 2024-10-14 23:23
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import encrypted_field.fields
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("profiles", "0016_alter_userprofile_timezone"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="todoist_auth_key",
|
||||||
|
field=encrypted_field.fields.EncryptedField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="todoist_state",
|
||||||
|
field=encrypted_field.fields.EncryptedField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="timezone",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("Pacific/Midway", "(GMT-1100) Pacific/Midway"),
|
||||||
|
("Pacific/Niue", "(GMT-1100) Pacific/Niue"),
|
||||||
|
("Pacific/Pago_Pago", "(GMT-1100) Pacific/Pago_Pago"),
|
||||||
|
("Pacific/Honolulu", "(GMT-1000) Pacific/Honolulu"),
|
||||||
|
("Pacific/Rarotonga", "(GMT-1000) Pacific/Rarotonga"),
|
||||||
|
("Pacific/Tahiti", "(GMT-1000) Pacific/Tahiti"),
|
||||||
|
("US/Hawaii", "(GMT-1000) US/Hawaii"),
|
||||||
|
("Pacific/Marquesas", "(GMT-0930) Pacific/Marquesas"),
|
||||||
|
("America/Adak", "(GMT-0900) America/Adak"),
|
||||||
|
("Pacific/Gambier", "(GMT-0900) Pacific/Gambier"),
|
||||||
|
("America/Anchorage", "(GMT-0800) America/Anchorage"),
|
||||||
|
("America/Juneau", "(GMT-0800) America/Juneau"),
|
||||||
|
("America/Metlakatla", "(GMT-0800) America/Metlakatla"),
|
||||||
|
("America/Nome", "(GMT-0800) America/Nome"),
|
||||||
|
("America/Sitka", "(GMT-0800) America/Sitka"),
|
||||||
|
("America/Yakutat", "(GMT-0800) America/Yakutat"),
|
||||||
|
("Pacific/Pitcairn", "(GMT-0800) Pacific/Pitcairn"),
|
||||||
|
("US/Alaska", "(GMT-0800) US/Alaska"),
|
||||||
|
("America/Creston", "(GMT-0700) America/Creston"),
|
||||||
|
("America/Dawson", "(GMT-0700) America/Dawson"),
|
||||||
|
(
|
||||||
|
"America/Dawson_Creek",
|
||||||
|
"(GMT-0700) America/Dawson_Creek",
|
||||||
|
),
|
||||||
|
("America/Fort_Nelson", "(GMT-0700) America/Fort_Nelson"),
|
||||||
|
("America/Hermosillo", "(GMT-0700) America/Hermosillo"),
|
||||||
|
("America/Los_Angeles", "(GMT-0700) America/Los_Angeles"),
|
||||||
|
("America/Mazatlan", "(GMT-0700) America/Mazatlan"),
|
||||||
|
("America/Phoenix", "(GMT-0700) America/Phoenix"),
|
||||||
|
("America/Tijuana", "(GMT-0700) America/Tijuana"),
|
||||||
|
("America/Vancouver", "(GMT-0700) America/Vancouver"),
|
||||||
|
("America/Whitehorse", "(GMT-0700) America/Whitehorse"),
|
||||||
|
("Canada/Pacific", "(GMT-0700) Canada/Pacific"),
|
||||||
|
("US/Arizona", "(GMT-0700) US/Arizona"),
|
||||||
|
("US/Pacific", "(GMT-0700) US/Pacific"),
|
||||||
|
(
|
||||||
|
"America/Bahia_Banderas",
|
||||||
|
"(GMT-0600) America/Bahia_Banderas",
|
||||||
|
),
|
||||||
|
("America/Belize", "(GMT-0600) America/Belize"),
|
||||||
|
("America/Boise", "(GMT-0600) America/Boise"),
|
||||||
|
(
|
||||||
|
"America/Cambridge_Bay",
|
||||||
|
"(GMT-0600) America/Cambridge_Bay",
|
||||||
|
),
|
||||||
|
("America/Chihuahua", "(GMT-0600) America/Chihuahua"),
|
||||||
|
(
|
||||||
|
"America/Ciudad_Juarez",
|
||||||
|
"(GMT-0600) America/Ciudad_Juarez",
|
||||||
|
),
|
||||||
|
("America/Costa_Rica", "(GMT-0600) America/Costa_Rica"),
|
||||||
|
("America/Denver", "(GMT-0600) America/Denver"),
|
||||||
|
("America/Edmonton", "(GMT-0600) America/Edmonton"),
|
||||||
|
("America/El_Salvador", "(GMT-0600) America/El_Salvador"),
|
||||||
|
("America/Guatemala", "(GMT-0600) America/Guatemala"),
|
||||||
|
("America/Inuvik", "(GMT-0600) America/Inuvik"),
|
||||||
|
("America/Managua", "(GMT-0600) America/Managua"),
|
||||||
|
("America/Merida", "(GMT-0600) America/Merida"),
|
||||||
|
("America/Mexico_City", "(GMT-0600) America/Mexico_City"),
|
||||||
|
("America/Monterrey", "(GMT-0600) America/Monterrey"),
|
||||||
|
("America/Regina", "(GMT-0600) America/Regina"),
|
||||||
|
(
|
||||||
|
"America/Swift_Current",
|
||||||
|
"(GMT-0600) America/Swift_Current",
|
||||||
|
),
|
||||||
|
("America/Tegucigalpa", "(GMT-0600) America/Tegucigalpa"),
|
||||||
|
("America/Yellowknife", "(GMT-0600) America/Yellowknife"),
|
||||||
|
("Canada/Mountain", "(GMT-0600) Canada/Mountain"),
|
||||||
|
("Pacific/Galapagos", "(GMT-0600) Pacific/Galapagos"),
|
||||||
|
("US/Mountain", "(GMT-0600) US/Mountain"),
|
||||||
|
("America/Atikokan", "(GMT-0500) America/Atikokan"),
|
||||||
|
("America/Bogota", "(GMT-0500) America/Bogota"),
|
||||||
|
("America/Cancun", "(GMT-0500) America/Cancun"),
|
||||||
|
("America/Cayman", "(GMT-0500) America/Cayman"),
|
||||||
|
("America/Chicago", "(GMT-0500) America/Chicago"),
|
||||||
|
("America/Eirunepe", "(GMT-0500) America/Eirunepe"),
|
||||||
|
("America/Guayaquil", "(GMT-0500) America/Guayaquil"),
|
||||||
|
(
|
||||||
|
"America/Indiana/Knox",
|
||||||
|
"(GMT-0500) America/Indiana/Knox",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Indiana/Tell_City",
|
||||||
|
"(GMT-0500) America/Indiana/Tell_City",
|
||||||
|
),
|
||||||
|
("America/Jamaica", "(GMT-0500) America/Jamaica"),
|
||||||
|
("America/Lima", "(GMT-0500) America/Lima"),
|
||||||
|
("America/Matamoros", "(GMT-0500) America/Matamoros"),
|
||||||
|
("America/Menominee", "(GMT-0500) America/Menominee"),
|
||||||
|
(
|
||||||
|
"America/North_Dakota/Beulah",
|
||||||
|
"(GMT-0500) America/North_Dakota/Beulah",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/North_Dakota/Center",
|
||||||
|
"(GMT-0500) America/North_Dakota/Center",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/North_Dakota/New_Salem",
|
||||||
|
"(GMT-0500) America/North_Dakota/New_Salem",
|
||||||
|
),
|
||||||
|
("America/Ojinaga", "(GMT-0500) America/Ojinaga"),
|
||||||
|
("America/Panama", "(GMT-0500) America/Panama"),
|
||||||
|
(
|
||||||
|
"America/Rankin_Inlet",
|
||||||
|
"(GMT-0500) America/Rankin_Inlet",
|
||||||
|
),
|
||||||
|
("America/Resolute", "(GMT-0500) America/Resolute"),
|
||||||
|
("America/Rio_Branco", "(GMT-0500) America/Rio_Branco"),
|
||||||
|
("America/Winnipeg", "(GMT-0500) America/Winnipeg"),
|
||||||
|
("Canada/Central", "(GMT-0500) Canada/Central"),
|
||||||
|
("Pacific/Easter", "(GMT-0500) Pacific/Easter"),
|
||||||
|
("US/Central", "(GMT-0500) US/Central"),
|
||||||
|
("America/Anguilla", "(GMT-0400) America/Anguilla"),
|
||||||
|
("America/Antigua", "(GMT-0400) America/Antigua"),
|
||||||
|
("America/Aruba", "(GMT-0400) America/Aruba"),
|
||||||
|
("America/Barbados", "(GMT-0400) America/Barbados"),
|
||||||
|
(
|
||||||
|
"America/Blanc-Sablon",
|
||||||
|
"(GMT-0400) America/Blanc-Sablon",
|
||||||
|
),
|
||||||
|
("America/Boa_Vista", "(GMT-0400) America/Boa_Vista"),
|
||||||
|
(
|
||||||
|
"America/Campo_Grande",
|
||||||
|
"(GMT-0400) America/Campo_Grande",
|
||||||
|
),
|
||||||
|
("America/Caracas", "(GMT-0400) America/Caracas"),
|
||||||
|
("America/Cuiaba", "(GMT-0400) America/Cuiaba"),
|
||||||
|
("America/Curacao", "(GMT-0400) America/Curacao"),
|
||||||
|
("America/Detroit", "(GMT-0400) America/Detroit"),
|
||||||
|
("America/Dominica", "(GMT-0400) America/Dominica"),
|
||||||
|
("America/Grand_Turk", "(GMT-0400) America/Grand_Turk"),
|
||||||
|
("America/Grenada", "(GMT-0400) America/Grenada"),
|
||||||
|
("America/Guadeloupe", "(GMT-0400) America/Guadeloupe"),
|
||||||
|
("America/Guyana", "(GMT-0400) America/Guyana"),
|
||||||
|
("America/Havana", "(GMT-0400) America/Havana"),
|
||||||
|
(
|
||||||
|
"America/Indiana/Indianapolis",
|
||||||
|
"(GMT-0400) America/Indiana/Indianapolis",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Indiana/Marengo",
|
||||||
|
"(GMT-0400) America/Indiana/Marengo",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Indiana/Petersburg",
|
||||||
|
"(GMT-0400) America/Indiana/Petersburg",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Indiana/Vevay",
|
||||||
|
"(GMT-0400) America/Indiana/Vevay",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Indiana/Vincennes",
|
||||||
|
"(GMT-0400) America/Indiana/Vincennes",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Indiana/Winamac",
|
||||||
|
"(GMT-0400) America/Indiana/Winamac",
|
||||||
|
),
|
||||||
|
("America/Iqaluit", "(GMT-0400) America/Iqaluit"),
|
||||||
|
(
|
||||||
|
"America/Kentucky/Louisville",
|
||||||
|
"(GMT-0400) America/Kentucky/Louisville",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Kentucky/Monticello",
|
||||||
|
"(GMT-0400) America/Kentucky/Monticello",
|
||||||
|
),
|
||||||
|
("America/Kralendijk", "(GMT-0400) America/Kralendijk"),
|
||||||
|
("America/La_Paz", "(GMT-0400) America/La_Paz"),
|
||||||
|
(
|
||||||
|
"America/Lower_Princes",
|
||||||
|
"(GMT-0400) America/Lower_Princes",
|
||||||
|
),
|
||||||
|
("America/Manaus", "(GMT-0400) America/Manaus"),
|
||||||
|
("America/Marigot", "(GMT-0400) America/Marigot"),
|
||||||
|
("America/Martinique", "(GMT-0400) America/Martinique"),
|
||||||
|
("America/Montserrat", "(GMT-0400) America/Montserrat"),
|
||||||
|
("America/Nassau", "(GMT-0400) America/Nassau"),
|
||||||
|
("America/New_York", "(GMT-0400) America/New_York"),
|
||||||
|
(
|
||||||
|
"America/Port-au-Prince",
|
||||||
|
"(GMT-0400) America/Port-au-Prince",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Port_of_Spain",
|
||||||
|
"(GMT-0400) America/Port_of_Spain",
|
||||||
|
),
|
||||||
|
("America/Porto_Velho", "(GMT-0400) America/Porto_Velho"),
|
||||||
|
("America/Puerto_Rico", "(GMT-0400) America/Puerto_Rico"),
|
||||||
|
(
|
||||||
|
"America/Santo_Domingo",
|
||||||
|
"(GMT-0400) America/Santo_Domingo",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/St_Barthelemy",
|
||||||
|
"(GMT-0400) America/St_Barthelemy",
|
||||||
|
),
|
||||||
|
("America/St_Kitts", "(GMT-0400) America/St_Kitts"),
|
||||||
|
("America/St_Lucia", "(GMT-0400) America/St_Lucia"),
|
||||||
|
("America/St_Thomas", "(GMT-0400) America/St_Thomas"),
|
||||||
|
("America/St_Vincent", "(GMT-0400) America/St_Vincent"),
|
||||||
|
("America/Toronto", "(GMT-0400) America/Toronto"),
|
||||||
|
("America/Tortola", "(GMT-0400) America/Tortola"),
|
||||||
|
("Canada/Eastern", "(GMT-0400) Canada/Eastern"),
|
||||||
|
("US/Eastern", "(GMT-0400) US/Eastern"),
|
||||||
|
("America/Araguaina", "(GMT-0300) America/Araguaina"),
|
||||||
|
(
|
||||||
|
"America/Argentina/Buenos_Aires",
|
||||||
|
"(GMT-0300) America/Argentina/Buenos_Aires",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Argentina/Catamarca",
|
||||||
|
"(GMT-0300) America/Argentina/Catamarca",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Argentina/Cordoba",
|
||||||
|
"(GMT-0300) America/Argentina/Cordoba",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Argentina/Jujuy",
|
||||||
|
"(GMT-0300) America/Argentina/Jujuy",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Argentina/La_Rioja",
|
||||||
|
"(GMT-0300) America/Argentina/La_Rioja",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Argentina/Mendoza",
|
||||||
|
"(GMT-0300) America/Argentina/Mendoza",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Argentina/Rio_Gallegos",
|
||||||
|
"(GMT-0300) America/Argentina/Rio_Gallegos",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Argentina/Salta",
|
||||||
|
"(GMT-0300) America/Argentina/Salta",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Argentina/San_Juan",
|
||||||
|
"(GMT-0300) America/Argentina/San_Juan",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Argentina/San_Luis",
|
||||||
|
"(GMT-0300) America/Argentina/San_Luis",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Argentina/Tucuman",
|
||||||
|
"(GMT-0300) America/Argentina/Tucuman",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Argentina/Ushuaia",
|
||||||
|
"(GMT-0300) America/Argentina/Ushuaia",
|
||||||
|
),
|
||||||
|
("America/Asuncion", "(GMT-0300) America/Asuncion"),
|
||||||
|
("America/Bahia", "(GMT-0300) America/Bahia"),
|
||||||
|
("America/Belem", "(GMT-0300) America/Belem"),
|
||||||
|
("America/Cayenne", "(GMT-0300) America/Cayenne"),
|
||||||
|
("America/Fortaleza", "(GMT-0300) America/Fortaleza"),
|
||||||
|
("America/Glace_Bay", "(GMT-0300) America/Glace_Bay"),
|
||||||
|
("America/Goose_Bay", "(GMT-0300) America/Goose_Bay"),
|
||||||
|
("America/Halifax", "(GMT-0300) America/Halifax"),
|
||||||
|
("America/Maceio", "(GMT-0300) America/Maceio"),
|
||||||
|
("America/Moncton", "(GMT-0300) America/Moncton"),
|
||||||
|
("America/Montevideo", "(GMT-0300) America/Montevideo"),
|
||||||
|
("America/Paramaribo", "(GMT-0300) America/Paramaribo"),
|
||||||
|
(
|
||||||
|
"America/Punta_Arenas",
|
||||||
|
"(GMT-0300) America/Punta_Arenas",
|
||||||
|
),
|
||||||
|
("America/Recife", "(GMT-0300) America/Recife"),
|
||||||
|
("America/Santarem", "(GMT-0300) America/Santarem"),
|
||||||
|
("America/Santiago", "(GMT-0300) America/Santiago"),
|
||||||
|
("America/Sao_Paulo", "(GMT-0300) America/Sao_Paulo"),
|
||||||
|
("America/Thule", "(GMT-0300) America/Thule"),
|
||||||
|
("Antarctica/Palmer", "(GMT-0300) Antarctica/Palmer"),
|
||||||
|
("Antarctica/Rothera", "(GMT-0300) Antarctica/Rothera"),
|
||||||
|
("Atlantic/Bermuda", "(GMT-0300) Atlantic/Bermuda"),
|
||||||
|
("Atlantic/Stanley", "(GMT-0300) Atlantic/Stanley"),
|
||||||
|
("Canada/Atlantic", "(GMT-0300) Canada/Atlantic"),
|
||||||
|
("America/St_Johns", "(GMT-0230) America/St_Johns"),
|
||||||
|
("Canada/Newfoundland", "(GMT-0230) Canada/Newfoundland"),
|
||||||
|
("America/Miquelon", "(GMT-0200) America/Miquelon"),
|
||||||
|
("America/Noronha", "(GMT-0200) America/Noronha"),
|
||||||
|
("America/Nuuk", "(GMT-0200) America/Nuuk"),
|
||||||
|
(
|
||||||
|
"Atlantic/South_Georgia",
|
||||||
|
"(GMT-0200) Atlantic/South_Georgia",
|
||||||
|
),
|
||||||
|
("Atlantic/Cape_Verde", "(GMT-0100) Atlantic/Cape_Verde"),
|
||||||
|
("Africa/Abidjan", "(GMT+0000) Africa/Abidjan"),
|
||||||
|
("Africa/Accra", "(GMT+0000) Africa/Accra"),
|
||||||
|
("Africa/Bamako", "(GMT+0000) Africa/Bamako"),
|
||||||
|
("Africa/Banjul", "(GMT+0000) Africa/Banjul"),
|
||||||
|
("Africa/Bissau", "(GMT+0000) Africa/Bissau"),
|
||||||
|
("Africa/Conakry", "(GMT+0000) Africa/Conakry"),
|
||||||
|
("Africa/Dakar", "(GMT+0000) Africa/Dakar"),
|
||||||
|
("Africa/Freetown", "(GMT+0000) Africa/Freetown"),
|
||||||
|
("Africa/Lome", "(GMT+0000) Africa/Lome"),
|
||||||
|
("Africa/Monrovia", "(GMT+0000) Africa/Monrovia"),
|
||||||
|
("Africa/Nouakchott", "(GMT+0000) Africa/Nouakchott"),
|
||||||
|
("Africa/Ouagadougou", "(GMT+0000) Africa/Ouagadougou"),
|
||||||
|
("Africa/Sao_Tome", "(GMT+0000) Africa/Sao_Tome"),
|
||||||
|
(
|
||||||
|
"America/Danmarkshavn",
|
||||||
|
"(GMT+0000) America/Danmarkshavn",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Scoresbysund",
|
||||||
|
"(GMT+0000) America/Scoresbysund",
|
||||||
|
),
|
||||||
|
("Atlantic/Azores", "(GMT+0000) Atlantic/Azores"),
|
||||||
|
("Atlantic/Reykjavik", "(GMT+0000) Atlantic/Reykjavik"),
|
||||||
|
("Atlantic/St_Helena", "(GMT+0000) Atlantic/St_Helena"),
|
||||||
|
("GMT", "(GMT+0000) GMT"),
|
||||||
|
("UTC", "(GMT+0000) UTC"),
|
||||||
|
("Africa/Algiers", "(GMT+0100) Africa/Algiers"),
|
||||||
|
("Africa/Bangui", "(GMT+0100) Africa/Bangui"),
|
||||||
|
("Africa/Brazzaville", "(GMT+0100) Africa/Brazzaville"),
|
||||||
|
("Africa/Casablanca", "(GMT+0100) Africa/Casablanca"),
|
||||||
|
("Africa/Douala", "(GMT+0100) Africa/Douala"),
|
||||||
|
("Africa/El_Aaiun", "(GMT+0100) Africa/El_Aaiun"),
|
||||||
|
("Africa/Kinshasa", "(GMT+0100) Africa/Kinshasa"),
|
||||||
|
("Africa/Lagos", "(GMT+0100) Africa/Lagos"),
|
||||||
|
("Africa/Libreville", "(GMT+0100) Africa/Libreville"),
|
||||||
|
("Africa/Luanda", "(GMT+0100) Africa/Luanda"),
|
||||||
|
("Africa/Malabo", "(GMT+0100) Africa/Malabo"),
|
||||||
|
("Africa/Ndjamena", "(GMT+0100) Africa/Ndjamena"),
|
||||||
|
("Africa/Niamey", "(GMT+0100) Africa/Niamey"),
|
||||||
|
("Africa/Porto-Novo", "(GMT+0100) Africa/Porto-Novo"),
|
||||||
|
("Africa/Tunis", "(GMT+0100) Africa/Tunis"),
|
||||||
|
("Atlantic/Canary", "(GMT+0100) Atlantic/Canary"),
|
||||||
|
("Atlantic/Faroe", "(GMT+0100) Atlantic/Faroe"),
|
||||||
|
("Atlantic/Madeira", "(GMT+0100) Atlantic/Madeira"),
|
||||||
|
("Europe/Dublin", "(GMT+0100) Europe/Dublin"),
|
||||||
|
("Europe/Guernsey", "(GMT+0100) Europe/Guernsey"),
|
||||||
|
("Europe/Isle_of_Man", "(GMT+0100) Europe/Isle_of_Man"),
|
||||||
|
("Europe/Jersey", "(GMT+0100) Europe/Jersey"),
|
||||||
|
("Europe/Lisbon", "(GMT+0100) Europe/Lisbon"),
|
||||||
|
("Europe/London", "(GMT+0100) Europe/London"),
|
||||||
|
("Africa/Blantyre", "(GMT+0200) Africa/Blantyre"),
|
||||||
|
("Africa/Bujumbura", "(GMT+0200) Africa/Bujumbura"),
|
||||||
|
("Africa/Cairo", "(GMT+0200) Africa/Cairo"),
|
||||||
|
("Africa/Ceuta", "(GMT+0200) Africa/Ceuta"),
|
||||||
|
("Africa/Gaborone", "(GMT+0200) Africa/Gaborone"),
|
||||||
|
("Africa/Harare", "(GMT+0200) Africa/Harare"),
|
||||||
|
("Africa/Johannesburg", "(GMT+0200) Africa/Johannesburg"),
|
||||||
|
("Africa/Juba", "(GMT+0200) Africa/Juba"),
|
||||||
|
("Africa/Khartoum", "(GMT+0200) Africa/Khartoum"),
|
||||||
|
("Africa/Kigali", "(GMT+0200) Africa/Kigali"),
|
||||||
|
("Africa/Lubumbashi", "(GMT+0200) Africa/Lubumbashi"),
|
||||||
|
("Africa/Lusaka", "(GMT+0200) Africa/Lusaka"),
|
||||||
|
("Africa/Maputo", "(GMT+0200) Africa/Maputo"),
|
||||||
|
("Africa/Maseru", "(GMT+0200) Africa/Maseru"),
|
||||||
|
("Africa/Mbabane", "(GMT+0200) Africa/Mbabane"),
|
||||||
|
("Africa/Tripoli", "(GMT+0200) Africa/Tripoli"),
|
||||||
|
("Africa/Windhoek", "(GMT+0200) Africa/Windhoek"),
|
||||||
|
("Antarctica/Troll", "(GMT+0200) Antarctica/Troll"),
|
||||||
|
("Arctic/Longyearbyen", "(GMT+0200) Arctic/Longyearbyen"),
|
||||||
|
("Europe/Amsterdam", "(GMT+0200) Europe/Amsterdam"),
|
||||||
|
("Europe/Andorra", "(GMT+0200) Europe/Andorra"),
|
||||||
|
("Europe/Belgrade", "(GMT+0200) Europe/Belgrade"),
|
||||||
|
("Europe/Berlin", "(GMT+0200) Europe/Berlin"),
|
||||||
|
("Europe/Bratislava", "(GMT+0200) Europe/Bratislava"),
|
||||||
|
("Europe/Brussels", "(GMT+0200) Europe/Brussels"),
|
||||||
|
("Europe/Budapest", "(GMT+0200) Europe/Budapest"),
|
||||||
|
("Europe/Busingen", "(GMT+0200) Europe/Busingen"),
|
||||||
|
("Europe/Copenhagen", "(GMT+0200) Europe/Copenhagen"),
|
||||||
|
("Europe/Gibraltar", "(GMT+0200) Europe/Gibraltar"),
|
||||||
|
("Europe/Kaliningrad", "(GMT+0200) Europe/Kaliningrad"),
|
||||||
|
("Europe/Ljubljana", "(GMT+0200) Europe/Ljubljana"),
|
||||||
|
("Europe/Luxembourg", "(GMT+0200) Europe/Luxembourg"),
|
||||||
|
("Europe/Madrid", "(GMT+0200) Europe/Madrid"),
|
||||||
|
("Europe/Malta", "(GMT+0200) Europe/Malta"),
|
||||||
|
("Europe/Monaco", "(GMT+0200) Europe/Monaco"),
|
||||||
|
("Europe/Oslo", "(GMT+0200) Europe/Oslo"),
|
||||||
|
("Europe/Paris", "(GMT+0200) Europe/Paris"),
|
||||||
|
("Europe/Podgorica", "(GMT+0200) Europe/Podgorica"),
|
||||||
|
("Europe/Prague", "(GMT+0200) Europe/Prague"),
|
||||||
|
("Europe/Rome", "(GMT+0200) Europe/Rome"),
|
||||||
|
("Europe/San_Marino", "(GMT+0200) Europe/San_Marino"),
|
||||||
|
("Europe/Sarajevo", "(GMT+0200) Europe/Sarajevo"),
|
||||||
|
("Europe/Skopje", "(GMT+0200) Europe/Skopje"),
|
||||||
|
("Europe/Stockholm", "(GMT+0200) Europe/Stockholm"),
|
||||||
|
("Europe/Tirane", "(GMT+0200) Europe/Tirane"),
|
||||||
|
("Europe/Vaduz", "(GMT+0200) Europe/Vaduz"),
|
||||||
|
("Europe/Vatican", "(GMT+0200) Europe/Vatican"),
|
||||||
|
("Europe/Vienna", "(GMT+0200) Europe/Vienna"),
|
||||||
|
("Europe/Warsaw", "(GMT+0200) Europe/Warsaw"),
|
||||||
|
("Europe/Zagreb", "(GMT+0200) Europe/Zagreb"),
|
||||||
|
("Europe/Zurich", "(GMT+0200) Europe/Zurich"),
|
||||||
|
("Africa/Addis_Ababa", "(GMT+0300) Africa/Addis_Ababa"),
|
||||||
|
("Africa/Asmara", "(GMT+0300) Africa/Asmara"),
|
||||||
|
(
|
||||||
|
"Africa/Dar_es_Salaam",
|
||||||
|
"(GMT+0300) Africa/Dar_es_Salaam",
|
||||||
|
),
|
||||||
|
("Africa/Djibouti", "(GMT+0300) Africa/Djibouti"),
|
||||||
|
("Africa/Kampala", "(GMT+0300) Africa/Kampala"),
|
||||||
|
("Africa/Mogadishu", "(GMT+0300) Africa/Mogadishu"),
|
||||||
|
("Africa/Nairobi", "(GMT+0300) Africa/Nairobi"),
|
||||||
|
("Antarctica/Syowa", "(GMT+0300) Antarctica/Syowa"),
|
||||||
|
("Asia/Aden", "(GMT+0300) Asia/Aden"),
|
||||||
|
("Asia/Amman", "(GMT+0300) Asia/Amman"),
|
||||||
|
("Asia/Baghdad", "(GMT+0300) Asia/Baghdad"),
|
||||||
|
("Asia/Bahrain", "(GMT+0300) Asia/Bahrain"),
|
||||||
|
("Asia/Beirut", "(GMT+0300) Asia/Beirut"),
|
||||||
|
("Asia/Damascus", "(GMT+0300) Asia/Damascus"),
|
||||||
|
("Asia/Famagusta", "(GMT+0300) Asia/Famagusta"),
|
||||||
|
("Asia/Gaza", "(GMT+0300) Asia/Gaza"),
|
||||||
|
("Asia/Hebron", "(GMT+0300) Asia/Hebron"),
|
||||||
|
("Asia/Jerusalem", "(GMT+0300) Asia/Jerusalem"),
|
||||||
|
("Asia/Kuwait", "(GMT+0300) Asia/Kuwait"),
|
||||||
|
("Asia/Nicosia", "(GMT+0300) Asia/Nicosia"),
|
||||||
|
("Asia/Qatar", "(GMT+0300) Asia/Qatar"),
|
||||||
|
("Asia/Riyadh", "(GMT+0300) Asia/Riyadh"),
|
||||||
|
("Europe/Athens", "(GMT+0300) Europe/Athens"),
|
||||||
|
("Europe/Bucharest", "(GMT+0300) Europe/Bucharest"),
|
||||||
|
("Europe/Chisinau", "(GMT+0300) Europe/Chisinau"),
|
||||||
|
("Europe/Helsinki", "(GMT+0300) Europe/Helsinki"),
|
||||||
|
("Europe/Istanbul", "(GMT+0300) Europe/Istanbul"),
|
||||||
|
("Europe/Kirov", "(GMT+0300) Europe/Kirov"),
|
||||||
|
("Europe/Kyiv", "(GMT+0300) Europe/Kyiv"),
|
||||||
|
("Europe/Mariehamn", "(GMT+0300) Europe/Mariehamn"),
|
||||||
|
("Europe/Minsk", "(GMT+0300) Europe/Minsk"),
|
||||||
|
("Europe/Moscow", "(GMT+0300) Europe/Moscow"),
|
||||||
|
("Europe/Riga", "(GMT+0300) Europe/Riga"),
|
||||||
|
("Europe/Simferopol", "(GMT+0300) Europe/Simferopol"),
|
||||||
|
("Europe/Sofia", "(GMT+0300) Europe/Sofia"),
|
||||||
|
("Europe/Tallinn", "(GMT+0300) Europe/Tallinn"),
|
||||||
|
("Europe/Vilnius", "(GMT+0300) Europe/Vilnius"),
|
||||||
|
("Europe/Volgograd", "(GMT+0300) Europe/Volgograd"),
|
||||||
|
("Indian/Antananarivo", "(GMT+0300) Indian/Antananarivo"),
|
||||||
|
("Indian/Comoro", "(GMT+0300) Indian/Comoro"),
|
||||||
|
("Indian/Mayotte", "(GMT+0300) Indian/Mayotte"),
|
||||||
|
("Asia/Tehran", "(GMT+0330) Asia/Tehran"),
|
||||||
|
("Asia/Baku", "(GMT+0400) Asia/Baku"),
|
||||||
|
("Asia/Dubai", "(GMT+0400) Asia/Dubai"),
|
||||||
|
("Asia/Muscat", "(GMT+0400) Asia/Muscat"),
|
||||||
|
("Asia/Tbilisi", "(GMT+0400) Asia/Tbilisi"),
|
||||||
|
("Asia/Yerevan", "(GMT+0400) Asia/Yerevan"),
|
||||||
|
("Europe/Astrakhan", "(GMT+0400) Europe/Astrakhan"),
|
||||||
|
("Europe/Samara", "(GMT+0400) Europe/Samara"),
|
||||||
|
("Europe/Saratov", "(GMT+0400) Europe/Saratov"),
|
||||||
|
("Europe/Ulyanovsk", "(GMT+0400) Europe/Ulyanovsk"),
|
||||||
|
("Indian/Mahe", "(GMT+0400) Indian/Mahe"),
|
||||||
|
("Indian/Mauritius", "(GMT+0400) Indian/Mauritius"),
|
||||||
|
("Indian/Reunion", "(GMT+0400) Indian/Reunion"),
|
||||||
|
("Asia/Kabul", "(GMT+0430) Asia/Kabul"),
|
||||||
|
("Antarctica/Mawson", "(GMT+0500) Antarctica/Mawson"),
|
||||||
|
("Asia/Aqtau", "(GMT+0500) Asia/Aqtau"),
|
||||||
|
("Asia/Aqtobe", "(GMT+0500) Asia/Aqtobe"),
|
||||||
|
("Asia/Ashgabat", "(GMT+0500) Asia/Ashgabat"),
|
||||||
|
("Asia/Atyrau", "(GMT+0500) Asia/Atyrau"),
|
||||||
|
("Asia/Dushanbe", "(GMT+0500) Asia/Dushanbe"),
|
||||||
|
("Asia/Karachi", "(GMT+0500) Asia/Karachi"),
|
||||||
|
("Asia/Oral", "(GMT+0500) Asia/Oral"),
|
||||||
|
("Asia/Qyzylorda", "(GMT+0500) Asia/Qyzylorda"),
|
||||||
|
("Asia/Samarkand", "(GMT+0500) Asia/Samarkand"),
|
||||||
|
("Asia/Tashkent", "(GMT+0500) Asia/Tashkent"),
|
||||||
|
("Asia/Yekaterinburg", "(GMT+0500) Asia/Yekaterinburg"),
|
||||||
|
("Indian/Kerguelen", "(GMT+0500) Indian/Kerguelen"),
|
||||||
|
("Indian/Maldives", "(GMT+0500) Indian/Maldives"),
|
||||||
|
("Asia/Colombo", "(GMT+0530) Asia/Colombo"),
|
||||||
|
("Asia/Kolkata", "(GMT+0530) Asia/Kolkata"),
|
||||||
|
("Asia/Kathmandu", "(GMT+0545) Asia/Kathmandu"),
|
||||||
|
("Antarctica/Vostok", "(GMT+0600) Antarctica/Vostok"),
|
||||||
|
("Asia/Almaty", "(GMT+0600) Asia/Almaty"),
|
||||||
|
("Asia/Bishkek", "(GMT+0600) Asia/Bishkek"),
|
||||||
|
("Asia/Dhaka", "(GMT+0600) Asia/Dhaka"),
|
||||||
|
("Asia/Omsk", "(GMT+0600) Asia/Omsk"),
|
||||||
|
("Asia/Qostanay", "(GMT+0600) Asia/Qostanay"),
|
||||||
|
("Asia/Thimphu", "(GMT+0600) Asia/Thimphu"),
|
||||||
|
("Asia/Urumqi", "(GMT+0600) Asia/Urumqi"),
|
||||||
|
("Indian/Chagos", "(GMT+0600) Indian/Chagos"),
|
||||||
|
("Asia/Yangon", "(GMT+0630) Asia/Yangon"),
|
||||||
|
("Indian/Cocos", "(GMT+0630) Indian/Cocos"),
|
||||||
|
("Antarctica/Davis", "(GMT+0700) Antarctica/Davis"),
|
||||||
|
("Asia/Bangkok", "(GMT+0700) Asia/Bangkok"),
|
||||||
|
("Asia/Barnaul", "(GMT+0700) Asia/Barnaul"),
|
||||||
|
("Asia/Ho_Chi_Minh", "(GMT+0700) Asia/Ho_Chi_Minh"),
|
||||||
|
("Asia/Hovd", "(GMT+0700) Asia/Hovd"),
|
||||||
|
("Asia/Jakarta", "(GMT+0700) Asia/Jakarta"),
|
||||||
|
("Asia/Krasnoyarsk", "(GMT+0700) Asia/Krasnoyarsk"),
|
||||||
|
("Asia/Novokuznetsk", "(GMT+0700) Asia/Novokuznetsk"),
|
||||||
|
("Asia/Novosibirsk", "(GMT+0700) Asia/Novosibirsk"),
|
||||||
|
("Asia/Phnom_Penh", "(GMT+0700) Asia/Phnom_Penh"),
|
||||||
|
("Asia/Pontianak", "(GMT+0700) Asia/Pontianak"),
|
||||||
|
("Asia/Tomsk", "(GMT+0700) Asia/Tomsk"),
|
||||||
|
("Asia/Vientiane", "(GMT+0700) Asia/Vientiane"),
|
||||||
|
("Indian/Christmas", "(GMT+0700) Indian/Christmas"),
|
||||||
|
("Asia/Brunei", "(GMT+0800) Asia/Brunei"),
|
||||||
|
("Asia/Choibalsan", "(GMT+0800) Asia/Choibalsan"),
|
||||||
|
("Asia/Hong_Kong", "(GMT+0800) Asia/Hong_Kong"),
|
||||||
|
("Asia/Irkutsk", "(GMT+0800) Asia/Irkutsk"),
|
||||||
|
("Asia/Kuala_Lumpur", "(GMT+0800) Asia/Kuala_Lumpur"),
|
||||||
|
("Asia/Kuching", "(GMT+0800) Asia/Kuching"),
|
||||||
|
("Asia/Macau", "(GMT+0800) Asia/Macau"),
|
||||||
|
("Asia/Makassar", "(GMT+0800) Asia/Makassar"),
|
||||||
|
("Asia/Manila", "(GMT+0800) Asia/Manila"),
|
||||||
|
("Asia/Shanghai", "(GMT+0800) Asia/Shanghai"),
|
||||||
|
("Asia/Singapore", "(GMT+0800) Asia/Singapore"),
|
||||||
|
("Asia/Taipei", "(GMT+0800) Asia/Taipei"),
|
||||||
|
("Asia/Ulaanbaatar", "(GMT+0800) Asia/Ulaanbaatar"),
|
||||||
|
("Australia/Perth", "(GMT+0800) Australia/Perth"),
|
||||||
|
("Australia/Eucla", "(GMT+0845) Australia/Eucla"),
|
||||||
|
("Asia/Chita", "(GMT+0900) Asia/Chita"),
|
||||||
|
("Asia/Dili", "(GMT+0900) Asia/Dili"),
|
||||||
|
("Asia/Jayapura", "(GMT+0900) Asia/Jayapura"),
|
||||||
|
("Asia/Khandyga", "(GMT+0900) Asia/Khandyga"),
|
||||||
|
("Asia/Pyongyang", "(GMT+0900) Asia/Pyongyang"),
|
||||||
|
("Asia/Seoul", "(GMT+0900) Asia/Seoul"),
|
||||||
|
("Asia/Tokyo", "(GMT+0900) Asia/Tokyo"),
|
||||||
|
("Asia/Yakutsk", "(GMT+0900) Asia/Yakutsk"),
|
||||||
|
("Pacific/Palau", "(GMT+0900) Pacific/Palau"),
|
||||||
|
("Australia/Darwin", "(GMT+0930) Australia/Darwin"),
|
||||||
|
(
|
||||||
|
"Antarctica/DumontDUrville",
|
||||||
|
"(GMT+1000) Antarctica/DumontDUrville",
|
||||||
|
),
|
||||||
|
("Asia/Ust-Nera", "(GMT+1000) Asia/Ust-Nera"),
|
||||||
|
("Asia/Vladivostok", "(GMT+1000) Asia/Vladivostok"),
|
||||||
|
("Australia/Brisbane", "(GMT+1000) Australia/Brisbane"),
|
||||||
|
("Australia/Lindeman", "(GMT+1000) Australia/Lindeman"),
|
||||||
|
("Pacific/Chuuk", "(GMT+1000) Pacific/Chuuk"),
|
||||||
|
("Pacific/Guam", "(GMT+1000) Pacific/Guam"),
|
||||||
|
(
|
||||||
|
"Pacific/Port_Moresby",
|
||||||
|
"(GMT+1000) Pacific/Port_Moresby",
|
||||||
|
),
|
||||||
|
("Pacific/Saipan", "(GMT+1000) Pacific/Saipan"),
|
||||||
|
("Australia/Adelaide", "(GMT+1030) Australia/Adelaide"),
|
||||||
|
(
|
||||||
|
"Australia/Broken_Hill",
|
||||||
|
"(GMT+1030) Australia/Broken_Hill",
|
||||||
|
),
|
||||||
|
("Antarctica/Casey", "(GMT+1100) Antarctica/Casey"),
|
||||||
|
(
|
||||||
|
"Antarctica/Macquarie",
|
||||||
|
"(GMT+1100) Antarctica/Macquarie",
|
||||||
|
),
|
||||||
|
("Asia/Magadan", "(GMT+1100) Asia/Magadan"),
|
||||||
|
("Asia/Sakhalin", "(GMT+1100) Asia/Sakhalin"),
|
||||||
|
("Asia/Srednekolymsk", "(GMT+1100) Asia/Srednekolymsk"),
|
||||||
|
("Australia/Hobart", "(GMT+1100) Australia/Hobart"),
|
||||||
|
("Australia/Lord_Howe", "(GMT+1100) Australia/Lord_Howe"),
|
||||||
|
("Australia/Melbourne", "(GMT+1100) Australia/Melbourne"),
|
||||||
|
("Australia/Sydney", "(GMT+1100) Australia/Sydney"),
|
||||||
|
(
|
||||||
|
"Pacific/Bougainville",
|
||||||
|
"(GMT+1100) Pacific/Bougainville",
|
||||||
|
),
|
||||||
|
("Pacific/Efate", "(GMT+1100) Pacific/Efate"),
|
||||||
|
("Pacific/Guadalcanal", "(GMT+1100) Pacific/Guadalcanal"),
|
||||||
|
("Pacific/Kosrae", "(GMT+1100) Pacific/Kosrae"),
|
||||||
|
("Pacific/Noumea", "(GMT+1100) Pacific/Noumea"),
|
||||||
|
("Pacific/Pohnpei", "(GMT+1100) Pacific/Pohnpei"),
|
||||||
|
("Asia/Anadyr", "(GMT+1200) Asia/Anadyr"),
|
||||||
|
("Asia/Kamchatka", "(GMT+1200) Asia/Kamchatka"),
|
||||||
|
("Pacific/Fiji", "(GMT+1200) Pacific/Fiji"),
|
||||||
|
("Pacific/Funafuti", "(GMT+1200) Pacific/Funafuti"),
|
||||||
|
("Pacific/Kwajalein", "(GMT+1200) Pacific/Kwajalein"),
|
||||||
|
("Pacific/Majuro", "(GMT+1200) Pacific/Majuro"),
|
||||||
|
("Pacific/Nauru", "(GMT+1200) Pacific/Nauru"),
|
||||||
|
("Pacific/Norfolk", "(GMT+1200) Pacific/Norfolk"),
|
||||||
|
("Pacific/Tarawa", "(GMT+1200) Pacific/Tarawa"),
|
||||||
|
("Pacific/Wake", "(GMT+1200) Pacific/Wake"),
|
||||||
|
("Pacific/Wallis", "(GMT+1200) Pacific/Wallis"),
|
||||||
|
("Antarctica/McMurdo", "(GMT+1300) Antarctica/McMurdo"),
|
||||||
|
("Pacific/Apia", "(GMT+1300) Pacific/Apia"),
|
||||||
|
("Pacific/Auckland", "(GMT+1300) Pacific/Auckland"),
|
||||||
|
("Pacific/Fakaofo", "(GMT+1300) Pacific/Fakaofo"),
|
||||||
|
("Pacific/Kanton", "(GMT+1300) Pacific/Kanton"),
|
||||||
|
("Pacific/Tongatapu", "(GMT+1300) Pacific/Tongatapu"),
|
||||||
|
("Pacific/Chatham", "(GMT+1345) Pacific/Chatham"),
|
||||||
|
("Pacific/Kiritimati", "(GMT+1400) Pacific/Kiritimati"),
|
||||||
|
],
|
||||||
|
default="UTC",
|
||||||
|
max_length=255,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.16 on 2024-10-15 18:47
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("profiles", "0017_userprofile_todoist_auth_key_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="todoist_user_id",
|
||||||
|
field=models.CharField(blank=True, max_length=100, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
# Generated by Django 4.2.16 on 2024-10-20 20:04
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import encrypted_field.fields
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("profiles", "0018_userprofile_todoist_user_id"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="webdav_auto_import",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="webdav_pass",
|
||||||
|
field=encrypted_field.fields.EncryptedField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="webdav_url",
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="webdav_user",
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,611 @@
|
|||||||
|
# Generated by Django 4.2.16 on 2024-11-05 19:29
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("profiles", "0019_userprofile_webdav_auto_import_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="ntfy_enabled",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="ntfy_url",
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="timezone",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("Pacific/Midway", "(GMT-1100) Pacific/Midway"),
|
||||||
|
("Pacific/Niue", "(GMT-1100) Pacific/Niue"),
|
||||||
|
("Pacific/Pago_Pago", "(GMT-1100) Pacific/Pago_Pago"),
|
||||||
|
("America/Adak", "(GMT-1000) America/Adak"),
|
||||||
|
("Pacific/Honolulu", "(GMT-1000) Pacific/Honolulu"),
|
||||||
|
("Pacific/Rarotonga", "(GMT-1000) Pacific/Rarotonga"),
|
||||||
|
("Pacific/Tahiti", "(GMT-1000) Pacific/Tahiti"),
|
||||||
|
("US/Hawaii", "(GMT-1000) US/Hawaii"),
|
||||||
|
("Pacific/Marquesas", "(GMT-0930) Pacific/Marquesas"),
|
||||||
|
("America/Anchorage", "(GMT-0900) America/Anchorage"),
|
||||||
|
("America/Juneau", "(GMT-0900) America/Juneau"),
|
||||||
|
("America/Metlakatla", "(GMT-0900) America/Metlakatla"),
|
||||||
|
("America/Nome", "(GMT-0900) America/Nome"),
|
||||||
|
("America/Sitka", "(GMT-0900) America/Sitka"),
|
||||||
|
("America/Yakutat", "(GMT-0900) America/Yakutat"),
|
||||||
|
("Pacific/Gambier", "(GMT-0900) Pacific/Gambier"),
|
||||||
|
("US/Alaska", "(GMT-0900) US/Alaska"),
|
||||||
|
("America/Los_Angeles", "(GMT-0800) America/Los_Angeles"),
|
||||||
|
("America/Tijuana", "(GMT-0800) America/Tijuana"),
|
||||||
|
("America/Vancouver", "(GMT-0800) America/Vancouver"),
|
||||||
|
("Canada/Pacific", "(GMT-0800) Canada/Pacific"),
|
||||||
|
("Pacific/Pitcairn", "(GMT-0800) Pacific/Pitcairn"),
|
||||||
|
("US/Pacific", "(GMT-0800) US/Pacific"),
|
||||||
|
("America/Boise", "(GMT-0700) America/Boise"),
|
||||||
|
(
|
||||||
|
"America/Cambridge_Bay",
|
||||||
|
"(GMT-0700) America/Cambridge_Bay",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Ciudad_Juarez",
|
||||||
|
"(GMT-0700) America/Ciudad_Juarez",
|
||||||
|
),
|
||||||
|
("America/Creston", "(GMT-0700) America/Creston"),
|
||||||
|
("America/Dawson", "(GMT-0700) America/Dawson"),
|
||||||
|
(
|
||||||
|
"America/Dawson_Creek",
|
||||||
|
"(GMT-0700) America/Dawson_Creek",
|
||||||
|
),
|
||||||
|
("America/Denver", "(GMT-0700) America/Denver"),
|
||||||
|
("America/Edmonton", "(GMT-0700) America/Edmonton"),
|
||||||
|
("America/Fort_Nelson", "(GMT-0700) America/Fort_Nelson"),
|
||||||
|
("America/Hermosillo", "(GMT-0700) America/Hermosillo"),
|
||||||
|
("America/Inuvik", "(GMT-0700) America/Inuvik"),
|
||||||
|
("America/Mazatlan", "(GMT-0700) America/Mazatlan"),
|
||||||
|
("America/Phoenix", "(GMT-0700) America/Phoenix"),
|
||||||
|
("America/Whitehorse", "(GMT-0700) America/Whitehorse"),
|
||||||
|
("America/Yellowknife", "(GMT-0700) America/Yellowknife"),
|
||||||
|
("Canada/Mountain", "(GMT-0700) Canada/Mountain"),
|
||||||
|
("US/Arizona", "(GMT-0700) US/Arizona"),
|
||||||
|
("US/Mountain", "(GMT-0700) US/Mountain"),
|
||||||
|
(
|
||||||
|
"America/Bahia_Banderas",
|
||||||
|
"(GMT-0600) America/Bahia_Banderas",
|
||||||
|
),
|
||||||
|
("America/Belize", "(GMT-0600) America/Belize"),
|
||||||
|
("America/Chicago", "(GMT-0600) America/Chicago"),
|
||||||
|
("America/Chihuahua", "(GMT-0600) America/Chihuahua"),
|
||||||
|
("America/Costa_Rica", "(GMT-0600) America/Costa_Rica"),
|
||||||
|
("America/El_Salvador", "(GMT-0600) America/El_Salvador"),
|
||||||
|
("America/Guatemala", "(GMT-0600) America/Guatemala"),
|
||||||
|
(
|
||||||
|
"America/Indiana/Knox",
|
||||||
|
"(GMT-0600) America/Indiana/Knox",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Indiana/Tell_City",
|
||||||
|
"(GMT-0600) America/Indiana/Tell_City",
|
||||||
|
),
|
||||||
|
("America/Managua", "(GMT-0600) America/Managua"),
|
||||||
|
("America/Matamoros", "(GMT-0600) America/Matamoros"),
|
||||||
|
("America/Menominee", "(GMT-0600) America/Menominee"),
|
||||||
|
("America/Merida", "(GMT-0600) America/Merida"),
|
||||||
|
("America/Mexico_City", "(GMT-0600) America/Mexico_City"),
|
||||||
|
("America/Monterrey", "(GMT-0600) America/Monterrey"),
|
||||||
|
(
|
||||||
|
"America/North_Dakota/Beulah",
|
||||||
|
"(GMT-0600) America/North_Dakota/Beulah",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/North_Dakota/Center",
|
||||||
|
"(GMT-0600) America/North_Dakota/Center",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/North_Dakota/New_Salem",
|
||||||
|
"(GMT-0600) America/North_Dakota/New_Salem",
|
||||||
|
),
|
||||||
|
("America/Ojinaga", "(GMT-0600) America/Ojinaga"),
|
||||||
|
(
|
||||||
|
"America/Rankin_Inlet",
|
||||||
|
"(GMT-0600) America/Rankin_Inlet",
|
||||||
|
),
|
||||||
|
("America/Regina", "(GMT-0600) America/Regina"),
|
||||||
|
("America/Resolute", "(GMT-0600) America/Resolute"),
|
||||||
|
(
|
||||||
|
"America/Swift_Current",
|
||||||
|
"(GMT-0600) America/Swift_Current",
|
||||||
|
),
|
||||||
|
("America/Tegucigalpa", "(GMT-0600) America/Tegucigalpa"),
|
||||||
|
("America/Winnipeg", "(GMT-0600) America/Winnipeg"),
|
||||||
|
("Canada/Central", "(GMT-0600) Canada/Central"),
|
||||||
|
("Pacific/Galapagos", "(GMT-0600) Pacific/Galapagos"),
|
||||||
|
("US/Central", "(GMT-0600) US/Central"),
|
||||||
|
("America/Atikokan", "(GMT-0500) America/Atikokan"),
|
||||||
|
("America/Bogota", "(GMT-0500) America/Bogota"),
|
||||||
|
("America/Cancun", "(GMT-0500) America/Cancun"),
|
||||||
|
("America/Cayman", "(GMT-0500) America/Cayman"),
|
||||||
|
("America/Detroit", "(GMT-0500) America/Detroit"),
|
||||||
|
("America/Eirunepe", "(GMT-0500) America/Eirunepe"),
|
||||||
|
("America/Grand_Turk", "(GMT-0500) America/Grand_Turk"),
|
||||||
|
("America/Guayaquil", "(GMT-0500) America/Guayaquil"),
|
||||||
|
("America/Havana", "(GMT-0500) America/Havana"),
|
||||||
|
(
|
||||||
|
"America/Indiana/Indianapolis",
|
||||||
|
"(GMT-0500) America/Indiana/Indianapolis",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Indiana/Marengo",
|
||||||
|
"(GMT-0500) America/Indiana/Marengo",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Indiana/Petersburg",
|
||||||
|
"(GMT-0500) America/Indiana/Petersburg",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Indiana/Vevay",
|
||||||
|
"(GMT-0500) America/Indiana/Vevay",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Indiana/Vincennes",
|
||||||
|
"(GMT-0500) America/Indiana/Vincennes",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Indiana/Winamac",
|
||||||
|
"(GMT-0500) America/Indiana/Winamac",
|
||||||
|
),
|
||||||
|
("America/Iqaluit", "(GMT-0500) America/Iqaluit"),
|
||||||
|
("America/Jamaica", "(GMT-0500) America/Jamaica"),
|
||||||
|
(
|
||||||
|
"America/Kentucky/Louisville",
|
||||||
|
"(GMT-0500) America/Kentucky/Louisville",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Kentucky/Monticello",
|
||||||
|
"(GMT-0500) America/Kentucky/Monticello",
|
||||||
|
),
|
||||||
|
("America/Lima", "(GMT-0500) America/Lima"),
|
||||||
|
("America/Nassau", "(GMT-0500) America/Nassau"),
|
||||||
|
("America/New_York", "(GMT-0500) America/New_York"),
|
||||||
|
("America/Panama", "(GMT-0500) America/Panama"),
|
||||||
|
(
|
||||||
|
"America/Port-au-Prince",
|
||||||
|
"(GMT-0500) America/Port-au-Prince",
|
||||||
|
),
|
||||||
|
("America/Rio_Branco", "(GMT-0500) America/Rio_Branco"),
|
||||||
|
("America/Toronto", "(GMT-0500) America/Toronto"),
|
||||||
|
("Canada/Eastern", "(GMT-0500) Canada/Eastern"),
|
||||||
|
("Pacific/Easter", "(GMT-0500) Pacific/Easter"),
|
||||||
|
("US/Eastern", "(GMT-0500) US/Eastern"),
|
||||||
|
("America/Anguilla", "(GMT-0400) America/Anguilla"),
|
||||||
|
("America/Antigua", "(GMT-0400) America/Antigua"),
|
||||||
|
("America/Aruba", "(GMT-0400) America/Aruba"),
|
||||||
|
("America/Barbados", "(GMT-0400) America/Barbados"),
|
||||||
|
(
|
||||||
|
"America/Blanc-Sablon",
|
||||||
|
"(GMT-0400) America/Blanc-Sablon",
|
||||||
|
),
|
||||||
|
("America/Boa_Vista", "(GMT-0400) America/Boa_Vista"),
|
||||||
|
(
|
||||||
|
"America/Campo_Grande",
|
||||||
|
"(GMT-0400) America/Campo_Grande",
|
||||||
|
),
|
||||||
|
("America/Caracas", "(GMT-0400) America/Caracas"),
|
||||||
|
("America/Cuiaba", "(GMT-0400) America/Cuiaba"),
|
||||||
|
("America/Curacao", "(GMT-0400) America/Curacao"),
|
||||||
|
("America/Dominica", "(GMT-0400) America/Dominica"),
|
||||||
|
("America/Glace_Bay", "(GMT-0400) America/Glace_Bay"),
|
||||||
|
("America/Goose_Bay", "(GMT-0400) America/Goose_Bay"),
|
||||||
|
("America/Grenada", "(GMT-0400) America/Grenada"),
|
||||||
|
("America/Guadeloupe", "(GMT-0400) America/Guadeloupe"),
|
||||||
|
("America/Guyana", "(GMT-0400) America/Guyana"),
|
||||||
|
("America/Halifax", "(GMT-0400) America/Halifax"),
|
||||||
|
("America/Kralendijk", "(GMT-0400) America/Kralendijk"),
|
||||||
|
("America/La_Paz", "(GMT-0400) America/La_Paz"),
|
||||||
|
(
|
||||||
|
"America/Lower_Princes",
|
||||||
|
"(GMT-0400) America/Lower_Princes",
|
||||||
|
),
|
||||||
|
("America/Manaus", "(GMT-0400) America/Manaus"),
|
||||||
|
("America/Marigot", "(GMT-0400) America/Marigot"),
|
||||||
|
("America/Martinique", "(GMT-0400) America/Martinique"),
|
||||||
|
("America/Moncton", "(GMT-0400) America/Moncton"),
|
||||||
|
("America/Montserrat", "(GMT-0400) America/Montserrat"),
|
||||||
|
(
|
||||||
|
"America/Port_of_Spain",
|
||||||
|
"(GMT-0400) America/Port_of_Spain",
|
||||||
|
),
|
||||||
|
("America/Porto_Velho", "(GMT-0400) America/Porto_Velho"),
|
||||||
|
("America/Puerto_Rico", "(GMT-0400) America/Puerto_Rico"),
|
||||||
|
(
|
||||||
|
"America/Santo_Domingo",
|
||||||
|
"(GMT-0400) America/Santo_Domingo",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/St_Barthelemy",
|
||||||
|
"(GMT-0400) America/St_Barthelemy",
|
||||||
|
),
|
||||||
|
("America/St_Kitts", "(GMT-0400) America/St_Kitts"),
|
||||||
|
("America/St_Lucia", "(GMT-0400) America/St_Lucia"),
|
||||||
|
("America/St_Thomas", "(GMT-0400) America/St_Thomas"),
|
||||||
|
("America/St_Vincent", "(GMT-0400) America/St_Vincent"),
|
||||||
|
("America/Thule", "(GMT-0400) America/Thule"),
|
||||||
|
("America/Tortola", "(GMT-0400) America/Tortola"),
|
||||||
|
("Atlantic/Bermuda", "(GMT-0400) Atlantic/Bermuda"),
|
||||||
|
("Canada/Atlantic", "(GMT-0400) Canada/Atlantic"),
|
||||||
|
("America/St_Johns", "(GMT-0330) America/St_Johns"),
|
||||||
|
("Canada/Newfoundland", "(GMT-0330) Canada/Newfoundland"),
|
||||||
|
("America/Araguaina", "(GMT-0300) America/Araguaina"),
|
||||||
|
(
|
||||||
|
"America/Argentina/Buenos_Aires",
|
||||||
|
"(GMT-0300) America/Argentina/Buenos_Aires",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Argentina/Catamarca",
|
||||||
|
"(GMT-0300) America/Argentina/Catamarca",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Argentina/Cordoba",
|
||||||
|
"(GMT-0300) America/Argentina/Cordoba",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Argentina/Jujuy",
|
||||||
|
"(GMT-0300) America/Argentina/Jujuy",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Argentina/La_Rioja",
|
||||||
|
"(GMT-0300) America/Argentina/La_Rioja",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Argentina/Mendoza",
|
||||||
|
"(GMT-0300) America/Argentina/Mendoza",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Argentina/Rio_Gallegos",
|
||||||
|
"(GMT-0300) America/Argentina/Rio_Gallegos",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Argentina/Salta",
|
||||||
|
"(GMT-0300) America/Argentina/Salta",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Argentina/San_Juan",
|
||||||
|
"(GMT-0300) America/Argentina/San_Juan",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Argentina/San_Luis",
|
||||||
|
"(GMT-0300) America/Argentina/San_Luis",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Argentina/Tucuman",
|
||||||
|
"(GMT-0300) America/Argentina/Tucuman",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Argentina/Ushuaia",
|
||||||
|
"(GMT-0300) America/Argentina/Ushuaia",
|
||||||
|
),
|
||||||
|
("America/Asuncion", "(GMT-0300) America/Asuncion"),
|
||||||
|
("America/Bahia", "(GMT-0300) America/Bahia"),
|
||||||
|
("America/Belem", "(GMT-0300) America/Belem"),
|
||||||
|
("America/Cayenne", "(GMT-0300) America/Cayenne"),
|
||||||
|
("America/Fortaleza", "(GMT-0300) America/Fortaleza"),
|
||||||
|
("America/Maceio", "(GMT-0300) America/Maceio"),
|
||||||
|
("America/Miquelon", "(GMT-0300) America/Miquelon"),
|
||||||
|
("America/Montevideo", "(GMT-0300) America/Montevideo"),
|
||||||
|
("America/Paramaribo", "(GMT-0300) America/Paramaribo"),
|
||||||
|
(
|
||||||
|
"America/Punta_Arenas",
|
||||||
|
"(GMT-0300) America/Punta_Arenas",
|
||||||
|
),
|
||||||
|
("America/Recife", "(GMT-0300) America/Recife"),
|
||||||
|
("America/Santarem", "(GMT-0300) America/Santarem"),
|
||||||
|
("America/Santiago", "(GMT-0300) America/Santiago"),
|
||||||
|
("America/Sao_Paulo", "(GMT-0300) America/Sao_Paulo"),
|
||||||
|
("Antarctica/Palmer", "(GMT-0300) Antarctica/Palmer"),
|
||||||
|
("Antarctica/Rothera", "(GMT-0300) Antarctica/Rothera"),
|
||||||
|
("Atlantic/Stanley", "(GMT-0300) Atlantic/Stanley"),
|
||||||
|
("America/Noronha", "(GMT-0200) America/Noronha"),
|
||||||
|
("America/Nuuk", "(GMT-0200) America/Nuuk"),
|
||||||
|
(
|
||||||
|
"Atlantic/South_Georgia",
|
||||||
|
"(GMT-0200) Atlantic/South_Georgia",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"America/Scoresbysund",
|
||||||
|
"(GMT-0100) America/Scoresbysund",
|
||||||
|
),
|
||||||
|
("Atlantic/Azores", "(GMT-0100) Atlantic/Azores"),
|
||||||
|
("Atlantic/Cape_Verde", "(GMT-0100) Atlantic/Cape_Verde"),
|
||||||
|
("Africa/Abidjan", "(GMT+0000) Africa/Abidjan"),
|
||||||
|
("Africa/Accra", "(GMT+0000) Africa/Accra"),
|
||||||
|
("Africa/Bamako", "(GMT+0000) Africa/Bamako"),
|
||||||
|
("Africa/Banjul", "(GMT+0000) Africa/Banjul"),
|
||||||
|
("Africa/Bissau", "(GMT+0000) Africa/Bissau"),
|
||||||
|
("Africa/Conakry", "(GMT+0000) Africa/Conakry"),
|
||||||
|
("Africa/Dakar", "(GMT+0000) Africa/Dakar"),
|
||||||
|
("Africa/Freetown", "(GMT+0000) Africa/Freetown"),
|
||||||
|
("Africa/Lome", "(GMT+0000) Africa/Lome"),
|
||||||
|
("Africa/Monrovia", "(GMT+0000) Africa/Monrovia"),
|
||||||
|
("Africa/Nouakchott", "(GMT+0000) Africa/Nouakchott"),
|
||||||
|
("Africa/Ouagadougou", "(GMT+0000) Africa/Ouagadougou"),
|
||||||
|
("Africa/Sao_Tome", "(GMT+0000) Africa/Sao_Tome"),
|
||||||
|
(
|
||||||
|
"America/Danmarkshavn",
|
||||||
|
"(GMT+0000) America/Danmarkshavn",
|
||||||
|
),
|
||||||
|
("Antarctica/Troll", "(GMT+0000) Antarctica/Troll"),
|
||||||
|
("Atlantic/Canary", "(GMT+0000) Atlantic/Canary"),
|
||||||
|
("Atlantic/Faroe", "(GMT+0000) Atlantic/Faroe"),
|
||||||
|
("Atlantic/Madeira", "(GMT+0000) Atlantic/Madeira"),
|
||||||
|
("Atlantic/Reykjavik", "(GMT+0000) Atlantic/Reykjavik"),
|
||||||
|
("Atlantic/St_Helena", "(GMT+0000) Atlantic/St_Helena"),
|
||||||
|
("Europe/Dublin", "(GMT+0000) Europe/Dublin"),
|
||||||
|
("Europe/Guernsey", "(GMT+0000) Europe/Guernsey"),
|
||||||
|
("Europe/Isle_of_Man", "(GMT+0000) Europe/Isle_of_Man"),
|
||||||
|
("Europe/Jersey", "(GMT+0000) Europe/Jersey"),
|
||||||
|
("Europe/Lisbon", "(GMT+0000) Europe/Lisbon"),
|
||||||
|
("Europe/London", "(GMT+0000) Europe/London"),
|
||||||
|
("GMT", "(GMT+0000) GMT"),
|
||||||
|
("UTC", "(GMT+0000) UTC"),
|
||||||
|
("Africa/Algiers", "(GMT+0100) Africa/Algiers"),
|
||||||
|
("Africa/Bangui", "(GMT+0100) Africa/Bangui"),
|
||||||
|
("Africa/Brazzaville", "(GMT+0100) Africa/Brazzaville"),
|
||||||
|
("Africa/Casablanca", "(GMT+0100) Africa/Casablanca"),
|
||||||
|
("Africa/Ceuta", "(GMT+0100) Africa/Ceuta"),
|
||||||
|
("Africa/Douala", "(GMT+0100) Africa/Douala"),
|
||||||
|
("Africa/El_Aaiun", "(GMT+0100) Africa/El_Aaiun"),
|
||||||
|
("Africa/Kinshasa", "(GMT+0100) Africa/Kinshasa"),
|
||||||
|
("Africa/Lagos", "(GMT+0100) Africa/Lagos"),
|
||||||
|
("Africa/Libreville", "(GMT+0100) Africa/Libreville"),
|
||||||
|
("Africa/Luanda", "(GMT+0100) Africa/Luanda"),
|
||||||
|
("Africa/Malabo", "(GMT+0100) Africa/Malabo"),
|
||||||
|
("Africa/Ndjamena", "(GMT+0100) Africa/Ndjamena"),
|
||||||
|
("Africa/Niamey", "(GMT+0100) Africa/Niamey"),
|
||||||
|
("Africa/Porto-Novo", "(GMT+0100) Africa/Porto-Novo"),
|
||||||
|
("Africa/Tunis", "(GMT+0100) Africa/Tunis"),
|
||||||
|
("Arctic/Longyearbyen", "(GMT+0100) Arctic/Longyearbyen"),
|
||||||
|
("Europe/Amsterdam", "(GMT+0100) Europe/Amsterdam"),
|
||||||
|
("Europe/Andorra", "(GMT+0100) Europe/Andorra"),
|
||||||
|
("Europe/Belgrade", "(GMT+0100) Europe/Belgrade"),
|
||||||
|
("Europe/Berlin", "(GMT+0100) Europe/Berlin"),
|
||||||
|
("Europe/Bratislava", "(GMT+0100) Europe/Bratislava"),
|
||||||
|
("Europe/Brussels", "(GMT+0100) Europe/Brussels"),
|
||||||
|
("Europe/Budapest", "(GMT+0100) Europe/Budapest"),
|
||||||
|
("Europe/Busingen", "(GMT+0100) Europe/Busingen"),
|
||||||
|
("Europe/Copenhagen", "(GMT+0100) Europe/Copenhagen"),
|
||||||
|
("Europe/Gibraltar", "(GMT+0100) Europe/Gibraltar"),
|
||||||
|
("Europe/Ljubljana", "(GMT+0100) Europe/Ljubljana"),
|
||||||
|
("Europe/Luxembourg", "(GMT+0100) Europe/Luxembourg"),
|
||||||
|
("Europe/Madrid", "(GMT+0100) Europe/Madrid"),
|
||||||
|
("Europe/Malta", "(GMT+0100) Europe/Malta"),
|
||||||
|
("Europe/Monaco", "(GMT+0100) Europe/Monaco"),
|
||||||
|
("Europe/Oslo", "(GMT+0100) Europe/Oslo"),
|
||||||
|
("Europe/Paris", "(GMT+0100) Europe/Paris"),
|
||||||
|
("Europe/Podgorica", "(GMT+0100) Europe/Podgorica"),
|
||||||
|
("Europe/Prague", "(GMT+0100) Europe/Prague"),
|
||||||
|
("Europe/Rome", "(GMT+0100) Europe/Rome"),
|
||||||
|
("Europe/San_Marino", "(GMT+0100) Europe/San_Marino"),
|
||||||
|
("Europe/Sarajevo", "(GMT+0100) Europe/Sarajevo"),
|
||||||
|
("Europe/Skopje", "(GMT+0100) Europe/Skopje"),
|
||||||
|
("Europe/Stockholm", "(GMT+0100) Europe/Stockholm"),
|
||||||
|
("Europe/Tirane", "(GMT+0100) Europe/Tirane"),
|
||||||
|
("Europe/Vaduz", "(GMT+0100) Europe/Vaduz"),
|
||||||
|
("Europe/Vatican", "(GMT+0100) Europe/Vatican"),
|
||||||
|
("Europe/Vienna", "(GMT+0100) Europe/Vienna"),
|
||||||
|
("Europe/Warsaw", "(GMT+0100) Europe/Warsaw"),
|
||||||
|
("Europe/Zagreb", "(GMT+0100) Europe/Zagreb"),
|
||||||
|
("Europe/Zurich", "(GMT+0100) Europe/Zurich"),
|
||||||
|
("Africa/Blantyre", "(GMT+0200) Africa/Blantyre"),
|
||||||
|
("Africa/Bujumbura", "(GMT+0200) Africa/Bujumbura"),
|
||||||
|
("Africa/Cairo", "(GMT+0200) Africa/Cairo"),
|
||||||
|
("Africa/Gaborone", "(GMT+0200) Africa/Gaborone"),
|
||||||
|
("Africa/Harare", "(GMT+0200) Africa/Harare"),
|
||||||
|
("Africa/Johannesburg", "(GMT+0200) Africa/Johannesburg"),
|
||||||
|
("Africa/Juba", "(GMT+0200) Africa/Juba"),
|
||||||
|
("Africa/Khartoum", "(GMT+0200) Africa/Khartoum"),
|
||||||
|
("Africa/Kigali", "(GMT+0200) Africa/Kigali"),
|
||||||
|
("Africa/Lubumbashi", "(GMT+0200) Africa/Lubumbashi"),
|
||||||
|
("Africa/Lusaka", "(GMT+0200) Africa/Lusaka"),
|
||||||
|
("Africa/Maputo", "(GMT+0200) Africa/Maputo"),
|
||||||
|
("Africa/Maseru", "(GMT+0200) Africa/Maseru"),
|
||||||
|
("Africa/Mbabane", "(GMT+0200) Africa/Mbabane"),
|
||||||
|
("Africa/Tripoli", "(GMT+0200) Africa/Tripoli"),
|
||||||
|
("Africa/Windhoek", "(GMT+0200) Africa/Windhoek"),
|
||||||
|
("Asia/Beirut", "(GMT+0200) Asia/Beirut"),
|
||||||
|
("Asia/Famagusta", "(GMT+0200) Asia/Famagusta"),
|
||||||
|
("Asia/Gaza", "(GMT+0200) Asia/Gaza"),
|
||||||
|
("Asia/Hebron", "(GMT+0200) Asia/Hebron"),
|
||||||
|
("Asia/Jerusalem", "(GMT+0200) Asia/Jerusalem"),
|
||||||
|
("Asia/Nicosia", "(GMT+0200) Asia/Nicosia"),
|
||||||
|
("Europe/Athens", "(GMT+0200) Europe/Athens"),
|
||||||
|
("Europe/Bucharest", "(GMT+0200) Europe/Bucharest"),
|
||||||
|
("Europe/Chisinau", "(GMT+0200) Europe/Chisinau"),
|
||||||
|
("Europe/Helsinki", "(GMT+0200) Europe/Helsinki"),
|
||||||
|
("Europe/Kaliningrad", "(GMT+0200) Europe/Kaliningrad"),
|
||||||
|
("Europe/Kyiv", "(GMT+0200) Europe/Kyiv"),
|
||||||
|
("Europe/Mariehamn", "(GMT+0200) Europe/Mariehamn"),
|
||||||
|
("Europe/Riga", "(GMT+0200) Europe/Riga"),
|
||||||
|
("Europe/Sofia", "(GMT+0200) Europe/Sofia"),
|
||||||
|
("Europe/Tallinn", "(GMT+0200) Europe/Tallinn"),
|
||||||
|
("Europe/Vilnius", "(GMT+0200) Europe/Vilnius"),
|
||||||
|
("Africa/Addis_Ababa", "(GMT+0300) Africa/Addis_Ababa"),
|
||||||
|
("Africa/Asmara", "(GMT+0300) Africa/Asmara"),
|
||||||
|
(
|
||||||
|
"Africa/Dar_es_Salaam",
|
||||||
|
"(GMT+0300) Africa/Dar_es_Salaam",
|
||||||
|
),
|
||||||
|
("Africa/Djibouti", "(GMT+0300) Africa/Djibouti"),
|
||||||
|
("Africa/Kampala", "(GMT+0300) Africa/Kampala"),
|
||||||
|
("Africa/Mogadishu", "(GMT+0300) Africa/Mogadishu"),
|
||||||
|
("Africa/Nairobi", "(GMT+0300) Africa/Nairobi"),
|
||||||
|
("Antarctica/Syowa", "(GMT+0300) Antarctica/Syowa"),
|
||||||
|
("Asia/Aden", "(GMT+0300) Asia/Aden"),
|
||||||
|
("Asia/Amman", "(GMT+0300) Asia/Amman"),
|
||||||
|
("Asia/Baghdad", "(GMT+0300) Asia/Baghdad"),
|
||||||
|
("Asia/Bahrain", "(GMT+0300) Asia/Bahrain"),
|
||||||
|
("Asia/Damascus", "(GMT+0300) Asia/Damascus"),
|
||||||
|
("Asia/Kuwait", "(GMT+0300) Asia/Kuwait"),
|
||||||
|
("Asia/Qatar", "(GMT+0300) Asia/Qatar"),
|
||||||
|
("Asia/Riyadh", "(GMT+0300) Asia/Riyadh"),
|
||||||
|
("Europe/Istanbul", "(GMT+0300) Europe/Istanbul"),
|
||||||
|
("Europe/Kirov", "(GMT+0300) Europe/Kirov"),
|
||||||
|
("Europe/Minsk", "(GMT+0300) Europe/Minsk"),
|
||||||
|
("Europe/Moscow", "(GMT+0300) Europe/Moscow"),
|
||||||
|
("Europe/Simferopol", "(GMT+0300) Europe/Simferopol"),
|
||||||
|
("Europe/Volgograd", "(GMT+0300) Europe/Volgograd"),
|
||||||
|
("Indian/Antananarivo", "(GMT+0300) Indian/Antananarivo"),
|
||||||
|
("Indian/Comoro", "(GMT+0300) Indian/Comoro"),
|
||||||
|
("Indian/Mayotte", "(GMT+0300) Indian/Mayotte"),
|
||||||
|
("Asia/Tehran", "(GMT+0330) Asia/Tehran"),
|
||||||
|
("Asia/Baku", "(GMT+0400) Asia/Baku"),
|
||||||
|
("Asia/Dubai", "(GMT+0400) Asia/Dubai"),
|
||||||
|
("Asia/Muscat", "(GMT+0400) Asia/Muscat"),
|
||||||
|
("Asia/Tbilisi", "(GMT+0400) Asia/Tbilisi"),
|
||||||
|
("Asia/Yerevan", "(GMT+0400) Asia/Yerevan"),
|
||||||
|
("Europe/Astrakhan", "(GMT+0400) Europe/Astrakhan"),
|
||||||
|
("Europe/Samara", "(GMT+0400) Europe/Samara"),
|
||||||
|
("Europe/Saratov", "(GMT+0400) Europe/Saratov"),
|
||||||
|
("Europe/Ulyanovsk", "(GMT+0400) Europe/Ulyanovsk"),
|
||||||
|
("Indian/Mahe", "(GMT+0400) Indian/Mahe"),
|
||||||
|
("Indian/Mauritius", "(GMT+0400) Indian/Mauritius"),
|
||||||
|
("Indian/Reunion", "(GMT+0400) Indian/Reunion"),
|
||||||
|
("Asia/Kabul", "(GMT+0430) Asia/Kabul"),
|
||||||
|
("Antarctica/Mawson", "(GMT+0500) Antarctica/Mawson"),
|
||||||
|
("Asia/Aqtau", "(GMT+0500) Asia/Aqtau"),
|
||||||
|
("Asia/Aqtobe", "(GMT+0500) Asia/Aqtobe"),
|
||||||
|
("Asia/Ashgabat", "(GMT+0500) Asia/Ashgabat"),
|
||||||
|
("Asia/Atyrau", "(GMT+0500) Asia/Atyrau"),
|
||||||
|
("Asia/Dushanbe", "(GMT+0500) Asia/Dushanbe"),
|
||||||
|
("Asia/Karachi", "(GMT+0500) Asia/Karachi"),
|
||||||
|
("Asia/Oral", "(GMT+0500) Asia/Oral"),
|
||||||
|
("Asia/Qyzylorda", "(GMT+0500) Asia/Qyzylorda"),
|
||||||
|
("Asia/Samarkand", "(GMT+0500) Asia/Samarkand"),
|
||||||
|
("Asia/Tashkent", "(GMT+0500) Asia/Tashkent"),
|
||||||
|
("Asia/Yekaterinburg", "(GMT+0500) Asia/Yekaterinburg"),
|
||||||
|
("Indian/Kerguelen", "(GMT+0500) Indian/Kerguelen"),
|
||||||
|
("Indian/Maldives", "(GMT+0500) Indian/Maldives"),
|
||||||
|
("Asia/Colombo", "(GMT+0530) Asia/Colombo"),
|
||||||
|
("Asia/Kolkata", "(GMT+0530) Asia/Kolkata"),
|
||||||
|
("Asia/Kathmandu", "(GMT+0545) Asia/Kathmandu"),
|
||||||
|
("Antarctica/Vostok", "(GMT+0600) Antarctica/Vostok"),
|
||||||
|
("Asia/Almaty", "(GMT+0600) Asia/Almaty"),
|
||||||
|
("Asia/Bishkek", "(GMT+0600) Asia/Bishkek"),
|
||||||
|
("Asia/Dhaka", "(GMT+0600) Asia/Dhaka"),
|
||||||
|
("Asia/Omsk", "(GMT+0600) Asia/Omsk"),
|
||||||
|
("Asia/Qostanay", "(GMT+0600) Asia/Qostanay"),
|
||||||
|
("Asia/Thimphu", "(GMT+0600) Asia/Thimphu"),
|
||||||
|
("Asia/Urumqi", "(GMT+0600) Asia/Urumqi"),
|
||||||
|
("Indian/Chagos", "(GMT+0600) Indian/Chagos"),
|
||||||
|
("Asia/Yangon", "(GMT+0630) Asia/Yangon"),
|
||||||
|
("Indian/Cocos", "(GMT+0630) Indian/Cocos"),
|
||||||
|
("Antarctica/Davis", "(GMT+0700) Antarctica/Davis"),
|
||||||
|
("Asia/Bangkok", "(GMT+0700) Asia/Bangkok"),
|
||||||
|
("Asia/Barnaul", "(GMT+0700) Asia/Barnaul"),
|
||||||
|
("Asia/Ho_Chi_Minh", "(GMT+0700) Asia/Ho_Chi_Minh"),
|
||||||
|
("Asia/Hovd", "(GMT+0700) Asia/Hovd"),
|
||||||
|
("Asia/Jakarta", "(GMT+0700) Asia/Jakarta"),
|
||||||
|
("Asia/Krasnoyarsk", "(GMT+0700) Asia/Krasnoyarsk"),
|
||||||
|
("Asia/Novokuznetsk", "(GMT+0700) Asia/Novokuznetsk"),
|
||||||
|
("Asia/Novosibirsk", "(GMT+0700) Asia/Novosibirsk"),
|
||||||
|
("Asia/Phnom_Penh", "(GMT+0700) Asia/Phnom_Penh"),
|
||||||
|
("Asia/Pontianak", "(GMT+0700) Asia/Pontianak"),
|
||||||
|
("Asia/Tomsk", "(GMT+0700) Asia/Tomsk"),
|
||||||
|
("Asia/Vientiane", "(GMT+0700) Asia/Vientiane"),
|
||||||
|
("Indian/Christmas", "(GMT+0700) Indian/Christmas"),
|
||||||
|
("Asia/Brunei", "(GMT+0800) Asia/Brunei"),
|
||||||
|
("Asia/Choibalsan", "(GMT+0800) Asia/Choibalsan"),
|
||||||
|
("Asia/Hong_Kong", "(GMT+0800) Asia/Hong_Kong"),
|
||||||
|
("Asia/Irkutsk", "(GMT+0800) Asia/Irkutsk"),
|
||||||
|
("Asia/Kuala_Lumpur", "(GMT+0800) Asia/Kuala_Lumpur"),
|
||||||
|
("Asia/Kuching", "(GMT+0800) Asia/Kuching"),
|
||||||
|
("Asia/Macau", "(GMT+0800) Asia/Macau"),
|
||||||
|
("Asia/Makassar", "(GMT+0800) Asia/Makassar"),
|
||||||
|
("Asia/Manila", "(GMT+0800) Asia/Manila"),
|
||||||
|
("Asia/Shanghai", "(GMT+0800) Asia/Shanghai"),
|
||||||
|
("Asia/Singapore", "(GMT+0800) Asia/Singapore"),
|
||||||
|
("Asia/Taipei", "(GMT+0800) Asia/Taipei"),
|
||||||
|
("Asia/Ulaanbaatar", "(GMT+0800) Asia/Ulaanbaatar"),
|
||||||
|
("Australia/Perth", "(GMT+0800) Australia/Perth"),
|
||||||
|
("Australia/Eucla", "(GMT+0845) Australia/Eucla"),
|
||||||
|
("Asia/Chita", "(GMT+0900) Asia/Chita"),
|
||||||
|
("Asia/Dili", "(GMT+0900) Asia/Dili"),
|
||||||
|
("Asia/Jayapura", "(GMT+0900) Asia/Jayapura"),
|
||||||
|
("Asia/Khandyga", "(GMT+0900) Asia/Khandyga"),
|
||||||
|
("Asia/Pyongyang", "(GMT+0900) Asia/Pyongyang"),
|
||||||
|
("Asia/Seoul", "(GMT+0900) Asia/Seoul"),
|
||||||
|
("Asia/Tokyo", "(GMT+0900) Asia/Tokyo"),
|
||||||
|
("Asia/Yakutsk", "(GMT+0900) Asia/Yakutsk"),
|
||||||
|
("Pacific/Palau", "(GMT+0900) Pacific/Palau"),
|
||||||
|
("Australia/Darwin", "(GMT+0930) Australia/Darwin"),
|
||||||
|
(
|
||||||
|
"Antarctica/DumontDUrville",
|
||||||
|
"(GMT+1000) Antarctica/DumontDUrville",
|
||||||
|
),
|
||||||
|
("Asia/Ust-Nera", "(GMT+1000) Asia/Ust-Nera"),
|
||||||
|
("Asia/Vladivostok", "(GMT+1000) Asia/Vladivostok"),
|
||||||
|
("Australia/Brisbane", "(GMT+1000) Australia/Brisbane"),
|
||||||
|
("Australia/Lindeman", "(GMT+1000) Australia/Lindeman"),
|
||||||
|
("Pacific/Chuuk", "(GMT+1000) Pacific/Chuuk"),
|
||||||
|
("Pacific/Guam", "(GMT+1000) Pacific/Guam"),
|
||||||
|
(
|
||||||
|
"Pacific/Port_Moresby",
|
||||||
|
"(GMT+1000) Pacific/Port_Moresby",
|
||||||
|
),
|
||||||
|
("Pacific/Saipan", "(GMT+1000) Pacific/Saipan"),
|
||||||
|
("Australia/Adelaide", "(GMT+1030) Australia/Adelaide"),
|
||||||
|
(
|
||||||
|
"Australia/Broken_Hill",
|
||||||
|
"(GMT+1030) Australia/Broken_Hill",
|
||||||
|
),
|
||||||
|
("Antarctica/Casey", "(GMT+1100) Antarctica/Casey"),
|
||||||
|
(
|
||||||
|
"Antarctica/Macquarie",
|
||||||
|
"(GMT+1100) Antarctica/Macquarie",
|
||||||
|
),
|
||||||
|
("Asia/Magadan", "(GMT+1100) Asia/Magadan"),
|
||||||
|
("Asia/Sakhalin", "(GMT+1100) Asia/Sakhalin"),
|
||||||
|
("Asia/Srednekolymsk", "(GMT+1100) Asia/Srednekolymsk"),
|
||||||
|
("Australia/Hobart", "(GMT+1100) Australia/Hobart"),
|
||||||
|
("Australia/Lord_Howe", "(GMT+1100) Australia/Lord_Howe"),
|
||||||
|
("Australia/Melbourne", "(GMT+1100) Australia/Melbourne"),
|
||||||
|
("Australia/Sydney", "(GMT+1100) Australia/Sydney"),
|
||||||
|
(
|
||||||
|
"Pacific/Bougainville",
|
||||||
|
"(GMT+1100) Pacific/Bougainville",
|
||||||
|
),
|
||||||
|
("Pacific/Efate", "(GMT+1100) Pacific/Efate"),
|
||||||
|
("Pacific/Guadalcanal", "(GMT+1100) Pacific/Guadalcanal"),
|
||||||
|
("Pacific/Kosrae", "(GMT+1100) Pacific/Kosrae"),
|
||||||
|
("Pacific/Noumea", "(GMT+1100) Pacific/Noumea"),
|
||||||
|
("Pacific/Pohnpei", "(GMT+1100) Pacific/Pohnpei"),
|
||||||
|
("Asia/Anadyr", "(GMT+1200) Asia/Anadyr"),
|
||||||
|
("Asia/Kamchatka", "(GMT+1200) Asia/Kamchatka"),
|
||||||
|
("Pacific/Fiji", "(GMT+1200) Pacific/Fiji"),
|
||||||
|
("Pacific/Funafuti", "(GMT+1200) Pacific/Funafuti"),
|
||||||
|
("Pacific/Kwajalein", "(GMT+1200) Pacific/Kwajalein"),
|
||||||
|
("Pacific/Majuro", "(GMT+1200) Pacific/Majuro"),
|
||||||
|
("Pacific/Nauru", "(GMT+1200) Pacific/Nauru"),
|
||||||
|
("Pacific/Norfolk", "(GMT+1200) Pacific/Norfolk"),
|
||||||
|
("Pacific/Tarawa", "(GMT+1200) Pacific/Tarawa"),
|
||||||
|
("Pacific/Wake", "(GMT+1200) Pacific/Wake"),
|
||||||
|
("Pacific/Wallis", "(GMT+1200) Pacific/Wallis"),
|
||||||
|
("Antarctica/McMurdo", "(GMT+1300) Antarctica/McMurdo"),
|
||||||
|
("Pacific/Apia", "(GMT+1300) Pacific/Apia"),
|
||||||
|
("Pacific/Auckland", "(GMT+1300) Pacific/Auckland"),
|
||||||
|
("Pacific/Fakaofo", "(GMT+1300) Pacific/Fakaofo"),
|
||||||
|
("Pacific/Kanton", "(GMT+1300) Pacific/Kanton"),
|
||||||
|
("Pacific/Tongatapu", "(GMT+1300) Pacific/Tongatapu"),
|
||||||
|
("Pacific/Chatham", "(GMT+1345) Pacific/Chatham"),
|
||||||
|
("Pacific/Kiritimati", "(GMT+1400) Pacific/Kiritimati"),
|
||||||
|
],
|
||||||
|
default="UTC",
|
||||||
|
max_length=255,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user