Add web UI for configuration and management

- Flask-based web interface on port 8080
- Dashboard with channel statistics and sync status
- Channel management (add/remove channels via UI)
- Settings page for all configuration options
- Cookie file upload interface
- Real-time log viewer
- Manual sync trigger from web UI
- Updated Dockerfile to include Flask and web assets
- Updated Unraid template with WebUI port
- Updated README with web UI documentation
This commit is contained in:
2026-05-08 19:00:36 -04:00
parent 0ffb54e12e
commit b3f378a8ef
13 changed files with 1290 additions and 24 deletions

View File

@@ -9,15 +9,18 @@ RUN apk add --no-cache \
curl \ curl \
tzdata \ tzdata \
dcron \ dcron \
&& pip3 install --no-cache-dir yt-dlp --break-system-packages && pip3 install --no-cache-dir yt-dlp flask --break-system-packages
# Create directories # Create directories
RUN mkdir -p /downloads /config /app RUN mkdir -p /downloads /config /app
# Copy scripts # Copy scripts and web UI
COPY sync.sh /app/sync.sh COPY sync.sh /app/sync.sh
COPY entrypoint.sh /app/entrypoint.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 # Set working directory
WORKDIR /app WORKDIR /app
@@ -30,6 +33,9 @@ ENV DOWNLOAD_DIR=/downloads \
SLEEP_INTERVAL=2 \ SLEEP_INTERVAL=2 \
TZ=America/Phoenix TZ=America/Phoenix
# Expose web UI port
EXPOSE 8080
# Expose volume mount points # Expose volume mount points
VOLUME ["/downloads", "/config"] VOLUME ["/downloads", "/config"]

View File

@@ -1,9 +1,10 @@
# YouTube Channel Sync Docker # 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 ## Features
- **Web-based configuration interface** - No more editing text files!
- Downloads videos in best quality up to 1080p (configurable) - Downloads videos in best quality up to 1080p (configurable)
- Organizes videos by season folders (year-based) - Organizes videos by season folders (year-based)
- Proper episode naming for media servers: `S2026E001 - Video Title - [VideoID].mp4` - 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 - Tracks downloaded videos to avoid re-downloading
- Supports YouTube cookies for authentication (bypasses bot detection) - Supports YouTube cookies for authentication (bypasses bot detection)
- Scheduled automatic syncs via cron - Scheduled automatic syncs via cron
- Lightweight Alpine Linux base (~200MB) - Real-time sync status and logs
- Lightweight Alpine Linux base (~250MB)
## Quick Start (Unraid) ## Quick Start (Unraid)
1. Install from Community Applications: Search for "YouTube Sync" 1. Install from Community Applications: Search for "YouTube Sync"
2. Configure paths: 2. Configure paths:
- Web UI Port: `8080` (or choose another port)
- Download Directory: `/mnt/user/media/YouTube` (or your media location) - Download Directory: `/mnt/user/media/YouTube` (or your media location)
- Config Directory: `/mnt/user/appdata/youtube-sync` - Config Directory: `/mnt/user/appdata/youtube-sync`
3. Edit `/mnt/user/appdata/youtube-sync/channels.txt` and add your channels 3. Start the container
4. (Optional) Add YouTube cookies to `/mnt/user/appdata/youtube-sync/cookies.txt` 4. Open the web UI at `http://YOUR-SERVER-IP:8080`
5. Start the container 5. Add channels, configure settings, and upload cookies (optional) through the web interface
## Configuration ## 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" 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 ## Environment Variables
| Variable | Default | Description | | Variable | Default | Description |
@@ -73,6 +90,8 @@ services:
youtube-sync: youtube-sync:
image: azcomputerguru/youtube-sync:latest image: azcomputerguru/youtube-sync:latest
container_name: youtube-sync container_name: youtube-sync
ports:
- "8080:8080"
environment: environment:
- SYNC_SCHEDULE=0 2 * * * - SYNC_SCHEDULE=0 2 * * *
- MAX_QUALITY=1080 - MAX_QUALITY=1080
@@ -91,6 +110,7 @@ services:
```bash ```bash
docker run -d \ docker run -d \
--name youtube-sync \ --name youtube-sync \
-p 8080:8080 \
-e SYNC_SCHEDULE="0 2 * * *" \ -e SYNC_SCHEDULE="0 2 * * *" \
-e MAX_QUALITY=1080 \ -e MAX_QUALITY=1080 \
-e TZ=America/Phoenix \ -e TZ=America/Phoenix \

