diff --git a/py/routes/recipe_routes.py b/py/routes/recipe_routes.py index e57b4d72..42c8b53d 100644 --- a/py/routes/recipe_routes.py +++ b/py/routes/recipe_routes.py @@ -44,6 +44,10 @@ class RecipeRoutes: app.router.add_get('/api/recipes/top-tags', routes.get_top_tags) app.router.add_get('/api/recipes/base-models', routes.get_base_models) + # Add new sharing endpoints + app.router.add_get('/api/recipe/{recipe_id}/share', routes.share_recipe) + app.router.add_get('/api/recipe/{recipe_id}/share/download', routes.download_shared_recipe) + # Start cache initialization app.on_startup.append(routes._init_cache) @@ -597,4 +601,123 @@ class RecipeRoutes: return web.json_response({ 'success': False, 'error': str(e) - }, status=500) \ No newline at end of file + }, status=500) + + async def share_recipe(self, request: web.Request) -> web.Response: + """Process a recipe image for sharing by adding metadata to EXIF""" + try: + recipe_id = request.match_info['recipe_id'] + + # Get all recipes from cache + cache = await self.recipe_scanner.get_cached_data() + + # Find the specific recipe + recipe = next((r for r in cache.raw_data if str(r.get('id', '')) == recipe_id), None) + + if not recipe: + return web.json_response({"error": "Recipe not found"}, status=404) + + # Get the image path + image_path = recipe.get('file_path') + if not image_path or not os.path.exists(image_path): + return web.json_response({"error": "Recipe image not found"}, status=404) + + # Create a temporary copy of the image to modify + import tempfile + import shutil + + # Create temp file with same extension + ext = os.path.splitext(image_path)[1] + with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as temp_file: + temp_path = temp_file.name + + # Copy the original image to temp file + shutil.copy2(image_path, temp_path) + + # Add recipe metadata to the image + from ..utils.exif_utils import ExifUtils + processed_path = ExifUtils.append_recipe_metadata(temp_path, recipe) + + # Create a URL for the processed image + # Use a timestamp to prevent caching + timestamp = int(time.time()) + filename = os.path.basename(processed_path) + url_path = f"/api/recipe/{recipe_id}/share/download?t={timestamp}" + + # Store the temp path in a dictionary to serve later + if not hasattr(self, '_shared_recipes'): + self._shared_recipes = {} + + self._shared_recipes[recipe_id] = { + 'path': processed_path, + 'timestamp': timestamp, + 'expires': time.time() + 300 # Expire after 5 minutes + } + + # Clean up old entries + self._cleanup_shared_recipes() + + return web.json_response({ + 'success': True, + 'download_url': url_path, + 'filename': f"recipe_{recipe.get('title', '').replace(' ', '_').lower()}{ext}" + }) + except Exception as e: + logger.error(f"Error sharing recipe: {e}", exc_info=True) + return web.json_response({"error": str(e)}, status=500) + + async def download_shared_recipe(self, request: web.Request) -> web.Response: + """Serve a processed recipe image for download""" + try: + recipe_id = request.match_info['recipe_id'] + + # Check if we have this shared recipe + if not hasattr(self, '_shared_recipes') or recipe_id not in self._shared_recipes: + return web.json_response({"error": "Shared recipe not found or expired"}, status=404) + + shared_info = self._shared_recipes[recipe_id] + file_path = shared_info['path'] + + if not os.path.exists(file_path): + return web.json_response({"error": "Shared recipe file not found"}, status=404) + + # Get recipe to determine filename + cache = await self.recipe_scanner.get_cached_data() + recipe = next((r for r in cache.raw_data if str(r.get('id', '')) == recipe_id), None) + + # Set filename for download + filename = f"recipe_{recipe.get('title', '').replace(' ', '_').lower() if recipe else recipe_id}" + ext = os.path.splitext(file_path)[1] + download_filename = f"{filename}{ext}" + + # Serve the file + return web.FileResponse( + file_path, + headers={ + 'Content-Disposition': f'attachment; filename="{download_filename}"' + } + ) + except Exception as e: + logger.error(f"Error downloading shared recipe: {e}", exc_info=True) + return web.json_response({"error": str(e)}, status=500) + + def _cleanup_shared_recipes(self): + """Clean up expired shared recipes""" + if not hasattr(self, '_shared_recipes'): + return + + current_time = time.time() + expired_ids = [rid for rid, info in self._shared_recipes.items() + if current_time > info.get('expires', 0)] + + for rid in expired_ids: + try: + # Delete the temporary file + file_path = self._shared_recipes[rid]['path'] + if os.path.exists(file_path): + os.unlink(file_path) + + # Remove from dictionary + del self._shared_recipes[rid] + except Exception as e: + logger.error(f"Error cleaning up shared recipe {rid}: {e}") \ No newline at end of file diff --git a/py/utils/exif_utils.py b/py/utils/exif_utils.py index 827d5662..60451b00 100644 --- a/py/utils/exif_utils.py +++ b/py/utils/exif_utils.py @@ -42,11 +42,9 @@ class ExifUtils: if 'Exif' not in exif_dict: exif_dict['Exif'] = {} - # Update the UserComment field - if isinstance(user_comment, str): - user_comment_bytes = user_comment.encode('utf-8') - else: - user_comment_bytes = user_comment + # Update the UserComment field - use UNICODE format + unicode_bytes = user_comment.encode('utf-16be') + user_comment_bytes = b'UNICODE\0' + unicode_bytes exif_dict['Exif'][piexif.ExifIFD.UserComment] = user_comment_bytes @@ -122,7 +120,7 @@ class ExifUtils: """Extract recipe metadata section from UserComment if it exists""" try: # Look for recipe metadata section - recipe_match = re.search(r'recipe metadata: (\{.*\})', user_comment, re.IGNORECASE | re.DOTALL) + recipe_match = re.search(r'Recipe metadata: (\{.*\})', user_comment, re.IGNORECASE | re.DOTALL) if not recipe_match: return None @@ -130,4 +128,37 @@ class ExifUtils: return json.loads(recipe_json) except Exception as e: logger.error(f"Error extracting recipe metadata: {e}") - return None \ No newline at end of file + return None + + @staticmethod + def append_recipe_metadata(image_path: str, recipe_data: Dict) -> str: + """Append recipe metadata to image EXIF data and return the path to the modified image""" + try: + # Extract existing user comment + existing_comment = ExifUtils.extract_user_comment(image_path) or "" + + # Prepare recipe metadata to append + recipe_metadata = { + "title": recipe_data.get("title", ""), + "base_model": recipe_data.get("base_model", ""), + "loras": recipe_data.get("loras", []), + "gen_params": recipe_data.get("gen_params", {}), + "tags": recipe_data.get("tags", []) + } + + # Convert to JSON string + recipe_json = json.dumps(recipe_metadata, ensure_ascii=False) + + # Append to existing comment + if existing_comment and not existing_comment.endswith("\n"): + existing_comment += "\n" + + new_comment = existing_comment + "Recipe metadata: " + recipe_json + + # Update the image with new comment + ExifUtils.update_user_comment(image_path, new_comment) + + return image_path + except Exception as e: + logger.error(f"Error appending recipe metadata: {e}") + return image_path # Return original path on error \ No newline at end of file diff --git a/static/js/components/RecipeCard.js b/static/js/components/RecipeCard.js index dbf2c9f0..91d3f745 100644 --- a/static/js/components/RecipeCard.js +++ b/static/js/components/RecipeCard.js @@ -234,29 +234,48 @@ class RecipeCard { shareRecipe() { try { - // Get the image URL - const imageUrl = this.recipe.file_url || - (this.recipe.file_path ? `/loras_static/root1/preview/${this.recipe.file_path.split('/').pop()}` : - '/loras_static/images/no-preview.png'); + // Get recipe ID + const recipeId = this.recipe.id; + if (!recipeId) { + showToast('Cannot share recipe: Missing recipe ID', 'error'); + return; + } - // Create a temporary anchor element - const downloadLink = document.createElement('a'); - downloadLink.href = imageUrl; + // Show loading toast + showToast('Preparing recipe for sharing...', 'info'); - // Set the download attribute with the recipe title as filename - const fileExtension = imageUrl.split('.').pop(); - const safeFileName = this.recipe.title.replace(/[^a-z0-9]/gi, '_').toLowerCase(); - downloadLink.download = `recipe_${safeFileName}.${fileExtension}`; - - // Append to body, click and remove - document.body.appendChild(downloadLink); - downloadLink.click(); - document.body.removeChild(downloadLink); - - showToast('Recipe image download started', 'success'); + // Call the API to process the image with metadata + fetch(`/api/recipe/${recipeId}/share`) + .then(response => { + if (!response.ok) { + throw new Error('Failed to prepare recipe for sharing'); + } + return response.json(); + }) + .then(data => { + if (!data.success) { + throw new Error(data.error || 'Unknown error'); + } + + // Create a temporary anchor element for download + const downloadLink = document.createElement('a'); + downloadLink.href = data.download_url; + downloadLink.download = data.filename; + + // Append to body, click and remove + document.body.appendChild(downloadLink); + downloadLink.click(); + document.body.removeChild(downloadLink); + + showToast('Recipe download started', 'success'); + }) + .catch(error => { + console.error('Error sharing recipe:', error); + showToast('Error sharing recipe: ' + error.message, 'error'); + }); } catch (error) { console.error('Error sharing recipe:', error); - showToast('Error downloading recipe image', 'error'); + showToast('Error preparing recipe for sharing', 'error'); } } }