# 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 from sqlalchemy.orm import joinedload 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. """ images_per_tag = 3 kind = request.args.get('kind') # 'artist' | 'archive' | 'user' | 'character' | 'series' | 'rating' | None # 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() tag_data = [] for t, count in tags_with_counts: imgs = ( ImageRecord.query .join(ImageRecord.tags) .filter(Tag.id == t.id) .order_by(desc(func.coalesce(ImageRecord.taken_at, ImageRecord.imported_at))) .limit(images_per_tag) .all() ) # Display label: for prefixed kinds use the part after ':', else full name label = t.name.split(":", 1)[1] if (":" in t.name) else t.name tag_data.append({ "id": t.id, "name": label, "tag": t.name, "images": imgs, "kind": t.kind, "count": count }) 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.""" # Load from file first, fall back to env defaults import json settings_path = "/import/settings.json" settings = { "min_width": int(os.environ.get("IMPORT_MIN_WIDTH", "0")), "min_height": int(os.environ.get("IMPORT_MIN_HEIGHT", "0")), "skip_transparent": os.environ.get("IMPORT_SKIP_TRANSPARENT", "false").lower() == "true", "transparency_threshold": float(os.environ.get("IMPORT_TRANSPARENCY_THRESHOLD", "0.9")), "phash_threshold": int(os.environ.get("IMPORT_PHASH_THRESHOLD", "2")), } try: if os.path.exists(settings_path): with open(settings_path, "r") as f: file_settings = json.load(f) settings.update(file_settings) except Exception: pass return jsonify(ok=True, settings=settings) @main.post("/api/import-settings") def save_import_settings(): """ Save import filter settings. Settings are stored in /import/settings.json and loaded by the importer. """ settings = { "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", 2, type=int), } import json settings_path = "/import/settings.json" try: with open(settings_path, "w") as f: json.dump(settings, f, indent=2) 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 ] })