289
app.py Normal file
View File

@@ -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/<int:index>')
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)

View File

@@ -12,6 +12,7 @@ echo "Sync Schedule: $SYNC_SCHEDULE"
echo "Max Quality: ${MAX_QUALITY}p" echo "Max Quality: ${MAX_QUALITY}p"
echo "Sleep Interval: ${SLEEP_INTERVAL}s" echo "Sleep Interval: ${SLEEP_INTERVAL}s"
echo "Timezone: $TZ" echo "Timezone: $TZ"
echo "Web UI: http://localhost:8080"
echo "==========================================" echo "=========================================="
# Create example channels file if it doesn't exist # Create example channels file if it doesn't exist
@@ -33,6 +34,9 @@ if [ ! -f "$CONFIG_DIR/channels.txt" ]; then
EOF EOF
fi fi
# Create log file
touch /var/log/youtube-sync.log
# Set up cron job if SYNC_SCHEDULE is provided # Set up cron job if SYNC_SCHEDULE is provided
if [ "$SYNC_SCHEDULE" != "manual" ]; then if [ "$SYNC_SCHEDULE" != "manual" ]; then
echo "[INFO] Setting up cron schedule: $SYNC_SCHEDULE" echo "[INFO] Setting up cron schedule: $SYNC_SCHEDULE"
@@ -42,15 +46,11 @@ if [ "$SYNC_SCHEDULE" != "manual" ]; then
crond -f -l 2 & crond -f -l 2 &
CRON_PID=$! CRON_PID=$!
echo "[INFO] Cron daemon started (PID: $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 else
echo "[INFO] Manual mode enabled. Running sync once..." echo "[INFO] Manual mode enabled. Use Web UI to trigger syncs."
/app/sync.sh
fi fi
# Start Flask web UI
echo "[INFO] Starting web UI on port 8080..."
cd /app
exec python3 app.py

34
static/script.js Normal file
View File

@@ -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 = '';
}
});
});
});

440
static/style.css Normal file
View File

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

49
templates/base.html Normal file
View File

@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>YouTube Sync - {% block title %}Dashboard{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<nav class="navbar">
<div class="container">
<div class="nav-brand">
<h1>YouTube Sync</h1>
</div>
<ul class="nav-menu">
<li><a href="{{ url_for('index') }}" class="{% if request.endpoint == 'index' %}active{% endif %}">Dashboard</a></li>
<li><a href="{{ url_for('channels') }}" class="{% if request.endpoint == 'channels' %}active{% endif %}">Channels</a></li>
<li><a href="{{ url_for('settings') }}" class="{% if request.endpoint == 'settings' %}active{% endif %}">Settings</a></li>
<li><a href="{{ url_for('cookies') }}" class="{% if request.endpoint == 'cookies' %}active{% endif %}">Cookies</a></li>
<li><a href="{{ url_for('logs') }}" class="{% if request.endpoint == 'logs' %}active{% endif %}">Logs</a></li>
</ul>
</div>
</nav>
<main class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">
{{ message }}
<button class="close" onclick="this.parentElement.remove()">×</button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
<footer>
<div class="container">
<p>YouTube Sync Docker &copy; 2026 Arizona Computer Guru LLC</p>
</div>
</footer>
<script src="{{ url_for('static', filename='script.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>

78
templates/channels.html Normal file
View File

@@ -0,0 +1,78 @@
{% extends "base.html" %}
{% block title %}Channels{% endblock %}
{% block content %}
<div class="page-header">
<h2>Channel Management</h2>
</div>
<div class="card">
<h3>Add New Channel</h3>
<form method="POST" action="{{ url_for('add_channel') }}" class="form">
<div class="form-group">
<label for="channel_id">Channel ID <span class="required">*</span></label>
<input type="text" id="channel_id" name="channel_id" required
placeholder="UCfDNi1aEljAQ17mUrfUjkvg" class="form-control">
<small class="form-help">Find this in the channel URL or page source</small>
</div>
<div class="form-group">
<label for="channel_name">Channel Name <span class="required">*</span></label>
<input type="text" id="channel_name" name="channel_name" required
placeholder="Alton Brown" class="form-control">
<small class="form-help">Display name for the channel folder</small>
</div>
<button type="submit" class="btn btn-primary">Add Channel</button>
</form>
</div>
<div class="card">
<h3>Configured Channels</h3>
{% if channels %}
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Channel Name</th>
<th>Channel ID</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for channel in channels %}
<tr>
<td><strong>{{ channel.name }}</strong></td>
<td><code>{{ channel.id }}</code></td>
<td>
<a href="https://www.youtube.com/channel/{{ channel.id }}"
target="_blank" class="btn btn-sm btn-secondary">View on YouTube</a>
<a href="{{ url_for('delete_channel', index=loop.index0) }}"
class="btn btn-sm btn-danger"
onclick="return confirm('Delete {{ channel.name }}? Downloaded videos will not be deleted.')">Delete</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">
<p>No channels configured yet. Add your first channel above to get started!</p>
</div>
{% endif %}
</div>
<div class="card info-card">
<h3>How to Find Channel IDs</h3>
<ol>
<li>Go to the YouTube channel page</li>
<li>Right-click and select "View Page Source" (or press Ctrl+U / Cmd+U)</li>
<li>Search for "channelId" (Ctrl+F / Cmd+F)</li>
<li>Copy the value that looks like: <code>UCfDNi1aEljAQ17mUrfUjkvg</code></li>
</ol>
<p><strong>Alternative:</strong> Some channel URLs include the ID directly:<br>
<code>youtube.com/channel/<strong>CHANNEL_ID_HERE</strong>/videos</code></p>
</div>
{% endblock %}

