From c5203fcfbb67c4025c7651f6481514739aa1a60b Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Thu, 15 Jan 2026 14:43:27 -0500 Subject: [PATCH] Initial Commit --- README.md | 120 +++++++++++++++++ update_channel_logos.py | 280 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 400 insertions(+) create mode 100644 README.md create mode 100644 update_channel_logos.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..9b2f3c1 --- /dev/null +++ b/README.md @@ -0,0 +1,120 @@ +# Emby Live TV Channel Logo Updater + +Automatically copy or clear Live TV channel logos in Emby Server. + +## What It Does + +- **Copy Mode** (default): Copies the Primary logo (Logo Dark Version) to LogoLight (Logo Light Version) and LogoLightColor (Logo Light with Colour) for all TV channels +- **Clear Mode** (`--clear`): Removes all logos (Primary, LogoLight, LogoLightColor) from channels + +## Requirements + +- Python 3.6+ +- Emby Server with API access +- API key from Emby Server + +## Getting Your API Key + +1. Log into Emby Server as an administrator +2. Go to Settings → Advanced → API Keys +3. Create a new API key or use an existing one + +## Usage + +### Copy Logos (Default Mode) + +**Dry run** (see what would happen without making changes): +```bash +python update_channel_logos.py \ + --server https://your-emby-server.com \ + --api-key YOUR_API_KEY +``` + +**Execute** (actually make changes): +```bash +python update_channel_logos.py \ + --server https://your-emby-server.com \ + --api-key YOUR_API_KEY \ + --execute +``` + +**Test on first channel only**: +```bash +python update_channel_logos.py \ + --server https://your-emby-server.com \ + --api-key YOUR_API_KEY \ + --first-only \ + --execute +``` + +### Clear All Logos + +**Dry run**: +```bash +python update_channel_logos.py \ + --server https://your-emby-server.com \ + --api-key YOUR_API_KEY \ + --clear +``` + +**Execute**: +```bash +python update_channel_logos.py \ + --server https://your-emby-server.com \ + --api-key YOUR_API_KEY \ + --clear \ + --execute +``` + +## Command-Line Options + +| Option | Description | +|--------|-------------| +| `--server` | **Required.** Emby server URL (e.g., `https://emby.example.com`) | +| `--api-key` | **Required.** Your Emby API key | +| `--execute` | Actually perform the changes (without this, runs in dry-run mode) | +| `--first-only` | Only process the first channel (useful for testing) | +| `--clear` | Clear all logos instead of copying them | + +## Examples + +**Recommended workflow:** + +1. Test on first channel with dry run: + ```bash + python update_channel_logos.py --server URL --api-key KEY --first-only + ``` + +2. Execute on first channel: + ```bash + python update_channel_logos.py --server URL --api-key KEY --first-only --execute + ``` + +3. If successful, run on all channels: + ```bash + python update_channel_logos.py --server URL --api-key KEY --execute + ``` + +## How It Works + +1. Connects to your Emby server +2. Fetches all Live TV channels +3. For each channel: + - **Copy mode**: Downloads the Primary logo and uploads it as LogoLight and LogoLightColor + - **Clear mode**: Deletes Primary, LogoLight, and LogoLightColor +4. Skips channels that already have the logos (copy mode) or have no logos (clear mode) +5. Shows a summary of results + +## Safety Features + +- **Dry run by default**: Always runs in simulation mode unless `--execute` is specified +- **Confirmation prompt**: Asks for confirmation before executing changes +- **First-only testing**: Test on a single channel before processing all channels +- **Skip existing**: In copy mode, skips channels that already have both light logos + +## Notes + +- The script only affects Live TV channels, not other media +- Changes may take a few moments to appear in the Emby interface +- You may need to refresh your browser/app to see changes +- Use `--clear` with caution - it deletes logos from all channels diff --git a/update_channel_logos.py b/update_channel_logos.py new file mode 100644 index 0000000..b8c8445 --- /dev/null +++ b/update_channel_logos.py @@ -0,0 +1,280 @@ +#!/usr/bin/env python3 +""" +Emby Live TV Channel Logo Updater + +Copies 'Logo (Dark Version)' to 'Logo (Light Version)' and 'Logo (Light with Colour)'. +""" + +import argparse +import base64 +import json +import sys +from typing import Dict, List, Optional +import urllib.request +import urllib.error +import urllib.parse + + +class EmbyClient: + """Client for interacting with the Emby Server API.""" + + def __init__(self, server_url: str, api_key: str): + self.server_url = server_url.rstrip('/') + self.api_key = api_key + + def _make_request(self, endpoint: str, method: str = 'GET', data: Optional[Dict] = None) -> Dict: + url = f"{self.server_url}{endpoint}" + if method == 'GET': + separator = '&' if '?' in url else '?' + url = f"{url}{separator}api_key={self.api_key}" + + request = urllib.request.Request(url, method=method) + request.add_header('X-Emby-Token', self.api_key) + + if data is not None: + request.add_header('Content-Type', 'application/json') + request.data = json.dumps(data).encode('utf-8') + + try: + with urllib.request.urlopen(request) as response: + if response.status == 204: + return {} + return json.loads(response.read().decode('utf-8')) + except urllib.error.HTTPError as e: + error_body = e.read().decode('utf-8') if e.fp else 'No error details' + raise Exception(f"HTTP {e.code}: {error_body}") + + def get_live_tv_channels(self) -> List[Dict]: + response = self._make_request('/LiveTv/Channels?Type=TV&EnableImages=true') + return response.get('Items', []) + + def get_channel_by_id(self, channel_id: str) -> Dict: + return self._make_request(f'/LiveTv/Channels/{channel_id}') + + def download_image(self, item_id: str, image_type: str) -> bytes: + url = f"{self.server_url}/Items/{item_id}/Images/{image_type}?api_key={self.api_key}" + with urllib.request.urlopen(urllib.request.Request(url)) as response: + return response.read() + + def upload_image(self, item_id: str, image_type: str, image_data: bytes) -> None: + if image_data.startswith(b'\x89PNG'): + content_type = 'image/png' + elif image_data.startswith(b'\xff\xd8'): + content_type = 'image/jpeg' + elif image_data.startswith(b'GIF'): + content_type = 'image/gif' + else: + content_type = 'application/octet-stream' + + url = f"{self.server_url}/Items/{item_id}/Images/{image_type}" + request = urllib.request.Request(url, method='POST') + request.add_header('X-Emby-Token', self.api_key) + request.add_header('Content-Type', content_type) + request.data = base64.b64encode(image_data).decode('utf-8').encode('utf-8') + + try: + with urllib.request.urlopen(request): + pass + except urllib.error.HTTPError as e: + error_body = e.read().decode('utf-8') if e.fp else 'No error details' + raise Exception(f"Failed to upload {image_type}: HTTP {e.code} - {error_body}") + + def delete_image(self, item_id: str, image_type: str) -> None: + url = f"{self.server_url}/Items/{item_id}/Images/{image_type}" + request = urllib.request.Request(url, method='DELETE') + request.add_header('X-Emby-Token', self.api_key) + + try: + with urllib.request.urlopen(request): + pass + except urllib.error.HTTPError as e: + error_body = e.read().decode('utf-8') if e.fp else 'No error details' + raise Exception(f"Failed to delete {image_type}: HTTP {e.code} - {error_body}") + + +def copy_primary_to_light_logos(client: EmbyClient, channel_id: str, channel_name: str, dry_run: bool = True) -> bool: + try: + channel_data = client.get_channel_by_id(channel_id) + image_tags = channel_data.get('ImageTags', {}) + + if not image_tags.get('Primary'): + print(f" ⚠️ No Primary image, skipping") + return False + + has_logo_light = 'LogoLight' in image_tags + has_logo_light_color = 'LogoLightColor' in image_tags + + if has_logo_light and has_logo_light_color: + print(f" ✓ Already has both logos") + return True + + missing = [] + if not has_logo_light: + missing.append('LogoLight') + if not has_logo_light_color: + missing.append('LogoLightColor') + + if dry_run: + print(f" → Would upload: {', '.join(missing)}") + return True + + print(f" 📥 Downloading Primary...") + primary_image_data = client.download_image(channel_id, 'Primary') + + if not has_logo_light: + print(f" 📤 Uploading LogoLight...") + client.upload_image(channel_id, 'LogoLight', primary_image_data) + + if not has_logo_light_color: + print(f" 📤 Uploading LogoLightColor...") + client.upload_image(channel_id, 'LogoLightColor', primary_image_data) + + print(f" ✓ Successfully uploaded") + return True + + except Exception as e: + print(f" ✗ Error: {e}") + import traceback + traceback.print_exc() + return False + + +def clear_channel_logos(client: EmbyClient, channel_id: str, channel_name: str, dry_run: bool = True) -> bool: + try: + channel_data = client.get_channel_by_id(channel_id) + image_tags = channel_data.get('ImageTags', {}) + + image_types = ['Primary', 'LogoLight', 'LogoLightColor'] + existing = [img_type for img_type in image_types if img_type in image_tags] + + if not existing: + print(f" ℹ️ No logos to clear") + return True + + if dry_run: + print(f" → Would delete: {', '.join(existing)}") + return True + + for img_type in existing: + print(f" 🗑️ Deleting {img_type}...") + client.delete_image(channel_id, img_type) + + print(f" ✓ Successfully cleared {len(existing)} logo(s)") + return True + + except Exception as e: + print(f" ✗ Error: {e}") + import traceback + traceback.print_exc() + return False + + +def update_channel_logos(client: EmbyClient, dry_run: bool = True, first_only: bool = False) -> None: + channels = client.get_live_tv_channels() + channels_to_process = [channels[0]] if first_only else channels + + print("\n" + "=" * 80) + if first_only: + print(f"{'DRY RUN: TESTING ON' if dry_run else 'PROCESSING'} FIRST CHANNEL ONLY") + else: + print(f"{'DRY RUN: SIMULATING' if dry_run else 'PROCESSING'} ALL {len(channels)} CHANNELS") + print("=" * 80) + + success_count = 0 + skip_count = 0 + + for i, channel in enumerate(channels_to_process, 1): + print(f"\n[{i}/{len(channels_to_process)}] {channel.get('Name', 'Unknown')}") + if copy_primary_to_light_logos(client, channel['Id'], channel.get('Name'), dry_run): + success_count += 1 + else: + skip_count += 1 + + print("\n" + "=" * 80) + print("SUMMARY") + print("=" * 80) + print(f"Total: {len(channels_to_process)}") + print(f"{'Would update' if dry_run else 'Updated'}: {success_count}") + print(f"Skipped: {skip_count}") + + if dry_run: + print("\n💡 Dry run. Use --execute to perform actual updates.") + if first_only and not dry_run: + print("\n💡 Processed first channel only. Remove --first-only to process all.") + + +def clear_logos(client: EmbyClient, dry_run: bool = True, first_only: bool = False) -> None: + channels = client.get_live_tv_channels() + channels_to_process = [channels[0]] if first_only else channels + + print("\n" + "=" * 80) + if first_only: + print(f"{'DRY RUN: CLEARING' if dry_run else 'CLEARING'} FIRST CHANNEL ONLY") + else: + print(f"{'DRY RUN: CLEARING' if dry_run else 'CLEARING'} ALL {len(channels)} CHANNELS") + print("=" * 80) + + success_count = 0 + skip_count = 0 + + for i, channel in enumerate(channels_to_process, 1): + print(f"\n[{i}/{len(channels_to_process)}] {channel.get('Name', 'Unknown')}") + if clear_channel_logos(client, channel['Id'], channel.get('Name'), dry_run): + success_count += 1 + else: + skip_count += 1 + + print("\n" + "=" * 80) + print("SUMMARY") + print("=" * 80) + print(f"Total: {len(channels_to_process)}") + print(f"{'Would clear' if dry_run else 'Cleared'}: {success_count}") + print(f"Skipped: {skip_count}") + + if dry_run: + print("\n💡 Dry run. Use --execute to perform actual deletions.") + if first_only and not dry_run: + print("\n💡 Processed first channel only. Remove --first-only to process all.") + + +def main(): + parser = argparse.ArgumentParser(description='Copy Emby Live TV channel dark logos to light versions') + parser.add_argument('--server', required=True, help='Emby server URL') + parser.add_argument('--api-key', required=True, help='Emby API key') + parser.add_argument('--execute', action='store_true', help='Actually perform updates') + parser.add_argument('--first-only', action='store_true', help='Only process first channel') + parser.add_argument('--clear', action='store_true', help='Clear all logos (Primary, LogoLight, LogoLightColor)') + args = parser.parse_args() + + client = EmbyClient(args.server, args.api_key) + + try: + print(f"Connecting to {args.server}...") + channels = client.get_live_tv_channels() + print(f"✓ Connected! Found {len(channels)} TV channels") + except Exception as e: + print(f"✗ Connection failed: {e}", file=sys.stderr) + sys.exit(1) + + if args.clear: + if args.execute: + target = "first channel" if args.first_only else "all channels" + if input(f"\nCLEAR ALL LOGOS from {target}? (yes/no): ").lower() == 'yes': + clear_logos(client, dry_run=False, first_only=args.first_only) + else: + print("Cancelled.") + else: + clear_logos(client, dry_run=True, first_only=args.first_only) + else: + if args.execute: + target = "first channel" if args.first_only else "all channels" + if input(f"\nModify {target}? (yes/no): ").lower() == 'yes': + update_channel_logos(client, dry_run=False, first_only=args.first_only) + else: + print("Cancelled.") + else: + update_channel_logos(client, dry_run=True, first_only=args.first_only) + + +if __name__ == '__main__': + main()