[videos] Add Twitch video scrobbling
All checks were successful
build & deploy / test (push) Successful in 1m51s
build & deploy / deploy (push) Successful in 26s

This commit is contained in:
2026-04-01 12:09:05 -04:00
parent 9ce6dd8876
commit ce7128c7ac
4 changed files with 128 additions and 3 deletions

View File

@ -1,7 +1,8 @@
import logging
from enum import Enum from enum import Enum
from typing import Optional from typing import Optional
from cinemagoerng import web as imdb from cinemagoerng import web as imdb
import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -15,6 +16,7 @@ class VideoType(Enum):
MOVIE = "M" MOVIE = "M"
SKATE_VIDEO = "S" SKATE_VIDEO = "S"
YOUTUBE = "Y" YOUTUBE = "Y"
TWITCH = "T"
@classmethod @classmethod
def as_choices(cls) -> tuple: def as_choices(cls) -> tuple:
@ -40,6 +42,7 @@ class VideoMetadata:
imdb_id: Optional[str] imdb_id: Optional[str]
tmdb_id: Optional[str] tmdb_id: Optional[str]
youtube_id: Optional[str] youtube_id: Optional[str]
twitch_id: Optional[str]
# IMDB specific # IMDB specific
episode_number: Optional[str] episode_number: Optional[str]
@ -64,10 +67,12 @@ class VideoMetadata:
self, self,
imdb_id: Optional[str] = "", imdb_id: Optional[str] = "",
youtube_id: Optional[str] = "", youtube_id: Optional[str] = "",
twitch_id: Optional[str] = "",
base_run_time_seconds: int = 900, base_run_time_seconds: int = 900,
): ):
self.imdb_id = imdb_id self.imdb_id = imdb_id
self.youtube_id = youtube_id self.youtube_id = youtube_id
self.twitch_id = twitch_id
self.base_run_time_seconds = base_run_time_seconds self.base_run_time_seconds = base_run_time_seconds
def enrich_from_imdb(self): def enrich_from_imdb(self):

View File

@ -0,0 +1,39 @@
# Generated by Django 4.2.29 on 2026-04-01 16:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("videos", "0027_video_tags"),
]
operations = [
migrations.AddField(
model_name="channel",
name="twitch_id",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name="video",
name="twitch_id",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name="video",
name="video_type",
field=models.CharField(
choices=[
("U", "Unknown"),
("E", "TV Episode"),
("M", "Movie"),
("S", "Skate Video"),
("Y", "YouTube Video"),
("T", "Twitch Video"),
],
default="U",
max_length=1,
),
),
]

View File

@ -1,6 +1,6 @@
from dataclasses import dataclass
import re
import logging import logging
import re
from dataclasses import dataclass
from typing import Optional from typing import Optional
from uuid import uuid4 from uuid import uuid4
@ -23,6 +23,7 @@ from videos.metadata import VideoMetadata
from videos.sources.imdb import lookup_video_from_imdb from videos.sources.imdb import lookup_video_from_imdb
from videos.sources.tmdb import lookup_video_from_tmdb from videos.sources.tmdb import lookup_video_from_tmdb
from videos.sources.youtube import lookup_video_from_youtube from videos.sources.youtube import lookup_video_from_youtube
from vrobbler.apps.scrobbles.dataclasses import BaseLogData, WithPeopleLogData from vrobbler.apps.scrobbles.dataclasses import BaseLogData, WithPeopleLogData
YOUTUBE_VIDEO_URL = "https://www.youtube.com/watch?v=" YOUTUBE_VIDEO_URL = "https://www.youtube.com/watch?v="
@ -56,6 +57,7 @@ class Channel(TimeStampedModel):
options={"quality": 75}, options={"quality": 75},
) )
youtube_id = models.CharField(max_length=255, **BNULL) youtube_id = models.CharField(max_length=255, **BNULL)
twitch_id = models.CharField(max_length=255, **BNULL)
genre = TaggableManager(through=ObjectWithGenres) genre = TaggableManager(through=ObjectWithGenres)
def __str__(self) -> str: def __str__(self) -> str:
@ -245,6 +247,7 @@ class Video(ScrobblableMixin):
MOVIE = "M", _("Movie") MOVIE = "M", _("Movie")
SKATE_VIDEO = "S", _("Skate Video") SKATE_VIDEO = "S", _("Skate Video")
YOUTUBE = "Y", _("YouTube Video") YOUTUBE = "Y", _("YouTube Video")
TWITCH = "T", _("Twitch Video")
video_type = models.CharField( video_type = models.CharField(
max_length=1, max_length=1,
@ -281,6 +284,7 @@ class Video(ScrobblableMixin):
tvdb_id = models.CharField(max_length=20, **BNULL) tvdb_id = models.CharField(max_length=20, **BNULL)
tmdb_id = models.CharField(max_length=20, **BNULL) tmdb_id = models.CharField(max_length=20, **BNULL)
youtube_id = models.CharField(max_length=255, **BNULL) youtube_id = models.CharField(max_length=255, **BNULL)
twitch_id = models.CharField(max_length=255, **BNULL)
plot = models.TextField(**BNULL) plot = models.TextField(**BNULL)
upload_date = models.DateField(**BNULL) upload_date = models.DateField(**BNULL)

View File

@ -0,0 +1,77 @@
import logging
import pendulum
import requests
from bs4 import BeautifulSoup
from videos.metadata import VideoMetadata, VideoType
logger = logging.getLogger(__name__)
TWITCH_VIDEO_URL = "https://www.twitch.tv/videos/{video_id}"
def lookup_video_from_twitch(twitch_video_id: str) -> VideoMetadata:
video_metadata = VideoMetadata(twitch_id=twitch_video_id)
url = TWITCH_VIDEO_URL.format(video_id=twitch_video_id)
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
}
try:
response = requests.get(url, headers=headers, timeout=10)
except requests.RequestException as e:
logger.warning(f"Failed to fetch Twitch page: {e}")
return video_metadata
if response.status_code != 200:
logger.warning(f"Twitch page returned status {response.status_code}")
return video_metadata
soup = BeautifulSoup(response.text, "html.parser")
title_tag = soup.find("meta", property="og:title")
if title_tag:
video_metadata.title = title_tag.get("content", "")
description_tag = soup.find("meta", property="og:description")
if description_tag:
video_metadata.overview = description_tag.get("content", "")
image_tag = soup.find("meta", property="og:image")
if image_tag:
video_metadata.cover_url = image_tag.get("content", "")
duration_tag = soup.find("meta", property="og:video:duration")
if duration_tag:
video_metadata.base_run_time_seconds = int(duration_tag.get("content", 900))
release_date_tag = soup.find("meta", property="og:video:release_date")
if release_date_tag:
try:
video_metadata.upload_date = pendulum.parse(
release_date_tag.get("content")
).date()
video_metadata.year = video_metadata.upload_date.year
except Exception:
pass
video_metadata.twitch_id = twitch_video_id
video_metadata.video_type = VideoType.TWITCH.value
title = video_metadata.title
if title and " - " in title:
channel_name = title.split(" - ")[-1].replace(" on Twitch", "").strip()
if channel_name:
try:
from videos.models import Channel
channel, _ = Channel.objects.get_or_create(
name=channel_name,
defaults={"name": channel_name},
)
video_metadata.channel_id = channel.id
except Exception as e:
logger.warning(f"Failed to get or create channel: {e}")
return video_metadata