The "Zero-Effort" Twitch-to-Youtube Pipeline Part 2 Dark _ □ ✕
  • Posts
  • About
  • Debug
Post

The "Zero-Effort" Twitch-to-Youtube Pipeline Part 2

Published
2026-09-01 05:38 UTC (precision: second)
Author
Cheshire
Source feed
Byte Vortex [exclude this blog]
Link
https://www.bytevortex.tech/the-zero-effort-twitch-to-youtube-pipeline-part-2/
Canonical
https://www.bytevortex.tech/the-zero-effort-twitch-to-youtube-pipeline-part-2
Length
33609 runes
Extracted text
I won't do a rehash of the previous article that I wrote back in February of 2026. It was a very lengthy process and since its birth, I've updated some things. If you want to go read that, please do that now before continuing further in this post or you will be lost as hell. With a lot of projects, testing, updating, re-testing, and improvements are bound to happen. This project is no different and 6 months later, it's time for an update post. What did I add: A new way to keep the token regeneration without the need for human intervention Handling long stream names that prevented the uploading to YouTube Getting clips Working with "special" clips such as Ko-fi and Patreon interactions Stopping the Need for Human Interaction - Token Edition Quick edit to the original script. This was mostly due to finding the actual code to handle the auto-request for this, as well as just being lazy: if creds.expired and creds.refresh_token: creds.refresh(Request()) with open(TOKEN_FILE, 'w') as f: f.write(creds.to_json()) Handling Long Stream Names for Youtube Upload Over the past 6 months, there would be times I ran into a VOD getting all the way through the process until the uploading part. No error was given, no exception thrown, nothing. The script would finish its run and close the window. Not until a moderator told me that my past few streams were not showing up on YouTube did I realize that it was failing. The next stream, I sat at my desk and literally watched the entire thing run, trying to nail down the source of failure. googleapiclient.errors.ResumableUploadError: <HttpError 400 when requesting None returned "The request metadata specifies an invalid or empty video title.". Details: "[{'message': 'The request metadata specifies an invalid or empty video title.', 'domain': 'youtube.video', 'reason': 'invalidTitle', 'location': 'body.snippet.title', 'locationType': 'other'}]" This made me think that there was some weird issue with the title being grabbed from the VOD. Yet, the terminal output right above this error showed the title being just fine: Uploading: [10-07-2026] [ARCHIVE] LAST STREAM B4 TENNOCON! Hex Tasks, Nokko Bounties, & Starting the Memoria Grind Clearly the title wasn't empty and I was starting to get a bit frustrated with this situation because I just couldn't figure it out. Turns out, it was nothing to do with my script. YouTube has a hard cap of 100 characters. I use the format of [DD-MM-YYYY] [ARCHIVE] in my prefix uploads. That's 23 characters long. So if I have a stream title that gets kind of lengthy, it fails to upload. Anything that landed within the ~77-100 character range was in the danger zone. Now that I found the issue, the fix: date_str = datetime.now().strftime("%d-%m-%Y") formatted_title = f"[{date_str}] [ARCHIVE] {stream_title}" if len(formatted_title) > 100: formatted_title = formatted_title[:97] + "..." print(f"Title: {formatted_title}") This essentially gives my longer stream titles a truncated name on YouTube. Clip Capturing and Sharing As I had mentioned in the previous article, I wanted to handle clip capturing with Streamer.bot. I didn't share much of anything of that entire set up because it was still a work-in-progress. Well, I've got it figured out and it is working like a charm. For my set-up, I wanted multiple ways for a clip to get captured: a physical button, a chat command, and an automatic pipeline that turns these clips into YouTube shorts. I'm one of those people that if one way doesn't work or I forget to do it, there's another route. I've essentially redone my entire process, so I've updated the files below. This update needs two additional setup pieces beyond the Google/YouTube credentials from Part 1. New installs: add requests and google-auth to what you already installed: requests google-auth Registering a Twitch app: head to the Twitch Developer Console, click Register Your Application, give it a name, set the OAuth Redirect URL to http://localhost, and pick Category: Application Integration. Once created, click Manage to grab your Client ID, then generate a Client Secret. These go into a new file, twitch_secret.json: { "client_id": "YOUR_TWITCH_CLIENT_ID_HERE", "client_secret": "YOUR_TWITCH_CLIENT_SECRET_HERE" } One more field for clip creation: creating clips via the Twitch API needs a user access token with clips:edit scope – the app credentials above aren't enough alone. I generate mine at twitchtokengenerator.com, then add it as a user_token field in the same file: { "client_id": "YOUR_TWITCH_CLIENT_ID_HERE", "client_secret": "YOUR_TWITCH_CLIENT_SECRET_HERE", "user_token": "YOUR_USER_ACCESS_TOKEN_HERE" } Place twitch_secret.json in the same folder as everything else. Now, onto the archiver.py. archiver.py import sys import os import time import json import subprocess import threading import webbrowser import requests from http.server import HTTPServer, BaseHTTPRequestHandler from datetime import datetime, timezone from urllib.parse import parse_qs from yt_dlp import YoutubeDL from googleapiclient.discovery import build from googleapiclient.http import MediaFileUpload from google.oauth2.credentials import Credentials from google.auth.transport.requests import Request # ============================================================================= # SETTINGS — edit these as needed # ============================================================================= BASE_DIR = os.path.dirname(os.path.abspath(__file__)) TOKEN_FILE = os.path.join(BASE_DIR, 'token.json') YT_SECRET_FILE = os.path.join(BASE_DIR, 'client_secret.json') TW_SECRET_FILE = os.path.join(BASE_DIR, 'twitch_secret.json') SUPPORTER_LOG = os.path.join(BASE_DIR, 'supporter_clips.json') TWITCH_USERNAME = "YOUR TWITCH USERNAME HERE, NOT A BOT YOUR OWN USERNAME" TWITCH_VOD_URL = f"https://www.twitch.tv/{TWITCH_USERNAME}/videos?filter=archives" SCOPES = ['https://www.googleapis.com/auth/youtube.upload'] # FFmpeg crop settings for Shorts (update if your stream layout changes) FACECAM_CROP = "480:360:1440:0" # w:h:x:y — top-right facecam GAMEPLAY_CROP = "607:1080:656:0" # w:h:x:y — centre gameplay FONT_FILE = os.path.join(BASE_DIR, 'WHATEVER FONT YOU WANT TO PUT HERE.ttf') # Split-stream detection window SPLIT_WINDOW_HOURS = 6 # Clip review server port REVIEW_SERVER_PORT = 9876 # ============================================================================= # TWITCH API # ============================================================================= def load_twitch_credentials(): if not os.path.exists(TW_SECRET_FILE): print(f"CRITICAL: twitch_secret.json not found in {BASE_DIR}") sys.exit(1) with open(TW_SECRET_FILE) as f: data = json.load(f) return data['client_id'], data['client_secret'] def get_twitch_token(client_id, client_secret): resp = requests.post('https://id.twitch.tv/oauth2/token', params={ 'client_id': client_id, 'client_secret': client_secret, 'grant_type': 'client_credentials' }) resp.raise_for_status() return resp.json()['access_token'] def get_twitch_user_id(client_id, token): resp = requests.get('https://api.twitch.tv/helix/users', params={'login': TWITCH_USERNAME}, headers={'Client-ID': client_id, 'Authorization': f'Bearer {token}'} ) resp.raise_for_status() return resp.json()['data'][0]['id'] def fetch_clips_since(client_id, token, user_id, started_at): """Fetches all clips created after started_at (ISO 8601 string).""" clips = [] cursor = None while True: params = {'broadcaster_id': user_id, 'started_at': started_at, 'first': 20} if cursor: params['after'] = cursor resp = requests.get('https://api.twitch.tv/helix/clips', params=params, headers={'Client-ID': client_id, 'Authorization': f'Bearer {token}'} ) resp.raise_for_status() data = resp.json() clips.extend(data.get('data', [])) cursor = data.get('pagination', {}).get('cursor') if not cursor or not data.get('data'): break return clips def create_twitch_clip(client_id, token, broadcaster_id): """ Creates a clip of the current live stream via the Twitch API. Returns the clip ID if successful, None otherwise. Note: requires a user access token with clips:edit scope, not just app token. We use the OAuth token from twitch_secret.json if a user_token field exists, otherwise we fall back to app token (may not work for clip creation). """ # Load user token if available with open(TW_SECRET_FILE) as f: tw_data = json.load(f) user_token = tw_data.get('user_token', token) resp = requests.post('https://api.twitch.tv/helix/clips', params={'broadcaster_id': broadcaster_id}, headers={'Client-ID': client_id, 'Authorization': f'Bearer {user_token}'} ) if resp.status_code == 202: return resp.json()['data'][0]['id'] else: print(f" Clip creation failed: {resp.status_code} — {resp.text}") return None # ============================================================================= # SUPPORTER CLIP LOG # Tracks clips created by supporter donations so the review page can badge them # ============================================================================= def load_supporter_log(): if not os.path.exists(SUPPORTER_LOG): return {} with open(SUPPORTER_LOG) as f: return json.load(f) def save_supporter_log(data): with open(SUPPORTER_LOG, 'w') as f: json.dump(data, f, indent=2) def clear_supporter_log(): if os.path.exists(SUPPORTER_LOG): os.remove(SUPPORTER_LOG) def log_supporter_clip(clip_id, donor_name): log = load_supporter_log() log[clip_id] = donor_name save_supporter_log(log) print(f" Logged supporter clip {clip_id} for {donor_name}") # ============================================================================= # YOUTUBE AUTH # ============================================================================= def get_youtube_client(): if not os.path.exists(TOKEN_FILE): print("CRITICAL: token.json not found. Run get_token.py first.") sys.exit(1) creds = Credentials.from_authorized_user_file(TOKEN_FILE, SCOPES) if creds.expired and creds.refresh_token: print("Token expired — refreshing...") creds.refresh(Request()) with open(TOKEN_FILE, 'w') as f: f.write(creds.to_json()) print("Token refreshed.") return build('youtube', 'v3', credentials=creds) # ============================================================================= # YOUTUBE UPLOAD # ============================================================================= def upload_to_youtube(youtube, file_path, title, description, is_short=False): final_title = f"{title} #Shorts" if is_short else title body = { 'snippet': { 'title': final_title, 'description': description, 'categoryId': '20' }, 'status': { 'privacyStatus': 'public', 'selfDeclaredMadeForKids': False } } media = MediaFileUpload(file_path, chunksize=4 * 1024 * 1024, resumable=True) request = youtube.videos().insert(part='snippet,status', body=body, media_body=media) print(f"Uploading: {final_title}") response = None while response is None: status, response = request.next_chunk() if status: print(f" Progress: {int(status.progress() * 100)}%", end='\r') print(f"\nDone! Video ID: {response.get('id')}") return response.get('id') # ============================================================================= # SHORT PROCESSING # ============================================================================= def process_and_upload_short(clip_url, clip_title): raw_file = os.path.join(BASE_DIR, 'raw_clip.mp4') output_file = os.path.join(BASE_DIR, 'final_short.mp4') print(f" Downloading: {clip_title}") ydl_opts = { 'format': 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best', 'outtmpl': raw_file, 'quiet': True } with YoutubeDL(ydl_opts) as ydl: ydl.download([clip_url]) print(" Converting to vertical (1080x1920)...") font_path = FONT_FILE.replace('\\', '/').replace(':', '\\:') filter_complex = ( f"[0:v]split=2[v1][v2];" f"[v1]crop={FACECAM_CROP},scale=1080:720," f"eq=saturation=0.7:contrast=1.2:brightness=-0.00[face];" f"[v2]crop={GAMEPLAY_CROP},scale=1080:1200," f"eq=contrast=0.9:brightness=0.05:gamma=0.8,unsharp=3:3:1.5[game];" f"[face][game]vstack=inputs=2,scale=1080:1920[stacked];" f"[stacked]drawtext=fontfile='{font_path}':" f"text='{TWITCH_USERNAME}':" f"fontcolor=0xE0E0E0:fontsize=100:x=(w-tw)/2:y=50:" f"shadowcolor=black@0.9:shadowx=6:shadowy=6[v]" ) cmd = [ 'ffmpeg', '-y', '-i', raw_file, '-filter_complex', filter_complex, '-map', '[v]', '-map', '0:a?', '-c:v', 'libx264', '-crf', '18', '-preset', 'veryfast', '-pix_fmt', 'yuv420p', output_file ] try: subprocess.run(cmd, check=True, capture_output=True) except subprocess.CalledProcessError as e: print(f" FFmpeg failed: {e}") return False finally: if os.path.exists(raw_file): os.remove(raw_file) if not os.path.exists(output_file): print(" ERROR: FFmpeg produced no output.") return False description = ( f"Thanks for the support!\n\n" f"Catch the live streams: https://www.twitch.tv/{TWITCH_USERNAME}\n\n" "#Shorts #Twitch" ) youtube = get_youtube_client() upload_to_youtube(youtube, output_file, clip_title, description, is_short=True) os.remove(output_file) return True # ============================================================================= # MODE 1: VOD ARCHIVE # ============================================================================= def fetch_recent_vods(limit=10): ydl_opts = {'quiet': True, 'extract_flat': True} with YoutubeDL(ydl_opts) as ydl: result = ydl.extract_info(TWITCH_VOD_URL, download=False) if not result or 'entries' not in result: return [] return list(reversed(result['entries'][:limit])) def parse_vod_timestamp(vod): raw = vod.get('timestamp') or vod.get('upload_date') if not raw: return None if isinstance(raw, (int, float)): return datetime.fromtimestamp(raw, tz=timezone.utc) try: return datetime.strptime(str(raw), "%Y%m%d").replace(tzinfo=timezone.utc) except ValueError: return None def find_matching_vods(all_vods, stream_title): title_lower = stream_title.strip().lower() candidates = [v for v in all_vods if v.get('title', '').strip().lower() == title_lower] if len(candidates) <= 1: return candidates anchor_time = parse_vod_timestamp(candidates[0]) if anchor_time is None: return candidates return [ v for v in candidates if parse_vod_timestamp(v) and abs((parse_vod_timestamp(v) - anchor_time).total_seconds()) / 3600 <= SPLIT_WINDOW_HOURS ] def download_vod(vod, output_path): url = vod.get('url') or vod.get('webpage_url') ydl_args = { 'outtmpl': output_path, 'format': 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best', 'merge_output_format': 'mp4' } with YoutubeDL(ydl_args) as ydl: ydl.download([url]) def merge_vods(part_files, output_path): concat_list = os.path.join(BASE_DIR, 'concat_list.txt') with open(concat_list, 'w') as f: for part in part_files: f.write(f"file '{part.replace(chr(92), '/')}'\n") cmd = ['ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', concat_list, '-c', 'copy', output_path] print(f"Merging {len(part_files)} VOD parts...") subprocess.run(cmd, check=True) os.remove(concat_list) def run_archive(stream_title): print("=" * 50) print("MODE: VOD Archive") print("=" * 50) print("Waiting 90s for Twitch to process VOD...") time.sleep(90) print("Fetching recent VODs...") all_vods = fetch_recent_vods(limit=10) if not all_vods: print("ERROR: No VODs found.") sys.exit(1) # Always use the actual VOD title from Twitch rather than relying on # Streamer.bot's variable, which doesn't include the stream title on offline events latest_vod_title = all_vods[-1].get('title', '').strip() if latest_vod_title and stream_title in ("Stream Archive", "Test", ""): print(f"Using VOD title from Twitch: {latest_vod_title}") stream_title = latest_vod_title date_str = datetime.now().strftime("%d-%m-%Y") formatted_title = f"[{date_str}] [ARCHIVE] {stream_title}" if len(formatted_title) > 100: formatted_title = formatted_title[:97] + "..." print(f"Title: {formatted_title}") matching_vods = find_matching_vods(all_vods, stream_title) if not matching_vods: print(f"WARNING: No VODs matched '{stream_title}'. Using latest VOD.") matching_vods = [all_vods[-1]] print(f"Found {len(matching_vods)} VOD part(s).") part_files = [] for i, vod in enumerate(matching_vods): part_path = os.path.join(BASE_DIR, f'vod_part_{i+1}.mp4') print(f"Downloading part {i+1}/{len(matching_vods)}...") download_vod(vod, part_path) if os.path.exists(part_path): part_files.append(part_path) else: print(f"WARNING: Part {i+1} failed — skipping.") if not part_files: print("ERROR: All downloads failed.") sys.exit(1) final_file = os.path.join(BASE_DIR, 'vod_final.mp4') if len(part_files) == 1: if os.path.exists(final_file): os.remove(final_file) os.rename(part_files[0], final_file) else: try: merge_vods(part_files, final_file) except subprocess.CalledProcessError as e: print(f"ERROR: Merge failed: {e}") sys.exit(1) finally: for f in part_files: if os.path.exists(f): os.remove(f) description = ( f"Full stream archive from {date_str}.\n\n" f"Watch live: https://www.twitch.tv/{TWITCH_USERNAME}\n\n" "Automated Twitch Archive." ) if len(matching_vods) > 1: description = f"[Merged from {len(matching_vods)} parts due to stream interruption]\n\n" + description youtube = get_youtube_client() upload_to_youtube(youtube, final_file, formatted_title, description) os.remove(final_file) print("Archive complete!") # ============================================================================= # MODE 2: SUPPORTER CLIP # Triggered mid-stream by Ko-fi/Patreon donations via Streamer.bot. # Silently creates a Twitch clip and logs the donor's name. # Args: archiver.py supporter "%userName%" # # IMPORTANT: Clip creation requires a Twitch USER access token with # clips:edit scope. Add a "user_token" field to twitch_secret.json. # ============================================================================= def run_supporter(donor_name): print("=" * 50) print("MODE: Supporter Clip") print("=" * 50) print(f"Donor: {donor_name}") client_id, client_secret = load_twitch_credentials() token = get_twitch_token(client_id, client_secret) user_id = get_twitch_user_id(client_id, token) print("Creating Twitch clip...") clip_id = create_twitch_clip(client_id, token, user_id) if clip_id: # Wait a few seconds for Twitch to process the clip time.sleep(5) log_supporter_clip(clip_id, donor_name) print(f"Supporter clip created and logged! Clip ID: {clip_id}") else: print("WARNING: Could not create clip. Stream may not be live.") # ============================================================================= # MODE 3: CLIP REVIEW # Auto-launches after stream ends. Opens browser UI showing all clips from # the session. Supporter clips appear at the top with a 💜 badge. # Args: archiver.py review "%targetChannelTitle%" # ============================================================================= _approved_clips = None _server_done = threading.Event() class ClipReviewHandler(BaseHTTPRequestHandler): clips = [] supporter_log = {} def log_message(self, format, *args): pass def do_GET(self): if self.path == '/': self.send_response(200) self.send_header('Content-Type', 'text/html; charset=utf-8') self.end_headers() self.wfile.write( build_review_page(self.clips, self.supporter_log).encode('utf-8') ) else: self.send_response(404) self.end_headers() def do_POST(self): global _approved_clips if self.path == '/approve': length = int(self.headers.get('Content-Length', 0)) body = self.rfile.read(length).decode('utf-8') data = parse_qs(body) approved_ids = data.get('clip_id', []) # Build dict of clip_id -> custom title from form inputs custom_titles = { k.replace('title_', ''): v[0] for k, v in data.items() if k.startswith('title_') and v[0].strip() } approved = [] for c in self.clips: if c['id'] in approved_ids: c = dict(c) if c['id'] in custom_titles: c['title'] = custom_titles[c['id']] approved.append(c) _approved_clips = approved self.send_response(200) self.send_header('Content-Type', 'text/html; charset=utf-8') self.end_headers() count = len(_approved_clips) self.wfile.write(f"""<!DOCTYPE html> <html><body style="background:#0e0e0e;color:#e0e0e0;font-family:sans-serif; text-align:center;padding:80px 20px"> <h2>✅ {count} clip(s) queued for upload as Shorts.</h2> <p style="color:#888;margin-top:12px">You can close this tab. Processing has started in the background.</p> </body></html>""".encode('utf-8')) _server_done.set() else: self.send_response(404) self.end_headers() def build_review_page(clips, supporter_log): if not clips: return """<!DOCTYPE html><html><body style="background:#0e0e0e;color:#e0e0e0; font-family:sans-serif;text-align:center;padding:80px"> <h2>No clips found from this stream session.</h2></body></html>""" # Split into supporter clips (pinned to top) and regular clips supporter_ids = set(supporter_log.keys()) supporter_clips = [c for c in clips if c['id'] in supporter_ids] regular_clips = [c for c in clips if c['id'] not in supporter_ids] ordered_clips = supporter_clips + regular_clips def make_card(clip): is_supporter = clip['id'] in supporter_ids donor = supporter_log.get(clip['id'], '') badge = f'<div class="supporter-badge">💜 {donor}</div>' if is_supporter else '' card_class = 'card supporter' if is_supporter else 'card' return f""" <div class="{card_class}" onclick="toggleCard(this)"> <input type="checkbox" name="clip_id" id="c_{clip['id']}" value="{clip['id']}" {'checked' if is_supporter else ''} style="display:none"> <div class="thumb-wrap"> <img src="{clip['thumbnail_url']}" alt="thumbnail" loading="lazy"> <div class="duration">{clip['duration']}s</div> {badge} </div> <div class="info"> <div class="title">{clip['title']}</div> <div class="meta">👁 {clip['view_count']} views · ✂️ {clip['creator_name']}</div> <input class="title-edit" type="text" name="title_{clip['id']}" placeholder="Custom title (optional)" autocomplete="off" onclick="event.stopPropagation()"> </div> <div class="check-badge">✓</div> </div>""" cards = "".join(make_card(c) for c in ordered_clips) supporter_section = "" if supporter_clips: supporter_section = f""" <div class="section-label">💜 Supporter Clips — {len(supporter_clips)} clip(s) <span style="color:#666;font-size:0.8rem">(pre-selected)</span></div>""" return f"""<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>Clip Review — {TWITCH_USERNAME}</title> <style> * {{ box-sizing: border-box; margin: 0; padding: 0; }} body {{ background: #0e0e0e; color: #e0e0e0; font-family: sans-serif; padding: 30px; }} h1 {{ text-align: center; margin-bottom: 6px; font-size: 1.6rem; letter-spacing: 1px; }} p.sub {{ text-align: center; color: #666; margin-bottom: 28px; font-size: 0.9rem; }} .section-label {{ font-size: 0.85rem; font-weight: bold; color: #9b59b6; margin-bottom: 12px; letter-spacing: 0.5px; }} .grid {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; margin-bottom: 24px; }} .card {{ position: relative; display: flex; flex-direction: column; background: #181818; border: 2px solid #2a2a2a; border-radius: 10px; overflow: hidden; cursor: pointer; transition: border-color 0.15s, background 0.15s; }} .card:has(input:checked) {{ border-color: #9b59b6; background: #1a1025; }} .card.supporter {{ border-color: #5b2d8e; background: #160d24; }} .card.supporter:has(input:checked) {{ border-color: #c77dff; }} .thumb-wrap {{ position: relative; }} .thumb-wrap img {{ width: 100%; aspect-ratio: 16/9; object-fit: cover; display: block; }} .duration {{ position: absolute; bottom: 6px; right: 8px; background: rgba(0,0,0,0.75); font-size: 0.75rem; padding: 2px 6px; border-radius: 4px; }} .supporter-badge {{ position: absolute; top: 6px; left: 6px; background: #9b59b6; color: white; font-size: 0.72rem; padding: 3px 8px; border-radius: 12px; font-weight: bold; }} .info {{ padding: 10px 12px 12px; }} .title {{ font-size: 0.9rem; font-weight: 600; margin-bottom: 5px; line-height: 1.3; }} .meta {{ font-size: 0.75rem; color: #666; margin-bottom: 8px; }} .title-edit {{ width: 100%; background: #111; border: 1px solid #333; border-radius: 5px; color: #e0e0e0; font-size: 0.8rem; padding: 6px 8px; margin-top: 2px; outline: none; }} .title-edit:focus {{ border-color: #9b59b6; }} .title-edit::placeholder {{ color: #444; }} .check-badge {{ display: none; position: absolute; top: 8px; right: 8px; background: #9b59b6; color: white; border-radius: 50%; width: 26px; height: 26px; align-items: center; justify-content: center; font-size: 0.85rem; font-weight: bold; }} .card:has(input:checked) .check-badge {{ display: flex; }} .actions {{ position: sticky; bottom: 0; background: #0e0e0e; border-top: 1px solid #222; padding: 16px; text-align: center; margin-top: 8px; }} button {{ background: #9b59b6; color: white; border: none; padding: 13px 42px; font-size: 1rem; border-radius: 8px; cursor: pointer; letter-spacing: 0.5px; }} button:hover {{ background: #7d3c98; }} .count {{ color: #555; margin-top: 8px; font-size: 0.82rem; }} .select-all {{ background: none; border: 1px solid #444; color: #aaa; padding: 6px 16px; font-size: 0.8rem; border-radius: 6px; cursor: pointer; margin-bottom: 16px; }} .select-all:hover {{ border-color: #9b59b6; color: #e0e0e0; }} </style> </head> <body> <h1>🎬 Clip Review</h1> <p class="sub">Select clips to export as YouTube Shorts, then click Export.</p> <form method="POST" action="/approve"> <div style="text-align:center"> <button type="button" class="select-all" onclick="toggleAll()">Select All</button> </div> {supporter_section} <div class="grid">{cards}</div> <div class="actions"> <button type="submit">⬆ Export Selected as Shorts</button> <div class="count">{len(clips)} clip(s) · {len(supporter_clips)} supporter clip(s)</div> </div> </form> <script> let allSelected = false; function toggleCard(card) {{ const cb = card.querySelector('input[type=checkbox]'); cb.checked = !cb.checked; }} function toggleAll() {{ allSelected = !allSelected; document.querySelectorAll('input[type=checkbox]').forEach(cb => cb.checked = allSelected); document.querySelector('.select-all').textContent = allSelected ? 'Deselect All' : 'Select All'; }} </script> </body> </html>""" def run_review(stream_title): global _approved_clips print("=" * 50) print("MODE: Clip Review") print("=" * 50) client_id, client_secret = load_twitch_credentials() print("Getting Twitch token...") token = get_twitch_token(client_id, client_secret) user_id = get_twitch_user_id(client_id, token) started_at = datetime.now(tz=timezone.utc).replace( hour=0, minute=0, second=0, microsecond=0 ).isoformat() print("Fetching clips from this stream session...") clips = fetch_clips_since(client_id, token, user_id, started_at) # Load supporter log to pin and badge those clips supporter_log = load_supporter_log() if not clips: print("No clips found from this stream. Nothing to review.") clear_supporter_log() return # Supporter clips first, then rest sorted by view count supporter_ids = set(supporter_log.keys()) supporter_clips = [c for c in clips if c['id'] in supporter_ids] regular_clips = sorted( [c for c in clips if c['id'] not in supporter_ids], key=lambda c: c.get('view_count', 0), reverse=True ) ordered_clips = supporter_clips + regular_clips print(f"Found {len(clips)} clip(s) ({len(supporter_clips)} supporter). Launching review page...") ClipReviewHandler.clips = ordered_clips ClipReviewHandler.supporter_log = supporter_log server = HTTPServer(('localhost', REVIEW_SERVER_PORT), ClipReviewHandler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() webbrowser.open(f'http://localhost:{REVIEW_SERVER_PORT}') print(f"Review page: http://localhost:{REVIEW_SERVER_PORT}") print("Waiting for your selections... (times out after 30 minutes)") _server_done.wait(timeout=1800) server.shutdown() if not _approved_clips: print("No clips approved — nothing uploaded.") clear_supporter_log() return print(f"\n{len(_approved_clips)} clip(s) approved. Processing Shorts...") for i, clip in enumerate(_approved_clips): print(f"\n[{i+1}/{len(_approved_clips)}] {clip['title']}") clip_url = f"https://www.twitch.tv/{TWITCH_USERNAME}/clip/{clip['id']}" success = process_and_upload_short(clip_url, clip['title']) print(f" {'✅ Done' if success else '❌ Failed'}") # Clean up supporter log after processing clear_supporter_log() print("\nClip review complete!") # ============================================================================= # ENTRY POINT # ============================================================================= if __name__ == "__main__": if len(sys.argv) < 2: print("Usage:") print(" archiver.py archive \"Stream Title\" — archive VOD to YouTube") print(" archiver.py review \"Stream Title\" — review & export clips as Shorts") print(" archiver.py supporter \"Donor Name\" — create supporter clip mid-stream") sys.exit(1) mode = sys.argv[1].lower() if mode == "archive": title = sys.argv[2] if len(sys.argv) > 2 else "Stream Archive" if "%" in title: title = "Stream Archive" run_archive(title) elif mode == "review": title = sys.argv[2] if len(sys.argv) > 2 else "Stream" if "%" in title: title = "Stream" run_review(title) elif mode == "supporter": donor = sys.argv[2] if len(sys.argv) > 2 else "Anonymous" if "%" in donor: donor = "Anonymous" run_supporter(donor) else: print(f"Unknown mode: '{mode}'. Use 'archive', 'review', or 'supporter'.") sys.exit(1) In the settings section in the above archiver.py, read carefully the lines within. There are parts you will have to enter with your Twitch username and a font of your choice. One other quick mention is that I added in a section that "in case of power failure", the program runs and searches for streams with matching names. It will download all streams with matching names, put them together, then upload them whole on YouTube. I added in a !clip command as an action for my Streamer.bot application. This command sits nicely as a button on my StreamDeck Pi that I created around the same time the original "Zero-Effort" Twitch-to-Youtube Pipeline article. I do plan on creating a post about that whole project later on, mostly because I'm still fine tuning it and still encountering some issues. Even if my button fails, I can still manually type !clip in my chat and it will still create it. When my stream ends, I still get that command window to show up and give me the status of the whole VOD download, convert/fix, then upload progress. Now, alongside that, another command window shows up that fetches the clips. It opens an HTML window that shows me all of the clips for that stream session. My clip review window that shows me the clips obtained during the stream In order to have this work with Streamer.bot, you will need to add in a few things. Initial Streamer.bot screen for "Auto Clip Review" section. I am clicked on the Auto Clip Review underneath the YouTube Tools action. This is what I have set for my main Action. Nothing special here. Auto Clip Review Action window My Sub-Action in the lower right window: Sub-Action for the Auto Clip Review If you've renamed things or have it in another pathway, please rename accordingly. "Special Clips" for Ko-fi and Patreon I don't often get these kinds of interactions, so when I planned on getting something set up for these in particular, I had to make it a bit different. I looked into Streamer.bot's capabilities for capturing Ko-fi and Patreon donations and subscriptions, and the process looked easy enough. I can't manually capture these specific clips. These are actually captured automatically, so the capture is my genuine reaction. Quick note before you test any of this – and this applies to both the !clip action from earlier and the Supporter Clip action below: don't use Streamer.bot's built-in test button on your clip actions to check if they're working. It flags the request as test traffic, and Twitch's Clip API silently returns failure – no error, no red text, nothing in Action History. It'll just look broken even when it's set up correctly. Took me a while to figure that one out. Test with a real !clip in chat, or an actual Ko-fi/Patreon donation, and you'll actually see it work. When my stream ends, these "special clips" are flagged with a 💜 in my review UI. This helps me differentiate which clips are the ones manually created and the ones that are triggered via Ko-fi and Patreon. In case I don't get a notification that a clip was caught for this, I can always default to the manual !clip method if I remember. Here is a picture semi-helpful-walkthrough: Initial Streamer.bot screen for "Special Clips" section. I am clicked on the Supporter Clip underneath the YouTube Tools action. You will need to have a group in the Actions area that is easy to understand. I simply named mine YouTube Tools. Here, I have an action called "Supporter Clip". Supporter Clip action window Next up is creating 2 Sub-Actions, the small lower right window. First Sub-Action that needs to be on top Please note that if you are renaming ANYTHING in this, reflect accordingly or else it won't work. For me, I have this is my D:\ drive, in a folder called TwitchArchiver. Obviously if you are hosting this somewhere else, just make sure the pathways match. Second Sub-Action that needs to be underneath the first one Same thing applies here: match pathways and names. As for the Triggers window (top right), simply find the Kofi and Patreon options and add those in. There isn't any configuration for them. I picked all 4 Kofi options and both Patreon options, but if you want to have it slightly different, by all means go for it. That's it for this round of updates. Up next on the list: a full StreamDeck Pi writeup, including a fun one where a single missing field in a WebSocket payload was silently dropping every command with zero errors. If you don't want to miss it, subscribe (for free!) and it'll land in your inbox the day it goes up.
Stored topics

