[templates] Try using defensive checks for missing media
All checks were successful
build & deploy / test (push) Successful in 1m46s
build & deploy / deploy (push) Successful in 21s

This commit is contained in:
2026-03-12 14:12:01 -04:00
parent f5675e5319
commit 7cf817025b
7 changed files with 97 additions and 39 deletions

View File

@ -39,9 +39,7 @@ class Producer(TimeStampedModel):
class Podcast(TimeStampedModel): class Podcast(TimeStampedModel):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL) uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
producer = models.ForeignKey( producer = models.ForeignKey(Producer, on_delete=models.DO_NOTHING, **BNULL)
Producer, on_delete=models.DO_NOTHING, **BNULL
)
podcastindex_id = models.CharField(max_length=100, **BNULL) podcastindex_id = models.CharField(max_length=100, **BNULL)
owner = models.CharField(max_length=150, *BNULL) owner = models.CharField(max_length=150, *BNULL)
description = models.TextField(**BNULL) description = models.TextField(**BNULL)
@ -60,6 +58,16 @@ class Podcast(TimeStampedModel):
def get_absolute_url(self): def get_absolute_url(self):
return reverse("podcasts:podcast_detail", kwargs={"slug": self.uuid}) return reverse("podcasts:podcast_detail", kwargs={"slug": self.uuid})
@property
def safe_cover_image_url(self) -> str:
if self.cover_image:
try:
if self.cover_image.storage.exists(self.cover_image.name):
return self.cover_image.url
except Exception:
pass
return "/static/images/not-found.jpg"
def scrobbles(self, user): def scrobbles(self, user):
Scrobble = apps.get_model("scrobbles", "Scrobble") Scrobble = apps.get_model("scrobbles", "Scrobble")
return Scrobble.objects.filter( return Scrobble.objects.filter(
@ -113,9 +121,7 @@ class PodcastEpisode(ScrobblableMixin):
mopidy_uri = models.CharField(max_length=255, **BNULL) mopidy_uri = models.CharField(max_length=255, **BNULL)
def get_absolute_url(self): def get_absolute_url(self):
return reverse( return reverse("podcasts:podcast_detail", kwargs={"slug": self.podcast.uuid})
"podcasts:podcast_detail", kwargs={"slug": self.podcast.uuid}
)
def __str__(self): def __str__(self):
return f"{self.title}" return f"{self.title}"

View File

@ -84,7 +84,11 @@ def mopidy_scrobble_media(post_data: dict, user_id: int) -> Scrobble:
run_time_seconds=post_data.get("run_time", 900000), run_time_seconds=post_data.get("run_time", 900000),
) )
try: try:
album_id = Album.objects.filter(name=post_data.get("album", "")).first().id album_id = (
Album.objects.filter(name=post_data.get("album", ""))
.first()
.id
)
except Exception: except Exception:
pass pass
@ -104,14 +108,17 @@ 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(MOPIDY_POST_KEYS.get("PLAYBACK_POSITION_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"), ""),
log=log, log=log,
) )
def jellyfin_scrobble_media(post_data: dict, user_id: int) -> Optional[Scrobble]: def jellyfin_scrobble_media(
post_data: dict, user_id: int
) -> Optional[Scrobble]:
media_type = Scrobble.MediaType.VIDEO media_type = Scrobble.MediaType.VIDEO
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
@ -129,12 +136,13 @@ def jellyfin_scrobble_media(post_data: dict, user_id: int) -> Optional[Scrobble]
) )
return return
timestamp = parse(post_data.get(JELLYFIN_POST_KEYS.get("TIMESTAMP"), "")).replace( timestamp = parse(
tzinfo=pytz.utc post_data.get(JELLYFIN_POST_KEYS.get("TIMESTAMP"), "")
) ).replace(tzinfo=pytz.utc)
playback_position_seconds = int( playback_position_seconds = int(
post_data.get(JELLYFIN_POST_KEYS.get("PLAYBACK_POSITION_TICKS"), 1) / 10000000 post_data.get(JELLYFIN_POST_KEYS.get("PLAYBACK_POSITION_TICKS"), 1)
/ 10000000
) )
album_id = None album_id = None
if media_type == Scrobble.MediaType.VIDEO: if media_type == Scrobble.MediaType.VIDEO:
@ -145,10 +153,16 @@ def jellyfin_scrobble_media(post_data: dict, user_id: int) -> Optional[Scrobble]
title=post_data.get("Name", ""), title=post_data.get("Name", ""),
artist_name=post_data.get("Artist", ""), artist_name=post_data.get("Artist", ""),
album_name=post_data.get("Album", ""), album_name=post_data.get("Album", ""),
run_time_seconds=convert_to_seconds(post_data.get("RunTime", 900000)), run_time_seconds=convert_to_seconds(
post_data.get("RunTime", 900000)
),
) )
try: try:
album_id = Album.objects.filter(name=post_data.get("Album", "")).first().id album_id = (
Album.objects.filter(name=post_data.get("Album", ""))
.first()
.id
)
except Exception: except Exception:
pass pass
# A hack because we don't worry about updating music ... we either finish it or we don't # A hack because we don't worry about updating music ... we either finish it or we don't
@ -314,7 +328,9 @@ def manual_scrobble_book(
if not page: if not page:
page = 1 page = 1
logger.info("[scrobblers] Book page included in scrobble, should update!") logger.info(
"[scrobblers] Book page included in scrobble, should update!"
)
source = READCOMICSONLINE_URL.replace("https://", "") source = READCOMICSONLINE_URL.replace("https://", "")
@ -431,7 +447,9 @@ def email_scrobble_board_game(
if not location.name: if not location.name:
location.name = location_dict.get("name") location.name = location_dict.get("name")
update_fields.append("name") update_fields.append("name")
geoloc = GeoLocation.objects.filter(title__icontains=location.name).first() geoloc = GeoLocation.objects.filter(
title__icontains=location.name
).first()
if geoloc: if geoloc:
location.geo_location = geoloc location.geo_location = geoloc
update_fields.append("geo_location") update_fields.append("geo_location")
@ -483,7 +501,9 @@ def email_scrobble_board_game(
log_data.pop("expansion_ids") log_data.pop("expansion_ids")
if play_dict.get("locationRefId", False): if play_dict.get("locationRefId", False):
log_data["location_id"] = locations[play_dict.get("locationRefId")].id log_data["location_id"] = locations[
play_dict.get("locationRefId")
].id
if play_dict.get("rounds", False): if play_dict.get("rounds", False):
log_data["rounds"] = play_dict.get("rounds") log_data["rounds"] = play_dict.get("rounds")
if play_dict.get("board", False): if play_dict.get("board", False):
@ -506,7 +526,9 @@ def email_scrobble_board_game(
timestamp = parse(play_dict.get("playDate")) timestamp = parse(play_dict.get("playDate"))
if hour and minute: if hour and minute:
logger.info(f"Scrobble playDate has manual start time {timestamp}") logger.info(f"Scrobble playDate has manual start time {timestamp}")
timestamp = timestamp.replace(hour=hour, minute=minute, second=second or 0) timestamp = timestamp.replace(
hour=hour, minute=minute, second=second or 0
)
logger.info(f"Update to {timestamp}") logger.info(f"Update to {timestamp}")
profile = UserProfile.objects.filter(user_id=user_id).first() profile = UserProfile.objects.filter(user_id=user_id).first()
@ -601,7 +623,9 @@ def manual_scrobble_from_url(
scrobbler. Otherwise, return nothing.""" scrobbler. Otherwise, return nothing."""
if RecipeScraperService.is_recipe_url(url): if RecipeScraperService.is_recipe_url(url):
return scrobble_from_recipe_website(url, user_id, source=source, action=action) return scrobble_from_recipe_website(
url, user_id, source=source, action=action
)
content_key = "" content_key = ""
domain = extract_domain(url) domain = extract_domain(url)
@ -804,7 +828,9 @@ def emacs_scrobble_update_task(
if not scrobble.log.get('notes"'): if not scrobble.log.get('notes"'):
scrobble.log["notes"] = [] scrobble.log["notes"] = []
if note.get("timestamp") not in existing_note_ts: if note.get("timestamp") not in existing_note_ts:
scrobble.log["notes"].append({note.get("timestamp"): note.get("content")}) scrobble.log["notes"].append(
{note.get("timestamp"): note.get("content")}
)
notes_updated = True notes_updated = True
if notes_updated: if notes_updated:
@ -830,7 +856,9 @@ def emacs_scrobble_task(
user_context_list: list[str] = [], user_context_list: list[str] = [],
) -> Scrobble | None: ) -> Scrobble | None:
orgmode_id = task_data.get("source_id") orgmode_id = task_data.get("source_id")
title = get_title_from_labels(task_data.get("labels", []), user_context_list) title = get_title_from_labels(
task_data.get("labels", []), user_context_list
)
task = Task.find_or_create(title) task = Task.find_or_create(title)
@ -953,7 +981,9 @@ def manual_scrobble_webpage(
): ):
if RecipeScraperService.is_recipe_url(url): if RecipeScraperService.is_recipe_url(url):
return scrobble_from_recipe_website(url, user_id, source=source, action=action) return scrobble_from_recipe_website(
url, user_id, source=source, action=action
)
webpage = WebPage.find_or_create({"url": url}) webpage = WebPage.find_or_create({"url": url})

View File

@ -75,6 +75,16 @@ class Channel(TimeStampedModel):
url = self.cover_image_medium.url url = self.cover_image_medium.url
return url return url
@property
def safe_cover_image_url(self) -> str:
if self.cover_image:
try:
if self.cover_image.storage.exists(self.cover_image.name):
return self.cover_medium.url
except Exception:
pass
return "/static/images/not-found.jpg"
def scrobbles_for_user(self, user_id: int, include_playing=False): def scrobbles_for_user(self, user_id: int, include_playing=False):
from scrobbles.models import Scrobble from scrobbles.models import Scrobble
@ -137,6 +147,16 @@ class Series(TimeStampedModel):
url = self.cover_image_medium.url url = self.cover_image_medium.url
return url return url
@property
def safe_cover_image_url(self) -> str:
if self.cover_image:
try:
if self.cover_image.storage.exists(self.cover_image.name):
return self.cover_medium.url
except Exception:
pass
return "/static/images/not-found.jpg"
def save_image_from_url(self, url: str, force_update: bool = False): def save_image_from_url(self, url: str, force_update: bool = False):
if not self.cover_image or (force_update and url): if not self.cover_image or (force_update and url):
r = requests.get(url) r = requests.get(url)
@ -158,17 +178,13 @@ class Series(TimeStampedModel):
def last_scrobbled_episode(self, user_id: int) -> Optional["Video"]: def last_scrobbled_episode(self, user_id: int) -> Optional["Video"]:
episode = None episode = None
last_scrobble = self.scrobbles_for_user( last_scrobble = self.scrobbles_for_user(user_id, include_playing=True).first()
user_id, include_playing=True
).first()
if last_scrobble: if last_scrobble:
episode = last_scrobble.media_obj episode = last_scrobble.media_obj
return episode return episode
def is_episode_playing(self, user_id: int) -> bool: def is_episode_playing(self, user_id: int) -> bool:
last_scrobble = self.scrobbles_for_user( last_scrobble = self.scrobbles_for_user(user_id, include_playing=True).first()
user_id, include_playing=True
).first()
return not last_scrobble.played_to_completion return not last_scrobble.played_to_completion
def fix_metadata(self, force_update=False): def fix_metadata(self, force_update=False):
@ -321,6 +337,16 @@ class Video(ScrobblableMixin):
url = self.cover_image_medium.url url = self.cover_image_medium.url
return url return url
@property
def safe_cover_image_url(self) -> str:
if self.cover_image:
try:
if self.cover_image.storage.exists(self.cover_image.name):
return self.cover_image_medium.url
except Exception:
pass
return "/static/images/not-found.jpg"
@property @property
def strings(self) -> ScrobblableConstants: def strings(self) -> ScrobblableConstants:
return ScrobblableConstants(verb="Watching", tags="movie_camera") return ScrobblableConstants(verb="Watching", tags="movie_camera")
@ -333,9 +359,7 @@ 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 get_from_youtube_id( def get_from_youtube_id(cls, youtube_id: str, overwrite: bool = False) -> "Video":
cls, youtube_id: str, overwrite: bool = False
) -> "Video":
video, created = cls.objects.get_or_create(youtube_id=youtube_id) video, created = cls.objects.get_or_create(youtube_id=youtube_id)
if not created and not overwrite: if not created and not overwrite:
return video return video
@ -355,9 +379,7 @@ class Video(ScrobblableMixin):
return video return video
@classmethod @classmethod
def get_from_imdb_id( def get_from_imdb_id(cls, imdb_id: str, overwrite: bool = False) -> "Video":
cls, imdb_id: str, overwrite: bool = False
) -> "Video":
video, created = cls.objects.get_or_create(imdb_id=imdb_id) video, created = cls.objects.get_or_create(imdb_id=imdb_id)
if not created and not overwrite: if not created and not overwrite:
return video return video

View File

@ -16,7 +16,7 @@
<div class="row header"> <div class="row header">
<div class="cover image-wrapper"> <div class="cover image-wrapper">
<img src="{% if object.cover_image %}{{object.cover_image.url}}{% else %}{% static 'images/no-video-cover.jpg' %}{% endif %}" width="400px" /> <img src="{{ object.safe_cover_image_url }}" width="400px" />
</div> </div>
<div class="summary"> <div class="summary">
{% if object.description %}<p>{{object.description|safe|linebreaks|truncatewords:160}}</p>{% endif %} {% if object.description %}<p>{{object.description|safe|linebreaks|truncatewords:160}}</p>{% endif %}

View File

@ -26,7 +26,7 @@
<div class="row header"> <div class="row header">
<div class="cover image-wrapper"> <div class="cover image-wrapper">
<img src="{% if object.cover_image %}{{object.cover_image.url}}{% else %}{% static 'images/no-video-cover.jpg' %}{% endif %}" width="400px" /> <img src="{{ object.safe_cover_image_url }}" width="400px" />
</div> </div>
<div class="summary"> <div class="summary">
{% if object.youtube_id %}<p><a href="{{object.youtube_url}}" target="_blank">View on YouTube</a></p>{% endif %} {% if object.youtube_id %}<p><a href="{{object.youtube_url}}" target="_blank">View on YouTube</a></p>{% endif %}

View File

@ -27,7 +27,7 @@
<div class="row header"> <div class="row header">
<div class="cover image-wrapper"> <div class="cover image-wrapper">
{% if object.imdb_rating %}<div class="caption">{{object.imdb_rating}}</div>{% endif %} {% if object.imdb_rating %}<div class="caption">{{object.imdb_rating}}</div>{% endif %}
<img src="{% if object.cover_image %}{{object.cover_image.url}}{% else %}{% static 'images/no-video-cover.jpg' %}{% endif %}" width="400px" /> <img src="{{ object.safe_cover_image_url }}" width="400px" />
{% if next_episode_id %} {% if next_episode_id %}
<form id="scrobble-form" action="{% url 'scrobbles:lookup-manual-scrobble' %}" method="post"> <form id="scrobble-form" action="{% url 'scrobbles:lookup-manual-scrobble' %}" method="post">
{% csrf_token %} {% csrf_token %}

View File

@ -58,7 +58,7 @@ dd {
<div class="cover image-wrapper"> <div class="cover image-wrapper">
{% if object.imdb_rating %}<div class="caption">{{object.imdb_rating}}</div>{% endif %} {% if object.imdb_rating %}<div class="caption">{{object.imdb_rating}}</div>{% endif %}
{% if object.tmdb_rating %}<div class="caption">{{object.tmdb_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" /> <img src="{{ object.safe_cover_image_url }}" 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 class="caption-footer">{{object.year}}{% if object.tv_series %} | <b>S</b>{{object.season_number}} <b>E</b>{{object.episode_number}}{% endif %}</div>
</div> </div>
<div class="summary"> <div class="summary">