Initial commit

This commit is contained in:
2026-06-12 15:29:30 -07:00
commit e6e0c8d77e
13 changed files with 1030 additions and 0 deletions

146
frontend/app.js Normal file
View File

@@ -0,0 +1,146 @@
const form = document.querySelector("#convert-form");
const urlInput = document.querySelector("#url");
const formatSelect = document.querySelector("#format");
const qualityField = document.querySelector("#quality-field");
const qualitySelect = document.querySelector("#quality");
const convertButton = document.querySelector("#convert-button");
const metadataPanel = document.querySelector("#metadata");
const thumbnail = document.querySelector("#thumbnail");
const videoTitle = document.querySelector("#video-title");
const videoChannel = document.querySelector("#video-channel");
const videoDuration = document.querySelector("#video-duration");
const statusDot = document.querySelector("#status-dot");
const statusText = document.querySelector("#status-text");
const errorText = document.querySelector("#error-text");
const downloadLink = document.querySelector("#download-link");
let pollTimer = null;
formatSelect.addEventListener("change", () => {
qualityField.classList.toggle("hidden", formatSelect.value !== "mp3");
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
clearPolling();
resetDownload();
setBusy(true);
setStatus("active", "Validating URL");
const payload = {
url: urlInput.value.trim(),
format: formatSelect.value,
quality: qualitySelect.value,
};
try {
setStatus("active", "Fetching video info");
const info = await postJson("/api/info", { url: payload.url });
renderMetadata(info);
setStatus("active", "Queued");
const { job_id: jobId } = await postJson("/api/convert", payload);
pollStatus(jobId);
} catch (error) {
showError(error.message);
setBusy(false);
}
});
async function pollStatus(jobId) {
try {
const status = await getJson(`/api/status/${jobId}`);
if (status.metadata) {
renderMetadata(status.metadata);
}
if (status.status === "complete") {
setStatus("done", "Ready to download");
downloadLink.href = status.download_url;
downloadLink.classList.remove("hidden");
setBusy(false);
return;
}
if (status.status === "error") {
showError(status.error || "Conversion failed.");
setBusy(false);
return;
}
setStatus("active", status.message || "Working");
pollTimer = window.setTimeout(() => pollStatus(jobId), 1200);
} catch (error) {
showError(error.message);
setBusy(false);
}
}
async function postJson(url, body) {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return parseJsonResponse(response);
}
async function getJson(url) {
const response = await fetch(url);
return parseJsonResponse(response);
}
async function parseJsonResponse(response) {
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.detail || "Request failed.");
}
return data;
}
function renderMetadata(info) {
videoTitle.textContent = info.title || "Untitled YouTube video";
videoChannel.textContent = info.channel || "Unknown channel";
videoDuration.textContent = info.duration ? `Duration ${info.duration}` : "Duration unknown";
if (info.thumbnail) {
thumbnail.src = info.thumbnail;
thumbnail.classList.remove("hidden");
} else {
thumbnail.removeAttribute("src");
thumbnail.classList.add("hidden");
}
metadataPanel.classList.remove("hidden");
}
function setStatus(kind, message) {
statusDot.className = `status-dot ${kind}`;
statusText.textContent = message;
errorText.classList.add("hidden");
errorText.textContent = "";
}
function showError(message) {
setStatus("error", "Error");
errorText.textContent = message;
errorText.classList.remove("hidden");
}
function resetDownload() {
downloadLink.classList.add("hidden");
downloadLink.removeAttribute("href");
}
function setBusy(isBusy) {
convertButton.disabled = isBusy;
convertButton.textContent = isBusy ? "Converting..." : "Convert";
}
function clearPolling() {
if (pollTimer) {
window.clearTimeout(pollTimer);
pollTimer = null;
}
}

71
frontend/index.html Normal file
View File

@@ -0,0 +1,71 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>YouTube Audio Converter</title>
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<main class="shell">
<section class="converter-card">
<div class="header">
<p class="eyebrow">Local utility</p>
<h1>YouTube Audio Converter</h1>
</div>
<form id="convert-form" class="form">
<label class="field field-wide" for="url">
<span>YouTube URL</span>
<input id="url" name="url" type="url" placeholder="https://www.youtube.com/watch?v=..." autocomplete="off" required />
</label>
<div class="controls">
<label class="field" for="format">
<span>Format</span>
<select id="format" name="format">
<option value="mp3">MP3</option>
<option value="wav">WAV</option>
<option value="flac">FLAC</option>
<option value="m4a">M4A</option>
<option value="ogg">OGG</option>
</select>
</label>
<label class="field" id="quality-field" for="quality">
<span>MP3 quality</span>
<select id="quality" name="quality">
<option value="128">128 kbps</option>
<option value="192" selected>192 kbps</option>
<option value="256">256 kbps</option>
<option value="320">320 kbps</option>
</select>
</label>
</div>
<button id="convert-button" type="submit">Convert</button>
</form>
<section id="metadata" class="metadata hidden" aria-live="polite">
<img id="thumbnail" alt="" />
<div>
<h2 id="video-title"></h2>
<p id="video-channel"></p>
<p id="video-duration"></p>
</div>
</section>
<section class="status-panel" aria-live="polite">
<div class="status-line">
<span id="status-dot" class="status-dot idle"></span>
<span id="status-text">Idle</span>
</div>
<p id="error-text" class="error-text hidden"></p>
<a id="download-link" class="download-button hidden" href="#">Download audio</a>
</section>
</section>
</main>
<script src="/static/app.js"></script>
</body>
</html>