80
templates/cookies.html Normal file
View File

@@ -0,0 +1,80 @@
{% extends "base.html" %}
{% block title %}Cookies{% endblock %}
{% block content %}
<div class="page-header">
<h2>YouTube Cookies</h2>
</div>
<div class="card">
<h3>Cookie Status</h3>
<div class="status-info">
<div class="status-item">
<span class="label">Status:</span>
<span class="value {% if has_cookies %}status-ok{% else %}status-warning{% endif %}">
{% if has_cookies %}
Cookies file configured
{% else %}
No cookies file uploaded
{% endif %}
</span>
</div>
</div>
{% if has_cookies %}
<div class="alert alert-success">
<strong>Success!</strong> YouTube cookies are configured. This helps prevent "Sign in to confirm you're not a bot" errors.
</div>
<a href="{{ url_for('delete_cookies') }}" class="btn btn-danger"
onclick="return confirm('Delete cookies file? You may experience download errors without it.')">
Delete Cookies
</a>
{% else %}
<div class="alert alert-warning">
<strong>Warning:</strong> Without cookies, YouTube may block downloads with "Sign in to confirm you're not a bot" errors.
</div>
{% endif %}
</div>
<div class="card">
<h3>Upload Cookies File</h3>
<form method="POST" action="{{ url_for('cookies') }}" enctype="multipart/form-data" class="form">
<div class="form-group">
<label for="cookies_file">Select cookies.txt file</label>
<input type="file" id="cookies_file" name="cookies_file" accept=".txt" class="form-control" required>
<small class="form-help">Must be in Netscape format (cookies.txt)</small>
</div>
<button type="submit" class="btn btn-primary">Upload Cookies</button>
</form>
</div>
<div class="card info-card">
<h3>How to Export YouTube Cookies</h3>
<h4>Method 1: Browser Extension (Recommended)</h4>
<ol>
<li>Install the <strong>"Get cookies.txt LOCALLY"</strong> extension for Firefox or Chrome:
<ul>
<li>Firefox: <a href="https://addons.mozilla.org/en-US/firefox/addon/cookies-txt/" target="_blank">addons.mozilla.org/firefox/addon/cookies-txt</a></li>
<li>Chrome: <a href="https://chrome.google.com/webstore/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc" target="_blank">Chrome Web Store</a></li>
</ul>
</li>
<li>Go to <strong>youtube.com</strong> and make sure you're logged in</li>
<li>Click the extension icon in your browser toolbar</li>
<li>Click "Export" or "Download" to save cookies.txt</li>
<li>Upload the file here</li>
</ol>
<h4>Method 2: Using yt-dlp (Advanced)</h4>
<p>If you have yt-dlp installed locally:</p>
<pre><code>yt-dlp --cookies-from-browser firefox --cookies cookies.txt --skip-download "https://www.youtube.com/watch?v=dQw4w9WgXcQ"</code></pre>
<p>Replace <code>firefox</code> with <code>chrome</code>, <code>edge</code>, or <code>safari</code> as needed.</p>
<h4>Why Are Cookies Needed?</h4>
<p>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.</p>
<p><strong>Security Note:</strong> Cookies contain authentication tokens. Only upload cookies to trusted applications. The cookie file stays on your server and is not transmitted elsewhere.</p>
</div>
{% endblock %}

144
templates/index.html Normal file
View File

@@ -0,0 +1,144 @@
{% extends "base.html" %}
{% block title %}Dashboard{% endblock %}
{% block content %}
<div class="page-header">
<h2>Dashboard</h2>
<div class="header-actions">
<button id="sync-btn" class="btn btn-primary" onclick="startSync()">
<span id="sync-text">Run Sync Now</span>
<span id="sync-spinner" class="spinner" style="display: none;"></span>
</button>
</div>
</div>
<div class="cards">
<div class="card">
<h3>Status</h3>
<div class="status-info">
<div class="status-item">
<span class="label">Sync Status:</span>
<span class="value {% if sync_running %}status-running{% else %}status-idle{% endif %}">
{% if sync_running %}Running{% else %}Idle{% endif %}
</span>
</div>
<div class="status-item">
<span class="label">Channels Configured:</span>
<span class="value">{{ stats|length }}</span>
</div>
<div class="status-item">
<span class="label">Cookies:</span>
<span class="value {% if has_cookies %}status-ok{% else %}status-warning{% endif %}">
{% if has_cookies %}Configured{% else %}Not Set{% endif %}
</span>
</div>
<div class="status-item">
<span class="label">Schedule:</span>
<span class="value">{{ settings.sync_schedule }}</span>
</div>
</div>
</div>
<div class="card">
<h3>Configuration</h3>
<div class="config-info">
<div class="config-item">
<span class="label">Max Quality:</span>
<span class="value">{{ settings.max_quality }}p</span>
</div>
<div class="config-item">
<span class="label">Sleep Interval:</span>
<span class="value">{{ settings.sleep_interval }}s</span>
</div>
<div class="config-item">
<span class="label">Timezone:</span>
<span class="value">{{ settings.timezone }}</span>
</div>
</div>
</div>
</div>
<div class="card">
<h3>Channels</h3>
{% if stats %}
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Channel Name</th>
<th>Videos</th>
<th>Total Size</th>
<th>Last Sync</th>
</tr>
</thead>
<tbody>
{% for channel in stats %}
<tr>
<td><strong>{{ channel.name }}</strong></td>
<td>{{ channel.video_count }}</td>
<td>{{ channel.total_size }}</td>
<td>{{ channel.last_sync }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">
<p>No channels configured.</p>
<a href="{{ url_for('channels') }}" class="btn btn-primary">Add Your First Channel</a>
</div>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script>
function startSync() {
const btn = document.getElementById('sync-btn');
const text = document.getElementById('sync-text');
const spinner = document.getElementById('sync-spinner');
btn.disabled = true;
text.style.display = 'none';
spinner.style.display = 'inline-block';
fetch('/api/sync/start', { method: 'POST' })
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
alert('Sync started! Check the Logs page to monitor progress.');
setTimeout(() => location.reload(), 2000);
} else {
alert('Error: ' + data.message);
btn.disabled = false;
text.style.display = 'inline';
spinner.style.display = 'none';
}
})
.catch(error => {
alert('Error starting sync: ' + error);
btn.disabled = false;
text.style.display = 'inline';
spinner.style.display = 'none';
});
}
// Auto-refresh status every 10 seconds
setInterval(() => {
fetch('/api/sync/status')
.then(response => response.json())
.then(data => {
const statusEl = document.querySelector('.status-info .value');
if (data.running) {
statusEl.textContent = 'Running';
statusEl.className = 'value status-running';
} else {
statusEl.textContent = 'Idle';
statusEl.className = 'value status-idle';
}
});
}, 10000);
</script>
{% endblock %}

50
templates/logs.html Normal file
View File

@@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block title %}Logs{% endblock %}
{% block content %}
<div class="page-header">
<h2>Sync Logs</h2>
<div class="header-actions">
<button onclick="location.reload()" class="btn btn-secondary">Refresh</button>
</div>
</div>
<div class="card">
<div class="logs-container">
{% if logs %}
<pre class="logs"><code>{% for line in logs %}{{ line }}
{% endfor %}</code></pre>
{% else %}
<div class="empty-state">
<p>No logs available yet. Run a sync to see output here.</p>
</div>
{% endif %}
</div>
</div>
<div class="card info-card">
<h3>About Logs</h3>
<p>This page shows the last 200 lines of sync output. Logs include:</p>
<ul>
<li>Download progress for each video</li>
<li>Errors and warnings</li>
<li>Sync start/completion times</li>
<li>Skipped videos (already downloaded)</li>
</ul>
<p><strong>Tip:</strong> Use the Refresh button to see the latest log output while a sync is running.</p>
</div>
{% endblock %}
{% block scripts %}
<script>
// Auto-refresh logs every 5 seconds if sync is running
fetch('/api/sync/status')
.then(response => response.json())
.then(data => {
if (data.running) {
setInterval(() => location.reload(), 5000);
}
});
</script>
{% endblock %}

76
templates/settings.html Normal file
View File

@@ -0,0 +1,76 @@
{% extends "base.html" %}
{% block title %}Settings{% endblock %}
{% block content %}
<div class="page-header">
<h2>Settings</h2>
</div>
<form method="POST" action="{{ url_for('save_settings_route') }}">
<div class="card">
<h3>Sync Configuration</h3>
<div class="form-group">
<label for="sync_schedule">Sync Schedule (Cron Format)</label>
<input type="text" id="sync_schedule" name="sync_schedule"
value="{{ settings.sync_schedule }}" class="form-control" required>
<small class="form-help">
Cron schedule for automatic syncs. Examples:<br>
<code>0 2 * * *</code> = Every day at 2:00 AM<br>
<code>0 */6 * * *</code> = Every 6 hours<br>
<code>0 0 * * 0</code> = Every Sunday at midnight<br>
<code>manual</code> = Disable automatic syncs
</small>
</div>
<div class="form-group">
<label for="max_quality">Maximum Video Quality</label>
<select id="max_quality" name="max_quality" class="form-control">
<option value="480" {% if settings.max_quality == '480' %}selected{% endif %}>480p</option>
<option value="720" {% if settings.max_quality == '720' %}selected{% endif %}>720p</option>
<option value="1080" {% if settings.max_quality == '1080' %}selected{% endif %}>1080p (Default)</option>
<option value="1440" {% if settings.max_quality == '1440' %}selected{% endif %}>1440p (2K)</option>
<option value="2160" {% if settings.max_quality == '2160' %}selected{% endif %}>2160p (4K)</option>
</select>
<small class="form-help">Maximum resolution to download. Higher quality = larger file sizes.</small>
</div>
<div class="form-group">
<label for="sleep_interval">Sleep Interval (seconds)</label>
<input type="number" id="sleep_interval" name="sleep_interval"
value="{{ settings.sleep_interval }}" class="form-control" min="0" max="10" required>
<small class="form-help">Delay between downloads to avoid rate limiting (recommended: 2-5 seconds)</small>
</div>
<div class="form-group">
<label for="timezone">Timezone</label>
<select id="timezone" name="timezone" class="form-control">
<option value="America/Phoenix" {% if settings.timezone == 'America/Phoenix' %}selected{% endif %}>America/Phoenix (MST)</option>
<option value="America/New_York" {% if settings.timezone == 'America/New_York' %}selected{% endif %}>America/New_York (EST)</option>
<option value="America/Chicago" {% if settings.timezone == 'America/Chicago' %}selected{% endif %}>America/Chicago (CST)</option>
<option value="America/Denver" {% if settings.timezone == 'America/Denver' %}selected{% endif %}>America/Denver (MST)</option>
<option value="America/Los_Angeles" {% if settings.timezone == 'America/Los_Angeles' %}selected{% endif %}>America/Los_Angeles (PST)</option>
<option value="Europe/London" {% if settings.timezone == 'Europe/London' %}selected{% endif %}>Europe/London (GMT)</option>
<option value="Europe/Paris" {% if settings.timezone == 'Europe/Paris' %}selected{% endif %}>Europe/Paris (CET)</option>
<option value="Asia/Tokyo" {% if settings.timezone == 'Asia/Tokyo' %}selected{% endif %}>Asia/Tokyo (JST)</option>
<option value="Australia/Sydney" {% if settings.timezone == 'Australia/Sydney' %}selected{% endif %}>Australia/Sydney (AEST)</option>
<option value="UTC" {% if settings.timezone == 'UTC' %}selected{% endif %}>UTC</option>
</select>
<small class="form-help">Timezone for scheduling automatic syncs</small>
</div>
<button type="submit" class="btn btn-primary">Save Settings</button>
</div>
</form>
<div class="card info-card">
<h3>About Settings</h3>
<ul>
<li><strong>Sync Schedule:</strong> Uses cron format (minute hour day month weekday). Changes take effect after container restart.</li>
<li><strong>Max Quality:</strong> Downloads best available quality up to this limit. Higher = more storage space.</li>
<li><strong>Sleep Interval:</strong> Wait time between downloads. Helps avoid YouTube rate limiting.</li>
<li><strong>Timezone:</strong> Used for scheduling. Make sure it matches your location.</li>
</ul>
</div>
{% endblock %}

