diff --git a/Dockerfile b/Dockerfile index 558e703..2d32821 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,15 +9,18 @@ RUN apk add --no-cache \ curl \ tzdata \ dcron \ - && pip3 install --no-cache-dir yt-dlp --break-system-packages + && pip3 install --no-cache-dir yt-dlp flask --break-system-packages # Create directories RUN mkdir -p /downloads /config /app -# Copy scripts +# Copy scripts and web UI COPY sync.sh /app/sync.sh COPY entrypoint.sh /app/entrypoint.sh -RUN chmod +x /app/sync.sh /app/entrypoint.sh +COPY app.py /app/app.py +COPY templates/ /app/templates/ +COPY static/ /app/static/ +RUN chmod +x /app/sync.sh /app/entrypoint.sh /app/app.py # Set working directory WORKDIR /app @@ -30,6 +33,9 @@ ENV DOWNLOAD_DIR=/downloads \ SLEEP_INTERVAL=2 \ TZ=America/Phoenix +# Expose web UI port +EXPOSE 8080 + # Expose volume mount points VOLUME ["/downloads", "/config"] diff --git a/README.md b/README.md index b2eaa3c..35d5c8d 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,10 @@ # YouTube Channel Sync Docker -Automatically download and organize YouTube channel videos for Emby, Plex, or Jellyfin media servers. +Automatically download and organize YouTube channel videos for Emby, Plex, or Jellyfin media servers with a built-in web UI for easy configuration. ## Features +- **Web-based configuration interface** - No more editing text files! - Downloads videos in best quality up to 1080p (configurable) - Organizes videos by season folders (year-based) - Proper episode naming for media servers: `S2026E001 - Video Title - [VideoID].mp4` @@ -12,17 +13,19 @@ Automatically download and organize YouTube channel videos for Emby, Plex, or Je - Tracks downloaded videos to avoid re-downloading - Supports YouTube cookies for authentication (bypasses bot detection) - Scheduled automatic syncs via cron -- Lightweight Alpine Linux base (~200MB) +- Real-time sync status and logs +- Lightweight Alpine Linux base (~250MB) ## Quick Start (Unraid) 1. Install from Community Applications: Search for "YouTube Sync" 2. Configure paths: + - Web UI Port: `8080` (or choose another port) - Download Directory: `/mnt/user/media/YouTube` (or your media location) - Config Directory: `/mnt/user/appdata/youtube-sync` -3. Edit `/mnt/user/appdata/youtube-sync/channels.txt` and add your channels -4. (Optional) Add YouTube cookies to `/mnt/user/appdata/youtube-sync/cookies.txt` -5. Start the container +3. Start the container +4. Open the web UI at `http://YOUR-SERVER-IP:8080` +5. Add channels, configure settings, and upload cookies (optional) through the web interface ## Configuration @@ -54,6 +57,20 @@ YouTube may block downloads without authentication. To fix this: yt-dlp --cookies-from-browser firefox --cookies cookies.txt --skip-download "https://www.youtube.com/watch?v=dQw4w9WgXcQ" ``` +## Web UI + +The container includes a built-in web interface for easy management: + +- **Dashboard**: View sync status, channel statistics, and recent activity +- **Channels**: Add, remove, and manage YouTube channels +- **Settings**: Configure sync schedule, video quality, and other options +- **Cookies**: Upload YouTube cookies for authentication +- **Logs**: Real-time view of sync progress and errors + +Access the web UI at: `http://YOUR-SERVER-IP:8080` + +All configuration can be done through the web interface - no need to manually edit config files! + ## Environment Variables | Variable | Default | Description | @@ -73,6 +90,8 @@ services: youtube-sync: image: azcomputerguru/youtube-sync:latest container_name: youtube-sync + ports: + - "8080:8080" environment: - SYNC_SCHEDULE=0 2 * * * - MAX_QUALITY=1080 @@ -91,6 +110,7 @@ services: ```bash docker run -d \ --name youtube-sync \ + -p 8080:8080 \ -e SYNC_SCHEDULE="0 2 * * *" \ -e MAX_QUALITY=1080 \ -e TZ=America/Phoenix \ diff --git a/app.py b/app.py new file mode 100644 index 0000000..efa84a9 --- /dev/null +++ b/app.py @@ -0,0 +1,289 @@ +#!/usr/bin/env python3 +""" +YouTube Sync Web Interface +Provides a web UI for managing YouTube channel downloads +""" + +import os +import subprocess +import json +from datetime import datetime +from pathlib import Path +from flask import Flask, render_template, request, jsonify, redirect, url_for, flash + +app = Flask(__name__) +app.secret_key = os.environ.get('SECRET_KEY', 'youtube-sync-secret-key-change-me') + +# Configuration +DOWNLOAD_DIR = os.environ.get('DOWNLOAD_DIR', '/downloads') +CONFIG_DIR = os.environ.get('CONFIG_DIR', '/config') +CHANNELS_FILE = os.path.join(CONFIG_DIR, 'channels.txt') +COOKIES_FILE = os.path.join(CONFIG_DIR, 'cookies.txt') +SETTINGS_FILE = os.path.join(CONFIG_DIR, 'settings.json') +LOG_FILE = '/var/log/youtube-sync.log' + +def get_settings(): + """Load settings from file or return defaults""" + defaults = { + 'sync_schedule': os.environ.get('SYNC_SCHEDULE', '0 2 * * *'), + 'max_quality': os.environ.get('MAX_QUALITY', '1080'), + 'sleep_interval': os.environ.get('SLEEP_INTERVAL', '2'), + 'timezone': os.environ.get('TZ', 'America/Phoenix') + } + + if os.path.exists(SETTINGS_FILE): + try: + with open(SETTINGS_FILE, 'r') as f: + return json.load(f) + except: + pass + + return defaults + +def save_settings(settings): + """Save settings to file""" + with open(SETTINGS_FILE, 'w') as f: + json.dump(settings, f, indent=2) + +def get_channels(): + """Read channels from channels.txt""" + channels = [] + + if not os.path.exists(CHANNELS_FILE): + return channels + + with open(CHANNELS_FILE, 'r') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#'): + parts = line.split('|') + if len(parts) == 2: + channels.append({ + 'id': parts[0].strip(), + 'name': parts[1].strip() + }) + + return channels + +def save_channels(channels): + """Write channels to channels.txt""" + with open(CHANNELS_FILE, 'w') as f: + f.write('# YouTube Channel Configuration\n') + f.write('# Format: CHANNEL_ID|Channel Name\n') + f.write('# One channel per line. Lines starting with # are ignored.\n') + f.write('#\n') + for channel in channels: + f.write(f"{channel['id']}|{channel['name']}\n") + +def get_channel_stats(): + """Get statistics for each channel""" + stats = [] + + for channel in get_channels(): + channel_dir = os.path.join(DOWNLOAD_DIR, channel['name']) + video_count = 0 + total_size = 0 + last_sync = None + + if os.path.exists(channel_dir): + # Count videos (mp4 files) + for root, dirs, files in os.walk(channel_dir): + for file in files: + if file.endswith('.mp4'): + video_count += 1 + file_path = os.path.join(root, file) + total_size += os.path.getsize(file_path) + + # Get last sync time from .downloaded.txt + downloaded_file = os.path.join(channel_dir, '.downloaded.txt') + if os.path.exists(downloaded_file): + last_sync = datetime.fromtimestamp(os.path.getmtime(downloaded_file)) + + stats.append({ + 'name': channel['name'], + 'id': channel['id'], + 'video_count': video_count, + 'total_size': format_size(total_size), + 'last_sync': last_sync.strftime('%Y-%m-%d %H:%M') if last_sync else 'Never' + }) + + return stats + +def format_size(bytes): + """Format bytes into human readable size""" + for unit in ['B', 'KB', 'MB', 'GB', 'TB']: + if bytes < 1024.0: + return f"{bytes:.1f} {unit}" + bytes /= 1024.0 + return f"{bytes:.1f} PB" + +def get_logs(lines=100): + """Get last N lines from log file""" + if not os.path.exists(LOG_FILE): + return [] + + try: + result = subprocess.run( + ['tail', '-n', str(lines), LOG_FILE], + capture_output=True, + text=True + ) + return result.stdout.split('\n') + except: + return [] + +def is_sync_running(): + """Check if sync script is currently running""" + try: + result = subprocess.run( + ['pgrep', '-f', 'sync.sh'], + capture_output=True + ) + return result.returncode == 0 + except: + return False + +@app.route('/') +def index(): + """Dashboard page""" + stats = get_channel_stats() + settings = get_settings() + sync_running = is_sync_running() + has_cookies = os.path.exists(COOKIES_FILE) + + return render_template('index.html', + stats=stats, + settings=settings, + sync_running=sync_running, + has_cookies=has_cookies) + +@app.route('/channels') +def channels(): + """Channel management page""" + channels = get_channels() + return render_template('channels.html', channels=channels) + +@app.route('/channels/add', methods=['POST']) +def add_channel(): + """Add a new channel""" + channel_id = request.form.get('channel_id', '').strip() + channel_name = request.form.get('channel_name', '').strip() + + if not channel_id or not channel_name: + flash('Channel ID and Name are required', 'error') + return redirect(url_for('channels')) + + channels = get_channels() + channels.append({'id': channel_id, 'name': channel_name}) + save_channels(channels) + + flash(f'Added channel: {channel_name}', 'success') + return redirect(url_for('channels')) + +@app.route('/channels/delete/') +def delete_channel(index): + """Delete a channel""" + channels = get_channels() + + if 0 <= index < len(channels): + channel_name = channels[index]['name'] + del channels[index] + save_channels(channels) + flash(f'Deleted channel: {channel_name}', 'success') + else: + flash('Channel not found', 'error') + + return redirect(url_for('channels')) + +@app.route('/settings') +def settings(): + """Settings page""" + settings = get_settings() + return render_template('settings.html', settings=settings) + +@app.route('/settings/save', methods=['POST']) +def save_settings_route(): + """Save settings""" + settings = { + 'sync_schedule': request.form.get('sync_schedule', '0 2 * * *'), + 'max_quality': request.form.get('max_quality', '1080'), + 'sleep_interval': request.form.get('sleep_interval', '2'), + 'timezone': request.form.get('timezone', 'America/Phoenix') + } + + save_settings(settings) + flash('Settings saved successfully', 'success') + return redirect(url_for('settings')) + +@app.route('/cookies', methods=['GET', 'POST']) +def cookies(): + """Cookie management page""" + if request.method == 'POST': + if 'cookies_file' not in request.files: + flash('No file selected', 'error') + return redirect(url_for('cookies')) + + file = request.files['cookies_file'] + + if file.filename == '': + flash('No file selected', 'error') + return redirect(url_for('cookies')) + + file.save(COOKIES_FILE) + flash('Cookies file uploaded successfully', 'success') + return redirect(url_for('index')) + + has_cookies = os.path.exists(COOKIES_FILE) + return render_template('cookies.html', has_cookies=has_cookies) + +@app.route('/cookies/delete') +def delete_cookies(): + """Delete cookies file""" + if os.path.exists(COOKIES_FILE): + os.remove(COOKIES_FILE) + flash('Cookies file deleted', 'success') + + return redirect(url_for('cookies')) + +@app.route('/logs') +def logs(): + """Logs page""" + log_lines = get_logs(200) + return render_template('logs.html', logs=log_lines) + +@app.route('/api/sync/start', methods=['POST']) +def start_sync(): + """Start manual sync""" + if is_sync_running(): + return jsonify({'status': 'error', 'message': 'Sync already running'}), 409 + + try: + # Start sync in background + subprocess.Popen( + ['/app/sync.sh'], + stdout=open(LOG_FILE, 'a'), + stderr=subprocess.STDOUT + ) + return jsonify({'status': 'success', 'message': 'Sync started'}) + except Exception as e: + return jsonify({'status': 'error', 'message': str(e)}), 500 + +@app.route('/api/sync/status') +def sync_status(): + """Get sync status""" + return jsonify({ + 'running': is_sync_running() + }) + +@app.route('/api/stats') +def api_stats(): + """Get channel statistics""" + return jsonify(get_channel_stats()) + +if __name__ == '__main__': + # Ensure directories exist + os.makedirs(CONFIG_DIR, exist_ok=True) + os.makedirs(DOWNLOAD_DIR, exist_ok=True) + + # Run Flask app + app.run(host='0.0.0.0', port=8080, debug=False) diff --git a/entrypoint.sh b/entrypoint.sh index 3b43221..800552c 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -12,6 +12,7 @@ echo "Sync Schedule: $SYNC_SCHEDULE" echo "Max Quality: ${MAX_QUALITY}p" echo "Sleep Interval: ${SLEEP_INTERVAL}s" echo "Timezone: $TZ" +echo "Web UI: http://localhost:8080" echo "==========================================" # Create example channels file if it doesn't exist @@ -33,6 +34,9 @@ if [ ! -f "$CONFIG_DIR/channels.txt" ]; then EOF fi +# Create log file +touch /var/log/youtube-sync.log + # Set up cron job if SYNC_SCHEDULE is provided if [ "$SYNC_SCHEDULE" != "manual" ]; then echo "[INFO] Setting up cron schedule: $SYNC_SCHEDULE" @@ -42,15 +46,11 @@ if [ "$SYNC_SCHEDULE" != "manual" ]; then crond -f -l 2 & CRON_PID=$! echo "[INFO] Cron daemon started (PID: $CRON_PID)" - - # Run initial sync - echo "[INFO] Running initial sync..." - /app/sync.sh || true - - # Keep container running and monitor cron - echo "[INFO] Container ready. Watching for scheduled syncs..." - wait $CRON_PID else - echo "[INFO] Manual mode enabled. Running sync once..." - /app/sync.sh + echo "[INFO] Manual mode enabled. Use Web UI to trigger syncs." fi + +# Start Flask web UI +echo "[INFO] Starting web UI on port 8080..." +cd /app +exec python3 app.py diff --git a/static/script.js b/static/script.js new file mode 100644 index 0000000..0d66ab3 --- /dev/null +++ b/static/script.js @@ -0,0 +1,34 @@ +// Auto-dismiss alerts after 5 seconds +document.addEventListener('DOMContentLoaded', function() { + const alerts = document.querySelectorAll('.alert'); + alerts.forEach(alert => { + setTimeout(() => { + alert.style.opacity = '0'; + alert.style.transition = 'opacity 0.5s'; + setTimeout(() => alert.remove(), 500); + }, 5000); + }); +}); + +// Confirm navigation away from form if modified +document.addEventListener('DOMContentLoaded', function() { + const forms = document.querySelectorAll('form'); + forms.forEach(form => { + let formModified = false; + + form.addEventListener('input', () => { + formModified = true; + }); + + form.addEventListener('submit', () => { + formModified = false; + }); + + window.addEventListener('beforeunload', (e) => { + if (formModified) { + e.preventDefault(); + e.returnValue = ''; + } + }); + }); +}); diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..23ece10 --- /dev/null +++ b/static/style.css @@ -0,0 +1,440 @@ +/* Reset and Base Styles */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --primary: #2563eb; + --primary-hover: #1d4ed8; + --secondary: #64748b; + --success: #10b981; + --warning: #f59e0b; + --danger: #ef4444; + --light: #f8fafc; + --dark: #1e293b; + --border: #e2e8f0; + --text: #334155; + --text-light: #64748b; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + line-height: 1.6; + color: var(--text); + background: var(--light); + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* Navigation */ +.navbar { + background: white; + border-bottom: 1px solid var(--border); + padding: 0; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); +} + +.navbar .container { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 2rem; + max-width: 1200px; + margin: 0 auto; +} + +.nav-brand h1 { + font-size: 1.5rem; + color: var(--primary); +} + +.nav-menu { + list-style: none; + display: flex; + gap: 2rem; +} + +.nav-menu a { + text-decoration: none; + color: var(--text); + font-weight: 500; + transition: color 0.2s; + padding: 0.5rem 0; + border-bottom: 2px solid transparent; +} + +.nav-menu a:hover { + color: var(--primary); +} + +.nav-menu a.active { + color: var(--primary); + border-bottom-color: var(--primary); +} + +/* Container */ +.container { + max-width: 1200px; + margin: 0 auto; + padding: 2rem; + flex: 1; +} + +/* Page Header */ +.page-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; +} + +.page-header h2 { + font-size: 2rem; + color: var(--dark); +} + +.header-actions { + display: flex; + gap: 1rem; +} + +/* Cards */ +.card { + background: white; + border-radius: 8px; + padding: 1.5rem; + margin-bottom: 1.5rem; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); +} + +.card h3 { + font-size: 1.25rem; + color: var(--dark); + margin-bottom: 1rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--border); +} + +.cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 1.5rem; + margin-bottom: 1.5rem; +} + +.info-card { + background: #eff6ff; + border-left: 4px solid var(--primary); +} + +.info-card h3 { + color: var(--primary); +} + +/* Status and Config Info */ +.status-info, .config-info { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.status-item, .config-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0; +} + +.status-item .label, .config-item .label { + font-weight: 500; + color: var(--text-light); +} + +.status-item .value, .config-item .value { + font-weight: 600; +} + +.status-ok { + color: var(--success); +} + +.status-warning { + color: var(--warning); +} + +.status-running { + color: var(--primary); +} + +.status-idle { + color: var(--text-light); +} + +/* Tables */ +.table-responsive { + overflow-x: auto; +} + +.table { + width: 100%; + border-collapse: collapse; +} + +.table thead { + background: var(--light); +} + +.table th { + text-align: left; + padding: 0.75rem; + font-weight: 600; + color: var(--text-light); + font-size: 0.875rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.table td { + padding: 0.75rem; + border-bottom: 1px solid var(--border); +} + +.table tbody tr:hover { + background: var(--light); +} + +.table code { + background: var(--light); + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.875rem; +} + +/* Forms */ +.form { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.form-group label { + font-weight: 500; + color: var(--dark); +} + +.form-control { + padding: 0.75rem; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 1rem; + transition: border-color 0.2s; + font-family: inherit; +} + +.form-control:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); +} + +.form-help { + font-size: 0.875rem; + color: var(--text-light); +} + +.required { + color: var(--danger); +} + +/* Buttons */ +.btn { + padding: 0.75rem 1.5rem; + border: none; + border-radius: 6px; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + text-decoration: none; + display: inline-block; + text-align: center; +} + +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.btn-primary { + background: var(--primary); + color: white; +} + +.btn-primary:hover:not(:disabled) { + background: var(--primary-hover); +} + +.btn-secondary { + background: var(--secondary); + color: white; +} + +.btn-secondary:hover:not(:disabled) { + background: #475569; +} + +.btn-danger { + background: var(--danger); + color: white; +} + +.btn-danger:hover:not(:disabled) { + background: #dc2626; +} + +.btn-sm { + padding: 0.5rem 1rem; + font-size: 0.875rem; +} + +/* Alerts */ +.alert { + padding: 1rem; + border-radius: 6px; + margin-bottom: 1.5rem; + position: relative; + display: flex; + justify-content: space-between; + align-items: center; +} + +.alert-success { + background: #d1fae5; + color: #065f46; + border-left: 4px solid var(--success); +} + +.alert-error { + background: #fee2e2; + color: #991b1b; + border-left: 4px solid var(--danger); +} + +.alert-warning { + background: #fef3c7; + color: #92400e; + border-left: 4px solid var(--warning); +} + +.alert .close { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: inherit; + opacity: 0.7; +} + +.alert .close:hover { + opacity: 1; +} + +/* Empty State */ +.empty-state { + text-align: center; + padding: 3rem 1rem; + color: var(--text-light); +} + +.empty-state p { + margin-bottom: 1rem; +} + +/* Logs */ +.logs-container { + max-height: 600px; + overflow-y: auto; + background: var(--dark); + border-radius: 6px; +} + +.logs { + color: #e2e8f0; + font-family: 'Courier New', monospace; + font-size: 0.875rem; + line-height: 1.4; + padding: 1rem; + margin: 0; + white-space: pre-wrap; + word-wrap: break-word; +} + +/* Spinner */ +.spinner { + display: inline-block; + width: 1rem; + height: 1rem; + border: 2px solid rgba(255,255,255,0.3); + border-top-color: white; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Footer */ +footer { + background: white; + border-top: 1px solid var(--border); + padding: 1.5rem 0; + margin-top: auto; +} + +footer .container { + text-align: center; + color: var(--text-light); + font-size: 0.875rem; +} + +/* Responsive */ +@media (max-width: 768px) { + .navbar .container { + flex-direction: column; + gap: 1rem; + } + + .nav-menu { + flex-direction: column; + gap: 0.5rem; + text-align: center; + } + + .page-header { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .cards { + grid-template-columns: 1fr; + } + + .table { + font-size: 0.875rem; + } + + .container { + padding: 1rem; + } +} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..26dc31b --- /dev/null +++ b/templates/base.html @@ -0,0 +1,49 @@ + + + + + + YouTube Sync - {% block title %}Dashboard{% endblock %} + + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
+ {{ message }} + +
+ {% endfor %} + {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+ + + + + {% block scripts %}{% endblock %} + + diff --git a/templates/channels.html b/templates/channels.html new file mode 100644 index 0000000..756cfc8 --- /dev/null +++ b/templates/channels.html @@ -0,0 +1,78 @@ +{% extends "base.html" %} + +{% block title %}Channels{% endblock %} + +{% block content %} + + +
+

Add New Channel

+
+
+ + + Find this in the channel URL or page source +
+ +
+ + + Display name for the channel folder +
+ + +
+
+ +
+

Configured Channels

+ {% if channels %} +
+ + + + + + + + + + {% for channel in channels %} + + + + + + {% endfor %} + +
Channel NameChannel IDActions
{{ channel.name }}{{ channel.id }} + View on YouTube + Delete +
+
+ {% else %} +
+

No channels configured yet. Add your first channel above to get started!

+
+ {% endif %} +
+ +
+

How to Find Channel IDs

+
    +
  1. Go to the YouTube channel page
  2. +
  3. Right-click and select "View Page Source" (or press Ctrl+U / Cmd+U)
  4. +
  5. Search for "channelId" (Ctrl+F / Cmd+F)
  6. +
  7. Copy the value that looks like: UCfDNi1aEljAQ17mUrfUjkvg
  8. +
+

Alternative: Some channel URLs include the ID directly:
+ youtube.com/channel/CHANNEL_ID_HERE/videos

+
+{% endblock %} diff --git a/templates/cookies.html b/templates/cookies.html new file mode 100644 index 0000000..4d5f286 --- /dev/null +++ b/templates/cookies.html @@ -0,0 +1,80 @@ +{% extends "base.html" %} + +{% block title %}Cookies{% endblock %} + +{% block content %} + + +
+

Cookie Status

+
+
+ Status: + + {% if has_cookies %} + Cookies file configured + {% else %} + No cookies file uploaded + {% endif %} + +
+
+ + {% if has_cookies %} +
+ Success! YouTube cookies are configured. This helps prevent "Sign in to confirm you're not a bot" errors. +
+ + Delete Cookies + + {% else %} +
+ Warning: Without cookies, YouTube may block downloads with "Sign in to confirm you're not a bot" errors. +
+ {% endif %} +
+ +
+

Upload Cookies File

+
+
+ + + Must be in Netscape format (cookies.txt) +
+ + +
+
+ +
+

How to Export YouTube Cookies

+ +

Method 1: Browser Extension (Recommended)

+
    +
  1. Install the "Get cookies.txt LOCALLY" extension for Firefox or Chrome: + +
  2. +
  3. Go to youtube.com and make sure you're logged in
  4. +
  5. Click the extension icon in your browser toolbar
  6. +
  7. Click "Export" or "Download" to save cookies.txt
  8. +
  9. Upload the file here
  10. +
+ +

Method 2: Using yt-dlp (Advanced)

+

If you have yt-dlp installed locally:

+
yt-dlp --cookies-from-browser firefox --cookies cookies.txt --skip-download "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
+

Replace firefox with chrome, edge, or safari as needed.

+ +

Why Are Cookies Needed?

+

YouTube uses bot detection to prevent automated downloads. By providing cookies from a logged-in browser session, yt-dlp can authenticate as your browser and avoid these restrictions.

+ +

Security Note: Cookies contain authentication tokens. Only upload cookies to trusted applications. The cookie file stays on your server and is not transmitted elsewhere.

+
+{% endblock %} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..26e435e --- /dev/null +++ b/templates/index.html @@ -0,0 +1,144 @@ +{% extends "base.html" %} + +{% block title %}Dashboard{% endblock %} + +{% block content %} + + +
+
+

Status

+
+
+ Sync Status: + + {% if sync_running %}Running{% else %}Idle{% endif %} + +
+
+ Channels Configured: + {{ stats|length }} +
+
+ Cookies: + + {% if has_cookies %}Configured{% else %}Not Set{% endif %} + +
+
+ Schedule: + {{ settings.sync_schedule }} +
+
+
+ +
+

Configuration

+
+
+ Max Quality: + {{ settings.max_quality }}p +
+
+ Sleep Interval: + {{ settings.sleep_interval }}s +
+
+ Timezone: + {{ settings.timezone }} +
+
+
+
+ +
+

Channels

+ {% if stats %} +
+ + + + + + + + + + + {% for channel in stats %} + + + + + + + {% endfor %} + +
Channel NameVideosTotal SizeLast Sync
{{ channel.name }}{{ channel.video_count }}{{ channel.total_size }}{{ channel.last_sync }}
+
+ {% else %} +
+

No channels configured.

+ Add Your First Channel +
+ {% endif %} +
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/logs.html b/templates/logs.html new file mode 100644 index 0000000..e122637 --- /dev/null +++ b/templates/logs.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} + +{% block title %}Logs{% endblock %} + +{% block content %} + + +
+
+ {% if logs %} +
{% for line in logs %}{{ line }}
+{% endfor %}
+ {% else %} +
+

