[videos] Add preliminary Twitch video support

This commit is contained in:
2026-04-02 11:10:39 -04:00
parent 1bf558938d
commit b6af201ba3
10 changed files with 430 additions and 24 deletions

View File

@ -40,7 +40,7 @@ class VideoLogData(BaseLogData, WithPeopleLogData):
rating: Optional[int] = None
class Channel(TimeStampedModel):
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)
@ -60,32 +60,29 @@ class Channel(TimeStampedModel):
twitch_id = models.CharField(max_length=255, **BNULL)
genre = TaggableManager(through=ObjectWithGenres)
class Meta:
verbose_name_plural = "channels"
SECONDS_TO_STALE = 7200 # 2 hours
COMPLETION_PERCENT = 100
def __str__(self) -> str:
return self.name
def get_absolute_url(self):
return reverse("videos:channel_detail", kwargs={"slug": self.uuid})
@property
def run_time_seconds(self) -> int:
return self.base_run_time_seconds or 7200
@property
def youtube_url(self):
return YOUTUBE_CHANNEL_URL + self.youtube_id
def title(self):
return self.name
@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
@ -95,10 +92,51 @@ class Channel(TimeStampedModel):
played_query = models.Q()
return Scrobble.objects.filter(
played_query,
video__channel=self,
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")
@ -415,6 +453,39 @@ class Video(ScrobblableMixin):
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
@ -426,6 +497,8 @@ class Video(ScrobblableMixin):
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")