From e1acd126a79bacfd48f688bddc5952834d5a692c Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Sun, 8 Feb 2026 11:58:22 -0500 Subject: [PATCH] containerize & enhance filtering --- .dockerignore | 31 +++++ .env.example | 40 ++++++ .gitea/workflows/build-image.yml | 32 +++++ Dockerfile | 24 ++++ README.md | 217 +++++++++++++++++++++++++++++-- docker-compose.yml | 20 +++ entrypoint.sh | 142 ++++++++++++++++++++ update_channel_logos.py | 103 +++++++++++++-- 8 files changed, 591 insertions(+), 18 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitea/workflows/build-image.yml create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 entrypoint.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..17a6c7a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,31 @@ +# Git files +.git +.gitignore +.gitea + +# Environment files +.env +.env.* +!.env.example + +# Python cache +__pycache__ +*.py[cod] +*$py.class +*.so +.Python + +# Documentation +*.md +!README.md + +# IDE +.vscode +.idea +*.swp +*.swo +*~ + +# OS files +.DS_Store +Thumbs.db diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..13a3ad9 --- /dev/null +++ b/.env.example @@ -0,0 +1,40 @@ +# Copy to .env: cp .env.example .env + +# Required - Emby Server Configuration +EMBY_SERVER_URL=https://your-emby-server.com +EMBY_API_KEY=your_api_key_here + +# Mode: copy (default) or clear +MODE=copy + +# Safety: true = dry run, false = execute +DRY_RUN=true + +# Test on first channel only +FIRST_ONLY=false + +# Overwrite existing logos (copy mode only) +FORCE=false + +# Filter by tags (comma-separated, e.g. sports,news) +TAGS= + +# List all available tags and exit +LIST_TAGS=false + +# Cron schedule (minute hour day month weekday) +# Examples: 0 3 * * * (daily 3am), 0 */6 * * * (every 6hrs) +CRON_SCHEDULE=0 3 * * * + +# Run once and exit (ignores cron) +RUN_ONCE=false + +# Timezone (e.g. America/New_York, Europe/London) +TZ=UTC + +# --- Example Configurations --- +# List tags: LIST_TAGS=true RUN_ONCE=true +# Test: DRY_RUN=true FIRST_ONLY=true +# Production: DRY_RUN=false MODE=copy +# By tags: TAGS=sports,news DRY_RUN=false +# Run now: RUN_ONCE=true DRY_RUN=false diff --git a/.gitea/workflows/build-image.yml b/.gitea/workflows/build-image.yml new file mode 100644 index 0000000..768a07f --- /dev/null +++ b/.gitea/workflows/build-image.yml @@ -0,0 +1,32 @@ +name: Build & Push Docker Image + +on: push + +jobs: + build-and-publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Output Repository Information + run: | + echo This workflow will build and push a Docker image to: + echo ${{ vars.REPO_ADDRESS }}/${{ vars.REPO_USER }}/${{ vars.REPO_NAME }}:latest + echo https://${{ vars.REPO_ADDRESS }}/${{ vars.REPO_USER }}/${{ vars.REPO_NAME }} + + - name: Build Docker Image + run: | + docker build -t ${{ vars.REPO_ADDRESS }}/${{ vars.REPO_USER }}/${{ vars.REPO_NAME }}:latest --output type=docker --platform linux/amd64 . + + - name: Log in to Container Repo + uses: docker/login-action@v3 + with: + username: ${{ vars.REPO_USER }} + registry: ${{ vars.REPO_ADDRESS }} + password: ${{ secrets.REPO_TOKEN }} + + - name: Push Docker Image + run: | + echo "Pushing Docker image with tag: latest to ${{ vars.REPO_ADDRESS }}" + docker push ${{ vars.REPO_ADDRESS }}/${{ vars.REPO_USER }}/${{ vars.REPO_NAME }}:latest + docker rmi ${{ vars.REPO_ADDRESS }}/${{ vars.REPO_USER }}/${{ vars.REPO_NAME }}:latest || true diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8e7a1cc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.11-slim + +# Install cron and timezone handling +RUN apt-get update && \ + apt-get install -y cron tzdata && \ + rm -rf /var/lib/apt/lists/* + +# Create app directory +WORKDIR /app + +# Copy the Python script +COPY update_channel_logos.py /app/ + +# Copy the entrypoint script +COPY entrypoint.sh /app/ + +# Make the entrypoint executable +RUN chmod +x /app/entrypoint.sh + +# Set timezone (can be overridden) +ENV TZ=UTC + +# Run the entrypoint +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/README.md b/README.md index d746ec4..87c6dec 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,42 @@ Automatically copy or clear Live TV channel logos in Emby Server. - **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 +## Quick Start (Docker - Recommended) + +1. **Copy the environment template:** + ```bash + cp .env.example .env + ``` + +2. **Edit `.env` and set your Emby credentials:** + ```bash + EMBY_SERVER_URL=https://your-emby-server.com + EMBY_API_KEY=your_api_key_here + ``` + +3. **Test with dry run on first channel:** + ```bash + docker compose up + ``` + +4. **When ready, enable execution:** + Edit `.env` and set: + ```bash + DRY_RUN=false + FIRST_ONLY=false + ``` + +5. **Run in the background:** + ```bash + docker compose up -d + ``` + +See the [Docker Usage](#docker-usage-recommended) section for detailed configuration. + ## Requirements -- Python 3.6+ +- **Docker** (recommended): Docker and Docker Compose - uses pre-built image from GHCR +- **Python** (alternative): Python 3.6+ - Emby Server with API access - API key from Emby Server @@ -19,7 +52,161 @@ Automatically copy or clear Live TV channel logos in Emby Server. 2. Go to Settings → Advanced → API Keys 3. Create a new API key or use an existing one -## Usage +## Docker Usage (Recommended) + +### Configuration + +All configuration is done via the `.env` file. Copy `.env.example` to `.env` and customize: + +```bash +cp .env.example .env +``` + +#### Required Settings + +| Variable | Description | +|----------|-------------| +| `EMBY_SERVER_URL` | Your Emby server URL (e.g., `https://emby.example.com`) | +| `EMBY_API_KEY` | Your Emby API key | + +#### Optional Settings + +| Variable | Default | Description | +|----------|---------|-------------| +| `MODE` | `copy` | Operation mode: `copy` (copy logos) or `clear` (delete logos) | +| `DRY_RUN` | `true` | `true` = simulate only, `false` = execute changes | +| `FIRST_ONLY` | `false` | `true` = process first channel only (for testing) | +| `FORCE` | `false` | `true` = overwrite existing logos (copy mode only) | +| `TAGS` | _(empty)_ | Comma-separated list of tags to filter channels (e.g., `sports,news`) | +| `LIST_TAGS` | `false` | `true` = list all available tags and exit | +| `CRON_SCHEDULE` | `0 3 * * *` | Cron schedule (default: daily at 3 AM) | +| `RUN_ONCE` | `false` | `true` = run once and exit, `false` = run on schedule | +| `TZ` | `UTC` | Timezone for cron (e.g., `America/New_York`) | + +### Common Scenarios + +#### Discover Available Tags +```bash +# In .env: +LIST_TAGS=true +RUN_ONCE=true + +# Run: +docker compose up +``` + +#### Safe Testing (Recommended First Run) +```bash +# In .env: +MODE=copy +DRY_RUN=true +FIRST_ONLY=true + +# Run: +docker compose up +``` + +#### Production - Daily Updates +```bash +# In .env: +MODE=copy +DRY_RUN=false +CRON_SCHEDULE=0 3 * * * + +# Run in background: +docker compose up -d +``` + +#### One-Time Update Now +```bash +# In .env: +MODE=copy +DRY_RUN=false +RUN_ONCE=true + +# Run: +docker compose up +``` + +#### Force Overwrite Existing Logos +```bash +# In .env: +MODE=copy +DRY_RUN=false +FORCE=true + +# Run: +docker compose up -d +``` + +#### Clear All Logos (Use with Caution!) +```bash +# In .env: +MODE=clear +DRY_RUN=false + +# Run: +docker compose up +``` + +#### Process Only Channels with Specific Tags +```bash +# In .env: +MODE=copy +DRY_RUN=false +TAGS=sports,news + +# Run: +docker compose up -d +``` + +#### Force Update Sports Channels Only +```bash +# In .env: +MODE=copy +DRY_RUN=false +FORCE=true +TAGS=sports + +# Run: +docker compose up +``` + +### Docker Commands + +```bash +# Build and start +docker compose up + +# Start in background +docker compose up -d + +# View logs +docker compose logs -f + +# Stop +docker compose down + +# Build locally (if modifying source) +# Uncomment "build: ." in docker-compose.yml, then: +docker compose up --build +``` + +### Cron Schedule Examples + +Format: `minute hour day month weekday` + +| Schedule | Description | +|----------|-------------| +| `0 3 * * *` | Daily at 3 AM (default) | +| `0 */6 * * *` | Every 6 hours | +| `0 0 * * 0` | Weekly on Sunday at midnight | +| `0 2 1 * *` | Monthly on the 1st at 2 AM | +| `*/30 * * * *` | Every 30 minutes | + +## Python Usage (Alternative) + +If you prefer to run the script directly with Python instead of Docker: ### Copy Logos (Default Mode) @@ -76,35 +263,49 @@ python update_channel_logos.py \ | `--first-only` | Only process the first channel (useful for testing) | | `--clear` | Clear all logos instead of copying them | | `--force` | Overwrite existing LogoLight/LogoLightColor (copy mode only) | +| `--tags` | Comma-separated list of tags to filter channels (e.g., `sports,news`) | +| `--list-tags` | List all available tags and exit | +| `--non-interactive` | Skip confirmation prompts (for automated execution) | ## Examples **Recommended workflow:** -1. Test on first channel with dry run: +1. Discover available tags: + ```bash + python update_channel_logos.py --server URL --api-key KEY --list-tags + ``` + +2. 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: +3. 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: +4. If successful, run on all channels: ```bash python update_channel_logos.py --server URL --api-key KEY --execute ``` +5. Process only channels with specific tags: + ```bash + python update_channel_logos.py --server URL --api-key KEY --tags sports,news --execute + ``` + ## How It Works 1. Connects to your Emby server 2. Fetches all Live TV channels -3. For each channel: +3. Filters channels by tags (if specified) +4. 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, unless `--force` is used) or have no logos (clear mode) -5. Shows a summary of results +5. Skips channels that already have the logos (copy mode, unless `--force` is used) or have no logos (clear mode) +6. Shows a summary of results ## Safety Features diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0d2bfac --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +version: '3.8' + +services: + emby-logo-tools: + image: ghcr.io/sethwv/emby-logo-tools:latest + # build: . # Uncomment to build locally instead + container_name: emby-logo-tools + restart: unless-stopped + environment: + - EMBY_SERVER_URL=${EMBY_SERVER_URL} + - EMBY_API_KEY=${EMBY_API_KEY} + - MODE=${MODE:-copy} + - DRY_RUN=${DRY_RUN:-true} + - FIRST_ONLY=${FIRST_ONLY:-false} + - FORCE=${FORCE:-false} + - TAGS=${TAGS:-} + - LIST_TAGS=${LIST_TAGS:-false} + - CRON_SCHEDULE=${CRON_SCHEDULE:-0 3 * * *} + - RUN_ONCE=${RUN_ONCE:-false} + - TZ=${TZ:-UTC} diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..c348d72 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,142 @@ +#!/bin/bash +set -e + +# Function to build the command based on environment variables +build_command() { + local cmd="python3 /app/update_channel_logos.py" + + # Required parameters + cmd="$cmd --server \"$EMBY_SERVER_URL\"" + cmd="$cmd --api-key \"$EMBY_API_KEY\"" + + # Handle --list-tags mode (takes precedence) + if [ "${LIST_TAGS:-false}" = "true" ]; then + cmd="$cmd --list-tags" + echo "$cmd" + return + fi + + # Optional parameters + if [ "${MODE:-copy}" = "clear" ]; then + cmd="$cmd --clear" + fi + + if [ "${DRY_RUN:-true}" != "true" ]; then + cmd="$cmd --execute" + fi + + if [ "${FIRST_ONLY:-false}" = "true" ]; then + cmd="$cmd --first-only" + fi + + if [ "${FORCE:-false}" = "true" ] && [ "${MODE:-copy}" = "copy" ]; then + cmd="$cmd --force" + fi + + # Add tags if specified + if [ -n "${TAGS:-}" ]; then + cmd="$cmd --tags \"$TAGS\"" + fi + + # Add non-interactive flag + cmd="$cmd --non-interactive" + + echo "$cmd" +} + +# Validate required environment variables +if [ -z "$EMBY_SERVER_URL" ]; then + echo "ERROR: EMBY_SERVER_URL environment variable is required" + exit 1 +fi + +if [ -z "$EMBY_API_KEY" ]; then + echo "ERROR: EMBY_API_KEY environment variable is required" + exit 1 +fi + +# Get the cron schedule (default: daily at 3 AM) +CRON_SCHEDULE="${CRON_SCHEDULE:-0 3 * * *}" + +# Build the command +COMMAND=$(build_command) + +# Log the configuration +echo "==========================================" +echo "Emby TV Logo Tools - Docker Container" +echo "==========================================" +echo "Emby Server: $EMBY_SERVER_URL" +echo "Mode: ${MODE:-copy}" +echo "Dry Run: ${DRY_RUN:-true}" +echo "First Only: ${FIRST_ONLY:-false}" +echo "Force: ${FORCE:-false}" +echo "Tags: ${TAGS:-(none)}" +echo "Cron Schedule: $CRON_SCHEDULE" +echo "Timezone: ${TZ:-UTC}" +echo "==========================================" +echo "" + +# If RUN_ONCE is set, just run the command once and exit +if [ "${RUN_ONCE:-false}" = "true" ]; then + echo "Running once (RUN_ONCE=true)..." + eval "$COMMAND" + exit 0 +fi + +# Create cron job +echo "Setting up cron job..." +echo "$CRON_SCHEDULE $COMMAND >> /var/log/cron.log 2>&1" > /etc/cron.d/emby-logo-update + +# Give execution rights on the cron job +chmod 0644 /etc/cron.d/emby-logo-update + +# Create the log file +touch /var/log/cron.log + +# Apply cron job +crontab /etc/cron.d/emby-logo-update + +# Print next scheduled run times +echo "" +echo "Cron job installed. Next 5 scheduled runs:" +echo "==========================================" + +# Calculate next run times (this is approximate) +python3 - < List[Dict]: + """Filter channels by tags. Returns all channels if tags is None or empty.""" + if not tags: + return channels + + filtered = [] + for channel in channels: + channel_tags = channel.get('Tags', []) + if any(tag in channel_tags for tag in tags): + filtered.append(channel) + + return filtered + + +def get_all_unique_tags(channels: List[Dict]) -> List[str]: + """Get all unique tags from channels.""" + tags = set() + for channel in channels: + channel_tags = channel.get('Tags', []) + tags.update(channel_tags) + return sorted(list(tags)) + + def copy_primary_to_light_logos(client: EmbyClient, channel_id: str, channel_name: str, dry_run: bool = True, force: bool = False) -> bool: try: channel_data = client.get_channel_by_id(channel_id) @@ -172,8 +195,26 @@ def clear_channel_logos(client: EmbyClient, channel_id: str, channel_name: str, return False -def update_channel_logos(client: EmbyClient, dry_run: bool = True, first_only: bool = False, force: bool = False) -> None: +def update_channel_logos(client: EmbyClient, dry_run: bool = True, first_only: bool = False, force: bool = False, tags: Optional[List[str]] = None) -> None: channels = client.get_live_tv_channels() + + # Filter by tags if specified + if tags: + print(f"Filtering channels by tags: {', '.join(tags)}") + channels = filter_channels_by_tags(channels, tags) + if not channels: + print(f"\n⚠️ No channels found with the specified tags") + all_tags = get_all_unique_tags(client.get_live_tv_channels()) + if all_tags: + print(f"\nAvailable tags in your channels ({len(all_tags)}):") + for tag in all_tags[:20]: # Show first 20 tags + print(f" - {tag}") + if len(all_tags) > 20: + print(f" ... and {len(all_tags) - 20} more") + else: + print("No tags found in any channels") + return + channels_to_process = [channels[0]] if first_only else channels print("\n" + "=" * 80) @@ -208,8 +249,26 @@ def update_channel_logos(client: EmbyClient, dry_run: bool = True, first_only: b 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: +def clear_logos(client: EmbyClient, dry_run: bool = True, first_only: bool = False, tags: Optional[List[str]] = None) -> None: channels = client.get_live_tv_channels() + + # Filter by tags if specified + if tags: + print(f"Filtering channels by tags: {', '.join(tags)}") + channels = filter_channels_by_tags(channels, tags) + if not channels: + print(f"\n⚠️ No channels found with the specified tags") + all_tags = get_all_unique_tags(client.get_live_tv_channels()) + if all_tags: + print(f"\nAvailable tags in your channels ({len(all_tags)}):") + for tag in all_tags[:20]: # Show first 20 tags + print(f" - {tag}") + if len(all_tags) > 20: + print(f" ... and {len(all_tags) - 20} more") + else: + print("No tags found in any channels") + return + channels_to_process = [channels[0]] if first_only else channels print("\n" + "=" * 80) @@ -250,10 +309,21 @@ def main(): 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)') parser.add_argument('--force', action='store_true', help='Overwrite existing logos (only applies to copy mode)') + parser.add_argument('--non-interactive', action='store_true', help='Skip confirmation prompts (for automated execution)') + parser.add_argument('--tags', help='Comma-separated list of tags to filter channels (e.g., "sports,news")') + parser.add_argument('--list-tags', action='store_true', help='List all available tags and exit') args = parser.parse_args() - + + # Parse tags if provided + tags = None + if args.tags: + tags = [tag.strip() for tag in args.tags.split(',') if tag.strip()] + if not tags: + print("Warning: --tags specified but no valid tags found", file=sys.stderr) + tags = None + client = EmbyClient(args.server, args.api_key) - + try: print(f"Connecting to {args.server}...") channels = client.get_live_tv_channels() @@ -261,26 +331,39 @@ def main(): except Exception as e: print(f"✗ Connection failed: {e}", file=sys.stderr) sys.exit(1) + + # Handle --list-tags + if args.list_tags: + all_tags = get_all_unique_tags(channels) + if all_tags: + print(f"\nFound {len(all_tags)} unique tags:") + for tag in all_tags: + print(f" - {tag}") + else: + print("\nNo tags found in any channels") + sys.exit(0) 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) + tag_info = f" with tags [{', '.join(tags)}]" if tags else "" + if args.non_interactive or input(f"\nCLEAR ALL LOGOS from {target}{tag_info}? (yes/no): ").lower() == 'yes': + clear_logos(client, dry_run=False, first_only=args.first_only, tags=tags) else: print("Cancelled.") else: - clear_logos(client, dry_run=True, first_only=args.first_only) + clear_logos(client, dry_run=True, first_only=args.first_only, tags=tags) else: if args.execute: target = "first channel" if args.first_only else "all channels" action = "overwrite logos in" if args.force else "modify" - if input(f"\n{action.capitalize()} {target}? (yes/no): ").lower() == 'yes': - update_channel_logos(client, dry_run=False, first_only=args.first_only, force=args.force) + tag_info = f" with tags [{', '.join(tags)}]" if tags else "" + if args.non_interactive or input(f"\n{action.capitalize()} {target}{tag_info}? (yes/no): ").lower() == 'yes': + update_channel_logos(client, dry_run=False, first_only=args.first_only, force=args.force, tags=tags) else: print("Cancelled.") else: - update_channel_logos(client, dry_run=True, first_only=args.first_only, force=args.force) + update_channel_logos(client, dry_run=True, first_only=args.first_only, force=args.force, tags=tags) if __name__ == '__main__':