No logs available yet. Run a sync to see output here.

+
+ {% endif %} +
+
+ +
+

About Logs

+

This page shows the last 200 lines of sync output. Logs include:

+ +

Tip: Use the Refresh button to see the latest log output while a sync is running.

+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/settings.html b/templates/settings.html new file mode 100644 index 0000000..9761bd1 --- /dev/null +++ b/templates/settings.html @@ -0,0 +1,76 @@ +{% extends "base.html" %} + +{% block title %}Settings{% endblock %} + +{% block content %} + + +
+
+

Sync Configuration

+ +
+ + + + Cron schedule for automatic syncs. Examples:
+ 0 2 * * * = Every day at 2:00 AM
+ 0 */6 * * * = Every 6 hours
+ 0 0 * * 0 = Every Sunday at midnight
+ manual = Disable automatic syncs +
+
+ +
+ + + Maximum resolution to download. Higher quality = larger file sizes. +
+ +
+ + + Delay between downloads to avoid rate limiting (recommended: 2-5 seconds) +
+ +
+ + + Timezone for scheduling automatic syncs +
+ + +
+
+ +
+

About Settings

+ +
+{% endblock %} diff --git a/youtube-sync.xml b/youtube-sync.xml index 4514be2..903eaa9 100644 --- a/youtube-sync.xml +++ b/youtube-sync.xml @@ -10,9 +10,10 @@ https://github.com/azcomputerguru/youtube-sync-docker https://github.com/azcomputerguru/youtube-sync-docker - Automatically download and organize YouTube channel videos for Emby/Plex/Jellyfin. + Automatically download and organize YouTube channel videos for Emby/Plex/Jellyfin with a built-in web UI. Features: + - Web-based configuration interface - Downloads videos in best quality up to 1080p (configurable) - Organizes by season folders (by year) - Creates proper episode naming for media servers @@ -21,14 +22,12 @@ - Tracks downloaded videos to avoid duplicates - Supports YouTube cookies for authentication - Scheduled automatic syncs via cron + - Real-time sync status and logs - Configuration: - 1. Edit /mnt/user/appdata/youtube-sync/channels.txt - 2. Add your channels in format: CHANNEL_ID|Channel Name - 3. (Optional) Add YouTube cookies to /mnt/user/appdata/youtube-sync/cookies.txt + Access the web UI at http://YOUR-SERVER-IP:8080 MediaApp:Video MediaServer:Video - + http://[IP]:[PORT:8080]/ https://raw.githubusercontent.com/azcomputerguru/youtube-sync-docker/main/youtube-sync.xml https://raw.githubusercontent.com/azcomputerguru/youtube-sync-docker/main/icon.png @@ -38,6 +37,7 @@ + 8080 /mnt/user/media/YouTube /mnt/user/appdata/youtube-sync 0 2 * * *