Skip to content

Commit ed1739d

Browse files
committed
progressive requestarr
1 parent 78ed88c commit ed1739d

File tree

4 files changed

+266
-7
lines changed

4 files changed

+266
-7
lines changed

frontend/static/js/requestarr.js

Lines changed: 108 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ class RequestarrModule {
119119
if (!resultsContainer) return;
120120

121121
// Show loading
122-
resultsContainer.innerHTML = '<div class="loading">🔍 Searching and checking availability...</div>';
122+
resultsContainer.innerHTML = '<div class="loading">🔍 Searching...</div>';
123123

124124
try {
125125
const params = new URLSearchParams({
@@ -128,14 +128,65 @@ class RequestarrModule {
128128
instance_name: this.selectedInstance.instanceName
129129
});
130130

131-
const response = await fetch(`./api/requestarr/search?${params}`);
132-
const data = await response.json();
131+
// Use streaming endpoint for progressive loading
132+
const response = await fetch(`./api/requestarr/search/stream?${params}`);
133133

134-
if (data.error) {
135-
throw new Error(data.error);
134+
if (!response.ok) {
135+
throw new Error('Search failed');
136136
}
137137

138-
this.displayResults(data.results || []);
138+
const reader = response.body.getReader();
139+
const decoder = new TextDecoder();
140+
let buffer = '';
141+
let hasResults = false;
142+
143+
// Clear loading message once we start getting results
144+
const clearLoadingOnce = () => {
145+
if (!hasResults) {
146+
resultsContainer.innerHTML = '';
147+
hasResults = true;
148+
}
149+
};
150+
151+
while (true) {
152+
const { done, value } = await reader.read();
153+
154+
if (done) break;
155+
156+
buffer += decoder.decode(value, { stream: true });
157+
const lines = buffer.split('\n');
158+
buffer = lines.pop(); // Keep incomplete line in buffer
159+
160+
for (const line of lines) {
161+
if (line.startsWith('data: ')) {
162+
try {
163+
const data = JSON.parse(line.slice(6));
164+
165+
if (data.error) {
166+
throw new Error(data.error);
167+
}
168+
169+
clearLoadingOnce();
170+
171+
if (data._update) {
172+
// This is an availability update for an existing result
173+
this.updateResultAvailability(data);
174+
} else {
175+
// This is a new result
176+
this.addResult(data);
177+
}
178+
179+
} catch (parseError) {
180+
console.error('Error parsing streaming data:', parseError);
181+
}
182+
}
183+
}
184+
}
185+
186+
// If no results were found
187+
if (!hasResults) {
188+
resultsContainer.innerHTML = '<div class="no-results">No results found.</div>';
189+
}
139190

140191
} catch (error) {
141192
console.error('Error searching media:', error);
@@ -159,6 +210,57 @@ class RequestarrModule {
159210
this.setupRequestButtons();
160211
}
161212

213+
addResult(item) {
214+
const resultsContainer = document.getElementById('requestarr-results');
215+
if (!resultsContainer) return;
216+
217+
// Create and append the new result card
218+
const cardHTML = this.createResultCard(item);
219+
const cardElement = document.createElement('div');
220+
cardElement.innerHTML = cardHTML;
221+
const actualCard = cardElement.firstElementChild;
222+
223+
resultsContainer.appendChild(actualCard);
224+
225+
// Add event listeners to the new request button
226+
const requestBtn = actualCard.querySelector('.request-btn:not([disabled])');
227+
if (requestBtn) {
228+
requestBtn.addEventListener('click', (e) => this.handleRequest(e.target));
229+
}
230+
}
231+
232+
updateResultAvailability(updatedItem) {
233+
const cardId = `result-card-${updatedItem.tmdb_id}-${updatedItem.media_type}`;
234+
const card = document.querySelector(`[data-card-id="${cardId}"]`);
235+
236+
if (!card) return;
237+
238+
// Update stored item data
239+
this.itemData[cardId] = updatedItem;
240+
241+
// Update availability status display
242+
const statusElement = card.querySelector('.availability-status');
243+
const requestButton = card.querySelector('.request-btn');
244+
245+
if (statusElement && requestButton) {
246+
const statusInfo = this.getStatusInfo(updatedItem.availability);
247+
248+
// Update status display
249+
statusElement.className = `availability-status ${statusInfo.className}`;
250+
statusElement.innerHTML = `<span class="status-icon">${statusInfo.icon}</span><span class="status-text">${statusInfo.message}</span>`;
251+
252+
// Update button
253+
requestButton.textContent = statusInfo.buttonText;
254+
requestButton.className = `request-btn ${statusInfo.buttonClass}`;
255+
requestButton.disabled = statusInfo.disabled;
256+
257+
// Add event listener if button is now enabled
258+
if (!statusInfo.disabled) {
259+
requestButton.addEventListener('click', (e) => this.handleRequest(e.target));
260+
}
261+
}
262+
}
263+
162264
createResultCard(item) {
163265
const year = item.year ? `(${item.year})` : '';
164266
// Use a simple data URL placeholder instead of missing file

src/primary/apps/requestarr/__init__.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,132 @@ def search_media_with_availability(self, query: str, app_type: str, instance_nam
111111
except Exception as e:
112112
logger.error(f"Error searching TMDB: {e}")
113113
return []
114+
115+
def search_media_with_availability_stream(self, query: str, app_type: str, instance_name: str):
116+
"""Stream search results as they become available"""
117+
api_key = self.get_tmdb_api_key()
118+
119+
# Determine search type based on app
120+
media_type = "movie" if app_type == "radarr" else "tv" if app_type == "sonarr" else "multi"
121+
122+
try:
123+
# Use search to get movies or TV shows
124+
url = f"{self.tmdb_base_url}/search/{media_type}"
125+
params = {
126+
'api_key': api_key,
127+
'query': query,
128+
'include_adult': False
129+
}
130+
131+
response = requests.get(url, params=params, timeout=10)
132+
response.raise_for_status()
133+
134+
data = response.json()
135+
136+
# Get instance configuration for availability checking
137+
app_config = self.db.get_app_config(app_type)
138+
target_instance = None
139+
if app_config and app_config.get('instances'):
140+
for instance in app_config['instances']:
141+
if instance.get('name') == instance_name:
142+
target_instance = instance
143+
break
144+
145+
# Process items and yield results as they become available
146+
processed_items = []
147+
148+
for item in data.get('results', []):
149+
# Skip person results in multi search
150+
if item.get('media_type') == 'person':
151+
continue
152+
153+
# Determine media type
154+
item_type = item.get('media_type')
155+
if not item_type:
156+
# For single-type searches
157+
item_type = 'movie' if media_type == 'movie' else 'tv'
158+
159+
# Skip if media type doesn't match app type
160+
if app_type == "radarr" and item_type != "movie":
161+
continue
162+
if app_type == "sonarr" and item_type != "tv":
163+
continue
164+
165+
# Get title and year
166+
title = item.get('title') or item.get('name', '')
167+
release_date = item.get('release_date') or item.get('first_air_date', '')
168+
year = None
169+
if release_date:
170+
try:
171+
year = int(release_date.split('-')[0])
172+
except (ValueError, IndexError):
173+
pass
174+
175+
# Build poster URL
176+
poster_path = item.get('poster_path')
177+
poster_url = f"{self.tmdb_image_base_url}{poster_path}" if poster_path else None
178+
179+
# Build backdrop URL
180+
backdrop_path = item.get('backdrop_path')
181+
backdrop_url = f"{self.tmdb_image_base_url}{backdrop_path}" if backdrop_path else None
182+
183+
# Create basic result first (without availability check)
184+
basic_result = {
185+
'tmdb_id': item.get('id'),
186+
'media_type': item_type,
187+
'title': title,
188+
'year': year,
189+
'overview': item.get('overview', ''),
190+
'poster_path': poster_url,
191+
'backdrop_path': backdrop_url,
192+
'vote_average': item.get('vote_average', 0),
193+
'popularity': item.get('popularity', 0),
194+
'availability': {
195+
'status': 'checking',
196+
'message': 'Checking availability...',
197+
'in_app': False,
198+
'already_requested': False
199+
}
200+
}
201+
202+
processed_items.append((basic_result, item.get('id'), item_type))
203+
204+
# Sort by popularity before streaming
205+
processed_items.sort(key=lambda x: x[0]['popularity'], reverse=True)
206+
processed_items = processed_items[:20] # Limit to top 20 results
207+
208+
# Yield basic results first
209+
for basic_result, tmdb_id, item_type in processed_items:
210+
yield basic_result
211+
212+
# Now check availability for each item and yield updates
213+
for basic_result, tmdb_id, item_type in processed_items:
214+
try:
215+
availability_status = self._get_availability_status(tmdb_id, item_type, target_instance, app_type)
216+
217+
# Yield updated result with availability
218+
updated_result = basic_result.copy()
219+
updated_result['availability'] = availability_status
220+
updated_result['_update'] = True # Flag to indicate this is an update
221+
222+
yield updated_result
223+
224+
except Exception as e:
225+
logger.error(f"Error checking availability for {tmdb_id}: {e}")
226+
# Yield error status
227+
error_result = basic_result.copy()
228+
error_result['availability'] = {
229+
'status': 'error',
230+
'message': 'Error checking availability',
231+
'in_app': False,
232+
'already_requested': False
233+
}
234+
error_result['_update'] = True
235+
yield error_result
236+
237+
except Exception as e:
238+
logger.error(f"Error in streaming search: {e}")
239+
yield {'error': str(e)}
114240

115241
def _get_availability_status(self, tmdb_id: int, media_type: str, instance: Dict[str, str], app_type: str) -> Dict[str, Any]:
116242
"""Get availability status for media item"""

src/primary/apps/requestarr_routes.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,37 @@ def search_media():
3232
logger.error(f"Error searching media: {e}")
3333
return jsonify({'error': 'Search failed'}), 500
3434

35+
@requestarr_bp.route('/search/stream', methods=['GET'])
36+
def search_media_stream():
37+
"""Stream search results as they become available"""
38+
from flask import Response
39+
import json
40+
41+
try:
42+
query = request.args.get('q', '').strip()
43+
app_type = request.args.get('app_type', '').strip()
44+
instance_name = request.args.get('instance_name', '').strip()
45+
46+
if not query:
47+
return jsonify({'error': 'Query parameter is required'}), 400
48+
49+
if not app_type or not instance_name:
50+
return jsonify({'error': 'App type and instance name are required'}), 400
51+
52+
def generate():
53+
try:
54+
for result in requestarr_api.search_media_with_availability_stream(query, app_type, instance_name):
55+
yield f"data: {json.dumps(result)}\n\n"
56+
except Exception as e:
57+
logger.error(f"Error in streaming search: {e}")
58+
yield f"data: {json.dumps({'error': str(e)})}\n\n"
59+
60+
return Response(generate(), mimetype='text/plain')
61+
62+
except Exception as e:
63+
logger.error(f"Error setting up streaming search: {e}")
64+
return jsonify({'error': 'Search failed'}), 500
65+
3566
@requestarr_bp.route('/instances', methods=['GET'])
3667
def get_enabled_instances():
3768
"""Get enabled Sonarr and Radarr instances"""

version.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
8.2.1
1+
8.2.2

0 commit comments

Comments
 (0)