[videos] Add preliminary Twitch video support
This commit is contained in:
@ -46,6 +46,7 @@ SCROBBLE_CONTENT_URLS = {
|
|||||||
"-p": ["https://www.ipdb.plus/IPDb/puzzle.php?id="],
|
"-p": ["https://www.ipdb.plus/IPDb/puzzle.php?id="],
|
||||||
"-l": ["https://brickset.com/sets/"],
|
"-l": ["https://brickset.com/sets/"],
|
||||||
"-c": ["https://readcomicsonline.ru"],
|
"-c": ["https://readcomicsonline.ru"],
|
||||||
|
"-h": ["https://www.twitch.tv/"],
|
||||||
}
|
}
|
||||||
|
|
||||||
EXCLUDE_FROM_NOW_PLAYING = ("GeoLocation",)
|
EXCLUDE_FROM_NOW_PLAYING = ("GeoLocation",)
|
||||||
@ -63,6 +64,7 @@ MANUAL_SCROBBLE_FNS = {
|
|||||||
"-l": "manual_scrobble_brickset",
|
"-l": "manual_scrobble_brickset",
|
||||||
"-c": "manual_scrobble_book",
|
"-c": "manual_scrobble_book",
|
||||||
"-f": "manual_scrobble_food",
|
"-f": "manual_scrobble_food",
|
||||||
|
"-h": "manual_scrobble_twitch_channel",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,11 @@
|
|||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Check recent Twitch channel scrobbles for matching VODs"
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
from scrobbles.tasks import check_twitch_channels_for_vods
|
||||||
|
|
||||||
|
check_twitch_channels_for_vods()
|
||||||
|
self.stdout.write(self.style.SUCCESS("Done"))
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
# Generated by Django 4.2.29 on 2026-04-01 16:18
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("videos", "0028_channel_twitch_id_video_twitch_id_and_more"),
|
||||||
|
("scrobbles", "0074_add_tags_to_scrobble"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="scrobble",
|
||||||
|
name="channel",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||||
|
to="videos.channel",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="scrobble",
|
||||||
|
name="media_type",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("Video", "Video"),
|
||||||
|
("Track", "Track"),
|
||||||
|
("PodcastEpisode", "Podcast episode"),
|
||||||
|
("SportEvent", "Sport event"),
|
||||||
|
("Book", "Book"),
|
||||||
|
("Paper", "Paper"),
|
||||||
|
("VideoGame", "Video game"),
|
||||||
|
("BoardGame", "Board game"),
|
||||||
|
("GeoLocation", "GeoLocation"),
|
||||||
|
("Trail", "Trail"),
|
||||||
|
("Beer", "Beer"),
|
||||||
|
("Puzzle", "Puzzle"),
|
||||||
|
("Food", "Food"),
|
||||||
|
("Task", "Task"),
|
||||||
|
("WebPage", "Web Page"),
|
||||||
|
("LifeEvent", "Life event"),
|
||||||
|
("Mood", "Mood"),
|
||||||
|
("BrickSet", "Brick set"),
|
||||||
|
("Channel", "Channel"),
|
||||||
|
],
|
||||||
|
default="Video",
|
||||||
|
max_length=14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -397,6 +397,7 @@ class Scrobble(TimeStampedModel):
|
|||||||
LIFE_EVENT = "LifeEvent", "Life event"
|
LIFE_EVENT = "LifeEvent", "Life event"
|
||||||
MOOD = "Mood", "Mood"
|
MOOD = "Mood", "Mood"
|
||||||
BRICKSET = "BrickSet", "Brick set"
|
BRICKSET = "BrickSet", "Brick set"
|
||||||
|
CHANNEL = "Channel", "Channel"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def list(cls):
|
def list(cls):
|
||||||
@ -404,6 +405,7 @@ class Scrobble(TimeStampedModel):
|
|||||||
|
|
||||||
uuid = models.UUIDField(editable=False, **BNULL)
|
uuid = models.UUIDField(editable=False, **BNULL)
|
||||||
video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL)
|
video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL)
|
||||||
|
channel = models.ForeignKey("videos.Channel", on_delete=models.DO_NOTHING, **BNULL)
|
||||||
track = models.ForeignKey(Track, on_delete=models.DO_NOTHING, **BNULL)
|
track = models.ForeignKey(Track, on_delete=models.DO_NOTHING, **BNULL)
|
||||||
podcast_episode = models.ForeignKey(
|
podcast_episode = models.ForeignKey(
|
||||||
PodcastEpisode, on_delete=models.DO_NOTHING, **BNULL
|
PodcastEpisode, on_delete=models.DO_NOTHING, **BNULL
|
||||||
@ -961,6 +963,8 @@ class Scrobble(TimeStampedModel):
|
|||||||
media_obj = self.task
|
media_obj = self.task
|
||||||
if self.food:
|
if self.food:
|
||||||
media_obj = self.food
|
media_obj = self.food
|
||||||
|
if self.channel:
|
||||||
|
media_obj = self.channel
|
||||||
return media_obj
|
return media_obj
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
|||||||
@ -35,8 +35,8 @@ from scrobbles.notifications import ScrobbleNtfyNotification
|
|||||||
from scrobbles.utils import (
|
from scrobbles.utils import (
|
||||||
convert_to_seconds,
|
convert_to_seconds,
|
||||||
extract_domain,
|
extract_domain,
|
||||||
remove_last_part,
|
|
||||||
next_url_if_exists,
|
next_url_if_exists,
|
||||||
|
remove_last_part,
|
||||||
)
|
)
|
||||||
from sports.models import SportEvent
|
from sports.models import SportEvent
|
||||||
from sports.thesportsdb import lookup_event_from_thesportsdb
|
from sports.thesportsdb import lookup_event_from_thesportsdb
|
||||||
@ -188,6 +188,15 @@ def web_scrobbler_scrobble_media(
|
|||||||
return video.scrobble_for_user(user_id, status, source="Web Scrobbler")
|
return video.scrobble_for_user(user_id, status, source="Web Scrobbler")
|
||||||
|
|
||||||
|
|
||||||
|
def twitch_scrobble_channel(
|
||||||
|
twitch_handle: str, user_id: int, status: str = "started"
|
||||||
|
) -> Optional[Scrobble]:
|
||||||
|
from videos.models import Channel
|
||||||
|
|
||||||
|
channel = Channel.find_or_create(twitch_handle)
|
||||||
|
return channel.scrobble_for_user(user_id, status, source="Twitch")
|
||||||
|
|
||||||
|
|
||||||
def manual_scrobble_video(
|
def manual_scrobble_video(
|
||||||
video_id: str,
|
video_id: str,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
@ -629,6 +638,8 @@ def manual_scrobble_from_url(
|
|||||||
item_id = url
|
item_id = url
|
||||||
elif content_key == "-i" and "title/tt" in url:
|
elif content_key == "-i" and "title/tt" in url:
|
||||||
item_id = "tt" + str(item_id)
|
item_id = "tt" + str(item_id)
|
||||||
|
elif content_key == "-h" and "twitch.tv" in url:
|
||||||
|
item_id = url
|
||||||
|
|
||||||
scrobble_fn = MANUAL_SCROBBLE_FNS[content_key]
|
scrobble_fn = MANUAL_SCROBBLE_FNS[content_key]
|
||||||
return eval(scrobble_fn)(item_id, user_id, source=source, action=action)
|
return eval(scrobble_fn)(item_id, user_id, source=source, action=action)
|
||||||
@ -984,6 +995,44 @@ def manual_scrobble_webpage(
|
|||||||
return scrobble
|
return scrobble
|
||||||
|
|
||||||
|
|
||||||
|
def manual_scrobble_twitch_channel(
|
||||||
|
url: str,
|
||||||
|
user_id: int,
|
||||||
|
source: str = "Manual",
|
||||||
|
action: Optional[str] = None,
|
||||||
|
):
|
||||||
|
from videos.models import Channel
|
||||||
|
|
||||||
|
handle = (
|
||||||
|
url.replace("https://www.twitch.tv/", "")
|
||||||
|
.replace("https://twitch.tv/", "")
|
||||||
|
.rstrip("/")
|
||||||
|
)
|
||||||
|
|
||||||
|
channel = Channel.find_or_create(handle)
|
||||||
|
|
||||||
|
scrobble_dict = {
|
||||||
|
"user_id": user_id,
|
||||||
|
"timestamp": timezone.now(),
|
||||||
|
"playback_position_seconds": 0,
|
||||||
|
"source": source,
|
||||||
|
}
|
||||||
|
logger.info(
|
||||||
|
"[vrobbler-scrobble] twitch channel scrobble request received",
|
||||||
|
extra={
|
||||||
|
"channel_id": channel.id,
|
||||||
|
"handle": handle,
|
||||||
|
"user_id": user_id,
|
||||||
|
"scrobble_dict": scrobble_dict,
|
||||||
|
"media_type": Scrobble.MediaType.CHANNEL,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
scrobble = Scrobble.create_or_update(channel, user_id, scrobble_dict)
|
||||||
|
|
||||||
|
return scrobble
|
||||||
|
|
||||||
|
|
||||||
def gpslogger_scrobble_location(data_dict: dict, user_id: int) -> Scrobble:
|
def gpslogger_scrobble_location(data_dict: dict, user_id: int) -> Scrobble:
|
||||||
location = GeoLocation.find_or_create(data_dict)
|
location = GeoLocation.find_or_create(data_dict)
|
||||||
|
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
from charts.utils import (
|
from charts.utils import (
|
||||||
build_charts_since,
|
build_charts_since,
|
||||||
build_daily_charts,
|
build_daily_charts,
|
||||||
build_yearly_charts,
|
|
||||||
build_monthly_charts,
|
build_monthly_charts,
|
||||||
build_weekly_charts,
|
build_weekly_charts,
|
||||||
|
build_yearly_charts,
|
||||||
)
|
)
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
@ -29,6 +30,68 @@ MEDIA_TYPES = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def check_twitch_channels_for_vods():
|
||||||
|
"""Check recent Twitch channel scrobbles for matching VODs."""
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
|
cutoff = timezone.now() - timedelta(hours=48)
|
||||||
|
|
||||||
|
channel_scrobbles = Scrobble.objects.filter(
|
||||||
|
media_type=Scrobble.MediaType.CHANNEL,
|
||||||
|
timestamp__gte=cutoff,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"[twitch_vods] Checking {channel_scrobbles.count()} channel scrobbles")
|
||||||
|
|
||||||
|
matched_count = 0
|
||||||
|
|
||||||
|
for scrobble in channel_scrobbles:
|
||||||
|
if not scrobble.channel or not scrobble.channel.twitch_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
from videos.sources.twitch import get_channel_vods
|
||||||
|
|
||||||
|
vods = get_channel_vods(scrobble.channel.twitch_id)
|
||||||
|
scrobble_time = scrobble.timestamp
|
||||||
|
|
||||||
|
for vod in vods:
|
||||||
|
if not vod.get("published_at"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
from videos.sources import twitch as twitch_source
|
||||||
|
|
||||||
|
vod_time = twitch_source.parse_twitch_datetime(vod["published_at"])
|
||||||
|
|
||||||
|
if not vod_time:
|
||||||
|
continue
|
||||||
|
|
||||||
|
time_diff = (vod_time - scrobble_time).total_seconds()
|
||||||
|
|
||||||
|
if 0 < time_diff <= 86400:
|
||||||
|
from videos.models import Video
|
||||||
|
|
||||||
|
video = Video.get_from_twitch_id(vod["id"], overwrite=True)
|
||||||
|
if video:
|
||||||
|
video.scrobble_for_user(
|
||||||
|
scrobble.user_id,
|
||||||
|
status="stopped",
|
||||||
|
source="Twitch VOD",
|
||||||
|
)
|
||||||
|
matched_count += 1
|
||||||
|
logger.info(
|
||||||
|
f"[twitch_vods] Matched VOD {vod['id']} for channel scrobble {scrobble.id}"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"[twitch_vods] Error processing scrobble {scrobble.id}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"[twitch_vods] Matched {matched_count} VODs")
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def process_retroarch_import(import_id):
|
def process_retroarch_import(import_id):
|
||||||
RetroarchImport = apps.get_model("scrobbles", "RetroarchImport")
|
RetroarchImport = apps.get_model("scrobbles", "RetroarchImport")
|
||||||
|
|||||||
@ -59,6 +59,9 @@ class VideoMetadata:
|
|||||||
# YouTube specific
|
# YouTube specific
|
||||||
channel_id: Optional[int]
|
channel_id: Optional[int]
|
||||||
|
|
||||||
|
# Twitch specific
|
||||||
|
upload_date: Optional[str]
|
||||||
|
|
||||||
# General
|
# General
|
||||||
cover_url: Optional[str]
|
cover_url: Optional[str]
|
||||||
genres: list[str]
|
genres: list[str]
|
||||||
|
|||||||
@ -0,0 +1,35 @@
|
|||||||
|
# Generated by Django 4.2.29 on 2026-04-01 16:24
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import taggit.managers
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("taggit", "0004_alter_taggeditem_content_type_alter_taggeditem_tag"),
|
||||||
|
("videos", "0028_channel_twitch_id_video_twitch_id_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="channel",
|
||||||
|
options={"verbose_name_plural": "channels"},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="channel",
|
||||||
|
name="base_run_time_seconds",
|
||||||
|
field=models.IntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="channel",
|
||||||
|
name="tags",
|
||||||
|
field=taggit.managers.TaggableManager(
|
||||||
|
blank=True,
|
||||||
|
help_text="A comma-separated list of tags.",
|
||||||
|
through="taggit.TaggedItem",
|
||||||
|
to="taggit.Tag",
|
||||||
|
verbose_name="Tags",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -40,7 +40,7 @@ class VideoLogData(BaseLogData, WithPeopleLogData):
|
|||||||
rating: Optional[int] = None
|
rating: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class Channel(TimeStampedModel):
|
class Channel(ScrobblableMixin):
|
||||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
cover_image = models.ImageField(upload_to="videos/channels/", **BNULL)
|
cover_image = models.ImageField(upload_to="videos/channels/", **BNULL)
|
||||||
@ -60,32 +60,29 @@ class Channel(TimeStampedModel):
|
|||||||
twitch_id = models.CharField(max_length=255, **BNULL)
|
twitch_id = models.CharField(max_length=255, **BNULL)
|
||||||
genre = TaggableManager(through=ObjectWithGenres)
|
genre = TaggableManager(through=ObjectWithGenres)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name_plural = "channels"
|
||||||
|
|
||||||
|
SECONDS_TO_STALE = 7200 # 2 hours
|
||||||
|
COMPLETION_PERCENT = 100
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
def get_absolute_url(self):
|
@property
|
||||||
return reverse("videos:channel_detail", kwargs={"slug": self.uuid})
|
def run_time_seconds(self) -> int:
|
||||||
|
return self.base_run_time_seconds or 7200
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def youtube_url(self):
|
def title(self):
|
||||||
return YOUTUBE_CHANNEL_URL + self.youtube_id
|
return self.name
|
||||||
|
|
||||||
@property
|
def save_image_from_url(self, url: str, force_update: bool = False):
|
||||||
def primary_image_url(self) -> str:
|
if not self.cover_image or (force_update and url):
|
||||||
url = ""
|
r = requests.get(url)
|
||||||
if self.cover_image:
|
if r.status_code == 200:
|
||||||
url = self.cover_image_medium.url
|
fname = f"{self.name}_{self.uuid}.jpg"
|
||||||
return url
|
self.cover_image.save(fname, ContentFile(r.content), save=True)
|
||||||
|
|
||||||
@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
|
||||||
@ -95,10 +92,51 @@ class Channel(TimeStampedModel):
|
|||||||
played_query = models.Q()
|
played_query = models.Q()
|
||||||
return Scrobble.objects.filter(
|
return Scrobble.objects.filter(
|
||||||
played_query,
|
played_query,
|
||||||
video__channel=self,
|
channel=self,
|
||||||
user=user_id,
|
user=user_id,
|
||||||
).order_by("-timestamp")
|
).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):
|
def fix_metadata(self, force: bool = False):
|
||||||
# TODO Scrape channel info from Youtube
|
# TODO Scrape channel info from Youtube
|
||||||
logger.warning("Not implemented yet")
|
logger.warning("Not implemented yet")
|
||||||
@ -415,6 +453,39 @@ class Video(ScrobblableMixin):
|
|||||||
|
|
||||||
return video
|
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
|
@classmethod
|
||||||
def find_or_create(
|
def find_or_create(
|
||||||
cls, source_id: Optional[str], overwrite: bool = False
|
cls, source_id: Optional[str], overwrite: bool = False
|
||||||
@ -426,6 +497,8 @@ class Video(ScrobblableMixin):
|
|||||||
return cls.get_from_imdb_id(source_id, overwrite)
|
return cls.get_from_imdb_id(source_id, overwrite)
|
||||||
if bool(YOUTUBE_ID_PATTERN.match(source_id)):
|
if bool(YOUTUBE_ID_PATTERN.match(source_id)):
|
||||||
return cls.get_from_youtube_id(source_id, overwrite)
|
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")
|
logger.warning("Video ID not recognized, not scrobbling")
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
import pendulum
|
import pendulum
|
||||||
import requests
|
import requests
|
||||||
@ -8,6 +9,117 @@ from videos.metadata import VideoMetadata, VideoType
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
TWITCH_VIDEO_URL = "https://www.twitch.tv/videos/{video_id}"
|
TWITCH_VIDEO_URL = "https://www.twitch.tv/videos/{video_id}"
|
||||||
|
TWITCH_CHANNEL_URL = "https://www.twitch.tv/{handle}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ChannelMetadata:
|
||||||
|
name: str = ""
|
||||||
|
display_name: str = ""
|
||||||
|
profile_image_url: str = ""
|
||||||
|
bio: str = ""
|
||||||
|
twitch_id: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_channel_from_twitch(handle: str) -> ChannelMetadata:
|
||||||
|
channel_metadata = ChannelMetadata(twitch_id=handle)
|
||||||
|
|
||||||
|
url = TWITCH_CHANNEL_URL.format(handle=handle)
|
||||||
|
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 channel page: {e}")
|
||||||
|
return channel_metadata
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.warning(f"Twitch channel page returned status {response.status_code}")
|
||||||
|
return channel_metadata
|
||||||
|
|
||||||
|
soup = BeautifulSoup(response.text, "html.parser")
|
||||||
|
|
||||||
|
title_tag = soup.find("meta", property="og:title")
|
||||||
|
if title_tag:
|
||||||
|
content = title_tag.get("content", "")
|
||||||
|
if " on Twitch" in content:
|
||||||
|
channel_metadata.display_name = content.replace(" on Twitch", "").strip()
|
||||||
|
else:
|
||||||
|
channel_metadata.display_name = content
|
||||||
|
channel_metadata.name = channel_metadata.display_name.replace(
|
||||||
|
" - Twitch", ""
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
description_tag = soup.find("meta", property="og:description")
|
||||||
|
if description_tag:
|
||||||
|
channel_metadata.bio = description_tag.get("content", "")
|
||||||
|
|
||||||
|
image_tag = soup.find("meta", property="og:image")
|
||||||
|
if image_tag:
|
||||||
|
channel_metadata.profile_image_url = image_tag.get("content", "")
|
||||||
|
|
||||||
|
return channel_metadata
|
||||||
|
|
||||||
|
|
||||||
|
def get_channel_vods(handle: str) -> list:
|
||||||
|
"""Get recent VODs for a channel by scraping the channel's videos page."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
url = f"https://www.twitch.tv/{handle}/videos"
|
||||||
|
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 channel videos page: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.warning(f"Twitch videos page returned status {response.status_code}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
page_text = response.text
|
||||||
|
vods = []
|
||||||
|
|
||||||
|
video_pattern = r'"url":\s*"https://www\.twitch\.tv/videos/(\d+)"'
|
||||||
|
url_matches = re.findall(video_pattern, page_text)
|
||||||
|
|
||||||
|
upload_pattern = r'"uploadDate":\s*"([^"]+)"'
|
||||||
|
date_matches = re.findall(upload_pattern, page_text)
|
||||||
|
|
||||||
|
name_pattern = r'"name":\s*"([^"]+)"'
|
||||||
|
name_matches = re.findall(name_pattern, page_text)
|
||||||
|
|
||||||
|
for i, video_id in enumerate(url_matches):
|
||||||
|
title = name_matches[i] if i < len(name_matches) else ""
|
||||||
|
published_at = date_matches[i] if i < len(date_matches) else ""
|
||||||
|
|
||||||
|
vods.append(
|
||||||
|
{
|
||||||
|
"id": video_id,
|
||||||
|
"title": title,
|
||||||
|
"published_at": published_at,
|
||||||
|
"thumbnail": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"[get_channel_vods] Found {len(vods)} VODs for {handle}")
|
||||||
|
print(vods)
|
||||||
|
return vods
|
||||||
|
|
||||||
|
|
||||||
|
def parse_twitch_datetime(date_str: str):
|
||||||
|
"""Parse Twitch datetime string to timezone-aware datetime."""
|
||||||
|
if not date_str:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return pendulum.parse(date_str)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def lookup_video_from_twitch(twitch_video_id: str) -> VideoMetadata:
|
def lookup_video_from_twitch(twitch_video_id: str) -> VideoMetadata:
|
||||||
|
|||||||
Reference in New Issue
Block a user