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
+
+
+
+
+
+
+
+
\ 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.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 = `
`;
+ 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 = `
`;
+ 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 = '
';
+
+ 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.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