diff --git a/vrobbler/apps/scrobbles/scrobblers.py b/vrobbler/apps/scrobbles/scrobblers.py index 521ecbf..6e4ca09 100644 --- a/vrobbler/apps/scrobbles/scrobblers.py +++ b/vrobbler/apps/scrobbles/scrobblers.py @@ -158,11 +158,6 @@ def jellyfin_scrobble_track( def jellyfin_scrobble_video(data_dict: dict, user_id: Optional[int]): - if not data_dict.get("Provider_imdb", None): - logger.error( - "No IMDB ID received. This is likely because all metadata is bad, not scrobbling" - ) - return video = Video.find_or_create(data_dict) scrobble_dict = build_scrobble_dict(data_dict, user_id) @@ -170,25 +165,21 @@ def jellyfin_scrobble_video(data_dict: dict, user_id: Optional[int]): return Scrobble.create_or_update(video, user_id, scrobble_dict) -def manual_scrobble_video(data_dict: dict, user_id: Optional[int]): - if not data_dict.get("Provider_imdb", None): - logger.error( - "No IMDB ID received. This is likely because all metadata is bad, not scrobbling" - ) - return - video = Video.find_or_create(data_dict) +def manual_scrobble_video(imdb_id: str, user_id: int): + video = Video.find_or_create({"imdb_id": imdb_id}) - scrobble_dict = build_scrobble_dict(data_dict, user_id) + # TODO allow series to be marked with a source id + scrobble_dict = { + "user_id": user_id, + "timestamp": timezone.now(), + "playback_position_seconds": 0, + "source": "Vrobbler", + } return Scrobble.create_or_update(video, user_id, scrobble_dict) def manual_scrobble_event(data_dict: dict, user_id: Optional[int]): - if not data_dict.get("Provider_thesportsdb", None): - logger.error( - "No TheSportsDB ID received. This is likely because all metadata is bad, not scrobbling" - ) - return event = SportEvent.find_or_create(data_dict) scrobble_dict = build_scrobble_dict(data_dict, user_id) diff --git a/vrobbler/apps/scrobbles/views.py b/vrobbler/apps/scrobbles/views.py index 1458b3e..ddbc458 100644 --- a/vrobbler/apps/scrobbles/views.py +++ b/vrobbler/apps/scrobbles/views.py @@ -60,7 +60,6 @@ from scrobbles.tasks import ( ) from sports.thesportsdb import lookup_event_from_thesportsdb from videogames.howlongtobeat import lookup_game_from_hltb -from videos.imdb import lookup_video_from_imdb from books.openlibrary import lookup_book_from_openlibrary from scrobbles.utils import ( @@ -228,9 +227,7 @@ class ManualScrobbleView(FormView): manual_scrobble_event(data_dict, self.request.user.id) if key == "-i": - data_dict = lookup_video_from_imdb(item_id) - if data_dict: - manual_scrobble_video(data_dict, self.request.user.id) + manual_scrobble_video(item_id, self.request.user.id) return HttpResponseRedirect(reverse("vrobbler-home")) diff --git a/vrobbler/apps/videos/admin.py b/vrobbler/apps/videos/admin.py index b19e355..ff9431f 100644 --- a/vrobbler/apps/videos/admin.py +++ b/vrobbler/apps/videos/admin.py @@ -7,7 +7,7 @@ from scrobbles.admin import ScrobbleInline @admin.register(Series) class SeriesAdmin(admin.ModelAdmin): date_hierarchy = "created" - list_display = ("name", "tagline") + list_display = ("name", "imdb_id") ordering = ("-created",) diff --git a/vrobbler/apps/videos/imdb.py b/vrobbler/apps/videos/imdb.py index b272c56..1eb0bc6 100644 --- a/vrobbler/apps/videos/imdb.py +++ b/vrobbler/apps/videos/imdb.py @@ -1,56 +1,44 @@ import logging from django.utils import timezone -from imdb import Cinemagoer +from imdb import Cinemagoer, helpers +from imdb.Character import IMDbParserError imdb_client = Cinemagoer() logger = logging.getLogger(__name__) -def lookup_video_from_imdb(imdb_id: str) -> dict: - - if "tt" not in imdb_id: - logger.warning(f"IMDB ID should begin with 'tt' {imdb_id}") - return - - lookup_id = imdb_id.strip("tt") - media = imdb_client.get_movie(lookup_id) - - run_time_seconds = 60 * 60 - runtimes = media.get("runtimes") - if runtimes: - run_time_seconds = int(runtimes[0]) * 60 - - item_type = "Movie" - if media.get("series title"): - item_type = "Episode" +def lookup_video_from_imdb(name_or_id: str, kind: str = "movie") -> dict: + name_or_id = name_or_id.strip("tt") + video_dict = {} + imdb_id = None try: - plot = media.get("plot")[0] - except TypeError: - plot = "" - except IndexError: - plot = "" + imdb_id = int(name_or_id) + except ValueError: + pass - # Build a rough approximation of a Jellyfin data response - data_dict = { - "ItemType": item_type, - "Name": media.get("title"), - "Overview": plot, - "Tagline": media.get("tagline"), - "Year": media.get("year"), - "Provider_imdb": imdb_id, - "RunTime": run_time_seconds, - "SeriesName": media.get("series title"), - "EpisodeNumber": media.get("episode"), - "SeasonNumber": media.get("season"), - "PlaybackPositionTicks": 1, - "PlaybackPosition": 1, - "UtcTimestamp": timezone.now().strftime("%Y-%m-%d %H:%M:%S.%f%z"), - "IsPaused": False, - "PlayedToCompletion": False, - } - logger.debug(f"Parsed data from IMDB data: {data_dict}") + if imdb_id: + imdb_result = imdb_client.get_movie(name_or_id) + video_dict = imdb_result - return data_dict + if not video_dict: + imdb_results = imdb_client.search_movie(name_or_id) + if len(imdb_results) > 1: + for result in imdb_results: + if result["kind"] == kind: + video_dict = result + break + + if len(imdb_results) == 1: + video_dict = imdb_results[0] + + if not video_dict: + logger.warn(f"No video found for key {name_or_id}") + return video_dict + + cover_url = video_dict.get("cover url") + if cover_url: + video_dict["cover url"] = helpers.resizeImage(cover_url, width=800) + return video_dict diff --git a/vrobbler/apps/videos/migrations/0009_rename_overview_series_plot_remove_series_tagline_and_more.py b/vrobbler/apps/videos/migrations/0009_rename_overview_series_plot_remove_series_tagline_and_more.py new file mode 100644 index 0000000..b7b4029 --- /dev/null +++ b/vrobbler/apps/videos/migrations/0009_rename_overview_series_plot_remove_series_tagline_and_more.py @@ -0,0 +1,68 @@ +# Generated by Django 4.1.5 on 2023-03-13 04:00 + +from django.db import migrations, models +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ("taggit", "0004_alter_taggeditem_content_type_alter_taggeditem_tag"), + ("videos", "0008_rename_run_time_video_run_time_seconds"), + ] + + operations = [ + migrations.RenameField( + model_name="series", + old_name="overview", + new_name="plot", + ), + migrations.RemoveField( + model_name="series", + name="tagline", + ), + migrations.AddField( + model_name="series", + name="cover_image", + field=models.ImageField( + blank=True, null=True, upload_to="videos/series/" + ), + ), + migrations.AddField( + model_name="series", + name="genres", + field=taggit.managers.TaggableManager( + help_text="A comma-separated list of tags.", + through="taggit.TaggedItem", + to="taggit.Tag", + verbose_name="Tags", + ), + ), + migrations.AddField( + model_name="series", + name="imdb_id", + field=models.CharField(blank=True, max_length=20, null=True), + ), + migrations.AddField( + model_name="series", + name="imdb_rating", + field=models.FloatField(blank=True, null=True), + ), + migrations.AddField( + model_name="video", + name="cover_image", + field=models.ImageField( + blank=True, null=True, upload_to="videos/series/" + ), + ), + migrations.AddField( + model_name="video", + name="imdb_rating", + field=models.FloatField(blank=True, null=True), + ), + migrations.AddField( + model_name="video", + name="plot", + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/vrobbler/apps/videos/models.py b/vrobbler/apps/videos/models.py index cb0747c..1890cf0 100644 --- a/vrobbler/apps/videos/models.py +++ b/vrobbler/apps/videos/models.py @@ -2,13 +2,18 @@ import logging from typing import Dict from uuid import uuid4 +import requests from django.conf import settings +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 _ from django_extensions.db.models import TimeStampedModel -from scrobbles.utils import convert_to_seconds from scrobbles.mixins import ScrobblableMixin +from scrobbles.utils import convert_to_seconds +from taggit.managers import TaggableManager + +from videos.imdb import lookup_video_from_imdb logger = logging.getLogger(__name__) BNULL = {"blank": True, "null": True} @@ -17,10 +22,12 @@ BNULL = {"blank": True, "null": True} class Series(TimeStampedModel): uuid = models.UUIDField(default=uuid4, editable=False, **BNULL) name = models.CharField(max_length=255) - overview = models.TextField(**BNULL) - tagline = models.TextField(**BNULL) - # tvdb_id = models.CharField(max_length=20, **BNULL) - # imdb_id = models.CharField(max_length=20, **BNULL) + plot = models.TextField(**BNULL) + imdb_id = models.CharField(max_length=20, **BNULL) + imdb_rating = models.FloatField(**BNULL) + cover_image = models.ImageField(upload_to="videos/series/", **BNULL) + + genres = TaggableManager() def __str__(self): return self.name @@ -31,6 +38,22 @@ class Series(TimeStampedModel): class Meta: verbose_name_plural = "series" + def fix_metadata(self, force_update=False): + imdb_dict = lookup_video_from_imdb(self.name, kind="tv series") + logger.info(imdb_dict) + self.imdb_id = imdb_dict.get("movieID") + self.imdb_rating = imdb_dict.get("rating") + self.plot = imdb_dict.get("plot outline") + self.save(update_fields=["imdb_id", "imdb_rating", "plot"]) + + cover_url = imdb_dict.get("cover url") + + if (not self.cover_image or force_update) 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) + class Video(ScrobblableMixin): COMPLETION_PERCENT = getattr(settings, "VIDEO_COMPLETION_PERCENT", 90) @@ -54,9 +77,13 @@ class Video(ScrobblableMixin): tv_series = models.ForeignKey(Series, on_delete=models.DO_NOTHING, **BNULL) season_number = models.IntegerField(**BNULL) episode_number = models.IntegerField(**BNULL) - tvdb_id = models.CharField(max_length=20, **BNULL) imdb_id = models.CharField(max_length=20, **BNULL) + imdb_rating = models.FloatField(**BNULL) + cover_image = models.ImageField(upload_to="videos/video/", **BNULL) tvrage_id = models.CharField(max_length=20, **BNULL) + tvdb_id = models.CharField(max_length=20, **BNULL) + plot = models.TextField(**BNULL) + year = models.IntegerField(**BNULL) class Meta: unique_together = [["title", "imdb_id"]] @@ -87,6 +114,21 @@ class Video(ScrobblableMixin): def link(self): return self.imdb_link + def fix_metadata(self, force_update=False): + imdb_dict = lookup_video_from_imdb(self.imdb_id) + self.imdb_rating = imdb_dict.get("rating") + self.plot = imdb_dict.get("plot outline") + self.year = imdb_dict.get("year") + self.save(update_fields=["imdb_rating", "plot", "year"]) + + cover_url = imdb_dict.get("cover url") + + if (not self.cover_image or force_update) and cover_url: + r = requests.get(cover_url) + if r.status_code == 200: + fname = f"{self.title}_{self.uuid}.jpg" + self.cover_image.save(fname, ContentFile(r.content), save=True) + @classmethod def find_or_create(cls, data_dict: Dict) -> "Video": """Given a data dict from Jellyfin, does the heavy lifting of looking up @@ -94,44 +136,21 @@ class Video(ScrobblableMixin): exist. """ - video_dict = { - "title": data_dict.get("Name", ""), - "imdb_id": data_dict.get("Provider_imdb", None), - "video_type": Video.VideoType.MOVIE, - } + from videos.utils import ( + get_or_create_video, + get_or_create_video_from_jellyfin, + ) - series = None - if data_dict.get("ItemType", "") == "Episode": - series_name = data_dict.get("SeriesName", "") - series, series_created = Series.objects.get_or_create( - name=series_name + if "NotificationType" not in data_dict.keys(): + name_or_id = data_dict.get("imdb_id") or data_dict.get("title") + video = get_or_create_video(name_or_id) + return video + + if not data_dict.get("Provider_imdb"): + title = data_dict.get("Name", "") + logger.warn( + f"No IMDB ID from Jellyfin, check metadata for {title}" ) - video_dict["video_type"] = Video.VideoType.TV_EPISODE + return - video, created = cls.objects.get_or_create(**video_dict) - - run_time_ticks = data_dict.get("RunTimeTicks", None) - if run_time_ticks: - run_time_ticks = run_time_ticks // 10000 - - video_extra_dict = { - "year": data_dict.get("Year", ""), - "overview": data_dict.get("Overview", None), - "tagline": data_dict.get("Tagline", None), - "run_time_ticks": run_time_ticks, - "run_time": convert_to_seconds(data_dict.get("RunTime", "")), - "tvdb_id": data_dict.get("Provider_tvdb", None), - "tvrage_id": data_dict.get("Provider_tvrage", None), - "episode_number": data_dict.get("EpisodeNumber", None), - "season_number": data_dict.get("SeasonNumber", None), - } - - if series: - video_extra_dict["tv_series_id"] = series.id - - if not video.run_time_ticks: - for key, value in video_extra_dict.items(): - setattr(video, key, value) - video.save() - - return video + return get_or_create_video_from_jellyfin(data_dict) diff --git a/vrobbler/apps/videos/utils.py b/vrobbler/apps/videos/utils.py new file mode 100644 index 0000000..e3944f3 --- /dev/null +++ b/vrobbler/apps/videos/utils.py @@ -0,0 +1,95 @@ +import logging + +from videos.imdb import lookup_video_from_imdb +from videos.models import Series, Video +from scrobbles.utils import convert_to_seconds + +logger = logging.getLogger(__name__) + + +def get_or_create_video(name_or_id: str, force_update=False): + imdb_dict = lookup_video_from_imdb(name_or_id) + + if not imdb_dict: + return + + video, video_created = Video.objects.get_or_create( + imdb_id=imdb_dict.get("imdbID"), title=imdb_dict.get("title") + ) + + if video_created or force_update: + video_type = Video.VideoType.MOVIE + series = None + if imdb_dict.get("kind") == "episode": + series_name = imdb_dict.get("episode of").data.get("title") + series, series_created = Series.objects.get_or_create( + name=series_name + ) + video_type = Video.VideoType.TV_EPISODE + if series_created: + series.fix_metadata() + + run_time_seconds = 0 + if imdb_dict.get("runtimes"): + run_time_seconds = int(imdb_dict.get("runtimes")[0]) * 60 + video_dict = { + "video_type": video_type, + "run_time_seconds": run_time_seconds, + "episode_number": imdb_dict.get("episode", None), + "season_number": imdb_dict.get("season", None), + "tv_series_id": series.id if series else None, + } + Video.objects.filter(pk=video.id).update(**video_dict) + video.refresh_from_db() + + video.fix_metadata() + + return video + + +def get_or_create_video_from_jellyfin(jellyfin_data: dict, force_update=True): + """Given a Jellyfin webhook payload as a dictionary, lookup the video or + create a new one. + + """ + + video, video_created = Video.objects.get_or_create( + imdb_id=jellyfin_data.get("Provider_imdb").replace("tt", ""), + title=jellyfin_data.get("Name"), + ) + + if video_created: + video_type = Video.VideoType.MOVIE + series = None + if jellyfin_data.get("ItemType", "") == "Episode": + series_name = jellyfin_data.get("SeriesName", "") + series, series_created = Series.objects.get_or_create( + name=series_name + ) + if series_created: + series.fix_metadata() + video_type = Video.VideoType.TV_EPISODE + + video_dict = { + "video_type": video_type, + "year": jellyfin_data.get("Year", ""), + "overview": jellyfin_data.get("Overview", None), + "tagline": jellyfin_data.get("Tagline", None), + "run_time_seconds": convert_to_seconds( + jellyfin_data.get("RunTime", 0) + ), + "tvdb_id": jellyfin_data.get("Provider_tvdb", None), + "tvrage_id": jellyfin_data.get("Provider_tvrage", None), + "episode_number": jellyfin_data.get("EpisodeNumber", None), + "season_number": jellyfin_data.get("SeasonNumber", None), + } + + if series: + video_dict["tv_series_id"] = series.id + + Video.objects.filter(pk=video.id).update(**video_dict) + video.refresh_from_db() + + video.fix_metadata() + + return video diff --git a/vrobbler/settings.py b/vrobbler/settings.py index eb439e4..1c07f81 100644 --- a/vrobbler/settings.py +++ b/vrobbler/settings.py @@ -33,6 +33,8 @@ DEBUG = os.getenv("VROBBLER_DEBUG", False) TESTING = len(sys.argv) > 1 and sys.argv[1] == "test" +TAGGIT_CASE_INSENSITIVE = True + KEEP_DETAILED_SCROBBLE_LOGS = os.getenv( "VROBBLER_KEEP_DETAILED_SCROBBLE_LOGS", False ) @@ -91,6 +93,7 @@ INSTALLED_APPS = [ "django.contrib.humanize", "django_filters", "django_extensions", + "taggit", "rest_framework.authtoken", "encrypted_field", "profiles",