1
0

containerize & enhance filtering
All checks were successful
Build & Push Docker Image / build-and-publish (push) Successful in 11s

This commit is contained in:
2026-02-08 11:58:22 -05:00
parent 25ef2adf71
commit 3efdc7b7bc
8 changed files with 518 additions and 13 deletions

31
.dockerignore Normal file
View File

@@ -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

36
.env.example Normal file
View File

@@ -0,0 +1,36 @@
# 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=
# 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 ---
# 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

View File

@@ -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

24
Dockerfile Normal file
View File

@@ -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"]

194
README.md
View File

@@ -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 - **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 - **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 ## 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 - Emby Server with API access
- API key from Emby Server - API key from Emby Server
@@ -19,7 +52,150 @@ Automatically copy or clear Live TV channel logos in Emby Server.
2. Go to Settings → Advanced → API Keys 2. Go to Settings → Advanced → API Keys
3. Create a new API key or use an existing one 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`) |
| `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
#### 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) ### Copy Logos (Default Mode)
@@ -76,6 +252,8 @@ python update_channel_logos.py \
| `--first-only` | Only process the first channel (useful for testing) | | `--first-only` | Only process the first channel (useful for testing) |
| `--clear` | Clear all logos instead of copying them | | `--clear` | Clear all logos instead of copying them |
| `--force` | Overwrite existing LogoLight/LogoLightColor (copy mode only) | | `--force` | Overwrite existing LogoLight/LogoLightColor (copy mode only) |
| `--tags` | Comma-separated list of tags to filter channels (e.g., `sports,news`) |
| `--non-interactive` | Skip confirmation prompts (for automated execution) |
## Examples ## Examples
@@ -96,15 +274,21 @@ python update_channel_logos.py \
python update_channel_logos.py --server URL --api-key KEY --execute python update_channel_logos.py --server URL --api-key KEY --execute
``` ```
4. Process only channels with specific tags:
```bash
python update_channel_logos.py --server URL --api-key KEY --tags sports,news --execute
```
## How It Works ## How It Works
1. Connects to your Emby server 1. Connects to your Emby server
2. Fetches all Live TV channels 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 - **Copy mode**: Downloads the Primary logo and uploads it as LogoLight and LogoLightColor
- **Clear mode**: Deletes Primary, 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. 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 6. Shows a summary of results
## Safety Features ## Safety Features

19
docker-compose.yml Normal file
View File

@@ -0,0 +1,19 @@
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:-}
- CRON_SCHEDULE=${CRON_SCHEDULE:-0 3 * * *}
- RUN_ONCE=${RUN_ONCE:-false}
- TZ=${TZ:-UTC}

135
entrypoint.sh Normal file
View File

@@ -0,0 +1,135 @@
#!/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\""
# 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 - <<EOF
from datetime import datetime, timedelta
import sys
schedule = "${CRON_SCHEDULE}"
parts = schedule.split()
if len(parts) != 5:
print("Invalid cron schedule format")
sys.exit(1)
minute, hour = parts[0], parts[1]
# Simple calculation for daily cron jobs
if parts[2] == '*' and parts[3] == '*' and parts[4] == '*':
now = datetime.now()
target_hour = int(hour) if hour != '*' else now.hour
target_minute = int(minute) if minute != '*' else 0
next_run = now.replace(hour=target_hour, minute=target_minute, second=0, microsecond=0)
if next_run <= now:
next_run += timedelta(days=1)
for i in range(5):
print(next_run.strftime("%Y-%m-%d %H:%M:%S"))
next_run += timedelta(days=1)
else:
print("(Schedule calculation only supports simple daily jobs)")
print("Your schedule: $CRON_SCHEDULE")
EOF
echo "=========================================="
echo ""
echo "Starting cron daemon..."
echo "Container is running. Logs will appear below."
echo ""
# Start cron in foreground and tail the log
cron && tail -f /var/log/cron.log

View File

@@ -92,6 +92,20 @@ class EmbyClient:
raise Exception(f"Failed to delete {image_type}: HTTP {e.code} - {error_body}") raise Exception(f"Failed to delete {image_type}: HTTP {e.code} - {error_body}")
def filter_channels_by_tags(channels: List[Dict], tags: Optional[List[str]]) -> 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 copy_primary_to_light_logos(client: EmbyClient, channel_id: str, channel_name: str, dry_run: bool = True, force: bool = False) -> bool: def copy_primary_to_light_logos(client: EmbyClient, channel_id: str, channel_name: str, dry_run: bool = True, force: bool = False) -> bool:
try: try:
channel_data = client.get_channel_by_id(channel_id) channel_data = client.get_channel_by_id(channel_id)
@@ -172,8 +186,17 @@ def clear_channel_logos(client: EmbyClient, channel_id: str, channel_name: str,
return False 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() 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")
return
channels_to_process = [channels[0]] if first_only else channels channels_to_process = [channels[0]] if first_only else channels
print("\n" + "=" * 80) print("\n" + "=" * 80)
@@ -208,8 +231,17 @@ 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.") 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() 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")
return
channels_to_process = [channels[0]] if first_only else channels channels_to_process = [channels[0]] if first_only else channels
print("\n" + "=" * 80) print("\n" + "=" * 80)
@@ -250,7 +282,17 @@ def main():
parser.add_argument('--first-only', action='store_true', help='Only process first channel') 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('--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('--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")')
args = parser.parse_args() 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) client = EmbyClient(args.server, args.api_key)
@@ -265,22 +307,24 @@ def main():
if args.clear: if args.clear:
if args.execute: if args.execute:
target = "first channel" if args.first_only else "all channels" target = "first channel" if args.first_only else "all channels"
if input(f"\nCLEAR ALL LOGOS from {target}? (yes/no): ").lower() == 'yes': tag_info = f" with tags [{', '.join(tags)}]" if tags else ""
clear_logos(client, dry_run=False, first_only=args.first_only) 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: else:
print("Cancelled.") print("Cancelled.")
else: 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: else:
if args.execute: if args.execute:
target = "first channel" if args.first_only else "all channels" target = "first channel" if args.first_only else "all channels"
action = "overwrite logos in" if args.force else "modify" action = "overwrite logos in" if args.force else "modify"
if input(f"\n{action.capitalize()} {target}? (yes/no): ").lower() == 'yes': tag_info = f" with tags [{', '.join(tags)}]" if tags else ""
update_channel_logos(client, dry_run=False, first_only=args.first_only, force=args.force) 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: else:
print("Cancelled.") print("Cancelled.")
else: 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__': if __name__ == '__main__':