# app/main.py from flask import Blueprint, render_template, flash, redirect, url_for, abort, send_from_directory, request, jsonify from sqlalchemy import desc, func, text, extract, and_ from sqlalchemy.orm import joinedload, aliased from collections import OrderedDict from datetime import datetime from app.models import ImageRecord, Tag, ArchiveRecord, image_tags, ImportTask, ImportBatch from app import db import os import shutil main = Blueprint('main', __name__) @main.route('/') def index(): """ Showcase view: full-bleed masonry collage of random images/videos. """ images = ( ImageRecord.query .options(joinedload(ImageRecord.tags)) .order_by(func.random()) .limit(20) .all() ) return render_template('showcase.html', images=images) @main.route('/api/random-images') def random_images_api(): """ JSON endpoint returning random images for shuffle/infinite scroll. Query params: - count: number of images to return (default 12) - exclude: comma-separated list of image IDs to exclude (for infinite scroll) """ count = request.args.get('count', 12, type=int) exclude_str = request.args.get('exclude', '') # Parse exclusion list exclude_ids = [] if exclude_str: try: exclude_ids = [int(x) for x in exclude_str.split(',') if x.strip()] except ValueError: pass # Build query q = ImageRecord.query.options(joinedload(ImageRecord.tags)) if exclude_ids: q = q.filter(~ImageRecord.id.in_(exclude_ids)) images = q.order_by(func.random()).limit(count).all() result = [] for img in images: is_video = img.filename.lower().endswith(('.mp4', '.mov')) thumb_path = (img.thumb_path or img.filepath).replace('/images/', '') full_path = img.filepath.replace('/images/', '') result.append({ 'id': img.id, 'filename': img.filename, 'thumb_url': url_for('main.serve_image', filename=thumb_path), 'full_url': url_for('main.serve_image', filename=full_path), 'is_video': is_video, 'date': (img.taken_at or img.imported_at).strftime('%Y-%m-%d') if (img.taken_at or img.imported_at) else '', 'tags': [{'name': t.name, 'kind': t.kind} for t in img.tags] }) return jsonify(images=result) @main.route('/gallery') def gallery(): """ Gallery with infinite scroll and year-month date dividers. Server renders initial batch, then JS takes over for infinite scroll. Optional tag filter via ?tag=artist:NAME or any tag name. """ initial_limit = 30 tag_name = request.args.get('tag') # Base query + eager-load tags q = ImageRecord.query.options(joinedload(ImageRecord.tags)) # Optional tag filter if tag_name: q = q.join(ImageRecord.tags).filter(Tag.name == tag_name) # Fetch initial batch ordered by date descending images = q.order_by( desc(func.coalesce(ImageRecord.taken_at, ImageRecord.imported_at)) ).limit(initial_limit + 1).all() # Check if there are more images has_more = len(images) > initial_limit if has_more: images = images[:initial_limit] # Group by year-month for initial render grouped_images = _group_images_by_month(images) # Compute initial cursor for JS to continue from initial_cursor = None if images and has_more: last_img = images[-1] last_dt = _get_image_date(last_img) if last_dt: initial_cursor = last_dt.isoformat() return render_template( 'gallery.html', grouped_images=grouped_images, active_tag=tag_name, initial_cursor=initial_cursor, has_more=has_more ) @main.route('/images/') def serve_image(filename): return send_from_directory('/images', filename) # ---------------------------- # Gallery Infinite Scroll API # ---------------------------- def _get_image_date(img): """Get the effective date for an image (taken_at or imported_at).""" return img.taken_at or img.imported_at def _format_year_month(dt): """Format a datetime as year-month key and label.""" if not dt: return 'unknown', 'Unknown Date' return dt.strftime('%Y-%m'), dt.strftime('%B %Y') def _group_images_by_month(images): """ Group images by year-month, preserving order. Returns list of (group_info, images) tuples. """ groups = OrderedDict() for img in images: dt = _get_image_date(img) key, label = _format_year_month(dt) if key not in groups: groups[key] = {'key': key, 'label': label, 'images': []} groups[key]['images'].append(img) return [(info['key'], info['label'], info['images']) for info in groups.values()] def _serialize_image(img): """Serialize an ImageRecord for JSON response.""" is_video = img.filename.lower().endswith(('.mp4', '.mov')) thumb_path = (img.thumb_path or img.filepath).replace('/images/', '') full_path = img.filepath.replace('/images/', '') dt = _get_image_date(img) key, label = _format_year_month(dt) return { 'id': img.id, 'filename': img.filename, 'thumb_url': url_for('main.serve_image', filename=thumb_path), 'full_url': url_for('main.serve_image', filename=full_path), 'is_video': is_video, 'date': dt.strftime('%Y-%m-%d') if dt else '', 'year_month': key, 'year_month_label': label, 'tags': [{'name': t.name, 'kind': t.kind} for t in img.tags if t.kind != 'archive'] } @main.route('/api/gallery/scroll') def gallery_scroll(): """ Cursor-based infinite scroll endpoint with date grouping. Query params: - cursor: ISO datetime string for pagination (fetch images older than this) - limit: number of images per batch (default 30) - tag: optional tag filter Returns: - images: list of image objects with year_month grouping info - next_cursor: datetime string for next batch (null if no more) - date_groups: summary of year-month groups in this batch - has_more: boolean indicating if more images exist """ cursor_str = request.args.get('cursor') limit = request.args.get('limit', 30, type=int) tag_name = request.args.get('tag') # Build base query q = ImageRecord.query.options(joinedload(ImageRecord.tags)) # Optional tag filter if tag_name: q = q.join(ImageRecord.tags).filter(Tag.name == tag_name) # Apply cursor filter (fetch images older than cursor) if cursor_str: try: cursor_dt = datetime.fromisoformat(cursor_str.replace('Z', '+00:00')) q = q.filter( func.coalesce(ImageRecord.taken_at, ImageRecord.imported_at) < cursor_dt ) except ValueError: pass # Invalid cursor, ignore # Order by date descending and fetch with limit + 1 to check has_more images = q.order_by( desc(func.coalesce(ImageRecord.taken_at, ImageRecord.imported_at)) ).limit(limit + 1).all() # Check if there are more images has_more = len(images) > limit if has_more: images = images[:limit] # Serialize images serialized = [_serialize_image(img) for img in images] # Build date_groups summary groups_seen = OrderedDict() for s in serialized: key = s['year_month'] if key not in groups_seen: groups_seen[key] = {'key': key, 'label': s['year_month_label'], 'count': 0} groups_seen[key]['count'] += 1 # Compute next cursor next_cursor = None if images and has_more: last_img = images[-1] last_dt = _get_image_date(last_img) if last_dt: next_cursor = last_dt.isoformat() return jsonify( images=serialized, next_cursor=next_cursor, has_more=has_more, date_groups=list(groups_seen.values()) ) @main.route('/api/gallery/timeline') def gallery_timeline(): """ Returns all year-month groups with counts for timeline navigation. Query params: - tag: optional tag filter (same as gallery) Returns list of {key, label, count, year} ordered newest to oldest. """ tag_name = request.args.get('tag') # Use SQL to aggregate by year-month date_expr = func.coalesce(ImageRecord.taken_at, ImageRecord.imported_at) q = db.session.query( extract('year', date_expr).label('year'), extract('month', date_expr).label('month'), func.count(ImageRecord.id).label('count') ) if tag_name: q = q.join(ImageRecord.tags).filter(Tag.name == tag_name) q = q.group_by( extract('year', date_expr), extract('month', date_expr) ).order_by( extract('year', date_expr).desc(), extract('month', date_expr).desc() ) results = q.all() # Format results groups = [] for row in results: if row.year and row.month: year = int(row.year) month = int(row.month) key = f"{year:04d}-{month:02d}" # Create a date object to format the month name dt = datetime(year, month, 1) label = dt.strftime('%B %Y') short_label = dt.strftime('%b') # Short month name for sidebar groups.append({ 'key': key, 'label': label, 'short_label': short_label, 'year': year, 'month': month, 'count': row.count }) return jsonify(groups=groups) @main.route('/api/gallery/jump') def gallery_jump(): """ Fetch images starting from a specific year-month for timeline jump navigation. Query params: - year_month: target year-month (e.g., '2024-03') - limit: number of images (default 30) - tag: optional tag filter Returns same format as /api/gallery/scroll but starting from the target month. """ year_month = request.args.get('year_month', '') limit = request.args.get('limit', 30, type=int) tag_name = request.args.get('tag') if not year_month: return jsonify(error='year_month is required'), 400 try: # Parse year-month to get start of that month target_dt = datetime.strptime(year_month + '-01', '%Y-%m-%d') # Get end of month (start of next month) if target_dt.month == 12: next_month = datetime(target_dt.year + 1, 1, 1) else: next_month = datetime(target_dt.year, target_dt.month + 1, 1) except ValueError: return jsonify(error='Invalid year_month format'), 400 # Build query for images in that month and earlier q = ImageRecord.query.options(joinedload(ImageRecord.tags)) if tag_name: q = q.join(ImageRecord.tags).filter(Tag.name == tag_name) # Get images from start of target month onwards (but ordered descending) date_expr = func.coalesce(ImageRecord.taken_at, ImageRecord.imported_at) q = q.filter(date_expr < next_month) images = q.order_by(desc(date_expr)).limit(limit + 1).all() has_more = len(images) > limit if has_more: images = images[:limit] serialized = [_serialize_image(img) for img in images] # Build date_groups summary groups_seen = OrderedDict() for s in serialized: key = s['year_month'] if key not in groups_seen: groups_seen[key] = {'key': key, 'label': s['year_month_label'], 'count': 0} groups_seen[key]['count'] += 1 next_cursor = None if images and has_more: last_img = images[-1] last_dt = _get_image_date(last_img) if last_dt: next_cursor = last_dt.isoformat() return jsonify( images=serialized, next_cursor=next_cursor, has_more=has_more, date_groups=list(groups_seen.values()), target_year_month=year_month ) @main.route('/tags') def tag_list(): """ Generic tag explorer: /tags -> all tags /tags?kind=user -> only user tags /tags?kind=artist -> only artist tags /tags?kind=archive -> only archive tags /tags?kind=character -> only character tags /tags?kind=series -> only series tags /tags?kind=rating -> only rating tags Shows a few preview images per tag. Optimized to use 2 queries instead of N+1: 1. Get all tags with counts 2. Get preview images for all tags at once using window function """ images_per_tag = 3 kind = request.args.get('kind') # Query 1: Get tags with image counts q = db.session.query( Tag, func.count(image_tags.c.image_id).label('img_count') ).outerjoin(image_tags).group_by(Tag.id) if kind: q = q.filter(Tag.kind == kind) tags_with_counts = q.order_by(Tag.name.asc()).all() # Build tag_id list and initialize tag_data dict tag_ids = [t.id for t, _ in tags_with_counts] tag_map = {} for t, count in tags_with_counts: label = t.name.split(":", 1)[1] if (":" in t.name) else t.name tag_map[t.id] = { "id": t.id, "name": label, "tag": t.name, "images": [], "kind": t.kind, "count": count } # Query 2: Get preview images for all tags at once using a subquery with row_number # This fetches the top N images per tag in a single query if tag_ids: # Subquery to rank images within each tag row_num = func.row_number().over( partition_by=image_tags.c.tag_id, order_by=desc(func.coalesce(ImageRecord.taken_at, ImageRecord.imported_at)) ).label('rn') subq = ( db.session.query( image_tags.c.tag_id, ImageRecord.id.label('image_id'), row_num ) .join(ImageRecord, ImageRecord.id == image_tags.c.image_id) .filter(image_tags.c.tag_id.in_(tag_ids)) .subquery() ) # Get only the top N images per tag preview_rows = ( db.session.query(subq.c.tag_id, ImageRecord) .join(ImageRecord, ImageRecord.id == subq.c.image_id) .filter(subq.c.rn <= images_per_tag) .all() ) # Group images by tag_id for tag_id, img in preview_rows: if tag_id in tag_map: tag_map[tag_id]["images"].append(img) # Convert to list maintaining original order tag_data = [tag_map[t.id] for t, _ in tags_with_counts] return render_template('tags_list.html', tag_data=tag_data, images_per_tag=images_per_tag, active_kind=kind) @main.route('/artists') def artist_list(): # Backward-compatible route that now shows the Tag Explorer filtered to artist tags return redirect(url_for('main.tag_list', kind='artist')) @main.route('/artist/') def artist_gallery(artist_name): # Backward-compatible: go to gallery filtered by artist tag return redirect(url_for('main.gallery', tag=f"artist:{artist_name}")) @main.route('/settings') def settings(): return render_template('settings.html') @main.route('/trigger-thumbnail-generation', methods=['POST']) def trigger_thumbnail_generation(): try: with open('/import/thumbnail.flag', 'w') as f: f.write("trigger") flash("Thumbnail regeneration has been triggered.", "success") except Exception as e: flash(f"Failed to trigger thumbnail regeneration: {e}", "danger") return redirect(url_for('main.settings')) @main.route('/import-images') def trigger_image_import(): with open('/import/trigger.flag', 'w') as f: f.write('start') flash("Image import triggered.", "success") return redirect(url_for('main.settings')) @main.route('/reset-db', methods=['POST']) def reset_db(): """ Safe reset that works with Postgres (TRUNCATE ... CASCADE). Falls back to ordered deletes if TRUNCATE isn't supported (e.g., SQLite). """ try: # Postgres fast path db.session.execute(text(""" TRUNCATE TABLE image_tags, archive_record, image_record, tag RESTART IDENTITY CASCADE """)) db.session.commit() except Exception: # Fallback: manual delete order db.session.execute(image_tags.delete()) ArchiveRecord.query.delete() ImageRecord.query.delete() Tag.query.delete() db.session.commit() # Optional: clear thumbs on disk to regenerate fresh shutil.rmtree("/images/thumbs", ignore_errors=True) os.makedirs("/images/thumbs", exist_ok=True) flash("Database has been reset. All records removed.", "info") return redirect(url_for('main.settings')) # ---------------------------- # Tag add/remove endpoints # ---------------------------- @main.get("/api/tags/search") def search_tags(): """ Search existing tags for autocomplete. Query params: - q: search query (matches tag name, case-insensitive) - limit: max results (default 10) - exclude_kind: comma-separated list of tag kinds to exclude (e.g., 'archive') """ query = request.args.get('q', '').strip().lower() limit = request.args.get('limit', 10, type=int) exclude_kind = request.args.get('exclude_kind', '').strip() exclude_kinds = [k.strip() for k in exclude_kind.split(',') if k.strip()] if not query: # Return most-used tags when no query base_query = ( Tag.query .join(image_tags) .group_by(Tag.id) ) if exclude_kinds: base_query = base_query.filter(~Tag.kind.in_(exclude_kinds)) tags = ( base_query .order_by(func.count(image_tags.c.image_id).desc()) .limit(limit) .all() ) else: base_query = Tag.query.filter(Tag.name.ilike(f'%{query}%')) if exclude_kinds: base_query = base_query.filter(~Tag.kind.in_(exclude_kinds)) tags = ( base_query .order_by(Tag.name) .limit(limit) .all() ) return jsonify(tags=[{"id": t.id, "name": t.name, "kind": t.kind} for t in tags]) @main.get("/image//tags") def list_tags(image_id): img = ImageRecord.query.get_or_404(image_id) return jsonify(ok=True, tags=[{"name": t.name, "kind": t.kind} for t in img.tags]) @main.post("/image//tags/add") def add_tag(image_id): """ Minimal JSON endpoint to add a tag to an image. Accepts either 'artist:NAME' / 'archive:...' or a bare freeform tag ('mytag'). """ name = (request.form.get("name") or "").strip() if not name: abort(400) # Normalize: keep explicit kind prefixes, otherwise treat as user tag if ":" in name: kind = name.split(":", 1)[0] tag_name = name else: kind = "user" tag_name = name img = ImageRecord.query.get_or_404(image_id) tag = Tag.query.filter_by(name=tag_name).first() if not tag: tag = Tag(name=tag_name, kind=kind) db.session.add(tag) db.session.flush() if tag not in img.tags: img.tags.append(tag) db.session.commit() return jsonify(ok=True, tag={"name": tag.name, "kind": tag.kind}) @main.post("/image//tags/remove") def remove_tag(image_id): """ Minimal JSON endpoint to remove a tag from an image. """ name = (request.form.get("name") or "").strip() img = ImageRecord.query.get_or_404(image_id) tag = Tag.query.filter_by(name=name).first() if tag and tag in img.tags: img.tags.remove(tag) db.session.commit() return jsonify(ok=True) # ---------------------------- # Tag management endpoints # ---------------------------- @main.get("/api/tag/") def get_tag(tag_id): """Get a single tag's details.""" tag = Tag.query.get_or_404(tag_id) # Get display name (without prefix) display_name = tag.name.split(":", 1)[1] if ":" in tag.name else tag.name return jsonify(ok=True, tag={ "id": tag.id, "name": tag.name, "display_name": display_name, "kind": tag.kind }) @main.post("/api/tag//update") def update_tag(tag_id): """ Update a tag's name and/or kind. For prefixed kinds (artist, archive, character, series, rating), the name will be stored as 'kind:name'. If the new name matches an existing tag, merge them: - Move all images from source tag to target tag - Delete the source tag - Return the target tag info with merged=True """ tag = Tag.query.get_or_404(tag_id) new_name = (request.form.get("name") or "").strip() new_kind = (request.form.get("kind") or "user").strip() force_merge = request.form.get("merge") == "true" if not new_name: return jsonify(ok=False, error="Name is required"), 400 # Build the full tag name based on kind prefixed_kinds = ("artist", "archive", "character", "series", "rating") if new_kind in prefixed_kinds: # Remove any existing prefix from name if present if ":" in new_name: new_name = new_name.split(":", 1)[1] full_name = f"{new_kind}:{new_name}" else: # User tags don't have prefixes full_name = new_name # Check for duplicates (excluding current tag) existing = Tag.query.filter(Tag.name == full_name, Tag.id != tag_id).first() if existing: if not force_merge: # Ask user to confirm merge return jsonify( ok=False, error="A tag with this name already exists", can_merge=True, target_tag={ "id": existing.id, "name": existing.name, "kind": existing.kind } ), 409 # Conflict status # Merge: move all images from source tag to target tag source_images = tag.images[:] # Copy list to avoid mutation during iteration merged_count = 0 for img in source_images: if existing not in img.tags: img.tags.append(existing) merged_count += 1 img.tags.remove(tag) # Delete the source tag db.session.delete(tag) db.session.commit() return jsonify(ok=True, merged=True, merged_count=merged_count, tag={ "id": existing.id, "name": existing.name, "kind": existing.kind }) # No conflict - just update the tag tag.name = full_name tag.kind = new_kind db.session.commit() return jsonify(ok=True, tag={ "id": tag.id, "name": tag.name, "kind": tag.kind }) @main.post("/api/tag//delete") def delete_tag(tag_id): """Delete a tag entirely.""" tag = Tag.query.get_or_404(tag_id) # Remove from all images first (handled by cascade, but explicit is clearer) db.session.delete(tag) db.session.commit() return jsonify(ok=True) # ---------------------------- # Filtered deletion endpoints # ---------------------------- @main.post("/api/delete-by-tag") def delete_images_by_tag(): """ Delete all images that have a specific tag. Also removes the image files and thumbnails from disk. Query params: - tag_id: ID of the tag whose images should be deleted - tag_name: Name of the tag (must match for security validation) - delete_tag: if "true", also delete the tag itself """ tag_id = request.form.get("tag_id", type=int) tag_name = request.form.get("tag_name", "").strip() delete_tag_also = request.form.get("delete_tag") == "true" if not tag_id: return jsonify(ok=False, error="tag_id is required"), 400 if not tag_name: return jsonify(ok=False, error="tag_name confirmation is required"), 400 tag = Tag.query.get_or_404(tag_id) # Security: verify the provided tag name matches the actual tag if tag.name != tag_name: return jsonify(ok=False, error="Tag name confirmation does not match"), 403 images_to_delete = list(tag.images) deleted_count = 0 errors = [] for img in images_to_delete: try: # Remove image file from disk if img.filepath and os.path.exists(img.filepath): os.remove(img.filepath) # Remove thumbnail if exists if img.thumb_path and os.path.exists(img.thumb_path): os.remove(img.thumb_path) # Delete DB record db.session.delete(img) deleted_count += 1 except Exception as e: errors.append(f"Failed to delete {img.filename}: {e}") # Optionally delete the tag itself if delete_tag_also: db.session.delete(tag) db.session.commit() return jsonify( ok=True, deleted_count=deleted_count, tag_deleted=delete_tag_also, errors=errors if errors else None ) @main.get("/api/tag//image-count") def get_tag_image_count(tag_id): """Get the number of images associated with a tag.""" tag = Tag.query.get_or_404(tag_id) count = len(tag.images) return jsonify(ok=True, count=count, tag_name=tag.name) # ---------------------------- # Import settings endpoints # ---------------------------- @main.get("/api/import-settings") def get_import_settings(): """Get current import filter settings from database.""" from app.utils.version_migration import ( get_setting_int, get_setting_float, get_setting_bool ) settings = { "min_width": get_setting_int("import_min_width", int(os.environ.get("IMPORT_MIN_WIDTH", "0"))), "min_height": get_setting_int("import_min_height", int(os.environ.get("IMPORT_MIN_HEIGHT", "0"))), "skip_transparent": get_setting_bool("import_skip_transparent", os.environ.get("IMPORT_SKIP_TRANSPARENT", "false").lower() == "true"), "transparency_threshold": get_setting_float("import_transparency_threshold", float(os.environ.get("IMPORT_TRANSPARENCY_THRESHOLD", "0.9"))), "phash_threshold": get_setting_int("phash_threshold", int(os.environ.get("IMPORT_PHASH_THRESHOLD", "10"))), } return jsonify(ok=True, settings=settings) @main.post("/api/import-settings") def save_import_settings(): """ Save import filter settings to database. Settings are loaded by the Celery workers via load_import_settings(). """ from app.utils.version_migration import set_setting min_width = request.form.get("min_width", 0, type=int) min_height = request.form.get("min_height", 0, type=int) skip_transparent = request.form.get("skip_transparent") == "true" transparency_threshold = request.form.get("transparency_threshold", 0.9, type=float) phash_threshold = request.form.get("phash_threshold", 10, type=int) try: set_setting("import_min_width", str(min_width)) set_setting("import_min_height", str(min_height)) set_setting("import_skip_transparent", str(skip_transparent).lower()) set_setting("import_transparency_threshold", str(transparency_threshold)) set_setting("phash_threshold", str(phash_threshold)) settings = { "min_width": min_width, "min_height": min_height, "skip_transparent": skip_transparent, "transparency_threshold": transparency_threshold, "phash_threshold": phash_threshold, } return jsonify(ok=True, settings=settings) except Exception as e: return jsonify(ok=False, error=str(e)), 500 @main.get("/api/import-stats") def get_import_stats(): """Get import statistics including filtered image counts.""" import json stats_path = "/import/stats.json" try: if os.path.exists(stats_path): with open(stats_path, "r") as f: stats = json.load(f) else: stats = { "total_processed": 0, "total_imported": 0, "filtered_by_dimension": 0, "filtered_by_transparency": 0, "filtered_by_duplicate": 0, } return jsonify(ok=True, stats=stats) except Exception as e: return jsonify(ok=False, error=str(e)), 500 # ---------------------------- # Image filter/cleanup endpoints # ---------------------------- @main.get("/api/filter-scan") def scan_filterable_images(): """ Scan existing images against current filter settings. Returns counts and IDs of images that would be filtered. Query params: - filter_type: 'transparency' or 'dimensions' - threshold: for transparency (0.0-1.0), or min_width/min_height for dimensions - min_width: minimum width (for dimensions filter) - min_height: minimum height (for dimensions filter) - limit: max results to return (default 100) """ from app.utils.image_importer import is_mostly_transparent filter_type = request.args.get("filter_type", "transparency") limit = request.args.get("limit", 100, type=int) results = [] if filter_type == "transparency": threshold = request.args.get("threshold", 0.9, type=float) # Only check images that could have transparency images = ImageRecord.query.filter( ImageRecord.filepath.ilike("%.png") | ImageRecord.filepath.ilike("%.gif") | ImageRecord.filepath.ilike("%.webp") ).limit(limit * 5).all() # Check more to find enough matches for img in images: if len(results) >= limit: break if img.filepath and os.path.exists(img.filepath): try: if is_mostly_transparent(img.filepath, threshold): results.append({ "id": img.id, "filename": img.filename, "filepath": img.filepath, "thumb_url": url_for('main.serve_image', filename=(img.thumb_path or img.filepath).replace('/images/', '')) }) except Exception: pass elif filter_type == "dimensions": min_width = request.args.get("min_width", 0, type=int) min_height = request.args.get("min_height", 0, type=int) if min_width <= 0 and min_height <= 0: return jsonify(ok=True, count=0, images=[], message="No dimension filter set") query = ImageRecord.query if min_width > 0: query = query.filter(ImageRecord.width < min_width) if min_height > 0: query = query.filter(ImageRecord.height < min_height) images = query.limit(limit).all() for img in images: results.append({ "id": img.id, "filename": img.filename, "filepath": img.filepath, "width": img.width, "height": img.height, "thumb_url": url_for('main.serve_image', filename=(img.thumb_path or img.filepath).replace('/images/', '')) }) # Get total count (without limit) for progress indication total_count = len(results) return jsonify(ok=True, count=total_count, images=results, filter_type=filter_type) @main.post("/api/filter-delete") def delete_filtered_images(): """ Delete images by their IDs after filter review. Query params: - image_ids: comma-separated list of image IDs to delete - confirm: must be 'true' to actually delete """ image_ids_str = request.form.get("image_ids", "") confirm = request.form.get("confirm") == "true" if not confirm: return jsonify(ok=False, error="Confirmation required"), 400 if not image_ids_str: return jsonify(ok=False, error="No image IDs provided"), 400 try: image_ids = [int(x) for x in image_ids_str.split(",") if x.strip()] except ValueError: return jsonify(ok=False, error="Invalid image IDs"), 400 deleted_count = 0 errors = [] for img_id in image_ids: img = ImageRecord.query.get(img_id) if not img: continue try: # Remove image file from disk if img.filepath and os.path.exists(img.filepath): os.remove(img.filepath) # Remove thumbnail if exists if img.thumb_path and os.path.exists(img.thumb_path): os.remove(img.thumb_path) # Delete DB record db.session.delete(img) deleted_count += 1 except Exception as e: errors.append(f"Failed to delete {img.filename}: {e}") db.session.commit() return jsonify( ok=True, deleted_count=deleted_count, errors=errors if errors else None ) # ---------------------------- # Import Task Queue API (Celery) # ---------------------------- @main.route('/api/import/trigger', methods=['POST']) def trigger_import_celery(): """ Trigger a directory scan and import via Celery. Replaces the old flag-file mechanism. """ try: from app.tasks.scan import scan_directory source_dir = request.form.get('source_dir', '/import') dest_dir = request.form.get('dest_dir', '/images') result = scan_directory.delay(source_dir, dest_dir) return jsonify({ 'ok': True, 'task_id': result.id, 'message': 'Import scan started' }) except Exception as e: return jsonify({'ok': False, 'error': str(e)}), 500 @main.route('/api/import/status') def import_queue_status(): """ Get current import queue status. Returns counts by task status and recent task info. """ try: # Count tasks by status status_counts = dict( db.session.query( ImportTask.status, func.count(ImportTask.id) ).group_by(ImportTask.status).all() ) # Get active batch info active_batch = ImportBatch.query.filter_by(status='running').order_by( ImportBatch.started_at.desc() ).first() # Recent tasks (last 20) recent_tasks = ImportTask.query.order_by( ImportTask.created_at.desc() ).limit(20).all() return jsonify({ 'ok': True, 'status_counts': { 'pending': status_counts.get('pending', 0), 'queued': status_counts.get('queued', 0), 'processing': status_counts.get('processing', 0), 'complete': status_counts.get('complete', 0), 'failed': status_counts.get('failed', 0), 'skipped': status_counts.get('skipped', 0), }, 'active_batch': { 'id': active_batch.id, 'source_directory': active_batch.source_directory, 'total_files': active_batch.total_files, 'processed_files': active_batch.processed_files, 'imported_count': active_batch.imported_count, 'skipped_count': active_batch.skipped_count, 'failed_count': active_batch.failed_count, 'started_at': active_batch.started_at.isoformat() if active_batch.started_at else None } if active_batch else None, 'recent_tasks': [ { 'id': t.id, 'source_path': os.path.basename(t.source_path) if t.source_path else None, 'task_type': t.task_type, 'status': t.status, 'error_message': t.error_message[:100] if t.error_message else None, 'created_at': t.created_at.isoformat() if t.created_at else None } for t in recent_tasks ] }) except Exception as e: return jsonify({'ok': False, 'error': str(e)}), 500 @main.route('/api/import/task/') def get_import_task(task_id): """ Get detailed status for a specific import task. """ task = ImportTask.query.get_or_404(task_id) # Check Celery task status if still processing celery_status = None if task.celery_task_id: try: from app.celery_app import celery result = celery.AsyncResult(task.celery_task_id) celery_status = result.status except Exception: pass return jsonify({ 'ok': True, 'task': { 'id': task.id, 'source_path': task.source_path, 'file_hash': task.file_hash, 'file_size': task.file_size, 'task_type': task.task_type, 'status': task.status, 'celery_status': celery_status, 'celery_task_id': task.celery_task_id, 'error_message': task.error_message, 'retry_count': task.retry_count, 'context': task.context, 'batch_id': task.batch_id, 'result_image_id': task.result_image_id, 'created_at': task.created_at.isoformat() if task.created_at else None, 'started_at': task.started_at.isoformat() if task.started_at else None, 'completed_at': task.completed_at.isoformat() if task.completed_at else None } }) @main.route('/api/import/retry-failed', methods=['POST']) def retry_failed_import_tasks(): """ Re-queue all failed import tasks. """ try: from app.tasks.import_file import import_media_file, import_archive failed_tasks = ImportTask.query.filter_by(status='failed').all() retried = 0 for task in failed_tasks: task.status = 'pending' task.error_message = None task.retry_count = 0 task.started_at = None task.completed_at = None if task.task_type == 'import_image': result = import_media_file.delay(task.id) elif task.task_type == 'import_archive': result = import_archive.delay(task.id) else: continue task.celery_task_id = result.id task.status = 'queued' retried += 1 db.session.commit() return jsonify({ 'ok': True, 'retried': retried }) except Exception as e: db.session.rollback() return jsonify({'ok': False, 'error': str(e)}), 500 @main.route('/api/import/clear-completed', methods=['POST']) def clear_completed_tasks(): """ Clear completed and skipped import tasks from the database. Keeps failed tasks for review. """ try: deleted = ImportTask.query.filter( ImportTask.status.in_(['complete', 'skipped']) ).delete(synchronize_session=False) db.session.commit() return jsonify({ 'ok': True, 'deleted': deleted }) except Exception as e: db.session.rollback() return jsonify({'ok': False, 'error': str(e)}), 500 @main.route('/api/thumbnails/regenerate', methods=['POST']) def regenerate_thumbnails_celery(): """ Trigger thumbnail regeneration for all images via Celery. """ try: from app.tasks.thumbnail import regenerate_all_thumbnails overwrite = request.form.get('overwrite', 'true').lower() == 'true' result = regenerate_all_thumbnails.delay(overwrite=overwrite) return jsonify({ 'ok': True, 'task_id': result.id, 'message': 'Thumbnail regeneration started' }) except Exception as e: return jsonify({'ok': False, 'error': str(e)}), 500 @main.route('/api/thumbnails/regenerate-missing', methods=['POST']) def regenerate_missing_thumbnails_celery(): """ Generate thumbnails only for images that don't have them. """ try: from app.tasks.thumbnail import regenerate_missing_thumbnails result = regenerate_missing_thumbnails.delay() return jsonify({ 'ok': True, 'task_id': result.id, 'message': 'Missing thumbnail generation started' }) except Exception as e: return jsonify({'ok': False, 'error': str(e)}), 500 @main.route('/api/celery/status') def celery_cluster_status(): """ Get Celery cluster status (workers, queues, etc.). """ try: from app.celery_app import celery inspect = celery.control.inspect() # Get worker stats active = inspect.active() or {} scheduled = inspect.scheduled() or {} reserved = inspect.reserved() or {} stats = inspect.stats() or {} # Count active tasks active_count = sum(len(tasks) for tasks in active.values()) return jsonify({ 'ok': True, 'workers': list(stats.keys()), 'worker_count': len(stats), 'active_tasks': active_count, 'active': active, 'scheduled': scheduled, 'reserved': reserved }) except Exception as e: return jsonify({ 'ok': False, 'error': str(e), 'message': 'Celery may not be running' }), 503 @main.route('/api/import/batches') def list_import_batches(): """ List recent import batches with statistics. """ limit = request.args.get('limit', 10, type=int) batches = ImportBatch.query.order_by( ImportBatch.started_at.desc() ).limit(limit).all() return jsonify({ 'ok': True, 'batches': [ { 'id': b.id, 'source_directory': b.source_directory, 'status': b.status, 'total_files': b.total_files, 'processed_files': b.processed_files, 'imported_count': b.imported_count, 'skipped_count': b.skipped_count, 'failed_count': b.failed_count, 'started_at': b.started_at.isoformat() if b.started_at else None, 'completed_at': b.completed_at.isoformat() if b.completed_at else None } for b in batches ] }) # ============================================= # Duplicate Detection API # ============================================= @main.route('/api/duplicates/scan', methods=['POST']) def scan_duplicates(): """ Scan for duplicate images using perceptual hash comparison. Returns groups of similar images within the specified hamming distance. """ import imagehash threshold = request.form.get('threshold', 10, type=int) limit = request.form.get('limit', 100, type=int) # Get all images with perceptual hashes images = ImageRecord.query.filter( ImageRecord.perceptual_hash.isnot(None) ).all() if not images: return jsonify({'ok': True, 'groups': [], 'message': 'No images with perceptual hashes found'}) # Build hash lookup hash_data = [] for img in images: try: phash = imagehash.hex_to_hash(img.perceptual_hash) hash_data.append({ 'id': img.id, 'hash': phash, 'width': img.width, 'height': img.height, 'filename': img.filename, 'filepath': img.filepath, 'thumb_path': img.thumb_path, 'file_size': img.file_size }) except Exception: continue # Find duplicate groups duplicate_groups = [] processed = set() for i, img1 in enumerate(hash_data): if img1['id'] in processed: continue group = [img1] for j, img2 in enumerate(hash_data[i+1:], start=i+1): if img2['id'] in processed: continue distance = img1['hash'] - img2['hash'] if distance <= threshold: group.append(img2) processed.add(img2['id']) if len(group) > 1: processed.add(img1['id']) # Sort group by resolution (largest first) group.sort(key=lambda x: x['width'] * x['height'], reverse=True) duplicate_groups.append(group) if len(duplicate_groups) >= limit: break # Format response result_groups = [] for group in duplicate_groups: result_groups.append([ { 'id': img['id'], 'filename': img['filename'], 'width': img['width'], 'height': img['height'], 'file_size': img['file_size'], 'thumb_url': url_for('main.serve_image', filename=(img['thumb_path'] or img['filepath']).replace('/images/', '')) } for img in group ]) return jsonify({ 'ok': True, 'groups': result_groups, 'total_groups': len(result_groups), 'threshold': threshold }) @main.route('/api/duplicates/delete', methods=['POST']) def delete_duplicates(): """ Delete specified duplicate images. Expects JSON body with 'image_ids' array. """ data = request.get_json() or {} image_ids = data.get('image_ids', []) if not image_ids: return jsonify({'ok': False, 'error': 'No image IDs provided'}) deleted_count = 0 errors = [] for image_id in image_ids: try: image = ImageRecord.query.get(image_id) if not image: errors.append(f"Image {image_id} not found") continue # Delete physical files if image.filepath and os.path.exists(image.filepath): os.remove(image.filepath) if image.thumb_path and os.path.exists(image.thumb_path): os.remove(image.thumb_path) # Delete database record db.session.delete(image) deleted_count += 1 except Exception as e: errors.append(f"Error deleting {image_id}: {str(e)}") db.session.commit() return jsonify({ 'ok': True, 'deleted_count': deleted_count, 'errors': errors if errors else None }) # ============================================= # Bulk Tag Operations API # ============================================= @main.route('/api/images/common-tags', methods=['POST']) def get_common_tags(): """ Get tags that are common to all specified images. Expects JSON body with 'image_ids' array. Returns tags that appear on EVERY selected image. """ data = request.get_json() or {} image_ids = data.get('image_ids', []) if not image_ids: return jsonify({'ok': True, 'tags': []}) # Find tags that appear on ALL selected images # We use a subquery approach: count occurrences of each tag and filter # for tags where the count equals the number of images num_images = len(image_ids) # Query: get tags where the count of associated images (from our list) equals num_images common_tags = db.session.query(Tag).join(image_tags).filter( image_tags.c.image_id.in_(image_ids) ).group_by(Tag.id).having( func.count(image_tags.c.image_id) == num_images ).order_by(Tag.name).all() return jsonify({ 'ok': True, 'tags': [ {'id': t.id, 'name': t.name, 'kind': t.kind} for t in common_tags ] }) @main.route('/api/images/bulk-add-tag', methods=['POST']) def bulk_add_tag(): """ Add a tag to multiple images at once. Expects JSON body with 'image_ids' array and 'tag_name' string. """ data = request.get_json() or {} image_ids = data.get('image_ids', []) tag_name = (data.get('tag_name') or '').strip() if not image_ids: return jsonify({'ok': False, 'error': 'No image IDs provided'}), 400 if not tag_name: return jsonify({'ok': False, 'error': 'No tag name provided'}), 400 # Determine tag kind from name if ':' in tag_name: kind = tag_name.split(':', 1)[0] else: kind = 'user' # Get or create the tag tag = Tag.query.filter_by(name=tag_name).first() if not tag: tag = Tag(name=tag_name, kind=kind) db.session.add(tag) db.session.flush() # Get images that don't already have this tag images = ImageRecord.query.filter( ImageRecord.id.in_(image_ids) ).all() added_count = 0 for img in images: if tag not in img.tags: img.tags.append(tag) added_count += 1 db.session.commit() return jsonify({ 'ok': True, 'added_count': added_count, 'tag': {'id': tag.id, 'name': tag.name, 'kind': tag.kind} }) @main.route('/api/images/bulk-remove-tag', methods=['POST']) def bulk_remove_tag(): """ Remove a tag from multiple images at once. Expects JSON body with 'image_ids' array and 'tag_name' string. """ data = request.get_json() or {} image_ids = data.get('image_ids', []) tag_name = (data.get('tag_name') or '').strip() if not image_ids: return jsonify({'ok': False, 'error': 'No image IDs provided'}), 400 if not tag_name: return jsonify({'ok': False, 'error': 'No tag name provided'}), 400 # Find the tag tag = Tag.query.filter_by(name=tag_name).first() if not tag: return jsonify({'ok': True, 'removed_count': 0, 'message': 'Tag not found'}) # Get images that have this tag images = ImageRecord.query.filter( ImageRecord.id.in_(image_ids) ).all() removed_count = 0 for img in images: if tag in img.tags: img.tags.remove(tag) removed_count += 1 db.session.commit() return jsonify({ 'ok': True, 'removed_count': removed_count })