[music] Fix jellyfin music scrobbling sort of
This commit is contained in:
@ -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 = {
|
||||||
|
|||||||
@ -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)
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
18
vrobbler/apps/videos/migrations/0016_video_tmdb_id.py
Normal file
18
vrobbler/apps/videos/migrations/0016_video_tmdb_id.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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)
|
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user