Files
vrobbler/vrobbler/apps/videos/models.py

608 lines
20 KiB
Python

import logging
import re
from dataclasses import dataclass
from typing import Optional
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 imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit
from scrobbles.mixins import (
ObjectWithGenres,
ScrobblableConstants,
ScrobblableMixin,
)
from taggit.managers import TaggableManager
from videos.metadata import VideoMetadata
from videos.sources.omdb import lookup_video_from_omdb
from videos.sources.tmdb import lookup_video_from_tmdb
from videos.sources.youtube import lookup_video_from_youtube
from vrobbler.apps.scrobbles.dataclasses import BaseLogData, WithPeopleLogData
YOUTUBE_VIDEO_URL = "https://www.youtube.com/watch?v="
YOUTUBE_CHANNEL_URL = "https://www.youtube.com/channel/"
YOUTUBE_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{11}$")
IMDB_VIDEO_URL = "https://www.imdb.com/title/"
logger = logging.getLogger(__name__)
BNULL = {"blank": True, "null": True}
@dataclass
class VideoLogData(BaseLogData, WithPeopleLogData):
rating: Optional[int] = None
class Channel(ScrobblableMixin):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
name = models.CharField(max_length=255)
cover_image = models.ImageField(upload_to="videos/channels/", **BNULL)
cover_small = ImageSpecField(
source="cover_image",
processors=[ResizeToFit(100, 100)],
format="JPEG",
options={"quality": 60},
)
cover_medium = ImageSpecField(
source="cover_image",
processors=[ResizeToFit(300, 300)],
format="JPEG",
options={"quality": 75},
)
youtube_id = models.CharField(max_length=255, **BNULL)
twitch_id = models.CharField(max_length=255, **BNULL)
genre = TaggableManager(through=ObjectWithGenres, blank=True, verbose_name="Genre")
class Meta:
verbose_name_plural = "channels"
SECONDS_TO_STALE = 7200 # 2 hours
COMPLETION_PERCENT = 100
def __str__(self) -> str:
return self.name
@property
def run_time_seconds(self) -> int:
return self.base_run_time_seconds or 7200
@property
def title(self):
return self.name
def save_image_from_url(self, url: str, force_update: bool = False):
if not self.cover_image or (force_update and url):
r = requests.get(url)
if r.status_code == 200:
fname = f"{self.name}_{self.uuid}.jpg"
self.cover_image.save(fname, ContentFile(r.content), save=True)
def scrobbles_for_user(self, user_id: int, include_playing=False):
from scrobbles.models import Scrobble
played_query = models.Q(played_to_completion=True)
if include_playing:
played_query = models.Q()
return Scrobble.objects.filter(
played_query,
channel=self,
user=user_id,
).order_by("-timestamp")
def get_absolute_url(self):
return reverse("videos:channel_detail", kwargs={"slug": self.uuid})
@classmethod
def find_or_create(cls, twitch_handle: str, overwrite: bool = False):
channel, created = cls.objects.get_or_create(twitch_id=twitch_handle)
if not created and not overwrite:
return channel
try:
from videos.sources.twitch import lookup_channel_from_twitch
metadata = lookup_channel_from_twitch(twitch_handle)
if metadata.name:
channel.name = metadata.name
if metadata.profile_image_url:
channel.save_image_from_url(metadata.profile_image_url)
channel.save()
except Exception as e:
logger.warning(f"Failed to get channel metadata: {e}")
return channel
def scrobble_for_user(
self,
user_id: int,
status: str = "started",
source: str = "Twitch",
**kwargs,
):
from scrobbles.models import Scrobble
return Scrobble.create_or_update(
media_object=self,
user_id=user_id,
status=status,
source=source,
**kwargs,
)
def fix_metadata(self, force: bool = False):
# TODO Scrape channel info from Youtube
logger.warning("Not implemented yet")
return
class Series(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
name = models.CharField(max_length=255)
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)
cover_small = ImageSpecField(
source="cover_image",
processors=[ResizeToFit(100, 100)],
format="JPEG",
options={"quality": 60},
)
cover_medium = ImageSpecField(
source="cover_image",
processors=[ResizeToFit(300, 300)],
format="JPEG",
options={"quality": 75},
)
preferred_source = models.CharField(max_length=100, **BNULL)
genre = TaggableManager(through=ObjectWithGenres, blank=True, verbose_name="Genre")
class Meta:
verbose_name_plural = "series"
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse("videos:series_detail", kwargs={"slug": self.uuid})
def imdb_link(self) -> str:
if self.imdb_id:
return IMDB_VIDEO_URL + self.imdb_id
return ""
@property
def primary_image_url(self) -> str:
url = ""
if self.cover_image:
url = self.cover_image_medium.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):
if not self.cover_image or (force_update and url):
r = requests.get(url)
if r.status_code == 200:
fname = f"{self.name}_{self.uuid}.jpg"
self.cover_image.save(fname, ContentFile(r.content), save=True)
def scrobbles_for_user(self, user_id: int, include_playing=False):
from scrobbles.models import Scrobble
played_query = models.Q(played_to_completion=True)
if include_playing:
played_query = models.Q()
return Scrobble.objects.filter(
played_query,
video__tv_series=self,
user=user_id,
).order_by("-timestamp")
def last_scrobbled_episode(self, user_id: int) -> Optional["Video"]:
episode = None
last_scrobble = self.scrobbles_for_user(user_id, include_playing=True).first()
if last_scrobble:
episode = last_scrobble.media_obj
return episode
def is_episode_playing(self, user_id: int) -> bool:
last_scrobble = self.scrobbles_for_user(user_id, include_playing=True).first()
if not last_scrobble:
return False
return not last_scrobble.played_to_completion
def fix_metadata(self, force_update=False):
name_or_id = self.name
if self.imdb_id:
name_or_id = self.imdb_id
video_metadata: VideoMetadata = lookup_video_from_tmdb(name_or_id)
if not video_metadata.title:
logger.warning(f"No imdb data for {self}")
return
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)
if genres := imdb_dict.get("genres"):
self.genre.add(*genres)
@classmethod
def find_or_create(cls, imdb_id: str, overwrite: bool = True):
series, created = cls.objects.get_or_create(imdb_id=imdb_id)
if not created and not overwrite:
logger.info("Series not created and overwrite=False, returning")
return series
try:
metadata = lookup_video_from_tmdb(imdb_id)
except Exception as e:
logger.warning(f"TMDB lookup failed for series {imdb_id}: {e}")
metadata = None
if not metadata or not metadata.title:
metadata = lookup_video_from_omdb(imdb_id)
if not metadata or not metadata.title:
logger.warning(f"No metadata found for series {imdb_id} from TMDB or OMDB")
return series
vdict, _, cover, genres = metadata.as_dict_with_cover_and_genres()
vdict.pop("video_type")
vdict["name"] = vdict.pop("title")
for k, v in vdict.items():
setattr(series, k, v)
series.save()
if cover:
series.save_image_from_url(cover)
if genres:
series.genre.add(*genres)
return series
class Video(ScrobblableMixin):
COMPLETION_PERCENT = getattr(settings, "VIDEO_COMPLETION_PERCENT", 90)
SECONDS_TO_STALE = getattr(settings, "VIDEO_SECONDS_TO_STALE", 14400)
METADATA_CLASS = VideoMetadata
class VideoType(models.TextChoices):
UNKNOWN = "U", _("Unknown")
TV_EPISODE = "E", _("TV Episode")
MOVIE = "M", _("Movie")
SKATE_VIDEO = "S", _("Skate Video")
YOUTUBE = "Y", _("YouTube Video")
TWITCH = "T", _("Twitch Video")
video_type = models.CharField(
max_length=1,
choices=VideoType.choices,
default=VideoType.UNKNOWN,
)
overview = models.TextField(**BNULL)
tagline = models.TextField(**BNULL)
year = models.IntegerField(**BNULL)
# TV show specific fields
tv_series = models.ForeignKey(Series, on_delete=models.DO_NOTHING, **BNULL)
channel = models.ForeignKey(Channel, on_delete=models.DO_NOTHING, **BNULL)
season_number = models.IntegerField(**BNULL)
episode_number = models.IntegerField(**BNULL)
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",
processors=[ResizeToFit(100, 100)],
format="JPEG",
options={"quality": 60},
)
cover_image_medium = ImageSpecField(
source="cover_image",
processors=[ResizeToFit(300, 300)],
format="JPEG",
options={"quality": 75},
)
tvrage_id = models.CharField(max_length=20, **BNULL)
tvdb_id = models.CharField(max_length=20, **BNULL)
tmdb_id = models.CharField(max_length=20, **BNULL)
youtube_id = models.CharField(max_length=255, **BNULL)
twitch_id = models.CharField(max_length=255, **BNULL)
plot = models.TextField(**BNULL)
upload_date = models.DateField(**BNULL)
def __str__(self):
if not self.title:
return self.youtube_id or self.imdb_id
if self.video_type == self.VideoType.TV_EPISODE:
return f"{self.title} / [S{self.season_number}E{self.episode_number}] {self.tv_series}"
if self.video_type == self.VideoType.YOUTUBE:
return f"{self.title} / {self.channel}"
return self.title
class Meta:
constraints = [
models.UniqueConstraint(
fields=["imdb_id", "youtube_id"],
name="unique_imdb_or_youtube_id",
condition=~models.Q(imdb_id__isnull=True)
| ~models.Q(youtube_id__isnull=True),
),
]
def get_absolute_url(self):
return reverse("videos:video_detail", kwargs={"slug": self.uuid})
@property
def logdata_cls(self):
return VideoLogData
@property
def subtitle(self):
if self.tv_series:
return self.tv_series
return ""
@property
def imdb_link(self):
prefix = ""
if "tt" not in self.imdb_id:
prefix = "tt"
return f"https://www.imdb.com/title/{prefix}{self.imdb_id}"
@property
def info_link(self):
return self.imdb_link
@property
def link(self) -> str:
return self.imdb_link
@property
def youtube_link(self) -> str:
if self.youtube_id:
return f"https://www.youtube.com/watch?v={self.youtube_id}"
return ""
@property
def primary_image_url(self) -> str:
url = ""
if self.cover_image:
url = self.cover_image_medium.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
def strings(self) -> ScrobblableConstants:
return ScrobblableConstants(verb="Watching", tags="movie_camera")
def save_image_from_url(self, url: str, force_update: bool = False):
if not self.cover_image or (force_update and url):
r = requests.get(url)
if r.status_code == 200:
fname = f"{self.title}_{self.uuid}.jpg"
self.cover_image.save(fname, ContentFile(r.content), save=True)
def fix_metadata(self, force_update: bool = False) -> None:
if self.video_type == self.VideoType.YOUTUBE and self.youtube_id:
vdict, _, cover, genres = lookup_video_from_youtube(
self.youtube_id
).as_dict_with_cover_and_genres()
for k, v in vdict.items():
setattr(self, k, v)
self.save()
if cover:
self.save_image_from_url(cover)
if genres:
self.genre.add(*genres)
return
if self.video_type == self.VideoType.TWITCH and self.twitch_id:
from videos.sources.twitch import lookup_video_from_twitch
metadata = lookup_video_from_twitch(self.twitch_id)
self.title = metadata.title or f"Twitch VOD {self.twitch_id}"
self.overview = metadata.overview
self.base_run_time_seconds = metadata.base_run_time_seconds
if metadata.upload_date:
self.upload_date = metadata.upload_date
if metadata.year:
self.year = metadata.year
self.video_type = Video.VideoType.TWITCH
if metadata.channel_id:
from videos.models import Channel
self.channel = Channel.objects.filter(
id=metadata.channel_id
).first()
self.save()
if metadata.cover_url:
self.save_image_from_url(metadata.cover_url)
return
if self.imdb_id:
try:
metadata = lookup_video_from_tmdb(self.imdb_id)
except Exception as e:
logger.warning(f"TMDB lookup failed for {self.imdb_id}: {e}")
metadata = None
if not metadata or not metadata.title:
metadata = lookup_video_from_omdb(self.imdb_id)
if not metadata or not metadata.title:
logger.warning(f"No metadata found for {self} from TMDB or OMDB")
return
vdict, series_id, cover, genres = (
metadata.as_dict_with_cover_and_genres()
)
for k, v in vdict.items():
setattr(self, k, v)
if series_id:
self.tv_series = Series.find_or_create(imdb_id=series_id)
self.save()
if cover:
self.save_image_from_url(cover)
if genres:
self.genre.add(*genres)
return
logger.warning(
f"No metadata source available for {self} (type={self.video_type})"
)
@classmethod
def get_from_youtube_id(cls, youtube_id: str, overwrite: bool = False) -> "Video":
video, created = cls.objects.get_or_create(youtube_id=youtube_id)
if not created and not overwrite:
return video
vdict, _, cover, genres = lookup_video_from_youtube(
youtube_id
).as_dict_with_cover_and_genres()
if created or overwrite:
for k, v in vdict.items():
setattr(video, k, v)
video.save()
if cover:
video.save_image_from_url(cover)
if genres:
video.genre.add(*genres)
return video
@classmethod
def get_from_imdb_id(cls, imdb_id: str, overwrite: bool = False) -> "Video":
video, created = cls.objects.get_or_create(imdb_id=imdb_id)
if not created and not overwrite:
return video
try:
metadata = lookup_video_from_tmdb(imdb_id)
except Exception as e:
logger.warning(f"TMDB lookup failed for {imdb_id}: {e}")
metadata = None
if not metadata or not metadata.title:
metadata = lookup_video_from_omdb(imdb_id)
if not metadata or not metadata.title:
logger.warning(f"No metadata found for {imdb_id} from TMDB or OMDB")
return video
vdict, series_id, cover, genres = metadata.as_dict_with_cover_and_genres()
if created or overwrite:
for k, v in vdict.items():
setattr(video, k, v)
if series_id:
video.tv_series = Series.find_or_create(imdb_id=series_id)
video.save()
if cover:
video.save_image_from_url(cover)
if genres:
video.genre.add(*genres)
return video
@classmethod
def get_from_twitch_id(cls, twitch_id: str, overwrite: bool = False) -> "Video":
video, created = cls.objects.get_or_create(twitch_id=twitch_id)
if not created and not overwrite:
return video
from videos.sources.twitch import lookup_video_from_twitch
metadata = lookup_video_from_twitch(twitch_id)
if created or overwrite:
video.title = metadata.title or f"Twitch VOD {twitch_id}"
video.overview = metadata.overview
video.cover_url = metadata.cover_url
video.base_run_time_seconds = metadata.base_run_time_seconds
if metadata.upload_date:
video.upload_date = metadata.upload_date
if metadata.year:
video.year = metadata.year
video.video_type = Video.VideoType.TWITCH
if metadata.channel_id:
from videos.models import Channel
video.channel = Channel.objects.filter(id=metadata.channel_id).first()
video.save()
if metadata.cover_url:
video.save_image_from_url(metadata.cover_url)
return video
@classmethod
def find_or_create(
cls, source_id: Optional[str], overwrite: bool = False
) -> Optional["Video"]:
if not source_id:
logger.warning("No source_id provided for Video.find_or_create")
return None
if "tt" in source_id:
return cls.get_from_imdb_id(source_id, overwrite)
if bool(YOUTUBE_ID_PATTERN.match(source_id)):
return cls.get_from_youtube_id(source_id, overwrite)
if source_id.isdigit():
return cls.get_from_twitch_id(source_id, overwrite)
logger.warning("Video ID not recognized, not scrobbling")
return None