[scrobbling] Refactor webhook and simplify
This commit is contained in:
@ -45,7 +45,7 @@ def test_scrobble_mopidy_track(
|
|||||||
assert scrobble.media_obj.title == "Same in the End"
|
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,
|
||||||
|
|||||||
@ -29,12 +29,6 @@ class LifeEvent(ScrobblableMixin):
|
|||||||
def find_or_create(cls, title: str) -> "LifeEvent":
|
def find_or_create(cls, title: str) -> "LifeEvent":
|
||||||
return cls.objects.filter(title=title).first()
|
return cls.objects.filter(title=title).first()
|
||||||
|
|
||||||
def scrobble_for_user(self, user_id):
|
|
||||||
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
|
||||||
return Scrobble.objects.create(
|
|
||||||
user_id=user_id, life_event=self, timestamp=pendulum.now()
|
|
||||||
)
|
|
||||||
|
|
||||||
def scrobbles(self, user_id):
|
def scrobbles(self, user_id):
|
||||||
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
||||||
return Scrobble.objects.filter(
|
return Scrobble.objects.filter(
|
||||||
|
|||||||
@ -7,7 +7,7 @@ VARIOUS_ARTIST_DICT = {
|
|||||||
JELLYFIN_POST_KEYS = {
|
JELLYFIN_POST_KEYS = {
|
||||||
"ITEM_TYPE": "ItemType",
|
"ITEM_TYPE": "ItemType",
|
||||||
"RUN_TIME": "RunTime",
|
"RUN_TIME": "RunTime",
|
||||||
"TITLE": "Name",
|
"TRACK_TITLE": "Name",
|
||||||
"TIMESTAMP": "UtcTimestamp",
|
"TIMESTAMP": "UtcTimestamp",
|
||||||
"YEAR": "Year",
|
"YEAR": "Year",
|
||||||
"PLAYBACK_POSITION_TICKS": "PlaybackPositionTicks",
|
"PLAYBACK_POSITION_TICKS": "PlaybackPositionTicks",
|
||||||
@ -18,4 +18,21 @@ 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",
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
}
|
}
|
||||||
|
|||||||
@ -104,22 +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")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
top_result = musicbrainzngs.search_recordings(
|
results = musicbrainzngs.search_recordings(
|
||||||
query=track_name, artist=artist_mbid, release=album_mbid
|
query=track_name, artist=artist_mb_id, release=album_mb_id
|
||||||
)["recording-list"][0]
|
)["recording-list"]
|
||||||
|
logger.info(
|
||||||
|
"[lookup_track_from_mb] musicbrainz recordings search results",
|
||||||
|
extra={"results": results},
|
||||||
|
)
|
||||||
|
top_result = results[0]
|
||||||
except IndexError:
|
except IndexError:
|
||||||
return ""
|
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,36 +90,49 @@ 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):
|
||||||
title: str,
|
artist_name = post_data.get(post_keys.get("ARTIST_NAME"), "")
|
||||||
artist: Artist,
|
artist_mb_id = post_data.get(post_keys.get("ARTIST_MB_ID"), "")
|
||||||
album: Album = None,
|
album_title = post_data.get(post_keys.get("ALBUM_NAME"), "")
|
||||||
mbid: str = None,
|
album_mb_id = post_data.get(post_keys.get("ALBUM_MB_ID"), "")
|
||||||
run_time_seconds=None,
|
track_run_time_seconds = convert_to_seconds(
|
||||||
) -> Track:
|
post_data.get(post_keys.get("RUN_TIME"), 0)
|
||||||
|
)
|
||||||
|
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
|
track = None
|
||||||
if not mbid and album:
|
if not track_mb_id and album:
|
||||||
try:
|
try:
|
||||||
mbid = lookup_track_from_mb(
|
track_mb_id = lookup_track_from_mb(
|
||||||
title,
|
track_title,
|
||||||
artist.musicbrainz_id,
|
artist.musicbrainz_id,
|
||||||
album.musicbrainz_id,
|
album.musicbrainz_id,
|
||||||
).get("id", 0)
|
).get("id", 0)
|
||||||
except TypeError:
|
except TypeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if mbid:
|
if track_mb_id:
|
||||||
track = Track.objects.filter(musicbrainz_id=mbid).first()
|
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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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):
|
||||||
|
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
|
||||||
|
)
|
||||||
@ -47,21 +47,27 @@ class ScrobblableMixin(TimeStampedModel):
|
|||||||
class Meta:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
def basic_scrobble_data(self, user_id) -> dict:
|
def scrobble_for_user(
|
||||||
return {
|
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,
|
"user_id": user_id,
|
||||||
"timestamp": timezone.now(),
|
"timestamp": timezone.now(),
|
||||||
"playback_position_seconds": 0,
|
"source": source,
|
||||||
"source": "Vrobbler",
|
"playback_position_seconds": playback_position_seconds,
|
||||||
|
"status": status,
|
||||||
}
|
}
|
||||||
|
|
||||||
def scrobble_for_user(self, user_id):
|
|
||||||
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
|
||||||
scrobble_data = self.basic_scrobble_data(user_id)
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"[scrobble_for_user] called",
|
"[scrobble_for_user] called",
|
||||||
extra={
|
extra={
|
||||||
"webpage_id": self.id,
|
"id": self.id,
|
||||||
|
"media_type": self.__class__,
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"scrobble_data": scrobble_data,
|
"scrobble_data": scrobble_data,
|
||||||
"media_type": Scrobble.MediaType.WEBPAGE,
|
"media_type": Scrobble.MediaType.WEBPAGE,
|
||||||
@ -90,12 +96,6 @@ class ScrobblableMixin(TimeStampedModel):
|
|||||||
def find_or_create(cls) -> None:
|
def find_or_create(cls) -> None:
|
||||||
logger.warn("find_or_create() not implemented yet")
|
logger.warn("find_or_create() not implemented yet")
|
||||||
|
|
||||||
def scrobble(self, user_id, **kwargs):
|
|
||||||
"""Given a user ID and a dictionary of data, attempts to scrobble it"""
|
|
||||||
from scrobbles.models import Scrobble
|
|
||||||
|
|
||||||
Scrobble.create_or_update(self, user_id, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class LongPlayScrobblableMixin(ScrobblableMixin):
|
class LongPlayScrobblableMixin(ScrobblableMixin):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|||||||
@ -648,7 +648,10 @@ class Scrobble(TimeStampedModel):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def is_stale(self) -> bool:
|
def is_stale(self) -> bool:
|
||||||
"""Mark scrobble as stale if it's been more than an hour since it was updated"""
|
"""Mark scrobble as stale if it's been more than an hour since it was updated
|
||||||
|
|
||||||
|
Effectively, this allows 'resuming' a video scrobble within an hour of starting it.
|
||||||
|
"""
|
||||||
is_stale = False
|
is_stale = False
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
seconds_since_last_update = (now - self.modified).seconds
|
seconds_since_last_update = (now - self.modified).seconds
|
||||||
@ -911,7 +914,7 @@ class Scrobble(TimeStampedModel):
|
|||||||
# GeoLocations are a special case scrobble
|
# GeoLocations are a special case scrobble
|
||||||
if mtype == cls.MediaType.GEO_LOCATION:
|
if mtype == cls.MediaType.GEO_LOCATION:
|
||||||
logger.warn(
|
logger.warn(
|
||||||
f"[scrobbling] use create_or_update_location for GeoLocations"
|
f"[create_or_update] geoloc requires create_or_update_location"
|
||||||
)
|
)
|
||||||
scrobble = cls.create_or_update_location(
|
scrobble = cls.create_or_update_location(
|
||||||
media, scrobble_data, user_id
|
media, scrobble_data, user_id
|
||||||
@ -919,22 +922,16 @@ class Scrobble(TimeStampedModel):
|
|||||||
return scrobble
|
return scrobble
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[scrobbling] check for existing scrobble to update ",
|
f"[create_or_update] check for existing scrobble to update ",
|
||||||
extra={
|
extra={
|
||||||
"scrobble_id": scrobble.id if scrobble else None,
|
"scrobble_id": scrobble.id if scrobble else None,
|
||||||
"media_type": mtype,
|
"media_type": mtype,
|
||||||
"media_id": media.id,
|
"media_id": media.id,
|
||||||
"scrobble_data": scrobble_data,
|
"scrobble_data": scrobble_data,
|
||||||
"percent_played": scrobble.percent_played if scrobble else 0,
|
|
||||||
"can_be_updated": scrobble.can_be_updated
|
|
||||||
if scrobble
|
|
||||||
else False,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
scrobble_data["playback_status"] = scrobble_data.pop(
|
scrobble_data["playback_status"] = scrobble_data.pop("status", None)
|
||||||
"mopidy_status", scrobble_data.pop("jellyfin_status", None)
|
|
||||||
)
|
|
||||||
# If it's marked as stopped, send it through our update mechanism, which will complete it
|
# If it's marked as stopped, send it through our update mechanism, which will complete it
|
||||||
if scrobble and (
|
if scrobble and (
|
||||||
scrobble.can_be_updated
|
scrobble.can_be_updated
|
||||||
@ -1069,7 +1066,7 @@ class Scrobble(TimeStampedModel):
|
|||||||
playback_status = scrobble_data.pop("playback_status", None)
|
playback_status = scrobble_data.pop("playback_status", None)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"[scrobbling] update called",
|
"[update] called",
|
||||||
extra={
|
extra={
|
||||||
"scrobble_id": self.id,
|
"scrobble_id": self.id,
|
||||||
"scrobble_data": scrobble_data,
|
"scrobble_data": scrobble_data,
|
||||||
@ -1101,16 +1098,6 @@ class Scrobble(TimeStampedModel):
|
|||||||
update_fields.append(key)
|
update_fields.append(key)
|
||||||
self.save(update_fields=update_fields)
|
self.save(update_fields=update_fields)
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"[scrobbling] update finished",
|
|
||||||
extra={
|
|
||||||
"scrobble_id": self.id,
|
|
||||||
"scrobble_data": scrobble_data,
|
|
||||||
"playback_status": playback_status,
|
|
||||||
"media_type": self.media_type,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@ -1,25 +1,21 @@
|
|||||||
|
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
|
|
||||||
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.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 PodcastEpisode
|
|
||||||
from scrobbles.models import Scrobble
|
from scrobbles.models import Scrobble
|
||||||
from scrobbles.utils import convert_to_seconds, parse_mopidy_uri
|
from scrobbles.utils import convert_to_seconds
|
||||||
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,180 +26,88 @@ 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")}
|
|
||||||
|
|
||||||
podcast_name = data_dict.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": data_dict.get("run_time"),
|
|
||||||
"number": parsed_data.get("episode_num"),
|
|
||||||
"pub_date": parsed_data.get("pub_date"),
|
|
||||||
"mopidy_uri": mopidy_uri,
|
|
||||||
}
|
|
||||||
|
|
||||||
episode = PodcastEpisode.find_or_create(
|
|
||||||
podcast_dict, producer_dict, episode_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(
|
logger.info(
|
||||||
"[scrobblers] webhook mopidy scrobble request received",
|
"[scrobblers] webhook mopidy scrobble request received",
|
||||||
extra={
|
extra={
|
||||||
"episode_id": episode.id if episode else None,
|
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"scrobble_dict": mopidy_data,
|
"post_data": post_data,
|
||||||
"media_type": Scrobble.MediaType.PODCAST_EPISODE,
|
"media_type": media_type,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
scrobble = None
|
if media_type == Scrobble.MediaType.PODCAST_EPISODE:
|
||||||
if episode:
|
media_obj = get_or_create_podcast(post_data)
|
||||||
scrobble = Scrobble.create_or_update(episode, user_id, mopidy_data)
|
else:
|
||||||
return scrobble
|
media_obj = get_or_create_track(post_data, MOPIDY_POST_KEYS)
|
||||||
|
|
||||||
|
|
||||||
def mopidy_scrobble_track(
|
|
||||||
data_dict: dict, user_id: Optional[int]
|
|
||||||
) -> Optional[Scrobble]:
|
|
||||||
artist = get_or_create_artist(
|
|
||||||
data_dict.get("artist"),
|
|
||||||
mbid=data_dict.get("musicbrainz_artist_id", None),
|
|
||||||
)
|
|
||||||
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
|
# Now we run off a scrobble
|
||||||
playback_seconds = data_dict.get("playback_time_ticks") / 1000
|
playback_seconds = post_data.get("playback_time_ticks", 1) / 1000
|
||||||
mopidy_data = {
|
playback_status = post_data.get(MOPIDY_POST_KEYS.get("STATUS"), "")
|
||||||
"user_id": user_id,
|
|
||||||
"timestamp": timezone.now(),
|
return media_obj.scrobble_for_user(
|
||||||
"playback_position_seconds": playback_seconds,
|
user_id,
|
||||||
"source": "Mopidy",
|
source="Mopidy",
|
||||||
"mopidy_status": data_dict.get("status"),
|
playback_position_seconds=playback_seconds,
|
||||||
}
|
status=playback_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def jellyfin_scrobble_media(
|
||||||
|
post_data: dict, user_id: int
|
||||||
|
) -> Optional[Scrobble]:
|
||||||
|
media_type = Scrobble.MediaType.VIDEO
|
||||||
|
if post_data.pop("ItemType", "") in JELLYFIN_AUDIO_ITEM_TYPES:
|
||||||
|
media_type = Scrobble.MediaType.TRACK
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"[scrobblers] webhook mopidy scrobble request received",
|
"[jellyfin_scrobble_track] called",
|
||||||
extra={
|
extra={
|
||||||
"track_id": track.id,
|
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"scrobble_dict": mopidy_data,
|
"post_data": post_data,
|
||||||
"media_type": Scrobble.MediaType.TRACK,
|
"media_type": media_type,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
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"),
|
|
||||||
"log": {"media_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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
playback_status = "resumed"
|
||||||
|
if post_data.get("IsPaused"):
|
||||||
|
playback_status = "paused"
|
||||||
|
elif post_data.get("NotificationType") == "PlaybackStop":
|
||||||
|
playback_status = "stopped"
|
||||||
|
|
||||||
# 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_track] no playback position tick, aborting",
|
||||||
|
extra={"post_data": post_data},
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
artist = get_or_create_artist(
|
playback_position_seconds = convert_to_seconds(
|
||||||
data_dict.get(JELLYFIN_POST_KEYS["ARTIST_NAME"]),
|
post_data.get(JELLYFIN_POST_KEYS.get("RUN_TIME"), 0)
|
||||||
mbid=data_dict.get(JELLYFIN_POST_KEYS["ARTIST_MB_ID"]),
|
|
||||||
)
|
|
||||||
album = get_or_create_album(
|
|
||||||
data_dict.get(JELLYFIN_POST_KEYS["ALBUM_NAME"]),
|
|
||||||
artist=artist,
|
|
||||||
mbid=data_dict.get(JELLYFIN_POST_KEYS["ALBUM_MB_ID"]),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
run_time = convert_to_seconds(
|
if media_type == Scrobble.MediaType.VIDEO:
|
||||||
data_dict.get(JELLYFIN_POST_KEYS["RUN_TIME"])
|
media_obj = Video.find_or_create(post_data)
|
||||||
|
else:
|
||||||
|
media_obj = get_or_create_track(
|
||||||
|
post_data, post_keys=JELLYFIN_POST_KEYS
|
||||||
|
)
|
||||||
|
|
||||||
|
return media_obj.scrobble_for_user_id(
|
||||||
|
user_id,
|
||||||
|
playback_position_seconds=playback_position_seconds,
|
||||||
|
status=playback_status,
|
||||||
)
|
)
|
||||||
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)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"[scrobblers] webhook 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)
|
|
||||||
|
|
||||||
|
|
||||||
def manual_scrobble_video(imdb_id: str, user_id: int):
|
def manual_scrobble_video(imdb_id: str, user_id: int):
|
||||||
|
|||||||
@ -1,11 +1,8 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import re
|
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
|
||||||
@ -19,9 +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
|
||||||
@ -45,49 +39,6 @@ def convert_to_seconds(run_time: str) -> int:
|
|||||||
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 = 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_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")
|
||||||
|
|
||||||
|
|||||||
@ -320,29 +320,24 @@ def lastfm_import(request):
|
|||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
@api_view(["POST"])
|
@api_view(["POST"])
|
||||||
def jellyfin_webhook(request):
|
def jellyfin_webhook(request):
|
||||||
data_dict = request.data
|
post_data = request.data
|
||||||
|
logger.info(
|
||||||
|
"[jellyfin_webhook] called",
|
||||||
|
extra={"post_data": post_data},
|
||||||
|
)
|
||||||
|
|
||||||
# For making things easier to build new input processors
|
in_progress = post_data.get("NotificationType", "") == "PlaybackProgress"
|
||||||
if getattr(settings, "DUMP_REQUEST_DATA", False):
|
is_music = post_data.get("ItemType", "") == "Audio"
|
||||||
json_data = json.dumps(data_dict, indent=4)
|
|
||||||
logger.info(f"{json_data}")
|
|
||||||
|
|
||||||
in_progress = data_dict.get("NotificationType", "") == "PlaybackProgress"
|
|
||||||
is_music = data_dict.get("ItemType", "") == "Audio"
|
|
||||||
|
|
||||||
# Disregard progress updates
|
# Disregard progress updates
|
||||||
if in_progress and is_music:
|
if in_progress and is_music:
|
||||||
logger.debug("Disregarding playback update from Jellyfin")
|
logger.info(
|
||||||
|
"[jellyfin_webhook] ignoring update of music in progress",
|
||||||
|
extra={"post_data": post_data},
|
||||||
|
)
|
||||||
return Response({}, status=status.HTTP_304_NOT_MODIFIED)
|
return Response({}, status=status.HTTP_304_NOT_MODIFIED)
|
||||||
|
|
||||||
scrobble = None
|
scrobble = jellyfin_scrobble_media(post_data, request.user.id)
|
||||||
media_type = data_dict.get("ItemType", "")
|
|
||||||
|
|
||||||
if media_type in JELLYFIN_AUDIO_ITEM_TYPES:
|
|
||||||
scrobble = jellyfin_scrobble_track(data_dict, request.user.id)
|
|
||||||
|
|
||||||
if media_type in JELLYFIN_VIDEO_ITEM_TYPES:
|
|
||||||
scrobble = jellyfin_scrobble_video(data_dict, request.user.id)
|
|
||||||
|
|
||||||
if not scrobble:
|
if not scrobble:
|
||||||
return Response({}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
@ -359,15 +354,7 @@ def mopidy_webhook(request):
|
|||||||
except TypeError:
|
except TypeError:
|
||||||
data_dict = request.data
|
data_dict = request.data
|
||||||
|
|
||||||
# For making things easier to build new input processors
|
scrobble = mopidy_scrobble_media(data_dict, request.user.id)
|
||||||
if getattr(settings, "DUMP_REQUEST_DATA", False):
|
|
||||||
json_data = json.dumps(data_dict, indent=4)
|
|
||||||
logger.debug(f"{json_data}")
|
|
||||||
|
|
||||||
if "podcast" in data_dict.get("mopidy_uri"):
|
|
||||||
scrobble = mopidy_scrobble_podcast(data_dict, request.user.id)
|
|
||||||
else:
|
|
||||||
scrobble = mopidy_scrobble_track(data_dict, request.user.id)
|
|
||||||
|
|
||||||
if not scrobble:
|
if not scrobble:
|
||||||
return Response({}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|||||||
Reference in New Issue
Block a user