├── .github └── workflows │ └── docker-publish.yml ├── Dockerfile ├── README.md ├── app.py ├── demo ├── active_dl.png ├── complete_dl.png ├── home_screen.png └── search.png ├── static ├── css │ └── style.css ├── favicon.ico └── js │ └── app.js └── templates └── index.html /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Build and Push Docker Image 2 | 3 | on: 4 | push: 5 | branches: [ "main", "master" ] 6 | tags: [ "v*.*.*" ] 7 | pull_request: 8 | branches: [ "main", "master" ] 9 | 10 | env: 11 | REGISTRY_IMAGE: anoddname/streamrip-web-gui 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | permissions: 17 | contents: read 18 | packages: write 19 | id-token: write 20 | 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v4 24 | 25 | - name: Extract metadata 26 | id: meta 27 | uses: docker/metadata-action@v5 28 | with: 29 | images: ${{ env.REGISTRY_IMAGE }} 30 | tags: | 31 | type=ref,event=branch 32 | type=ref,event=pr 33 | type=semver,pattern={{version}} 34 | type=semver,pattern={{major}}.{{minor}} 35 | type=raw,value=latest,enable={{is_default_branch}} 36 | 37 | - name: Set up QEMU 38 | uses: docker/setup-qemu-action@v3 39 | 40 | - name: Set up Docker Buildx 41 | uses: docker/setup-buildx-action@v3 42 | 43 | - name: Log in to Docker Hub 44 | if: github.event_name != 'pull_request' 45 | uses: docker/login-action@v3 46 | with: 47 | username: ${{ secrets.DOCKERHUB_USERNAME }} 48 | password: ${{ secrets.DOCKERHUB_TOKEN }} 49 | 50 | - name: Build and push Docker image 51 | uses: docker/build-push-action@v6 52 | with: 53 | context: . 54 | platforms: linux/amd64,linux/arm64 55 | push: ${{ github.event_name != 'pull_request' }} 56 | tags: ${{ steps.meta.outputs.tags }} 57 | labels: ${{ steps.meta.outputs.labels }} 58 | annotations: ${{ steps.meta.outputs.annotations }} 59 | cache-from: type=gha 60 | cache-to: type=gha,mode=max 61 | provenance: true 62 | sbom: true 63 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-slim 2 | 3 | # Install system dependencies 4 | RUN apt-get update && apt-get install -y \ 5 | ffmpeg \ 6 | git \ 7 | gcc \ 8 | python3-dev \ 9 | && rm -rf /var/lib/apt/lists/* 10 | 11 | # Create a non-root user 12 | RUN groupadd -g 1000 appuser && \ 13 | useradd -r -u 1000 -g appuser appuser 14 | 15 | # Set working directory 16 | WORKDIR /app 17 | 18 | # Install Python dependencies 19 | RUN pip install --no-cache-dir \ 20 | flask \ 21 | flask-cors \ 22 | streamrip \ 23 | gunicorn \ 24 | gevent 25 | 26 | # Copy application files 27 | COPY app.py /app/ 28 | COPY templates /app/templates/ 29 | COPY static /app/static/ 30 | 31 | # Create necessary directories with proper ownership 32 | RUN mkdir -p /downloads /logs /config/streamrip && \ 33 | chown -R 1000:1000 /downloads /logs /config 34 | 35 | # Switch to non-root user 36 | USER 1000:1000 37 | 38 | # Expose port 39 | EXPOSE 5000 40 | 41 | # Run with aggressive worker recycling 42 | CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--worker-class", "gevent", "--workers", "2", "--timeout", "60", "app:app"] 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![streamrip web interface](https://github.com/AnOddName/streamrip-web-gui/blob/main/demo/home_screen.png?raw=true) 2 | 3 | # Streamrip Web GUI 4 | 5 | A web interface for [Streamrip](https://github.com/nathom/streamrip), providing a GUI for downloading music from various streaming services. 6 | 7 | Streamrip is lit but CLI-only. Having to SSH into my stupid little server each time I wanted to download a track was too much effort for me. 8 | (Mainly Quboz for me low key I don't even know if Tidal/Deezer work because I don't have accounts for them) 9 | 10 | Intended to be used for Docker/Docker-Compose but you can run it locally too. 11 | 12 | ![Python](https://img.shields.io/badge/python-3.11-blue.svg) 13 | ![Docker](https://img.shields.io/badge/docker-ready-green.svg) 14 | 15 | ## Features 16 | 17 | - **Multi-Service Support**: Download from Qobuz, Tidal, Deezer, SoundCloud 18 | - **Built-in Search**: Search and download directly from the web interface 19 | - **Download Management**: Track active downloads, view history, and browse downloaded files 20 | - **Configuration Editor**: Edit streamrip settings directly from the web interface 21 | - **Docker Ready**: Easy deployment with Docker 22 | 23 | ## Screenshots 24 | 25 | ![search](https://github.com/AnOddName/streamrip-web-gui/blob/main/demo/search.png?raw=true) 26 | ![download](https://github.com/AnOddName/streamrip-web-gui/blob/main/demo/active_dl.png?raw=true) 27 | 28 | ## Prerequisites 29 | 30 | - Python 3.11+ (if running without Docker) 31 | - Docker and Docker Compose (for containerized deployment) 32 | - Valid streaming service credentials configured in streamrip 33 | 34 | ## Installation 35 | 36 | You MUST install and configure Streamrip first. 37 | 38 | 1. Install Streamrip: 39 | ```bash 40 | pip install streamrip 41 | ``` 42 | 43 | 2. Configure Streamrip: 44 | ```bash 45 | rip config 46 | ``` 47 | Follow the [Streamrip configuration guide](https://github.com/nathom/streamrip/wiki/Configuration) to set up your credentials. 48 | 49 | ### Option 1: Pre-built workflow. 50 | 1: Add this to your `docker-compose.yml` 51 | 52 | ``` 53 | streamrip: 54 | image: anoddname/streamrip-web-gui:latest 55 | container_name: streamrip-web 56 | user: "1000:1000" 57 | environment: 58 | - HOME=/config 59 | - XDG_CONFIG_HOME=/config 60 | - STREAMRIP_CONFIG=/config/streamrip/config.toml 61 | - DOWNLOAD_DIR=/music 62 | - MAX_CONCURRENT_DOWNLOADS=1 63 | volumes: 64 | - /home/YOURUSERNAME/.config/streamrip:/config/streamrip:rw 65 | - /home/YOURUSERNAME/media-server/data/Music:/music:rw 66 | ports: 67 | - "5002:5000" 68 | restart: unless-stopped 69 | ``` 70 | 71 | 2: run with `docker-compose up` 72 | 73 | 3: Access the web interface at `http://localhost:5002` 74 | 75 | ### Option 2: Docker 76 | 77 | 1. Clone the repository: 78 | ```bash 79 | git clone https://github.com/anoddname/streamrip-web-gui.git 80 | cd streamrip-web 81 | ``` 82 | 83 | 2. Create a `docker-compose.yml` file: 84 | ```yaml 85 | services: 86 | streamrip: 87 | build: ./streamrip-web 88 | container_name: streamrip 89 | user: "1000:1000" 90 | environment: 91 | - HOME=/config 92 | - XDG_CONFIG_HOME=/config 93 | - STREAMRIP_CONFIG=/config/streamrip/config.toml 94 | - DOWNLOAD_DIR=/music 95 | - MAX_CONCURRENT_DOWNLOADS=2 96 | volumes: 97 | - /home/YOURUSERNAME/.config/streamrip:/config/streamrip:rw 98 | - /home/YOURUSERNAME/media-server/data/Music:/music:rw 99 | ports: 100 | - "5002:5000" 101 | restart: unless-stopped 102 | ``` 103 | 104 | 3. Build and run: 105 | ```bash 106 | docker-compose up -d --build 107 | ``` 108 | 109 | 4. Access the web interface at `http://localhost:5002` 110 | 111 | ### Option 3: Manual Installation 112 | 113 | 1. Clone this repository: 114 | ```bash 115 | git clone https://github.com/anoddname/streamrip-web.git 116 | cd streamrip-web 117 | ``` 118 | 119 | 2. Install dependencies: 120 | ```bash 121 | pip install flask gunicorn requests 122 | ``` 123 | 124 | 3. Run the application: 125 | ```bash 126 | python app.py 127 | ``` 128 | 129 | ## Configuration 130 | 131 | ### Streamrip Configuration 132 | 133 | Before using Streamrip Web, you need to configure streamrip with your streaming service credentials: 134 | 135 | 1. **Qobuz**: Requires email and password (or TOKEN) 136 | 2. **Tidal**: Requires email and password 137 | 3. **Deezer**: Requires ARL 138 | 4. **SoundCloud**: Works without authentication 139 | 140 | Check the [Streamrip documentation](https://github.com/nathom/streamrip/wiki) for instructions. 141 | 142 | ## Usage 143 | 144 | ### Downloading from URL 145 | 146 | 1. Paste a streaming service URL in the input field 147 | 2. Select quality (MP3 128/320 or FLAC 16/24-bit) 148 | 3. Click DOWNLOAD 149 | 150 | ### Searching for Music 151 | 152 | 1. Select a streaming service from the dropdown 153 | 2. Choose search type (Albums, Tracks, or Artists) 154 | 3. Enter your search query 155 | 4. Click on DOWNLOAD next to any result 156 | 157 | - Searches will use the QUALITY from the URL paste dropdown. 158 | 159 | ## Troubleshooting 160 | 161 | ### Common Issues 162 | 163 | 1. **"Config file not found"**: Make sure streamrip is properly configured. Run `rip config` to create a configuration file. Also check locations. 164 | 165 | 2. **Downloads failing/Searches timing out**: Check that your streaming service credentials are valid and properly configured in streamrip. Tidal will timeout, Deezer will throw errors. 166 | 167 | 3. **Downloads disappearing from Active DL/History tabs**: The files were still prolly downloaded, dont worry about it I'll fix it later it was pissing me off 168 | 169 | 4. **No images when run locally**: CORS issue 170 | 171 | 5. Unable to open database file/Failed to parse JSON Error: This occurs when the config file inside the container has wrong paths. Fix it with: 172 | ```bash 173 | docker exec -it streamrip /bin/bash 174 | sed -i 's|/home/YOURUSERNAME/StreamripDownloads|/music|g' /config/streamrip/config.toml 175 | sed -i 's|/home/YOURUSERNAME/.config/streamrip/|/config/streamrip/|g' /config/streamrip/config.toml 176 | exit 177 | ``` 178 | Note to replace `YOURUSERNAME` with, you guessed it, your username. 179 | 180 | 181 | ## Disclaimer 182 | 183 | This tool is for educational purposes only. Ensure you comply with the terms of service of the streaming platforms you use. Support artists by purchasing their music. 184 | 185 | --- 186 | 187 | 188 | Fueled by spite 189 | 190 | 191 | 192 | 193 | 194 | 195 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from flask import Flask, render_template, request, jsonify, Response, stream_with_context 3 | import subprocess 4 | import os 5 | import threading 6 | import queue 7 | import time 8 | import tempfile 9 | import requests 10 | import shutil 11 | import re 12 | import json 13 | 14 | logging.basicConfig(level=logging.INFO) 15 | logger = logging.getLogger(__name__) 16 | 17 | app = Flask(__name__) 18 | 19 | STREAMRIP_CONFIG = os.environ.get('STREAMRIP_CONFIG', '/config/config.toml') 20 | DOWNLOAD_DIR = os.environ.get('DOWNLOAD_DIR', '/music') 21 | MAX_CONCURRENT_DOWNLOADS = int(os.environ.get('MAX_CONCURRENT_DOWNLOADS', '2')) 22 | 23 | download_queue = queue.Queue() 24 | active_downloads = {} 25 | download_history = [] 26 | sse_clients = [] 27 | album_art_cache = {} 28 | cache_lock = threading.Lock() 29 | 30 | class DownloadWorker(threading.Thread): 31 | def __init__(self): 32 | super().__init__(daemon=True) 33 | self.current_process = None 34 | 35 | def run(self): 36 | while True: 37 | task = download_queue.get() 38 | if task is None: 39 | break 40 | 41 | task_id = task['id'] 42 | url = task['url'] 43 | quality = task.get('quality', 3) 44 | metadata = task.get('metadata', {}) 45 | 46 | active_downloads[task_id] = { 47 | 'status': 'downloading', 48 | 'url': url, 49 | 'metadata': metadata, 50 | 'started': time.time() 51 | } 52 | 53 | broadcast_sse({ 54 | 'type': 'download_started', 55 | 'id': task_id, 56 | 'metadata': metadata, 57 | 'status': 'downloading' 58 | }) 59 | 60 | output_lines = [] 61 | process = None 62 | 63 | try: 64 | cmd = ['rip'] 65 | if os.path.exists(STREAMRIP_CONFIG): 66 | cmd.extend(['--config-path', STREAMRIP_CONFIG]) 67 | cmd.extend(['-f', DOWNLOAD_DIR]) 68 | cmd.extend(['-q', str(quality)]) 69 | cmd.extend(['url', url]) 70 | 71 | process = subprocess.Popen( 72 | cmd, 73 | stdout=subprocess.PIPE, 74 | stderr=subprocess.STDOUT, 75 | text=True, 76 | bufsize=1, 77 | universal_newlines=True 78 | ) 79 | 80 | self.current_process = process 81 | 82 | for line in process.stdout: 83 | line = line.strip() 84 | if line: 85 | output_lines.append(line) 86 | if len(output_lines) % 10 == 0: 87 | broadcast_sse({ 88 | 'type': 'download_progress', 89 | 'id': task_id, 90 | 'output': "\n".join(output_lines[-5:]), 91 | 'progress': {'raw_output': True} 92 | }) 93 | 94 | process.wait() 95 | 96 | broadcast_sse({ 97 | 'type': 'download_completed', 98 | 'id': task_id, 99 | 'status': 'completed' if process.returncode == 0 else 'failed', 100 | 'metadata': metadata, 101 | 'output': "\n".join(output_lines) 102 | 103 | }) 104 | 105 | except Exception as e: 106 | broadcast_sse({ 107 | 'type': 'download_error', 108 | 'id': task_id, 109 | 'error': str(e), 110 | 'output': "\n".join(output_lines) if output_lines else str(e) 111 | }) 112 | 113 | finally: 114 | self.current_process = None 115 | if task_id in active_downloads: 116 | del active_downloads[task_id] 117 | if process and process.poll() is None: 118 | process.terminate() 119 | 120 | download_queue.task_done() 121 | 122 | def broadcast_sse(data): 123 | message = f"data: {json.dumps(data)}\n\n" 124 | dead_clients = [] 125 | 126 | for client in sse_clients: 127 | try: 128 | client.put(message) 129 | except: 130 | dead_clients.append(client) 131 | 132 | for client in dead_clients: 133 | sse_clients.remove(client) 134 | 135 | @app.route('/api/events') 136 | def sse_events(): 137 | def generate(): 138 | q = queue.Queue() 139 | sse_clients.append(q) 140 | 141 | try: 142 | yield f"data: {json.dumps({'type': 'connected'})}\n\n" 143 | 144 | while True: 145 | try: 146 | msg = q.get(timeout=30) 147 | yield msg 148 | except queue.Empty: 149 | continue #previous heartbeat check 150 | finally: 151 | sse_clients.remove(q) 152 | 153 | return Response( 154 | stream_with_context(generate()), 155 | mimetype='text/event-stream', 156 | headers={ 157 | 'Cache-Control': 'no-cache', 158 | 'X-Accel-Buffering': 'no' #disable nginx buffering 159 | } 160 | ) 161 | 162 | workers = [] 163 | for _ in range(MAX_CONCURRENT_DOWNLOADS): 164 | worker = DownloadWorker() 165 | worker.start() 166 | workers.append(worker) 167 | 168 | @app.route('/') 169 | def index(): 170 | return render_template('index.html') 171 | 172 | @app.route('/api/download', methods=['POST']) 173 | def start_download(): 174 | data = request.json 175 | url = data.get('url') 176 | quality = data.get('quality', 3) 177 | 178 | if not url: 179 | return jsonify({'error': 'URL is required'}), 400 180 | 181 | #Validate URL (basic check) 182 | #youtube-dl for later 183 | valid_services = ['spotify.com', 'deezer.com', 'tidal.com', 'qobuz.com', 'soundcloud.com', 'youtube.com'] 184 | if not any(service in url.lower() for service in valid_services): 185 | return jsonify({'error': 'Unsupported service URL'}), 400 186 | 187 | metadata = extract_metadata_from_url(url) 188 | 189 | task_id = f"dl_{int(time.time() * 1000)}" 190 | task = { 191 | 'id': task_id, 192 | 'url': url, 193 | 'quality': quality, 194 | 'metadata': metadata 195 | } 196 | 197 | download_queue.put(task) 198 | 199 | return jsonify({'task_id': task_id, 'status': 'queued'}) 200 | 201 | 202 | @app.route('/api/status') 203 | def get_all_status(): 204 | return jsonify({ 205 | 'active': active_downloads, 206 | 'history': download_history[-20:], 207 | 'queue_size': download_queue.qsize() 208 | }) 209 | 210 | 211 | @app.route('/api/config', methods=['GET', 'POST']) 212 | def config(): 213 | if request.method == 'GET': 214 | if os.path.exists(STREAMRIP_CONFIG): 215 | with open(STREAMRIP_CONFIG, 'r') as f: 216 | return jsonify({'config': f.read()}) 217 | return jsonify({'config': ''}) 218 | 219 | elif request.method == 'POST': 220 | data = request.json 221 | config_content = data.get('config', '') 222 | 223 | try: 224 | if os.path.exists(STREAMRIP_CONFIG): 225 | shutil.copy2(STREAMRIP_CONFIG, f"{STREAMRIP_CONFIG}.bak") 226 | 227 | os.makedirs(os.path.dirname(STREAMRIP_CONFIG), exist_ok=True) 228 | with open(STREAMRIP_CONFIG, 'w') as f: 229 | f.write(config_content) 230 | 231 | return jsonify({'status': 'success'}) 232 | except Exception as e: 233 | return jsonify({'error': str(e)}), 500 234 | 235 | 236 | @app.route('/api/search', methods=['POST']) 237 | def search_music(): 238 | data = request.json 239 | query = data.get('query') 240 | search_type = data.get('type', 'album') 241 | source = data.get('source', 'qobuz') 242 | 243 | if not query: 244 | return jsonify({'error': 'Query required'}), 400 245 | 246 | if source == 'soundcloud' and search_type in ['album', 'artist']: 247 | logger.debug(f"SoundCloud doesn't support {search_type} search") 248 | return jsonify({ 249 | 'results': [], 250 | 'query': query, 251 | 'source': source, 252 | 'total_count': 0, 253 | 'message': f'SoundCloud does not support {search_type} searches. Try searching for tracks or playlists instead.' 254 | }) 255 | 256 | try: 257 | with tempfile.NamedTemporaryFile(mode='w+', suffix='.txt', delete=False) as tmp_file: 258 | tmp_path = tmp_file.name 259 | 260 | cmd = ['rip'] 261 | if os.path.exists(STREAMRIP_CONFIG): 262 | cmd.extend(['--config-path', STREAMRIP_CONFIG]) 263 | 264 | cmd.extend(['search', '--output-file', tmp_path]) 265 | cmd.extend([source, search_type, query]) 266 | 267 | result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) 268 | results = [] 269 | 270 | try: 271 | with open(tmp_path, 'r') as f: 272 | content = f.read() 273 | 274 | try: 275 | search_data = json.loads(content) 276 | for item in search_data: 277 | item_id = item.get('id', '') 278 | media_type = item.get('media_type', search_type) 279 | url = construct_url(item.get('source', source), media_type, item_id) 280 | 281 | desc = item.get('desc', '') 282 | artist = '' 283 | title = desc 284 | 285 | if ' by ' in desc: 286 | parts = desc.rsplit(' by ', 1) 287 | title = parts[0] 288 | artist = parts[1] 289 | 290 | results.append({ 291 | 'id': item_id, 292 | 'service': item.get('source', source), 293 | 'type': media_type, 294 | 'artist': artist if artist else desc, 295 | 'title': title if artist else '', 296 | 'desc': desc, 297 | 'url': url, 298 | 'album_art': '' #Empty initially, will be loaded on demand 299 | }) 300 | except json.JSONDecodeError as e: 301 | logger.error(f"Failed to parse JSON: {e}") 302 | finally: 303 | if os.path.exists(tmp_path): 304 | os.remove(tmp_path) 305 | 306 | return jsonify({ 307 | 'results': results, 308 | 'query': query, 309 | 'source': source, 310 | 'total_count': len(results) 311 | }) 312 | 313 | except Exception as e: 314 | logger.error(f"Search error: {e}") 315 | return jsonify({'error': str(e)}), 500 316 | 317 | @app.route('/api/album-art', methods=['GET']) 318 | def get_album_art(): 319 | source = request.args.get('source') 320 | media_type = request.args.get('type') 321 | item_id = request.args.get('id') 322 | 323 | if not all([source, media_type, item_id]): 324 | return jsonify({'error': 'Missing parameters'}), 400 325 | 326 | #Todo: handle SoundCloud special case and get correct albums if possible 327 | if source == 'soundcloud': 328 | if '|' in item_id: 329 | item_id = item_id.split('|')[0] 330 | elif 'soundcloud:tracks:' in item_id: 331 | match = re.search(r'soundcloud:tracks:(\d+)', item_id) 332 | if match: 333 | item_id = match.group(1) 334 | 335 | cache_key = f"{source}_{media_type}_{item_id}" 336 | if cache_key in album_art_cache: 337 | return jsonify({'album_art': album_art_cache[cache_key]}) 338 | 339 | try: 340 | if source == 'qobuz': 341 | app_id = get_qobuz_app_id() 342 | album_art = fetch_single_album_art(item_id, media_type, app_id) 343 | if album_art: 344 | album_art_cache[cache_key] = album_art 345 | return jsonify({'album_art': album_art}) 346 | return jsonify({'album_art': ''}) 347 | 348 | elif source == 'tidal': 349 | if media_type == 'artist': 350 | album_art = f"https://resources.tidal.com/images/{item_id}/750x750.jpg" 351 | else: 352 | album_art = f"https://resources.tidal.com/images/{item_id}/320x320.jpg" 353 | 354 | if album_art: 355 | album_art_cache[cache_key] = album_art 356 | return jsonify({'album_art': album_art}) 357 | return jsonify({'album_art': ''}) 358 | 359 | elif source == 'deezer': 360 | if media_type == 'artist': 361 | try: 362 | response = requests.get(f"https://api.deezer.com/artist/{item_id}", timeout=3) 363 | if response.status_code == 200: 364 | data = response.json() 365 | album_art = data.get('picture_medium', data.get('picture', '')) 366 | if album_art: 367 | album_art_cache[cache_key] = album_art 368 | return jsonify({'album_art': album_art}) 369 | except: 370 | pass 371 | return jsonify({'album_art': ''}) 372 | else: 373 | album_art = f"https://api.deezer.com/{media_type}/{item_id}/image" 374 | if album_art: 375 | album_art_cache[cache_key] = album_art 376 | return jsonify({'album_art': album_art}) 377 | return jsonify({'album_art': ''}) 378 | 379 | elif source == 'soundcloud': 380 | #SoundCloud doesn't provide easy access to artwork 381 | #Just return empty and let the frontend handle placeholders 382 | return jsonify({'album_art': ''}) 383 | 384 | #Default return for unknown sources 385 | return jsonify({'album_art': ''}) 386 | 387 | except Exception as e: 388 | logger.error(f"Error fetching album art for {source}/{media_type}/{item_id}: {e}") 389 | return jsonify({'album_art': ''}) 390 | 391 | @app.route('/api/browse') 392 | def browse_downloads(): 393 | try: 394 | files = [] 395 | for root, dirs, filenames in os.walk(DOWNLOAD_DIR): 396 | for filename in filenames: 397 | if filename.endswith(('.mp3', '.flac', '.m4a', '.opus')): 398 | filepath = os.path.join(root, filename) 399 | rel_path = os.path.relpath(filepath, DOWNLOAD_DIR) 400 | files.append({ 401 | 'name': rel_path, 402 | 'size': os.path.getsize(filepath), 403 | 'modified': os.path.getmtime(filepath) 404 | }) 405 | 406 | files.sort(key=lambda x: x['modified'], reverse=True) 407 | return jsonify(files[:100]) 408 | except Exception as e: 409 | return jsonify({'error': str(e)}), 500 410 | 411 | 412 | def fetch_single_album_art(item_id, media_type, app_id): 413 | """Fetch album art for a single Qobuz item""" 414 | try: 415 | api_base = "https://www.qobuz.com/api.json/0.2" 416 | endpoints = { 417 | 'album': f"{api_base}/album/get", 418 | 'track': f"{api_base}/track/get", 419 | 'artist': f"{api_base}/artist/get" 420 | } 421 | 422 | if media_type not in endpoints: 423 | return None 424 | 425 | params = {'app_id': app_id} 426 | if media_type == 'album': 427 | params['album_id'] = item_id 428 | elif media_type == 'track': 429 | params['track_id'] = item_id 430 | elif media_type == 'artist': 431 | params['artist_id'] = item_id 432 | 433 | response = requests.get(endpoints[media_type], params=params, timeout=3) 434 | if response.status_code != 200: 435 | return None 436 | 437 | data = response.json() 438 | 439 | #Check if we got a minimal response (no actual data) 440 | if len(data) <= 2: 441 | logger.debug(f"Minimal data for {media_type} {item_id}: {data}") 442 | return None 443 | 444 | image_data = None 445 | 446 | if media_type == 'album' and 'image' in data: 447 | image_data = data['image'] 448 | elif media_type == 'track' and 'album' in data and 'image' in data['album']: 449 | image_data = data['album']['image'] 450 | elif media_type == 'artist': 451 | 452 | for field in ['image', 'picture', 'photo', 'images', 'thumbnail', 'avatar']: 453 | if field in data: 454 | image_data = data[field] 455 | break 456 | 457 | if not image_data: 458 | logger.debug(f"No image field found for artist {item_id}") 459 | return None 460 | 461 | if not image_data: 462 | return None 463 | 464 | if isinstance(image_data, dict): 465 | for size in ['extralarge', 'large', 'medium', 'small', 'thumbnail']: 466 | if size in image_data and image_data[size]: 467 | url = image_data[size] 468 | if url and isinstance(url, str) and url.startswith('http'): 469 | return url 470 | elif isinstance(image_data, str) and image_data.startswith('http'): 471 | return image_data 472 | elif isinstance(image_data, list) and len(image_data) > 0: 473 | for item in image_data: 474 | if isinstance(item, str) and item.startswith('http'): 475 | return item 476 | 477 | return None 478 | 479 | except Exception as e: 480 | logger.error(f"Error fetching album art for {media_type} {item_id}: {e}") 481 | return None 482 | 483 | def get_qobuz_app_id(): 484 | try: 485 | if os.path.exists(STREAMRIP_CONFIG): 486 | with open(STREAMRIP_CONFIG, 'r') as f: 487 | config_content = f.read() 488 | #logger.debug(f"Config file content: {config_content[:200]}...") # First 200 chars 489 | 490 | app_id_match = re.search(r'app_id\s*=\s*["\']?([^"\'\n]+)["\']?', config_content) 491 | 492 | if app_id_match: 493 | app_id = app_id_match.group(1).strip() 494 | logger.debug(f"Found app_id in config: {app_id}") 495 | return app_id 496 | else: 497 | logger.debug("No app_id found in config, using fallback") 498 | 499 | #Return a known working app_id as fallback 500 | fallback_app_id = "950096963" 501 | logger.debug(f"Using fallback app_id: {fallback_app_id}") 502 | return fallback_app_id 503 | 504 | except Exception as e: 505 | logger.error(f"Error extracting app_id: {e}") 506 | return "950096963" 507 | 508 | 509 | def construct_url(source, media_type, item_id): 510 | if not item_id: 511 | return '' 512 | 513 | url_patterns = { 514 | 'qobuz': { 515 | 'album': f'https://open.qobuz.com/album/{item_id}', 516 | 'track': f'https://open.qobuz.com/track/{item_id}', 517 | 'artist': f'https://open.qobuz.com/artist/{item_id}', 518 | 'playlist': f'https://open.qobuz.com/playlist/{item_id}' 519 | }, 520 | 'tidal': { 521 | 'album': f'https://tidal.com/browse/album/{item_id}', 522 | 'track': f'https://tidal.com/browse/track/{item_id}', 523 | 'artist': f'https://tidal.com/browse/artist/{item_id}', 524 | 'playlist': f'https://tidal.com/browse/playlist/{item_id}' 525 | }, 526 | 'deezer': { 527 | 'album': f'https://www.deezer.com/album/{item_id}', 528 | 'track': f'https://www.deezer.com/track/{item_id}', 529 | 'artist': f'https://www.deezer.com/artist/{item_id}', 530 | 'playlist': f'https://www.deezer.com/playlist/{item_id}' 531 | }, 532 | 'soundcloud': { 533 | 'track': f'https://soundcloud.com/{item_id}', 534 | 'album': f'https://soundcloud.com/{item_id}', 535 | 'playlist': f'https://soundcloud.com/{item_id}' 536 | } 537 | } 538 | 539 | if source in url_patterns and media_type in url_patterns[source]: 540 | return url_patterns[source][media_type] 541 | 542 | return f'https://open.{source}.com/{media_type}/{item_id}' 543 | 544 | 545 | 546 | def extract_metadata_from_url(url): 547 | metadata = { 548 | 'service': None, 549 | 'type': None, 550 | 'id': None, 551 | 'title': None, 552 | 'artist': None, 553 | 'album_art': None 554 | } 555 | 556 | try: 557 | if 'spotify.com' in url: 558 | metadata['service'] = 'spotify' 559 | match = re.search(r'/(album|track|playlist|artist)/([a-zA-Z0-9]+)', url) 560 | if match: 561 | metadata['type'] = match.group(1) 562 | metadata['id'] = match.group(2) 563 | #Note: Spotify requires OAuth for metadata, so we can't easily fetch it 564 | 565 | elif 'qobuz.com' in url: 566 | metadata['service'] = 'qobuz' 567 | match = re.search(r'/(album|track|playlist|artist)/([0-9]+)', url) 568 | if match: 569 | metadata['type'] = match.group(1) 570 | metadata['id'] = match.group(2) 571 | metadata.update(fetch_qobuz_metadata(metadata['id'], metadata['type'])) 572 | 573 | elif 'tidal.com' in url: 574 | metadata['service'] = 'tidal' 575 | match = re.search(r'/(album|track|playlist|artist)/([0-9]+)', url) 576 | if match: 577 | metadata['type'] = match.group(1) 578 | metadata['id'] = match.group(2) 579 | metadata['album_art'] = f"https://resources.tidal.com/images/{metadata['id']}/320x320.jpg" 580 | 581 | elif 'deezer.com' in url: 582 | metadata['service'] = 'deezer' 583 | match = re.search(r'/(album|track|playlist|artist)/([0-9]+)', url) 584 | if match: 585 | metadata['type'] = match.group(1) 586 | metadata['id'] = match.group(2) 587 | metadata.update(fetch_deezer_metadata(metadata['id'], metadata['type'])) 588 | 589 | except Exception as e: 590 | logger.error(f"Error extracting metadata from URL: {e}") 591 | 592 | return metadata 593 | 594 | def fetch_qobuz_metadata(item_id, item_type): 595 | metadata = {} 596 | try: 597 | app_id = get_qobuz_app_id() 598 | api_base = "https://www.qobuz.com/api.json/0.2" 599 | 600 | if item_type == 'album': 601 | response = requests.get( 602 | f"{api_base}/album/get", 603 | params={'album_id': item_id, 'app_id': app_id}, 604 | timeout=5 605 | ) 606 | if response.status_code == 200: 607 | data = response.json() 608 | metadata['title'] = data.get('title', '') 609 | metadata['artist'] = data.get('artist', {}).get('name', '') 610 | if 'image' in data: 611 | for size in ['small', 'medium', 'large', 'thumbnail']: 612 | if size in data['image']: 613 | metadata['album_art'] = data['image'][size] 614 | break 615 | 616 | elif item_type == 'track': 617 | response = requests.get( 618 | f"{api_base}/track/get", 619 | params={'track_id': item_id, 'app_id': app_id}, 620 | timeout=5 621 | ) 622 | if response.status_code == 200: 623 | data = response.json() 624 | metadata['title'] = data.get('title', '') 625 | metadata['artist'] = data.get('performer', {}).get('name', '') 626 | album = data.get('album', {}) 627 | if 'image' in album: 628 | for size in ['small', 'medium', 'large', 'thumbnail']: 629 | if size in album['image']: 630 | metadata['album_art'] = album['image'][size] 631 | break 632 | 633 | except Exception as e: 634 | logger.error(f"Error fetching Qobuz metadata: {e}") 635 | 636 | return metadata 637 | 638 | def fetch_deezer_metadata(item_id, item_type): 639 | metadata = {} 640 | try: 641 | api_base = "https://api.deezer.com" 642 | 643 | if item_type == 'album': 644 | response = requests.get(f"{api_base}/album/{item_id}", timeout=5) 645 | if response.status_code == 200: 646 | data = response.json() 647 | metadata['title'] = data.get('title', '') 648 | metadata['artist'] = data.get('artist', {}).get('name', '') 649 | metadata['album_art'] = data.get('cover_medium', '') 650 | 651 | elif item_type == 'track': 652 | response = requests.get(f"{api_base}/track/{item_id}", timeout=5) 653 | if response.status_code == 200: 654 | data = response.json() 655 | metadata['title'] = data.get('title', '') 656 | metadata['artist'] = data.get('artist', {}).get('name', '') 657 | album = data.get('album', {}) 658 | metadata['album_art'] = album.get('cover_medium', '') 659 | 660 | except Exception as e: 661 | logger.error(f"Error fetching Deezer metadata: {e}") 662 | 663 | return metadata 664 | 665 | 666 | @app.route('/api/download-from-url', methods=['POST']) 667 | def download_from_url(): 668 | data = request.json 669 | url = data.get('url') 670 | quality = data.get('quality', 3) 671 | 672 | title = data.get('title') 673 | artist = data.get('artist') 674 | album_art = data.get('album_art') 675 | service = data.get('service') 676 | 677 | if not url: 678 | return jsonify({'error': 'URL required'}), 400 679 | 680 | if title and artist and service: 681 | metadata = { 682 | 'title': title, 683 | 'artist': artist, 684 | 'album_art': album_art, 685 | 'service': service 686 | } 687 | else: 688 | metadata = extract_metadata_from_url(url) 689 | 690 | task_id = f"dl_{int(time.time() * 1000)}" 691 | task = { 692 | 'id': task_id, 693 | 'url': url, 694 | 'quality': quality, 695 | 'metadata': metadata 696 | } 697 | 698 | download_queue.put(task) 699 | 700 | return jsonify({ 701 | 'task_id': task_id, 702 | 'status': 'queued', 703 | 'metadata': metadata 704 | }) 705 | 706 | 707 | 708 | 709 | 710 | if __name__ == '__main__': 711 | app.run(host='0.0.0.0', port=5000, debug=False) 712 | -------------------------------------------------------------------------------- /demo/active_dl.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnOddName/streamrip-web-gui/7c35c769a6294880bfa219137169702ebc8a2505/demo/active_dl.png -------------------------------------------------------------------------------- /demo/complete_dl.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnOddName/streamrip-web-gui/7c35c769a6294880bfa219137169702ebc8a2505/demo/complete_dl.png -------------------------------------------------------------------------------- /demo/home_screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnOddName/streamrip-web-gui/7c35c769a6294880bfa219137169702ebc8a2505/demo/home_screen.png -------------------------------------------------------------------------------- /demo/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnOddName/streamrip-web-gui/7c35c769a6294880bfa219137169702ebc8a2505/demo/search.png -------------------------------------------------------------------------------- /static/css/style.css: -------------------------------------------------------------------------------- 1 | /* static/css/style.css */ 2 | 3 | * { 4 | margin: 0; 5 | padding: 0; 6 | box-sizing: border-box; 7 | } 8 | 9 | :root { 10 | --bg-primary: #0a0a0a; 11 | --bg-secondary: #141414; 12 | --bg-tertiary: #1a1a1a; 13 | --text-primary: #ffffff; 14 | --text-secondary: #888888; 15 | --text-tertiary: #555555; 16 | --accent: #ffffff; 17 | --border: #2a2a2a; 18 | --success: #00ff88; 19 | --error: #ff3333; 20 | --warning: #ffaa00; 21 | } 22 | 23 | body { 24 | font-family: 'IBM Plex Mono', monospace; 25 | background: var(--bg-primary); 26 | color: var(--text-primary); 27 | min-height: 100vh; 28 | padding: 20px; 29 | line-height: 1.6; 30 | } 31 | 32 | .container { 33 | max-width: 1200px; 34 | margin: 0 auto; 35 | } 36 | 37 | .header { 38 | border-bottom: 1px solid var(--border); 39 | padding: 30px 0; 40 | margin-bottom: 30px; 41 | } 42 | 43 | h1 { 44 | font-size: 48px; 45 | font-weight: 300; 46 | letter-spacing: 8px; 47 | margin-bottom: 10px; 48 | } 49 | 50 | .subtitle { 51 | color: var(--text-secondary); 52 | font-size: 12px; 53 | text-transform: uppercase; 54 | letter-spacing: 2px; 55 | } 56 | 57 | 58 | .input-group { 59 | display: flex; 60 | gap: 10px; 61 | margin-bottom: 30px; 62 | } 63 | 64 | input[type="text"] { 65 | flex: 1; 66 | padding: 12px; 67 | background: var(--bg-primary); 68 | border: 1px solid var(--border); 69 | color: var(--text-primary); 70 | font-family: inherit; 71 | font-size: 14px; 72 | transition: border-color 0.2s; 73 | } 74 | 75 | input[type="text"]:focus { 76 | outline: none; 77 | border-color: var(--accent); 78 | } 79 | 80 | input[type="text"]::placeholder { 81 | color: var(--text-tertiary); 82 | } 83 | 84 | select { 85 | padding: 12px; 86 | background: var(--bg-primary); 87 | border: 1px solid var(--border); 88 | color: var(--text-primary); 89 | font-family: inherit; 90 | font-size: 14px; 91 | cursor: pointer; 92 | min-width: 150px; 93 | } 94 | 95 | select:focus { 96 | outline: none; 97 | border-color: var(--accent); 98 | } 99 | 100 | button { 101 | padding: 12px 24px; 102 | background: var(--bg-primary); 103 | color: var(--text-primary); 104 | border: 1px solid var(--accent); 105 | font-family: inherit; 106 | font-size: 12px; 107 | font-weight: 400; 108 | letter-spacing: 1px; 109 | cursor: pointer; 110 | transition: all 0.2s; 111 | text-transform: uppercase; 112 | } 113 | 114 | button:hover:not(:disabled) { 115 | background: var(--accent); 116 | color: var(--bg-primary); 117 | } 118 | 119 | button:disabled { 120 | opacity: 0.3; 121 | cursor: not-allowed; 122 | } 123 | 124 | .secondary-btn { 125 | border-color: var(--border); 126 | color: var(--text-secondary); 127 | } 128 | 129 | .secondary-btn:hover:not(:disabled) { 130 | border-color: var(--accent); 131 | background: transparent; 132 | color: var(--accent); 133 | } 134 | 135 | .tabs { 136 | display: flex; 137 | gap: 0; 138 | margin-bottom: 30px; 139 | border-bottom: 1px solid var(--border); 140 | margin-top: 10px; 141 | } 142 | 143 | .tab { 144 | padding: 15px 30px; 145 | background: transparent; 146 | border: none; 147 | border-bottom: 2px solid transparent; 148 | color: var(--text-tertiary); 149 | cursor: pointer; 150 | font-size: 12px; 151 | letter-spacing: 1px; 152 | transition: all 0.2s; 153 | margin-bottom: -1px; 154 | } 155 | 156 | .tab:hover { 157 | color: var(--text-secondary); 158 | } 159 | 160 | .tab.active { 161 | color: var(--text-primary); 162 | border-bottom-color: var(--accent); 163 | } 164 | 165 | .tab-content { 166 | display: none; 167 | min-height: 400px; 168 | } 169 | 170 | .tab-content.active { 171 | display: block; 172 | } 173 | 174 | .download-item { 175 | background: var(--bg-tertiary); 176 | border: 1px solid var(--border); 177 | padding: 20px; 178 | margin-bottom: 10px; 179 | position: relative; 180 | transition: border-color 0.2s; 181 | } 182 | 183 | .download-item:hover { 184 | border-color: var(--text-tertiary); 185 | } 186 | 187 | .download-item.downloading { 188 | border-left: 3px solid var(--warning); 189 | } 190 | 191 | .download-item.completed { 192 | border-left: 3px solid var(--success); 193 | } 194 | 195 | .download-item.failed { 196 | border-left: 3px solid var(--error); 197 | } 198 | 199 | .download-content { 200 | display: flex; 201 | gap: 15px; 202 | align-items: center; 203 | position: relative; 204 | } 205 | 206 | .download-spinner { 207 | margin-left: auto; 208 | margin-right: 15px; 209 | flex-shrink: 0; 210 | width: 24px; 211 | height: 24px; 212 | display: flex; 213 | align-items: center; 214 | justify-content: center; 215 | font-family: 'IBM Plex Mono', monospace; 216 | font-size: 20px; 217 | color: var(--accent); 218 | } 219 | 220 | .download-spinner::after { 221 | content: '⠋'; 222 | animation: terminal-spin 0.8s steps(10) infinite; 223 | } 224 | 225 | @keyframes terminal-spin { 226 | 0% { content: '⠋'; } 227 | 10% { content: '⠙'; } 228 | 20% { content: '⠹'; } 229 | 30% { content: '⠸'; } 230 | 40% { content: '⠼'; } 231 | 50% { content: '⠴'; } 232 | 60% { content: '⠦'; } 233 | 70% { content: '⠧'; } 234 | 80% { content: '⠇'; } 235 | 90% { content: '⠏'; } 236 | 100% { content: '⠋'; } 237 | } 238 | 239 | .download-album-art { 240 | width: 60px; 241 | height: 60px; 242 | object-fit: cover; 243 | border: 1px solid var(--border); 244 | flex-shrink: 0; 245 | } 246 | 247 | .download-album-art.placeholder { 248 | display: flex; 249 | align-items: center; 250 | justify-content: center; 251 | background: var(--bg-primary); 252 | color: var(--text-tertiary); 253 | font-size: 24px; 254 | position: relative; 255 | overflow: hidden; 256 | } 257 | 258 | 259 | .download-title { 260 | font-size: 15px; 261 | color: var(--text-primary); 262 | margin-bottom: 4px; 263 | font-weight: 500; 264 | overflow: hidden; 265 | text-overflow: ellipsis; 266 | white-space: nowrap; 267 | } 268 | 269 | .download-artist { 270 | font-size: 13px; 271 | color: var(--text-secondary); 272 | margin-bottom: 8px; 273 | overflow: hidden; 274 | text-overflow: ellipsis; 275 | white-space: nowrap; 276 | } 277 | 278 | 279 | .download-meta { 280 | display: flex; 281 | align-items: center; 282 | gap: 10px; 283 | flex-wrap: wrap; 284 | } 285 | 286 | .service-badge { 287 | display: inline-block; 288 | padding: 2px 8px; 289 | font-size: 10px; 290 | text-transform: uppercase; 291 | letter-spacing: 0.5px; 292 | border: 1px solid var(--text-tertiary); 293 | color: var(--text-tertiary); 294 | } 295 | 296 | 297 | .download-album-art.placeholder.success { 298 | color: var(--success); 299 | } 300 | 301 | .download-album-art.placeholder.error { 302 | color: var(--error); 303 | } 304 | 305 | .download-info { 306 | flex: 1; 307 | min-width: 0; 308 | overflow: hidden; 309 | } 310 | 311 | .download-output { 312 | margin-top: 15px; 313 | padding: 15px; 314 | background: var(--bg-primary); 315 | border: 1px solid var(--border); 316 | font-family: 'IBM Plex Mono', monospace; 317 | font-size: 11px; 318 | max-height: 150px; 319 | overflow-y: auto; 320 | display: none; 321 | color: var(--text-secondary); 322 | } 323 | 324 | .download-item.expanded .download-output { 325 | display: block; 326 | } 327 | 328 | .toggle-output { 329 | font-size: 11px; 330 | color: var(--text-tertiary); 331 | cursor: pointer; 332 | text-decoration: none; 333 | transition: color 0.2s; 334 | margin-top: 10px; 335 | display: inline-block; 336 | } 337 | 338 | .toggle-output:hover { 339 | color: var(--accent); 340 | } 341 | 342 | .empty-state { 343 | text-align: center; 344 | padding: 60px 20px; 345 | color: var(--text-tertiary); 346 | font-size: 12px; 347 | letter-spacing: 1px; 348 | text-transform: uppercase; 349 | } 350 | 351 | .status-badge { 352 | display: inline-block; 353 | padding: 4px 12px; 354 | font-size: 10px; 355 | font-weight: 400; 356 | text-transform: uppercase; 357 | letter-spacing: 1px; 358 | border: 1px solid; 359 | } 360 | 361 | .status-badge.queued { 362 | color: var(--warning); 363 | border-color: var(--warning); 364 | } 365 | 366 | .status-badge.downloading { 367 | color: var(--warning); 368 | border-color: var(--warning); 369 | } 370 | 371 | .status-badge.completed { 372 | color: var(--success); 373 | border-color: var(--success); 374 | } 375 | 376 | .status-badge.failed { 377 | color: var(--error); 378 | border-color: var(--error); 379 | } 380 | 381 | .config-editor { 382 | width: 100%; 383 | min-height: 400px; 384 | padding: 20px; 385 | background: var(--bg-primary); 386 | border: 1px solid var(--border); 387 | color: var(--text-primary); 388 | font-family: 'IBM Plex Mono', monospace; 389 | font-size: 13px; 390 | resize: vertical; 391 | } 392 | 393 | .config-editor:focus { 394 | outline: none; 395 | border-color: var(--accent); 396 | } 397 | 398 | .config-buttons { 399 | margin-top: 20px; 400 | display: flex; 401 | gap: 10px; 402 | } 403 | 404 | .file-list { 405 | max-height: 500px; 406 | overflow-y: auto; 407 | margin-top: 20px; 408 | } 409 | 410 | .file-item { 411 | display: flex; 412 | justify-content: space-between; 413 | padding: 15px; 414 | background: var(--bg-tertiary); 415 | border: 1px solid var(--border); 416 | margin-bottom: 8px; 417 | transition: border-color 0.2s; 418 | } 419 | 420 | .file-item:hover { 421 | border-color: var(--text-tertiary); 422 | } 423 | 424 | .file-name { 425 | flex: 1; 426 | font-size: 13px; 427 | color: var(--text-primary); 428 | word-break: break-all; 429 | font-family: 'IBM Plex Mono', monospace; 430 | } 431 | 432 | .file-meta { 433 | font-size: 11px; 434 | color: var(--text-tertiary); 435 | margin-left: 20px; 436 | text-transform: uppercase; 437 | letter-spacing: 0.5px; 438 | } 439 | 440 | .search-container { 441 | margin-bottom: 20px; 442 | } 443 | 444 | .search-options { 445 | display: flex; 446 | gap: 10px; 447 | margin-bottom: 15px; 448 | } 449 | 450 | .search-type-selector { 451 | display: flex; 452 | gap: 0; 453 | flex: 1; 454 | } 455 | 456 | .search-type-btn { 457 | padding: 10px 20px; 458 | background: var(--bg-primary); 459 | border: 1px solid var(--border); 460 | color: var(--text-tertiary); 461 | cursor: pointer; 462 | transition: all 0.2s; 463 | flex: 1; 464 | } 465 | 466 | .search-type-btn:not(:last-child) { 467 | border-right: none; 468 | } 469 | 470 | .search-type-btn.active { 471 | background: var(--accent); 472 | color: var(--bg-primary); 473 | border-color: var(--accent); 474 | } 475 | 476 | .search-type-btn:hover:not(.active) { 477 | color: var(--text-secondary); 478 | } 479 | 480 | .search-controls { 481 | display: flex; 482 | justify-content: space-between; 483 | align-items: center; 484 | margin-bottom: 20px; 485 | padding: 15px; 486 | background: var(--bg-tertiary); 487 | border: 1px solid var(--border); 488 | } 489 | 490 | .pagination { 491 | display: flex; 492 | align-items: center; 493 | gap: 15px; 494 | } 495 | 496 | .pagination button { 497 | padding: 8px 16px; 498 | font-size: 11px; 499 | } 500 | 501 | #pageInfo { 502 | font-size: 12px; 503 | color: var(--text-secondary); 504 | text-transform: uppercase; 505 | letter-spacing: 0.5px; 506 | } 507 | 508 | .results-info { 509 | font-size: 12px; 510 | color: var(--text-secondary); 511 | text-transform: uppercase; 512 | letter-spacing: 0.5px; 513 | } 514 | 515 | .search-results { 516 | max-height: 500px; 517 | overflow-y: auto; 518 | } 519 | 520 | .search-result-item { 521 | display: flex; 522 | align-items: center; 523 | padding: 20px; 524 | background: var(--bg-tertiary); 525 | border: 1px solid var(--border); 526 | margin-bottom: 8px; 527 | transition: all 0.2s; 528 | gap: 20px; 529 | } 530 | 531 | .search-result-item:hover { 532 | border-color: var(--text-tertiary); 533 | } 534 | 535 | .result-album-art { 536 | width: 60px; 537 | height: 60px; 538 | object-fit: cover; 539 | background: var(--bg-primary); 540 | border: 1px solid var(--border); 541 | flex-shrink: 0; 542 | } 543 | 544 | .result-album-art.placeholder { 545 | display: flex; 546 | align-items: center; 547 | justify-content: center; 548 | color: var(--text-tertiary); 549 | font-size: 24px; 550 | } 551 | 552 | .result-info { 553 | flex: 1; 554 | min-width: 0; 555 | } 556 | 557 | .result-service { 558 | display: inline-block; 559 | padding: 2px 8px; 560 | border: 1px solid var(--text-tertiary); 561 | color: var(--text-tertiary); 562 | font-size: 10px; 563 | text-transform: uppercase; 564 | letter-spacing: 0.5px; 565 | margin-bottom: 8px; 566 | } 567 | 568 | .result-title { 569 | font-weight: 400; 570 | color: var(--text-primary); 571 | font-size: 14px; 572 | margin-bottom: 4px; 573 | overflow: hidden; 574 | text-overflow: ellipsis; 575 | white-space: nowrap; 576 | } 577 | 578 | .result-artist { 579 | color: var(--text-secondary); 580 | font-size: 12px; 581 | overflow: hidden; 582 | text-overflow: ellipsis; 583 | white-space: nowrap; 584 | } 585 | 586 | .result-id { 587 | font-size: 10px; 588 | color: var(--text-tertiary); 589 | margin-top: 4px; 590 | font-family: 'IBM Plex Mono', monospace; 591 | } 592 | 593 | .result-download-btn { 594 | padding: 10px 20px; 595 | font-size: 11px; 596 | flex-shrink: 0; 597 | } 598 | 599 | ::-webkit-scrollbar { 600 | width: 8px; 601 | height: 8px; 602 | } 603 | 604 | ::-webkit-scrollbar-track { 605 | background: var(--bg-primary); 606 | } 607 | 608 | ::-webkit-scrollbar-thumb { 609 | background: var(--border); 610 | border: none; 611 | } 612 | 613 | ::-webkit-scrollbar-thumb:hover { 614 | background: var(--text-tertiary); 615 | } 616 | 617 | @keyframes pulse { 618 | 0% { opacity: 0.3; } 619 | 50% { opacity: 0.6; } 620 | 100% { opacity: 0.3; } 621 | } 622 | 623 | .result-album-art.placeholder { 624 | animation: pulse 2s infinite; 625 | } 626 | 627 | .result-album-art img { 628 | animation: none; 629 | } 630 | .result-album-art.placeholder.loaded { 631 | animation: none; 632 | } 633 | .search-section { 634 | margin: 25px 0; 635 | padding: 20px; 636 | background: var(--bg-tertiary); 637 | border: 1px solid var(--border); 638 | } 639 | 640 | .search-header { 641 | display: flex; 642 | align-items: center; 643 | margin-bottom: 15px; 644 | } 645 | 646 | .search-header h2 { 647 | font-size: 16px; 648 | font-weight: 500; 649 | letter-spacing: 1px; 650 | text-transform: uppercase; 651 | color: var(--text-primary); 652 | margin-right: 15px; 653 | } 654 | 655 | .search-header .divider { 656 | flex: 1; 657 | height: 1px; 658 | background: var(--border); 659 | } 660 | 661 | .section { 662 | background: var(--bg-secondary); 663 | border: 1px solid var(--border); 664 | padding: 25px; 665 | margin-bottom: 20px; 666 | } 667 | 668 | .section-header { 669 | display: flex; 670 | align-items: center; 671 | margin-bottom: 20px; 672 | } 673 | 674 | .section-header h2 { 675 | font-size: 16px; 676 | font-weight: 500; 677 | letter-spacing: 1px; 678 | text-transform: uppercase; 679 | color: var(--text-primary); 680 | margin-right: 15px; 681 | } 682 | 683 | .section-header .divider { 684 | flex: 1; 685 | height: 1px; 686 | background: var(--border); 687 | } 688 | 689 | .url-section .input-group { 690 | margin-bottom: 0; 691 | } 692 | 693 | .search-section .search-container { 694 | margin-bottom: 20px; 695 | } 696 | 697 | .search-section .search-controls { 698 | margin-bottom: 20px; 699 | } 700 | 701 | .tabs-section .tabs { 702 | margin-bottom: 20px; 703 | } 704 | -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnOddName/streamrip-web-gui/7c35c769a6294880bfa219137169702ebc8a2505/static/favicon.ico -------------------------------------------------------------------------------- /static/js/app.js: -------------------------------------------------------------------------------- 1 | // static/js/app.js 2 | 3 | let currentTab = 'active'; 4 | let currentSearchType = 'album'; 5 | let currentPage = 1; 6 | let itemsPerPage = 10; 7 | let totalResults = 0; 8 | let allSearchResults = []; 9 | 10 | 11 | let eventSource = null; 12 | let activeDownloads = new Map(); 13 | let downloadHistory = []; 14 | 15 | 16 | function initializeSSE() { 17 | if (eventSource) { 18 | eventSource.close(); 19 | } 20 | 21 | eventSource = new EventSource('/api/events'); 22 | 23 | 24 | eventSource.onerror = function(error) { 25 | console.error('SSE error:', error); 26 | setTimeout(initializeSSE, 5000); 27 | }; 28 | 29 | eventSource.onmessage = function(event) { 30 | const data = JSON.parse(event.data); 31 | handleSSEMessage(data); 32 | }; 33 | } 34 | 35 | function handleSSEMessage(data) { 36 | 37 | switch(data.type) { 38 | case 'download_started': 39 | handleDownloadStarted(data); 40 | break; 41 | case 'download_progress': 42 | handleDownloadProgress(data); 43 | break; 44 | case 'download_completed': 45 | handleDownloadCompleted(data); 46 | break; 47 | case 'download_error': 48 | handleDownloadError(data); 49 | break; 50 | case 'heartbeat': 51 | console.log('badump') 52 | default: 53 | console.log('Unknown SSE message type:', data.type); 54 | } 55 | } 56 | 57 | function handleDownloadStarted(data) { 58 | activeDownloads.set(data.id, { 59 | id: data.id, 60 | metadata: data.metadata, 61 | status: 'downloading', 62 | progress: 0, 63 | output: [], 64 | startTime: Date.now() 65 | }); 66 | 67 | if (currentTab === 'active') { 68 | renderActiveDownloads(); 69 | } 70 | } 71 | 72 | function handleDownloadError(data) { 73 | const download = activeDownloads.get(data.id); 74 | if (download) { 75 | download.status = 'error'; 76 | download.error = data.error; 77 | updateDownloadElement(data.id, download); 78 | 79 | setTimeout(() => { 80 | downloadHistory.unshift({ 81 | ...download, 82 | completedAt: Date.now() 83 | }); 84 | activeDownloads.delete(data.id); 85 | 86 | if (currentTab === 'active') { 87 | renderActiveDownloads(); 88 | } else if (currentTab === 'history') { 89 | renderDownloadHistory(); 90 | } 91 | }, 2000); 92 | } 93 | } 94 | 95 | function renderDownloadHistory() { 96 | const container = document.getElementById('downloadHistory'); 97 | if (!container) return; 98 | 99 | if (downloadHistory.length === 0) { 100 | container.innerHTML = '
NO DOWNLOAD HISTORY
'; 101 | return; 102 | } 103 | 104 | container.innerHTML = downloadHistory.map(item => { 105 | const statusIcon = item.status === 'completed' ? '✓' : '✗'; 106 | const statusClass = item.status === 'completed' ? 'success' : 'error'; 107 | 108 | return ` 109 |
110 |
111 | ${item.metadata?.album_art ? 112 | `` : 113 | `
${statusIcon}
` 114 | } 115 |
116 |
${item.metadata?.title || 'Unknown'}
117 |
${item.metadata?.artist || 'Unknown Artist'}
118 |
119 | ${item.status} 120 | ${item.metadata?.service ? 121 | `${item.metadata.service.toUpperCase()}` : ''} 122 |
123 |
124 |
125 |
126 | `}).join(''); 127 | } 128 | 129 | 130 | function handleDownloadProgress(data) { 131 | const download = activeDownloads.get(data.id); 132 | if (download) { 133 | download.latestOutput = data.output; 134 | download.allOutput = download.allOutput || []; 135 | download.allOutput.push(data.output); 136 | } 137 | } 138 | 139 | function handleDownloadCompleted(data) { 140 | const download = activeDownloads.get(data.id); 141 | if (download) { 142 | download.status = data.status; 143 | download.endTime = Date.now(); 144 | download.output = data.output || download.allOutput.join('\n') || 'No output captured'; 145 | updateDownloadElement(data.id, download); 146 | 147 | setTimeout(() => { 148 | downloadHistory.unshift({ 149 | ...download, 150 | completedAt: Date.now() 151 | }); 152 | 153 | if (downloadHistory.length > 50) { 154 | downloadHistory.pop(); 155 | } 156 | 157 | activeDownloads.delete(data.id); 158 | 159 | if (currentTab === 'active') { 160 | renderActiveDownloads(); 161 | } else if (currentTab === 'history') { 162 | renderDownloadHistory(); 163 | } 164 | }, 3000); //3 seconds in tab 165 | } 166 | } 167 | 168 | 169 | function updateDownloadElement(id, download) { 170 | const element = document.querySelector(`[data-download-id="${id}"]`); 171 | if (!element) { 172 | renderActiveDownloads(); 173 | return; 174 | } 175 | 176 | const statusBadge = element.querySelector('.status-badge'); 177 | if (statusBadge) { 178 | statusBadge.textContent = download.status; 179 | statusBadge.className = `status-badge ${download.status}`; 180 | } 181 | 182 | if (element.classList.contains('expanded')) { 183 | const outputEl = element.querySelector('.download-output'); 184 | if (outputEl) { 185 | outputEl.textContent = download.output; 186 | outputEl.scrollTop = outputEl.scrollHeight; 187 | } 188 | } 189 | } 190 | function renderActiveDownloads() { 191 | const container = document.getElementById('activeDownloads'); 192 | 193 | if (activeDownloads.size === 0) { 194 | container.innerHTML = '
NO ACTIVE DOWNLOADS
'; 195 | return; 196 | } 197 | 198 | container.innerHTML = Array.from(activeDownloads.values()).map(item => ` 199 |
200 |
201 | ${item.metadata.album_art ? 202 | `` : 203 | `
` 204 | } 205 |
206 |
${item.metadata.title || 'Unknown'}
207 |
${item.metadata.artist || 'Unknown Artist'}
208 |
209 | ${item.status} 210 | ${item.metadata.service ? 211 | `${item.metadata.service.toUpperCase()}` : ''} 212 |
213 | ${item.output ? `SHOW OUTPUT` : ''} 214 |
215 |
216 |
217 | ${item.output ? ` 218 |
219 | ${item.output} 220 |
221 | ` : ''} 222 |
223 | `).join(''); 224 | } 225 | 226 | 227 | function toggleOutput(id) { 228 | const item = document.querySelector(`.download-item[data-download-id="${id}"]`); 229 | if (item) { 230 | item.classList.toggle('expanded'); 231 | const toggleBtn = item.querySelector('.toggle-output'); 232 | if (toggleBtn) { 233 | toggleBtn.textContent = item.classList.contains('expanded') ? 'HIDE OUTPUT' : 'SHOW OUTPUT'; 234 | } 235 | } 236 | } 237 | 238 | 239 | function switchTab(tab, element) { 240 | currentTab = tab; 241 | 242 | if (element && !element.classList.contains('search-header')) { 243 | document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); 244 | element.classList.add('active'); 245 | } 246 | 247 | document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active')); 248 | document.getElementById(tab + 'Tab').classList.add('active'); 249 | 250 | if (tab === 'active') { 251 | renderActiveDownloads(); 252 | } else if (tab === 'history') { 253 | renderDownloadHistory(); 254 | } else if (tab === 'config') { 255 | loadConfig(); 256 | } else if (tab === 'files') { 257 | loadFiles(); 258 | } 259 | } 260 | 261 | async function startDownload() { 262 | const url = document.getElementById('urlInput').value.trim(); 263 | const quality = document.getElementById('qualitySelect').value; 264 | 265 | if (!url) { 266 | alert('Please enter a URL'); 267 | return; 268 | } 269 | 270 | const btn = document.getElementById('downloadBtn'); 271 | btn.disabled = true; 272 | 273 | try { 274 | const response = await fetch('/api/download', { 275 | method: 'POST', 276 | headers: { 'Content-Type': 'application/json' }, 277 | body: JSON.stringify({ url, quality: parseInt(quality) }) 278 | }); 279 | 280 | const data = await response.json(); 281 | 282 | if (response.ok) { 283 | document.getElementById('urlInput').value = ''; 284 | document.querySelector('.tab').click(); 285 | } else { 286 | alert(data.error || 'Failed to start download'); 287 | } 288 | } catch (error) { 289 | alert('Error: ' + error.message); 290 | } finally { 291 | btn.disabled = false; 292 | } 293 | } 294 | 295 | 296 | async function loadConfig() { 297 | try { 298 | const response = await fetch('/api/config'); 299 | const data = await response.json(); 300 | document.getElementById('configEditor').value = data.config || ''; 301 | } catch (error) { 302 | alert('Failed to load config: ' + error.message); 303 | } 304 | } 305 | 306 | async function saveConfig() { 307 | const config = document.getElementById('configEditor').value; 308 | 309 | try { 310 | const response = await fetch('/api/config', { 311 | method: 'POST', 312 | headers: { 'Content-Type': 'application/json' }, 313 | body: JSON.stringify({ config }) 314 | }); 315 | 316 | if (response.ok) { 317 | alert('Config saved successfully'); 318 | } else { 319 | const data = await response.json(); 320 | alert('Failed to save config: ' + (data.error || 'Unknown error')); 321 | } 322 | } catch (error) { 323 | alert('Error: ' + error.message); 324 | } 325 | } 326 | 327 | async function loadFiles() { 328 | try { 329 | const response = await fetch('/api/browse'); 330 | const files = await response.json(); 331 | 332 | const container = document.getElementById('fileList'); 333 | 334 | if (files.length === 0) { 335 | container.innerHTML = '
NO FILES FOUND
'; 336 | return; 337 | } 338 | 339 | container.innerHTML = files.map(file => ` 340 |
341 |
${file.name}
342 |
343 | ${(file.size / 1024 / 1024).toFixed(2)} MB • 344 | ${new Date(file.modified * 1000).toLocaleDateString()} 345 |
346 |
347 | `).join(''); 348 | } catch (error) { 349 | alert('Failed to load files: ' + error.message); 350 | } 351 | } 352 | 353 | function setSearchType(type, element) { 354 | currentSearchType = type; 355 | document.querySelectorAll('.search-type-btn').forEach(btn => btn.classList.remove('active')); 356 | element.classList.add('active'); 357 | } 358 | 359 | async function searchMusic() { 360 | const query = document.getElementById('searchInput').value.trim(); 361 | const source = document.getElementById('searchSource').value; 362 | 363 | if (!query) { 364 | alert('Please enter a search query'); 365 | return; 366 | } 367 | 368 | const resultsDiv = document.getElementById('searchResults'); 369 | resultsDiv.innerHTML = '
SEARCHING ' + source.toUpperCase() + '...
'; 370 | 371 | currentPage = 1; 372 | allSearchResults = []; 373 | 374 | try { 375 | const response = await fetch('/api/search', { 376 | method: 'POST', 377 | headers: { 'Content-Type': 'application/json' }, 378 | body: JSON.stringify({ 379 | query: query, 380 | type: currentSearchType, 381 | source: source 382 | }) 383 | }); 384 | 385 | const data = await response.json(); 386 | 387 | if (data.message) { 388 | resultsDiv.innerHTML = `
${data.message.toUpperCase()}
`; 389 | updatePaginationControls(); 390 | } else if (data.results && data.results.length > 0) { 391 | allSearchResults = data.results; 392 | totalResults = data.results.length; 393 | displayCurrentPage(); 394 | } else { 395 | resultsDiv.innerHTML = '
NO RESULTS FOUND ON ' + source.toUpperCase() + '
'; 396 | updatePaginationControls(); 397 | } 398 | } catch (error) { 399 | console.error('Search error:', error); 400 | resultsDiv.innerHTML = '
SEARCH FAILED: ' + error.message.toUpperCase() + '
'; 401 | } 402 | } 403 | 404 | function displayCurrentPage() { 405 | const startIndex = (currentPage - 1) * itemsPerPage; 406 | const endIndex = Math.min(startIndex + itemsPerPage, totalResults); 407 | const pageResults = allSearchResults.slice(startIndex, endIndex); 408 | 409 | const resultsDiv = document.getElementById('searchResults'); 410 | 411 | if (pageResults.length === 0) { 412 | resultsDiv.innerHTML = '
NO RESULTS FOUND
'; 413 | return; 414 | } 415 | 416 | resultsDiv.innerHTML = pageResults.map(result => ` 417 |
418 |
419 |
420 | ${result.service} 421 | ${result.title ? `
${result.title}
` : ''} 422 |
${result.artist || result.desc}
423 | ${result.id ? `
ID: ${result.id} (${result.type})
` : ''} 424 |
425 | ${result.url ? ` 426 | 429 | ` : ` 430 | 433 | `} 434 |
435 | `).join(''); 436 | 437 | updatePaginationControls(); 438 | loadAlbumArtForVisibleItems(); 439 | } 440 | function updatePaginationControls() { 441 | const totalPages = Math.ceil(totalResults / itemsPerPage); 442 | document.getElementById('pageInfo').textContent = `Page ${currentPage} of ${totalPages}`; 443 | document.getElementById('resultsCount').textContent = `${totalResults} results`; 444 | 445 | document.getElementById('prevPage').disabled = currentPage <= 1; 446 | document.getElementById('nextPage').disabled = currentPage >= totalPages; 447 | } 448 | 449 | function changePage(direction) { 450 | const totalPages = Math.ceil(totalResults / itemsPerPage); 451 | const newPage = currentPage + direction; 452 | 453 | if (newPage >= 1 && newPage <= totalPages) { 454 | currentPage = newPage; 455 | displayCurrentPage(); 456 | } 457 | } 458 | 459 | document.getElementById('searchSource').addEventListener('change', function(e) { 460 | const source = e.target.value; 461 | const albumBtn = document.querySelector('.search-type-btn[onclick*="album"]'); 462 | const artistBtn = document.querySelector('.search-type-btn[onclick*="artist"]'); 463 | const trackBtn = document.querySelector('.search-type-btn[onclick*="track"]'); 464 | 465 | if (source === 'soundcloud') { 466 | if (currentSearchType === 'album' || currentSearchType === 'artist') { 467 | trackBtn.click(); 468 | } 469 | 470 | albumBtn.style.opacity = '0.3'; 471 | albumBtn.style.pointerEvents = 'none'; 472 | albumBtn.title = 'Not available on SoundCloud'; 473 | 474 | artistBtn.style.opacity = '0.3'; 475 | artistBtn.style.pointerEvents = 'none'; 476 | artistBtn.title = 'Not available on SoundCloud'; 477 | } else { 478 | albumBtn.style.opacity = '1'; 479 | albumBtn.style.pointerEvents = 'auto'; 480 | albumBtn.title = ''; 481 | 482 | artistBtn.style.opacity = '1'; 483 | artistBtn.style.pointerEvents = 'auto'; 484 | artistBtn.title = ''; 485 | } 486 | }); 487 | 488 | async function loadAlbumArtForVisibleItems() { 489 | const visibleItems = document.querySelectorAll('.search-result-item'); 490 | 491 | for (const item of visibleItems) { 492 | const itemId = item.dataset.id; 493 | const source = item.dataset.source; 494 | const type = item.dataset.type; 495 | 496 | if (!itemId) continue; 497 | 498 | try { 499 | const response = await fetch(`/api/album-art?source=${source}&type=${type}&id=${encodeURIComponent(itemId)}`); 500 | const data = await response.json(); 501 | if (data.album_art) { 502 | const artElement = document.getElementById(`art-${itemId}`); 503 | if (artElement) { 504 | artElement.classList.remove('placeholder'); 505 | artElement.innerHTML = `Album art`; 506 | } 507 | } else { 508 | const artElement = document.getElementById(`art-${itemId}`); 509 | if (artElement && artElement.classList.contains('placeholder')) { 510 | artElement.classList.add('loaded'); 511 | if (type === 'artist') { 512 | artElement.innerHTML = '👤'; 513 | } else if (type === 'track') { 514 | artElement.innerHTML = '🎵'; 515 | } else { 516 | artElement.innerHTML = '▶'; 517 | } 518 | } 519 | } 520 | } catch (error) { 521 | console.error('Error loading album art:', error); 522 | const artElement = document.getElementById(`art-${itemId}`); 523 | if (artElement && artElement.classList.contains('placeholder')) { 524 | if (type === 'artist') { 525 | artElement.innerHTML = '👤'; 526 | } else if (type === 'track') { 527 | artElement.innerHTML = '🎵'; 528 | } else { 529 | artElement.innerHTML = '▶'; 530 | } 531 | } 532 | } 533 | } 534 | } 535 | 536 | async function downloadFromUrl(url) { 537 | const quality = document.getElementById('qualitySelect').value; 538 | 539 | const searchResults = document.querySelectorAll('.search-result-item'); 540 | let metadata = {}; 541 | 542 | searchResults.forEach(item => { 543 | const btn = item.querySelector('.result-download-btn'); 544 | if (btn && btn.onclick && btn.onclick.toString().includes(url)) { 545 | const serviceEl = item.querySelector('.result-service'); 546 | const titleEl = item.querySelector('.result-title'); 547 | const artistEl = item.querySelector('.result-artist'); 548 | const artImg = item.querySelector('.result-album-art img'); 549 | 550 | metadata = { 551 | title: titleEl?.textContent || '', 552 | artist: artistEl?.textContent || '', 553 | service: serviceEl?.textContent?.toLowerCase() || '', 554 | album_art: artImg?.src || '' 555 | }; 556 | } 557 | }); 558 | 559 | switchTab('active'); 560 | 561 | try { 562 | const response = await fetch('/api/download-from-url', { 563 | method: 'POST', 564 | headers: { 'Content-Type': 'application/json' }, 565 | body: JSON.stringify({ 566 | url: url, 567 | quality: parseInt(quality), 568 | ...metadata 569 | }) 570 | }); 571 | 572 | const data = await response.json(); 573 | 574 | if (!response.ok) { 575 | alert('Failed to start download: ' + (data.error || 'Unknown error')); 576 | } 577 | 578 | } catch (error) { 579 | alert('Error: ' + error.message); 580 | } 581 | } 582 | 583 | 584 | window.addEventListener('load', () => { 585 | initializeSSE(); 586 | }); 587 | 588 | window.addEventListener('beforeunload', () => { 589 | if (eventSource) { 590 | eventSource.close(); 591 | } 592 | }); 593 | 594 | document.addEventListener('DOMContentLoaded', () => { 595 | const urlInput = document.getElementById('urlInput'); 596 | if (urlInput) { 597 | urlInput.addEventListener('keypress', (e) => { 598 | if (e.key === 'Enter') { 599 | startDownload(); 600 | } 601 | }); 602 | } 603 | 604 | const searchInput = document.getElementById('searchInput'); 605 | if (searchInput) { 606 | searchInput.addEventListener('keypress', (e) => { 607 | if (e.key === 'Enter') { 608 | searchMusic(); 609 | } 610 | }); 611 | } 612 | }); -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Streamrip Web 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |
16 |

STREAMRIP WEB

17 |

WEB GUI FOR STREAMRIP

18 |
19 | 20 | 21 |
22 |
23 |

Download from URL

24 |
25 |
26 |
27 | 28 | 34 | 35 |
36 |
37 | 38 | 39 |
40 |
41 |

Search

42 |
43 |
44 | 45 |
46 |
47 | 53 |
54 | 55 | 56 | 57 |
58 |
59 |
60 | 61 | 62 |
63 |
64 | 65 |
66 | 71 |
72 | 0 results 73 |
74 |
75 | 76 |
77 |
ENTER A SEARCH QUERY ABOVE
78 |
79 |
80 | 81 | 82 |
83 |
84 |

INFO

85 |
86 |
87 |
88 | 89 | 90 | 91 | 92 |
93 | 94 |
95 |
96 |
NO ACTIVE DOWNLOADS
97 |
98 |
99 | 100 |
101 |
102 |
NO DOWNLOAD HISTORY
103 |
104 |
105 | 106 |
107 | 108 |
109 |
CLICK REFRESH TO LOAD FILES
110 |
111 |
112 | 113 |
114 | 115 |
116 | 117 | 118 |
119 |
120 |
121 |
122 | 123 | 124 | 125 | --------------------------------------------------------------------------------