Browse Source

Merge branch 'develop'

Colin Powell 2 tháng trước cách đây
mục cha
commit
ddd5ce1392

+ 48 - 1
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"

+ 2 - 0
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

+ 68 - 5
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:

+ 0 - 1
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)

+ 1 - 1
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:

+ 7 - 7
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},

+ 4 - 2
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

+ 18 - 0
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),
+        ),
+    ]

+ 6 - 4
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:

+ 14 - 10
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

+ 77 - 0
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