Compare commits

...

18 Commits
0.16.5 ... 16.8

Author SHA1 Message Date
3f2cbbb34a [videos] Fix TMDB lookup for movies 2025-06-13 12:10:43 -04:00
650ecf12c6 Merge branch 'main' into develop 2025-06-13 11:55:04 -04:00
d7cc009d07 [ci] Make deploys on happen on tags 2025-06-13 11:52:14 -04:00
a872cf3611 [templates] Use TMDB rating ID if available 2025-06-13 11:47:42 -04:00
1f9713312b [videos] Fix lookup for mevie posters 2025-06-13 11:47:04 -04:00
159e555d7c [templates] Remove global counts from home page 2025-06-13 11:33:21 -04:00
981f4f9c9a [videos] Quick fix 2025-06-13 11:23:37 -04:00
ddd5ce1392 Merge branch 'develop' 2025-06-13 11:21:07 -04:00
7a75b31b56 [todos] Update task for metadata fixing 2025-06-13 11:20:34 -04:00
24ac545f55 [videos] Switch to TMDB for scraping videos 2025-06-13 11:19:15 -04:00
d5da8ae701 [todos] Update priority task 2025-06-13 09:43:08 -04:00
53a04d064d [videos] At least stop IMDB from breaking 2025-06-13 09:15:48 -04:00
c0871a3b9e [scrobbles] Fix failing tests 2025-06-12 09:57:32 -04:00
d917dd8b2c [tasks] Fix source checking for emacs/orgmode tasks when updating 2025-06-12 09:54:16 -04:00
6fc8084f2d [scrobbles] Fix log of media type 2025-06-11 12:01:02 -04:00
41d6fe8ff6 [todos] Add new task 2025-06-10 11:27:37 -04:00
73838312cd [tasks] Clean up emacs webhook flow 2025-06-10 11:06:47 -04:00
eb2dd4c839 [todos] Clean up todos and add new milestone 17 2025-06-10 10:05:40 -04:00
14 changed files with 253 additions and 34 deletions

View File

@ -39,8 +39,8 @@ steps:
- vrobbler collectstatic --noinput
- immortalctl restart celery && immortalctl restart vrobbler
when:
branch:
- main
ref:
- refs/tags/*
- name: build success notification
image: parrazam/drone-ntfy:0.3-linux-amd64
when:

49
poetry.lock generated
View File

@ -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"

View File

@ -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

View File

@ -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:

View File

@ -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)

View File

@ -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:

View File

@ -201,7 +201,7 @@ class RecentScrobbleList(ListView):
processed_finished__isnull=True,
user=self.request.user,
)
data["counts"] = scrobble_counts(user)
data["counts"] = [] #scrobble_counts(user)
else:
data["weekly_data"] = week_of_scrobbles()
data["counts"] = scrobble_counts()

View File

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

View File

@ -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

View File

@ -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),
),
]

View File

@ -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:

View File

@ -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

View File

@ -0,0 +1,81 @@
import logging
import pendulum
from django.conf import settings
from themoviedb import TMDb
from tmdbv3api import TV, Movie, TMDb as TMDb_direct
from videos.metadata import VideoMetadata, VideoType
TMDB_KEY = getattr(settings, "TMDB_API_KEY", "")
tmdb_direct = TMDb_direct()
tmdb_direct.api_key = TMDB_KEY
tmdb = TMDb(key=TMDB_KEY, language="en-US", region="US")
TMDB_IMAGE_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)
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 = Movie().details(tmdb_result.movie_results[0].id)
video_metadata.video_type = VideoType.MOVIE.value
video_metadata.title = media.title
video_metadata.cover_url = TMDB_IMAGE_URL + media.poster_path # TODO: enrich this with TMDB url
video_metadata.year = pendulum.parse(media.release_date).year
video_metadata.genres = [g.get("name", "") for g in media.genres]
if len(tmdb_result.tv_episode_results) > 0:
video_metadata.video_type = VideoType.TV_EPISODE.value
media = tmdb_result.tv_episode_results[0]
video_metadata.title = media.name
video_metadata.cover_url = TMDB_IMAGE_URL + media.still_path # TODO: enrich this with TMDB url
video_metadata.episode_number = media.episode_number
video_metadata.season_number = media.season_number
video_metadata.year = media.air_date.year
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.run_time_seconds = media.runtime * 60
video_metadata.plot = media.overview
video_metadata.overview = media.overview
video_metadata.tmdb_rating = media.vote_average
#video_metadata.next_imdb_id = imdb_result.get("next episode", None)
return video_metadata

View File

@ -57,6 +57,7 @@ dd {
<div class="row header">
<div class="cover image-wrapper">
{% if object.imdb_rating %}<div class="caption">{{object.imdb_rating}}</div>{% endif %}
{% if object.tmdb_rating %}<div class="caption">{{object.tmdb_rating}}</div>{% endif %}
<img src="{% if object.cover_image %}{{object.cover_image.url}}{% else %}{% static 'images/no-video-cover.jpg' %}{% endif %}" width="400px" />
<div class="caption-footer">{{object.year}}{% if object.tv_series %} | <b>S</b>{{object.season_number}} <b>E</b>{{object.episode_number}}{% endif %}</div>
</div>