Compare commits
218 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e961076b4 | |||
| 14062f3b60 | |||
| 0a8acdf33f | |||
| b5d194e74f | |||
| 3a50a8b015 | |||
| 921cf9d8b3 | |||
| ba0be65ed0 | |||
| caad6329c9 | |||
| cfd6ac861e | |||
| ca36e25948 | |||
| c84acf6ae7 | |||
| 610464e732 | |||
| 047dd22069 | |||
| 2c73121367 | |||
| 9affd6e03a | |||
| b414bbf59c | |||
| 5e22cb3106 | |||
| cc9a2a64df | |||
| c2b334b926 | |||
| ad6f0e1b62 | |||
| 1df8157d8d | |||
| 7d33e6afdb | |||
| 40d112c58c | |||
| cdfd8af078 | |||
| 0ce19527a2 | |||
| f3fc58e2c0 | |||
| b02b75fa90 | |||
| b626ac583a | |||
| adc10ab43b | |||
| 5555b1b89e | |||
| 7fd90f8cf0 | |||
| f80daba67b | |||
| 8bd4fd1d4b | |||
| f30e416e8d | |||
| f29a039562 | |||
| 882367cee6 | |||
| 74c32bc4d5 | |||
| dcd7196010 | |||
| 2e57b2ce07 | |||
| 9d664ba476 | |||
| 0fa89af1d9 | |||
| 2c5516ae4e | |||
| 0cba46b103 | |||
| 5014c4428b | |||
| 1d554429f1 | |||
| daea268465 | |||
| 1cceb2bd63 | |||
| e15d253d58 | |||
| 8ed0bd3d21 | |||
| 113f200eb7 | |||
| 1b5ffd2a3c | |||
| f168608cee | |||
| c28f93a1bb | |||
| 99ad2f797f | |||
| 1f28179ed1 | |||
| 1648f988ff | |||
| dc3a77f14f | |||
| 5994dccf23 | |||
| bfadc0d87b | |||
| 78782cc538 | |||
| 06277f21de | |||
| 8fbe37a163 | |||
| 20dd4d217a | |||
| 53657a9454 | |||
| 42066cebb2 | |||
| 6d60a729b7 | |||
| 89541a13f2 | |||
| f87bc5fd55 | |||
| 0a8078dee0 | |||
| 37da74708c | |||
| b470e7acea | |||
| 5cdd12783f | |||
| 8bf43f298c | |||
| a269949a23 | |||
| 8a5f200b44 | |||
| 1c41ca3e18 | |||
| b69963b75f | |||
| 1327d5da40 | |||
| 5d5b49f19b | |||
| d93c827b80 | |||
| 2349c39487 | |||
| ae96831fe4 | |||
| 706be782dc | |||
| a72e0b0fb9 | |||
| f5df6c97a9 | |||
| e00f2de4b1 | |||
| b4a7cafa3d | |||
| aebe4f899d | |||
| 2bf0ca1a8c | |||
| a0c414135c | |||
| 1dcd151c65 | |||
| 2e9d92d9c7 | |||
| 8776f75d12 | |||
| c2a53875a0 | |||
| ffb0b7372d | |||
| d03744c240 | |||
| d9a7929cb0 | |||
| 9bc087d824 | |||
| e65ab4d8c2 | |||
| 2d26a7c3bd | |||
| 1fd3e47656 | |||
| dd0ce967c8 | |||
| 1f6a4956d2 | |||
| 22f5d288d4 | |||
| 77cf67ea09 | |||
| d1dfc5502d | |||
| 0d83050956 | |||
| 134bd3f650 | |||
| 261f2eb9ce | |||
| 8f56bfba50 | |||
| 750fa05b15 | |||
| 302ddf6650 | |||
| 49dd148f9f | |||
| 79d4e79f3e | |||
| 874d9dc7d2 | |||
| 0cd95036da | |||
| 829c6b9978 | |||
| df1bfd4177 | |||
| 0fff2ec388 | |||
| 8a83c9fdb3 | |||
| 651fc7a745 | |||
| c22a306840 | |||
| 11e6161502 | |||
| 3af2ad203c | |||
| 7697dd006d | |||
| 73a6277e29 | |||
| 79fc407c33 | |||
| 84ec84c018 | |||
| beb9569663 | |||
| efcf01b49f | |||
| 01270a9e32 | |||
| c9280c2bad | |||
| 416fd807f4 | |||
| 3069e93697 | |||
| e47f4905e8 | |||
| bf59fb213e | |||
| 589bf533ea | |||
| 1fb29304a3 | |||
| 42604b9e23 | |||
| 4642063510 | |||
| 65944eb911 | |||
| 4cf2ceb2dd | |||
| 78151e5070 | |||
| f90fa160d7 | |||
| 3b7498c419 | |||
| a59997dafb | |||
| 4cdb6150dc | |||
| 7fa21c27ea | |||
| b981c090c6 | |||
| 605435b9ea | |||
| f4d00b4a22 | |||
| 504620fc89 | |||
| df3424c68f | |||
| 71b5af7615 | |||
| 7035f01441 | |||
| 525c7c4e2b | |||
| 4e330a6f03 | |||
| ff18ad8efc | |||
| 15c1317395 | |||
| 6b80d107b0 | |||
| 13124aca6b | |||
| a685c6ff9c | |||
| 02200bb1f7 | |||
| 0bbd488f0d | |||
| 2c08336e33 | |||
| a637a59a40 | |||
| 123f7c53f9 | |||
| 2c26cc01ba | |||
| 36bc1c2e95 | |||
| 5651ccb990 | |||
| cd7b06eaa2 | |||
| 949416059d | |||
| ca434cb08a | |||
| 2661aee915 | |||
| 74f672a2fd | |||
| 449b74ae3f | |||
| 3d1ad8da3a | |||
| 75e374bb69 | |||
| 2ba694655b | |||
| 879942d070 | |||
| bfd210d280 | |||
| e5c6b5e8d9 | |||
| 3cd1603b91 | |||
| 27523bc7ff | |||
| 67ef1a15ec | |||
| 288027bfce | |||
| bfdf73d4c0 | |||
| 033e3c3b35 | |||
| 40504f83e1 | |||
| ccca81bbab | |||
| 84f49af163 | |||
| e044c70072 | |||
| d3e09c25bd | |||
| 30f78a9290 | |||
| a5553966de | |||
| 9e2d7a6bc0 | |||
| 70c7eda415 | |||
| 0ad7dac6cb | |||
| 209875f0e6 | |||
| 919fa1b0b4 | |||
| 0b3bc53704 | |||
| dfc1365fa3 | |||
| f22ef1a163 | |||
| 7cb818b585 | |||
| 1a5cd5106f | |||
| 3b65144b68 | |||
| 4ae13b3a1a | |||
| 3de2be50cf | |||
| 80d197bc54 | |||
| a274796405 | |||
| 37fd1d8458 | |||
| 45ddce36aa | |||
| c3ddd01a6f | |||
| 6920c99931 | |||
| d2d71b3c85 | |||
| 6300df8e9e | |||
| b427731bc3 | |||
| 6058024434 |
10
.drone.yml
10
.drone.yml
@ -14,7 +14,7 @@ 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
|
- poetry install --with dev
|
||||||
# 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 --cov-report term:skip-covered --cov=vrobbler tests
|
||||||
environment:
|
environment:
|
||||||
@ -30,7 +30,7 @@ steps:
|
|||||||
- vrobbler.service
|
- vrobbler.service
|
||||||
username: root
|
username: root
|
||||||
ssh_key:
|
ssh_key:
|
||||||
from_secret: ssh_key
|
from_secret: jail_key
|
||||||
command_timeout: 2m
|
command_timeout: 2m
|
||||||
script:
|
script:
|
||||||
- pip uninstall -y vrobbler
|
- pip uninstall -y vrobbler
|
||||||
@ -42,7 +42,7 @@ steps:
|
|||||||
branch:
|
branch:
|
||||||
- main
|
- main
|
||||||
- name: build success notification
|
- name: build success notification
|
||||||
image: parrazam/drone-ntfy
|
image: parrazam/drone-ntfy:0.3-linux-amd64
|
||||||
when:
|
when:
|
||||||
status: [success]
|
status: [success]
|
||||||
settings:
|
settings:
|
||||||
@ -50,11 +50,10 @@ steps:
|
|||||||
topic: drone
|
topic: drone
|
||||||
priority: low
|
priority: low
|
||||||
tags:
|
tags:
|
||||||
- cd
|
|
||||||
- failure
|
- failure
|
||||||
- vrobbler
|
- vrobbler
|
||||||
- name: build failure notification
|
- name: build failure notification
|
||||||
image: parrazam/drone-ntfy
|
image: parrazam/drone-ntfy:0.3-linux-amd64
|
||||||
when:
|
when:
|
||||||
status: [failure]
|
status: [failure]
|
||||||
settings:
|
settings:
|
||||||
@ -62,7 +61,6 @@ steps:
|
|||||||
topic: drone
|
topic: drone
|
||||||
priority: high
|
priority: high
|
||||||
tags:
|
tags:
|
||||||
- cd
|
|
||||||
- success
|
- success
|
||||||
- vrobbler
|
- vrobbler
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,4 +1,4 @@
|
|||||||
db.sqlite3
|
db.sqlite3*
|
||||||
vrobbler.conf
|
vrobbler.conf
|
||||||
media/
|
media/
|
||||||
dist/
|
dist/
|
||||||
|
|||||||
3
data/moods.json
Normal file
3
data/moods.json
Normal file
File diff suppressed because one or more lines are too long
3805
poetry.lock
generated
3805
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -44,8 +44,13 @@ ipython = "^8.14.0"
|
|||||||
pendulum = "^2.1.2"
|
pendulum = "^2.1.2"
|
||||||
trafilatura = "^1.6.3"
|
trafilatura = "^1.6.3"
|
||||||
django-imagekit = "^5.0.0"
|
django-imagekit = "^5.0.0"
|
||||||
|
thefuzz = "^0.22.1"
|
||||||
|
dataclass-wizard = "0.22.0"
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.group.dev]
|
||||||
|
optional = true
|
||||||
|
|
||||||
|
[tool.poetry.group.dev.dependencies]
|
||||||
Werkzeug = "2.0.3"
|
Werkzeug = "2.0.3"
|
||||||
black = "^22.3"
|
black = "^22.3"
|
||||||
coverage = "^7.0.5"
|
coverage = "^7.0.5"
|
||||||
@ -57,7 +62,6 @@ pytest-django = "^4.5.2"
|
|||||||
pytest-flake8 = "^1.1"
|
pytest-flake8 = "^1.1"
|
||||||
pytest-isort = "^3.0"
|
pytest-isort = "^3.0"
|
||||||
pytest-runner = "^6.0"
|
pytest-runner = "^6.0"
|
||||||
pytest-selenium = "^2.0.1"
|
|
||||||
time-machine = "^2.9.0"
|
time-machine = "^2.9.0"
|
||||||
types-pytz = "^2022.1"
|
types-pytz = "^2022.1"
|
||||||
types-requests = "^2.27"
|
types-requests = "^2.27"
|
||||||
@ -65,7 +69,7 @@ bandit = "^1.7.4"
|
|||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
minversion = "6.0"
|
minversion = "6.0"
|
||||||
addopts = "-ra -q"
|
addopts = "-ra -q --reuse-db"
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
DJANGO_SETTINGS_MODULE='vrobbler.settings-testing'
|
DJANGO_SETTINGS_MODULE='vrobbler.settings-testing'
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import pytest
|
||||||
from vrobbler.apps.podcasts.scrapers import scrape_data_from_google_podcasts
|
from vrobbler.apps.podcasts.scrapers import scrape_data_from_google_podcasts
|
||||||
|
|
||||||
expected_desc_snippet = (
|
expected_desc_snippet = (
|
||||||
@ -8,6 +9,7 @@ expected_img_url = "https://encrypted-tbn2.gstatic.com/images?q=tbn:ANd9GcR1F0Cf
|
|||||||
expected_google_url = "https://podcasts.google.com/feed/aHR0cHM6Ly9mZWVkcy5ucHIub3JnLzUxMDMxOC9wb2RjYXN0LnhtbA"
|
expected_google_url = "https://podcasts.google.com/feed/aHR0cHM6Ly9mZWVkcy5ucHIub3JnLzUxMDMxOC9wb2RjYXN0LnhtbA"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip("Google Podcasts is gone")
|
||||||
def test_get_not_allowed_from_mopidy():
|
def test_get_not_allowed_from_mopidy():
|
||||||
query = "Up First"
|
query = "Up First"
|
||||||
result_dict = scrape_data_from_google_podcasts(query)
|
result_dict = scrape_data_from_google_podcasts(query)
|
||||||
|
|||||||
@ -1,19 +1,39 @@
|
|||||||
import json
|
import json
|
||||||
import pytest
|
|
||||||
|
|
||||||
from rest_framework.authtoken.models import Token
|
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 scrobbles.models import Scrobble
|
||||||
|
from boardgames.models import BoardGame
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def boardgame_scrobble():
|
||||||
|
user = User.objects.create(
|
||||||
|
email="test@exmaple.com", first_name="Test", last_name="User"
|
||||||
|
)
|
||||||
|
return Scrobble.objects.create(
|
||||||
|
board_game=BoardGame.objects.create(title="Test Board Game"),
|
||||||
|
media_type="BoardGame",
|
||||||
|
played_to_completion=True,
|
||||||
|
log={
|
||||||
|
"players": [
|
||||||
|
{"user_id": user.id, "win": True, "score": 30, "color": "Blue"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class MopidyRequest:
|
class MopidyRequest:
|
||||||
name = "Same in the End"
|
name = "Same in the End"
|
||||||
artist = "Sublime"
|
artist = "Sublime"
|
||||||
album = "Sublime"
|
album = "Sublime"
|
||||||
track_number = 4
|
track_number = 4
|
||||||
run_time_ticks = 156604
|
run_time_ticks = 156604
|
||||||
run_time = "156"
|
run_time = 60
|
||||||
playback_time_ticks = 15045
|
playback_time_ticks = 15045
|
||||||
musicbrainz_track_id = "54214d63-5adf-4909-87cd-c65c37a6d558"
|
musicbrainz_track_id = "54214d63-5adf-4909-87cd-c65c37a6d558"
|
||||||
musicbrainz_album_id = "03b864cd-7761-314c-a892-05a89ddff00d"
|
musicbrainz_album_id = "03b864cd-7761-314c-a892-05a89ddff00d"
|
||||||
@ -65,8 +85,8 @@ def valid_auth_token():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mopidy_track_request_data():
|
def mopidy_track():
|
||||||
return MopidyRequest().request_json
|
return MopidyRequest()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@ -78,6 +98,61 @@ def mopidy_track_diff_album_request_data(**kwargs):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mopidy_podcast_request_data():
|
def mopidy_podcast():
|
||||||
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).request_json
|
return MopidyRequest(mopidy_uri=mopidy_uri)
|
||||||
|
|
||||||
|
|
||||||
|
class JellyfinTrackRequest:
|
||||||
|
name = "Emotion"
|
||||||
|
artist = "Carly Rae Jepsen"
|
||||||
|
album = "Emotion"
|
||||||
|
track_number = 1
|
||||||
|
item_type = "Audio"
|
||||||
|
timestamp = "2024-01-14 12:00:19"
|
||||||
|
run_time_ticks = 156604
|
||||||
|
run_time = "00:00:60"
|
||||||
|
playback_time_ticks = 15045
|
||||||
|
musicbrainz_track_id = "54214d63-5adf-4909-87cd-c65c37a6d558"
|
||||||
|
musicbrainz_album_id = "03b864cd-7761-314c-a892-05a89ddff00d"
|
||||||
|
musicbrainz_artist_id = "95f5b748-d370-47fe-85bd-0af2dc450bc0"
|
||||||
|
status = "resumed"
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
self.request_data = {
|
||||||
|
"Name": kwargs.get("name", self.name),
|
||||||
|
"Artist": kwargs.get("artist", self.artist),
|
||||||
|
"Album": kwargs.get("album", self.album),
|
||||||
|
"TrackNumber": int(kwargs.get("track_number", self.track_number)),
|
||||||
|
"RunTime": kwargs.get("run_time", self.run_time),
|
||||||
|
"ItemType": kwargs.get("item_type", self.item_type),
|
||||||
|
"UtcTimestamp": kwargs.get("timestamp", self.timestamp),
|
||||||
|
"PlaybackPositionTicks": int(
|
||||||
|
kwargs.get("playback_time_ticks", self.playback_time_ticks)
|
||||||
|
),
|
||||||
|
"Provider_musicbrainztrack": kwargs.get(
|
||||||
|
"musicbrainz_track_id", self.musicbrainz_track_id
|
||||||
|
),
|
||||||
|
"Provider_musicbrainzalbum": kwargs.get(
|
||||||
|
"musicbrainz_album_id", self.musicbrainz_album_id
|
||||||
|
),
|
||||||
|
"Provider_musicbrainzartist": kwargs.get(
|
||||||
|
"musicbrainz_artist_id", self.musicbrainz_artist_id
|
||||||
|
),
|
||||||
|
"Status": kwargs.get("status", self.status),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
for key in self.request_data.keys():
|
||||||
|
if self.request_data[key] != getattr(self, key):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def request_json(self):
|
||||||
|
return json.dumps(self.request_data)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def jellyfin_track():
|
||||||
|
return JellyfinTrackRequest()
|
||||||
|
|||||||
@ -1,6 +1,4 @@
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from unittest import mock
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import time_machine
|
import time_machine
|
||||||
@ -13,12 +11,13 @@ from profiles.models import UserProfile
|
|||||||
from scrobbles.models import Scrobble
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
|
|
||||||
def build_scrobbles(client, request_data, num=7, spacing=2):
|
def build_scrobbles(client, request_json, num=7, spacing=2):
|
||||||
url = reverse("scrobbles:mopidy-webhook")
|
url = reverse("scrobbles:mopidy-webhook")
|
||||||
user = get_user_model().objects.create(username="Test User")
|
user = get_user_model().objects.create(username="Test User")
|
||||||
UserProfile.objects.create(user=user, timezone="US/Eastern")
|
user.profile.timezone = "US/Eastern"
|
||||||
|
user.profile.save()
|
||||||
for i in range(num):
|
for i in range(num):
|
||||||
client.post(url, request_data, content_type="application/json")
|
client.post(url, request_json, content_type="application/json")
|
||||||
s = Scrobble.objects.last()
|
s = Scrobble.objects.last()
|
||||||
s.user = user
|
s.user = user
|
||||||
s.timestamp = timezone.now() - timedelta(days=i * spacing)
|
s.timestamp = timezone.now() - timedelta(days=i * spacing)
|
||||||
@ -28,8 +27,8 @@ def build_scrobbles(client, request_data, num=7, spacing=2):
|
|||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@time_machine.travel(datetime(2022, 3, 4, 1, 24))
|
@time_machine.travel(datetime(2022, 3, 4, 1, 24))
|
||||||
def test_scrobble_counts_data(client, mopidy_track_request_data):
|
def test_scrobble_counts_data(client, mopidy_track):
|
||||||
build_scrobbles(client, mopidy_track_request_data)
|
build_scrobbles(client, mopidy_track.request_json)
|
||||||
user = get_user_model().objects.first()
|
user = get_user_model().objects.first()
|
||||||
count_dict = scrobble_counts(user)
|
count_dict = scrobble_counts(user)
|
||||||
assert count_dict == {
|
assert count_dict == {
|
||||||
@ -43,8 +42,8 @@ def test_scrobble_counts_data(client, mopidy_track_request_data):
|
|||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@time_machine.travel(datetime(2022, 3, 4, 1, 24))
|
@time_machine.travel(datetime(2022, 3, 4, 1, 24))
|
||||||
def test_live_charts(client, mopidy_track_request_data):
|
def test_live_charts(client, mopidy_track):
|
||||||
build_scrobbles(client, mopidy_track_request_data, 7, 1)
|
build_scrobbles(client, mopidy_track.request_json, 7, 1)
|
||||||
user = get_user_model().objects.first()
|
user = get_user_model().objects.first()
|
||||||
|
|
||||||
week = week_of_scrobbles(user)
|
week = week_of_scrobbles(user)
|
||||||
|
|||||||
31
tests/scrobbles_tests/test_metadata.py
Normal file
31
tests/scrobbles_tests/test_metadata.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from scrobbles.dataclasses import BoardGameLogData, BoardGameScoreLogData
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_boardgame_log_data(boardgame_scrobble):
|
||||||
|
assert not boardgame_scrobble.geo_location
|
||||||
|
assert boardgame_scrobble.logdata == BoardGameLogData(
|
||||||
|
players=[
|
||||||
|
BoardGameScoreLogData(
|
||||||
|
user_id=1,
|
||||||
|
name_str="",
|
||||||
|
bgg_username="",
|
||||||
|
color="Blue",
|
||||||
|
character=None,
|
||||||
|
team=None,
|
||||||
|
score=30,
|
||||||
|
win=True,
|
||||||
|
new=None,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
location=None,
|
||||||
|
geo_location_id=None,
|
||||||
|
difficulty=None,
|
||||||
|
solo=None,
|
||||||
|
two_handed=None,
|
||||||
|
)
|
||||||
|
assert len(boardgame_scrobble.logdata.players) == 1
|
||||||
|
assert boardgame_scrobble.logdata.players[0].user.id == 1
|
||||||
|
assert boardgame_scrobble.logdata.players[0].name == "Test"
|
||||||
@ -1,7 +1,12 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
from unittest.mock import patch
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import time_machine
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from music.models import Track
|
from music.models import Track
|
||||||
from podcasts.models import Episode
|
from podcasts.models import PodcastEpisode
|
||||||
from scrobbles.models import Scrobble
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
|
|
||||||
@ -25,31 +30,57 @@ def test_bad_mopidy_request_data(client, valid_auth_token):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"seconds, expected_percent_played, expected_scrobble_id",
|
||||||
|
[
|
||||||
|
(1, 1, 1),
|
||||||
|
(58, 96, 1),
|
||||||
|
(59, 98, 1),
|
||||||
|
(60, 100, 1),
|
||||||
|
(1, 1, 2),
|
||||||
|
(1, 1, 3),
|
||||||
|
],
|
||||||
|
)
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_scrobble_mopidy_track(
|
def test_scrobble_mopidy_track(
|
||||||
client, mopidy_track_request_data, valid_auth_token
|
client,
|
||||||
|
mopidy_track,
|
||||||
|
valid_auth_token,
|
||||||
|
seconds,
|
||||||
|
expected_percent_played,
|
||||||
|
expected_scrobble_id,
|
||||||
):
|
):
|
||||||
url = reverse("scrobbles:mopidy-webhook")
|
url = reverse("scrobbles:mopidy-webhook")
|
||||||
headers = {"Authorization": f"Token {valid_auth_token}"}
|
headers = {"Authorization": f"Token {valid_auth_token}"}
|
||||||
response = client.post(
|
|
||||||
url,
|
|
||||||
mopidy_track_request_data,
|
|
||||||
content_type="application/json",
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.data == {"scrobble_id": 1}
|
|
||||||
|
|
||||||
scrobble = Scrobble.objects.get(id=1)
|
# Start new scrobble
|
||||||
assert scrobble.media_obj.__class__ == Track
|
minutes = 0
|
||||||
assert scrobble.media_obj.title == "Same in the End"
|
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="API is unstable")
|
@pytest.mark.skip(reason="Allmusic API is unstable")
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_scrobble_mopidy_same_track_different_album(
|
def test_scrobble_mopidy_same_track_different_album(
|
||||||
client,
|
client,
|
||||||
mopidy_track_request_data,
|
mopidy_track,
|
||||||
mopidy_track_diff_album_request_data,
|
mopidy_track_diff_album_request_data,
|
||||||
valid_auth_token,
|
valid_auth_token,
|
||||||
):
|
):
|
||||||
@ -57,7 +88,7 @@ def test_scrobble_mopidy_same_track_different_album(
|
|||||||
headers = {"Authorization": f"Token {valid_auth_token}"}
|
headers = {"Authorization": f"Token {valid_auth_token}"}
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url,
|
url,
|
||||||
mopidy_track_request_data,
|
mopidy_track.request_data,
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
)
|
)
|
||||||
@ -96,5 +127,85 @@ def test_scrobble_mopidy_podcast(
|
|||||||
assert response.data == {"scrobble_id": 1}
|
assert response.data == {"scrobble_id": 1}
|
||||||
|
|
||||||
scrobble = Scrobble.objects.get(id=1)
|
scrobble = Scrobble.objects.get(id=1)
|
||||||
assert scrobble.media_obj.__class__ == Episode
|
assert scrobble.media_obj.__class__ == PodcastEpisode
|
||||||
assert scrobble.media_obj.title == "Up First"
|
assert scrobble.media_obj.title == "Up First"
|
||||||
|
|
||||||
|
|
||||||
|
@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(
|
||||||
|
mock_lookup_artist,
|
||||||
|
mock_lookup_album,
|
||||||
|
mock_lookup_track,
|
||||||
|
mock_lookup_artist_tadb,
|
||||||
|
mock_lookup_album_tadb,
|
||||||
|
mock_fetch_artwork,
|
||||||
|
mock_scrape_allmusic,
|
||||||
|
client,
|
||||||
|
jellyfin_track,
|
||||||
|
valid_auth_token,
|
||||||
|
):
|
||||||
|
url = reverse("scrobbles:jellyfin-webhook")
|
||||||
|
headers = {"Authorization": f"Token {valid_auth_token}"}
|
||||||
|
|
||||||
|
with time_machine.travel(datetime(2024, 1, 14, 12, 00, 1)):
|
||||||
|
jellyfin_track.request_data["UtcTimestamp"] = timezone.now().strftime(
|
||||||
|
"%Y-%m-%d %H:%M:%S"
|
||||||
|
)
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
jellyfin_track.request_json,
|
||||||
|
content_type="application/json",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data == {"scrobble_id": 1}
|
||||||
|
|
||||||
|
scrobble = Scrobble.objects.get(id=1)
|
||||||
|
assert scrobble.media_obj.__class__ == Track
|
||||||
|
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
|
||||||
|
assert response.data == {"scrobble_id": 1}
|
||||||
|
|
||||||
|
scrobble = Scrobble.objects.get(id=1)
|
||||||
|
assert scrobble.media_obj.__class__ == Track
|
||||||
|
assert scrobble.media_obj.title == "Emotion"
|
||||||
|
|
||||||
|
with time_machine.travel(datetime(2024, 1, 14, 12, 1, 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"
|
||||||
|
|||||||
@ -20,6 +20,7 @@ VROBBLER_THESPORTSDB_API_KEY="<key>"
|
|||||||
VROBBLER_THEAUDIODB_API_KEY="<key>"
|
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>"
|
||||||
|
|
||||||
# Storages
|
# Storages
|
||||||
# VROBBLER_DATABASE_URL="postgres://USER:PASSWORD@HOST:PORT/NAME"
|
# VROBBLER_DATABASE_URL="postgres://USER:PASSWORD@HOST:PORT/NAME"
|
||||||
|
|||||||
@ -12,8 +12,8 @@ from django.urls import reverse
|
|||||||
from django_extensions.db.models import TimeStampedModel
|
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.mixins import ScrobblableMixin
|
from scrobbles.mixins import ScrobblableMixin
|
||||||
from vrobbler.apps.boardgames.bgg import lookup_boardgame_id_from_bgg
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
BNULL = {"blank": True, "null": True}
|
BNULL = {"blank": True, "null": True}
|
||||||
@ -96,6 +96,10 @@ class BoardGame(ScrobblableMixin):
|
|||||||
"boardgames:boardgame_detail", kwargs={"slug": self.uuid}
|
"boardgames:boardgame_detail", kwargs={"slug": self.uuid}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def logdata_cls(self):
|
||||||
|
return BoardGameLogData
|
||||||
|
|
||||||
def primary_image_url(self) -> str:
|
def primary_image_url(self) -> str:
|
||||||
url = ""
|
url = ""
|
||||||
if self.cover:
|
if self.cover:
|
||||||
@ -151,12 +155,12 @@ class BoardGame(ScrobblableMixin):
|
|||||||
cls, lookup_id: str, data: Optional[dict] = {}
|
cls, lookup_id: str, data: Optional[dict] = {}
|
||||||
) -> Optional["BoardGame"]:
|
) -> Optional["BoardGame"]:
|
||||||
"""Given a Lookup ID (either BGG or BGA ID), return a board game object"""
|
"""Given a Lookup ID (either BGG or BGA ID), return a board game object"""
|
||||||
boardgame = None
|
boardgame = cls.objects.filter(bggeek_id=lookup_id).first()
|
||||||
|
|
||||||
if not data:
|
if not data or not boardgame:
|
||||||
data = lookup_boardgame_from_bgg(lookup_id)
|
data = lookup_boardgame_from_bgg(lookup_id)
|
||||||
|
|
||||||
if data:
|
if data and not boardgame:
|
||||||
boardgame, created = cls.objects.get_or_create(
|
boardgame, created = cls.objects.get_or_create(
|
||||||
title=data["title"], bggeek_id=lookup_id
|
title=data["title"], bggeek_id=lookup_id
|
||||||
)
|
)
|
||||||
|
|||||||
@ -55,9 +55,7 @@ def scrape_data_from_amazon(url) -> dict:
|
|||||||
r = requests.get(url, headers=headers)
|
r = requests.get(url, headers=headers)
|
||||||
if r.status_code == 200:
|
if r.status_code == 200:
|
||||||
soup = BeautifulSoup(r.text, "html.parser")
|
soup = BeautifulSoup(r.text, "html.parser")
|
||||||
import pdb
|
# TODO Fix this scraper
|
||||||
|
|
||||||
pdb.set_trace()
|
|
||||||
data_dict["rating"] = get_rating_from_soup(soup)
|
data_dict["rating"] = get_rating_from_soup(soup)
|
||||||
data_dict["review"] = get_review_from_soup(soup)
|
data_dict["review"] = get_review_from_soup(soup)
|
||||||
return data_dict
|
return data_dict
|
||||||
|
|||||||
233
vrobbler/apps/books/comicvine.py
Normal file
233
vrobbler/apps/books/comicvine.py
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
"""
|
||||||
|
ComicVine API Information & Documentation:
|
||||||
|
https://comicvine.gamespot.com/api/
|
||||||
|
https://comicvine.gamespot.com/api/documentation
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ComicVineClient(object):
|
||||||
|
"""
|
||||||
|
Interacts with the ``search`` resource of the ComicVine API. Requires an
|
||||||
|
account on https://comicvine.gamespot.com/ in order to obtain an API key.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# All API requests made by this client will be made to this URL.
|
||||||
|
API_URL = "https://www.comicvine.com/api/search/"
|
||||||
|
|
||||||
|
# A valid User-Agent header must be set in order for our API requests to
|
||||||
|
# be accepted, otherwise our request will be rejected with a
|
||||||
|
# **403 - Forbidden** error.
|
||||||
|
HEADERS = {
|
||||||
|
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:7.0) "
|
||||||
|
"Gecko/20130825 Firefox/36.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
# A set of valid resource types to return in results.
|
||||||
|
RESOURCE_TYPES = {
|
||||||
|
"character",
|
||||||
|
"issue",
|
||||||
|
"location",
|
||||||
|
"object",
|
||||||
|
"person",
|
||||||
|
"publisher",
|
||||||
|
"story_arc",
|
||||||
|
"team",
|
||||||
|
"volume",
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, api_key, expire_after=300):
|
||||||
|
"""
|
||||||
|
Store the API key in a class variable, and install the requests cache,
|
||||||
|
configuring it using the ``expire_after`` parameter.
|
||||||
|
|
||||||
|
:param api_key: Your personal ComicVine API key.
|
||||||
|
:type api_key: str
|
||||||
|
:param expire_after: The number of seconds to retain an entry in cache.
|
||||||
|
:type expire_after: int or None
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.api_key = api_key
|
||||||
|
|
||||||
|
def search(self, query, offset=0, limit=10, resources=None):
|
||||||
|
"""
|
||||||
|
Perform a search against the API, using the provided query term. If
|
||||||
|
required, a list of resource types to filter search results to can
|
||||||
|
be included.
|
||||||
|
|
||||||
|
Take the JSON contained in the response and provide it to the custom
|
||||||
|
``Response`` object's constructor. Return the ``Response`` object.
|
||||||
|
|
||||||
|
:param query: The search query with which to make the request.
|
||||||
|
:type query: str
|
||||||
|
:param offset: The index of the first record returned.
|
||||||
|
:type offset: int or None
|
||||||
|
:param limit: How many records to return **(max 10)**
|
||||||
|
:type limit: int or None
|
||||||
|
:param resources: A list of resources to include in the search results.
|
||||||
|
:type resources: list or None
|
||||||
|
:type use_cache: bool
|
||||||
|
|
||||||
|
:return: The response object containing the results of the search
|
||||||
|
query.
|
||||||
|
:rtype: comicvine_search.response.Response
|
||||||
|
"""
|
||||||
|
|
||||||
|
params = self._request_params(query, offset, limit, resources)
|
||||||
|
json_data = self._query_api(params)
|
||||||
|
|
||||||
|
return json_data
|
||||||
|
|
||||||
|
def _request_params(self, query, offset, limit, resources):
|
||||||
|
"""
|
||||||
|
Construct a dict containing the required key-value pairs of parameters
|
||||||
|
required in order to make the API request.
|
||||||
|
|
||||||
|
The documentation for the ``search`` resource can be found at
|
||||||
|
https://comicvine.gamespot.com/api/documentation#toc-0-30.
|
||||||
|
|
||||||
|
Regarding 'limit', as per the documentation:
|
||||||
|
|
||||||
|
The number of results to display per page. This value defaults to
|
||||||
|
10 and can not exceed this number.
|
||||||
|
|
||||||
|
:param query: The search query with which to make the request.
|
||||||
|
:type query: str
|
||||||
|
:param offset: The index of the first record returned.
|
||||||
|
:type offset: int
|
||||||
|
:param limit: How many records to return **(max 10)**
|
||||||
|
:type limit: int
|
||||||
|
:param resources: A list of resources to include in the search results.
|
||||||
|
:type resources: list or None
|
||||||
|
|
||||||
|
:return: A dictionary of request parameters.
|
||||||
|
:rtype: dict
|
||||||
|
"""
|
||||||
|
|
||||||
|
return {
|
||||||
|
"api_key": self.api_key,
|
||||||
|
"format": "json",
|
||||||
|
"limit": min(10, limit), # hard limit of 10
|
||||||
|
"offset": max(0, offset), # cannot provide negative offset
|
||||||
|
"query": query,
|
||||||
|
"resources": self._validate_resources(resources),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _validate_resources(self, resources):
|
||||||
|
"""
|
||||||
|
Provided a list of resources, first convert it to a set and perform an
|
||||||
|
intersection with the set of valid resource types, ``RESOURCE_TYPES``.
|
||||||
|
Return a comma-separted string of the remaining valid resources, or
|
||||||
|
None if the set is empty.
|
||||||
|
|
||||||
|
:param resources: A list of resources to include in the search results.
|
||||||
|
:type resources: list or None
|
||||||
|
|
||||||
|
:return: A comma-separated string of valid resources.
|
||||||
|
:rtype: str or None
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not resources:
|
||||||
|
return None
|
||||||
|
|
||||||
|
valid_resources = self.RESOURCE_TYPES & set(resources)
|
||||||
|
return ",".join(valid_resources) if valid_resources else None
|
||||||
|
|
||||||
|
def _query_api(self, params):
|
||||||
|
"""
|
||||||
|
Query the ComicVine API's ``search`` resource, providing the required
|
||||||
|
headers and parameters with the request. Optionally allow the caller
|
||||||
|
of the function to disable the request cache.
|
||||||
|
|
||||||
|
If an error occurs during the request, handle it accordingly. Upon
|
||||||
|
success, return the JSON from the response.
|
||||||
|
|
||||||
|
:param params: Parameters to include with the request.
|
||||||
|
:type params: dict
|
||||||
|
:param use_cache: Toggle the use of requests_cache.
|
||||||
|
:type use_cache: bool
|
||||||
|
|
||||||
|
:return: The JSON contained in the response.
|
||||||
|
:rtype: dict
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Since we're performing the identical action regardless of whether
|
||||||
|
# or not the request cache is to be used, store the procedure in a
|
||||||
|
# local function to avoid repetition.
|
||||||
|
def __httpget():
|
||||||
|
response = requests.get(
|
||||||
|
self.API_URL, headers=self.HEADERS, params=params
|
||||||
|
)
|
||||||
|
|
||||||
|
if not response.ok:
|
||||||
|
self._handle_http_error(response)
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
return __httpget()
|
||||||
|
|
||||||
|
def _handle_http_error(self, response):
|
||||||
|
"""
|
||||||
|
Provided a ``requests.Response`` object, if the status code is
|
||||||
|
anything other than **200**, we will treat it as an error.
|
||||||
|
|
||||||
|
Using the response's status code, determine which type of exception to
|
||||||
|
raise. Construct an exception message from the response's status code
|
||||||
|
and reason properties before raising the exception.
|
||||||
|
|
||||||
|
:param response: The requests.Response object returned by the HTTP
|
||||||
|
request.
|
||||||
|
:type response: requests.Response
|
||||||
|
|
||||||
|
:raises ComicVineUnauthorizedException: if no API key provided.
|
||||||
|
:raises ComicVineForbiddenException: if no User-Agent header provided.
|
||||||
|
:raises ComicVineApiException: if an unidentified error occurs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
exception = {
|
||||||
|
401: Exception,
|
||||||
|
403: Exception,
|
||||||
|
}.get(response.status_code, Exception)
|
||||||
|
message = f"{response.status_code} {response.reason}"
|
||||||
|
|
||||||
|
raise exception(message)
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_comic_from_comicvine(title: str) -> dict:
|
||||||
|
api_key = getattr(settings, "COMICVINE_API_KEY", "")
|
||||||
|
if not api_key:
|
||||||
|
logger.warn("No ComicVine API key configured, not looking anything up")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
client = ComicVineClient(
|
||||||
|
api_key=getattr(settings, "COMICVINE_API_KEY", None)
|
||||||
|
)
|
||||||
|
result = [
|
||||||
|
r
|
||||||
|
for r in client.search(title).get("results")
|
||||||
|
if r.get("resource_type") == "volume"
|
||||||
|
][0]
|
||||||
|
|
||||||
|
if "volume" not in result.keys():
|
||||||
|
logger.warn("No result found on ComicVine", extra={"title": title})
|
||||||
|
return {}
|
||||||
|
|
||||||
|
title = " ".join([result.get("volume").get("name"), result.get("name)")])
|
||||||
|
data_dict = {
|
||||||
|
"title": title,
|
||||||
|
"cover_url": result.get("image").get("original_url"),
|
||||||
|
"comicvine_data": {
|
||||||
|
"id": result.get("id"),
|
||||||
|
"site_detail_url": result.get("site_detail_url"),
|
||||||
|
"description": result.get("description"),
|
||||||
|
"image": result.get("image").get("original_url"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return data_dict
|
||||||
@ -1,22 +1,20 @@
|
|||||||
import codecs
|
from collections import OrderedDict
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Iterable, List
|
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
import requests
|
import requests
|
||||||
from books.models import Author, Book, Page
|
from books.models import Author, Book
|
||||||
from books.openlibrary import get_author_openlibrary_id
|
from books.openlibrary import get_author_openlibrary_id
|
||||||
from django.db.models import Sum
|
from django.apps import apps
|
||||||
from pylast import httpx, tempfile
|
from django.contrib.auth import get_user_model
|
||||||
from scrobbles.models import Scrobble
|
|
||||||
from stream_sqlite import stream_sqlite
|
from stream_sqlite import stream_sqlite
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
class KoReaderBookColumn(Enum):
|
class KoReaderBookColumn(Enum):
|
||||||
@ -43,11 +41,79 @@ class KoReaderPageStatColumn(Enum):
|
|||||||
|
|
||||||
|
|
||||||
def _sqlite_bytes(sqlite_url):
|
def _sqlite_bytes(sqlite_url):
|
||||||
with httpx.stream("GET", sqlite_url) as r:
|
with requests.get(sqlite_url, stream=True) as r:
|
||||||
yield from r.iter_bytes(chunk_size=65_536)
|
yield from r.iter_content(chunk_size=65_536)
|
||||||
|
|
||||||
|
|
||||||
def get_book_map_from_sqlite(rows: Iterable) -> dict:
|
# Grace period between page reads for it to be a new scrobble
|
||||||
|
SESSION_GAP_SECONDS = 1800 # a half hour
|
||||||
|
|
||||||
|
|
||||||
|
def get_author_str_from_row(row):
|
||||||
|
"""Given a the raw author string from KoReader, convert it to a single line and
|
||||||
|
strip the middle initials, as OpenLibrary lookup usually fails with those.
|
||||||
|
"""
|
||||||
|
ko_authors = row[KoReaderBookColumn.AUTHORS.value].replace("\n", ", ")
|
||||||
|
# Strip middle initials, OpenLibrary often fails with these
|
||||||
|
return re.sub(" [A-Z]. ", " ", ko_authors)
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_or_create_authors_from_author_str(ko_author_str: str) -> list:
|
||||||
|
"""Takes a string of authors from KoReader and returns a list
|
||||||
|
of Authors from our database
|
||||||
|
"""
|
||||||
|
author_str_list = ko_author_str.split(", ")
|
||||||
|
author_list = []
|
||||||
|
for author_str in author_str_list:
|
||||||
|
logger.debug(f"Looking up author {author_str}")
|
||||||
|
# KoReader gave us nothing, bail
|
||||||
|
if author_str == "N/A":
|
||||||
|
logger.warn(f"KoReader author string is N/A, no authors to find")
|
||||||
|
continue
|
||||||
|
|
||||||
|
author = Author.objects.filter(name=author_str).first()
|
||||||
|
if not author:
|
||||||
|
author = Author.objects.create(
|
||||||
|
name=author_str,
|
||||||
|
openlibrary_id=get_author_openlibrary_id(author_str),
|
||||||
|
)
|
||||||
|
author.fix_metadata()
|
||||||
|
logger.debug(f"Created author {author}")
|
||||||
|
author_list.append(author)
|
||||||
|
return author_list
|
||||||
|
|
||||||
|
|
||||||
|
def create_book_from_row(row: list):
|
||||||
|
# No KoReader book yet, create it
|
||||||
|
author_str = get_author_str_from_row(row)
|
||||||
|
total_pages = row[KoReaderBookColumn.PAGES.value]
|
||||||
|
run_time = total_pages * Book.AVG_PAGE_READING_SECONDS
|
||||||
|
|
||||||
|
book = Book.objects.create(
|
||||||
|
title=row[KoReaderBookColumn.TITLE.value],
|
||||||
|
pages=total_pages,
|
||||||
|
koreader_data_by_hash={
|
||||||
|
row[KoReaderBookColumn.MD5.value]: {
|
||||||
|
"title": row[KoReaderBookColumn.TITLE.value],
|
||||||
|
"author_str": author_str,
|
||||||
|
"book_id": row[KoReaderBookColumn.ID.value],
|
||||||
|
"pages": total_pages,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
run_time_seconds=run_time,
|
||||||
|
)
|
||||||
|
book.fix_metadata()
|
||||||
|
|
||||||
|
# Add authors
|
||||||
|
author_list = lookup_or_create_authors_from_author_str(author_str)
|
||||||
|
if author_list:
|
||||||
|
book.authors.add(*author_list)
|
||||||
|
|
||||||
|
# self._lookup_authors
|
||||||
|
return book
|
||||||
|
|
||||||
|
|
||||||
|
def build_book_map(rows) -> dict:
|
||||||
"""Given an interable of sqlite rows from the books table, lookup existing
|
"""Given an interable of sqlite rows from the books table, lookup existing
|
||||||
books, create ones that don't exist, and return a mapping of koreader IDs to
|
books, create ones that don't exist, and return a mapping of koreader IDs to
|
||||||
primary key IDs for page creation.
|
primary key IDs for page creation.
|
||||||
@ -56,207 +122,278 @@ def get_book_map_from_sqlite(rows: Iterable) -> dict:
|
|||||||
book_id_map = {}
|
book_id_map = {}
|
||||||
|
|
||||||
for book_row in rows:
|
for book_row in rows:
|
||||||
|
if (
|
||||||
|
book_row[KoReaderBookColumn.TITLE.value]
|
||||||
|
== "KOReader Quickstart Guide"
|
||||||
|
):
|
||||||
|
logger.info(
|
||||||
|
"Ignoring the KOReader quickstart guide. No on wants that."
|
||||||
|
)
|
||||||
|
continue
|
||||||
book = Book.objects.filter(
|
book = Book.objects.filter(
|
||||||
koreader_md5=book_row[KoReaderBookColumn.MD5.value]
|
koreader_data_by_hash__icontains=book_row[
|
||||||
|
KoReaderBookColumn.MD5.value
|
||||||
|
]
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if not book:
|
if not book:
|
||||||
book, created = Book.objects.get_or_create(
|
book = create_book_from_row(book_row)
|
||||||
title=book_row[KoReaderBookColumn.TITLE.value]
|
|
||||||
)
|
|
||||||
|
|
||||||
if created:
|
|
||||||
total_pages = book_row[KoReaderBookColumn.PAGES.value]
|
|
||||||
run_time = total_pages * book.AVG_PAGE_READING_SECONDS
|
|
||||||
ko_authors = book_row[
|
|
||||||
KoReaderBookColumn.AUTHORS.value
|
|
||||||
].replace("\n", ", ")
|
|
||||||
# Strip middle initials, OpenLibrary often fails with these
|
|
||||||
ko_authors = re.sub(" [A-Z]. ", " ", ko_authors)
|
|
||||||
book_dict = {
|
|
||||||
"title": book_row[KoReaderBookColumn.TITLE.value],
|
|
||||||
"pages": total_pages,
|
|
||||||
"koreader_md5": book_row[KoReaderBookColumn.MD5.value],
|
|
||||||
"koreader_id": int(book_row[KoReaderBookColumn.ID.value]),
|
|
||||||
"koreader_authors": ko_authors,
|
|
||||||
"run_time_seconds": run_time,
|
|
||||||
}
|
|
||||||
Book.objects.filter(pk=book.id).update(**book_dict)
|
|
||||||
|
|
||||||
# Add authors
|
|
||||||
authors = ko_authors.split(", ")
|
|
||||||
author_list = []
|
|
||||||
for author_str in authors:
|
|
||||||
logger.debug(f"Looking up author {author_str}")
|
|
||||||
if author_str == "N/A":
|
|
||||||
continue
|
|
||||||
|
|
||||||
author = Author.objects.filter(name=author_str).first()
|
|
||||||
if not author:
|
|
||||||
author = Author.objects.create(
|
|
||||||
name=author_str,
|
|
||||||
openlibrary_id=get_author_openlibrary_id(
|
|
||||||
author_str
|
|
||||||
),
|
|
||||||
)
|
|
||||||
author.fix_metadata()
|
|
||||||
logger.debug(f"Created author {author}")
|
|
||||||
book.authors.add(author)
|
|
||||||
|
|
||||||
# This will try to fix metadata by looking it up on OL
|
|
||||||
book.fix_metadata()
|
|
||||||
|
|
||||||
book.refresh_from_db()
|
book.refresh_from_db()
|
||||||
total_seconds = 0
|
total_seconds = 0
|
||||||
if book_row[KoReaderBookColumn.TOTAL_READ_TIME.value]:
|
if book_row[KoReaderBookColumn.TOTAL_READ_TIME.value]:
|
||||||
total_seconds = book_row[KoReaderBookColumn.TOTAL_READ_TIME.value]
|
total_seconds = book_row[KoReaderBookColumn.TOTAL_READ_TIME.value]
|
||||||
|
|
||||||
book_id_map[book_row[KoReaderBookColumn.ID.value]] = (
|
book_id_map[book_row[KoReaderBookColumn.ID.value]] = {
|
||||||
book.id,
|
"book_id": book.id,
|
||||||
total_seconds,
|
"hash": book_row[KoReaderBookColumn.MD5.value],
|
||||||
)
|
"total_seconds": total_seconds,
|
||||||
|
}
|
||||||
return book_id_map
|
return book_id_map
|
||||||
|
|
||||||
|
|
||||||
def build_scrobbles_from_pages(
|
def build_page_data(page_rows: list, book_map: dict, user_tz=None) -> dict:
|
||||||
rows: Iterable, book_id_map: dict, user_id: int
|
"""Given rows of page data from KoReader, parse each row and build
|
||||||
) -> List[Scrobble]:
|
scrobbles for our user, loading the page data into the page_data
|
||||||
new_scrobbles = []
|
field on the scrobble instance.
|
||||||
|
"""
|
||||||
|
book_ids_not_found = []
|
||||||
|
for page_row in page_rows:
|
||||||
|
koreader_book_id = page_row[KoReaderPageStatColumn.ID_BOOK.value]
|
||||||
|
|
||||||
new_scrobbles = []
|
if koreader_book_id not in book_map.keys():
|
||||||
pages_found = []
|
book_ids_not_found.append(koreader_book_id)
|
||||||
book_read_time_map = {}
|
|
||||||
for page_row in rows:
|
|
||||||
koreader_id = page_row[KoReaderPageStatColumn.ID_BOOK.value]
|
|
||||||
if koreader_id not in book_id_map.keys():
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if "pages" not in book_map[koreader_book_id].keys():
|
||||||
|
book_map[koreader_book_id]["pages"] = {}
|
||||||
|
|
||||||
page_number = page_row[KoReaderPageStatColumn.PAGE.value]
|
page_number = page_row[KoReaderPageStatColumn.PAGE.value]
|
||||||
ts = page_row[KoReaderPageStatColumn.START_TIME.value]
|
duration = page_row[KoReaderPageStatColumn.DURATION.value]
|
||||||
book_id = book_id_map[koreader_id][0]
|
start_ts = page_row[KoReaderPageStatColumn.START_TIME.value]
|
||||||
book_read_time_map[book_id] = book_id_map[koreader_id][1]
|
|
||||||
|
|
||||||
page, page_created = Page.objects.get_or_create(
|
book_map[koreader_book_id]["pages"][page_number] = {
|
||||||
book_id=book_id, number=page_number, user_id=user_id
|
"duration": duration,
|
||||||
|
"start_ts": start_ts,
|
||||||
|
"end_ts": start_ts + duration,
|
||||||
|
}
|
||||||
|
if book_ids_not_found:
|
||||||
|
logger.info(
|
||||||
|
f"Found pages for books not in file: {set(book_ids_not_found)}"
|
||||||
)
|
)
|
||||||
if page_created:
|
return book_map
|
||||||
page.start_time = datetime.utcfromtimestamp(ts).replace(
|
|
||||||
tzinfo=pytz.utc
|
|
||||||
|
def build_scrobbles_from_book_map(
|
||||||
|
book_map: dict, user: "User"
|
||||||
|
) -> list["Scrobble"]:
|
||||||
|
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
||||||
|
|
||||||
|
scrobbles_to_create = []
|
||||||
|
|
||||||
|
pages_not_found = []
|
||||||
|
for koreader_book_id, book_dict in book_map.items():
|
||||||
|
book_id = book_dict["book_id"]
|
||||||
|
if "pages" not in book_dict.keys():
|
||||||
|
pages_not_found.append(book_id)
|
||||||
|
continue
|
||||||
|
|
||||||
|
should_create_scrobble = False
|
||||||
|
scrobble_page_data = {}
|
||||||
|
playback_position_seconds = 0
|
||||||
|
prev_page_stats = {}
|
||||||
|
last_page_number = 0
|
||||||
|
|
||||||
|
pages_processed = 0
|
||||||
|
total_pages_read = len(book_map[koreader_book_id]["pages"])
|
||||||
|
ordered_pages = sorted(
|
||||||
|
book_map[koreader_book_id]["pages"].items(),
|
||||||
|
key=lambda x: x[1]["start_ts"],
|
||||||
|
)
|
||||||
|
|
||||||
|
for cur_page_number, stats in ordered_pages:
|
||||||
|
pages_processed += 1
|
||||||
|
|
||||||
|
seconds_from_last_page = 0
|
||||||
|
if prev_page_stats:
|
||||||
|
seconds_from_last_page = stats.get(
|
||||||
|
"end_ts"
|
||||||
|
) - prev_page_stats.get("start_ts")
|
||||||
|
|
||||||
|
playback_position_seconds = playback_position_seconds + stats.get(
|
||||||
|
"duration"
|
||||||
)
|
)
|
||||||
page.duration_seconds = page_row[
|
|
||||||
KoReaderPageStatColumn.DURATION.value
|
|
||||||
]
|
|
||||||
page.save(update_fields=["start_time", "duration_seconds"])
|
|
||||||
pages_found.append(page)
|
|
||||||
|
|
||||||
playback_position_seconds = 0
|
end_of_reading = pages_processed == total_pages_read
|
||||||
for page in set(pages_found):
|
big_jump_to_this_page = (cur_page_number - last_page_number) > 10
|
||||||
# Add up page seconds to set the aggregate time of all pages to reading time
|
is_session_gap = seconds_from_last_page > SESSION_GAP_SECONDS
|
||||||
playback_position_seconds = (
|
if (
|
||||||
playback_position_seconds + page.duration_seconds
|
is_session_gap and not big_jump_to_this_page
|
||||||
)
|
) or end_of_reading:
|
||||||
if page.is_scrobblable:
|
should_create_scrobble = True
|
||||||
# Check to see if a scrobble with this timestamp, book and user already exists
|
|
||||||
scrobble = Scrobble.objects.filter(
|
if should_create_scrobble:
|
||||||
timestamp=page.start_time,
|
scrobble_page_data = OrderedDict(
|
||||||
book_id=page.book_id,
|
sorted(
|
||||||
user_id=user_id,
|
scrobble_page_data.items(),
|
||||||
).first()
|
key=lambda x: x[1]["start_ts"],
|
||||||
if not scrobble:
|
)
|
||||||
logger.debug(
|
|
||||||
f"Queueing scrobble for {page.book}, page {page.number}"
|
|
||||||
)
|
)
|
||||||
new_scrobble = Scrobble(
|
try:
|
||||||
book_id=page.book_id,
|
first_page = scrobble_page_data.get(
|
||||||
user_id=user_id,
|
list(scrobble_page_data.keys())[0]
|
||||||
source="KOReader",
|
)
|
||||||
media_type=Scrobble.MediaType.BOOK,
|
last_page = scrobble_page_data.get(
|
||||||
timestamp=page.start_time,
|
list(scrobble_page_data.keys())[-1]
|
||||||
played_to_completion=True,
|
)
|
||||||
playback_position_seconds=playback_position_seconds,
|
except IndexError:
|
||||||
in_progress=False,
|
logger.error(
|
||||||
book_pages_read=page.number,
|
"Could not process book, no page data found",
|
||||||
long_play_complete=False,
|
extra={"scrobble_page_data": scrobble_page_data},
|
||||||
)
|
)
|
||||||
new_scrobbles.append(new_scrobble)
|
continue
|
||||||
# After setting a scrobblable page, reset our accumulator
|
|
||||||
playback_position_seconds = 0
|
timezone = user.profile.timezone
|
||||||
return new_scrobbles
|
|
||||||
|
timestamp = datetime.fromtimestamp(
|
||||||
|
int(first_page.get("start_ts"))
|
||||||
|
).replace(tzinfo=pytz.timezone(timezone))
|
||||||
|
|
||||||
|
# Add a shim here temporarily to fix imports while we were in France
|
||||||
|
# if date is between 10/15 and 12/15, cast it to Europe/Central
|
||||||
|
if (
|
||||||
|
datetime(2023, 10, 15).replace(
|
||||||
|
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)
|
||||||
|
stop_timestamp = stop_timestamp - timedelta(hours=1)
|
||||||
|
|
||||||
|
scrobble = Scrobble.objects.filter(
|
||||||
|
timestamp=timestamp,
|
||||||
|
book_id=book_id,
|
||||||
|
user_id=user.id,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not scrobble:
|
||||||
|
logger.info(
|
||||||
|
f"Queueing scrobble for {book_id}, page {cur_page_number}"
|
||||||
|
)
|
||||||
|
log_data = {
|
||||||
|
"koreader_hash": book_dict.get("hash"),
|
||||||
|
"page_data": scrobble_page_data,
|
||||||
|
"pages_read": cur_page_number,
|
||||||
|
}
|
||||||
|
scrobbles_to_create.append(
|
||||||
|
Scrobble(
|
||||||
|
book_id=book_id,
|
||||||
|
user_id=user.id,
|
||||||
|
source="KOReader",
|
||||||
|
media_type=Scrobble.MediaType.BOOK,
|
||||||
|
timestamp=timestamp,
|
||||||
|
log=log_data,
|
||||||
|
stop_timestamp=stop_timestamp,
|
||||||
|
playback_position_seconds=playback_position_seconds,
|
||||||
|
in_progress=False,
|
||||||
|
played_to_completion=True,
|
||||||
|
long_play_complete=False,
|
||||||
|
timezone=timezone,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# Then start over
|
||||||
|
should_create_scrobble = False
|
||||||
|
playback_position_seconds = 0
|
||||||
|
scrobble_page_data = {}
|
||||||
|
|
||||||
|
# We accumulate pages for the scrobble until we should create a new one
|
||||||
|
scrobble_page_data[cur_page_number] = stats
|
||||||
|
|
||||||
|
last_page_number = cur_page_number
|
||||||
|
prev_page_stats = stats
|
||||||
|
if pages_not_found:
|
||||||
|
logger.info(f"Pages not found for books: {set(pages_not_found)}")
|
||||||
|
return scrobbles_to_create
|
||||||
|
|
||||||
|
|
||||||
def enrich_koreader_scrobbles(scrobbles: list) -> None:
|
def fix_long_play_stats_for_scrobbles(scrobbles: list) -> None:
|
||||||
"""Given a list of scrobbles, update pages read, long play seconds and check
|
"""Given a list of scrobbles, update pages read, long play seconds and check
|
||||||
for media completion"""
|
for media completion"""
|
||||||
|
|
||||||
for scrobble in scrobbles:
|
for scrobble in scrobbles:
|
||||||
scrobble.book_pages_read = scrobble.book.page_set.last().number
|
|
||||||
# But if there's a next scrobble, set pages read to their starting page
|
# But if there's a next scrobble, set pages read to their starting page
|
||||||
#
|
if scrobble.previous and not scrobble.previous.long_play_complete:
|
||||||
if scrobble.next:
|
scrobble.long_play_seconds = scrobble.playback_position_seconds + (
|
||||||
scrobble.book_pages_read = scrobble.next.book_pages_read - 1
|
scrobble.previous.long_play_seconds or 0
|
||||||
scrobble.long_play_seconds = scrobble.book.page_set.filter(
|
|
||||||
number__lte=scrobble.book_pages_read
|
|
||||||
).aggregate(Sum("duration_seconds"))["duration_seconds__sum"]
|
|
||||||
|
|
||||||
scrobble.save(update_fields=["book_pages_read", "long_play_seconds"])
|
|
||||||
|
|
||||||
|
|
||||||
def process_koreader_sqlite_url(file_url, user_id) -> list:
|
|
||||||
book_id_map = {}
|
|
||||||
new_scrobbles = []
|
|
||||||
|
|
||||||
for table_name, pragma_table_info, rows in stream_sqlite(
|
|
||||||
_sqlite_bytes(file_url), max_buffer_size=1_048_576
|
|
||||||
):
|
|
||||||
logger.debug(f"Found table {table_name} - processing")
|
|
||||||
if table_name == "book":
|
|
||||||
book_id_map = get_book_map_from_sqlite(rows)
|
|
||||||
|
|
||||||
if table_name == "page_stat_data":
|
|
||||||
new_scrobbles = build_scrobbles_from_pages(
|
|
||||||
rows, book_id_map, user_id
|
|
||||||
)
|
)
|
||||||
logger.debug(f"Creating {len(new_scrobbles)} new scrobbles")
|
else:
|
||||||
|
scrobble.long_play_seconds = scrobble.playback_position_seconds
|
||||||
|
scrobble.log["book_pages_read"] = scrobble.calc_pages_read()
|
||||||
|
|
||||||
created = []
|
scrobble.save(update_fields=["log", "long_play_seconds"])
|
||||||
if new_scrobbles:
|
|
||||||
created = Scrobble.objects.bulk_create(new_scrobbles)
|
|
||||||
enrich_koreader_scrobbles(created)
|
|
||||||
logger.info(
|
|
||||||
f"Created {len(created)} scrobbles",
|
|
||||||
extra={"created_scrobbles": created},
|
|
||||||
)
|
|
||||||
return created
|
|
||||||
|
|
||||||
|
|
||||||
def process_koreader_sqlite_file(file_path, user_id) -> list:
|
def process_koreader_sqlite_file(file_path, user_id) -> list:
|
||||||
"""Given a sqlite file from KoReader, open the book table, iterate
|
"""Given a sqlite file from KoReader, open the book table, iterate
|
||||||
over rows creating scrobbles from each book found"""
|
over rows creating scrobbles from each book found"""
|
||||||
# Create a SQL connection to our SQLite database
|
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
||||||
con = sqlite3.connect(file_path)
|
|
||||||
cur = con.cursor()
|
|
||||||
|
|
||||||
book_id_map = get_book_map_from_sqlite(cur.execute("SELECT * FROM book"))
|
new_scrobbles = []
|
||||||
new_scrobbles = build_scrobbles_from_pages(
|
user = User.objects.filter(id=user_id).first()
|
||||||
cur.execute("SELECT * from page_stat_data"), book_id_map, user_id
|
tz = pytz.utc
|
||||||
)
|
if user:
|
||||||
|
tz = user.profile.timezone
|
||||||
|
|
||||||
|
is_os_file = "https://" not in file_path
|
||||||
|
if is_os_file:
|
||||||
|
con = sqlite3.connect(file_path)
|
||||||
|
cur = con.cursor()
|
||||||
|
try:
|
||||||
|
book_map = build_book_map(cur.execute("SELECT * FROM book"))
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
logger.warning("KOReader sqlite file had not table: book")
|
||||||
|
return new_scrobbles
|
||||||
|
|
||||||
|
book_map = build_page_data(
|
||||||
|
cur.execute(
|
||||||
|
"SELECT * from page_stat_data ORDER BY id_book, start_time"
|
||||||
|
),
|
||||||
|
book_map,
|
||||||
|
tz,
|
||||||
|
)
|
||||||
|
new_scrobbles = build_scrobbles_from_book_map(book_map, user)
|
||||||
|
else:
|
||||||
|
for table_name, pragma_table_info, rows in stream_sqlite(
|
||||||
|
_sqlite_bytes(file_path), max_buffer_size=1_048_576
|
||||||
|
):
|
||||||
|
logger.debug(f"Found table {table_name} - processing")
|
||||||
|
if table_name == "book":
|
||||||
|
book_map = build_book_map(rows)
|
||||||
|
|
||||||
|
if table_name == "page_stat_data":
|
||||||
|
book_map = build_page_data(rows, book_map, tz)
|
||||||
|
new_scrobbles = build_scrobbles_from_book_map(book_map, user)
|
||||||
|
|
||||||
|
logger.info(f"Creating {len(new_scrobbles)} new scrobbles")
|
||||||
created = []
|
created = []
|
||||||
if new_scrobbles:
|
if new_scrobbles:
|
||||||
created = Scrobble.objects.bulk_create(new_scrobbles)
|
created = Scrobble.objects.bulk_create(new_scrobbles)
|
||||||
enrich_koreader_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 process_koreader_sqlite(file_path: str, user_id: int) -> list:
|
|
||||||
is_os_file = "https://" not in file_path
|
|
||||||
|
|
||||||
if is_os_file:
|
|
||||||
created = process_koreader_sqlite_file(file_path, user_id)
|
|
||||||
else:
|
|
||||||
created = process_koreader_sqlite_url(file_path, user_id)
|
|
||||||
return created
|
|
||||||
|
|||||||
@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import logging
|
||||||
|
import pytz
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from books.models import Book
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
from vrobbler.apps.books.koreader import fix_long_play_stats_for_scrobbles
|
||||||
|
from vrobbler.apps.scrobbles.utils import timestamp_user_tz_to_utc
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Grace period between page reads for it to be a new scrobble
|
||||||
|
SESSION_GAP_SECONDS = 1800 # a half hour
|
||||||
|
|
||||||
|
def update_scrobble_from_page_data(scrobble, commit=True):
|
||||||
|
page_list = list(scrobble.book_page_data.items())
|
||||||
|
first_page_start_ts = datetime.fromtimestamp(page_list[0][1]["start_ts"])
|
||||||
|
last_page_end_ts = datetime.fromtimestamp(page_list[-1][1]["end_ts"])
|
||||||
|
|
||||||
|
if (
|
||||||
|
datetime(2023, 10, 15) <= first_page_start_ts <= datetime(2023, 12, 15)
|
||||||
|
):
|
||||||
|
first_page_start_ts.replace(tzinfo=pytz.timezone("Europe/Paris"))
|
||||||
|
last_page_end_ts.replace(tzinfo=pytz.timezone("Europe/Paris"))
|
||||||
|
else:
|
||||||
|
first_page_start_ts.replace(tzinfo=pytz.timezone("US/Eastern"))
|
||||||
|
last_page_end_ts.replace(tzinfo=pytz.timezone("US/Eastern"))
|
||||||
|
|
||||||
|
scrobble.timestamp = first_page_start_ts
|
||||||
|
scrobble.stop_timestamp = last_page_end_ts
|
||||||
|
if commit:
|
||||||
|
scrobble.save(update_fields=["timestamp", "stop_timestamp"])
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
scrobbles_to_create = []
|
||||||
|
|
||||||
|
for scrobble in Scrobble.objects.filter(media_type="Book", source="KOReader"):
|
||||||
|
update_scrobble_from_page_data(scrobble)
|
||||||
@ -0,0 +1,160 @@
|
|||||||
|
import logging
|
||||||
|
import pytz
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from books.models import Book
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
from vrobbler.apps.books.koreader import fix_long_play_stats_for_scrobbles
|
||||||
|
from vrobbler.apps.scrobbles.utils import timestamp_user_tz_to_utc
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Grace period between page reads for it to be a new scrobble
|
||||||
|
SESSION_GAP_SECONDS = 1800 # a half hour
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
scrobbles_to_create = []
|
||||||
|
|
||||||
|
for book in Book.objects.filter(koreader_id__isnull=False):
|
||||||
|
book.scrobble_set.all().delete()
|
||||||
|
|
||||||
|
koreader_data = book.koreader_data_by_hash or {}
|
||||||
|
if book.koreader_md5:
|
||||||
|
koreader_data[book.koreader_md5] = {
|
||||||
|
"title": book.title,
|
||||||
|
"book_id": book.koreader_id,
|
||||||
|
"author_str": book.koreader_authors,
|
||||||
|
"pages": book.pages,
|
||||||
|
}
|
||||||
|
book.koreader_data_by_hash = koreader_data
|
||||||
|
book.save(update_fields=["koreader_data_by_hash"])
|
||||||
|
|
||||||
|
# Next parse all this book's pages into new scrobbles
|
||||||
|
should_create_scrobble = False
|
||||||
|
scrobble_page_data = {}
|
||||||
|
playback_position_seconds = 0
|
||||||
|
prev_page = None
|
||||||
|
|
||||||
|
pages_processed = 0
|
||||||
|
total_pages = book.pages
|
||||||
|
for page in book.page_set.order_by("number"):
|
||||||
|
user = page.user
|
||||||
|
book_id = page.book.id
|
||||||
|
pages_processed += 1
|
||||||
|
scrobble_page_data[page.number] = {
|
||||||
|
"duration": page.duration_seconds,
|
||||||
|
"start_ts": page.start_time.timestamp(),
|
||||||
|
"end_ts": page.end_time.timestamp(),
|
||||||
|
}
|
||||||
|
seconds_from_last_page = 0
|
||||||
|
if prev_page:
|
||||||
|
seconds_from_last_page = (
|
||||||
|
page.end_time.timestamp()
|
||||||
|
- prev_page.start_time.timestamp()
|
||||||
|
)
|
||||||
|
playback_position_seconds = (
|
||||||
|
playback_position_seconds + page.duration_seconds
|
||||||
|
)
|
||||||
|
|
||||||
|
end_of_reading = pages_processed == total_pages
|
||||||
|
big_jump_to_this_page = False
|
||||||
|
if prev_page:
|
||||||
|
big_jump_to_this_page = (
|
||||||
|
page.number - prev_page.number
|
||||||
|
) > 10
|
||||||
|
if (
|
||||||
|
seconds_from_last_page > SESSION_GAP_SECONDS
|
||||||
|
and not big_jump_to_this_page
|
||||||
|
):
|
||||||
|
should_create_scrobble = True
|
||||||
|
|
||||||
|
if should_create_scrobble:
|
||||||
|
first_page = scrobble_page_data.get(
|
||||||
|
list(scrobble_page_data.keys())[0]
|
||||||
|
)
|
||||||
|
last_page = scrobble_page_data.get(
|
||||||
|
list(scrobble_page_data.keys())[-1]
|
||||||
|
)
|
||||||
|
start_ts = int(first_page.get("start_ts"))
|
||||||
|
end_ts = start_ts + playback_position_seconds
|
||||||
|
|
||||||
|
timestamp = datetime.fromtimestamp(start_ts).replace(
|
||||||
|
tzinfo=user.profile.tzinfo
|
||||||
|
)
|
||||||
|
stop_timestamp = datetime.fromtimestamp(end_ts).replace(
|
||||||
|
tzinfo=user.profile.tzinfo
|
||||||
|
)
|
||||||
|
# Add a shim here temporarily to fix imports while we were in France
|
||||||
|
# if date is between 10/15 and 12/15, cast it to Europe/Central
|
||||||
|
if (
|
||||||
|
datetime(2023, 10, 15).replace(
|
||||||
|
tzinfo=pytz.timezone("Europe/Paris")
|
||||||
|
)
|
||||||
|
<= timestamp
|
||||||
|
<= datetime(2023, 12, 15).replace(
|
||||||
|
tzinfo=pytz.timezone("Europe/Paris")
|
||||||
|
)
|
||||||
|
):
|
||||||
|
timestamp.replace(tzinfo=pytz.timezone("Europe/Paris"))
|
||||||
|
|
||||||
|
elif (
|
||||||
|
timestamp.tzinfo._dst.seconds == 0
|
||||||
|
or stop_timestamp.tzinfo._dst.seconds == 0
|
||||||
|
):
|
||||||
|
timestamp = timestamp - timedelta(hours=1)
|
||||||
|
stop_timestamp = stop_timestamp - timedelta(hours=1)
|
||||||
|
|
||||||
|
scrobble = Scrobble.objects.filter(
|
||||||
|
timestamp=timestamp,
|
||||||
|
book_id=book_id,
|
||||||
|
user_id=user.id,
|
||||||
|
).first()
|
||||||
|
if scrobble:
|
||||||
|
logger.info(
|
||||||
|
f"Found existing scrobble {scrobble}, updating"
|
||||||
|
)
|
||||||
|
scrobble.book_page_data = scrobble_page_data
|
||||||
|
scrobble.playback_position_seconds = (
|
||||||
|
scrobble.calc_reading_duration()
|
||||||
|
)
|
||||||
|
scrobble.save(
|
||||||
|
update_fields=[
|
||||||
|
"book_page_data",
|
||||||
|
"playback_position_seconds",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if not scrobble:
|
||||||
|
logger.info(
|
||||||
|
f"Queueing scrobble for {book_id}, page {page.number}"
|
||||||
|
)
|
||||||
|
scrobbles_to_create.append(
|
||||||
|
Scrobble(
|
||||||
|
book_id=book_id,
|
||||||
|
user_id=user.id,
|
||||||
|
source="KOReader",
|
||||||
|
media_type=Scrobble.MediaType.BOOK,
|
||||||
|
timestamp=timestamp,
|
||||||
|
stop_timestamp=stop_timestamp,
|
||||||
|
playback_position_seconds=playback_position_seconds,
|
||||||
|
book_koreader_hash=list(
|
||||||
|
book.koreader_data_by_hash.keys()
|
||||||
|
)[0],
|
||||||
|
book_page_data=scrobble_page_data,
|
||||||
|
book_pages_read=page.number,
|
||||||
|
in_progress=False,
|
||||||
|
played_to_completion=True,
|
||||||
|
long_play_complete=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# Then start over
|
||||||
|
should_create_scrobble = False
|
||||||
|
playback_position_seconds = 0
|
||||||
|
scrobble_page_data = {}
|
||||||
|
|
||||||
|
prev_page = page
|
||||||
|
|
||||||
|
created = Scrobble.objects.bulk_create(scrobbles_to_create)
|
||||||
|
fix_long_play_stats_for_scrobbles(created)
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
import logging
|
||||||
|
import pytz
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from books.models import Book
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
from vrobbler.apps.books.koreader import fix_long_play_stats_for_scrobbles
|
||||||
|
from vrobbler.apps.scrobbles.utils import timestamp_user_tz_to_utc
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
total_books = Book.objects.all().count()
|
||||||
|
processed_books = 0
|
||||||
|
|
||||||
|
for book in Book.objects.all():
|
||||||
|
for scrobble in book.scrobble_set.all():
|
||||||
|
log_data = {
|
||||||
|
"koreader_hash": scrobble.book_koreader_hash,
|
||||||
|
"page_data": scrobble.book_page_data,
|
||||||
|
"pages_read": scrobble.book_pages_read,
|
||||||
|
}
|
||||||
|
scrobble.log = log_data
|
||||||
|
scrobble.save(update_fields=["log"])
|
||||||
|
processed_books += 1
|
||||||
|
logger.info(f"Processed book {processed_books} of {total_books}")
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 4.2.9 on 2024-01-29 05:55
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("books", "0019_alter_book_authors"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="author",
|
||||||
|
name="comicvine_data",
|
||||||
|
field=models.JSONField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="book",
|
||||||
|
name="comicvine_data",
|
||||||
|
field=models.JSONField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="book",
|
||||||
|
name="koreader_data_by_hash",
|
||||||
|
field=models.JSONField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -1,5 +1,6 @@
|
|||||||
|
from collections import OrderedDict
|
||||||
import logging
|
import logging
|
||||||
from datetime import timedelta
|
from datetime import timedelta, datetime
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
@ -22,6 +23,11 @@ from scrobbles.mixins import (
|
|||||||
)
|
)
|
||||||
from scrobbles.utils import get_scrobbles_for_media
|
from scrobbles.utils import get_scrobbles_for_media
|
||||||
from taggit.managers import TaggableManager
|
from taggit.managers import TaggableManager
|
||||||
|
from thefuzz import fuzz
|
||||||
|
from vrobbler.apps.books.comicvine import (
|
||||||
|
ComicVineClient,
|
||||||
|
lookup_comic_from_comicvine,
|
||||||
|
)
|
||||||
|
|
||||||
from vrobbler.apps.books.locg import (
|
from vrobbler.apps.books.locg import (
|
||||||
lookup_comic_by_locg_slug,
|
lookup_comic_by_locg_slug,
|
||||||
@ -29,6 +35,8 @@ from vrobbler.apps.books.locg import (
|
|||||||
lookup_comic_writer_by_locg_slug,
|
lookup_comic_writer_by_locg_slug,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
COMICVINE_API_KEY = getattr(settings, "COMICVINE_API_KEY", "")
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
BNULL = {"blank": True, "null": True}
|
BNULL = {"blank": True, "null": True}
|
||||||
@ -58,6 +66,7 @@ class Author(TimeStampedModel):
|
|||||||
wikidata_id = models.CharField(max_length=255, **BNULL)
|
wikidata_id = 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)
|
librarything_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
comicvine_data = models.JSONField(**BNULL)
|
||||||
amazon_id = models.CharField(max_length=255, **BNULL)
|
amazon_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
@ -91,9 +100,11 @@ 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)
|
goodreads_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
# All individual koreader fields are deprecated
|
||||||
koreader_id = models.IntegerField(**BNULL)
|
koreader_id = models.IntegerField(**BNULL)
|
||||||
koreader_authors = models.CharField(max_length=255, **BNULL)
|
koreader_authors = models.CharField(max_length=255, **BNULL)
|
||||||
koreader_md5 = models.CharField(max_length=255, **BNULL)
|
koreader_md5 = models.CharField(max_length=255, **BNULL)
|
||||||
|
koreader_data_by_hash = models.JSONField(**BNULL)
|
||||||
isbn = models.CharField(max_length=255, **BNULL)
|
isbn = 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)
|
||||||
@ -101,6 +112,7 @@ class Book(LongPlayScrobblableMixin):
|
|||||||
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)
|
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",
|
||||||
@ -144,27 +156,35 @@ class Book(LongPlayScrobblableMixin):
|
|||||||
if self.author:
|
if self.author:
|
||||||
author_name = self.author.name
|
author_name = self.author.name
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
logger.warn(f"Checking openlibrary for {self.title}")
|
||||||
|
if self.openlibrary_id and force_update:
|
||||||
|
data = lookup_book_from_openlibrary(
|
||||||
|
str(self.openlibrary_id)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
data = lookup_book_from_openlibrary(
|
||||||
|
str(self.title), author_name
|
||||||
|
)
|
||||||
|
|
||||||
if not data:
|
if not data:
|
||||||
if self.locg_slug:
|
if self.locg_slug:
|
||||||
|
logger.warn(
|
||||||
|
f"Checking LOCG for {self.title} with slug {self.locg_slug}"
|
||||||
|
)
|
||||||
data = lookup_comic_by_locg_slug(str(self.locg_slug))
|
data = lookup_comic_by_locg_slug(str(self.locg_slug))
|
||||||
else:
|
else:
|
||||||
|
logger.warn(f"Checking LOCG for {self.title}")
|
||||||
data = lookup_comic_from_locg(str(self.title))
|
data = lookup_comic_from_locg(str(self.title))
|
||||||
|
|
||||||
if not data:
|
if not data and COMICVINE_API_KEY:
|
||||||
logger.warn(
|
logger.warn(f"Checking ComicVine for {self.title}")
|
||||||
f"Book not found on LOCG, checking OL {self.title}"
|
cv_client = ComicVineClient(api_key=COMICVINE_API_KEY)
|
||||||
)
|
data = lookup_comic_from_comicvine(str(self.title))
|
||||||
if self.openlibrary_id and force_update:
|
|
||||||
data = lookup_book_from_openlibrary(
|
if not data:
|
||||||
str(self.openlibrary_id)
|
logger.warn(f"Book not found in any sources: {self.title}")
|
||||||
)
|
return
|
||||||
else:
|
|
||||||
data = lookup_book_from_openlibrary(
|
|
||||||
str(self.title), author_name
|
|
||||||
)
|
|
||||||
if not data:
|
|
||||||
logger.warn(f"Book not found in OL {self.title}")
|
|
||||||
return
|
|
||||||
|
|
||||||
# We can discard the author name from OL for now, we'll lookup details below
|
# We can discard the author name from OL for now, we'll lookup details below
|
||||||
data.pop("ol_author_name", "")
|
data.pop("ol_author_name", "")
|
||||||
@ -174,15 +194,31 @@ class Book(LongPlayScrobblableMixin):
|
|||||||
self.get_author_from_locg(data.pop("locg_writer_slug", ""))
|
self.get_author_from_locg(data.pop("locg_writer_slug", ""))
|
||||||
|
|
||||||
ol_title = data.get("title", "")
|
ol_title = data.get("title", "")
|
||||||
|
data.pop("ol_author_id", "")
|
||||||
|
|
||||||
# Kick out a little warning if we're about to change KoReader's title
|
# Kick out a little warning if we're about to change KoReader's title
|
||||||
if ol_title.lower() != str(self.title).lower():
|
if (
|
||||||
|
fuzz.ratio(ol_title.lower(), str(self.title).lower()) < 80
|
||||||
|
and not force_update
|
||||||
|
):
|
||||||
logger.warn(
|
logger.warn(
|
||||||
f"OL and KoReader disagree on this book title {self.title} != {ol_title}"
|
f"OL and KoReader disagree on this book title {self.title} != {ol_title}, check manually"
|
||||||
)
|
)
|
||||||
|
self.openlibrary_id = data.get("openlibrary_id")
|
||||||
|
self.save(update_fields=["openlibrary_id"])
|
||||||
|
return
|
||||||
|
|
||||||
# If we don't know pages, don't overwrite existing with None
|
# If we don't know pages, don't overwrite existing with None
|
||||||
if data.get("pages") == None:
|
if "pages" in data.keys() and data.get("pages") == None:
|
||||||
|
data.pop("pages")
|
||||||
|
|
||||||
|
if (
|
||||||
|
not isinstance(data.get("pages"), int)
|
||||||
|
and "pages" in data.keys()
|
||||||
|
):
|
||||||
|
logger.info(
|
||||||
|
f"Pages for {self} from OL expected to be int, but got {data.get('pages')}"
|
||||||
|
)
|
||||||
data.pop("pages")
|
data.pop("pages")
|
||||||
|
|
||||||
# Pop this, so we can look it up later
|
# Pop this, so we can look it up later
|
||||||
@ -242,6 +278,22 @@ class Book(LongPlayScrobblableMixin):
|
|||||||
author.headshot.save(fname, ContentFile(r.content), save=True)
|
author.headshot.save(fname, ContentFile(r.content), save=True)
|
||||||
self.authors.add(author)
|
self.authors.add(author)
|
||||||
|
|
||||||
|
|
||||||
|
def page_data_for_user(self, user_id: int, convert_timestamps: bool=True) -> dict:
|
||||||
|
scrobbles = self.scrobble_set.filter(user=user_id)
|
||||||
|
|
||||||
|
pages = {}
|
||||||
|
for scrobble in scrobbles:
|
||||||
|
if scrobble.book_page_data:
|
||||||
|
for page, data in scrobble.book_page_data.items():
|
||||||
|
if convert_timestamps:
|
||||||
|
data["start_ts"] = datetime.fromtimestamp(data["start_ts"])
|
||||||
|
data["end_ts"] = datetime.fromtimestamp(data["end_ts"])
|
||||||
|
pages[page] = data
|
||||||
|
sorted_pages = OrderedDict(sorted(pages.items(), key=lambda x: x[1]["start_ts"]))
|
||||||
|
|
||||||
|
return sorted_pages
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def author(self):
|
def author(self):
|
||||||
return self.authors.first()
|
return self.authors.first()
|
||||||
@ -264,34 +316,35 @@ class Book(LongPlayScrobblableMixin):
|
|||||||
last_scrobble = get_scrobbles_for_media(self, user).last()
|
last_scrobble = get_scrobbles_for_media(self, user).last()
|
||||||
progress = 0
|
progress = 0
|
||||||
if last_scrobble:
|
if last_scrobble:
|
||||||
progress = int((last_scrobble.book_pages_read / self.pages) * 100)
|
progress = int((last_scrobble.last_page_read / self.pages) * 100)
|
||||||
return progress
|
return progress
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def find_or_create(cls, lookup_id: str, author: str = "") -> "Book":
|
def find_or_create(cls, lookup_id: str, author: str = "") -> "Book":
|
||||||
data = lookup_book_from_openlibrary(lookup_id, author)
|
book = cls.objects.filter(openlibrary_id=lookup_id).first()
|
||||||
|
|
||||||
book, book_created = cls.objects.get_or_create(isbn=data["isbn"])
|
if not book:
|
||||||
if book_created:
|
data = lookup_book_from_openlibrary(lookup_id, author)
|
||||||
book.fix_metadata(data=data)
|
|
||||||
|
if not data:
|
||||||
|
logger.error(
|
||||||
|
f"No book found on openlibrary, or in our database for {lookup_id}"
|
||||||
|
)
|
||||||
|
return book
|
||||||
|
|
||||||
|
book, book_created = cls.objects.get_or_create(isbn=data["isbn"])
|
||||||
|
if book_created:
|
||||||
|
book.fix_metadata(data=data)
|
||||||
|
|
||||||
return book
|
return book
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
if (
|
|
||||||
(not self.isbn and not self.cover)
|
|
||||||
and (self.locg_slug or self.openlibrary_id)
|
|
||||||
and self.id
|
|
||||||
):
|
|
||||||
self.fix_metadata(force_update=True)
|
|
||||||
|
|
||||||
return super(Book, self).save(*args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class Page(TimeStampedModel):
|
class Page(TimeStampedModel):
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
"""DEPRECATED, we need to migrate pages into page_data on scrobbles and move on"""
|
||||||
|
|
||||||
book = models.ForeignKey(Book, on_delete=models.CASCADE)
|
book = models.ForeignKey(Book, on_delete=models.CASCADE)
|
||||||
number = models.IntegerField()
|
number = models.IntegerField()
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
start_time = models.DateTimeField(**BNULL)
|
start_time = models.DateTimeField(**BNULL)
|
||||||
end_time = models.DateTimeField(**BNULL)
|
end_time = models.DateTimeField(**BNULL)
|
||||||
duration_seconds = models.IntegerField(**BNULL)
|
duration_seconds = models.IntegerField(**BNULL)
|
||||||
|
|||||||
@ -5,6 +5,8 @@ import urllib
|
|||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
from thefuzz import fuzz
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
ISBN_URL = "https://openlibrary.org/isbn/{isbn}.json"
|
ISBN_URL = "https://openlibrary.org/isbn/{isbn}.json"
|
||||||
@ -36,8 +38,11 @@ def get_author_openlibrary_id(name: str) -> str:
|
|||||||
logger.warn(f"No author results found from search for {name}")
|
logger.warn(f"No author results found from search for {name}")
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
result = results.get("docs", [])
|
try:
|
||||||
return result[0].get("key")
|
result = results.get("docs", [])[0]
|
||||||
|
except IndexError:
|
||||||
|
result = {"key": ""}
|
||||||
|
return result.get("key")
|
||||||
|
|
||||||
|
|
||||||
def lookup_author_from_openlibrary(olid: str) -> dict:
|
def lookup_author_from_openlibrary(olid: str) -> dict:
|
||||||
@ -99,11 +104,22 @@ def lookup_book_from_openlibrary(
|
|||||||
|
|
||||||
top = None
|
top = None
|
||||||
for result in results.get("docs"):
|
for result in results.get("docs"):
|
||||||
# These Summary things suck and ruin our one-shot search
|
if fuzz.ratio(title.lower(), result.get("title", "").lower()) > 90:
|
||||||
if "Summary of" not in result.get("title"):
|
|
||||||
top = result
|
top = result
|
||||||
break
|
break
|
||||||
|
|
||||||
|
if not top:
|
||||||
|
for result in results.get("docs"):
|
||||||
|
# These Summary things suck and ruin our one-shot search
|
||||||
|
if "Summary of" in result.get("title"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if title.lower() in result.get("title", "").lower():
|
||||||
|
top = result
|
||||||
|
|
||||||
|
if not top and len(results.get("docs")) > 0:
|
||||||
|
top = results.get("docs")[0]
|
||||||
|
|
||||||
if not top:
|
if not top:
|
||||||
logger.warn(f"No book found for query {query}")
|
logger.warn(f"No book found for query {query}")
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
123
vrobbler/apps/books/tests/conftest.py
Normal file
123
vrobbler/apps/books/tests/conftest.py
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import hashlib
|
||||||
|
import random
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
from vrobbler.apps.books.koreader import KoReaderBookColumn
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
ordinal = lambda n: "%d%s" % (
|
||||||
|
n,
|
||||||
|
"tsnrhtdd"[(n // 10 % 10 != 1) * (n % 10 < 4) * n % 10 :: 4],
|
||||||
|
)
|
||||||
|
AVERAGE_PAGE_READING_SECONDS = 60
|
||||||
|
|
||||||
|
|
||||||
|
class DummyResponse:
|
||||||
|
status_code = 200
|
||||||
|
|
||||||
|
def status_code(self):
|
||||||
|
return self.status_code
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def valid_response():
|
||||||
|
return DummyResponse()
|
||||||
|
|
||||||
|
|
||||||
|
class KoReaderBookRows:
|
||||||
|
id = 1
|
||||||
|
DEFAULT_STR = "N/A"
|
||||||
|
DEFAULT_INT = 0
|
||||||
|
DEFAULT_TIME = 1703800469
|
||||||
|
BOOK_ROWS = []
|
||||||
|
PAGE_STATS_ROWS = []
|
||||||
|
|
||||||
|
def _gen_random_row(self, i):
|
||||||
|
wiggle = random.randrange(15)
|
||||||
|
title = f"Memoirs, Volume {i}"
|
||||||
|
return [
|
||||||
|
i,
|
||||||
|
title,
|
||||||
|
f"Lord Beaverbrook the {ordinal(i)}",
|
||||||
|
self.DEFAULT_INT + wiggle / 10,
|
||||||
|
self.DEFAULT_TIME + i * wiggle,
|
||||||
|
0,
|
||||||
|
300 + wiggle,
|
||||||
|
self.DEFAULT_STR,
|
||||||
|
self.DEFAULT_STR,
|
||||||
|
hashlib.md5(title.encode()),
|
||||||
|
i * wiggle * 20,
|
||||||
|
120,
|
||||||
|
]
|
||||||
|
|
||||||
|
def _generate_random_book_rows(self, book_count):
|
||||||
|
if book_count > 0:
|
||||||
|
for i in range(1, book_count + 1):
|
||||||
|
self.BOOK_ROWS.append(self._gen_random_row(i))
|
||||||
|
|
||||||
|
def _generate_custom_book_row(self, **kwargs):
|
||||||
|
title = kwargs.get("title", self.DEFAULT_STR)
|
||||||
|
if title and title != "N/A":
|
||||||
|
self.BOOK_ROWS.append(
|
||||||
|
[
|
||||||
|
kwargs.get("id", self.id),
|
||||||
|
kwargs.get("title", self.DEFAULT_STR),
|
||||||
|
kwargs.get("authors", self.DEFAULT_STR),
|
||||||
|
kwargs.get("notes", self.DEFAULT_INT),
|
||||||
|
kwargs.get("last_open", self.DEFAULT_TIME),
|
||||||
|
kwargs.get("highlights", self.DEFAULT_INT),
|
||||||
|
kwargs.get("pages", self.DEFAULT_INT),
|
||||||
|
kwargs.get("series", self.DEFAULT_STR),
|
||||||
|
kwargs.get("language", self.DEFAULT_STR),
|
||||||
|
hashlib.md5(title.encode()),
|
||||||
|
kwargs.get("total_read_time", self.DEFAULT_INT),
|
||||||
|
kwargs.get("total_read_pages", self.DEFAULT_INT),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def _generate_random_page_stats_rows(self):
|
||||||
|
for book in self.BOOK_ROWS:
|
||||||
|
pages = book[KoReaderBookColumn.PAGES.value]
|
||||||
|
pages_per_session = 20
|
||||||
|
|
||||||
|
start_time = book[KoReaderBookColumn.LAST_OPEN.value]
|
||||||
|
end_session = False
|
||||||
|
for page_num in range(
|
||||||
|
1, book[KoReaderBookColumn.TOTAL_READ_PAGES.value] + 1
|
||||||
|
):
|
||||||
|
wiggle = random.randrange(5)
|
||||||
|
self.PAGE_STATS_ROWS.append(
|
||||||
|
[
|
||||||
|
book[KoReaderBookColumn.ID.value],
|
||||||
|
page_num,
|
||||||
|
start_time,
|
||||||
|
AVERAGE_PAGE_READING_SECONDS + wiggle,
|
||||||
|
pages,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if end_session:
|
||||||
|
start_time += 3600 # one second over an hour, marking a new reading session
|
||||||
|
end_session = False
|
||||||
|
else:
|
||||||
|
start_time += AVERAGE_PAGE_READING_SECONDS
|
||||||
|
|
||||||
|
if page_num % pages_per_session == 0:
|
||||||
|
end_session = True
|
||||||
|
|
||||||
|
def __init__(self, book_count=0, **kwargs):
|
||||||
|
self._generate_random_book_rows(book_count)
|
||||||
|
self._generate_custom_book_row(**kwargs)
|
||||||
|
self._generate_random_page_stats_rows()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def koreader_rows():
|
||||||
|
return KoReaderBookRows(book_count=1)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def demo_user():
|
||||||
|
return User.objects.create(email="demo@example.com")
|
||||||
53
vrobbler/apps/books/tests/test_koreader.py
Normal file
53
vrobbler/apps/books/tests/test_koreader.py
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import pytest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from books.koreader import (
|
||||||
|
KoReaderBookColumn,
|
||||||
|
build_book_map,
|
||||||
|
build_page_data,
|
||||||
|
build_scrobbles_from_book_map,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@mock.patch("requests.get")
|
||||||
|
def test_build_book_map(get_mock, koreader_rows, valid_response):
|
||||||
|
get_mock.return_value = valid_response
|
||||||
|
book_map = build_book_map(koreader_rows.BOOK_ROWS)
|
||||||
|
assert len(book_map) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@mock.patch("requests.get")
|
||||||
|
def test_load_page_data_to_map(get_mock, koreader_rows, valid_response):
|
||||||
|
get_mock.return_value = valid_response
|
||||||
|
book_map = build_book_map(koreader_rows.BOOK_ROWS)
|
||||||
|
|
||||||
|
book_map = build_page_data(koreader_rows.PAGE_STATS_ROWS, book_map)
|
||||||
|
assert (
|
||||||
|
len(book_map[1]["pages"])
|
||||||
|
== koreader_rows.BOOK_ROWS[0][
|
||||||
|
KoReaderBookColumn.TOTAL_READ_PAGES.value
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@mock.patch("requests.get")
|
||||||
|
def test_build_scrobbles_from_pages(
|
||||||
|
get_mock, koreader_rows, demo_user, valid_response
|
||||||
|
):
|
||||||
|
get_mock.return_value = valid_response
|
||||||
|
book_map = build_book_map(koreader_rows.BOOK_ROWS)
|
||||||
|
book_map = build_page_data(koreader_rows.PAGE_STATS_ROWS, book_map)
|
||||||
|
|
||||||
|
scrobbles = build_scrobbles_from_book_map(book_map, demo_user)
|
||||||
|
# Corresponds to number of sessions per book ( 20 pages per session, 120 +/- 15 pages read )
|
||||||
|
expected_scrobbles = 6 * len(book_map.keys())
|
||||||
|
assert len(scrobbles) == expected_scrobbles
|
||||||
|
assert len(scrobbles[0].book_page_data.keys()) == 22
|
||||||
|
assert len(scrobbles[1].book_page_data.keys()) == 20
|
||||||
|
assert len(scrobbles[2].book_page_data.keys()) == 20
|
||||||
|
assert len(scrobbles[3].book_page_data.keys()) == 20
|
||||||
|
assert len(scrobbles[4].book_page_data.keys()) == 20
|
||||||
|
assert len(scrobbles[5].book_page_data.keys()) == 18
|
||||||
36
vrobbler/apps/books/tests/test_openlibrary.py
Normal file
36
vrobbler/apps/books/tests/test_openlibrary.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
from unittest import skip
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from books.openlibrary import lookup_book_from_openlibrary
|
||||||
|
|
||||||
|
|
||||||
|
def test_lookup_modern_book():
|
||||||
|
book = lookup_book_from_openlibrary("Matrix", "Lauren Groff")
|
||||||
|
assert book.get("title") == "Matrix"
|
||||||
|
assert book.get("openlibrary_id") == "OL32170218M"
|
||||||
|
assert book.get("ol_author_id") == "OL3675729A"
|
||||||
|
|
||||||
|
|
||||||
|
def test_lookup_classic_book():
|
||||||
|
book = lookup_book_from_openlibrary(
|
||||||
|
"The Life of Castruccio Castracani", "Machiavelli"
|
||||||
|
)
|
||||||
|
assert book.get("title") == "The Life of Castruccio Castracani of Lucca"
|
||||||
|
assert book.get("openlibrary_id") == "OL8950869M"
|
||||||
|
assert book.get("ol_author_id") == "OL23135A"
|
||||||
|
|
||||||
|
|
||||||
|
def test_lookup_foreign_book():
|
||||||
|
book = lookup_book_from_openlibrary("Ravagé", "René Barjavel")
|
||||||
|
assert book.get("title") == "Ravage"
|
||||||
|
assert book.get("openlibrary_id") == "OL8837839M"
|
||||||
|
assert book.get("ol_author_id") == "OL152472A"
|
||||||
|
|
||||||
|
|
||||||
|
@skip("This is rotten in OL, updated but waiting for it to update")
|
||||||
|
def test_lookup_book():
|
||||||
|
book = lookup_book_from_openlibrary("Hark! A Vagrant")
|
||||||
|
assert book.get("title") == "Hark! A Vagrant"
|
||||||
|
assert book.get("openlibrary_id") == "OL8837839M"
|
||||||
|
assert book.get("ol_author_id") == "OL152472A"
|
||||||
@ -1,15 +1,15 @@
|
|||||||
from django.views import generic
|
from django.views import generic
|
||||||
from books.models import Book, Author
|
from books.models import Book, Author
|
||||||
|
|
||||||
|
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
|
||||||
|
|
||||||
class BookListView(generic.ListView):
|
|
||||||
|
class BookListView(ScrobbleableListView):
|
||||||
model = Book
|
model = Book
|
||||||
paginate_by = 20
|
|
||||||
|
|
||||||
|
|
||||||
class BookDetailView(generic.DetailView):
|
class BookDetailView(ScrobbleableDetailView):
|
||||||
model = Book
|
model = Book
|
||||||
slug_field = "uuid"
|
|
||||||
|
|
||||||
|
|
||||||
class AuthorDetailView(generic.DetailView):
|
class AuthorDetailView(generic.DetailView):
|
||||||
|
|||||||
0
vrobbler/apps/bricksets/__init__.py
Normal file
0
vrobbler/apps/bricksets/__init__.py
Normal file
23
vrobbler/apps/bricksets/admin.py
Normal file
23
vrobbler/apps/bricksets/admin.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from bricksets.models import BrickSet
|
||||||
|
from scrobbles.admin import ScrobbleInline
|
||||||
|
|
||||||
|
|
||||||
|
class BrickSetInline(admin.TabularInline):
|
||||||
|
model = BrickSet
|
||||||
|
extra = 0
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(BrickSet)
|
||||||
|
class BrickSetAdmin(admin.ModelAdmin):
|
||||||
|
date_hierarchy = "created"
|
||||||
|
list_display = (
|
||||||
|
"uuid",
|
||||||
|
"title",
|
||||||
|
)
|
||||||
|
ordering = ("-created",)
|
||||||
|
search_fields = ("title",)
|
||||||
|
inlines = [
|
||||||
|
ScrobbleInline,
|
||||||
|
]
|
||||||
5
vrobbler/apps/bricksets/apps.py
Normal file
5
vrobbler/apps/bricksets/apps.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class BrickSetsConfig(AppConfig):
|
||||||
|
name = "bricksets"
|
||||||
106
vrobbler/apps/bricksets/migrations/0001_initial.py
Normal file
106
vrobbler/apps/bricksets/migrations/0001_initial.py
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
# Generated by Django 4.2.15 on 2024-09-07 05:38
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django_extensions.db.fields
|
||||||
|
import taggit.managers
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("scrobbles", "0059_remove_scrobble_book_koreader_hash_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="BrickSet",
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"number",
|
||||||
|
models.CharField(blank=True, max_length=10, null=True),
|
||||||
|
),
|
||||||
|
("release_year", models.IntegerField(blank=True, null=True)),
|
||||||
|
("piece_count", models.IntegerField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"brickset_rating",
|
||||||
|
models.DecimalField(
|
||||||
|
blank=True, decimal_places=1, max_digits=3, null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"lego_item_number",
|
||||||
|
models.CharField(blank=True, max_length=10, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"box_image",
|
||||||
|
models.ImageField(
|
||||||
|
blank=True, null=True, upload_to="brickset/boxes/"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"set_image",
|
||||||
|
models.ImageField(
|
||||||
|
blank=True, null=True, upload_to="brickset/sets/"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"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
vrobbler/apps/bricksets/migrations/__init__.py
Normal file
0
vrobbler/apps/bricksets/migrations/__init__.py
Normal file
63
vrobbler/apps/bricksets/models.py
Normal file
63
vrobbler/apps/bricksets/models.py
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
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 BrickSetLogData
|
||||||
|
from scrobbles.mixins import LongPlayScrobblableMixin
|
||||||
|
|
||||||
|
BNULL = {"blank": True, "null": True}
|
||||||
|
|
||||||
|
|
||||||
|
class BrickSet(LongPlayScrobblableMixin):
|
||||||
|
""""""
|
||||||
|
|
||||||
|
number = models.CharField(max_length=10, **BNULL)
|
||||||
|
release_year = models.IntegerField(**BNULL)
|
||||||
|
piece_count = models.IntegerField(**BNULL)
|
||||||
|
brickset_rating = models.DecimalField(max_digits=3, decimal_places=1, **BNULL)
|
||||||
|
lego_item_number = models.CharField(max_length=10, **BNULL)
|
||||||
|
box_image = models.ImageField(upload_to="brickset/boxes/", **BNULL)
|
||||||
|
box_image_small = ImageSpecField(
|
||||||
|
source="box_image",
|
||||||
|
processors=[ResizeToFit(100, 100)],
|
||||||
|
format="JPEG",
|
||||||
|
options={"quality": 60},
|
||||||
|
)
|
||||||
|
box_image_medium = ImageSpecField(
|
||||||
|
source="box_image",
|
||||||
|
processors=[ResizeToFit(300, 300)],
|
||||||
|
format="JPEG",
|
||||||
|
options={"quality": 75},
|
||||||
|
)
|
||||||
|
set_image = models.ImageField(upload_to="brickset/sets/", **BNULL)
|
||||||
|
set_image_small = ImageSpecField(
|
||||||
|
source="set_image",
|
||||||
|
processors=[ResizeToFit(100, 100)],
|
||||||
|
format="JPEG",
|
||||||
|
options={"quality": 60},
|
||||||
|
)
|
||||||
|
set_image_medium = ImageSpecField(
|
||||||
|
source="set_image",
|
||||||
|
processors=[ResizeToFit(300, 300)],
|
||||||
|
format="JPEG",
|
||||||
|
options={"quality": 75},
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse("bricksets:brickset_detail", kwargs={"slug": self.uuid})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def logdata_cls(self):
|
||||||
|
return BrickSetLogData
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def find_or_create(cls, title: str) -> "BrickSet":
|
||||||
|
return cls.objects.filter(title=title).first()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def primary_image_url(self) -> str:
|
||||||
|
if self.box_image:
|
||||||
|
return self.box_image.url
|
||||||
|
return ""
|
||||||
14
vrobbler/apps/bricksets/urls.py
Normal file
14
vrobbler/apps/bricksets/urls.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from bricksets import views
|
||||||
|
|
||||||
|
app_name = "bricksets"
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("bricksets/", views.BrickSetListView.as_view(), name="brickset_list"),
|
||||||
|
path(
|
||||||
|
"bricksets/<slug:slug>/",
|
||||||
|
views.BrickSetDetailView.as_view(),
|
||||||
|
name="brickset_detail",
|
||||||
|
),
|
||||||
|
]
|
||||||
10
vrobbler/apps/bricksets/views.py
Normal file
10
vrobbler/apps/bricksets/views.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
from bricksets.models import BrickSet
|
||||||
|
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
|
||||||
|
|
||||||
|
|
||||||
|
class BrickSetListView(ScrobbleableListView):
|
||||||
|
model = BrickSet
|
||||||
|
|
||||||
|
|
||||||
|
class BrickSetDetailView(ScrobbleableDetailView):
|
||||||
|
model = BrickSet
|
||||||
16
vrobbler/apps/lifeevents/admin.py
Normal file
16
vrobbler/apps/lifeevents/admin.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from lifeevents.models import LifeEvent
|
||||||
|
|
||||||
|
from scrobbles.admin import ScrobbleInline
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(LifeEvent)
|
||||||
|
class EventAdmin(admin.ModelAdmin):
|
||||||
|
date_hierarchy = "created"
|
||||||
|
list_display = ("title",)
|
||||||
|
search_fields = ("title",)
|
||||||
|
ordering = ("-created",)
|
||||||
|
inlines = [
|
||||||
|
ScrobbleInline,
|
||||||
|
]
|
||||||
79
vrobbler/apps/lifeevents/migrations/0001_initial.py
Normal file
79
vrobbler/apps/lifeevents/migrations/0001_initial.py
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
# Generated by Django 4.2.11 on 2024-05-07 13:37
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django_extensions.db.fields
|
||||||
|
import taggit.managers
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("scrobbles", "0055_rename_scrobble_log_scrobble_log"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="LifeEvent",
|
||||||
|
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)),
|
||||||
|
(
|
||||||
|
"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
vrobbler/apps/lifeevents/migrations/__init__.py
Normal file
0
vrobbler/apps/lifeevents/migrations/__init__.py
Normal file
36
vrobbler/apps/lifeevents/models.py
Normal file
36
vrobbler/apps/lifeevents/models.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
from django.apps import apps
|
||||||
|
from django.db import models
|
||||||
|
from django.urls import reverse
|
||||||
|
import pendulum
|
||||||
|
from scrobbles.dataclasses import LifeEventLogData
|
||||||
|
from scrobbles.mixins import ScrobblableMixin
|
||||||
|
|
||||||
|
BNULL = {"blank": True, "null": True}
|
||||||
|
|
||||||
|
|
||||||
|
class LifeEvent(ScrobblableMixin):
|
||||||
|
COMPLETION_PERCENT = 100
|
||||||
|
|
||||||
|
description = models.TextField(**BNULL)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse(
|
||||||
|
"life-events:life-event_detail", kwargs={"slug": self.uuid}
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def logdata_cls(self):
|
||||||
|
return LifeEventLogData
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def find_or_create(cls, title: str) -> "LifeEvent":
|
||||||
|
return cls.objects.filter(title=title).first()
|
||||||
|
|
||||||
|
def scrobbles(self, user_id):
|
||||||
|
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
||||||
|
return Scrobble.objects.filter(
|
||||||
|
user_id=user_id, life_event=self
|
||||||
|
).order_by("-timestamp")
|
||||||
16
vrobbler/apps/lifeevents/urls.py
Normal file
16
vrobbler/apps/lifeevents/urls.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from lifeevents import views
|
||||||
|
|
||||||
|
app_name = "lifeevents"
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path(
|
||||||
|
"lifeevents/", views.LifeEventListView.as_view(), name="lifeevent_list"
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"lifeevent/<slug:slug>/",
|
||||||
|
views.LifeEventDetailView.as_view(),
|
||||||
|
name="life-event_detail",
|
||||||
|
),
|
||||||
|
]
|
||||||
12
vrobbler/apps/lifeevents/views.py
Normal file
12
vrobbler/apps/lifeevents/views.py
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
from django.views import generic
|
||||||
|
from lifeevents.models import LifeEvent
|
||||||
|
|
||||||
|
|
||||||
|
class LifeEventListView(generic.ListView):
|
||||||
|
model = LifeEvent
|
||||||
|
paginate_by = 20
|
||||||
|
|
||||||
|
|
||||||
|
class LifeEventDetailView(generic.DetailView):
|
||||||
|
model = LifeEvent
|
||||||
|
slug_field = "uuid"
|
||||||
@ -1,6 +1,6 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from locations.models import GeoLocation, RawGeoLocation
|
from locations.models import GeoLocation
|
||||||
|
|
||||||
from scrobbles.admin import ScrobbleInline
|
from scrobbles.admin import ScrobbleInline
|
||||||
|
|
||||||
@ -19,18 +19,3 @@ class GeoLocationAdmin(admin.ModelAdmin):
|
|||||||
inlines = [
|
inlines = [
|
||||||
ScrobbleInline,
|
ScrobbleInline,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@admin.register(RawGeoLocation)
|
|
||||||
class RawGeoLocationAdmin(admin.ModelAdmin):
|
|
||||||
date_hierarchy = "created"
|
|
||||||
list_display = (
|
|
||||||
"lat",
|
|
||||||
"lon",
|
|
||||||
"altitude",
|
|
||||||
"speed",
|
|
||||||
)
|
|
||||||
ordering = (
|
|
||||||
"lat",
|
|
||||||
"lon",
|
|
||||||
)
|
|
||||||
|
|||||||
@ -0,0 +1,16 @@
|
|||||||
|
# Generated by Django 4.2.9 on 2024-02-20 00:20
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("locations", "0005_alter_rawgeolocation_options_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name="RawGeoLocation",
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
from decimal import Decimal, getcontext
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@ -13,11 +14,13 @@ logger = logging.getLogger(__name__)
|
|||||||
BNULL = {"blank": True, "null": True}
|
BNULL = {"blank": True, "null": True}
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
GEOLOC_ACCURACY = int(getattr(settings, "GEOLOC_ACCURACY", 4))
|
||||||
|
GEOLOC_PROXIMITY = Decimal(getattr(settings, "GEOLOC_PROXIMITY", "0.0001"))
|
||||||
|
|
||||||
|
|
||||||
class GeoLocation(ScrobblableMixin):
|
class GeoLocation(ScrobblableMixin):
|
||||||
COMPLETION_PERCENT = getattr(settings, "LOCATION_COMPLETION_PERCENT", 100)
|
COMPLETION_PERCENT = getattr(settings, "LOCATION_COMPLETION_PERCENT", 100)
|
||||||
|
|
||||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
|
||||||
lat = models.FloatField()
|
lat = models.FloatField()
|
||||||
lon = models.FloatField()
|
lon = models.FloatField()
|
||||||
truncated_lat = models.FloatField(**BNULL)
|
truncated_lat = models.FloatField(**BNULL)
|
||||||
@ -53,11 +56,11 @@ class GeoLocation(ScrobblableMixin):
|
|||||||
int_lon, r_lon = str(data_dict.get("lon", "")).split(".")
|
int_lon, r_lon = str(data_dict.get("lon", "")).split(".")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
trunc_lat = r_lat[0:4]
|
trunc_lat = r_lat[0:GEOLOC_ACCURACY]
|
||||||
except IndexError:
|
except IndexError:
|
||||||
trunc_lat = r_lat
|
trunc_lat = r_lat
|
||||||
try:
|
try:
|
||||||
trunc_lon = r_lon[0:4]
|
trunc_lon = r_lon[0:GEOLOC_ACCURACY]
|
||||||
except IndexError:
|
except IndexError:
|
||||||
trunc_lon = r_lon
|
trunc_lon = r_lon
|
||||||
|
|
||||||
@ -65,17 +68,12 @@ class GeoLocation(ScrobblableMixin):
|
|||||||
data_dict["lon"] = float(f"{int_lon}.{trunc_lon}")
|
data_dict["lon"] = float(f"{int_lon}.{trunc_lon}")
|
||||||
|
|
||||||
int_alt, r_alt = str(data_dict.get("alt", "")).split(".")
|
int_alt, r_alt = str(data_dict.get("alt", "")).split(".")
|
||||||
try:
|
|
||||||
trunc_alt = r_lon[0:4]
|
|
||||||
except IndexError:
|
|
||||||
trunc_alt = r_alt
|
|
||||||
|
|
||||||
data_dict["altitude"] = float(f"{int_alt}.{trunc_alt}")
|
data_dict["altitude"] = float(int_alt)
|
||||||
|
|
||||||
location = cls.objects.filter(
|
location = cls.objects.filter(
|
||||||
lat=data_dict.get("lat"),
|
lat=data_dict.get("lat"),
|
||||||
lon=data_dict.get("lon"),
|
lon=data_dict.get("lon"),
|
||||||
altitude=data_dict.get("altitude"),
|
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if not location:
|
if not location:
|
||||||
@ -86,11 +84,49 @@ class GeoLocation(ScrobblableMixin):
|
|||||||
)
|
)
|
||||||
return location
|
return location
|
||||||
|
|
||||||
|
@property
|
||||||
|
def subtitle(self) -> str:
|
||||||
|
if self.title:
|
||||||
|
return f"{self.lat} x {self.lon}"
|
||||||
|
return ""
|
||||||
|
|
||||||
class RawGeoLocation(TimeStampedModel):
|
def loc_diff(self, old_lat_lon: tuple) -> tuple:
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
return (
|
||||||
lat = models.FloatField()
|
abs(Decimal(old_lat_lon[0]) - Decimal(self.lat)),
|
||||||
lon = models.FloatField()
|
abs(Decimal(old_lat_lon[1]) - Decimal(self.lon)),
|
||||||
altitude = models.FloatField(**BNULL)
|
)
|
||||||
speed = models.FloatField(**BNULL)
|
|
||||||
timestamp = models.DateTimeField(**BNULL)
|
def has_moved(self, previous_location: "GeoLocation") -> bool:
|
||||||
|
has_moved = False
|
||||||
|
|
||||||
|
loc_diff = self.loc_diff(
|
||||||
|
(previous_location.lat, previous_location.lon)
|
||||||
|
)
|
||||||
|
if loc_diff[0] > GEOLOC_PROXIMITY or loc_diff[1] > GEOLOC_PROXIMITY:
|
||||||
|
has_moved = True
|
||||||
|
logger.debug(
|
||||||
|
f"[locations] checked whether location has moved against proximity setting",
|
||||||
|
extra={
|
||||||
|
"location_id": self.id,
|
||||||
|
"loc_diff": loc_diff,
|
||||||
|
"has_moved": has_moved,
|
||||||
|
"previous_location_id": previous_location.id,
|
||||||
|
"geoloc_proximity": GEOLOC_PROXIMITY,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return has_moved
|
||||||
|
|
||||||
|
def in_proximity(self, named=False) -> models.QuerySet:
|
||||||
|
lat_min = Decimal(self.lat) - GEOLOC_PROXIMITY
|
||||||
|
lat_max = Decimal(self.lat) + GEOLOC_PROXIMITY
|
||||||
|
lon_min = Decimal(self.lon) - GEOLOC_PROXIMITY
|
||||||
|
lon_max = Decimal(self.lon) + GEOLOC_PROXIMITY
|
||||||
|
is_title_null = not named
|
||||||
|
close_locations = GeoLocation.objects.filter(
|
||||||
|
title__isnull=is_title_null,
|
||||||
|
lat__lte=lat_max,
|
||||||
|
lat__gte=lat_min,
|
||||||
|
lon__lte=lon_max,
|
||||||
|
lon__gte=lon_min,
|
||||||
|
).exclude(id=self.id)
|
||||||
|
return close_locations
|
||||||
|
|||||||
90
vrobbler/apps/locations/tests/test_models.py
Normal file
90
vrobbler/apps/locations/tests/test_models.py
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import pytest
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from locations.models import GeoLocation
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_or_create(caplog):
|
||||||
|
assert not GeoLocation.find_or_create({})
|
||||||
|
assert "No lat or lon keys in data dict" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_find_or_create_truncation():
|
||||||
|
loc = GeoLocation.find_or_create(
|
||||||
|
{"lat": 44.2345, "lon": -68.2345, "alt": 60.356}
|
||||||
|
)
|
||||||
|
assert loc.lat == 44.234
|
||||||
|
assert loc.lon == -68.234
|
||||||
|
assert loc.altitude == 60
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_find_or_create_finds_existing():
|
||||||
|
extant = GeoLocation.objects.create(lat=44.234, lon=-68.234, altitude=50)
|
||||||
|
|
||||||
|
loc = GeoLocation.find_or_create(
|
||||||
|
{"lat": 44.2345, "lon": -68.2345, "alt": 60.356}
|
||||||
|
)
|
||||||
|
assert loc.id == extant.id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_find_or_create_creates_new():
|
||||||
|
extant = GeoLocation.objects.create(lat=44.234, lon=-69.234, altitude=60)
|
||||||
|
|
||||||
|
loc = GeoLocation.find_or_create(
|
||||||
|
{"lat": 44.2345, "lon": -68.2345, "alt": 60.356}
|
||||||
|
)
|
||||||
|
assert not loc.id == extant.id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_found_in_proximity_location():
|
||||||
|
lat = 44.234
|
||||||
|
lon = -69.234
|
||||||
|
loc = GeoLocation.objects.create(lat=lat, lon=lon, altitude=60)
|
||||||
|
|
||||||
|
close = GeoLocation.objects.create(
|
||||||
|
lat=lat + 0.0001, lon=lon - 0.0001, altitude=60
|
||||||
|
)
|
||||||
|
assert close not in loc.in_proximity(named=True)
|
||||||
|
assert close in loc.in_proximity()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_not_found_in_proximity_location():
|
||||||
|
lat = 44.234
|
||||||
|
lon = -69.234
|
||||||
|
loc = GeoLocation.objects.create(lat=lat, lon=lon, altitude=60)
|
||||||
|
|
||||||
|
far = GeoLocation.objects.create(
|
||||||
|
lat=lat + 0.0002, lon=lon - 0.0001, altitude=60
|
||||||
|
)
|
||||||
|
assert far not in loc.in_proximity()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_has_moved(caplog):
|
||||||
|
lat = 44.234
|
||||||
|
lon = -69.234
|
||||||
|
loc = GeoLocation.objects.create(lat=lat, lon=lon, altitude=60)
|
||||||
|
|
||||||
|
past = GeoLocation.objects.get_or_create(
|
||||||
|
lat=lat + 0.0009, lon=lon - 0.002, altitude=60
|
||||||
|
)[0]
|
||||||
|
assert loc.has_moved(past)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_has_not_moved():
|
||||||
|
lat = 44.234
|
||||||
|
lon = -69.234
|
||||||
|
loc = GeoLocation.objects.create(lat=lat, lon=lon, altitude=60)
|
||||||
|
|
||||||
|
past = GeoLocation.objects.get_or_create(
|
||||||
|
lat=lat + 0.00009, lon=lon - 0.00009, altitude=60
|
||||||
|
)[0]
|
||||||
|
assert not loc.has_moved(past)
|
||||||
0
vrobbler/apps/moods/__init__.py
Normal file
0
vrobbler/apps/moods/__init__.py
Normal file
23
vrobbler/apps/moods/admin.py
Normal file
23
vrobbler/apps/moods/admin.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from moods.models import Mood
|
||||||
|
from scrobbles.admin import ScrobbleInline
|
||||||
|
|
||||||
|
|
||||||
|
class MoodInline(admin.TabularInline):
|
||||||
|
model = Mood
|
||||||
|
extra = 0
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Mood)
|
||||||
|
class MoodAdmin(admin.ModelAdmin):
|
||||||
|
date_hierarchy = "created"
|
||||||
|
list_display = (
|
||||||
|
"uuid",
|
||||||
|
"title",
|
||||||
|
)
|
||||||
|
ordering = ("-created",)
|
||||||
|
search_fields = ("title",)
|
||||||
|
inlines = [
|
||||||
|
ScrobbleInline,
|
||||||
|
]
|
||||||
5
vrobbler/apps/moods/apps.py
Normal file
5
vrobbler/apps/moods/apps.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class MoodsConfig(AppConfig):
|
||||||
|
name = "moods"
|
||||||
1
vrobbler/apps/moods/fixtures/moods.json
Normal file
1
vrobbler/apps/moods/fixtures/moods.json
Normal file
File diff suppressed because one or more lines are too long
79
vrobbler/apps/moods/migrations/0001_initial.py
Normal file
79
vrobbler/apps/moods/migrations/0001_initial.py
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
# Generated by Django 4.2.13 on 2024-08-10 20:01
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django_extensions.db.fields
|
||||||
|
import taggit.managers
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("scrobbles", "0056_scrobble_life_event_alter_scrobble_media_type"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Mood",
|
||||||
|
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)),
|
||||||
|
(
|
||||||
|
"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,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
18
vrobbler/apps/moods/migrations/0002_mood_image.py
Normal file
18
vrobbler/apps/moods/migrations/0002_mood_image.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.13 on 2024-08-10 20:43
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("moods", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="mood",
|
||||||
|
name="image",
|
||||||
|
field=models.ImageField(blank=True, null=True, upload_to="moods/"),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
vrobbler/apps/moods/migrations/__init__.py
Normal file
0
vrobbler/apps/moods/migrations/__init__.py
Normal file
56
vrobbler/apps/moods/models.py
Normal file
56
vrobbler/apps/moods/models.py
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db import models
|
||||||
|
from django.urls import reverse
|
||||||
|
from imagekit.models import ImageSpecField
|
||||||
|
from imagekit.processors import ResizeToFit
|
||||||
|
from scrobbles.mixins import ScrobblableMixin
|
||||||
|
|
||||||
|
from vrobbler.apps.scrobbles.dataclasses import MoodLogData
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
BNULL = {"blank": True, "null": True}
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class Mood(ScrobblableMixin):
|
||||||
|
description = models.TextField(**BNULL)
|
||||||
|
image = models.ImageField(upload_to="moods/", **BNULL)
|
||||||
|
image_small = ImageSpecField(
|
||||||
|
source="thumbnail",
|
||||||
|
processors=[ResizeToFit(100, 100)],
|
||||||
|
format="JPEG",
|
||||||
|
options={"quality": 60},
|
||||||
|
)
|
||||||
|
image_medium = ImageSpecField(
|
||||||
|
source="thumbnail",
|
||||||
|
processors=[ResizeToFit(300, 300)],
|
||||||
|
format="JPEG",
|
||||||
|
options={"quality": 75},
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
if self.title:
|
||||||
|
return self.title
|
||||||
|
return str(self.uuid)
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse("moods:mood-detail", kwargs={"slug": self.uuid})
|
||||||
|
|
||||||
|
def get_start_url(self):
|
||||||
|
return reverse("scrobbles:start", kwargs={"uuid": self.uuid})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def subtitle(self) -> str:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def logdata_cls(self):
|
||||||
|
return MoodLogData
|
||||||
|
|
||||||
|
@property
|
||||||
|
def primary_image_url(self) -> str:
|
||||||
|
if self.image:
|
||||||
|
return self.image.url
|
||||||
|
return ""
|
||||||
14
vrobbler/apps/moods/urls.py
Normal file
14
vrobbler/apps/moods/urls.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from moods import views
|
||||||
|
|
||||||
|
app_name = "moods"
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("mood/", views.MoodListView.as_view(), name="mood-list"),
|
||||||
|
path(
|
||||||
|
"mood/<slug:slug>/",
|
||||||
|
views.MoodDetailView.as_view(),
|
||||||
|
name="mood-detail",
|
||||||
|
),
|
||||||
|
]
|
||||||
10
vrobbler/apps/moods/views.py
Normal file
10
vrobbler/apps/moods/views.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
from moods.models import Mood
|
||||||
|
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
|
||||||
|
|
||||||
|
|
||||||
|
class MoodListView(ScrobbleableListView):
|
||||||
|
model = Mood
|
||||||
|
|
||||||
|
|
||||||
|
class MoodDetailView(ScrobbleableDetailView):
|
||||||
|
model = Mood
|
||||||
@ -1,3 +1,78 @@
|
|||||||
|
jellyfin_keys = [
|
||||||
|
"ServerId",
|
||||||
|
"ServerName",
|
||||||
|
"ServerVersion",
|
||||||
|
"ServerUrl",
|
||||||
|
"NotificationType",
|
||||||
|
"Timestamp",
|
||||||
|
"UtcTimestamp",
|
||||||
|
"Name",
|
||||||
|
"Overview",
|
||||||
|
"Tagline",
|
||||||
|
"ItemId",
|
||||||
|
"RunTimeTicks",
|
||||||
|
"RunTime",
|
||||||
|
"Year",
|
||||||
|
"Provider_tmdb",
|
||||||
|
"Provider_imdb",
|
||||||
|
"Provider_tmdbcollection",
|
||||||
|
"Provider_tmdbset",
|
||||||
|
"Video_0_Title",
|
||||||
|
"Video_0_Type",
|
||||||
|
"Video_0_Codec",
|
||||||
|
"Video_0_Profile",
|
||||||
|
"Video_0_Level",
|
||||||
|
"Video_0_Height",
|
||||||
|
"Video_0_Width",
|
||||||
|
"Video_0_AspectRatio",
|
||||||
|
"Video_0_Interlaced",
|
||||||
|
"Video_0_FrameRate",
|
||||||
|
"Video_0_VideoRange",
|
||||||
|
"Video_0_ColorSpace",
|
||||||
|
"Video_0_ColorTransfer",
|
||||||
|
"Video_0_ColorPrimaries",
|
||||||
|
"Video_0_PixelFormat",
|
||||||
|
"Video_0_RefFrames",
|
||||||
|
"Audio_0_Title",
|
||||||
|
"Audio_0_Type",
|
||||||
|
"Audio_0_Language",
|
||||||
|
"Audio_0_Codec",
|
||||||
|
"Audio_0_Channels",
|
||||||
|
"Audio_0_Bitrate",
|
||||||
|
"Audio_0_SampleRate",
|
||||||
|
"Audio_0_Default",
|
||||||
|
"Subtitle_0_Title",
|
||||||
|
"Subtitle_0_Type",
|
||||||
|
"Subtitle_0_Language",
|
||||||
|
"Subtitle_0_Codec",
|
||||||
|
"Subtitle_0_Default",
|
||||||
|
"Subtitle_0_Forced",
|
||||||
|
"Subtitle_0_External",
|
||||||
|
"Subtitle_1_Title",
|
||||||
|
"Subtitle_1_Type",
|
||||||
|
"Subtitle_1_Language",
|
||||||
|
"Subtitle_1_Codec",
|
||||||
|
"Subtitle_1_Default",
|
||||||
|
"Subtitle_1_Forced",
|
||||||
|
"Subtitle_1_External",
|
||||||
|
"Subtitle_2_Title",
|
||||||
|
"Subtitle_2_Type",
|
||||||
|
"Subtitle_2_Language",
|
||||||
|
"Subtitle_2_Codec",
|
||||||
|
"Subtitle_2_Default",
|
||||||
|
"Subtitle_2_Forced",
|
||||||
|
"Subtitle_2_External",
|
||||||
|
"PlaybackPositionTicks",
|
||||||
|
"PlaybackPosition",
|
||||||
|
"MediaSourceId",
|
||||||
|
"IsPaused",
|
||||||
|
"IsAutomated",
|
||||||
|
"DeviceId",
|
||||||
|
"DeviceName",
|
||||||
|
"ClientName",
|
||||||
|
"NotificationUsername",
|
||||||
|
"UserId",
|
||||||
|
]
|
||||||
VARIOUS_ARTIST_DICT = {
|
VARIOUS_ARTIST_DICT = {
|
||||||
"name": "Various Artists",
|
"name": "Various Artists",
|
||||||
"theaudiodb_id": "113641",
|
"theaudiodb_id": "113641",
|
||||||
@ -6,10 +81,13 @@ VARIOUS_ARTIST_DICT = {
|
|||||||
|
|
||||||
JELLYFIN_POST_KEYS = {
|
JELLYFIN_POST_KEYS = {
|
||||||
"ITEM_TYPE": "ItemType",
|
"ITEM_TYPE": "ItemType",
|
||||||
|
"ITEM_ID": "ItemId",
|
||||||
"RUN_TIME": "RunTime",
|
"RUN_TIME": "RunTime",
|
||||||
"TITLE": "Name",
|
"TRACK_TITLE": "Name",
|
||||||
"TIMESTAMP": "UtcTimestamp",
|
"TIMESTAMP": "UtcTimestamp",
|
||||||
"YEAR": "Year",
|
"YEAR": "Year",
|
||||||
|
"OVERVIEW": "Overview",
|
||||||
|
"TAGLINE": "TAGLINE",
|
||||||
"PLAYBACK_POSITION_TICKS": "PlaybackPositionTicks",
|
"PLAYBACK_POSITION_TICKS": "PlaybackPositionTicks",
|
||||||
"PLAYBACK_POSITION": "PlaybackPosition",
|
"PLAYBACK_POSITION": "PlaybackPosition",
|
||||||
"ARTIST_MB_ID": "Provider_musicbrainzartist",
|
"ARTIST_MB_ID": "Provider_musicbrainzartist",
|
||||||
@ -18,4 +96,25 @@ JELLYFIN_POST_KEYS = {
|
|||||||
"TRACK_MB_ID": "Provider_musicbrainztrack",
|
"TRACK_MB_ID": "Provider_musicbrainztrack",
|
||||||
"ALBUM_NAME": "Album",
|
"ALBUM_NAME": "Album",
|
||||||
"ARTIST_NAME": "Artist",
|
"ARTIST_NAME": "Artist",
|
||||||
|
"STATUS": "Status",
|
||||||
|
"SOURCE": "ClientName",
|
||||||
|
"VIDEO_TITLE": "Name",
|
||||||
|
"IMDB_ID": "Provider_imdb",
|
||||||
|
"TMDB_ID": "Provider_tmdb",
|
||||||
|
}
|
||||||
|
|
||||||
|
MOPIDY_POST_KEYS = {
|
||||||
|
"ITEM_TYPE": None,
|
||||||
|
"RUN_TIME": "run_time",
|
||||||
|
"TRACK_TITLE": "name",
|
||||||
|
"TIMESTAMP": None,
|
||||||
|
"YEAR": None,
|
||||||
|
"PLAYBACK_POSITION_TICKS": "playback_time_ticks",
|
||||||
|
"ARTIST_MB_ID": "muscibrainz_artist_id",
|
||||||
|
"ALBUM_MB_ID": "musicbrainz_album_id",
|
||||||
|
"RELEASEGROUP_MB_ID": None,
|
||||||
|
"TRACK_MB_ID": "musicbrainz_track_id",
|
||||||
|
"ALBUM_NAME": "album",
|
||||||
|
"ARTIST_NAME": "artist",
|
||||||
|
"STATUS": "status",
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,7 +46,6 @@ class LastFM:
|
|||||||
|
|
||||||
new_scrobbles = []
|
new_scrobbles = []
|
||||||
source = "Last.fm"
|
source = "Last.fm"
|
||||||
source_id = ""
|
|
||||||
lastfm_scrobbles = self.get_last_scrobbles(time_from=last_processed)
|
lastfm_scrobbles = self.get_last_scrobbles(time_from=last_processed)
|
||||||
|
|
||||||
for lfm_scrobble in lastfm_scrobbles:
|
for lfm_scrobble in lastfm_scrobbles:
|
||||||
@ -60,12 +59,16 @@ class LastFM:
|
|||||||
lfm_scrobble["album"] = album
|
lfm_scrobble["album"] = album
|
||||||
track = get_or_create_track(**lfm_scrobble)
|
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(
|
new_scrobble = Scrobble(
|
||||||
user=self.vrobbler_user,
|
user=self.vrobbler_user,
|
||||||
timestamp=timestamp,
|
timestamp=timestamp,
|
||||||
source=source,
|
source=source,
|
||||||
source_id=source_id,
|
|
||||||
track=track,
|
track=track,
|
||||||
|
timezone=timezone,
|
||||||
played_to_completion=True,
|
played_to_completion=True,
|
||||||
in_progress=False,
|
in_progress=False,
|
||||||
media_type=Scrobble.MediaType.TRACK,
|
media_type=Scrobble.MediaType.TRACK,
|
||||||
|
|||||||
@ -404,7 +404,7 @@ class Album(TimeStampedModel):
|
|||||||
|
|
||||||
|
|
||||||
class Track(ScrobblableMixin):
|
class Track(ScrobblableMixin):
|
||||||
COMPLETION_PERCENT = getattr(settings, "MUSIC_COMPLETION_PERCENT", 90)
|
COMPLETION_PERCENT = getattr(settings, "MUSIC_COMPLETION_PERCENT", 100)
|
||||||
|
|
||||||
class Opinion(models.IntegerChoices):
|
class Opinion(models.IntegerChoices):
|
||||||
DOWN = -1, "Thumbs down"
|
DOWN = -1, "Thumbs down"
|
||||||
|
|||||||
@ -104,19 +104,37 @@ def lookup_artist_from_mb(artist_name: str) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def lookup_track_from_mb(
|
def lookup_track_from_mb(
|
||||||
track_name: str, artist_mbid: str, album_mbid: str
|
track_name: str, artist_mb_id: str, album_mb_id: str
|
||||||
) -> str:
|
) -> dict:
|
||||||
|
logger.info(
|
||||||
|
"[lookup_track_from_mb] called",
|
||||||
|
extra={
|
||||||
|
"track_name": track_name,
|
||||||
|
"artist_mb_id": artist_mb_id,
|
||||||
|
"album_mb_id": album_mb_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
musicbrainzngs.set_useragent("vrobbler", "0.3.0")
|
musicbrainzngs.set_useragent("vrobbler", "0.3.0")
|
||||||
|
|
||||||
top_result = musicbrainzngs.search_recordings(
|
try:
|
||||||
query=track_name, artist=artist_mbid, release=album_mbid
|
results = musicbrainzngs.search_recordings(
|
||||||
)["recording-list"][0]
|
query=track_name, artist=artist_mb_id, release=album_mb_id
|
||||||
|
)["recording-list"]
|
||||||
|
logger.info(
|
||||||
|
"[lookup_track_from_mb] musicbrainz recordings search results",
|
||||||
|
extra={"results": results},
|
||||||
|
)
|
||||||
|
top_result = results[0]
|
||||||
|
except IndexError:
|
||||||
|
logger.error("[lookup_track_from_mb] no results found")
|
||||||
|
return {}
|
||||||
|
|
||||||
score = int(top_result.get("ext:score"))
|
score = int(top_result.get("ext:score"))
|
||||||
if score < 85:
|
if score < 85:
|
||||||
logger.debug(
|
logger.info(
|
||||||
"Track lookup score below 85 threshold",
|
"[lookup_track_from_mb] no results above 85% certainty ",
|
||||||
extra={"result": top_result},
|
extra={"results": results},
|
||||||
)
|
)
|
||||||
return ""
|
return {}
|
||||||
|
|
||||||
return top_result
|
return top_result
|
||||||
|
|||||||
@ -8,6 +8,7 @@ from music.musicbrainz import (
|
|||||||
lookup_track_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__)
|
||||||
|
|
||||||
@ -89,32 +90,55 @@ def get_or_create_album(
|
|||||||
return album
|
return album
|
||||||
|
|
||||||
|
|
||||||
def get_or_create_track(
|
def get_or_create_track(post_data: dict, post_keys: dict) -> Track:
|
||||||
title: str,
|
try:
|
||||||
artist: Artist,
|
track_run_time_seconds = int(
|
||||||
album: Album = None,
|
post_data.get(post_keys.get("RUN_TIME"), 0)
|
||||||
mbid: str = None,
|
)
|
||||||
run_time_seconds=None,
|
except ValueError: # Sometimes we get run time as a string like "01:35"
|
||||||
) -> Track:
|
track_run_time_seconds = convert_to_seconds(
|
||||||
track = None
|
post_data.get(post_keys.get("RUN_TIME"), 0)
|
||||||
if not mbid and album:
|
)
|
||||||
mbid = lookup_track_from_mb(
|
|
||||||
title,
|
|
||||||
artist.musicbrainz_id,
|
|
||||||
album.musicbrainz_id,
|
|
||||||
)["id"]
|
|
||||||
|
|
||||||
track = Track.objects.filter(musicbrainz_id=mbid).first()
|
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:
|
if not track:
|
||||||
track = Track.objects.create(
|
track = Track.objects.create(
|
||||||
title=title,
|
title=track_title,
|
||||||
artist=artist,
|
artist=artist,
|
||||||
album=album,
|
album=album,
|
||||||
musicbrainz_id=mbid,
|
musicbrainz_id=track_mb_id,
|
||||||
run_time_seconds=run_time_seconds,
|
run_time_seconds=track_run_time_seconds,
|
||||||
)
|
)
|
||||||
|
|
||||||
return track
|
return track
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -4,20 +4,15 @@ from music.models import Album, Artist, Track
|
|||||||
from scrobbles.models import ChartRecord
|
from scrobbles.models import ChartRecord
|
||||||
from scrobbles.stats import get_scrobble_count_qs
|
from scrobbles.stats import get_scrobble_count_qs
|
||||||
|
|
||||||
|
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
|
||||||
|
|
||||||
class TrackListView(generic.ListView):
|
|
||||||
|
class TrackListView(ScrobbleableListView):
|
||||||
model = Track
|
model = Track
|
||||||
paginate_by = 200
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
return get_scrobble_count_qs(user=self.request.user).order_by(
|
|
||||||
"-scrobble_count"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TrackDetailView(generic.DetailView):
|
class TrackDetailView(ScrobbleableDetailView):
|
||||||
model = Track
|
model = Track
|
||||||
slug_field = "uuid"
|
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context_data = super().get_context_data(**kwargs)
|
context_data = super().get_context_data(**kwargs)
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from podcasts.models import Episode, Podcast, Producer
|
from podcasts.models import PodcastEpisode, Podcast, Producer
|
||||||
from scrobbles.admin import ScrobbleInline
|
from scrobbles.admin import ScrobbleInline
|
||||||
|
|
||||||
|
|
||||||
@ -21,8 +21,8 @@ class PodcastAdmin(admin.ModelAdmin):
|
|||||||
ordering = ("name",)
|
ordering = ("name",)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Episode)
|
@admin.register(PodcastEpisode)
|
||||||
class EpisodeAdmin(admin.ModelAdmin):
|
class PodcastEpisodeAdmin(admin.ModelAdmin):
|
||||||
date_hierarchy = "created"
|
date_hierarchy = "created"
|
||||||
list_display = (
|
list_display = (
|
||||||
"title",
|
"title",
|
||||||
|
|||||||
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.9 on 2024-02-10 18:54
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("scrobbles", "0048_alter_scrobble_media_type"),
|
||||||
|
("podcasts", "0012_alter_episode_genre"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameModel(
|
||||||
|
old_name="Episode",
|
||||||
|
new_name="PodcastEpisode",
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -76,7 +76,7 @@ class Podcast(TimeStampedModel):
|
|||||||
self.cover_image.save(fname, ContentFile(r.content), save=True)
|
self.cover_image.save(fname, ContentFile(r.content), save=True)
|
||||||
|
|
||||||
|
|
||||||
class Episode(ScrobblableMixin):
|
class PodcastEpisode(ScrobblableMixin):
|
||||||
COMPLETION_PERCENT = getattr(settings, "PODCAST_COMPLETION_PERCENT", 90)
|
COMPLETION_PERCENT = getattr(settings, "PODCAST_COMPLETION_PERCENT", 90)
|
||||||
|
|
||||||
podcast = models.ForeignKey(Podcast, on_delete=models.DO_NOTHING)
|
podcast = models.ForeignKey(Podcast, on_delete=models.DO_NOTHING)
|
||||||
|
|||||||
79
vrobbler/apps/podcasts/utils.py
Normal file
79
vrobbler/apps/podcasts/utils.py
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
|
from dateutil.parser import ParserError, parse
|
||||||
|
from podcasts.models import PodcastEpisode
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# TODO This should be configurable in settings or per deploy
|
||||||
|
PODCAST_DATE_FORMAT = "YYYY-MM-DD"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_mopidy_uri(uri: str) -> dict:
|
||||||
|
logger.debug(f"Parsing URI: {uri}")
|
||||||
|
parsed_uri = os.path.splitext(unquote(uri))[0].split("/")
|
||||||
|
|
||||||
|
episode_str = parsed_uri[-1]
|
||||||
|
podcast_name = parsed_uri[-2].strip()
|
||||||
|
episode_num = None
|
||||||
|
episode_num_pad = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Without episode numbers the date will lead
|
||||||
|
pub_date = parse(episode_str[0:10])
|
||||||
|
except ParserError:
|
||||||
|
episode_num = int(episode_str.split("-")[0])
|
||||||
|
episode_num_pad = len(str(episode_num)) + 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Beacuse we have epsiode numbers on
|
||||||
|
pub_date = parse(
|
||||||
|
episode_str[
|
||||||
|
episode_num_pad : len(PODCAST_DATE_FORMAT)
|
||||||
|
+ episode_num_pad
|
||||||
|
]
|
||||||
|
)
|
||||||
|
except ParserError:
|
||||||
|
pub_date = ""
|
||||||
|
|
||||||
|
gap_to_strip = 0
|
||||||
|
if pub_date:
|
||||||
|
gap_to_strip += len(PODCAST_DATE_FORMAT)
|
||||||
|
if episode_num:
|
||||||
|
gap_to_strip += episode_num_pad
|
||||||
|
|
||||||
|
episode_name = episode_str[gap_to_strip:].replace("-", " ").strip()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"episode_filename": episode_name,
|
||||||
|
"episode_num": episode_num,
|
||||||
|
"podcast_name": podcast_name,
|
||||||
|
"pub_date": pub_date,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_podcast(post_data: dict) -> PodcastEpisode:
|
||||||
|
mopidy_uri = post_data.get("mopidy_uri", "")
|
||||||
|
parsed_data = parse_mopidy_uri(mopidy_uri)
|
||||||
|
|
||||||
|
producer_dict = {"name": post_data.get("artist")}
|
||||||
|
|
||||||
|
podcast_name = post_data.get("album")
|
||||||
|
if not podcast_name:
|
||||||
|
podcast_name = parsed_data.get("podcast_name")
|
||||||
|
podcast_dict = {"name": podcast_name}
|
||||||
|
|
||||||
|
episode_name = parsed_data.get("episode_filename")
|
||||||
|
episode_dict = {
|
||||||
|
"title": episode_name,
|
||||||
|
"run_time_seconds": post_data.get("run_time"),
|
||||||
|
"number": parsed_data.get("episode_num"),
|
||||||
|
"pub_date": parsed_data.get("pub_date"),
|
||||||
|
"mopidy_uri": mopidy_uri,
|
||||||
|
}
|
||||||
|
|
||||||
|
return PodcastEpisode.find_or_create(
|
||||||
|
podcast_dict, producer_dict, episode_dict
|
||||||
|
)
|
||||||
@ -11,4 +11,5 @@ class UserProfileAdmin(admin.ModelAdmin):
|
|||||||
"twitch_token",
|
"twitch_token",
|
||||||
"twitch_client_secret",
|
"twitch_client_secret",
|
||||||
"lastfm_password",
|
"lastfm_password",
|
||||||
|
"archivebox_password",
|
||||||
)
|
)
|
||||||
|
|||||||
8
vrobbler/apps/profiles/apps.py
Normal file
8
vrobbler/apps/profiles/apps.py
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ProfilesConfig(AppConfig):
|
||||||
|
name = "profiles"
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
import profiles.signals
|
||||||
@ -0,0 +1,601 @@
|
|||||||
|
# Generated by Django 4.2.9 on 2024-01-27 04:43
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("profiles", "0010_userprofile_redirect_to_webpage"),
|
||||||
|
]
|
||||||
|
|
||||||
|
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"),
|
||||||
|
("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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,601 @@
|
|||||||
|
# Generated by Django 4.2.9 on 2024-03-27 01:39
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("profiles", "0011_alter_userprofile_timezone"),
|
||||||
|
]
|
||||||
|
|
||||||
|
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",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"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/Casablanca", "(GMT+0000) Africa/Casablanca"),
|
||||||
|
("Africa/Conakry", "(GMT+0000) Africa/Conakry"),
|
||||||
|
("Africa/Dakar", "(GMT+0000) Africa/Dakar"),
|
||||||
|
("Africa/El_Aaiun", "(GMT+0000) Africa/El_Aaiun"),
|
||||||
|
("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/Ceuta", "(GMT+0100) Africa/Ceuta"),
|
||||||
|
("Africa/Douala", "(GMT+0100) Africa/Douala"),
|
||||||
|
("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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,617 @@
|
|||||||
|
# Generated by Django 4.2.11 on 2024-04-13 02:03
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import encrypted_field.fields
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("profiles", "0012_alter_userprofile_timezone"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="archivebox_password",
|
||||||
|
field=encrypted_field.fields.EncryptedField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="archivebox_url",
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="archivebox_username",
|
||||||
|
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"),
|
||||||
|
("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/Easter", "(GMT-0600) Pacific/Easter"),
|
||||||
|
("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"),
|
||||||
|
("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/Santiago", "(GMT-0400) America/Santiago"),
|
||||||
|
(
|
||||||
|
"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/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/Casablanca", "(GMT+0000) Africa/Casablanca"),
|
||||||
|
("Africa/Conakry", "(GMT+0000) Africa/Conakry"),
|
||||||
|
("Africa/Dakar", "(GMT+0000) Africa/Dakar"),
|
||||||
|
("Africa/El_Aaiun", "(GMT+0000) Africa/El_Aaiun"),
|
||||||
|
("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/Douala", "(GMT+0100) Africa/Douala"),
|
||||||
|
("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,601 @@
|
|||||||
|
# Generated by Django 4.2.11 on 2024-04-19 13:10
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("profiles", "0013_userprofile_archivebox_password_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
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/Easter", "(GMT-0600) Pacific/Easter"),
|
||||||
|
("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"),
|
||||||
|
("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/Santiago", "(GMT-0400) America/Santiago"),
|
||||||
|
(
|
||||||
|
"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/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,18 @@
|
|||||||
|
# Generated by Django 4.2.13 on 2024-08-06 15:27
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("profiles", "0014_alter_userprofile_timezone"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="bgg_username",
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -17,9 +17,7 @@ class UserProfile(TimeStampedModel):
|
|||||||
User, on_delete=models.CASCADE, related_name="profile"
|
User, on_delete=models.CASCADE, related_name="profile"
|
||||||
)
|
)
|
||||||
timezone = models.CharField(
|
timezone = models.CharField(
|
||||||
max_length=255,
|
max_length=255, choices=PRETTY_TIMEZONE_CHOICES, default="UTC"
|
||||||
choices=PRETTY_TIMEZONE_CHOICES,
|
|
||||||
**BNULL,
|
|
||||||
)
|
)
|
||||||
lastfm_username = models.CharField(max_length=255, **BNULL)
|
lastfm_username = models.CharField(max_length=255, **BNULL)
|
||||||
lastfm_password = EncryptedField(**BNULL)
|
lastfm_password = EncryptedField(**BNULL)
|
||||||
@ -28,6 +26,12 @@ class UserProfile(TimeStampedModel):
|
|||||||
retroarch_path = models.CharField(max_length=255, **BNULL)
|
retroarch_path = models.CharField(max_length=255, **BNULL)
|
||||||
retroarch_auto_import = models.BooleanField(default=False)
|
retroarch_auto_import = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
archivebox_username = models.CharField(max_length=255, **BNULL)
|
||||||
|
archivebox_password = EncryptedField(**BNULL)
|
||||||
|
archivebox_url = models.CharField(max_length=255, **BNULL)
|
||||||
|
|
||||||
|
bgg_username = models.CharField(max_length=255, **BNULL)
|
||||||
|
|
||||||
redirect_to_webpage = models.BooleanField(default=True)
|
redirect_to_webpage = models.BooleanField(default=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
|||||||
@ -7,7 +7,6 @@ from scrobbles.models import (
|
|||||||
LastFmImport,
|
LastFmImport,
|
||||||
RetroarchImport,
|
RetroarchImport,
|
||||||
Scrobble,
|
Scrobble,
|
||||||
ScrobbledPage,
|
|
||||||
)
|
)
|
||||||
from scrobbles.mixins import Genre
|
from scrobbles.mixins import Genre
|
||||||
|
|
||||||
@ -24,10 +23,16 @@ class ScrobbleInline(admin.TabularInline):
|
|||||||
"sport_event",
|
"sport_event",
|
||||||
"board_game",
|
"board_game",
|
||||||
"geo_location",
|
"geo_location",
|
||||||
"webpage",
|
"web_page",
|
||||||
|
"life_event",
|
||||||
"user",
|
"user",
|
||||||
)
|
)
|
||||||
exclude = ("source_id", "scrobble_log")
|
exclude = (
|
||||||
|
"scrobble_log",
|
||||||
|
"timezone",
|
||||||
|
"videogame_save_data",
|
||||||
|
"screenshot",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ImportBaseAdmin(admin.ModelAdmin):
|
class ImportBaseAdmin(admin.ModelAdmin):
|
||||||
@ -108,6 +113,10 @@ class ScrobbleAdmin(admin.ModelAdmin):
|
|||||||
"sport_event",
|
"sport_event",
|
||||||
"book",
|
"book",
|
||||||
"video_game",
|
"video_game",
|
||||||
|
"board_game",
|
||||||
|
"geo_location",
|
||||||
|
"web_page",
|
||||||
|
"life_event",
|
||||||
)
|
)
|
||||||
list_filter = (
|
list_filter = (
|
||||||
"is_paused",
|
"is_paused",
|
||||||
@ -123,13 +132,3 @@ class ScrobbleAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
def playback_percent(self, obj):
|
def playback_percent(self, obj):
|
||||||
return obj.percent_played
|
return obj.percent_played
|
||||||
|
|
||||||
|
|
||||||
@admin.register(ScrobbledPage)
|
|
||||||
class ScrobbledPageAdmin(admin.ModelAdmin):
|
|
||||||
list_display = (
|
|
||||||
"number",
|
|
||||||
"scrobble",
|
|
||||||
"notes",
|
|
||||||
)
|
|
||||||
raw_id_fields = ("scrobble",)
|
|
||||||
|
|||||||
@ -6,14 +6,20 @@ JELLYFIN_AUDIO_ITEM_TYPES = ["Audio"]
|
|||||||
LONG_PLAY_MEDIA = {
|
LONG_PLAY_MEDIA = {
|
||||||
"videogames": "VideoGame",
|
"videogames": "VideoGame",
|
||||||
"books": "Book",
|
"books": "Book",
|
||||||
|
"bricksets": "BrickSet",
|
||||||
}
|
}
|
||||||
|
|
||||||
PLAY_AGAIN_MEDIA = {
|
PLAY_AGAIN_MEDIA = {
|
||||||
"videogames": "VideoGame",
|
"videogames": "VideoGame",
|
||||||
"books": "Book",
|
"books": "Book",
|
||||||
"boardgames": "BoardGame",
|
"boardgames": "BoardGame",
|
||||||
|
"moods": "Mood",
|
||||||
|
"bricksets": "BrickSet",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MEDIA_END_PADDING_SECONDS = {
|
||||||
|
"Video": 3600, # 60 min
|
||||||
|
}
|
||||||
|
|
||||||
EXCLUDE_FROM_NOW_PLAYING = ("GeoLocation",)
|
EXCLUDE_FROM_NOW_PLAYING = ("GeoLocation",)
|
||||||
|
|
||||||
|
|||||||
166
vrobbler/apps/scrobbles/dataclasses.py
Normal file
166
vrobbler/apps/scrobbles/dataclasses.py
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
from functools import cached_property
|
||||||
|
import inspect
|
||||||
|
import json
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from dataclass_wizard import JSONWizard
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from locations.models import GeoLocation
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class ScrobbleLogDataEncoder(json.JSONEncoder):
|
||||||
|
def default(self, o):
|
||||||
|
return o.__dict__
|
||||||
|
|
||||||
|
|
||||||
|
class ScrobbleLogDataDecoder(json.JSONDecoder):
|
||||||
|
def default(self, o):
|
||||||
|
return o.__dict__
|
||||||
|
|
||||||
|
|
||||||
|
class JSONDataclass(JSONWizard):
|
||||||
|
@property
|
||||||
|
def asdict(self):
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def json(self):
|
||||||
|
return json.dumps(self.asdict)
|
||||||
|
|
||||||
|
class LongPlayLogData(JSONDataclass):
|
||||||
|
serial_scrobble_id: Optional[int]
|
||||||
|
long_play_complete: Optional[bool]
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BoardGameScoreLogData(JSONDataclass):
|
||||||
|
user_id: Optional[int] = None
|
||||||
|
name_str: str = ""
|
||||||
|
bgg_username: str = ""
|
||||||
|
color: Optional[str] = None
|
||||||
|
character: Optional[str] = None
|
||||||
|
team: Optional[str] = None
|
||||||
|
score: Optional[int] = None
|
||||||
|
win: Optional[bool] = None
|
||||||
|
new: Optional[bool] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def user(self) -> Optional[User]:
|
||||||
|
user = None
|
||||||
|
if self.user_id:
|
||||||
|
user = User.objects.filter(id=self.user_id).first()
|
||||||
|
return user
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
name = self.name_str
|
||||||
|
if self.user_id:
|
||||||
|
name = self.user.first_name
|
||||||
|
return name
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
out = self.name
|
||||||
|
if self.score:
|
||||||
|
out += f" {self.score}"
|
||||||
|
if self.color:
|
||||||
|
out += f" ({self.color})"
|
||||||
|
if self.win:
|
||||||
|
out += f" [W]"
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BoardGameLogData(LongPlayLogData):
|
||||||
|
players: Optional[list[BoardGameScoreLogData]] = None
|
||||||
|
location: Optional[str] = None
|
||||||
|
geo_location_id: Optional[int] = None
|
||||||
|
difficulty: Optional[int] = None
|
||||||
|
solo: Optional[bool] = None
|
||||||
|
two_handed: Optional[bool] = None
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def geo_location(self) -> Optional[GeoLocation]:
|
||||||
|
if self.geo_location_id:
|
||||||
|
return GeoLocation.objects.filter(id=self.geo_location_id).first()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BookPageLogData(JSONDataclass):
|
||||||
|
page_number: Optional[int] = None
|
||||||
|
end_ts: Optional[int] = None
|
||||||
|
start_ts: Optional[int] = None
|
||||||
|
duration: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BookLogData(LongPlayLogData):
|
||||||
|
koreader_hash: Optional[str]
|
||||||
|
pages_read: Optional[int]
|
||||||
|
page_data: Optional[list[BookPageLogData]]
|
||||||
|
page_end: Optional[int]
|
||||||
|
page_start: Optional[int]
|
||||||
|
serial_scrobble_id: Optional[int]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LifeEventLogData(JSONDataclass):
|
||||||
|
participant_user_ids: Optional[list[int]] = None
|
||||||
|
participant_names: Optional[list[str]] = None
|
||||||
|
location: Optional[str] = None
|
||||||
|
geo_location_id: Optional[int] = None
|
||||||
|
details: Optional[str] = None
|
||||||
|
|
||||||
|
def participants(self) -> list[str]:
|
||||||
|
participants = []
|
||||||
|
if self.participant_user_ids:
|
||||||
|
participants += [u.full_name for u in self.participant_users]
|
||||||
|
if self.participant_names:
|
||||||
|
participants += self.participant_names
|
||||||
|
return participants
|
||||||
|
|
||||||
|
@property
|
||||||
|
def participant_users(self) -> list[User]:
|
||||||
|
participants = []
|
||||||
|
if self.participant_user_ids:
|
||||||
|
for id in self.participant_user_ids:
|
||||||
|
participants.append(User.objects.filter(id=id).first())
|
||||||
|
return participants
|
||||||
|
|
||||||
|
def geo_location(self):
|
||||||
|
return GeoLocation.objects.filter(id=self.geo_location_id).first()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MoodLogData(JSONDataclass):
|
||||||
|
reasons: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VideoLogData(JSONDataclass):
|
||||||
|
title: str
|
||||||
|
video_type: str
|
||||||
|
run_time_seconds: int
|
||||||
|
kind: str
|
||||||
|
year: Optional[int]
|
||||||
|
episode_number: Optional[int]
|
||||||
|
source_url: Optional[str]
|
||||||
|
imdbID: Optional[str]
|
||||||
|
season_number: Optional[int]
|
||||||
|
cover_url = Optional[str]
|
||||||
|
next_imdb_id: Optional[int]
|
||||||
|
tv_series_id: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VideoGameLogData(LongPlayLogData):
|
||||||
|
emulated: bool = False
|
||||||
|
console: Optional[str] = None
|
||||||
|
emulator: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BrickSetLogData(LongPlayLogData):
|
||||||
|
built_with_names: Optional[list[str]]
|
||||||
|
built_with_user_ids: Optional[list[str]]
|
||||||
@ -1,5 +1,5 @@
|
|||||||
from books.models import Book
|
from books.models import Book
|
||||||
from django.core.management.base import BaseCommand, no_translations
|
from django.core.management.base import BaseCommand
|
||||||
from scrobbles.models import ScrobbledPage
|
from scrobbles.models import ScrobbledPage
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,48 @@
|
|||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"--commit",
|
||||||
|
action="store_true",
|
||||||
|
help="Actually migrate data",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
dry_run = True
|
||||||
|
if options["commit"]:
|
||||||
|
dry_run = False
|
||||||
|
|
||||||
|
scrobbles_with_logs = (
|
||||||
|
Scrobble.objects.filter(scrobble_log__isnull=False)
|
||||||
|
.exclude(scrobble_log="")
|
||||||
|
.exclude(scrobble_log="\n")
|
||||||
|
)
|
||||||
|
updated_scrobble_count = 0
|
||||||
|
|
||||||
|
for scrobble in scrobbles_with_logs:
|
||||||
|
if "\n" in scrobble.scrobble_log:
|
||||||
|
lines = scrobble.scrobble_log.split("\n")
|
||||||
|
else:
|
||||||
|
lines = [scrobble.scrobble_log]
|
||||||
|
|
||||||
|
old_data = {}
|
||||||
|
for line_num, line in enumerate(lines):
|
||||||
|
if line_num == 0 or line == "":
|
||||||
|
continue
|
||||||
|
old_data[str(line_num)] = line
|
||||||
|
|
||||||
|
if old_data:
|
||||||
|
scrobble.scrobble_log = {"legacy_data": old_data}
|
||||||
|
updated_scrobble_count += 1
|
||||||
|
|
||||||
|
if not dry_run:
|
||||||
|
scrobble.save(update_fields=["scrobble_log"])
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"Scrobble {scrobble} scrobble_log updated to {old_data}"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Migrated scrobble logs for {updated_scrobble_count} scrobbles")
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 4.2.9 on 2024-01-22 00:53
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("scrobbles", "0045_scrobble_webpage_alter_scrobble_media_type"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="scrobble",
|
||||||
|
name="book_page_data",
|
||||||
|
field=models.JSONField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="chartrecord",
|
||||||
|
name="year",
|
||||||
|
field=models.IntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.9 on 2024-01-29 06:13
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("scrobbles", "0046_scrobble_book_page_data_alter_chartrecord_year"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="scrobble",
|
||||||
|
name="book_koreader_hash",
|
||||||
|
field=models.CharField(blank=True, max_length=50, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
# Generated by Django 4.2.9 on 2024-02-10 18:54
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("scrobbles", "0047_scrobble_book_koreader_hash"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="scrobble",
|
||||||
|
name="media_type",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("Video", "Video"),
|
||||||
|
("Track", "Track"),
|
||||||
|
("PodcastEpisode", "Podcast episode"),
|
||||||
|
("SportEvent", "Sport event"),
|
||||||
|
("Book", "Book"),
|
||||||
|
("VideoGame", "Video game"),
|
||||||
|
("BoardGame", "Board game"),
|
||||||
|
("GeoLocation", "GeoLocation"),
|
||||||
|
("WebPage", "Web Page"),
|
||||||
|
],
|
||||||
|
default="Video",
|
||||||
|
max_length=14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.9 on 2024-02-13 03:57
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("scrobbles", "0048_alter_scrobble_media_type"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="scrobble",
|
||||||
|
old_name="webpage",
|
||||||
|
new_name="web_page",
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
# Generated by Django 4.2.9 on 2024-02-20 00:21
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("scrobbles", "0049_rename_webpage_scrobble_web_page"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name="ScrobbledPage",
|
||||||
|
),
|
||||||
|
]
|
||||||
46
vrobbler/apps/scrobbles/migrations/0051_scrobble_timezone.py
Normal file
46
vrobbler/apps/scrobbles/migrations/0051_scrobble_timezone.py
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
# Generated by Django 4.2.11 on 2024-04-19 13:10
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def set_default_timezone(apps, schema_editor):
|
||||||
|
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
||||||
|
for s in Scrobble.objects.all():
|
||||||
|
if not s.timezone:
|
||||||
|
s.timezone = settings.TIME_ZONE
|
||||||
|
|
||||||
|
if s.user and s.user.profile:
|
||||||
|
s.timezone = s.user.profile.timezone
|
||||||
|
|
||||||
|
# A shim to adjust for our change to European time for 3 months
|
||||||
|
if (
|
||||||
|
datetime(2023, 10, 15).replace(
|
||||||
|
tzinfo=pytz.timezone("Europe/Paris")
|
||||||
|
)
|
||||||
|
<= s.timestamp
|
||||||
|
<= datetime(2023, 12, 15).replace(
|
||||||
|
tzinfo=pytz.timezone("Europe/Paris")
|
||||||
|
)
|
||||||
|
):
|
||||||
|
s.timezone = "Europe/Paris"
|
||||||
|
s.save(update_fields=["timezone"])
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("scrobbles", "0050_delete_scrobbledpage"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="scrobble",
|
||||||
|
name="timezone",
|
||||||
|
field=models.CharField(blank=True, max_length=50, null=True),
|
||||||
|
),
|
||||||
|
migrations.RunPython(set_default_timezone),
|
||||||
|
]
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
# Generated by Django 4.2.9 on 2024-04-05 14:39
|
||||||
|
|
||||||
|
import json
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def convert_log_to_json(apps, schema_editor):
|
||||||
|
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
||||||
|
for s in Scrobble.objects.filter(scrobble_log__isnull=False):
|
||||||
|
s.scrobble_log = json.dumps({"migrated_data": s.scrobble_log})
|
||||||
|
s.save(update_fields=["scrobble_log"])
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("scrobbles", "0051_scrobble_timezone"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(convert_log_to_json),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="scrobble",
|
||||||
|
name="scrobble_log",
|
||||||
|
field=models.JSONField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 4.2.9 on 2024-04-19 18:57
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("scrobbles", "0052_alter_scrobble_scroble_log"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="scrobble",
|
||||||
|
name="source_id",
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
# Generated by Django 4.2.9 on 2024-04-19 19:00
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("scrobbles", "0053_remove_scrobble_source_id"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="scrobble",
|
||||||
|
name="notes",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="scrobble",
|
||||||
|
name="playback_position_ticks",
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.11 on 2024-05-06 02:08
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("scrobbles", "0054_remove_scrobble_notes_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="scrobble",
|
||||||
|
old_name="scrobble_log",
|
||||||
|
new_name="log",
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
# Generated by Django 4.2.11 on 2024-05-07 13:37
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("lifeevents", "0001_initial"),
|
||||||
|
("scrobbles", "0055_rename_scrobble_log_scrobble_log"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="scrobble",
|
||||||
|
name="life_event",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||||
|
to="lifeevents.lifeevent",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="scrobble",
|
||||||
|
name="media_type",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("Video", "Video"),
|
||||||
|
("Track", "Track"),
|
||||||
|
("PodcastEpisode", "Podcast episode"),
|
||||||
|
("SportEvent", "Sport event"),
|
||||||
|
("Book", "Book"),
|
||||||
|
("VideoGame", "Video game"),
|
||||||
|
("BoardGame", "Board game"),
|
||||||
|
("GeoLocation", "GeoLocation"),
|
||||||
|
("WebPage", "Web Page"),
|
||||||
|
("LifeEvent", "Life event"),
|
||||||
|
],
|
||||||
|
default="Video",
|
||||||
|
max_length=14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
# Generated by Django 4.2.13 on 2024-08-06 15:27
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import scrobbles.dataclasses
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("scrobbles", "0056_scrobble_life_event_alter_scrobble_media_type"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="scrobble",
|
||||||
|
name="log",
|
||||||
|
field=models.JSONField(
|
||||||
|
blank=True,
|
||||||
|
decoder=scrobbles.dataclasses.ScrobbleLogDataDecoder,
|
||||||
|
encoder=scrobbles.dataclasses.ScrobbleLogDataEncoder,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
# Generated by Django 4.2.13 on 2024-08-10 20:01
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import scrobbles.dataclasses
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("moods", "0001_initial"),
|
||||||
|
("scrobbles", "0056_scrobble_life_event_alter_scrobble_media_type"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="scrobble",
|
||||||
|
name="mood",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||||
|
to="moods.mood",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="scrobble",
|
||||||
|
name="log",
|
||||||
|
field=models.JSONField(
|
||||||
|
blank=True,
|
||||||
|
decoder=scrobbles.dataclasses.ScrobbleLogDataDecoder,
|
||||||
|
encoder=scrobbles.dataclasses.ScrobbleLogDataEncoder,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="scrobble",
|
||||||
|
name="media_type",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("Video", "Video"),
|
||||||
|
("Track", "Track"),
|
||||||
|
("PodcastEpisode", "Podcast episode"),
|
||||||
|
("SportEvent", "Sport event"),
|
||||||
|
("Book", "Book"),
|
||||||
|
("VideoGame", "Video game"),
|
||||||
|
("BoardGame", "Board game"),
|
||||||
|
("GeoLocation", "GeoLocation"),
|
||||||
|
("WebPage", "Web Page"),
|
||||||
|
("LifeEvent", "Life event"),
|
||||||
|
("Mood", "Mood"),
|
||||||
|
],
|
||||||
|
default="Video",
|
||||||
|
max_length=14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
# Generated by Django 4.2.13 on 2024-08-11 02:34
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("scrobbles", "0057_alter_scrobble_log"),
|
||||||
|
("scrobbles", "0057_scrobble_mood_alter_scrobble_log_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = []
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
# Generated by Django 4.2.15 on 2024-08-20 14:17
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("scrobbles", "0058_merge_20240810_2234"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="scrobble",
|
||||||
|
name="book_koreader_hash",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="scrobble",
|
||||||
|
name="book_page_data",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="scrobble",
|
||||||
|
name="book_pages_read",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="scrobble",
|
||||||
|
name="videogame_screenshot",
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="scrobble",
|
||||||
|
name="screenshot",
|
||||||
|
field=models.ImageField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
upload_to="scrobbles/videogame_screenshot/",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="scrobble",
|
||||||
|
name="videogame_save_data",
|
||||||
|
field=models.FileField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
upload_to="scrobbles/videogame_save_data/",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
25
vrobbler/apps/scrobbles/migrations/0060_scrobble_brickset.py
Normal file
25
vrobbler/apps/scrobbles/migrations/0060_scrobble_brickset.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# Generated by Django 4.2.15 on 2024-09-07 05:38
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bricksets", "0001_initial"),
|
||||||
|
("scrobbles", "0059_remove_scrobble_book_koreader_hash_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="scrobble",
|
||||||
|
name="brickset",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||||
|
to="bricksets.brickset",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
# Generated by Django 4.2.15 on 2024-09-07 05:54
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("scrobbles", "0060_scrobble_brickset"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="scrobble",
|
||||||
|
name="media_type",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("Video", "Video"),
|
||||||
|
("Track", "Track"),
|
||||||
|
("PodcastEpisode", "Podcast episode"),
|
||||||
|
("SportEvent", "Sport event"),
|
||||||
|
("Book", "Book"),
|
||||||
|
("VideoGame", "Video game"),
|
||||||
|
("BoardGame", "Board game"),
|
||||||
|
("GeoLocation", "GeoLocation"),
|
||||||
|
("WebPage", "Web Page"),
|
||||||
|
("LifeEvent", "Life event"),
|
||||||
|
("Mood", "Mood"),
|
||||||
|
("BrickSet", "Brick set"),
|
||||||
|
],
|
||||||
|
default="Video",
|
||||||
|
max_length=14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -2,12 +2,14 @@ import logging
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
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
|
||||||
|
from django.utils import timezone
|
||||||
from django_extensions.db.models import TimeStampedModel
|
from django_extensions.db.models import TimeStampedModel
|
||||||
from taggit.managers import TaggableManager
|
|
||||||
from scrobbles.utils import get_scrobbles_for_media
|
from scrobbles.utils import get_scrobbles_for_media
|
||||||
from taggit.models import TagBase, GenericTaggedItemBase
|
from taggit.managers import TaggableManager
|
||||||
|
from taggit.models import GenericTaggedItemBase, TagBase
|
||||||
|
|
||||||
BNULL = {"blank": True, "null": True}
|
BNULL = {"blank": True, "null": True}
|
||||||
|
|
||||||
@ -33,6 +35,7 @@ class ObjectWithGenres(GenericTaggedItemBase):
|
|||||||
|
|
||||||
class ScrobblableMixin(TimeStampedModel):
|
class ScrobblableMixin(TimeStampedModel):
|
||||||
SECONDS_TO_STALE = 1600
|
SECONDS_TO_STALE = 1600
|
||||||
|
COMPLETION_PERCENT = 100
|
||||||
|
|
||||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||||
title = models.CharField(max_length=255, **BNULL)
|
title = models.CharField(max_length=255, **BNULL)
|
||||||
@ -44,22 +47,59 @@ class ScrobblableMixin(TimeStampedModel):
|
|||||||
class Meta:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
|
def scrobble_for_user(
|
||||||
|
self,
|
||||||
|
user_id,
|
||||||
|
source: str = "Vrobbler",
|
||||||
|
playback_position_seconds: int = 0,
|
||||||
|
status: str = "started",
|
||||||
|
):
|
||||||
|
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
||||||
|
scrobble_data = {
|
||||||
|
"user_id": user_id,
|
||||||
|
"timestamp": timezone.now(),
|
||||||
|
"source": source,
|
||||||
|
"playback_position_seconds": playback_position_seconds,
|
||||||
|
"status": status,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"[scrobble_for_user] called",
|
||||||
|
extra={
|
||||||
|
"id": self.id,
|
||||||
|
"media_type": self.__class__,
|
||||||
|
"user_id": user_id,
|
||||||
|
"scrobble_data": scrobble_data,
|
||||||
|
"media_type": Scrobble.MediaType.WEBPAGE,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return Scrobble.create_or_update(self, user_id, scrobble_data)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def primary_image_url(self) -> str:
|
def primary_image_url(self) -> str:
|
||||||
logger.warn(f"Not implemented yet")
|
logger.warning(f"Not implemented yet")
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def fix_metadata(self):
|
@property
|
||||||
logger.warn("fix_metadata() not implemented yet")
|
def logdata_cls(self) -> None:
|
||||||
|
logger.warning("logdata_cls() not implemented yet")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def subtitle(self) -> str:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def fix_metadata(self) -> None:
|
||||||
|
logger.warning("fix_metadata() not implemented yet")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def find_or_create(cls):
|
def find_or_create(cls) -> None:
|
||||||
logger.warn("find_or_create() not implemented yet")
|
logger.warning("find_or_create() not implemented yet")
|
||||||
|
|
||||||
def scrobble(self, user_id, **kwargs):
|
def __str__(self):
|
||||||
"""Given a user ID and a dictionary of data, attempts to scrobble it"""
|
if self.title:
|
||||||
from scrobbles.models import Scrobble
|
return self.title
|
||||||
Scrobble.create_or_update(self, user_id, **kwargs)
|
return str(self.uuid)
|
||||||
|
|
||||||
|
|
||||||
class LongPlayScrobblableMixin(ScrobblableMixin):
|
class LongPlayScrobblableMixin(ScrobblableMixin):
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,25 +1,22 @@
|
|||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import pendulum
|
import pendulum
|
||||||
from boardgames.bgg import lookup_boardgame_from_bgg
|
import pytz
|
||||||
from boardgames.models import BoardGame
|
from boardgames.models import BoardGame
|
||||||
from books.models import Book
|
from books.models import Book
|
||||||
from books.openlibrary import lookup_book_from_openlibrary
|
|
||||||
from dateutil.parser import parse
|
from dateutil.parser import parse
|
||||||
|
from django.conf import settings
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from locations.constants import LOCATION_PROVIDERS
|
from locations.constants import LOCATION_PROVIDERS
|
||||||
from locations.models import GeoLocation
|
from locations.models import GeoLocation
|
||||||
from music.constants import JELLYFIN_POST_KEYS
|
from music.constants import JELLYFIN_POST_KEYS, MOPIDY_POST_KEYS
|
||||||
from music.models import Track
|
from music.models import Track
|
||||||
from music.utils import (
|
from music.utils import get_or_create_track
|
||||||
get_or_create_album,
|
from podcasts.utils import get_or_create_podcast
|
||||||
get_or_create_artist,
|
from scrobbles.constants import JELLYFIN_AUDIO_ITEM_TYPES
|
||||||
get_or_create_track,
|
|
||||||
)
|
|
||||||
from podcasts.models import Episode
|
|
||||||
from scrobbles.models import Scrobble
|
from scrobbles.models import Scrobble
|
||||||
from scrobbles.utils import convert_to_seconds, parse_mopidy_uri
|
|
||||||
from sports.models import SportEvent
|
from sports.models import SportEvent
|
||||||
from sports.thesportsdb import lookup_event_from_thesportsdb
|
from sports.thesportsdb import lookup_event_from_thesportsdb
|
||||||
from videogames.howlongtobeat import lookup_game_from_hltb
|
from videogames.howlongtobeat import lookup_game_from_hltb
|
||||||
@ -30,149 +27,108 @@ from webpages.models import WebPage
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def mopidy_scrobble_podcast(
|
def mopidy_scrobble_media(post_data: dict, user_id: int) -> Scrobble:
|
||||||
data_dict: dict, user_id: Optional[int]
|
media_type = Scrobble.MediaType.TRACK
|
||||||
) -> Scrobble:
|
if "podcast" in post_data.get("mopidy_uri", ""):
|
||||||
mopidy_uri = data_dict.get("mopidy_uri", "")
|
media_type = Scrobble.MediaType.PODCAST_EPISODE
|
||||||
parsed_data = parse_mopidy_uri(mopidy_uri)
|
|
||||||
|
|
||||||
producer_dict = {"name": data_dict.get("artist")}
|
if settings.DUMP_REQUEST_DATA:
|
||||||
|
print("MOPIDY_DATA: ", post_data)
|
||||||
|
|
||||||
podcast_name = data_dict.get("album")
|
logger.info(
|
||||||
if not podcast_name:
|
"[scrobblers] webhook mopidy scrobble request received",
|
||||||
podcast_name = parsed_data.get("podcast_name")
|
extra={
|
||||||
podcast_dict = {"name": podcast_name}
|
"user_id": user_id,
|
||||||
|
"post_data": post_data,
|
||||||
|
"media_type": media_type,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
episode_name = parsed_data.get("episode_filename")
|
if media_type == Scrobble.MediaType.PODCAST_EPISODE:
|
||||||
episode_dict = {
|
media_obj = get_or_create_podcast(post_data)
|
||||||
"title": episode_name,
|
else:
|
||||||
"run_time_seconds": data_dict.get("run_time"),
|
media_obj = get_or_create_track(post_data, MOPIDY_POST_KEYS)
|
||||||
"number": parsed_data.get("episode_num"),
|
|
||||||
"pub_date": parsed_data.get("pub_date"),
|
|
||||||
"mopidy_uri": mopidy_uri,
|
|
||||||
}
|
|
||||||
|
|
||||||
episode = Episode.find_or_create(podcast_dict, producer_dict, episode_dict)
|
return media_obj.scrobble_for_user(
|
||||||
|
user_id,
|
||||||
# Now we run off a scrobble
|
source="Mopidy",
|
||||||
mopidy_data = {
|
playback_position_seconds=int(
|
||||||
"user_id": user_id,
|
post_data.get(MOPIDY_POST_KEYS.get("PLAYBACK_POSITION_TICKS"), 1)
|
||||||
"timestamp": timezone.now(),
|
/ 1000
|
||||||
"playback_position_seconds": data_dict.get("playback_time_ticks"),
|
),
|
||||||
"source": "Mopidy",
|
status=post_data.get(MOPIDY_POST_KEYS.get("STATUS"), ""),
|
||||||
"mopidy_status": data_dict.get("status"),
|
)
|
||||||
}
|
|
||||||
|
|
||||||
scrobble = None
|
|
||||||
if episode:
|
|
||||||
scrobble = Scrobble.create_or_update(episode, user_id, mopidy_data)
|
|
||||||
return scrobble
|
|
||||||
|
|
||||||
|
|
||||||
def mopidy_scrobble_track(
|
def jellyfin_scrobble_media(
|
||||||
data_dict: dict, user_id: Optional[int]
|
post_data: dict, user_id: int
|
||||||
) -> Optional[Scrobble]:
|
) -> Optional[Scrobble]:
|
||||||
artist = get_or_create_artist(
|
media_type = Scrobble.MediaType.VIDEO
|
||||||
data_dict.get("artist"),
|
if post_data.pop("ItemType", "") in JELLYFIN_AUDIO_ITEM_TYPES:
|
||||||
mbid=data_dict.get("musicbrainz_artist_id", None),
|
media_type = Scrobble.MediaType.TRACK
|
||||||
|
|
||||||
|
if settings.DUMP_REQUEST_DATA:
|
||||||
|
print("JELLYFIN_DATA: ", post_data)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"[jellyfin_scrobble_media] called",
|
||||||
|
extra={
|
||||||
|
"user_id": user_id,
|
||||||
|
"post_data": post_data,
|
||||||
|
"media_type": media_type,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
album = get_or_create_album(
|
|
||||||
data_dict.get("album"),
|
|
||||||
artist=artist,
|
|
||||||
mbid=data_dict.get("musicbrainz_album_id"),
|
|
||||||
)
|
|
||||||
track = get_or_create_track(
|
|
||||||
title=data_dict.get("name"),
|
|
||||||
mbid=data_dict.get("musicbrainz_track_id"),
|
|
||||||
artist=artist,
|
|
||||||
album=album,
|
|
||||||
run_time_seconds=data_dict.get("run_time"),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Now we run off a scrobble
|
|
||||||
playback_seconds = data_dict.get("playback_time_ticks") / 1000
|
|
||||||
mopidy_data = {
|
|
||||||
"user_id": user_id,
|
|
||||||
"timestamp": timezone.now(),
|
|
||||||
"playback_position_seconds": playback_seconds,
|
|
||||||
"source": "Mopidy",
|
|
||||||
"mopidy_status": data_dict.get("status"),
|
|
||||||
}
|
|
||||||
|
|
||||||
scrobble = Scrobble.create_or_update(track, user_id, mopidy_data)
|
|
||||||
|
|
||||||
return scrobble
|
|
||||||
|
|
||||||
|
|
||||||
def build_scrobble_dict(data_dict: dict, user_id: int) -> dict:
|
|
||||||
jellyfin_status = "resumed"
|
|
||||||
if data_dict.get("IsPaused"):
|
|
||||||
jellyfin_status = "paused"
|
|
||||||
elif data_dict.get("NotificationType") == "PlaybackStop":
|
|
||||||
jellyfin_status = "stopped"
|
|
||||||
|
|
||||||
playback_seconds = convert_to_seconds(
|
|
||||||
data_dict.get("PlaybackPosition", "")
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"user_id": user_id,
|
|
||||||
"timestamp": parse(data_dict.get("UtcTimestamp")),
|
|
||||||
"playback_position_seconds": playback_seconds,
|
|
||||||
"source": data_dict.get("ClientName", "Vrobbler"),
|
|
||||||
"source_id": data_dict.get("MediaSourceId"),
|
|
||||||
"jellyfin_status": jellyfin_status,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def jellyfin_scrobble_track(
|
|
||||||
data_dict: dict, user_id: Optional[int]
|
|
||||||
) -> Optional[Scrobble]:
|
|
||||||
|
|
||||||
null_position_on_progress = (
|
null_position_on_progress = (
|
||||||
data_dict.get("PlaybackPosition") == "00:00:00"
|
post_data.get("PlaybackPosition") == "00:00:00"
|
||||||
and data_dict.get("NotificationType") == "PlaybackProgress"
|
and post_data.get("NotificationType") == "PlaybackProgress"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Jellyfin has some race conditions with it's webhooks, these hacks fix some of them
|
# Jellyfin has some race conditions with it's webhooks, these hacks fix some of them
|
||||||
if null_position_on_progress:
|
if null_position_on_progress:
|
||||||
logger.error("No playback position tick from Jellyfin, aborting")
|
logger.info(
|
||||||
|
"[jellyfin_scrobble_media] no playback position tick, aborting",
|
||||||
|
extra={"post_data": post_data},
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
artist = get_or_create_artist(
|
timestamp = parse(
|
||||||
data_dict.get(JELLYFIN_POST_KEYS["ARTIST_NAME"]),
|
post_data.get(JELLYFIN_POST_KEYS.get("TIMESTAMP"), "")
|
||||||
mbid=data_dict.get(JELLYFIN_POST_KEYS["ARTIST_MB_ID"]),
|
).replace(tzinfo=pytz.utc)
|
||||||
|
|
||||||
|
playback_position_seconds = int(
|
||||||
|
post_data.get(JELLYFIN_POST_KEYS.get("PLAYBACK_POSITION_TICKS"), 1)
|
||||||
|
/ 10000000
|
||||||
)
|
)
|
||||||
album = get_or_create_album(
|
if media_type == Scrobble.MediaType.VIDEO:
|
||||||
data_dict.get(JELLYFIN_POST_KEYS["ALBUM_NAME"]),
|
media_obj = Video.find_or_create(post_data)
|
||||||
artist=artist,
|
else:
|
||||||
mbid=data_dict.get(JELLYFIN_POST_KEYS["ALBUM_MB_ID"]),
|
media_obj = get_or_create_track(
|
||||||
|
post_data, post_keys=JELLYFIN_POST_KEYS
|
||||||
|
)
|
||||||
|
# A hack because we don't worry about updating music ... we either finish it or we don't
|
||||||
|
playback_position_seconds = 0
|
||||||
|
|
||||||
|
if not media_obj:
|
||||||
|
logger.info(
|
||||||
|
"[jellyfin_scrobble_media] no video found from POST data",
|
||||||
|
extra={"post_data": post_data},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
playback_status = "resumed"
|
||||||
|
if post_data.get("IsPaused"):
|
||||||
|
playback_status = "paused"
|
||||||
|
elif post_data.get("NotificationType") == "PlaybackStop":
|
||||||
|
playback_status = "stopped"
|
||||||
|
|
||||||
|
return media_obj.scrobble_for_user(
|
||||||
|
user_id,
|
||||||
|
source=post_data.get(JELLYFIN_POST_KEYS.get("SOURCE")),
|
||||||
|
playback_position_seconds=playback_position_seconds,
|
||||||
|
status=playback_status,
|
||||||
)
|
)
|
||||||
|
|
||||||
run_time = convert_to_seconds(
|
|
||||||
data_dict.get(JELLYFIN_POST_KEYS["RUN_TIME"])
|
|
||||||
)
|
|
||||||
track = get_or_create_track(
|
|
||||||
title=data_dict.get("Name"),
|
|
||||||
artist=artist,
|
|
||||||
album=album,
|
|
||||||
run_time_seconds=run_time,
|
|
||||||
)
|
|
||||||
|
|
||||||
scrobble_dict = build_scrobble_dict(data_dict, user_id)
|
|
||||||
|
|
||||||
# A hack to make Jellyfin work more like Mopidy for music tracks
|
|
||||||
scrobble_dict["playback_position_seconds"] = 0
|
|
||||||
|
|
||||||
return Scrobble.create_or_update(track, user_id, scrobble_dict)
|
|
||||||
|
|
||||||
|
|
||||||
def jellyfin_scrobble_video(data_dict: dict, user_id: Optional[int]):
|
|
||||||
video = Video.find_or_create(data_dict)
|
|
||||||
|
|
||||||
scrobble_dict = build_scrobble_dict(data_dict, user_id)
|
|
||||||
|
|
||||||
return Scrobble.create_or_update(video, user_id, scrobble_dict)
|
|
||||||
|
|
||||||
|
|
||||||
def manual_scrobble_video(imdb_id: str, user_id: int):
|
def manual_scrobble_video(imdb_id: str, user_id: int):
|
||||||
video = Video.find_or_create({"imdb_id": imdb_id})
|
video = Video.find_or_create({"imdb_id": imdb_id})
|
||||||
@ -186,9 +142,18 @@ def manual_scrobble_video(imdb_id: str, user_id: int):
|
|||||||
"timestamp": timezone.now(),
|
"timestamp": timezone.now(),
|
||||||
"playback_position_seconds": 0,
|
"playback_position_seconds": 0,
|
||||||
"source": source,
|
"source": source,
|
||||||
"source_id": "Manually scrobbled from Vrobbler and looked up via IMDB",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"[scrobblers] manual video scrobble request received",
|
||||||
|
extra={
|
||||||
|
"video_id": video.id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"scrobble_dict": scrobble_dict,
|
||||||
|
"media_type": Scrobble.MediaType.VIDEO,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
return Scrobble.create_or_update(video, user_id, scrobble_dict)
|
return Scrobble.create_or_update(video, user_id, scrobble_dict)
|
||||||
|
|
||||||
|
|
||||||
@ -201,18 +166,40 @@ def manual_scrobble_event(thesportsdb_id: str, user_id: int):
|
|||||||
|
|
||||||
|
|
||||||
def manual_scrobble_video_game(hltb_id: str, user_id: int):
|
def manual_scrobble_video_game(hltb_id: str, user_id: int):
|
||||||
data_dict = lookup_game_from_hltb(hltb_id)
|
game = VideoGame.objects.filter(hltb_id=hltb_id).first()
|
||||||
game = VideoGame.find_or_create(data_dict)
|
if not game:
|
||||||
|
data_dict = lookup_game_from_hltb(hltb_id)
|
||||||
|
if not data_dict:
|
||||||
|
logger.info(
|
||||||
|
"[manual_scrobble_video_game] game not found on hltb",
|
||||||
|
extra={
|
||||||
|
"hltb_id": hltb_id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"media_type": Scrobble.MediaType.VIDEO_GAME,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
game = VideoGame.find_or_create(data_dict)
|
||||||
|
|
||||||
scrobble_dict = {
|
scrobble_dict = {
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"timestamp": timezone.now(),
|
"timestamp": timezone.now(),
|
||||||
"playback_position_seconds": 0,
|
"playback_position_seconds": 0,
|
||||||
"source": "Vrobbler",
|
"source": "Vrobbler",
|
||||||
"source_id": "Manually scrobbled from Vrobbler and looked up via HLTB.com",
|
|
||||||
"long_play_complete": False,
|
"long_play_complete": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"[scrobblers] manual video game scrobble request received",
|
||||||
|
extra={
|
||||||
|
"videogame_id": game.id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"scrobble_dict": scrobble_dict,
|
||||||
|
"media_type": Scrobble.MediaType.VIDEO_GAME,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
return Scrobble.create_or_update(game, user_id, scrobble_dict)
|
return Scrobble.create_or_update(game, user_id, scrobble_dict)
|
||||||
|
|
||||||
|
|
||||||
@ -227,6 +214,16 @@ def manual_scrobble_book(openlibrary_id: str, user_id: int):
|
|||||||
"long_play_complete": False,
|
"long_play_complete": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"[scrobblers] manual book scrobble request received",
|
||||||
|
extra={
|
||||||
|
"book_id": book.id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"scrobble_dict": scrobble_dict,
|
||||||
|
"media_type": Scrobble.MediaType.BOOK,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
return Scrobble.create_or_update(book, user_id, scrobble_dict)
|
return Scrobble.create_or_update(book, user_id, scrobble_dict)
|
||||||
|
|
||||||
|
|
||||||
@ -242,8 +239,16 @@ def manual_scrobble_board_game(bggeek_id: str, user_id: int):
|
|||||||
"timestamp": timezone.now(),
|
"timestamp": timezone.now(),
|
||||||
"playback_position_seconds": 0,
|
"playback_position_seconds": 0,
|
||||||
"source": "Vrobbler",
|
"source": "Vrobbler",
|
||||||
"source_id": "Manually scrobbled from Vrobbler and looked up via boardgamegeek.com",
|
|
||||||
}
|
}
|
||||||
|
logger.info(
|
||||||
|
"[webhook] board game scrobble request received",
|
||||||
|
extra={
|
||||||
|
"boardgame_id": boardgame.id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"scrobble_dict": scrobble_dict,
|
||||||
|
"media_type": Scrobble.MediaType.BOARD_GAME,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
return Scrobble.create_or_update(boardgame, user_id, scrobble_dict)
|
return Scrobble.create_or_update(boardgame, user_id, scrobble_dict)
|
||||||
|
|
||||||
@ -256,38 +261,113 @@ def manual_scrobble_webpage(url: str, user_id: int):
|
|||||||
"timestamp": timezone.now(),
|
"timestamp": timezone.now(),
|
||||||
"playback_position_seconds": 0,
|
"playback_position_seconds": 0,
|
||||||
"source": "Vrobbler",
|
"source": "Vrobbler",
|
||||||
"source_id": "Manually scrobbled from Vrobbler",
|
|
||||||
}
|
}
|
||||||
|
logger.info(
|
||||||
|
"[webhook] webpage scrobble request received",
|
||||||
|
extra={
|
||||||
|
"webpage_id": webpage.id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"scrobble_dict": scrobble_dict,
|
||||||
|
"media_type": Scrobble.MediaType.WEBPAGE,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
return Scrobble.create_or_update(webpage, user_id, scrobble_dict)
|
scrobble = Scrobble.create_or_update(webpage, user_id, scrobble_dict)
|
||||||
|
# possibly async this?
|
||||||
|
scrobble.push_to_archivebox()
|
||||||
|
return scrobble
|
||||||
|
|
||||||
|
|
||||||
def gpslogger_scrobble_location(
|
def gpslogger_scrobble_location(data_dict: dict, user_id: int) -> Scrobble:
|
||||||
data_dict: dict, user_id: Optional[int]
|
|
||||||
) -> Optional[Scrobble]:
|
|
||||||
# Save the data coming in
|
|
||||||
if not user_id:
|
|
||||||
user_id = 1 # TODO fix authing the end point to get user
|
|
||||||
|
|
||||||
location = GeoLocation.find_or_create(data_dict)
|
location = GeoLocation.find_or_create(data_dict)
|
||||||
|
|
||||||
# Now we run off a scrobble
|
timestamp = pendulum.parse(data_dict.get("time", timezone.now()))
|
||||||
playback_seconds = 1
|
|
||||||
extra_data = {
|
extra_data = {
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"timestamp": pendulum.parse(data_dict.get("time", timezone.now())),
|
"timestamp": timestamp,
|
||||||
"playback_position_seconds": playback_seconds,
|
|
||||||
"source": "GPSLogger",
|
"source": "GPSLogger",
|
||||||
|
"media_type": Scrobble.MediaType.GEO_LOCATION,
|
||||||
}
|
}
|
||||||
|
|
||||||
scrobble = Scrobble.create_or_update(location, user_id, extra_data)
|
scrobble = Scrobble.create_or_update_location(
|
||||||
|
location,
|
||||||
|
extra_data,
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
|
||||||
provider = f"data source: {LOCATION_PROVIDERS[data_dict.get('prov')]}"
|
provider = LOCATION_PROVIDERS[data_dict.get("prov")]
|
||||||
if scrobble:
|
|
||||||
if scrobble.notes:
|
if "gps_updates" not in scrobble.log.keys():
|
||||||
scrobble.notes = scrobble.notes + f"\n{provider}"
|
scrobble.log["gps_updates"] = []
|
||||||
else:
|
|
||||||
scrobble.notes = provider
|
scrobble.log["gps_updates"].append(
|
||||||
scrobble.save(update_fields=["notes"])
|
{
|
||||||
|
"timestamp": data_dict.get("time"),
|
||||||
|
"position_provider": provider,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if scrobble.timestamp:
|
||||||
|
scrobble.playback_position_seconds = (
|
||||||
|
timezone.now() - scrobble.timestamp
|
||||||
|
).seconds
|
||||||
|
|
||||||
|
scrobble.save(update_fields=["log", "playback_position_seconds"])
|
||||||
|
logger.info(
|
||||||
|
"[webhook] gpslogger scrobble request received",
|
||||||
|
extra={
|
||||||
|
"scrobble_id": scrobble.id,
|
||||||
|
"provider": provider,
|
||||||
|
"user_id": user_id,
|
||||||
|
"timestamp": extra_data.get("timestamp"),
|
||||||
|
"raw_timestamp": data_dict.get("time"),
|
||||||
|
"media_type": Scrobble.MediaType.GEO_LOCATION,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
return scrobble
|
return scrobble
|
||||||
|
|
||||||
|
|
||||||
|
def web_scrobbler_scrobble_video_or_song(
|
||||||
|
data_dict: dict, user_id: Optional[int]
|
||||||
|
) -> Scrobble:
|
||||||
|
# We're not going to create music tracks, because the only time
|
||||||
|
# we'd hit this is if we're listening to a concert or something.
|
||||||
|
artist_name = data_dict.get("artist")
|
||||||
|
track_name = data_dict.get("track")
|
||||||
|
tracks = Track.objects.filter(
|
||||||
|
artist__name=data_dict.get("artist"), title=data_dict.get("track")
|
||||||
|
)
|
||||||
|
if tracks.count() > 1:
|
||||||
|
logger.warning(
|
||||||
|
"Multiple tracks found for Web Scrobbler",
|
||||||
|
extra={"artist": artist_name, "track": track_name},
|
||||||
|
)
|
||||||
|
track = tracks.first()
|
||||||
|
|
||||||
|
# No track found, create a Video
|
||||||
|
if not track:
|
||||||
|
Video.find_or_create(data_dict)
|
||||||
|
|
||||||
|
# Now we run off a scrobble
|
||||||
|
mopidy_data = {
|
||||||
|
"user_id": user_id,
|
||||||
|
"timestamp": timezone.now(),
|
||||||
|
"playback_position_seconds": data_dict.get("playback_time_ticks"),
|
||||||
|
"source": "Mopidy",
|
||||||
|
"mopidy_status": data_dict.get("status"),
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"[scrobblers] webhook mopidy scrobble request received",
|
||||||
|
extra={
|
||||||
|
"episode_id": episode.id if episode else None,
|
||||||
|
"user_id": user_id,
|
||||||
|
"scrobble_dict": mopidy_data,
|
||||||
|
"media_type": Scrobble.MediaType.PODCAST_EPISODE,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
scrobble = None
|
||||||
|
if episode:
|
||||||
|
scrobble = Scrobble.create_or_update(episode, user_id, mopidy_data)
|
||||||
|
return scrobble
|
||||||
|
|||||||
BIN
vrobbler/apps/scrobbles/static/images/imdb_logo.png
Normal file
BIN
vrobbler/apps/scrobbles/static/images/imdb_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@ -35,12 +35,12 @@ def process_audioscrobbler_tsv_file(file_path, user_id, user_tz=None):
|
|||||||
source = "Audioscrobbler File"
|
source = "Audioscrobbler File"
|
||||||
rows = csv.reader(tsv_data, delimiter="\t")
|
rows = csv.reader(tsv_data, delimiter="\t")
|
||||||
|
|
||||||
source_id = ""
|
rockbox_info = ""
|
||||||
for row_num, row in enumerate(rows):
|
for row_num, row in enumerate(rows):
|
||||||
if row_num in [0, 1, 2]:
|
if row_num in [0, 1, 2]:
|
||||||
if "Rockbox" in row[0]:
|
if "Rockbox" in row[0]:
|
||||||
source = "Rockbox"
|
source = "Rockbox"
|
||||||
source_id += row[0] + "\n"
|
rockbox_info += row[0] + "\n"
|
||||||
continue
|
continue
|
||||||
if len(row) > 8:
|
if len(row) > 8:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@ -75,7 +75,7 @@ def process_audioscrobbler_tsv_file(file_path, user_id, user_tz=None):
|
|||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
timestamp=timestamp,
|
timestamp=timestamp,
|
||||||
source=source,
|
source=source,
|
||||||
source_id=source_id,
|
log={"rockbox_info": rockbox_info}
|
||||||
track=track,
|
track=track,
|
||||||
played_to_completion=True,
|
played_to_completion=True,
|
||||||
in_progress=False,
|
in_progress=False,
|
||||||
|
|||||||
@ -4,31 +4,27 @@ from scrobbles import views
|
|||||||
app_name = "scrobbles"
|
app_name = "scrobbles"
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
path("status/", views.ScrobbleStatusView.as_view(), name="status"),
|
||||||
path(
|
path(
|
||||||
"manual/lookup/",
|
"manual/lookup/",
|
||||||
views.ManualScrobbleView.as_view(),
|
views.ManualScrobbleView.as_view(),
|
||||||
name="lookup-manual-scrobble",
|
name="lookup-manual-scrobble",
|
||||||
),
|
),
|
||||||
path(
|
|
||||||
"manual/audioscrobbler/",
|
|
||||||
views.AudioScrobblerImportCreateView.as_view(),
|
|
||||||
name="audioscrobbler-file-upload",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"manual/koreader/",
|
|
||||||
views.KoReaderImportCreateView.as_view(),
|
|
||||||
name="koreader-file-upload",
|
|
||||||
),
|
|
||||||
path(
|
path(
|
||||||
"long-play-finish/<slug:uuid>/",
|
"long-play-finish/<slug:uuid>/",
|
||||||
views.scrobble_longplay_finish,
|
views.scrobble_longplay_finish,
|
||||||
name="longplay-finish",
|
name="longplay-finish",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"upload/",
|
"upload/audioscrobbler/",
|
||||||
views.AudioScrobblerImportCreateView.as_view(),
|
views.AudioScrobblerImportCreateView.as_view(),
|
||||||
name="audioscrobbler-file-upload",
|
name="audioscrobbler-file-upload",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"upload/koreader/",
|
||||||
|
views.KoReaderImportCreateView.as_view(),
|
||||||
|
name="koreader-file-upload",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"lastfm-import/",
|
"lastfm-import/",
|
||||||
views.lastfm_import,
|
views.lastfm_import,
|
||||||
|
|||||||
@ -1,10 +1,8 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import re
|
||||||
from datetime import datetime, timedelta, tzinfo
|
from datetime import datetime, timedelta, tzinfo
|
||||||
from urllib.parse import unquote
|
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
from dateutil.parser import ParserError, parse
|
|
||||||
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 django.db import models
|
from django.db import models
|
||||||
@ -18,8 +16,6 @@ logger = logging.getLogger(__name__)
|
|||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
PODCAST_DATE_FORMAT = "YYYY-MM-DD"
|
|
||||||
|
|
||||||
def timestamp_user_tz_to_utc(timestamp: int, user_tz: tzinfo) -> datetime:
|
def timestamp_user_tz_to_utc(timestamp: int, user_tz: tzinfo) -> datetime:
|
||||||
return user_tz.localize(datetime.utcfromtimestamp(timestamp)).astimezone(
|
return user_tz.localize(datetime.utcfromtimestamp(timestamp)).astimezone(
|
||||||
pytz.utc
|
pytz.utc
|
||||||
@ -42,94 +38,6 @@ def convert_to_seconds(run_time: str) -> int:
|
|||||||
run_time_int = int((((hours * 60) + minutes) * 60) + seconds)
|
run_time_int = int((((hours * 60) + minutes) * 60) + seconds)
|
||||||
return run_time_int
|
return run_time_int
|
||||||
|
|
||||||
def parse_mopidy_uri(uri: str) -> dict:
|
|
||||||
logger.debug(f"Parsing URI: {uri}")
|
|
||||||
parsed_uri = os.path.splitext(unquote(uri))[0].split("/")
|
|
||||||
|
|
||||||
episode_str = parsed_uri[-1]
|
|
||||||
podcast_name = parsed_uri[-2].strip()
|
|
||||||
episode_num = None
|
|
||||||
episode_num_pad = 0
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Without episode numbers the date will lead
|
|
||||||
pub_date = parse(episode_str[0:10])
|
|
||||||
except ParserError:
|
|
||||||
episode_num = int(episode_str.split("-")[0])
|
|
||||||
episode_num_pad = len(str(episode_num)) + 1
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Beacuse we have epsiode numbers on
|
|
||||||
pub_date = episode_str[episode_num_pad:14]
|
|
||||||
except ParserError:
|
|
||||||
pub_date = ""
|
|
||||||
|
|
||||||
gap_to_strip = 0
|
|
||||||
if pub_date:
|
|
||||||
gap_to_strip += len(PODCAST_DATE_FORMAT)
|
|
||||||
if episode_num:
|
|
||||||
gap_to_strip += episode_num_pad
|
|
||||||
|
|
||||||
episode_name = episode_str[gap_to_strip:].replace("-", " ").strip()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"episode_filename": episode_name,
|
|
||||||
"episode_num": episode_num,
|
|
||||||
"podcast_name": podcast_name,
|
|
||||||
"pub_date": pub_date,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def check_scrobble_for_finish(
|
|
||||||
scrobble: "Scrobble", force_to_100=False, force_finish=False
|
|
||||||
) -> None:
|
|
||||||
completion_percent = scrobble.media_obj.COMPLETION_PERCENT
|
|
||||||
if scrobble.percent_played >= completion_percent or force_finish:
|
|
||||||
logger.info(f"{scrobble.id} finished at {scrobble.percent_played}")
|
|
||||||
scrobble.playback_position_seconds = (
|
|
||||||
scrobble.media_obj.run_time_seconds
|
|
||||||
)
|
|
||||||
|
|
||||||
scrobble.in_progress = False
|
|
||||||
scrobble.is_paused = False
|
|
||||||
scrobble.played_to_completion = True
|
|
||||||
|
|
||||||
scrobble.save(
|
|
||||||
update_fields=[
|
|
||||||
"in_progress",
|
|
||||||
"is_paused",
|
|
||||||
"played_to_completion",
|
|
||||||
"playback_position_seconds",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.info(
|
|
||||||
f"{scrobble.id} not complete at {scrobble.percent_played}%"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def check_long_play_for_finish(scrobble):
|
|
||||||
seconds_elapsed = (timezone.now() - scrobble.timestamp).seconds
|
|
||||||
past_seconds = 0
|
|
||||||
|
|
||||||
# Set our playback seconds, and calc long play seconds
|
|
||||||
scrobble.playback_position_seconds = seconds_elapsed
|
|
||||||
if scrobble.previous:
|
|
||||||
past_seconds = scrobble.previous.long_play_seconds
|
|
||||||
|
|
||||||
scrobble.long_play_seconds = past_seconds + seconds_elapsed
|
|
||||||
|
|
||||||
# Long play scrobbles are always finished when we say they are
|
|
||||||
scrobble.played_to_completion = True
|
|
||||||
|
|
||||||
scrobble.save(
|
|
||||||
update_fields=[
|
|
||||||
"playback_position_seconds",
|
|
||||||
"played_to_completion",
|
|
||||||
"long_play_seconds",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_scrobbles_for_media(media_obj, user: User) -> models.QuerySet:
|
def get_scrobbles_for_media(media_obj, user: User) -> models.QuerySet:
|
||||||
Scrobble = apps.get_model(app_label="scrobbles", model_name="Scrobble")
|
Scrobble = apps.get_model(app_label="scrobbles", model_name="Scrobble")
|
||||||
@ -264,3 +172,7 @@ def delete_zombie_scrobbles(dry_run=True):
|
|||||||
f"Found {zombies_found} zombie scrobbles to delete, use dry_run=False to proceed"
|
f"Found {zombies_found} zombie scrobbles to delete, use dry_run=False to proceed"
|
||||||
)
|
)
|
||||||
return zombies_found
|
return zombies_found
|
||||||
|
|
||||||
|
|
||||||
|
def media_class_to_foreign_key(media_class: str) -> str:
|
||||||
|
return re.sub(r"(?<!^)(?=[A-Z])", "_", media_class).lower()
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user