Merge branch 'main' into develop
This commit is contained in:
16
vrobbler/apps/books/migrations/0028_delete_page.py
Normal file
16
vrobbler/apps/books/migrations/0028_delete_page.py
Normal file
@ -0,0 +1,16 @@
|
||||
# Generated by Django 4.2.19 on 2025-04-07 17:16
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("books", "0027_remove_paper_num_citations_paper_tldr"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name="Page",
|
||||
),
|
||||
]
|
||||
@ -5,6 +5,7 @@ import pytest
|
||||
from books.openlibrary import lookup_book_from_openlibrary
|
||||
|
||||
|
||||
@pytest.mark.skip()
|
||||
def test_lookup_modern_book():
|
||||
book = lookup_book_from_openlibrary("Matrix", "Lauren Groff")
|
||||
assert book.get("title") == "Matrix"
|
||||
@ -12,6 +13,7 @@ def test_lookup_modern_book():
|
||||
assert book.get("ol_author_id") == "OL3675729A"
|
||||
|
||||
|
||||
@pytest.mark.skip()
|
||||
def test_lookup_classic_book():
|
||||
book = lookup_book_from_openlibrary(
|
||||
"The Life of Castruccio Castracani", "Machiavelli"
|
||||
@ -21,6 +23,7 @@ def test_lookup_classic_book():
|
||||
assert book.get("ol_author_id") == "OL23135A"
|
||||
|
||||
|
||||
@pytest.mark.skip()
|
||||
def test_lookup_foreign_book():
|
||||
book = lookup_book_from_openlibrary("Ravagé", "René Barjavel")
|
||||
assert book.get("title") == "Ravage"
|
||||
|
||||
@ -1,14 +1,10 @@
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timedelta, UTC
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pylast
|
||||
import pytz
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from music.utils import (
|
||||
get_or_create_track,
|
||||
)
|
||||
from music.models import Track
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -47,14 +43,10 @@ class LastFM:
|
||||
lastfm_scrobbles = self.get_last_scrobbles(time_from=last_processed)
|
||||
|
||||
for lfm_scrobble in lastfm_scrobbles:
|
||||
track = get_or_create_track(
|
||||
lfm_scrobble,
|
||||
{
|
||||
"TRACK_TITLE": "title",
|
||||
"ARTIST_NAME": "artist",
|
||||
"ALBUM_NAME": "album",
|
||||
"RUN_TIME": "run_time_seconds",
|
||||
},
|
||||
track = Track.find_or_create(
|
||||
title=lfm_scrobble.get("title"),
|
||||
artist_name=lfm_scrobble.get("artist"),
|
||||
album_name=lfm_scrobble.get("album"),
|
||||
)
|
||||
|
||||
timezone = settings.TIME_ZONE
|
||||
@ -149,7 +141,7 @@ class LastFM:
|
||||
continue
|
||||
|
||||
# TODO figure out if this will actually work
|
||||
#timestamp = datetime.fromtimestamp(int(scrobble.timestamp), UTC)
|
||||
# timestamp = datetime.fromtimestamp(int(scrobble.timestamp), UTC)
|
||||
timestamp = datetime.utcfromtimestamp(
|
||||
int(scrobble.timestamp)
|
||||
).replace(tzinfo=pytz.utc)
|
||||
|
||||
18
vrobbler/apps/music/migrations/0025_artist_alt_names.py
Normal file
18
vrobbler/apps/music/migrations/0025_artist_alt_names.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.19 on 2025-04-07 00:23
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("music", "0024_alter_track_run_time_seconds"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="artist",
|
||||
name="alt_names",
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
18
vrobbler/apps/music/migrations/0026_album_alt_names.py
Normal file
18
vrobbler/apps/music/migrations/0026_album_alt_names.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.19 on 2025-04-07 00:34
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("music", "0025_artist_alt_names"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="album",
|
||||
name="alt_names",
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -1,13 +1,11 @@
|
||||
import logging
|
||||
from tempfile import NamedTemporaryFile
|
||||
from typing import Dict, Optional
|
||||
from urllib.request import urlopen
|
||||
from uuid import uuid4
|
||||
|
||||
import musicbrainzngs
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.core.files.base import ContentFile, File
|
||||
from django.core.files.base import ContentFile
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@ -16,6 +14,7 @@ from imagekit.models import ImageSpecField
|
||||
from imagekit.processors import ResizeToFit
|
||||
from music.allmusic import get_allmusic_slug, scrape_data_from_allmusic
|
||||
from music.bandcamp import get_bandcamp_slug
|
||||
from music.musicbrainz import lookup_album_dict_from_mb, lookup_track_from_mb
|
||||
from music.theaudiodb import lookup_album_from_tadb, lookup_artist_from_tadb
|
||||
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
||||
|
||||
@ -24,6 +23,16 @@ BNULL = {"blank": True, "null": True}
|
||||
|
||||
|
||||
class Artist(TimeStampedModel):
|
||||
"""Represents a music artist.
|
||||
|
||||
# Lookup or create by title alone
|
||||
>>> Artist.find_or_create(name="Bon Iver")
|
||||
|
||||
# Lookup or create by MB id alone
|
||||
>>> Artist.find_or_create(musicbrainz_id="0307edfc-437c-4b48-8700-80680e66a228")
|
||||
|
||||
"""
|
||||
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
name = models.CharField(max_length=255)
|
||||
biography = models.TextField(**BNULL)
|
||||
@ -46,6 +55,7 @@ class Artist(TimeStampedModel):
|
||||
format="JPEG",
|
||||
options={"quality": 75},
|
||||
)
|
||||
alt_names = models.TextField(**BNULL)
|
||||
|
||||
class Meta:
|
||||
unique_together = [["name", "musicbrainz_id"]]
|
||||
@ -62,8 +72,10 @@ class Artist(TimeStampedModel):
|
||||
return ""
|
||||
|
||||
@property
|
||||
def mb_link(self):
|
||||
return f"https://musicbrainz.org/artist/{self.musicbrainz_id}"
|
||||
def mb_link(self) -> str:
|
||||
if self.musicbrainz_id:
|
||||
return f"https://musicbrainz.org/artist/{self.musicbrainz_id}"
|
||||
return ""
|
||||
|
||||
@property
|
||||
def allmusic_link(self):
|
||||
@ -104,7 +116,9 @@ class Artist(TimeStampedModel):
|
||||
if not self.allmusic_id or force:
|
||||
slug = get_allmusic_slug(self.name)
|
||||
if not slug:
|
||||
logger.info(f"No allmsuic link for {self}")
|
||||
logger.info(
|
||||
"No allmusic link found", extra={"track_id": self.id}
|
||||
)
|
||||
return
|
||||
self.allmusic_id = slug
|
||||
self.save(update_fields=["allmusic_id"])
|
||||
@ -113,7 +127,9 @@ class Artist(TimeStampedModel):
|
||||
if not self.bandcamp_id or force:
|
||||
slug = get_bandcamp_slug(self.name)
|
||||
if not slug:
|
||||
logger.info(f"No bandcamp link for {self}")
|
||||
logger.info(
|
||||
"No bandcamp link found", extra={"track_id": self.id}
|
||||
)
|
||||
return
|
||||
self.bandcamp_id = slug
|
||||
self.save(update_fields=["bandcamp_id"])
|
||||
@ -153,6 +169,61 @@ class Artist(TimeStampedModel):
|
||||
artist = self.name.lower()
|
||||
return f"https://bandcamp.com/search?q={artist}&item_type=b"
|
||||
|
||||
@classmethod
|
||||
def find_or_create(cls, name: str, musicbrainz_id: str = "") -> "Artist":
|
||||
from music.musicbrainz import lookup_artist_from_mb
|
||||
from music.utils import clean_artist_name
|
||||
|
||||
if not name:
|
||||
raise Exception("Must have name to lookup artist")
|
||||
|
||||
artist = None
|
||||
name = clean_artist_name(name)
|
||||
|
||||
# Check for name/mbid combo, just mbid and then just name
|
||||
if musicbrainz_id:
|
||||
artist = cls.objects.filter(
|
||||
name=name, musicbrainz_id=musicbrainz_id
|
||||
).first()
|
||||
if not artist:
|
||||
artist = cls.objects.filter(musicbrainz_id=musicbrainz_id).first()
|
||||
if not artist:
|
||||
artist = cls.objects.filter(
|
||||
models.Q(name=name) | models.Q(alt_names__icontains=name)
|
||||
).first()
|
||||
|
||||
# Does not exist, look it up from Musicbrainz
|
||||
if not artist:
|
||||
alt_name = None
|
||||
try:
|
||||
artist_dict = lookup_artist_from_mb(name)
|
||||
musicbrainz_id = musicbrainz_id or artist_dict.get("id", "")
|
||||
if name != artist_dict.get("name", ""):
|
||||
alt_name = name
|
||||
name = artist_dict.get("name", "")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if musicbrainz_id:
|
||||
artist = cls.objects.filter(
|
||||
musicbrainz_id=musicbrainz_id
|
||||
).first()
|
||||
if artist and alt_name:
|
||||
if not artist.alt_names:
|
||||
artist.alt_names = alt_name
|
||||
else:
|
||||
artist.alt_names += f"\\{alt_name}"
|
||||
artist.save(update_fields=["alt_names"])
|
||||
|
||||
if not artist:
|
||||
artist = cls.objects.create(
|
||||
name=name, musicbrainz_id=musicbrainz_id, alt_names=alt_name
|
||||
)
|
||||
# TODO maybe this should be spun off into an async task?
|
||||
artist.fix_metadata()
|
||||
|
||||
return artist
|
||||
|
||||
|
||||
class Album(TimeStampedModel):
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
@ -196,9 +267,10 @@ class Album(TimeStampedModel):
|
||||
wikipedia_slug = models.CharField(max_length=255, **BNULL)
|
||||
discogs_id = models.CharField(max_length=255, **BNULL)
|
||||
wikidata_id = models.CharField(max_length=255, **BNULL)
|
||||
alt_names = models.TextField(**BNULL)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
def __str__(self) -> str:
|
||||
return "{} by {}".format(self.name, self.album_artist)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("music:album_detail", kwargs={"slug": self.uuid})
|
||||
@ -402,6 +474,69 @@ class Album(TimeStampedModel):
|
||||
album = self.name.lower()
|
||||
return f"https://bandcamp.com/search?q={album} {artist}&item_type=a"
|
||||
|
||||
@classmethod
|
||||
def find_or_create(
|
||||
cls, name: str, artist_name: str, musicbrainz_id: str = ""
|
||||
) -> "Album":
|
||||
if not name or not artist_name:
|
||||
raise Exception(
|
||||
"Must have at least name and artist name to lookup album"
|
||||
)
|
||||
|
||||
album = None
|
||||
if musicbrainz_id:
|
||||
album = cls.objects.filter(
|
||||
musicbrainz_id=musicbrainz_id,
|
||||
name=name,
|
||||
album_artist__name=artist_name,
|
||||
).first()
|
||||
if not album and musicbrainz_id:
|
||||
album = cls.objects.filter(
|
||||
musicbrainz_id=musicbrainz_id,
|
||||
).first()
|
||||
if not album:
|
||||
album = cls.objects.filter(
|
||||
models.Q(name=name) | models.Q(alt_names__icontains=name),
|
||||
album_artist__name=artist_name,
|
||||
).first()
|
||||
|
||||
if not album:
|
||||
alt_name = None
|
||||
try:
|
||||
album_dict = lookup_album_dict_from_mb(
|
||||
name, artist_name=artist_name
|
||||
)
|
||||
musicbrainz_id = musicbrainz_id or album_dict.get("mb_id", "")
|
||||
found_name = album_dict.get("title", "")
|
||||
if found_name and name != found_name:
|
||||
alt_name = name
|
||||
name = found_name
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if musicbrainz_id:
|
||||
album = cls.objects.filter(
|
||||
musicbrainz_id=musicbrainz_id
|
||||
).first()
|
||||
if album and alt_name:
|
||||
if not album.alt_names:
|
||||
album.alt_names = alt_name
|
||||
else:
|
||||
album.alt_names += f"\\{alt_name}"
|
||||
album.save(update_fields=["alt_names"])
|
||||
if not album:
|
||||
artist = Artist.find_or_create(name=artist_name)
|
||||
album = cls.objects.create(
|
||||
name=name,
|
||||
album_artist=artist,
|
||||
musicbrainz_id=musicbrainz_id,
|
||||
alt_names=alt_name,
|
||||
)
|
||||
# TODO maybe do this in a separate process?
|
||||
album.fix_metadata()
|
||||
|
||||
return album
|
||||
|
||||
|
||||
class Track(ScrobblableMixin):
|
||||
COMPLETION_PERCENT = getattr(settings, "MUSIC_COMPLETION_PERCENT", 100)
|
||||
@ -425,8 +560,8 @@ class Track(ScrobblableMixin):
|
||||
return reverse("music:track_detail", kwargs={"slug": self.uuid})
|
||||
|
||||
@property
|
||||
def subtitle(self):
|
||||
return self.artist
|
||||
def subtitle(self) -> str:
|
||||
return str(self.artist)
|
||||
|
||||
@property
|
||||
def strings(self) -> ScrobblableConstants:
|
||||
@ -451,31 +586,85 @@ class Track(ScrobblableMixin):
|
||||
|
||||
@classmethod
|
||||
def find_or_create(
|
||||
cls, artist_dict: Dict, album_dict: Dict, track_dict: Dict
|
||||
) -> Optional["Track"]:
|
||||
"""Given a data dict from Jellyfin, does the heavy lifting of looking up
|
||||
the video and, if need, TV Series, creating both if they don't yet
|
||||
exist.
|
||||
cls,
|
||||
title: str = "",
|
||||
musicbrainz_id: str = "",
|
||||
album_name: str = "",
|
||||
artist_name: str = "",
|
||||
enrich: bool = True,
|
||||
run_time_seconds: Optional[int] = None,
|
||||
) -> "Track":
|
||||
# TODO we can use Q to build queries here based on whether we have mbid and album name
|
||||
track = None
|
||||
# Full look up with MB ID
|
||||
if album_name:
|
||||
track = cls.objects.filter(
|
||||
musicbrainz_id=musicbrainz_id,
|
||||
title=title,
|
||||
artist__name=artist_name,
|
||||
album__name=album_name,
|
||||
).first()
|
||||
# Full look up without album
|
||||
if not track:
|
||||
track = cls.objects.filter(
|
||||
musicbrainz_id=musicbrainz_id,
|
||||
title=title,
|
||||
artist__name=artist_name,
|
||||
).first()
|
||||
|
||||
"""
|
||||
if not artist_dict.get("name") or not artist_dict.get(
|
||||
"musicbrainz_id"
|
||||
):
|
||||
logger.warning(
|
||||
f"No artist or artist musicbrainz ID found in message from source, not scrobbling"
|
||||
)
|
||||
return
|
||||
# Full look up without MB ID
|
||||
if not track:
|
||||
track = cls.objects.filter(
|
||||
title=title,
|
||||
artist__name=artist_name,
|
||||
album__name=album_name,
|
||||
).first()
|
||||
# Base look up without MB ID or album
|
||||
if not track:
|
||||
track = cls.objects.filter(
|
||||
title=title,
|
||||
artist__name=artist_name,
|
||||
).first()
|
||||
|
||||
artist, artist_created = Artist.objects.get_or_create(**artist_dict)
|
||||
album, album_created = Album.objects.get_or_create(**album_dict)
|
||||
if not track and enrich:
|
||||
track_dict = lookup_track_from_mb(title, artist_name, album_name)
|
||||
musicbrainz_id = musicbrainz_id or track_dict.get("id", "")
|
||||
# TODO This only works some of the time
|
||||
# try:
|
||||
# album_name = album_name or track_dict.get("release-list")[
|
||||
# 0
|
||||
# ].get("title", "")
|
||||
# except IndexError:
|
||||
# pass
|
||||
if not run_time_seconds:
|
||||
run_time_seconds = int(
|
||||
int(track_dict.get("length", 900000)) / 1000
|
||||
)
|
||||
if title != track_dict.get("name", "") and track_dict.get(
|
||||
"name", False
|
||||
):
|
||||
|
||||
album.fix_metadata()
|
||||
if not album.cover_image:
|
||||
album.fetch_artwork()
|
||||
title = track_dict.get("name", "")
|
||||
|
||||
track_dict["album_id"] = getattr(album, "id", None)
|
||||
track_dict["artist_id"] = artist.id
|
||||
|
||||
track, created = cls.objects.get_or_create(**track_dict)
|
||||
if musicbrainz_id:
|
||||
track = cls.objects.filter(
|
||||
musicbrainz_id=musicbrainz_id
|
||||
).first()
|
||||
if not track:
|
||||
artist = Artist.find_or_create(name=artist_name)
|
||||
album = None
|
||||
if album_name:
|
||||
album = Album.find_or_create(
|
||||
name=album_name, artist_name=artist_name
|
||||
)
|
||||
track = cls.objects.create(
|
||||
title=title,
|
||||
album=album,
|
||||
musicbrainz_id=musicbrainz_id,
|
||||
artist=artist,
|
||||
run_time_seconds=run_time_seconds,
|
||||
)
|
||||
# TODO maybe do this in a separate process?
|
||||
track.fix_metadata()
|
||||
|
||||
return track
|
||||
|
||||
@ -16,9 +16,8 @@ logger = logging.getLogger(__name__)
|
||||
from music.models import Album, Artist, Track
|
||||
|
||||
|
||||
def get_or_create_artist(name: str, mbid: str = None) -> Artist:
|
||||
artist = None
|
||||
|
||||
def clean_artist_name(name: str) -> str:
|
||||
"""Remove featured names from artist string."""
|
||||
if "feat." in name.lower():
|
||||
name = re.split("feat.", name, flags=re.IGNORECASE)[0].strip()
|
||||
if "featuring" in name.lower():
|
||||
@ -26,18 +25,44 @@ def get_or_create_artist(name: str, mbid: str = None) -> Artist:
|
||||
if "&" in name.lower():
|
||||
name = re.split("&", name, flags=re.IGNORECASE)[0].strip()
|
||||
|
||||
artist_dict = lookup_artist_from_mb(name)
|
||||
mbid = mbid or artist_dict.get("id", None)
|
||||
return name
|
||||
|
||||
if mbid:
|
||||
|
||||
# TODO These are depreacted, remove them eventually
|
||||
def get_or_create_artist(name: str, mbid: str = "") -> Artist:
|
||||
"""Get an Artist object from the database.
|
||||
|
||||
Check if an artist with this name or Musicbrainz ID already exists.
|
||||
Otherwise, go lookup artist data from Musicbrainz and create one.
|
||||
|
||||
"""
|
||||
artist = None
|
||||
name = clean_artist_name(name)
|
||||
|
||||
# Check for name/mbid combo, just mbid and then just name
|
||||
artist = Artist.objects.filter(name=name, mbid=mbid).first()
|
||||
if not artist:
|
||||
artist = Artist.objects.filter(musicbrainz_id=mbid).first()
|
||||
if not artist:
|
||||
artist = Artist.objects.filter(name=name).first()
|
||||
|
||||
# Does not exist, look it up from Musicbrainz
|
||||
if not artist:
|
||||
artist_dict = lookup_artist_from_mb(name)
|
||||
mbid = mbid or artist_dict.get("id", "")
|
||||
|
||||
if mbid:
|
||||
artist = Artist.objects.filter(musicbrainz_id=mbid).first()
|
||||
|
||||
if not artist:
|
||||
artist = Artist.objects.create(name=name, musicbrainz_id=mbid)
|
||||
# TODO maybe this should be spun off into an async task?
|
||||
artist.fix_metadata()
|
||||
|
||||
return artist
|
||||
|
||||
|
||||
# TODO These are depreacted, remove them eventually
|
||||
def get_or_create_album(
|
||||
name: str, artist: Artist, mbid: str = None
|
||||
) -> Optional[Album]:
|
||||
@ -90,6 +115,7 @@ def get_or_create_album(
|
||||
return album
|
||||
|
||||
|
||||
# TODO These are depreacted, remove them eventually
|
||||
def get_or_create_track(post_data: dict, post_keys: dict) -> Track:
|
||||
try:
|
||||
track_run_time_seconds = int(
|
||||
@ -107,16 +133,12 @@ def get_or_create_track(post_data: dict, post_keys: dict) -> Track:
|
||||
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,
|
||||
)
|
||||
artist = Artist.find_or_create(artist_name, artist_mb_id)
|
||||
album = None
|
||||
if album_mb_id:
|
||||
album = get_or_create_album(
|
||||
album_title,
|
||||
artist=artist,
|
||||
mbid=album_mb_id,
|
||||
# We may get no album ID or title, in which case, skip
|
||||
if album_mb_id or album_title:
|
||||
album = Album.find_or_create(
|
||||
album_title, str(artist.name), album_mb_id
|
||||
)
|
||||
|
||||
track = None
|
||||
@ -154,7 +176,7 @@ def get_or_create_track(post_data: dict, post_keys: dict) -> Track:
|
||||
return track
|
||||
|
||||
|
||||
def get_or_create_various_artists():
|
||||
def get_or_create_various_artists() -> Artist:
|
||||
artist = Artist.objects.filter(name="Various Artists").first()
|
||||
if not artist:
|
||||
artist = Artist.objects.create(**VARIOUS_ARTIST_DICT)
|
||||
|
||||
@ -0,0 +1,40 @@
|
||||
# Generated by Django 4.2.19 on 2025-04-07 17:16
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("podcasts", "0014_alter_podcastepisode_run_time_seconds"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="podcast",
|
||||
name="google_podcasts_url",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="podcast",
|
||||
name="dead_date",
|
||||
field=models.DateField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="podcast",
|
||||
name="itunes_id",
|
||||
field=models.TextField(blank=True, max_length=15, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="podcast",
|
||||
name="null",
|
||||
field=models.CharField(
|
||||
default="", max_length=150, verbose_name="blank"
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="podcast",
|
||||
name="site_link",
|
||||
field=models.URLField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
28
vrobbler/apps/podcasts/migrations/0016_podcast_genre.py
Normal file
28
vrobbler/apps/podcasts/migrations/0016_podcast_genre.py
Normal file
@ -0,0 +1,28 @@
|
||||
# Generated by Django 4.2.19 on 2025-04-07 17:18
|
||||
|
||||
from django.db import migrations
|
||||
import taggit.managers
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("scrobbles", "0068_scrobble_paper_alter_scrobble_media_type"),
|
||||
(
|
||||
"podcasts",
|
||||
"0015_remove_podcast_google_podcasts_url_podcast_dead_date_and_more",
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="podcast",
|
||||
name="genre",
|
||||
field=taggit.managers.TaggableManager(
|
||||
help_text="A comma-separated list of tags.",
|
||||
through="scrobbles.ObjectWithGenres",
|
||||
to="scrobbles.Genre",
|
||||
verbose_name="Tags",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.19 on 2025-04-07 17:29
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("podcasts", "0016_podcast_genre"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="podcast",
|
||||
name="podcastindex_id",
|
||||
field=models.CharField(blank=True, max_length=100, null=True),
|
||||
),
|
||||
]
|
||||
@ -1,5 +1,4 @@
|
||||
import logging
|
||||
from typing import Dict, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
import requests
|
||||
@ -10,8 +9,13 @@ from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
from podcasts.scrapers import scrape_data_from_google_podcasts
|
||||
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
||||
from podcasts.sources.podcastindex import lookup_podcast_from_podcastindex
|
||||
from scrobbles.mixins import (
|
||||
ObjectWithGenres,
|
||||
ScrobblableConstants,
|
||||
ScrobblableMixin,
|
||||
)
|
||||
from taggit.managers import TaggableManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
BNULL = {"blank": True, "null": True}
|
||||
@ -24,6 +28,13 @@ class Producer(TimeStampedModel):
|
||||
def __str__(self):
|
||||
return f"{self.name}"
|
||||
|
||||
@classmethod
|
||||
def find_or_create(cls, name):
|
||||
producer = cls.objects.filter(name__iexact=name).first()
|
||||
if not producer:
|
||||
producer = cls.objects.create(name=name)
|
||||
return producer
|
||||
|
||||
|
||||
class Podcast(TimeStampedModel):
|
||||
name = models.CharField(max_length=255)
|
||||
@ -31,11 +42,17 @@ class Podcast(TimeStampedModel):
|
||||
producer = models.ForeignKey(
|
||||
Producer, on_delete=models.DO_NOTHING, **BNULL
|
||||
)
|
||||
podcastindex_id = models.CharField(max_length=100, **BNULL)
|
||||
owner = models.CharField(max_length=150, *BNULL)
|
||||
description = models.TextField(**BNULL)
|
||||
active = models.BooleanField(default=True)
|
||||
feed_url = models.URLField(**BNULL)
|
||||
google_podcasts_url = models.URLField(**BNULL)
|
||||
site_link = models.URLField(**BNULL)
|
||||
description = models.TextField(**BNULL)
|
||||
cover_image = models.ImageField(upload_to="podcasts/covers/", **BNULL)
|
||||
itunes_id = models.TextField(max_length=15, **BNULL)
|
||||
dead_date = models.DateField(**BNULL)
|
||||
genre = TaggableManager(through=ObjectWithGenres)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name}"
|
||||
@ -49,32 +66,43 @@ class Podcast(TimeStampedModel):
|
||||
user=user, podcast_episode__podcast=self
|
||||
).order_by("-timestamp")
|
||||
|
||||
def scrape_google_podcasts(self, force=False):
|
||||
podcast_dict = {}
|
||||
if not self.cover_image or force:
|
||||
podcast_dict = scrape_data_from_google_podcasts(self.name)
|
||||
if podcast_dict:
|
||||
if not self.producer:
|
||||
self.producer, created = Producer.objects.get_or_create(
|
||||
name=podcast_dict["producer"]
|
||||
)
|
||||
self.description = podcast_dict.get("description")
|
||||
self.google_podcasts_url = podcast_dict.get("google_url")
|
||||
self.save(
|
||||
update_fields=[
|
||||
"description",
|
||||
"producer",
|
||||
"google_podcasts_url",
|
||||
]
|
||||
)
|
||||
@property
|
||||
def itunes_link(self) -> str:
|
||||
if not self.itunes_id:
|
||||
return ""
|
||||
return f"https://podcasts.apple.com/us/podcast/id{self.itunes_id}"
|
||||
|
||||
def fix_metadata(self, force=False):
|
||||
if self.podcastindex_id and not force:
|
||||
logger.warning(
|
||||
"Podcast already has PodcastIndex ID, use force=True to overwrite"
|
||||
)
|
||||
return
|
||||
|
||||
podcast_dict = lookup_podcast_from_podcastindex(self.name)
|
||||
|
||||
if not podcast_dict:
|
||||
logger.info(
|
||||
"No podcast data found from PodcastIndex. Are credentials setup?"
|
||||
)
|
||||
return
|
||||
|
||||
genres = podcast_dict.pop("genres")
|
||||
if genres:
|
||||
self.genre.add(*genres)
|
||||
|
||||
cover_url = podcast_dict.pop("image_url")
|
||||
|
||||
cover_url = podcast_dict.get("image_url")
|
||||
if (not self.cover_image or force) and cover_url:
|
||||
r = requests.get(cover_url)
|
||||
if r.status_code == 200:
|
||||
fname = f"{self.name}_{self.uuid}.jpg"
|
||||
self.cover_image.save(fname, ContentFile(r.content), save=True)
|
||||
|
||||
for attr, value in podcast_dict.items():
|
||||
setattr(self, attr, value)
|
||||
self.save()
|
||||
|
||||
|
||||
class PodcastEpisode(ScrobblableMixin):
|
||||
COMPLETION_PERCENT = getattr(settings, "PODCAST_COMPLETION_PERCENT", 90)
|
||||
@ -84,6 +112,11 @@ class PodcastEpisode(ScrobblableMixin):
|
||||
pub_date = models.DateField(**BNULL)
|
||||
mopidy_uri = models.CharField(max_length=255, **BNULL)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse(
|
||||
"podcasts:podcast_detail", kwargs={"slug": self.podcast.uuid}
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.title}"
|
||||
|
||||
@ -108,42 +141,45 @@ class PodcastEpisode(ScrobblableMixin):
|
||||
|
||||
@classmethod
|
||||
def find_or_create(
|
||||
cls, podcast_dict: Dict, producer_dict: Dict, episode_dict: Dict
|
||||
) -> Optional["Episode"]:
|
||||
cls,
|
||||
title: str,
|
||||
podcast_name: str,
|
||||
pub_date: str,
|
||||
number: int = 0,
|
||||
mopidy_uri: str = "",
|
||||
producer_name: str = "",
|
||||
run_time_seconds: int = 1800,
|
||||
enrich: bool = True,
|
||||
) -> "PodcastEpisode":
|
||||
"""Given a data dict from Mopidy, finds or creates a podcast and
|
||||
producer before saving the epsiode so it can be scrobbled.
|
||||
|
||||
"""
|
||||
if not podcast_dict.get("name"):
|
||||
logger.warning(f"No name from source for podcast, not scrobbling")
|
||||
return
|
||||
|
||||
producer = None
|
||||
if producer_dict.get("name"):
|
||||
producer, producer_created = Producer.objects.get_or_create(
|
||||
**producer_dict
|
||||
if producer_name:
|
||||
producer = Producer.find_or_create(producer_name)
|
||||
|
||||
podcast = Podcast.objects.filter(
|
||||
name__iexact=podcast_name,
|
||||
).first()
|
||||
if not podcast:
|
||||
podcast = Podcast.objects.create(
|
||||
name=podcast_name, producer=producer
|
||||
)
|
||||
if producer_created:
|
||||
logger.debug(f"Created new producer {producer}")
|
||||
else:
|
||||
logger.debug(f"Found producer {producer}")
|
||||
if enrich:
|
||||
podcast.fix_metadata()
|
||||
|
||||
if producer:
|
||||
podcast_dict["producer_id"] = producer.id
|
||||
podcast, podcast_created = Podcast.objects.get_or_create(
|
||||
**podcast_dict
|
||||
)
|
||||
if podcast_created:
|
||||
logger.debug(f"Created new podcast {podcast}")
|
||||
else:
|
||||
logger.debug(f"Found podcast {podcast}")
|
||||
|
||||
episode_dict["podcast_id"] = podcast.id
|
||||
|
||||
episode, created = cls.objects.get_or_create(**episode_dict)
|
||||
if created:
|
||||
logger.debug(f"Created new episode: {episode}")
|
||||
else:
|
||||
logger.debug(f"Found episode {episode}")
|
||||
episode = cls.objects.filter(
|
||||
title__iexact=title, podcast=podcast
|
||||
).first()
|
||||
if not episode:
|
||||
episode = cls.objects.create(
|
||||
title=title,
|
||||
podcast=podcast,
|
||||
run_time_seconds=run_time_seconds,
|
||||
number=number,
|
||||
pub_date=pub_date,
|
||||
mopidy_uri=mopidy_uri,
|
||||
)
|
||||
|
||||
return episode
|
||||
|
||||
75
vrobbler/apps/podcasts/sources/podcastindex.py
Normal file
75
vrobbler/apps/podcasts/sources/podcastindex.py
Normal file
@ -0,0 +1,75 @@
|
||||
import hashlib
|
||||
import time
|
||||
|
||||
import pytz
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from scrobbles.utils import timestamp_user_tz_to_utc
|
||||
|
||||
PODCASTINDEX_API_KEY = getattr(settings, "PODCASTINDEX_API_KEY")
|
||||
PODCASTINDEX_API_SECRET = getattr(settings, "PODCASTINDEX_API_SECRET")
|
||||
|
||||
|
||||
def get_auth_headers():
|
||||
now = int(time.time())
|
||||
hash_data = hashlib.sha1(
|
||||
(PODCASTINDEX_API_KEY + PODCASTINDEX_API_SECRET + str(now)).encode(
|
||||
"utf-8"
|
||||
)
|
||||
).hexdigest()
|
||||
|
||||
return {
|
||||
"User-Agent": "MyPodcastApp/1.0",
|
||||
"X-Auth-Date": str(now),
|
||||
"X-Auth-Key": PODCASTINDEX_API_KEY,
|
||||
"Authorization": hash_data,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def lookup_podcast_from_podcastindex(
|
||||
podcast_name: str, dump_raw_response: bool = False
|
||||
) -> dict:
|
||||
url = "https://api.podcastindex.org/api/1.0/search/byterm"
|
||||
headers = get_auth_headers()
|
||||
params = {"q": podcast_name}
|
||||
|
||||
response = requests.get(url, headers=headers, params=params)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if dump_raw_response:
|
||||
return data.get("feeds")
|
||||
if data.get("feeds"):
|
||||
try:
|
||||
top_feed_dict = data["feeds"][0]
|
||||
|
||||
newest_episode_date = timestamp_user_tz_to_utc(
|
||||
top_feed_dict.get("newestItemPubdate"), pytz.UTC
|
||||
)
|
||||
days_since_last_episode = ()
|
||||
dead_date = None
|
||||
if (timezone.now() - newest_episode_date).days > 180:
|
||||
dead_date = newest_episode_date
|
||||
|
||||
return {
|
||||
"podcastindex_id": top_feed_dict.get("id"),
|
||||
"title": top_feed_dict.get("title"),
|
||||
"site_link": top_feed_dict.get("link"),
|
||||
"description": top_feed_dict.get("description"),
|
||||
"owner": top_feed_dict.get("ownerName"),
|
||||
"image_url": top_feed_dict.get("artwork"),
|
||||
"feed_url": top_feed_dict.get("url"),
|
||||
"itunes_id": top_feed_dict.get("itunesId"),
|
||||
"genres": list(top_feed_dict.get("categories").values()),
|
||||
"dead_date": dead_date,
|
||||
}
|
||||
except IndexError:
|
||||
return {}
|
||||
else:
|
||||
print("No podcasts found.")
|
||||
return {}
|
||||
else:
|
||||
print("Failed to fetch data:", response.status_code, response.text)
|
||||
return {}
|
||||
@ -0,0 +1,601 @@
|
||||
# Generated by Django 4.2.19 on 2025-04-07 17:16
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("profiles", "0022_userprofile_task_context_tags_str_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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -124,12 +124,12 @@ class ScrobblableMixin(TimeStampedModel):
|
||||
logger.warning("fix_metadata() not implemented yet")
|
||||
|
||||
@classmethod
|
||||
def find_or_create(cls) -> None:
|
||||
def find_or_create(cls):
|
||||
logger.warning("find_or_create() not implemented yet")
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
if self.title:
|
||||
return self.title
|
||||
return str(self.title)
|
||||
return str(self.uuid)
|
||||
|
||||
|
||||
|
||||
@ -1034,7 +1034,6 @@ class Scrobble(TimeStampedModel):
|
||||
"scrobble_data": scrobble_data,
|
||||
},
|
||||
)
|
||||
|
||||
scrobble_data["playback_status"] = scrobble_data.pop("status", None)
|
||||
# If it's marked as stopped, send it through our update mechanism, which will complete it
|
||||
if scrobble and (
|
||||
@ -1140,9 +1139,9 @@ class Scrobble(TimeStampedModel):
|
||||
if existing_locations := location.in_proximity(named=True):
|
||||
existing_location = existing_locations.first()
|
||||
ts = int(pendulum.now().timestamp())
|
||||
scrobble.log[ts] = (
|
||||
f"Location {location.id} too close to this scrobble"
|
||||
)
|
||||
scrobble.log[
|
||||
ts
|
||||
] = f"Location {location.id} too close to this scrobble"
|
||||
scrobble.save(update_fields=["log"])
|
||||
logger.info(
|
||||
f"[scrobbling] finished - found existing named location",
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
from datetime import datetime
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import pendulum
|
||||
import pytz
|
||||
@ -16,13 +15,15 @@ from locations.models import GeoLocation
|
||||
from music.constants import JELLYFIN_POST_KEYS, MOPIDY_POST_KEYS
|
||||
from music.models import Track
|
||||
from music.utils import get_or_create_track
|
||||
from podcasts.utils import get_or_create_podcast
|
||||
from podcasts.models import PodcastEpisode
|
||||
from podcasts.utils import parse_mopidy_uri
|
||||
from scrobbles.constants import (
|
||||
JELLYFIN_AUDIO_ITEM_TYPES,
|
||||
MANUAL_SCROBBLE_FNS,
|
||||
SCROBBLE_CONTENT_URLS,
|
||||
)
|
||||
from scrobbles.models import Scrobble
|
||||
from scrobbles.utils import convert_to_seconds
|
||||
from sports.models import SportEvent
|
||||
from sports.thesportsdb import lookup_event_from_thesportsdb
|
||||
from tasks.models import Task
|
||||
@ -54,9 +55,26 @@ def mopidy_scrobble_media(post_data: dict, user_id: int) -> Scrobble:
|
||||
)
|
||||
|
||||
if media_type == Scrobble.MediaType.PODCAST_EPISODE:
|
||||
media_obj = get_or_create_podcast(post_data)
|
||||
parsed_data = parse_mopidy_uri(post_data.get("mopidy_uri", ""))
|
||||
podcast_name = post_data.get(
|
||||
"album", parsed_data.get("podcast_name", "")
|
||||
)
|
||||
|
||||
media_obj = PodcastEpisode.find_or_create(
|
||||
title=parsed_data.get("episode_filename", ""),
|
||||
podcast_name=podcast_name,
|
||||
producer_name=post_data.get("artist", ""),
|
||||
number=parsed_data.get("episode_num", ""),
|
||||
pub_date=parsed_data.get("pub_date", ""),
|
||||
mopidy_uri=post_data.get("mopidy_uri", ""),
|
||||
)
|
||||
else:
|
||||
media_obj = get_or_create_track(post_data, MOPIDY_POST_KEYS)
|
||||
media_obj = Track.find_or_create(
|
||||
title=post_data.get("name", ""),
|
||||
artist_name=post_data.get("artist", ""),
|
||||
album_name=post_data.get("album", ""),
|
||||
run_time_seconds=post_data.get("run_time", 900000),
|
||||
)
|
||||
|
||||
log = {}
|
||||
try:
|
||||
@ -109,8 +127,14 @@ def jellyfin_scrobble_media(
|
||||
post_data.get("Provider_imdb", "").replace("tt", "")
|
||||
)
|
||||
else:
|
||||
media_obj = get_or_create_track(
|
||||
post_data, post_keys=JELLYFIN_POST_KEYS
|
||||
media_obj = Track.find_or_create(
|
||||
title=post_data.get("Name", ""),
|
||||
artist_name=post_data.get("Artist", ""),
|
||||
album_name=post_data.get("Album", ""),
|
||||
run_time_seconds=convert_to_seconds(
|
||||
post_data.get("RunTime", 900000)
|
||||
),
|
||||
musicbrainz_id=post_data.get("Provider_musicbrainztrack", ""),
|
||||
)
|
||||
# A hack because we don't worry about updating music ... we either finish it or we don't
|
||||
playback_position_seconds = 0
|
||||
|
||||
@ -1,18 +1,12 @@
|
||||
import codecs
|
||||
import csv
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import pytz
|
||||
import requests
|
||||
from music.utils import (
|
||||
get_or_create_album,
|
||||
get_or_create_artist,
|
||||
get_or_create_track,
|
||||
)
|
||||
from music.models import Track
|
||||
from scrobbles.constants import AsTsvColumn
|
||||
from scrobbles.models import Scrobble
|
||||
from music.constants import MOPIDY_POST_KEYS
|
||||
|
||||
from scrobbles.utils import timestamp_user_tz_to_utc
|
||||
|
||||
@ -50,27 +44,12 @@ def process_audioscrobbler_tsv_file(file_path, user_id, user_tz=None):
|
||||
)
|
||||
continue
|
||||
|
||||
track = get_or_create_track(
|
||||
{
|
||||
"title": row[AsTsvColumn["TRACK_NAME"].value],
|
||||
"mbid": row[AsTsvColumn["MB_ID"].value],
|
||||
"artist_name": row[AsTsvColumn["ARTIST_NAME"].value],
|
||||
"album_name": row[AsTsvColumn["ALBUM_NAME"].value],
|
||||
"run_time_seconds": int(
|
||||
row[AsTsvColumn["RUN_TIME_SECONDS"].value]
|
||||
),
|
||||
},
|
||||
{
|
||||
"TRACK_MB_ID": "mbid",
|
||||
"TRACK_TITLE": "track_title",
|
||||
"ALBUM_NAME": "album_name",
|
||||
"ARTIST_NAME": "artist_name",
|
||||
"RUN_TIME": "run_time_seconds",
|
||||
},
|
||||
track = Track.find_or_create(
|
||||
title=row[AsTsvColumn["TRACK_NAME"].value],
|
||||
musicbrainz_id=row[AsTsvColumn["MB_ID"].value],
|
||||
artist_name=row[AsTsvColumn["ARTIST_NAME"].value]
|
||||
album_name=row[AsTsvColumn["ALBUM_NAME"].value]
|
||||
)
|
||||
if not track:
|
||||
logger.info(f"Skipping track {track} because not found")
|
||||
continue
|
||||
|
||||
# TODO Set all this up as constants
|
||||
if row[AsTsvColumn["COMPLETE"].value] == "S":
|
||||
|
||||
@ -60,6 +60,8 @@ DUMP_REQUEST_DATA = (
|
||||
|
||||
THESPORTSDB_API_KEY = os.getenv("VROBBLER_THESPORTSDB_API_KEY", "2")
|
||||
THEAUDIODB_API_KEY = os.getenv("VROBBLER_THEAUDIODB_API_KEY", "2")
|
||||
PODCASTINDEX_API_KEY = os.getenv("VROBBLER_PODCASTINDEX_API_KEY", "")
|
||||
PODCASTINDEX_API_SECRET = os.getenv("VROBBLER_PODCASTINDEX_API_SECRET", "")
|
||||
TMDB_API_KEY = os.getenv("VROBBLER_TMDB_API_KEY", "")
|
||||
LASTFM_API_KEY = os.getenv("VROBBLER_LASTFM_API_KEY")
|
||||
LASTFM_SECRET_KEY = os.getenv("VROBBLER_LASTFM_SECRET_KEY")
|
||||
|
||||
Reference in New Issue
Block a user