Related Problem/Issue
Is this Feature Request related to an existing GitHub Issue or Bug Report?
No
description of what the problem is
Embed a HTTP server for GET and POST requests from any frontend client. Use Flask as a backend that accepts the url and sends back the downloaded files to the client.
Ideal Solution
Use Flask to generate /download and /file endpoints. This can be deployed with the Docker container with a compose.yml file. And editable .env file setting CORS. This means you can chose between the plain CLI tool or add on the flask api.
This container is highly available. The container runs continuously and awaits requests that triggers the zotify app when needed. No need to deploy docker run --rm .... every time the app is needed.
Considered Alternatives
- separate container for api (would need to give whole host permissions to run cross container CLI command)
- using container proxy to run the cli tool (adds too much complexity)
Forked Example
https://github.com/wchorski/zotify/tree/flask-api
Let me know if you'd prefer a pull request or just pick through my code.
Credentials and Config
These files can be created on a different machine and mapped into the container. Check out compose.yml.example
Frontend Example
Here is a functioning frontend client that could be deployed in the same compose.yml file with an NGINX container.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Download App</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
padding: 40px;
max-width: 500px;
width: 100%;
}
h1 {
color: #333;
margin-bottom: 24px;
font-size: 28px;
}
.input-group {
margin-bottom: 20px;
}
input[type="text"] {
width: 100%;
padding: 14px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 16px;
transition: border-color 0.3s;
}
input[type="text"]:focus {
outline: none;
border-color: #667eea;
}
button {
width: 100%;
padding: 14px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.status {
margin-top: 20px;
padding: 16px;
border-radius: 8px;
font-size: 14px;
display: none;
}
.status.info {
background: #e3f2fd;
color: #1976d2;
border-left: 4px solid #1976d2;
}
.status.success {
background: #e8f5e9;
color: #388e3c;
border-left: 4px solid #388e3c;
}
.status.error {
background: #ffebee;
color: #d32f2f;
border-left: 4px solid #d32f2f;
}
.spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: white;
animation: spin 1s ease-in-out infinite;
margin-right: 8px;
vertical-align: middle;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
</head>
<body>
<div class="container">
<h1>Download Manager</h1>
<div class="input-group">
<input
type="text"
id="urlInput"
placeholder="Enter URL to download"
autocomplete="off"
/>
</div>
<button id="downloadBtn">Download</button>
<div id="status" class="status"></div>
</div>
<script>
const urlInput = document.getElementById("urlInput")
const downloadBtn = document.getElementById("downloadBtn")
const statusDiv = document.getElementById("status")
const API_BASE = "http://127.0.0.1:4000"
function showStatus(message, type = "info") {
statusDiv.textContent = message
statusDiv.className = `status ${type}`
statusDiv.style.display = "block"
}
function hideStatus() {
statusDiv.style.display = "none"
}
async function startDownload(url) {
try {
const response = await fetch(`${API_BASE}/download`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url }),
})
if (!response.ok) {
throw new Error(`Server error: ${response.status}`)
}
const data = await response.json()
return data.job_id
} catch (error) {
throw new Error(`Failed to start download: ${error.message || String(error)}`)
}
}
async function checkStatus(jobId) {
const response = await fetch(`${API_BASE}/status/${jobId}`)
if (!response.ok) {
throw new Error(`Status check failed: ${response.status}`)
}
return await response.json()
}
async function downloadFile(jobId) {
const response = await fetch(`${API_BASE}/files/${jobId}`)
if (!response.ok) {
const res = await response.json()
throw new Error(`File download failed: ${response.status} -> ${String(res.error)}`)
}
// Get filename from Content-Disposition header
const contentDisposition = response.headers.get("content-disposition")
let filename = "z-download"
if (contentDisposition) {
const matches = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(
contentDisposition
)
if (matches != null && matches[1]) {
filename = matches[1].replace(/['"]/g, "")
}
}
// Convert response to blob and trigger download
const blob = await response.blob()
const downloadUrl = window.URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = downloadUrl
a.download = filename
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(downloadUrl)
document.body.removeChild(a)
}
async function pollUntilComplete(jobId) {
while (true) {
const status = await checkStatus(jobId)
if (status.status === "completed") {
return true
} else if (status.status === "failed") {
throw new Error(status.error || "Download failed")
} else if (status.status === "downloading") {
showStatus("Downloading... Please wait", "info")
} else {
showStatus(`Status: ${status.status}`, "info")
}
// Wait 2 seconds before checking again
await new Promise((resolve) => setTimeout(resolve, 2000))
}
}
async function handleDownload() {
const url = urlInput.value.trim()
if (!url) {
showStatus("Please enter a URL", "error")
return
}
// Disable button and show loading
downloadBtn.disabled = true
downloadBtn.innerHTML = '<span class="spinner"></span>Processing...'
hideStatus()
try {
// Step 1: Start download
showStatus("Starting download...", "info")
const jobId = await startDownload(url)
// Step 2: Poll until complete
showStatus("Downloading... Please wait", "info")
await pollUntilComplete(jobId)
// Step 3: Download file
showStatus("Preparing file...", "info")
await downloadFile(jobId)
// Success!
showStatus("Download complete!", "success")
urlInput.value = ""
} catch (error) {
showStatus(`Error: ${error.message || String(error)}`, "error")
console.error("Download error:", error)
} finally {
// Re-enable button
downloadBtn.disabled = false
downloadBtn.textContent = "Download"
}
}
// Event listeners
downloadBtn.addEventListener("click", handleDownload)
urlInput.addEventListener("keypress", (e) => {
if (e.key === "Enter") {
handleDownload()
}
})
</script>
</body>
</html>
Related Problem/Issue
Is this Feature Request related to an existing GitHub Issue or Bug Report?
No
description of what the problem is
Embed a HTTP server for GET and POST requests from any frontend client. Use Flask as a backend that accepts the url and sends back the downloaded files to the client.
Ideal Solution
Use Flask to generate
/downloadand/fileendpoints. This can be deployed with the Docker container with acompose.ymlfile. And editable.envfile setting CORS. This means you can chose between the plain CLI tool or add on the flask api.This container is highly available. The container runs continuously and awaits requests that triggers the
zotifyapp when needed. No need to deploydocker run --rm ....every time the app is needed.Considered Alternatives
Forked Example
https://github.com/wchorski/zotify/tree/flask-api
Let me know if you'd prefer a pull request or just pick through my code.
Credentials and Config
These files can be created on a different machine and mapped into the container. Check out
compose.yml.exampleFrontend Example
Here is a functioning frontend client that could be deployed in the same
compose.ymlfile with an NGINX container.