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:
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 %}
|
||||
Reference in New Issue
Block a user