Skip to content

[Feature Request] Flask API #125

@wchorski

Description

@wchorski

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>

Metadata

Metadata

Assignees

No one assigned

    Labels

    consideringThis may be worth working on, but may not fit the overall goalsenhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions