diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c53e20b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,48 @@ +FROM python:3.11-slim + +# Instalar Docker, dependencias y herramientas necesarias +RUN apt-get update && apt-get install -y \ + docker.io \ + iptables \ + ca-certificates \ + curl \ + gnupg \ + lsb-release \ + nano \ + && rm -rf /var/lib/apt/lists/* + +# Crear el directorio de logs para dockerd +RUN mkdir -p /var/log && touch /var/log/dockerd.log + +# Establecer el directorio de trabajo +WORKDIR /app + +# Copiar archivos de la aplicación +COPY ./app.py ./ +COPY ./utils ./utils +COPY ./frontend ./frontend +COPY ./routes ./routes +COPY ./requirements.txt ./ +COPY ./zotify.tar ./ + +# Copiar y configurar el script de entrada +COPY ./entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# Instalar dependencias de Python +RUN pip install --no-cache-dir -r requirements.txt + +# Exponer el puerto de Flask +EXPOSE 7070 + +# Crear directorios de configuracion + +RUN mkdir ./credentials +RUN mkdir ./downloads + +# Variables de entorno para Flask +ENV FLASK_APP=app.py +ENV FLASK_RUN_HOST=0.0.0.0 + +# Usar el script de entrada para iniciar los servicios +CMD ["/entrypoint.sh"] diff --git a/app.py b/app.py new file mode 100644 index 0000000..ecedd1c --- /dev/null +++ b/app.py @@ -0,0 +1,18 @@ +from flask import Flask, send_from_directory +from routes.downloads import download_blueprint +from routes.search import search_blueprint +from routes.check import check_blueprint + +app = Flask(__name__, static_folder='frontend', static_url_path='') + +# Register API blueprints with prefix +app.register_blueprint(download_blueprint, url_prefix='/api') +app.register_blueprint(search_blueprint, url_prefix='/api') +app.register_blueprint(check_blueprint, url_prefix='/api') + +@app.route('/') +def serve_frontend(): + return send_from_directory(app.static_folder, 'index.html') + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=7070, debug=True, threaded=True) \ No newline at end of file diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..dc8ba00 --- /dev/null +++ b/build.sh @@ -0,0 +1,38 @@ +#!/bin/bash + +# Build the "zotify" image using ./builds as the build context +echo "Building zotify image..." +docker build -t zotify ./builds + +# Check if the build was successful +if [ $? -eq 0 ]; then + echo "zotify image built successfully." + + # Save the zotify image as a tar file + echo "Saving zotify image to ./zotify.tar..." + docker save -o ./zotify.tar zotify + + if [ $? -eq 0 ]; then + echo "zotify image saved to ./zotify.tar." + else + echo "Failed to save zotify image." + exit 1 + fi +else + echo "Failed to build zotify image." + exit 1 +fi + +# Build the "zotifarr" image using ./ as the build context +echo "Building zotifarr image..." +docker build -t zotifarr ./ + +# Check if the build was successful +if [ $? -eq 0 ]; then + echo "zotifarr image built successfully." +else + echo "Failed to build zotifarr image." + exit 1 +fi + +echo "All tasks completed successfully." diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..9f0b915 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,15 @@ +#!/bin/sh + +rm /var/run/docker.pid + +dockerd > /var/log/dockerd.log 2>&1 & + +echo "Esperando a que Docker Daemon se inicie..." +until docker info > /dev/null 2>&1; do + sleep 1 +done +echo "Docker Daemon está listo." + +docker load < /app/zotify.tar + +exec python /app/app.py \ No newline at end of file diff --git a/frontend/css/main.css b/frontend/css/main.css new file mode 100644 index 0000000..56b3f31 --- /dev/null +++ b/frontend/css/main.css @@ -0,0 +1,399 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap'); + +:root { + --primary-accent: #1DB954; + --background-dark: #121212; + --surface-dark: #1E1E1E; + --border-dark: #333; + --text-primary: #e0e0e0; +} + +* { + box-sizing: border-box; +} + +html { + font-size: 16px; +} + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + max-width: 1200px; + margin: 0 auto; + padding: 1rem; + background-color: var(--background-dark); + color: var(--text-primary); + line-height: 1.6; + -webkit-tap-highlight-color: transparent; +} + +.search-container { + background: var(--surface-dark); + padding: 1rem; + border-radius: 12px; + box-shadow: 0 4px 6px rgba(0,0,0,0.25); + margin-bottom: 1.5rem; + border: 1px solid var(--border-dark); +} + +.search-form { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-bottom: 1rem; +} + +select, input, button { + width: 100%; + padding: 0.8rem; + border-radius: 8px; + font-size: 1rem; + transition: all 0.2s ease; +} + +select, input { + background: #2D2D2D; + border: 1px solid var(--border-dark); + color: var(--text-primary); +} + +button { + background-color: var(--primary-accent); + color: white; + border: none; + font-weight: 500; + justify-content: center; +} + +.container-result { + background: #2D2D2D; + border: 1px solid #3A3A3A; + border-radius: 12px; + padding: 1rem; + transition: all 0.2s ease; + position: relative; +} + +.container-result:hover { + border-color: #4A4A4A; + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0,0,0,0.2); +} + +.items-grid { + display: grid; + grid-template-columns: 1fr; + gap: 1rem; + padding: 1rem 0; +} + +.item-card { + background: transparent; + position: static; +} + +.item-image { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 8px; + margin-bottom: 1rem; +} + +.item-info { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; +} + +.item-info > div { + flex: 1; + min-width: 0; /* Prevent text overflow */ +} + +.item-info h3 { + margin: 0 0 0.5rem; + display: flex; + align-items: center; + word-break: break-word; +} + +.explicit { + color: #ff4d4d; + font-size: 0.8rem; + margin-left: 0.5rem; + background: rgba(255,77,77,0.1); + padding: 0.2rem 0.4rem; + border-radius: 4px; +} + +.download-btn { + background: rgba(255, 255, 255, 0.1); + border: none; + padding: 0.5rem; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(4px); + flex-shrink: 0; + margin-left: 0.5rem; +} + +.download-btn:hover { + background: rgba(255,255,255,0.15); + transform: scale(1.1); +} + +.download-btn img { + filter: invert(1); + width: 20px; + height: 20px; + transition: opacity 0.2s ease; +} + +.download-btn.loading img { + animation: spin 1.5s linear infinite; +} + +.download-btn.completed img { + animation: none !important; +} + +.category summary { + padding: 0.75rem; + font-size: 0.95rem; + background: #2D2D2D; + border-radius: 12px; + cursor: pointer; + list-style: none; +} + +.category[open] summary { + border-radius: 12px 12px 0 0; +} + +@media (min-width: 480px) { + .search-form { + flex-direction: row; + } + + select, input { + flex: 1; + } + + button { + width: auto; + min-width: 120px; + } +} + +@media (min-width: 600px) { + html { + font-size: 17px; + } + + body { + padding: 1.5rem; + } + + .items-grid { + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1.5rem; + } + + .item-image { + height: 200px; + } +} + +@media (min-width: 768px) { + .search-container { + padding: 1.5rem; + } + + .search-form { + gap: 1rem; + margin-bottom: 1.5rem; + } + + .category summary { + padding: 1rem 1.25rem; + } + + .container-result { + padding: 1.25rem; + } +} + +@media (max-width: 480px) { + .item-info { + flex-direction: column; + gap: 0.5rem; + } + + .download-btn { + width: 36px; + height: 36px; + padding: 0.4rem; + margin-left: 0; + align-self: flex-end; + } + + .download-btn img { + width: 18px; + height: 18px; + } +} + +button:active { + transform: scale(0.98); +} + +input, select, button { + -webkit-appearance: none; + appearance: none; +} + +select { + background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23e0e0e0'%3e%3cpath d='M7 10l5 5 5-5z'/%3e%3c/svg%3e"); + background-repeat: no-repeat; + background-position: right 0.75rem center; + background-size: 1em; +} + +@media (hover: hover) { + html { + overflow-y: scroll; + } +} + +@media (prefers-reduced-motion: reduce) { + * { + transition: none !important; + animation: none !important; + } +} + +:focus-visible { + outline: 2px solid var(--primary-accent); + outline-offset: 2px; +} + +.download-progress { + margin-top: 1rem; + height: 20px; + background: var(--surface-dark); + border-radius: 10px; + overflow: hidden; + position: relative; + border: 1px solid var(--border-dark); +} + +.progress-bar { + height: 100%; + background: linear-gradient(90deg, var(--primary-accent), #1ED760); + transition: width 0.3s ease; +} + +.progress-text { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + font-size: 0.75rem; + font-weight: 500; + color: var(--text-primary); + mix-blend-mode: difference; + pointer-events: none; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.download-btn.completed { + background: rgba(29, 185, 84, 0.2); + cursor: default; +} + +.download-btn.completed img { + filter: invert(0.8); +} + +@keyframes checkmark-appear { + 0% { transform: scale(0); } + 90% { transform: scale(1.1); } + 100% { transform: scale(1); } +} + +.download-btn.completed img.checkmark { + animation: checkmark-appear 0.3s ease-out; +} + +@media (max-width: 480px) { + .download-progress { + height: 18px; + } + + .progress-text { + font-size: 0.7rem; + right: 6px; + } +} + +.category { + margin-bottom: 1.5rem; +} + +.items-grid { + gap: 1.5rem; +} + +.container-result { + margin-bottom: 0.5rem; +} + +.item-info p { + margin: 0.25rem 0; + line-height: 1.4; + color: #b0b0b0; +} + +.item-info p:last-child { + margin-bottom: 0.5rem; +} + +@media (min-width: 600px) { + .items-grid { + gap: 2rem; + } + + .container-result { + margin-bottom: 0.75rem; + } +} + +@media (max-width: 480px) { + .items-grid { + gap: 1.25rem; + } + + .container-result { + margin-bottom: 0.25rem; + } +} + +.loading-gif { + display: block; + margin: 2rem auto; + border-radius: 12px; + width: 80px; + height: 80px; + object-fit: contain; +} \ No newline at end of file diff --git a/frontend/favicon.ico b/frontend/favicon.ico new file mode 100644 index 0000000..ad9bbe1 Binary files /dev/null and b/frontend/favicon.ico differ diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..0c00338 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,24 @@ + + + + + + Zotifarr + + + +
+

