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:
12
Dockerfile
12
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"]
|
||||
|
||||
|
||||
30
README.md
30
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 \
|
||||
|
||||
289
app.py
Normal file
289
app.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
34
static/script.js
Normal file
34
static/script.js
Normal 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
440
static/style.css
Normal 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
49
templates/base.html
Normal 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 © 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
78
templates/channels.html
Normal 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
80
templates/cookies.html
Normal 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
144
templates/index.html
Normal 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
50
templates/logs.html
Normal 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
76
templates/settings.html
Normal 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 %}
|
||||
@@ -10,9 +10,10 @@
|
||||
<Support>https://github.com/azcomputerguru/youtube-sync-docker</Support>
|
||||
<Project>https://github.com/azcomputerguru/youtube-sync-docker</Project>
|
||||
<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:
|
||||
- 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
|
||||
</Overview>
|
||||
<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>
|
||||
<Icon>https://raw.githubusercontent.com/azcomputerguru/youtube-sync-docker/main/icon.png</Icon>
|
||||
<ExtraParams/>
|
||||
@@ -38,6 +37,7 @@
|
||||
<DonateText/>
|
||||
<DonateLink/>
|
||||
<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="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>
|
||||
|
||||
Reference in New Issue
Block a user