Every stored assignment is listed, including rows below the current cutoff (0.75), so a missing tag can be explained.

Concept Code Score Meter Margin Origin Path
economy, business and finance economy-business-and-finance 0.98 4.06 ↑derived from 20000225 economy, business and finance
products and services products-and-services 0.98 4.06 ↑derived from 20000225 economy, business and finance > products and services
computing and information technology computing-and-information-technology 0.98 4.06 direct economy, business and finance > products and services > computing and information technology
media and entertainment industry media-and-entertainment-industry 0.98 3.77 ↑derived from 20001293 economy, business and finance > products and services > media and entertainment industry
streaming service streaming-service 0.98 3.77 direct economy, business and finance > products and services > media and entertainment industry > streaming service
science and technology science-and-technology 0.98 3.77 direct science and technology
technology and engineering technology-and-engineering 0.97 3.63 direct science and technology > technology and engineering
information technology and computer science information-technology-and-computer 0.97 3.60 direct science and technology > technology and engineering > information technology and computer science
software and applications software-and-applications 0.96 3.29 direct economy, business and finance > products and services > computing and information technology > software and applications
Information security information-security 0.93 2.55 direct science and technology > technology and engineering > information technology and computer science > Information security
Open source open-source 0.93 2.54 direct economy, business and finance > products and services > computing and information technology > software and applications > Open source
Software development software-development 0.91 2.32 direct economy, business and finance > products and services > computing and information technology > software and applications > Software development
Cryptography cryptography 0.91 2.29 direct science and technology > technology and engineering > information technology and computer science > Cryptography
podcast podcast 0.87 1.88 direct economy, business and finance > products and services > media and entertainment industry > podcast
society society 0.81 1.46 ↑derived from 20001300 society
fundamental rights fundamental-rights 0.81 1.46 ↑derived from 20001300 society > fundamental rights
privacy privacy 0.81 1.46 direct society > fundamental rights > privacy
film industry film-industry 0.81 1.44 direct economy, business and finance > products and services > media and entertainment industry > film industry
crime, law and justice crime-law-and-justice 0.77 1.20 ↑derived from 20000086 crime, law and justice
crime crime 0.77 1.20 ↑derived from 20000086 crime, law and justice > crime
cyber crime cyber-crime 0.77 1.20 direct crime, law and justice > crime > cyber crime
lifestyle and leisure lifestyle-and-leisure 0.75 1.12 ↑derived from 20000548 lifestyle and leisure
leisure leisure 0.75 1.12 ↑derived from 20000548 lifestyle and leisure > leisure
game game 0.75 1.12 ↑derived from 20000548 lifestyle and leisure > leisure > game
video game video-game 0.75 1.12 direct lifestyle and leisure > leisure > game > video game
arts, culture, entertainment and media arts-culture-entertainment-and-media 0.74 1.03 ↑derived from 20001182 arts, culture, entertainment and media
mass media mass-media 0.74 1.03 ↑derived from 20001182 arts, culture, entertainment and media > mass media
social media social-media 0.74 1.03 direct arts, culture, entertainment and media > mass media > social media
artificial intelligence artificial-intelligence 0.69 0.79 direct science and technology > technology and engineering > information technology and computer science > artificial intelligence
television television 0.67 0.70 direct arts, culture, entertainment and media > mass media > television
arts and entertainment arts-and-entertainment 0.46 -0.15 ↑derived from 20000003 arts, culture, entertainment and media > arts and entertainment
animation animation 0.46 -0.15 direct arts, culture, entertainment and media > arts and entertainment > animation
Blogging blogging 0.27 -1.00 direct arts, culture, entertainment and media > mass media > social media > Blogging
music music 0.26 -1.02 direct arts, culture, entertainment and media > arts and entertainment > music
sport sport 0.20 -1.39 ↑derived from 20001183 sport
competition discipline competition-discipline 0.20 -1.39 ↑derived from 20001183 sport > competition discipline
eSports esports 0.20 -1.39 direct sport > competition discipline > eSports
environment environment 0.19 -1.45 direct environment
Self-hosting self-hosting 0.17 -1.56 direct economy, business and finance > products and services > computing and information technology > Self-hosting
visual arts visual-arts 0.13 -1.88 ↑derived from 20000036 arts, culture, entertainment and media > arts and entertainment > visual arts
photography photography 0.13 -1.88 direct arts, culture, entertainment and media > arts and entertainment > visual arts > photography
health health 0.12 -1.97 ↑derived from 20000458 health
disease and condition disease-and-condition 0.12 -1.97 ↑derived from 20000458 health > disease and condition
mental health and disorder mental-health-and-disorder 0.12 -1.97 direct health > disease and condition > mental health and disorder
MMORPG mmorpg 0.12 -2.02 direct lifestyle and leisure > leisure > game > video game > MMORPG
natural science natural-science 0.11 -2.06 direct science and technology > natural science
literature literature 0.11 -2.12 direct arts, culture, entertainment and media > arts and entertainment > literature
books and publishing books-and-publishing 0.10 -2.17 direct economy, business and finance > products and services > media and entertainment industry > books and publishing
biology biology 0.10 -2.17 direct science and technology > natural science > biology
scientific research scientific-research 0.09 -2.26 ↑derived from 20000739 science and technology > scientific research
scientific exploration scientific-exploration 0.09 -2.26 ↑derived from 20000739 science and technology > scientific research > scientific exploration
space exploration space-exploration 0.09 -2.26 direct science and technology > scientific research > scientific exploration > space exploration
social sciences social-sciences 0.09 -2.34 direct science and technology > social sciences
family family 0.08 -2.41 direct society > family
politics and government politics-and-government 0.08 -2.43 direct politics and government
Tabletop gaming tabletop-gaming 0.08 -2.46 direct lifestyle and leisure > leisure > game > Tabletop gaming
consumer goods consumer-goods 0.07 -2.53 ↑derived from 20001160 economy, business and finance > products and services > consumer goods
handicrafts handicrafts 0.07 -2.53 direct economy, business and finance > products and services > consumer goods > handicrafts
education education 0.07 -2.56 direct education
history history 0.06 -2.72 direct science and technology > social sciences > history
hobby hobby 0.06 -2.79 direct lifestyle and leisure > leisure > hobby
labour labour 0.05 -2.86 direct labour
human interest human-interest 0.05 -3.03 ↑derived from 20000498 human interest
award and prize award-and-prize 0.05 -3.03 direct human interest > award and prize
health treatment and procedure health-treatment-and-procedure 0.04 -3.28 direct health > health treatment and procedure
conflict, war and peace conflict-war-and-peace 0.03 -3.46 direct conflict, war and peace
disaster, accident and emergency incident disaster-accident-and-emergency-incident 0.03 -3.48 direct disaster, accident and emergency incident
card game card-game 0.03 -3.57 direct lifestyle and leisure > leisure > game > card game
public health public-health 0.03 -3.64 direct health > public health
culture culture 0.02 -3.69 direct arts, culture, entertainment and media > culture
climate change climate-change 0.02 -3.70 direct environment > climate change
board game board-game 0.02 -3.79 direct lifestyle and leisure > leisure > game > board game
exercise and fitness exercise-and-fitness 0.02 -3.93 direct lifestyle and leisure > wellness > exercise and fitness
wellness wellness 0.02 -3.93 ↑derived from 20001239 lifestyle and leisure > wellness
diet diet 0.02 -3.96 direct health > health treatment and procedure > diet
religion religion 0.01 -4.25 direct religion
lifestyle lifestyle 0.01 -4.25 ↑derived from 20001257 lifestyle and leisure > lifestyle
house and home house-and-home 0.01 -4.25 ↑derived from 20001257 lifestyle and leisure > lifestyle > house and home
gardening gardening 0.01 -4.25 direct lifestyle and leisure > lifestyle > house and home > gardening
weather weather 0.01 -4.36 ↑derived from 20001128 weather
weather forecast weather-forecast 0.01 -4.36 direct weather > weather forecast
travel and tourism travel-and-tourism 0.01 -4.36 direct lifestyle and leisure > leisure > travel and tourism
food and drink enthusiasm food-and-drink-enthusiasm 0.01 -4.60 direct lifestyle and leisure > leisure > hobby > food and drink enthusiasm

← Back to posts

feedtagger 0.1.0-dev model: modernbert-base-zeroshot-v2.0/int8@all-s256 2115 posts 0 unclassified min score 0.75