Zotifarr

+
+ + + +
+
+
+ + + + \ No newline at end of file diff --git a/frontend/js/app.js b/frontend/js/app.js new file mode 100644 index 0000000..b9ce889 --- /dev/null +++ b/frontend/js/app.js @@ -0,0 +1,9 @@ +import { loadAccounts } from './components/load.js'; +import { initSearch } from './components/search.js'; +import { initDownload } from './components/download.js'; + +document.addEventListener('DOMContentLoaded', () => { + loadAccounts(); + initSearch(); + initDownload(); +}); \ No newline at end of file diff --git a/frontend/js/components/download.js b/frontend/js/components/download.js new file mode 100644 index 0000000..8790ec5 --- /dev/null +++ b/frontend/js/components/download.js @@ -0,0 +1,228 @@ +export function initSearch() { + document.getElementById('searchForm').addEventListener('submit', handleSearch); +} + +async function handleSearch(e) { + e.preventDefault(); + const account = document.getElementById('accountSelect').value; + const query = document.getElementById('searchQuery').value; + + if (!account || !query) return; + + try { + const response = await fetch(`/api/search?account=${encodeURIComponent(account)}&query=${encodeURIComponent(query)}`); + const data = await response.json(); + displayResults(data); + } catch (error) { + console.error('Search failed:', error); + document.getElementById('resultsContainer').innerHTML = '
Search failed. Please try again.
'; + } +} + +function displayResults(data) { + const container = document.getElementById('resultsContainer'); + container.innerHTML = ''; + + ['albums', 'tracks'].forEach(type => { + if (data[type]?.length > 0) { + const section = document.createElement('details'); + section.className = 'category'; + section.innerHTML = ` + ${type.charAt(0).toUpperCase() + type.slice(1)} (${data[type].length}) +
+ ${data[type].map(item => ` +
+ ${createItemCard(item)} +
+ `).join('')} +
+ `; + container.appendChild(section); + } + }); +} + +function createItemCard(item) { + return ` +
+ ${item.image_url ? `${item.name}` : ''} +
+
+

${item.name}${item.explicit ? 'E' : ''}

+ ${item.artists ? `

Artists: ${item.artists.join(', ')}

` : ''} + ${item.owner ? `

Owner: ${item.owner}

` : ''} + ${item.total_tracks ? `

Total Tracks: ${item.total_tracks}

` : ''} +
+ ${['album', 'track'].includes(item.type) ? ` + + ` : ''} +
+
+ `; +} + +export function initDownload() { + document.getElementById('resultsContainer').addEventListener('click', async (e) => { + if (e.target.closest('.download-btn')) { + const card = e.target.closest('.item-card'); + const btn = card.querySelector('.download-btn'); + const account = document.getElementById('accountSelect').value; + const { spotifyId, type } = card.dataset; + const originalBtnHTML = btn.innerHTML; + + try { + // Show loading state + btn.innerHTML = `Loading`; + btn.classList.add('loading'); + btn.style.pointerEvents = 'none'; + + const statusLine = document.createElement('div'); + statusLine.className = 'status-line'; + card.appendChild(statusLine); + + const response = await fetch( + `/api/download?account=${encodeURIComponent(account)}&type=${type}&id=${spotifyId}` + ); + + const reader = response.body + .pipeThrough(new TextDecoderStream()) + .getReader(); + + let buffer = ''; + let lastPercentage = -1; + let totalTracks = 0; + let completedTracks = 0; + let isArtistDownload = type === 'artist'; + let firstAlbumStart = isArtistDownload; + + while (true) { + const { done, value } = await reader.read(); + if (done) { + // Update to checkmark and stop animation + btn.innerHTML = `Completed`; + btn.classList.remove('loading'); + btn.classList.add('completed'); + btn.style.pointerEvents = 'none'; + + if (isArtistDownload) { + statusLine.textContent = 'Artist download complete!'; + statusLine.classList.add('completed'); + } + break; + } + + buffer += value; + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.trim()) continue; + try { + const data = JSON.parse(line); + + switch(data.type) { + case 'track_start': + statusLine.textContent = 'Starting track download...'; + break; + + case 'album_start': + totalTracks = data.total_tracks; + if (isArtistDownload && firstAlbumStart) { + statusLine.textContent = `Downloading ${data.artist}`; + firstAlbumStart = false; + } else { + statusLine.textContent = `Downloading album: ${data.album}`; + } + break; + + case 'playlist_start': + statusLine.textContent = `Starting playlist: ${data.name}`; + break; + + case 'metadata': + if (type === 'track') { + statusLine.textContent = `Downloading: ${data.name} by ${data.artists[0]}`; + } + break; + + case 'download_start': + statusLine.textContent += ` (${formatBytes(data.total_size)})`; + break; + + case 'download_progress': + if (Math.floor(data.percentage) > lastPercentage) { + lastPercentage = Math.floor(data.percentage); + const progress = `${lastPercentage}% of ${formatBytes(data.total)}`; + statusLine.textContent = type === 'track' + ? `Downloading: ${progress}` + : `Downloading track ${completedTracks + 1}: ${progress}`; + } + break; + + case 'album_track_start': + statusLine.textContent = `Downloading track ${data.number}/${totalTracks}: ${data.track}`; + break; + + case 'playlist_track_start': + statusLine.textContent = `Downloading track ${data.position}: ${data.track}`; + break; + + case 'conversion_start': + statusLine.textContent = `Converting to ${data.format}...`; + break; + + case 'track_complete': + completedTracks++; + statusLine.textContent = `Completed: ${data.output_path.split('/').pop()}`; + statusLine.classList.add('completed'); + break; + + case 'track_skip': + completedTracks++; + statusLine.textContent = `Skipped: ${data.reason === 'already_exists' ? 'Already exists' : data.reason}`; + statusLine.classList.add('warning'); + break; + + case 'album_complete': + statusLine.textContent = `Album complete! ${data.successful}/${data.total} tracks`; + statusLine.classList.add('completed'); + break; + + case 'playlist_complete': + statusLine.textContent = `Playlist complete! ${data.downloaded} tracks`; + statusLine.classList.add('completed'); + break; + } + + } catch (e) { + console.error('Error parsing JSON:', e); + } + } + } + + } catch (error) { + console.error('Download failed:', error); + // Restore original button state + btn.innerHTML = originalBtnHTML; + btn.classList.remove('loading', 'completed'); + btn.style.pointerEvents = 'auto'; + + const statusLine = card.querySelector('.status-line'); + statusLine.textContent = `Download failed: ${error.message}`; + statusLine.classList.add('error'); + } + } + }); +} \ No newline at end of file diff --git a/frontend/js/components/load.js b/frontend/js/components/load.js new file mode 100644 index 0000000..c3166cf --- /dev/null +++ b/frontend/js/components/load.js @@ -0,0 +1,14 @@ +// Account loading functionality +export async function loadAccounts() { + try { + const response = await fetch('/api/check'); + const data = await response.json(); + const select = document.getElementById('accountSelect'); + + select.innerHTML = data.accounts.length + ? data.accounts.map(acc => ``).join('') + : ''; + } catch (error) { + console.error('Error loading accounts:', error); + } +} \ No newline at end of file diff --git a/frontend/js/components/search.js b/frontend/js/components/search.js new file mode 100644 index 0000000..fdfb62e --- /dev/null +++ b/frontend/js/components/search.js @@ -0,0 +1,75 @@ +export function initSearch() { + document.getElementById('searchForm').addEventListener('submit', handleSearch); +} + +async function handleSearch(e) { + e.preventDefault(); + const account = document.getElementById('accountSelect').value; + const query = document.getElementById('searchQuery').value; + + if (!account || !query) return; + + const container = document.getElementById('resultsContainer'); + container.innerHTML = 'Loading...'; + + try { + const response = await fetch(`/api/search?account=${encodeURIComponent(account)}&query=${encodeURIComponent(query)}`); + const data = await response.json(); + displayResults(data); + } catch (error) { + console.error('Search failed:', error); + container.innerHTML = '
Search failed. Please try again.
'; + } +} + +function displayResults(data) { + const container = document.getElementById('resultsContainer'); + container.innerHTML = ''; + + ['albums', 'tracks'].forEach(type => { + if (data[type]?.length > 0) { + const section = document.createElement('details'); + section.className = 'category'; + section.innerHTML = ` + ${type.charAt(0).toUpperCase() + type.slice(1)} (${data[type].length}) +
+ ${data[type].map(item => ` +
+ ${createItemCard(item)} +
+ `).join('')} +
+ `; + container.appendChild(section); + } + }); +} + +function createItemCard(item) { + return ` +
+
+ ${item.image_url ? `${item.name}` : ''} +
+
+

${item.name}${item.explicit ? 'E' : ''}

+ ${item.artists ? `

Artists: ${item.artists.join(', ')}

` : ''} + ${item.owner ? `

Owner: ${item.owner}

` : ''} + ${item.total_tracks ? `

Total Tracks: ${item.total_tracks}

` : ''} +
+ ${['album', 'track'].includes(item.type) ? ` + + ` : ''} +
+
+
+ `; +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c12b529 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +APScheduler==3.11.0 +blinker==1.9.0 +click==8.1.8 +Flask==3.1.0 +itsdangerous==2.2.0 +Jinja2==3.1.5 +MarkupSafe==3.0.2 +portalocker==3.1.1 +tenacity==9.0.0 +tzlocal==5.2 +Werkzeug==3.1.3 diff --git a/routes/__init__.py b/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/routes/__pycache__/__init__.cpython-311.pyc b/routes/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..f6b7201 Binary files /dev/null and b/routes/__pycache__/__init__.cpython-311.pyc differ diff --git a/routes/__pycache__/check.cpython-311.pyc b/routes/__pycache__/check.cpython-311.pyc new file mode 100644 index 0000000..c4a5d5c Binary files /dev/null and b/routes/__pycache__/check.cpython-311.pyc differ diff --git a/routes/__pycache__/downloads.cpython-311.pyc b/routes/__pycache__/downloads.cpython-311.pyc new file mode 100644 index 0000000..f808b19 Binary files /dev/null and b/routes/__pycache__/downloads.cpython-311.pyc differ diff --git a/routes/__pycache__/search.cpython-311.pyc b/routes/__pycache__/search.cpython-311.pyc new file mode 100644 index 0000000..9a21df5 Binary files /dev/null and b/routes/__pycache__/search.cpython-311.pyc differ diff --git a/routes/__pycache__/watch.cpython-311.pyc b/routes/__pycache__/watch.cpython-311.pyc new file mode 100644 index 0000000..a128269 Binary files /dev/null and b/routes/__pycache__/watch.cpython-311.pyc differ diff --git a/routes/check.py b/routes/check.py new file mode 100644 index 0000000..f3cf5cb --- /dev/null +++ b/routes/check.py @@ -0,0 +1,24 @@ +from flask import Blueprint, jsonify +import os + +check_blueprint = Blueprint('check', __name__) + +@check_blueprint.route('/check') +def check_accounts(): + credentials_dir = './credentials' + + try: + # Get all directory entries in credentials path + entries = os.listdir(credentials_dir) + + # Filter out only directories + accounts = [ + entry for entry in entries + if os.path.isdir(os.path.join(credentials_dir, entry)) + ] + + except FileNotFoundError: + # Directory doesn't exist + accounts = [] + + return jsonify({"accounts": accounts}) \ No newline at end of file diff --git a/routes/downloads.py b/routes/downloads.py new file mode 100644 index 0000000..5316845 --- /dev/null +++ b/routes/downloads.py @@ -0,0 +1,54 @@ +from flask import Blueprint, Response, request, jsonify +import subprocess +import json +from utils.commands import zotify_command_builder + +download_blueprint = Blueprint('download', __name__) +ALLOWED_TYPES = {'album', 'artist', 'track'} + +@download_blueprint.route('/download') +def download(): + type_param = request.args.get('type') + id_param = request.args.get('id') + account = request.args.get('account') + + if not all([type_param, id_param, account]): + return jsonify({"error": "Missing parameters"}), 400 + + if type_param.lower() not in ALLOWED_TYPES: + return jsonify({"error": f"Invalid type. Allowed: {', '.join(ALLOWED_TYPES)}"}), 400 + + spotify_url = f"https://open.spotify.com/{type_param}/{id_param}" + is_artist_download = type_param.lower() == 'artist' + + def generate(): + cmd = zotify_command_builder(account, 'download', spotify_url) + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1 + ) + + try: + while True: + line = proc.stdout.readline() + if not line and proc.poll() is not None: + break + if line: + try: + data = json.loads(line) + # Skip artist_start/artist_complete only for artist type + if is_artist_download and data.get('type') in ['artist_start', 'artist_complete']: + continue + # Re-encode the line to ensure proper formatting + yield f"{json.dumps(data)}\n" + except json.JSONDecodeError: + pass + except GeneratorExit: + proc.terminate() + finally: + proc.wait() + + return Response(generate(), mimetype='application/x-ndjson') \ No newline at end of file diff --git a/routes/search.py b/routes/search.py new file mode 100644 index 0000000..070a746 --- /dev/null +++ b/routes/search.py @@ -0,0 +1,47 @@ +from flask import Blueprint, Response, request, jsonify +import subprocess +import json +from utils.commands import zotify_command_builder + +search_blueprint = Blueprint('search', __name__) + +@search_blueprint.route('/search') +def search(): + account = request.args.get('account') + query = request.args.get('query') + + if not all([account, query]): + return jsonify({"error": "Missing 'account' or 'query'"}), 400 + + cmd = zotify_command_builder(account, 'search', query) + + try: + # Execute command and capture all output + result = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=30 # Increase timeout if needed for large results + ) + except subprocess.TimeoutExpired: + return jsonify({"error": "Search operation timed out"}), 504 + + # Handle command errors + if result.returncode != 0: + return jsonify({ + "error": "Search failed", + "details": result.stderr.strip() or "Unknown error" + }), 500 + + # Parse and return JSON response + try: + output = json.loads(result.stdout) + except json.JSONDecodeError as e: + return jsonify({ + "error": "Invalid JSON response", + "output": result.stdout, + "parse_error": str(e) + }), 500 + + return jsonify(output) \ No newline at end of file diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/__pycache__/__init__.cpython-311.pyc b/utils/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..4fa14a7 Binary files /dev/null and b/utils/__pycache__/__init__.cpython-311.pyc differ diff --git a/utils/__pycache__/commands.cpython-311.pyc b/utils/__pycache__/commands.cpython-311.pyc new file mode 100644 index 0000000..4823c71 Binary files /dev/null and b/utils/__pycache__/commands.cpython-311.pyc differ diff --git a/utils/commands.py b/utils/commands.py new file mode 100644 index 0000000..bcaf2ac --- /dev/null +++ b/utils/commands.py @@ -0,0 +1,16 @@ +def zotify_command_builder(account: str, action: str, args: str) -> list: + """Builds the Docker command for Zotify execution.""" + base_cmd = [ + 'docker', 'run', '--rm', '-t', + '-v', f'/app/credentials/{account}:/app/zotify/credentials', + '-v', '/app/downloads:/root/Music', + 'zotify', + 'python3', '-m', 'zotify', + '--credentials-location', '/app/zotify/credentials/credentials.json' + ] + + if action == 'download': + return [*base_cmd, args] # args is Spotify URL + elif action == 'search': + return [*base_cmd, '-s', args] # args is search query + raise ValueError(f"Invalid action: {action}") \ No newline at end of file