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

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 %}