feat: initialise project structure with core encoding queue, database models, and web management API
Build / Build and Push (push) Successful in 50s

This commit is contained in:
2026-06-05 19:53:31 +01:00
parent dceba72891
commit 07aa8e5ffe
33 changed files with 2911 additions and 1 deletions
+123
View File
@@ -0,0 +1,123 @@
{{define "content"}}
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 2rem;">
<div class="card" style="text-align: center; padding: 1.5rem;">
<h3 style="margin-top: 0; color: var(--text-secondary); font-size: 0.875rem; text-transform: uppercase;">Files Encoded</h3>
<div style="font-size: 2rem; font-weight: 700; color: var(--accent);">{{.FilesEncoded}}</div>
</div>
<div class="card" style="text-align: center; padding: 1.5rem;">
<h3 style="margin-top: 0; color: var(--text-secondary); font-size: 0.875rem; text-transform: uppercase;">Space Saved</h3>
<div style="font-size: 2rem; font-weight: 700; color: var(--accent);">{{.SavedSpace}}</div>
</div>
<div class="card" style="text-align: center; padding: 1.5rem;">
<h3 style="margin-top: 0; color: var(--text-secondary); font-size: 0.875rem; text-transform: uppercase;">In Queue</h3>
<div style="font-size: 2rem; font-weight: 700; color: var(--accent);">{{.QueueLength}}</div>
</div>
</div>
<div class="card">
<h2>Active Job Queue</h2>
<div class="table-container">
<table>
<thead>
<tr>
<th>ID</th>
<th>File</th>
<th>Type</th>
<th>Priority</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="queue-body">
<!-- Populated by JS -->
</tbody>
</table>
</div>
</div>
<script>
const queueBody = document.getElementById('queue-body');
async function loadQueue() {
const res = await fetch('/api/queue');
const jobs = await res.json();
renderQueue(jobs || []);
}
function renderQueue(jobs) {
queueBody.innerHTML = jobs.map(job => {
let statusBadge = `<span class="badge ${job.status}">${job.status}</span>`;
let progressHtml = '';
if (job.status === 'processing') {
progressHtml = `
<div class="progress-bar">
<div class="progress-fill" id="progress-${job.id}" style="width: 0%"></div>
</div>
<div style="font-size: 0.75rem; color: var(--text-secondary); margin-top: 0.25rem;" id="progress-text-${job.id}">0%</div>
`;
}
let actions = '';
if (job.status === 'pending') {
actions = `
<button class="btn btn-sm btn-outline" onclick="bumpJob(${job.id})">Bump</button>
<button class="btn btn-sm btn-danger" onclick="cancelJob(${job.id})">Cancel</button>
`;
} else if (job.status === 'processing') {
actions = `
<button class="btn btn-sm btn-danger" onclick="cancelJob(${job.id})">Cancel</button>
`;
} else if (job.status === 'failed') {
// Retry not fully implemented in API yet, but could re-enqueue
actions = `<span style="color: var(--error); font-size: 0.75rem;">${job.error_message}</span>`;
}
return `
<tr id="job-${job.id}">
<td>${job.id}</td>
<td style="word-break: break-all;">
${job.file_path.split('/').pop()}
${progressHtml}
</td>
<td><span class="badge" style="background: var(--surface-hover); color: var(--text-primary); border-color: var(--border);">${job.media_type}</span></td>
<td>${job.priority}</td>
<td>${statusBadge}</td>
<td>${actions}</td>
</tr>
`;
}).join('');
}
async function bumpJob(id) {
await fetch('/api/jobs/bump/' + id, { method: 'POST' });
loadQueue();
}
async function cancelJob(id) {
if (!confirm("Cancel job?")) return;
await fetch('/api/jobs/cancel/' + id, { method: 'POST' });
loadQueue();
}
// SSE listeners
window.sse.addEventListener('queue_updated', () => loadQueue());
window.sse.addEventListener('job_started', () => loadQueue());
window.sse.addEventListener('job_completed', () => loadQueue());
window.sse.addEventListener('job_failed', () => loadQueue());
window.sse.addEventListener('job_added', () => loadQueue());
window.sse.addEventListener('progress', (e) => {
const data = JSON.parse(e.data);
const fill = document.getElementById(`progress-${data.id}`);
const text = document.getElementById(`progress-text-${data.id}`);
if (fill && text) {
fill.style.width = `${data.progress}%`;
text.innerText = `${data.progress}%`;
}
});
// Initial load
loadQueue();
</script>
{{end}}
+152
View File
@@ -0,0 +1,152 @@
{{define "content"}}
<div style="display: flex; gap: 2rem; align-items: flex-start; flex-wrap: wrap;">
<div class="card" style="flex: 2; min-width: 600px;">
<h2>Configured Watch Folders</h2>
<div class="table-container">
<table>
<thead>
<tr>
<th>Path</th>
<th>Type</th>
<th>Resolution</th>
<th>Flags</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="folders-body">
<!-- Populated by JS -->
</tbody>
</table>
</div>
</div>
<div class="card" style="flex: 1; min-width: 300px;">
<h2>Add Watch Folder</h2>
<form id="add-folder-form" onsubmit="addFolder(event)">
<div class="form-group">
<label>Folder Path</label>
<input type="text" id="folder_path" class="form-control" placeholder="/media/movies" required>
</div>
<div class="form-group">
<label>Media Type</label>
<select id="media_type" class="form-control" required>
<option value="video">Video</option>
<option value="audio">Audio</option>
</select>
</div>
<div class="form-group">
<label>Target Resolution (Video only)</label>
<select id="target_resolution" class="form-control">
<option value="">Original</option>
<option value="1080p">1080p</option>
<option value="720p">720p</option>
<option value="480p">480p</option>
</select>
</div>
<div class="form-group" id="video_crf_group">
<label>CRF (Video only, 0-51)</label>
<input type="number" id="crf" class="form-control" placeholder="22" min="0" max="51">
</div>
<div class="form-group" id="video_preset_group">
<label>Preset (Video only)</label>
<select id="preset" class="form-control">
<option value="">Default (medium)</option>
<option value="ultrafast">ultrafast</option>
<option value="superfast">superfast</option>
<option value="veryfast">veryfast</option>
<option value="faster">faster</option>
<option value="fast">fast</option>
<option value="medium">medium</option>
<option value="slow">slow</option>
<option value="slower">slower</option>
<option value="veryslow">veryslow</option>
</select>
</div>
<div class="form-group">
<label>Custom FFmpeg Flags (Optional)</label>
<input type="text" id="custom_ffmpeg_flags" class="form-control" placeholder="-vcodec copy">
</div>
<button type="submit" class="btn" style="width: 100%;">Add Folder</button>
</form>
</div>
</div>
<script>
const foldersBody = document.getElementById('folders-body');
async function loadFolders() {
const res = await fetch('/api/folders');
const folders = await res.json();
renderFolders(folders || []);
}
function renderFolders(folders) {
foldersBody.innerHTML = folders.map(f => `
<tr>
<td style="word-break: break-all;">${f.folder_path}</td>
<td><span class="badge" style="background: var(--surface-hover); color: var(--text-primary); border-color: var(--border);">${f.media_type}</span></td>
<td>${f.target_resolution || '-'}</td>
<td><code style="background: var(--bg); padding: 0.2rem; border-radius: 4px;">${f.custom_ffmpeg_flags || '-'}</code></td>
<td>
<button class="btn btn-sm btn-danger" onclick="deleteFolder(${f.id})">Delete</button>
</td>
</tr>
`).join('');
}
async function addFolder(e) {
e.preventDefault();
let flags = document.getElementById('custom_ffmpeg_flags').value;
const crf = document.getElementById('crf').value;
const preset = document.getElementById('preset').value;
const mediaType = document.getElementById('media_type').value;
if (mediaType === 'video') {
if (crf) {
flags += (flags ? ' ' : '') + '-crf ' + crf;
}
if (preset) {
flags += (flags ? ' ' : '') + '-preset ' + preset;
}
}
const payload = {
folder_path: document.getElementById('folder_path').value,
media_type: mediaType,
target_resolution: document.getElementById('target_resolution').value,
custom_ffmpeg_flags: flags,
};
const res = await fetch('/api/folders/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (res.ok) {
document.getElementById('add-folder-form').reset();
loadFolders();
} else {
alert('Failed to add folder');
}
}
async function deleteFolder(id) {
if (!confirm('Delete watch folder?')) return;
const res = await fetch('/api/folders/delete/' + id, { method: 'DELETE' });
if (res.ok) {
loadFolders();
} else {
alert('Failed to delete folder');
}
}
loadFolders();
document.getElementById('media_type').addEventListener('change', function (e) {
const isVideo = e.target.value === 'video';
document.getElementById('target_resolution').parentElement.style.display = isVideo ? 'block' : 'none';
document.getElementById('video_crf_group').style.display = isVideo ? 'block' : 'none';
document.getElementById('video_preset_group').style.display = isVideo ? 'block' : 'none';
});
</script>
{{end}}
+80
View File
@@ -0,0 +1,80 @@
{{define "content"}}
<div class="card">
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem; margin-bottom: 1rem;">
<h2 style="margin: 0;">History</h2>
<div style="display: flex; align-items: center; gap: 1rem;">
<input type="text" id="history-search" class="form-control" placeholder="Search history..." onkeyup="filterHistory()" style="max-width: 300px;">
<span style="color: var(--text-secondary); font-size: 0.875rem; white-space: nowrap;">Total Records: <span id="record-count">{{.Total}}</span></span>
</div>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>File</th>
<th>Type</th>
<th>Status</th>
<th>Original Size</th>
<th>Encoded Size</th>
<th>Saved</th>
<th>Time</th>
<th>Date</th>
</tr>
</thead>
<tbody>
{{range .Reports}}
<tr>
<td style="word-break: break-all;">
<div style="font-weight: 500;">{{.FilePath}}</div>
{{if .ErrorMessage}}
<div style="color: var(--error); font-size: 0.75rem; margin-top: 0.25rem;">{{.ErrorMessage}}</div>
{{end}}
</td>
<td><span class="badge" style="background: var(--surface-hover); color: var(--text-primary); border-color: var(--border);">{{.MediaType}}</span></td>
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
<td>{{formatBytes .OriginalSize}}</td>
<td>{{formatBytes .EncodedSize}}</td>
<td style="color: var(--accent);">{{formatBytes .SizeSaved}}</td>
<td>{{.ProcessingTime}}s</td>
<td>{{.CreatedAt.Format "Jan 02, 15:04:05"}}</td>
</tr>
{{else}}
<tr>
<td colspan="8" style="text-align: center; color: var(--text-secondary);">No job reports found.</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
<script>
function filterHistory() {
var input, filter, table, tr, td, i, txtValue;
input = document.getElementById("history-search");
filter = input.value.toUpperCase();
table = document.querySelector(".table-container table");
tr = table.getElementsByTagName("tr");
var visibleCount = 0;
for (i = 1; i < tr.length; i++) { // Skip header row
// If it's the "No job reports found" row, skip it
if (tr[i].cells.length === 1) continue;
td = tr[i].getElementsByTagName("td")[0]; // File column
if (td) {
txtValue = td.textContent || td.innerText;
if (txtValue.toUpperCase().indexOf(filter) > -1) {
tr[i].style.display = "";
visibleCount++;
} else {
tr[i].style.display = "none";
}
}
}
// Update the record count
document.getElementById("record-count").textContent = filter ? visibleCount : "{{.Total}}";
}
</script>
{{end}}
+52
View File
@@ -0,0 +1,52 @@
{{define "layout"}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GoEncode</title>
<link rel="icon" type="image/png" href="/static/icon.png">
<link rel="stylesheet" href="/static/style.css">
<script>
// Global SSE listener setup early so components can attach to it
const sse = new EventSource('/api/sse');
window.sse = sse;
window.addEventListener('beforeunload', () => {
if (window.sse) {
window.sse.close();
}
});
</script>
</head>
<body>
<nav class="navbar">
<div class="navbar-brand" style="display: flex; align-items: center;">
<img src="/static/icon.png" alt="" style="height: 1.5rem; margin-right: 0.5rem; border-radius: 4px;">
Go<span>Encode</span>
</div>
<div class="nav-links">
<a href="/" id="nav-dashboard">Dashboard</a>
<a href="/folders" id="nav-folders">Watch Folders</a>
<a href="/history" id="nav-history">History</a>
<a href="/logs" id="nav-logs">Logs</a>
{{if .AuthEnabled}}
<a href="/logout" style="margin-left: 2rem; color: #ff4444; border-color: #ff4444;">Sign Out</a>
{{end}}
</div>
</nav>
<div class="container">
{{template "content" .}}
</div>
<script>
// Set active nav link
const path = window.location.pathname;
if (path === "/") document.getElementById("nav-dashboard").classList.add("active");
else if (path.startsWith("/folders")) document.getElementById("nav-folders").classList.add("active");
else if (path.startsWith("/history")) document.getElementById("nav-history").classList.add("active");
else if (path.startsWith("/logs")) document.getElementById("nav-logs").classList.add("active");
</script>
</body>
</html>
{{end}}
+140
View File
@@ -0,0 +1,140 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GoEncode - Login</title>
<link rel="icon" type="image/png" href="/static/icon.png">
<style>
:root {
--bg: #0a0a0a;
--surface: #141414;
--border: #2a2a2a;
--text-primary: #f0f0f0;
--text-secondary: #888888;
--accent: #21D198;
--accent-hover: #1cb582;
--error: #ef4444;
--font: 'Inter', system-ui, -apple-system, sans-serif;
}
body {
margin: 0;
background-color: var(--bg);
color: var(--text-primary);
font-family: var(--font);
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
.login-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 2.5rem;
width: 100%;
max-width: 400px;
box-shadow: 0 10px 25px rgba(0,0,0,0.5);
text-align: center;
}
h1 {
margin-top: 0;
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 2rem;
letter-spacing: -0.02em;
}
h1 span {
color: var(--accent);
}
.form-group {
margin-bottom: 1.5rem;
text-align: left;
}
label {
display: block;
margin-bottom: 0.5rem;
color: var(--text-secondary);
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
input {
width: 100%;
box-sizing: border-box;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text-primary);
padding: 0.75rem 1rem;
border-radius: 4px;
font-family: inherit;
font-size: 1rem;
transition: border-color 0.2s;
}
input:focus {
outline: none;
border-color: var(--accent);
}
.btn {
width: 100%;
background: var(--accent);
color: #000;
border: 1px solid var(--accent);
padding: 0.75rem;
border-radius: 4px;
cursor: pointer;
font-family: inherit;
font-weight: 600;
font-size: 1rem;
margin-top: 1rem;
transition: all 0.2s ease;
}
.btn:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
box-shadow: 0 0 10px rgba(33, 209, 152, 0.3);
}
.error {
color: var(--error);
font-size: 0.875rem;
margin-bottom: 1.5rem;
}
</style>
</head>
<body>
<div class="login-card">
<div style="display: flex; align-items: center; justify-content: center; margin-bottom: 2rem;">
<img src="/static/icon.png" alt="" style="height: 2.5rem; margin-right: 1rem; border-radius: 4px;" onerror="this.style.display='none'">
<h1 style="margin-bottom: 0;">Go<span>Encode</span></h1>
</div>
{{if .Error}}
<div class="error">{{.Error}}</div>
{{end}}
<form method="POST" action="/login">
<div class="form-group">
<label>Username</label>
<input type="text" name="username" required autofocus autocomplete="username">
</div>
<div class="form-group">
<label>Password</label>
<input type="password" name="password" required autocomplete="current-password">
</div>
<button type="submit" class="btn">Sign In</button>
</form>
</div>
</body>
</html>
+60
View File
@@ -0,0 +1,60 @@
{{define "content"}}
<div class="header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
<div>
<h2 style="margin: 0;">System Logs</h2>
<p style="color: var(--text-secondary); margin-top: 0.5rem;">Live streaming output of the application logs.</p>
</div>
</div>
<div class="card" style="padding: 0;">
<pre id="log-container" style="margin: 0; padding: 1.5rem; background: var(--surface); color: var(--text-primary); border-radius: 12px; font-family: monospace; height: 600px; overflow-y: auto; white-space: pre-wrap; word-wrap: break-word; font-size: 0.875rem; line-height: 1.5;"></pre>
</div>
<script>
const logContainer = document.getElementById('log-container');
let autoScroll = true;
logContainer.addEventListener('scroll', () => {
const isAtBottom = logContainer.scrollHeight - logContainer.scrollTop <= logContainer.clientHeight + 50;
autoScroll = isAtBottom;
});
function appendLog(timestamp, message) {
const entry = document.createElement('div');
const tsSpan = document.createElement('span');
tsSpan.style.color = '#888';
tsSpan.textContent = `[${timestamp}] `;
const msgSpan = document.createElement('span');
msgSpan.textContent = message;
entry.appendChild(tsSpan);
entry.appendChild(msgSpan);
logContainer.appendChild(entry);
if (autoScroll) {
logContainer.scrollTop = logContainer.scrollHeight;
}
}
// Load recent logs first
fetch('/api/logs')
.then(response => response.json())
.then(logs => {
if (logs && logs.length > 0) {
logs.forEach(log => {
appendLog(log.timestamp, log.message);
});
}
})
.catch(error => console.error('Error fetching logs:', error));
// Listen for live updates
if (window.sse) {
window.sse.addEventListener('log', function(e) {
const data = JSON.parse(e.data);
appendLog(data.timestamp, data.message);
});
}
</script>
{{end}}
+21
View File
@@ -0,0 +1,21 @@
{{define "content"}}
<div class="card">
<h2>Global Settings</h2>
<p style="color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 2rem;">
Database and core server configurations are managed via <code>goencode.yaml</code>.
Application preferences can be configured below.
</p>
<form onsubmit="event.preventDefault(); alert('Settings saved');">
<div class="form-group" style="max-width: 400px;">
<label>Default Video CRF (Quality)</label>
<input type="number" class="form-control" value="22" min="0" max="51">
</div>
<div class="form-group" style="max-width: 400px;">
<label>Default Audio Bitrate (kbps)</label>
<input type="number" class="form-control" value="320" step="32" min="64" max="320">
</div>
<button type="submit" class="btn">Save Preferences</button>
</form>
</div>
{{end}}