diff --git a/poetry.lock b/poetry.lock index 8bf5f17..072947f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1065,6 +1065,21 @@ ssh = ["bcrypt (>=3.1.5)"] test = ["certifi", "cryptography-vectors (==43.0.3)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] test-randomorder = ["pytest-randomly"] +[[package]] +name = "dacite" +version = "1.9.2" +description = "Simple creation of data classes from dictionaries." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "dacite-1.9.2-py3-none-any.whl", hash = "sha256:053f7c3f5128ca2e9aceb66892b1a3c8936d02c686e707bee96e19deef4bc4a0"}, + {file = "dacite-1.9.2.tar.gz", hash = "sha256:6ccc3b299727c7aa17582f0021f6ae14d5de47c7227932c47fec4cdfefd26f09"}, +] + +[package.extras] +dev = ["black", "coveralls", "mypy", "pre-commit", "pylint", "pytest (>=5)", "pytest-benchmark", "pytest-cov"] + [[package]] name = "dataclass-wizard" version = "0.22.0" @@ -4610,6 +4625,23 @@ files = [ [package.dependencies] rapidfuzz = ">=3.0.0,<4.0.0" +[[package]] +name = "themoviedb" +version = "1.0.2" +description = "A modern and easy to use API wrapper for The Movie Database (TMDb) API v3 written in Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "themoviedb-1.0.2-py3-none-any.whl", hash = "sha256:badf85e91010c7085509f40270bf2a40ea30ee5ef3ed6fb3ec332c5e50adb576"}, + {file = "themoviedb-1.0.2.tar.gz", hash = "sha256:7835615142a44e7ca25e48645a3a3c5e06b382e8c518c38c3537effa9a2596ce"}, +] + +[package.dependencies] +aiohttp = ">=3.8.4" +dacite = ">=1.8.0" +requests = ">=2.31.0" + [[package]] name = "time-machine" version = "2.16.0" @@ -4710,6 +4742,21 @@ files = [ {file = "tld-0.13.tar.gz", hash = "sha256:93dde5e1c04bdf1844976eae440706379d21f4ab235b73c05d7483e074fb5629"}, ] +[[package]] +name = "tmdbv3api" +version = "1.9.0" +description = "A lightweight Python library for The Movie Database (TMDb) API." +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "tmdbv3api-1.9.0-py3-none-any.whl", hash = "sha256:2bcd8c6e8902397860715a71045f200ecc3ee06804ecf786cb4c1e09b2deeba8"}, + {file = "tmdbv3api-1.9.0.tar.gz", hash = "sha256:504c5da6b99c4516ff160a01576112d097f209c0534f943c15c4b56cbd92c33b"}, +] + +[package.dependencies] +requests = "*" + [[package]] name = "toml" version = "0.10.2" @@ -5452,4 +5499,4 @@ cffi = ["cffi (>=1.11)"] [metadata] lock-version = "2.1" python-versions = ">=3.9,<3.12" -content-hash = "cdd7f577fe3a4c5c8cc960e0070d93b7ddbb2a7968fab63d72bb039afaa05bbe" +content-hash = "3a483aefea0a3afebf187b17b7df72a158788024ca8121b512b39567fb5ec8ca" diff --git a/pyproject.toml b/pyproject.toml index 319b93b..914141a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,8 @@ meta-yt = "^0.1.9" berserk = "^0.13.2" poetry-bumpversion = "^0.3.3" orgparse = "^0.4.20250520" +tmdbv3api = "^1.9.0" +themoviedb = "^1.0.2" [tool.poetry.group.test] optional = true diff --git a/todos.org b/todos.org index 3ba4b4b..7d6b412 100644 --- a/todos.org +++ b/todos.org @@ -1,15 +1,79 @@ #+title: TODOs -A fun way to keep track of things in the project to fix or improve. +* Version 17 +** DONE [#A] Investigate new source of video metadata :personal:project:video:imdb: +:PROPERTIES: +:ID: df2b486c-1170-5199-c312-9bc87760d962 +:END: +Cinemagoer broke and I probably should find a more reilable source of video data. + +- Note taken on [2025-06-13 Fri 11:19] + + TMDB is much more reliable, but does require an API key. That's all setup now, + so hopefully this breaking IMDB crap is over. + +** DONE IMDB video lookups are failing :personal:bug:video:imdb: +:PROPERTIES: +:ID: 38f1081f-37b4-f4f2-79aa-c1e87eca4b69 +:END: +<2025-06-13 Fri> + +- Note taken on [2025-06-13 Fri 08:24] + + Looks like Cinemagoer is broken: https://github.com/cinemagoer/cinemagoer/issues/537 + +** DONE [#A] Emacs is not syncing notes :personal:scrobbling:emacs:bug: +:PROPERTIES: +:ID: c79cd491-b30f-0945-d84b-b8cac7562791 +:END: +<2025-06-12 Thu 9:30> + +Not sure if the problem is in my Emacs hook sending or Vrobbler itself. + +- Note taken on [2025-06-12 Thu 09:47] + + Adding a quick note to check on it + +- Note taken on [2025-06-12 Thu 09:50] + + Ah ha. All the messing about with the source field meant that I was looking + for `emacs` as a source but the hook was initially setting sources to + `orgmode` I think I prefer `orgmode` as the source, so updating it thusly. + + Fixed in `490d60cbbb1f8bf90b5fc47d8685b15bdc1d485b` + +** TODO [#A] Show the description of a task in the string rep for a scrobble of a Task :personal:project:scrobbling:vrobbler:feature: +** TODO [#B] Add AllTrails as a source for Trail data :vrobbler:trails:feature:personal:project: +Pretty clear, I would love to make trails more useful. Historically I wasn't +hiking a lot, which made the source for this a bit silly. But it's clear that +AllTrails is the best source, though having TrailForks is nice to. +** TODO [#B] Add `garmin_activity_id` to the TrailMetadataLog class :vrobbler:trails:feature:personal:project: +Would be nice to have some loose connection to the actual event in my Garmin profile. +** TODO [#B] Explore a way to add metadata editing to scrobbles after saving :vrobbler:spike:scrobbling:personal:project: +Could be as simple as a JSON form on the scrobble detail page (do I have have one of those yet?). +** TODO [#B] Explore a good way to show notes and descriptions from scrobbles to users :personal:project:scrobbling:vrobbler:spike: +** TODO [#C] Replace commas in the bandcamp URL for artists with nothing :vrobbler:music:bug:personal: +:PROPERTIES: +:ID: 9b30d67b-91f0-a480-dfaa-5d9dc090e76c +:END: + +** TODO [#C] Fix bug where podcast scrobbling creates duplicate Podcast :project:vrobbler:scrobbling:podcasts:bug:personal: +Rather than pick up an existing Podcast using the podcast title in the mopidy +file name, Vrobbler creates a new podcast with no enriched data. Not a big deal +for my use as the volume of podcasts I listen to makes manual fixes easy. But +it's annoying. * Version 0.16.0 -** TODO [#A] Jellyfin, bandcamp tracks from Mopidy create duplicate music tracks :bug:scrobbling:music: +** DONE [#A] Jellyfin, bandcamp tracks from Mopidy create duplicate music tracks :bug:scrobbling:music: +:PROPERTIES: +:ID: 670e8634-49b5-dce9-1684-14f2ffb797f1 +:END: Effectively, any track that comes in without a MusicBrainz ID does some funky lookup where it doesn't find a track without an MB id and the track title / -artist combination and creates a new track everytime. This has to be cleaend up +artist combination and creates a new track every time. This has to be cleaned up by condensing the duplicated tracks into the original proper track. -But it opens a bigger quesiton about how much MB id should the drive the app +But it opens a bigger question about how much MB id should the drive the app lookup. If it can't be depended on to exist from all sources, it really can't be canonical. Instead, the combination of track title / artist is really the best we can do. Last.fm also has this problem, where it doesn't know about albums and @@ -481,4 +545,3 @@ has to re-populate when the server restarts. } } #+end_src -** TODO Fix bug in podcast scrobbling where a second scrobble is created after completion :scrobbling:podcasts:bug: diff --git a/vrobbler/apps/scrobbles/mixins.py b/vrobbler/apps/scrobbles/mixins.py index 812e379..4798fe7 100644 --- a/vrobbler/apps/scrobbles/mixins.py +++ b/vrobbler/apps/scrobbles/mixins.py @@ -92,7 +92,6 @@ class ScrobblableMixin(TimeStampedModel): "media_type": self.__class__, "user_id": user_id, "scrobble_data": scrobble_data, - "media_type": Scrobble.MediaType.WEBPAGE, }, ) return Scrobble.create_or_update(self, user_id, scrobble_data) diff --git a/vrobbler/apps/scrobbles/scrobblers.py b/vrobbler/apps/scrobbles/scrobblers.py index 08ed785..ad5bfc1 100644 --- a/vrobbler/apps/scrobbles/scrobblers.py +++ b/vrobbler/apps/scrobbles/scrobblers.py @@ -508,7 +508,7 @@ def emacs_scrobble_update_task( in_progress=True, user_id=user_id, log__source_id=emacs_id, - log__source="emacs", + log__source="orgmode", ).first() if not scrobble: diff --git a/vrobbler/apps/tasks/webhooks.py b/vrobbler/apps/tasks/webhooks.py index 68225fc..538ed4a 100644 --- a/vrobbler/apps/tasks/webhooks.py +++ b/vrobbler/apps/tasks/webhooks.py @@ -149,13 +149,6 @@ def emacs_webhook(request): post_data, user_id, started=task_in_progress, stopped=task_stopped ) - if scrobble and task_in_progress and post_data.get("notes"): - emacs_scrobble_update_task( - post_data.get("source_id"), - post_data.get("notes"), - user_id, - ) - if not scrobble: logger.info( "[emacs_webhook] finished with no note or task found", @@ -166,6 +159,13 @@ def emacs_webhook(request): status=status.HTTP_304_NOT_MODIFIED, ) + if task_in_progress and post_data.get("notes"): + emacs_scrobble_update_task( + post_data.get("source_id"), + post_data.get("notes"), + user_id, + ) + logger.info( "[emacs_webhook] finished", extra={"scrobble_id": scrobble.id}, diff --git a/vrobbler/apps/videos/metadata.py b/vrobbler/apps/videos/metadata.py index 80c3ca0..e836572 100644 --- a/vrobbler/apps/videos/metadata.py +++ b/vrobbler/apps/videos/metadata.py @@ -25,6 +25,7 @@ class VideoMetadata: 60 # Silly default, but things break if this is 0 or null ) imdb_id: Optional[str] + tmdb_id: Optional[str] youtube_id: Optional[str] # IMDB specific @@ -35,6 +36,7 @@ class VideoMetadata: tv_series_id: Optional[int] plot: Optional[str] imdb_rating: Optional[str] + tmdb_rating: Optional[str] cover_url: Optional[str] overview: Optional[str] @@ -59,6 +61,6 @@ class VideoMetadata: video_dict = vars(self) cover = None if "cover_url" in video_dict.keys(): - cover = video_dict.pop("cover_url") - genres = video_dict.pop("genres") + cover = video_dict.pop("cover_url", "") + genres = video_dict.pop("genres", []) return video_dict, cover, genres diff --git a/vrobbler/apps/videos/migrations/0023_video_tmdb_rating.py b/vrobbler/apps/videos/migrations/0023_video_tmdb_rating.py new file mode 100644 index 0000000..fe6bd5f --- /dev/null +++ b/vrobbler/apps/videos/migrations/0023_video_tmdb_rating.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.19 on 2025-06-13 15:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('videos', '0022_alter_video_run_time_seconds'), + ] + + operations = [ + migrations.AddField( + model_name='video', + name='tmdb_rating', + field=models.FloatField(blank=True, null=True), + ), + ] diff --git a/vrobbler/apps/videos/models.py b/vrobbler/apps/videos/models.py index c14295b..bd03697 100644 --- a/vrobbler/apps/videos/models.py +++ b/vrobbler/apps/videos/models.py @@ -2,7 +2,6 @@ import logging from typing import Optional from uuid import uuid4 -import pendulum import requests from django.conf import settings from django.core.files.base import ContentFile @@ -21,8 +20,8 @@ from scrobbles.mixins import ( from taggit.managers import TaggableManager from videos.metadata import VideoMetadata from videos.sources.imdb import lookup_video_from_imdb - -from vrobbler.apps.videos.sources.youtube import lookup_video_from_youtube +from videos.sources.tmdb import lookup_video_from_tmdb +from videos.sources.youtube import lookup_video_from_youtube YOUTUBE_VIDEO_URL = "https://www.youtube.com/watch?v=" YOUTUBE_CHANNEL_URL = "https://www.youtube.com/channel/" @@ -208,6 +207,7 @@ class Video(ScrobblableMixin): next_imdb_id = models.CharField(max_length=20, **BNULL) imdb_id = models.CharField(max_length=20, **BNULL) imdb_rating = models.FloatField(**BNULL) + tmdb_rating = models.FloatField(**BNULL) cover_image = models.ImageField(upload_to="videos/video/", **BNULL) cover_image_small = ImageSpecField( source="cover_image", @@ -309,11 +309,13 @@ class Video(ScrobblableMixin): def get_from_imdb_id( cls, imdb_id: str, overwrite: bool = False ) -> "Video": + if "tt" in imdb_id: + imdb_id = imdb_id[2:] video, created = cls.objects.get_or_create(imdb_id=imdb_id) if not created and not overwrite: return video - vdict, cover, genres = lookup_video_from_imdb( + vdict, cover, genres = lookup_video_from_tmdb( imdb_id ).as_dict_with_cover_and_genres() if created or overwrite: diff --git a/vrobbler/apps/videos/sources/imdb.py b/vrobbler/apps/videos/sources/imdb.py index 0c7ec02..333b401 100644 --- a/vrobbler/apps/videos/sources/imdb.py +++ b/vrobbler/apps/videos/sources/imdb.py @@ -72,26 +72,30 @@ def lookup_video_from_imdb( video_metadata.video_type = VideoType.MOVIE.value series_name = None if imdb_result.get("kind") == "episode": - series_name = imdb_result.get("episode of", None).data.get( - "title", None - ) - series, _ = Series.objects.get_or_create(name=series_name) - video_metadata.video_type = VideoType.TV_EPISODE.value - video_metadata.tv_series_id = series.id + try: + series_name = imdb_result.get("episode of", None).data.get( + "title", None + ) + except IndexError: + series_name = None + if series_name: + series, _ = Series.objects.get_or_create(name=series_name) + video_metadata.video_type = VideoType.TV_EPISODE.value + video_metadata.tv_series_id = series.id if imdb_result.get("runtimes"): video_metadata.run_time_seconds = ( int(imdb_result.get("runtimes")[0]) * 60 ) + video_metadata.imdb_id = name_or_id video_metadata.title = imdb_result.get("title", "") - video_metadata.imdb_id = imdb_result.get("imdbID") video_metadata.episode_number = imdb_result.get("episode", None) video_metadata.season_number = imdb_result.get("season", None) video_metadata.next_imdb_id = imdb_result.get("next episode", None) video_metadata.year = imdb_result.get("year", None) - video_metadata.plot = imdb_result.get("plot outline") - video_metadata.imdb_rating = imdb_result.get("rating") - video_metadata.genres = imdb_result.get("genres") + video_metadata.plot = imdb_result.get("plot outline", "") + video_metadata.imdb_rating = imdb_result.get("rating", None) + video_metadata.genres = imdb_result.get("genres", []) return video_metadata diff --git a/vrobbler/apps/videos/sources/tmdb.py b/vrobbler/apps/videos/sources/tmdb.py new file mode 100644 index 0000000..8bbb389 --- /dev/null +++ b/vrobbler/apps/videos/sources/tmdb.py @@ -0,0 +1,77 @@ +import logging + +from django.conf import settings +from themoviedb import TMDb +from tmdbv3api import TV, TMDb as TMDb_direct +from videos.metadata import VideoMetadata, VideoType + +key = getattr(settings, "TMDB_API_KEY", "33de8d24785931068ae356510dcfbac8") +key = "33de8d24785931068ae356510dcfbac8" + +tmdb_direct = TMDb_direct() +tmdb_direct.api_key = "33de8d24785931068ae356510dcfbac8" + +tmdb = TMDb(key=key, language="en-US", region="US") + +TMDB_STILL_URL = "https://image.tmdb.org/t/p/original" + +logger = logging.getLogger(__name__) + + +def lookup_video_from_tmdb( + name_or_id: str, kind: str = "movie" +) -> VideoMetadata: + from videos.models import Series + + imdb_id = name_or_id + if name_or_id.startswith("tt"): + imdb_id = name_or_id[2:] + + video_metadata = VideoMetadata(imdb_id=imdb_id) + imdb_result: dict = {} + + tmdb_result = tmdb.find().by_imdb("tt" + imdb_id) + + if not tmdb_result: + logger.info( + "[lookup_video_from_tmdb] no video found on tmdb", + extra={"name_or_id": name_or_id}, + ) + return None + + video_metadata = VideoMetadata(imdb_id=imdb_id) + + media = None + show = None + if len(tmdb_result.movie_results) > 0: + media = tmdb_result.movie_results[0] + video_metadata.video_type = VideoType.MOVIE.value + if len(tmdb_result.tv_episode_results) > 0: + video_metadata.video_type = VideoType.TV_EPISODE.value + media = tmdb_result.tv_episode_results[0] + series_imdb_id = TV().external_ids(media.show_id).imdb_id[2:] + + series, created = Series.objects.get_or_create(imdb_id=series_imdb_id) + if created: + show_data = TV().details(tv_id=media.show_id) + series.name = show_data.name + series.save() + video_metadata.tv_series_id = series.id + + if not media: + logger.warning("Video not found on TMDB", extra={"imdb_id":imdb_id}) + return video_metadata + + video_metadata.tmdb_id = media.id + video_metadata.cover_url = TMDB_STILL_URL + media.still_path # TODO: enrich this with TMDB url + video_metadata.run_time_seconds = media.runtime * 60 + video_metadata.title = media.name + video_metadata.episode_number = media.episode_number + video_metadata.season_number = media.season_number + #video_metadata.next_imdb_id = imdb_result.get("next episode", None) + video_metadata.year = media.air_date.year + video_metadata.plot = media.overview + video_metadata.imdb_rating = media.vote_average + #video_metadata.genres = imdb_result.get("genres", []) + + return video_metadata