From 535dead7e80c123d430b472da666423fe24bae9d Mon Sep 17 00:00:00 2001 From: Colin Powell Date: Tue, 16 Jun 2026 13:49:32 -0400 Subject: [PATCH] [videos] Clean up channel metadata --- PROJECT.org | 24 +++++- .../commands/cleanup_channel_metadata.py | 82 +++++++++++++++++++ vrobbler/apps/videos/models.py | 61 ++++++++++++-- 3 files changed, 158 insertions(+), 9 deletions(-) create mode 100644 vrobbler/apps/videos/management/commands/cleanup_channel_metadata.py diff --git a/PROJECT.org b/PROJECT.org index 35ff293..6b9c021 100644 --- a/PROJECT.org +++ b/PROJECT.org @@ -88,7 +88,7 @@ fetching and simple saving. *** Metadata sources **** Scraper -* Backlog [1/20] :vrobbler:project:personal: +* Backlog [2/22] :vrobbler:project:personal: ** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles: :PROPERTIES: :ID: 702462cf-d54b-48c6-8a7c-78b8de751deb @@ -579,6 +579,28 @@ named constants for maintainability. - ~vrobbler/apps/scrobbles/importers/tsv.py~ (line 55) -- ="S"= completion status +** TODO [#C] Clean up naming of =bgsplay= parsing :importers:refactoring: +:PROPERTIES: +:ID: c751dbbc-464a-4e63-9fe3-e034303f7b54 +:END: + +*** Description + +We should rename `email_scrobble_board_game` to reflect the fact that it's just +a helper method to create board game scrobbles given a json blob. It's +independent of the email flow it was originally creatdd for + +** DONE [#A] Implement YouTube channel info scraping :videos:youtube:stub: +:PROPERTIES: +:ID: 1d3beafd-62cb-4735-a465-edb37bf885db +:END: + +*** Description +File: ~vrobbler/apps/videos/models.py~ (line 140) + +=Video.fix_metadata()= is a stub that logs "Not implemented yet" and returns. +Needs actual implementation to scrape channel metadata from YouTube. + ** DONE [#A] Fix Amazon book scraper :amazon:scraper:broken: :PROPERTIES: :ID: c38aba25-0171-49ab-a9f3-acf2003da429 diff --git a/vrobbler/apps/videos/management/commands/cleanup_channel_metadata.py b/vrobbler/apps/videos/management/commands/cleanup_channel_metadata.py new file mode 100644 index 0000000..bcf4668 --- /dev/null +++ b/vrobbler/apps/videos/management/commands/cleanup_channel_metadata.py @@ -0,0 +1,82 @@ +import logging + +from django.core.management.base import BaseCommand +from django.db import models, transaction + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Enrich YouTube and Twitch channel metadata from upstream APIs" + + def add_arguments(self, parser): + parser.add_argument( + "--force", + action="store_true", + help="Overwrite existing channel name and cover image", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would be done without making changes", + ) + parser.add_argument( + "--youtube-only", + action="store_true", + help="Only process channels with a youtube_id", + ) + parser.add_argument( + "--twitch-only", + action="store_true", + help="Only process channels with a twitch_id", + ) + + def handle(self, *args, **options): + from videos.models import Channel + + force = options["force"] + dry_run = options["dry_run"] + youtube_only = options["youtube_only"] + twitch_only = options["twitch_only"] + + qs = Channel.objects.all() + + if youtube_only: + qs = qs.exclude(youtube_id__isnull=True).exclude(youtube_id="") + elif twitch_only: + qs = qs.exclude(twitch_id__isnull=True).exclude(twitch_id="") + else: + qs = qs.filter( + models.Q(youtube_id__isnull=False) | models.Q(twitch_id__isnull=False) + ).exclude(youtube_id="", twitch_id="") + + total = qs.count() + self.stdout.write(f"Processing {total} channels") + + if dry_run: + for channel in qs.iterator(): + source = "youtube" if channel.youtube_id else "twitch" + identifier = channel.youtube_id or channel.twitch_id + self.stdout.write( + f" [DRY RUN] Would fix {channel.name} ({source}: {identifier})" + ) + return + + updated = 0 + errors = 0 + for channel in qs.iterator(): + try: + with transaction.atomic(): + channel.fix_metadata(force=force) + updated += 1 + source = "youtube" if channel.youtube_id else "twitch" + self.stdout.write(f" [{updated}/{total}] {channel.name} ({source})") + except Exception as e: + errors += 1 + self.stdout.write( + self.style.ERROR(f" Error updating channel {channel.name}: {e}") + ) + + self.stdout.write( + self.style.SUCCESS(f"\nDone! {updated} channels updated, {errors} errors") + ) diff --git a/vrobbler/apps/videos/models.py b/vrobbler/apps/videos/models.py index 7d40fe6..e6b3de6 100644 --- a/vrobbler/apps/videos/models.py +++ b/vrobbler/apps/videos/models.py @@ -1,3 +1,4 @@ +import json import logging import re from dataclasses import dataclass @@ -138,8 +139,56 @@ class Channel(ScrobblableMixin): ) def fix_metadata(self, force: bool = False): - # TODO Scrape channel info from Youtube - logger.warning("Not implemented yet") + if self.youtube_id: + GOOGLE_CHANNELS_URL = "https://www.googleapis.com/youtube/v3/channels?part=snippet&id={channel_id}&key={key}" + url = GOOGLE_CHANNELS_URL.format( + channel_id=self.youtube_id, + key=settings.GOOGLE_API_KEY, + ) + headers = {"User-Agent": "Vrobbler 0.11.12"} + response = requests.get(url, headers=headers) + + if response.status_code != 200: + logger.warning( + "Bad response from Google for channel", + extra={"response": response}, + ) + return + + items = json.loads(response.content).get("items", []) + if not items: + logger.warning(f"No YouTube channel data for {self.youtube_id}") + return + + snippet = items[0].get("snippet", {}) + channel_name = snippet.get("title", "") + if channel_name and (not self.name or force): + self.name = channel_name + + thumbnails = snippet.get("thumbnails", {}) + cover_url = ( + thumbnails.get("high", {}).get("url") + or thumbnails.get("medium", {}).get("url") + or thumbnails.get("default", {}).get("url") + ) + if cover_url: + self.save_image_from_url(cover_url, force_update=force) + + self.save() + return + + if self.twitch_id: + from videos.sources.twitch import lookup_channel_from_twitch + + metadata = lookup_channel_from_twitch(self.twitch_id) + if metadata.name and (not self.name or force): + self.name = metadata.name + if metadata.profile_image_url: + self.save_image_from_url(metadata.profile_image_url, force_update=force) + self.save() + return + + logger.warning(f"No youtube_id or twitch_id set for channel {self}") return @@ -452,9 +501,7 @@ class Video(ScrobblableMixin): if metadata.channel_id: from videos.models import Channel - self.channel = Channel.objects.filter( - id=metadata.channel_id - ).first() + self.channel = Channel.objects.filter(id=metadata.channel_id).first() self.save() @@ -476,9 +523,7 @@ class Video(ScrobblableMixin): 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() - ) + vdict, series_id, cover, genres = metadata.as_dict_with_cover_and_genres() for k, v in vdict.items(): setattr(self, k, v)