251
frontend/style.css Normal file
View File

@@ -0,0 +1,251 @@
:root {
color-scheme: dark;
--bg: #10100f;
--panel: #1b1a18;
--panel-strong: #23211f;
--text: #f4f0e8;
--muted: #aaa49a;
--line: #38342f;
--accent: #3ddc97;
--accent-strong: #2fbd80;
--warning: #f2c14e;
--error: #ff6b6b;
--shadow: 0 24px 80px rgba(0, 0, 0, 0.45);
}
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
}
body {
margin: 0;
background:
radial-gradient(circle at top left, rgba(61, 220, 151, 0.14), transparent 34rem),
linear-gradient(135deg, #10100f 0%, #171614 54%, #0c0c0b 100%);
color: var(--text);
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.shell {
display: grid;
min-height: 100vh;
place-items: center;
padding: 28px;
}
.converter-card {
width: min(760px, 100%);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
background: rgba(27, 26, 24, 0.94);
box-shadow: var(--shadow);
padding: clamp(22px, 4vw, 38px);
}
.header {
margin-bottom: 28px;
}
.eyebrow {
margin: 0 0 8px;
color: var(--accent);
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
}
h1,
h2,
p {
margin-top: 0;
}
h1 {
margin-bottom: 0;
font-size: clamp(2rem, 5vw, 3.4rem);
line-height: 1;
letter-spacing: 0;
}
.form {
display: grid;
gap: 18px;
}
.field {
display: grid;
gap: 8px;
}
.field span {
color: var(--muted);
font-size: 0.88rem;
font-weight: 650;
}
input,
select,
button {
width: 100%;
border-radius: 8px;
font: inherit;
}
input,
select {
border: 1px solid var(--line);
background: var(--panel-strong);
color: var(--text);
min-height: 50px;
padding: 0 14px;
outline: none;
}
input:focus,
select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(61, 220, 151, 0.16);
}
.controls {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
button,
.download-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 52px;
border: 0;
background: var(--accent);
color: #09110d;
cursor: pointer;
font-weight: 800;
text-decoration: none;
transition:
transform 160ms ease,
background 160ms ease,
opacity 160ms ease;
}
button:hover,
.download-button:hover {
background: var(--accent-strong);
transform: translateY(-1px);
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
transform: none;
}
.metadata {
display: grid;
grid-template-columns: 150px 1fr;
gap: 16px;
align-items: center;
margin-top: 24px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(35, 33, 31, 0.72);
padding: 12px;
}
.metadata img {
width: 100%;
aspect-ratio: 16 / 9;
border-radius: 6px;
object-fit: cover;
background: #0c0c0b;
}
.metadata h2 {
margin-bottom: 8px;
font-size: 1rem;
line-height: 1.35;
}
.metadata p {
margin-bottom: 4px;
color: var(--muted);
font-size: 0.92rem;
}
.status-panel {
margin-top: 22px;
border-top: 1px solid var(--line);
padding-top: 18px;
}
.status-line {
display: flex;
align-items: center;
gap: 10px;
min-height: 28px;
color: var(--muted);
}
.status-dot {
width: 10px;
height: 10px;
flex: 0 0 auto;
border-radius: 50%;
background: var(--muted);
}
.status-dot.active {
background: var(--warning);
box-shadow: 0 0 0 6px rgba(242, 193, 78, 0.13);
}
.status-dot.done {
background: var(--accent);
box-shadow: 0 0 0 6px rgba(61, 220, 151, 0.13);
}
.status-dot.error {
background: var(--error);
box-shadow: 0 0 0 6px rgba(255, 107, 107, 0.13);
}
.error-text {
margin: 10px 0 0;
color: var(--error);
}
.download-button {
margin-top: 16px;
width: 100%;
border-radius: 8px;
}
.hidden {
display: none !important;
}
@media (max-width: 640px) {
.shell {
padding: 16px;
place-items: start center;
}
.converter-card {
margin-top: 18px;
}
.controls,
.metadata {
grid-template-columns: 1fr;
}
}