[music] Fix jellyfin music scrobbling sort of

This commit is contained in:
2024-09-06 10:27:04 -04:00
parent 3a50a8b015
commit b5d194e74f
9 changed files with 151 additions and 40 deletions

View File

@ -1,3 +1,78 @@
jellyfin_keys = [
"ServerId",
"ServerName",
"ServerVersion",
"ServerUrl",
"NotificationType",
"Timestamp",
"UtcTimestamp",
"Name",
"Overview",
"Tagline",
"ItemId",
"RunTimeTicks",
"RunTime",
"Year",
"Provider_tmdb",
"Provider_imdb",
"Provider_tmdbcollection",
"Provider_tmdbset",
"Video_0_Title",
"Video_0_Type",
"Video_0_Codec",
"Video_0_Profile",
"Video_0_Level",
"Video_0_Height",
"Video_0_Width",
"Video_0_AspectRatio",
"Video_0_Interlaced",
"Video_0_FrameRate",
"Video_0_VideoRange",
"Video_0_ColorSpace",
"Video_0_ColorTransfer",
"Video_0_ColorPrimaries",
"Video_0_PixelFormat",
"Video_0_RefFrames",
"Audio_0_Title",
"Audio_0_Type",
"Audio_0_Language",
"Audio_0_Codec",
"Audio_0_Channels",
"Audio_0_Bitrate",
"Audio_0_SampleRate",
"Audio_0_Default",
"Subtitle_0_Title",
"Subtitle_0_Type",
"Subtitle_0_Language",
"Subtitle_0_Codec",
"Subtitle_0_Default",
"Subtitle_0_Forced",
"Subtitle_0_External",
"Subtitle_1_Title",
"Subtitle_1_Type",
"Subtitle_1_Language",
"Subtitle_1_Codec",
"Subtitle_1_Default",
"Subtitle_1_Forced",
"Subtitle_1_External",
"Subtitle_2_Title",
"Subtitle_2_Type",
"Subtitle_2_Language",
"Subtitle_2_Codec",
"Subtitle_2_Default",
"Subtitle_2_Forced",
"Subtitle_2_External",
"PlaybackPositionTicks",
"PlaybackPosition",
"MediaSourceId",
"IsPaused",
"IsAutomated",
"DeviceId",
"DeviceName",
"ClientName",
"NotificationUsername",
"UserId",
]
VARIOUS_ARTIST_DICT = { VARIOUS_ARTIST_DICT = {
"name": "Various Artists", "name": "Various Artists",
"theaudiodb_id": "113641", "theaudiodb_id": "113641",
@ -6,10 +81,13 @@ VARIOUS_ARTIST_DICT = {
JELLYFIN_POST_KEYS = { JELLYFIN_POST_KEYS = {
"ITEM_TYPE": "ItemType", "ITEM_TYPE": "ItemType",
"ITEM_ID": "ItemId",
"RUN_TIME": "RunTime", "RUN_TIME": "RunTime",
"TRACK_TITLE": "Name", "TRACK_TITLE": "Name",
"TIMESTAMP": "UtcTimestamp", "TIMESTAMP": "UtcTimestamp",
"YEAR": "Year", "YEAR": "Year",
"OVERVIEW": "Overview",
"TAGLINE": "TAGLINE",
"PLAYBACK_POSITION_TICKS": "PlaybackPositionTicks", "PLAYBACK_POSITION_TICKS": "PlaybackPositionTicks",
"PLAYBACK_POSITION": "PlaybackPosition", "PLAYBACK_POSITION": "PlaybackPosition",
"ARTIST_MB_ID": "Provider_musicbrainzartist", "ARTIST_MB_ID": "Provider_musicbrainzartist",
@ -19,8 +97,10 @@ JELLYFIN_POST_KEYS = {
"ALBUM_NAME": "Album", "ALBUM_NAME": "Album",
"ARTIST_NAME": "Artist", "ARTIST_NAME": "Artist",
"STATUS": "Status", "STATUS": "Status",
"SOURCE": "ClientName",
"VIDEO_TITLE": "Name", "VIDEO_TITLE": "Name",
"IMDB_ID": "Provider_imdb", "IMDB_ID": "Provider_imdb",
"TMDB_ID": "Provider_tmdb",
} }
MOPIDY_POST_KEYS = { MOPIDY_POST_KEYS = {

View File

@ -91,8 +91,11 @@ def get_or_create_album(
def get_or_create_track(post_data: dict, post_keys: dict) -> Track: def get_or_create_track(post_data: dict, post_keys: dict) -> Track:
track_run_time_seconds = post_data.get(post_keys.get("RUN_TIME"), 0) try:
if post_keys.get("RUN_TIME") == "RunTime": track_run_time_seconds = int(
post_data.get(post_keys.get("RUN_TIME"), 0)
)
except ValueError: # Sometimes we get run time as a string like "01:35"
track_run_time_seconds = convert_to_seconds( track_run_time_seconds = convert_to_seconds(
post_data.get(post_keys.get("RUN_TIME"), 0) post_data.get(post_keys.get("RUN_TIME"), 0)
) )

View File

@ -77,12 +77,12 @@ class ScrobblableMixin(TimeStampedModel):
@property @property
def primary_image_url(self) -> str: def primary_image_url(self) -> str:
logger.warn(f"Not implemented yet") logger.warning(f"Not implemented yet")
return "" return ""
@property @property
def logdata_cls(self) -> None: def logdata_cls(self) -> None:
logger.warn("logdata_cls() not implemented yet") logger.warning("logdata_cls() not implemented yet")
return None return None
@property @property
@ -90,11 +90,11 @@ class ScrobblableMixin(TimeStampedModel):
return "" return ""
def fix_metadata(self) -> None: def fix_metadata(self) -> None:
logger.warn("fix_metadata() not implemented yet") logger.warning("fix_metadata() not implemented yet")
@classmethod @classmethod
def find_or_create(cls) -> None: def find_or_create(cls) -> None:
logger.warn("find_or_create() not implemented yet") logger.warning("find_or_create() not implemented yet")
class LongPlayScrobblableMixin(ScrobblableMixin): class LongPlayScrobblableMixin(ScrobblableMixin):

View File

@ -1,6 +1,5 @@
import json import json
import logging import logging
from datetime import datetime
from typing import Optional from typing import Optional
import pendulum import pendulum
@ -8,6 +7,7 @@ import pytz
from boardgames.models import BoardGame from boardgames.models import BoardGame
from books.models import Book from books.models import Book
from dateutil.parser import parse from dateutil.parser import parse
from django.conf import settings
from django.utils import timezone from django.utils import timezone
from locations.constants import LOCATION_PROVIDERS from locations.constants import LOCATION_PROVIDERS
from locations.models import GeoLocation from locations.models import GeoLocation
@ -17,7 +17,6 @@ from music.utils import get_or_create_track
from podcasts.utils import get_or_create_podcast from podcasts.utils import get_or_create_podcast
from scrobbles.constants import JELLYFIN_AUDIO_ITEM_TYPES from scrobbles.constants import JELLYFIN_AUDIO_ITEM_TYPES
from scrobbles.models import Scrobble from scrobbles.models import Scrobble
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
@ -33,6 +32,9 @@ def mopidy_scrobble_media(post_data: dict, user_id: int) -> Scrobble:
if "podcast" in post_data.get("mopidy_uri", ""): if "podcast" in post_data.get("mopidy_uri", ""):
media_type = Scrobble.MediaType.PODCAST_EPISODE media_type = Scrobble.MediaType.PODCAST_EPISODE
if settings.DUMP_REQUEST_DATA:
print("MOPIDY_DATA: ", post_data)
logger.info( logger.info(
"[scrobblers] webhook mopidy scrobble request received", "[scrobblers] webhook mopidy scrobble request received",
extra={ extra={
@ -51,7 +53,8 @@ def mopidy_scrobble_media(post_data: dict, user_id: int) -> Scrobble:
user_id, user_id,
source="Mopidy", source="Mopidy",
playback_position_seconds=int( playback_position_seconds=int(
post_data.get("playback_time_ticks", 1) / 1000 post_data.get(MOPIDY_POST_KEYS.get("PLAYBACK_POSITION_TICKS"), 1)
/ 1000
), ),
status=post_data.get(MOPIDY_POST_KEYS.get("STATUS"), ""), status=post_data.get(MOPIDY_POST_KEYS.get("STATUS"), ""),
) )
@ -64,6 +67,9 @@ def jellyfin_scrobble_media(
if post_data.pop("ItemType", "") in JELLYFIN_AUDIO_ITEM_TYPES: if post_data.pop("ItemType", "") in JELLYFIN_AUDIO_ITEM_TYPES:
media_type = Scrobble.MediaType.TRACK media_type = Scrobble.MediaType.TRACK
if settings.DUMP_REQUEST_DATA:
print("JELLYFIN_DATA: ", post_data)
logger.info( logger.info(
"[jellyfin_scrobble_media] called", "[jellyfin_scrobble_media] called",
extra={ extra={
@ -87,16 +93,20 @@ def jellyfin_scrobble_media(
return return
timestamp = parse( timestamp = parse(
post_data.get(JELLYFIN_POST_KEYS.get("TIMESTAMP")) post_data.get(JELLYFIN_POST_KEYS.get("TIMESTAMP"), "")
).replace(tzinfo=pytz.utc) ).replace(tzinfo=pytz.utc)
playback_position_seconds = int(
post_data.get(JELLYFIN_POST_KEYS.get("PLAYBACK_POSITION_TICKS"), 1)
/ 10000000
)
if media_type == Scrobble.MediaType.VIDEO: if media_type == Scrobble.MediaType.VIDEO:
media_obj = Video.find_or_create(post_data) media_obj = Video.find_or_create(post_data)
playback_position_seconds = (timezone.now() - timestamp).seconds
else: else:
media_obj = get_or_create_track( media_obj = get_or_create_track(
post_data, post_keys=JELLYFIN_POST_KEYS post_data, post_keys=JELLYFIN_POST_KEYS
) )
# A hack because we don't worry about updating music ... we either finish it or we don't
playback_position_seconds = 0 playback_position_seconds = 0
if not media_obj: if not media_obj:
@ -112,13 +122,9 @@ def jellyfin_scrobble_media(
elif post_data.get("NotificationType") == "PlaybackStop": elif post_data.get("NotificationType") == "PlaybackStop":
playback_status = "stopped" playback_status = "stopped"
logger.info(
"[jellyfin_scrobble_media] no playback position tick, aborting",
extra={"post_data": post_data, "playback_status": playback_status},
)
return media_obj.scrobble_for_user( return media_obj.scrobble_for_user(
user_id, user_id,
source=post_data.get(JELLYFIN_POST_KEYS.get("SOURCE")),
playback_position_seconds=playback_position_seconds, playback_position_seconds=playback_position_seconds,
status=playback_status, status=playback_status,
) )

View File

@ -29,8 +29,6 @@ from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from scrobbles.api import serializers from scrobbles.api import serializers
from scrobbles.constants import ( from scrobbles.constants import (
JELLYFIN_AUDIO_ITEM_TYPES,
JELLYFIN_VIDEO_ITEM_TYPES,
LONG_PLAY_MEDIA, LONG_PLAY_MEDIA,
MANUAL_SCROBBLE_FNS, MANUAL_SCROBBLE_FNS,
PLAY_AGAIN_MEDIA, PLAY_AGAIN_MEDIA,
@ -342,6 +340,10 @@ def jellyfin_webhook(request):
if not scrobble: if not scrobble:
return Response({}, status=status.HTTP_400_BAD_REQUEST) return Response({}, status=status.HTTP_400_BAD_REQUEST)
logger.info(
"[jellyfin_webhook] finished",
extra={"scrobble_id": scrobble.id},
)
return Response({"scrobble_id": scrobble.id}, status=status.HTTP_200_OK) return Response({"scrobble_id": scrobble.id}, status=status.HTTP_200_OK)

View File

@ -1,9 +1,7 @@
import logging import logging
from django.utils import timezone from typing import Optional
from imdb import Cinemagoer, helpers from imdb import Cinemagoer, helpers
from imdb.Character import IMDbParserError
from scrobbles.dataclasses import VideoLogData
imdb_client = Cinemagoer() imdb_client = Cinemagoer()
@ -12,7 +10,7 @@ logger = logging.getLogger(__name__)
def lookup_video_from_imdb( def lookup_video_from_imdb(
name_or_id: str, kind: str = "movie" name_or_id: str, kind: str = "movie"
) -> VideoLogData: ) -> Optional[dict]:
# Very few video titles start with tt, but IMDB IDs often come in with it # Very few video titles start with tt, but IMDB IDs often come in with it
if name_or_id.startswith("tt"): if name_or_id.startswith("tt"):
@ -50,7 +48,7 @@ def lookup_video_from_imdb(
f"[lookup_video_from_imdb] no video found on imdb", f"[lookup_video_from_imdb] no video found on imdb",
extra={"name_or_id": name_or_id}, extra={"name_or_id": name_or_id},
) )
return video_metadata return None
imdb_client.update(video_metadata) imdb_client.update(video_metadata)

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.15 on 2024-09-06 13:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("videos", "0015_alter_video_genre"),
]
operations = [
migrations.AddField(
model_name="video",
name="tmdb_id",
field=models.CharField(blank=True, max_length=20, null=True),
),
]

View File

@ -155,6 +155,7 @@ class Video(ScrobblableMixin):
) )
tvrage_id = models.CharField(max_length=20, **BNULL) tvrage_id = models.CharField(max_length=20, **BNULL)
tvdb_id = models.CharField(max_length=20, **BNULL) tvdb_id = models.CharField(max_length=20, **BNULL)
tmdb_id = models.CharField(max_length=20, **BNULL)
plot = models.TextField(**BNULL) plot = models.TextField(**BNULL)
year = models.IntegerField(**BNULL) year = models.IntegerField(**BNULL)
@ -229,20 +230,10 @@ class Video(ScrobblableMixin):
self.cover_image.save(fname, ContentFile(r.content), save=True) self.cover_image.save(fname, ContentFile(r.content), save=True)
@classmethod @classmethod
def find_or_create(cls, data_dict: Dict) -> Optional["Video"]: def find_or_create(
"""Given a data dict from Jellyfin, does the heavy lifting of looking up cls, data_dict: dict, post_keys: dict = JELLYFIN_POST_KEYS
the video and, if need, TV Series, creating both if they don't yet ) -> Optional["Video"]:
exist. """Thes smallest of wrappers around our actual get or create utility."""
from videos.utils import get_or_create_video
""" return get_or_create_video(data_dict, post_keys)
from videos.utils import (
get_or_create_video,
get_or_create_video_from_jellyfin,
)
video = get_or_create_video(data_dict, JELLYFIN_POST_KEYS)
if not video:
return
return get_or_create_video_from_jellyfin(data_dict)

View File

@ -29,8 +29,21 @@ def get_or_create_video(data_dict: dict, post_keys: dict, force_update=False):
title=video_dict.get("title"), title=video_dict.get("title"),
) )
if video_created or force_update: if video_created or force_update:
if not "overview" in video_dict.keys():
video_dict["overview"] = data_dict.get(
post_keys.get("OVERVIEW"), None
)
if not "tagline" in video_dict.keys():
video_dict["tagline"] = data_dict.get(
post_keys.get("TAGLINE"), None
)
if not "tmdb_id" in video_dict.keys():
video_dict["tmdb_id"] = data_dict.get(
post_keys.get("TMDB_ID"), None
)
series = None series = None
if video_dict.get("video_type") == Video.VideoType.TV_EPISODE: if video_dict.get("series_name"):
series_name = video_dict.pop("series_name") series_name = video_dict.pop("series_name")
series, series_created = Series.objects.get_or_create( series, series_created = Series.objects.get_or_create(