View File

@@ -10,9 +10,10 @@
<Support>https://github.com/azcomputerguru/youtube-sync-docker</Support> <Support>https://github.com/azcomputerguru/youtube-sync-docker</Support>
<Project>https://github.com/azcomputerguru/youtube-sync-docker</Project> <Project>https://github.com/azcomputerguru/youtube-sync-docker</Project>
<Overview> <Overview>
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: Features:
- Web-based configuration interface
- Downloads videos in best quality up to 1080p (configurable) - Downloads videos in best quality up to 1080p (configurable)
- Organizes by season folders (by year) - Organizes by season folders (by year)
- Creates proper episode naming for media servers - Creates proper episode naming for media servers
@@ -21,14 +22,12 @@
- Tracks downloaded videos to avoid duplicates - Tracks downloaded videos to avoid duplicates
- Supports YouTube cookies for authentication - Supports YouTube cookies for authentication
- Scheduled automatic syncs via cron - Scheduled automatic syncs via cron
- Real-time sync status and logs
Configuration: Access the web UI at http://YOUR-SERVER-IP:8080
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
</Overview> </Overview>
<Category>MediaApp:Video MediaServer:Video</Category> <Category>MediaApp:Video MediaServer:Video</Category>
<WebUI/> <WebUI>http://[IP]:[PORT:8080]/</WebUI>
<TemplateURL>https://raw.githubusercontent.com/azcomputerguru/youtube-sync-docker/main/youtube-sync.xml</TemplateURL> <TemplateURL>https://raw.githubusercontent.com/azcomputerguru/youtube-sync-docker/main/youtube-sync.xml</TemplateURL>
<Icon>https://raw.githubusercontent.com/azcomputerguru/youtube-sync-docker/main/icon.png</Icon> <Icon>https://raw.githubusercontent.com/azcomputerguru/youtube-sync-docker/main/icon.png</Icon>
<ExtraParams/> <ExtraParams/>
@@ -38,6 +37,7 @@
<DonateText/> <DonateText/>
<DonateLink/> <DonateLink/>
<Requires/> <Requires/>
<Config Name="Web UI Port" Target="8080" Default="8080" Mode="tcp" Description="Port for web configuration interface" Type="Port" Display="always" Required="true" Mask="false">8080</Config>
<Config Name="Download Directory" Target="/downloads" Default="/mnt/user/media/YouTube" Mode="rw" Description="Where downloaded videos will be stored" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/media/YouTube</Config> <Config Name="Download Directory" Target="/downloads" Default="/mnt/user/media/YouTube" Mode="rw" Description="Where downloaded videos will be stored" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/media/YouTube</Config>
<Config Name="Config Directory" Target="/config" Default="/mnt/user/appdata/youtube-sync" Mode="rw" Description="Configuration files (channels.txt, cookies.txt)" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/youtube-sync</Config> <Config Name="Config Directory" Target="/config" Default="/mnt/user/appdata/youtube-sync" Mode="rw" Description="Configuration files (channels.txt, cookies.txt)" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/youtube-sync</Config>
<Config Name="Sync Schedule" Target="SYNC_SCHEDULE" Default="0 2 * * *" Mode="" Description="Cron schedule for automatic syncs (default: 2 AM daily). Use 'manual' to disable scheduling." Type="Variable" Display="always" Required="false" Mask="false">0 2 * * *</Config> <Config Name="Sync Schedule" Target="SYNC_SCHEDULE" Default="0 2 * * *" Mode="" Description="Cron schedule for automatic syncs (default: 2 AM daily). Use 'manual' to disable scheduling." Type="Variable" Display="always" Required="false" Mask="false">0 2 * * *</Config>