# app/main.py from flask import Blueprint, render_template, flash, redirect, url_for, abort, send_from_directory, request, jsonify, current_app from sqlalchemy import desc, func, text, extract, and_ from sqlalchemy.orm import joinedload, aliased, contains_eager from collections import OrderedDict from datetime import datetime from app.models import ImageRecord, Tag, ArchiveRecord, image_tags, ImportTask, ImportBatch, AppSettings from app import db from app.utils.tag_names import normalize_display_name import os import shutil main = Blueprint('main', __name__) def _ensure_fandom_tag(fandom_name): """Find or create a fandom tag by bare name. Returns the Tag object. Normalizes the display name so callers don't have to remember. """ normalized = normalize_display_name(fandom_name.strip()) fandom_tag = Tag.query.filter_by(kind='fandom', name=normalized).first() if not fandom_tag: fandom_tag = Tag(kind='fandom', name=normalized) db.session.add(fandom_tag) db.session.flush() return fandom_tag @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, 'width': img.width, 'height': img.height, '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. """ from app.models import PostMetadata initial_limit = 30 tag_id = request.args.get('tag_id', type=int) active_tag_obj = ( Tag.query.options(joinedload(Tag.fandom)).get(tag_id) if tag_id else None ) # Base query + eager-load tags q = ImageRecord.query.options(joinedload(ImageRecord.tags)) if active_tag_obj: q = q.join(ImageRecord.tags).filter(Tag.id == active_tag_obj.id) # 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() # Fetch post metadata if filtering by a post tag post_metadata = None if active_tag_obj and active_tag_obj.kind == 'post': pm = PostMetadata.query.filter_by(tag_id=active_tag_obj.id).first() if pm: post_metadata = { 'title': pm.title, 'description': pm.description, 'source_url': pm.source_url, 'platform': pm.platform, 'artist': pm.artist, 'post_id': pm.post_id, 'attachment_count': pm.attachment_count, 'published_at': pm.published_at, } # Fetch series info if filtering by a series tag from app.models import SeriesPage series_info = None if active_tag_obj and active_tag_obj.kind == 'series': page_count = SeriesPage.query.filter_by(series_tag_id=active_tag_obj.id).count() series_info = { 'tag_id': active_tag_obj.id, 'name': active_tag_obj.name, 'page_count': page_count, } return render_template( 'gallery.html', grouped_images=grouped_images, active_tag=active_tag_obj.display_name if active_tag_obj else None, active_tag_obj=active_tag_obj, initial_cursor=initial_cursor, has_more=has_more, post_metadata=post_metadata, series_info=series_info ) @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_id = request.args.get('tag_id', type=int) # Build base query q = ImageRecord.query.options(joinedload(ImageRecord.tags)) # Optional tag filter if tag_id: q = q.join(ImageRecord.tags).filter(Tag.id == tag_id) # 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_id = request.args.get('tag_id', type=int) # 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_id: q = q.join(ImageRecord.tags).filter(Tag.id == tag_id) 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_id = request.args.get('tag_id', type=int) 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_id: q = q.join(ImageRecord.tags).filter(Tag.id == tag_id) # 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 ) def _get_tags_with_previews(kind=None, limit=35, offset=0, images_per_tag=3, search=None): """ Helper function to get tags with counts and preview images. Returns (tag_data list, total_count, has_more). Optimized to use 2 queries instead of N+1: 1. Get tags with counts (paginated) 2. Get preview images for those tags using window function """ # Query for total count count_q = db.session.query(func.count(Tag.id)) if kind: count_q = count_q.filter(Tag.kind == kind) if search: count_q = count_q.filter(Tag.name.ilike(f'%{search}%')) total_count = count_q.scalar() # Query 1: Get tags with image counts (paginated) 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) if search: q = q.filter(Tag.name.ilike(f'%{search}%')) tags_with_counts = q.order_by(Tag.name.asc()).offset(offset).limit(limit).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.display_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 if tag_ids: 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() ) 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() ) 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] has_more = (offset + len(tag_data)) < total_count return tag_data, total_count, has_more @main.route('/tags') def tag_list(): """ Generic tag explorer with infinite scroll: /tags -> all tags /tags?kind=user -> only user tags /tags?search=xyz -> tags matching search term etc. Shows a few preview images per tag. Initially loads 35 tags, more loaded via API as user scrolls. """ kind = request.args.get('kind') search = request.args.get('search', '').strip() or None limit = 35 images_per_tag = 3 tag_data, total_count, has_more = _get_tags_with_previews( kind=kind, limit=limit, offset=0, images_per_tag=images_per_tag, search=search ) return render_template('tags_list.html', tag_data=tag_data, images_per_tag=images_per_tag, active_kind=kind, search_query=search or '', total_count=total_count, has_more=has_more, page_size=limit) @main.route('/api/tags/list') def api_tags_list(): """ API endpoint for loading more tags (infinite scroll). Returns JSON with tag cards HTML and pagination info. """ kind = request.args.get('kind') search = request.args.get('search', '').strip() or None offset = request.args.get('offset', 0, type=int) limit = request.args.get('limit', 35, type=int) images_per_tag = 3 tag_data, total_count, has_more = _get_tags_with_previews( kind=kind, limit=limit, offset=offset, images_per_tag=images_per_tag, search=search ) # Render tag cards to HTML cards_html = render_template('_tag_cards.html', tag_data=tag_data) return jsonify( ok=True, html=cards_html, count=len(tag_data), total=total_count, has_more=has_more, offset=offset + len(tag_data) ) @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(): valid_tabs = {'overview', 'import', 'maintenance'} tab = request.args.get('tab', 'overview') if tab not in valid_tabs: tab = 'overview' return render_template('settings.html', active_tab=tab) @main.post('/settings/maintenance/ml-backfill') def trigger_ml_backfill(): from app.tasks.ml import backfill backfill.apply_async(queue='ml') return redirect(url_for('main.settings', tab='maintenance')) @main.post('/settings/maintenance/recompute-centroids') def trigger_recompute_all_centroids(): from app.tasks.ml import recompute_all_centroids recompute_all_centroids.apply_async(queue='ml') return redirect(url_for('main.settings', tab='maintenance')) @main.post('/settings/maintenance/sync-character-fandoms') def trigger_sync_character_fandoms(): from app.tasks.maintenance import sync_character_fandoms sync_character_fandoms.apply_async(queue='maintenance') return redirect(url_for('main.settings', tab='maintenance')) # ---------------------------- # Tag add/remove endpoints # ---------------------------- @main.get("/api/tags/search") def search_tags(): """ Search existing tags for autocomplete. Query params: - q: search query (matches against display_name, case-insensitive) - limit: max results (default 10) - exclude_kind: comma-separated list of tag kinds to exclude - kind: filter to only include tags of this kind """ from sqlalchemy import case 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()] include_kind = request.args.get("kind", "").strip() f = aliased(Tag) display_expr = case( ( and_(Tag.kind == "character", Tag.fandom_id.isnot(None)), func.concat(Tag.name, " (", f.name, ")"), ), else_=Tag.name, ) base_query = ( db.session.query(Tag) .outerjoin(f, f.id == Tag.fandom_id) .options(contains_eager(Tag.fandom, alias=f)) ) if exclude_kinds: base_query = base_query.filter(~Tag.kind.in_(exclude_kinds)) if include_kind: base_query = base_query.filter(Tag.kind == include_kind) if not query: # No query: most-used tags. base_query = base_query.join(image_tags, image_tags.c.tag_id == Tag.id).group_by(Tag.id, f.id) tags = ( base_query .order_by(func.count(image_tags.c.image_id).desc()) .limit(limit) .all() ) else: base_query = base_query.filter(display_expr.ilike(f"%{query}%")) tags = ( base_query .order_by(display_expr) .limit(limit) .all() ) return jsonify(tags=[ { "id": t.id, "name": t.name, "display_name": t.display_name, "kind": t.kind, "fandom_id": t.fandom_id if t.kind == "character" else None, "fandom_name": (t.fandom.name if t.fandom else None) if t.kind == "character" else None, } for t in tags ]) @main.get("/image//tags") def list_tags(image_id): img = ImageRecord.query.get_or_404(image_id) tags_with_fandom = ( db.session.query(Tag) .join(image_tags, image_tags.c.tag_id == Tag.id) .filter(image_tags.c.image_id == image_id) .options(joinedload(Tag.fandom)) .order_by(Tag.kind.asc(), Tag.name.asc()) .all() ) return jsonify(tags=[ { "id": t.id, "name": t.name, "display_name": t.display_name, "kind": t.kind, "fandom_id": t.fandom_id if t.kind == "character" else None, "fandom_name": (t.fandom.name if t.fandom else None) if t.kind == "character" else None, } for t in tags_with_fandom ]) # --------------------------------------------------------------------------- # Tag suggestions (ML-backed) # --------------------------------------------------------------------------- @main.get("/image//suggestions") def get_image_suggestions(image_id): from app.services.tag_suggestions import get_suggestions suggestions = get_suggestions(image_id) return jsonify({'ok': True, 'suggestions': suggestions}) @main.post("/api/suggestions/bulk") def get_bulk_suggestions_api(): """Consensus ML suggestions across a selection of images. Body: {"image_ids": [...], "threshold": 0.8} Returns grouped suggestions where each tag was suggested for or already applied to at least `threshold` fraction of the selected images. """ from app.services.tag_suggestions import get_bulk_suggestions payload = request.get_json(force=True, silent=True) or {} image_ids = payload.get('image_ids') or [] try: image_ids = [int(x) for x in image_ids] except (TypeError, ValueError): return jsonify({'ok': False, 'error': 'invalid_image_ids'}), 400 if not image_ids: return jsonify({'ok': False, 'error': 'no_image_ids'}), 400 try: threshold = float(payload.get('threshold', 0.8)) except (TypeError, ValueError): threshold = 0.8 threshold = max(0.0, min(1.0, threshold)) suggestions = get_bulk_suggestions(image_ids, threshold=threshold) return jsonify({'ok': True, 'suggestions': suggestions, 'evaluated': len(image_ids), 'threshold': threshold}) @main.post("/image//suggestions/accept") def accept_image_suggestion(image_id): """ Accept a single tag suggestion for an image. Post-refactor resolution: - If `tag_id` is provided, attach that exact tag (used by the frontend after the user resolves an ambiguous suggestion). - Else for character category: search by (kind='character', name=bare). 0 matches -> create null-fandom tag. 1 match -> attach. N matches -> return HTTP 409 with candidate list for the frontend to disambiguate. - Else for fandom/general: find-or-create by (kind, name). Records a SuggestionFeedback row on every accepted decision (including the explicit_tag_id disambiguation path) and conditionally enqueues a centroid recompute when enough new accepts have accumulated on an eligible kind. The 409 ambiguous branch does NOT log feedback because no decision has been made yet. """ from app.models import SuggestionFeedback from app.services.tag_suggestions import _WD14_CATEGORY_TO_KIND, _canonicalize_wd14_name data = request.get_json() or {} category = data.get("category") name = (data.get("name") or "").strip() explicit_tag_id = data.get("tag_id") source = data.get("source") or "" confidence = float(data.get("confidence") or 0.0) # Defensive: clients may send raw WD14 names with underscores or off casing. if name: name = _canonicalize_wd14_name(name, category) img = ImageRecord.query.get_or_404(image_id) kind = _WD14_CATEGORY_TO_KIND.get(category) if explicit_tag_id: tag = Tag.query.get(explicit_tag_id) if tag is None: return jsonify(ok=False, error="tag_not_found"), 404 elif kind == "character": candidates = Tag.query.filter_by(kind="character", name=name).all() if len(candidates) == 1: tag = candidates[0] elif len(candidates) == 0: tag = Tag(kind="character", name=name, fandom_id=None) db.session.add(tag) db.session.flush() else: return jsonify( ok=False, error="ambiguous", candidates=[ { "id": c.id, "display_name": c.display_name, "fandom_id": c.fandom_id, "fandom_name": c.fandom.name if c.fandom else None, } for c in candidates ], ), 409 else: effective_kind = kind if kind else "user" tag = Tag.query.filter_by(kind=effective_kind, name=name).first() if tag is None: tag = Tag(kind=effective_kind, name=name) db.session.add(tag) db.session.flush() # Auto-apply the character's fandom if set. fandom_tag = None if tag.kind == "character" and tag.fandom_id: fandom_tag = Tag.query.get(tag.fandom_id) if tag not in img.tags: img.tags.append(tag) if fandom_tag and fandom_tag not in img.tags: img.tags.append(fandom_tag) db.session.add(SuggestionFeedback( image_id=image_id, tag_name=tag.name, suggestion_source=source, confidence=confidence, decision='accepted', )) db.session.commit() # Conditionally schedule centroid recompute for eligible tag kinds. from app.services.tag_suggestions import ELIGIBLE_CENTROID_KINDS if tag.kind in ELIGIBLE_CENTROID_KINDS: from app.models import TagReferenceEmbedding, TagSuggestionConfig from app.tasks.ml import recompute_centroid from app.ml.siglip import MODEL_VERSION as SIGLIP_VER delta_cfg = TagSuggestionConfig.query.filter_by(key='centroid_recompute_delta').first() delta_threshold = int(delta_cfg.value) if delta_cfg else 3 current_count = ( db.session.query(db.func.count(image_tags.c.image_id)) .filter(image_tags.c.tag_id == tag.id) .scalar() ) or 0 ref = ( TagReferenceEmbedding.query .filter_by(tag_name=tag.name, model_version=SIGLIP_VER) .first() ) last_count = ref.reference_count if ref else 0 if current_count - last_count >= delta_threshold: try: recompute_centroid.apply_async(args=[tag.name], queue='ml') except Exception: pass return jsonify( ok=True, tag={ "id": tag.id, "name": tag.name, "display_name": tag.display_name, "kind": tag.kind, }, fandom_tag=( { "id": fandom_tag.id, "name": fandom_tag.name, "display_name": fandom_tag.display_name, "kind": fandom_tag.kind, } if fandom_tag else None ), ) @main.post("/image//suggestions/reject") def reject_image_suggestion(image_id): from app.models import SuggestionFeedback payload = request.get_json(force=True, silent=True) or {} tag_name = (payload.get('tag_name') or '').strip() source = payload.get('source') or '' confidence = float(payload.get('confidence') or 0.0) if not tag_name or not source: return jsonify({'ok': False, 'error': 'missing_fields'}), 400 if ImageRecord.query.get(image_id) is None: return jsonify({'ok': False, 'error': 'image_not_found'}), 404 db.session.add(SuggestionFeedback( image_id=image_id, tag_name=tag_name, suggestion_source=source, confidence=confidence, decision='rejected', )) db.session.commit() return jsonify({'ok': True}) @main.post("/image//tags/add") def add_tag(image_id): """ JSON endpoint to add a tag to an image. Accepts the user-facing 'kind:name' shortcut in the 'name' field (e.g. 'character:Saber'). Bare strings without a recognized prefix are treated as user tags. For character tags, optional 'fandom_id' (preferred) or 'fandom_name' attaches the character to a fandom. Both omitted = null fandom (allowed). """ from app.utils.tag_prefix import parse_kind_prefix from app.utils.tag_names import underscores_to_spaces raw = (request.form.get("name") or "").strip() if not raw: abort(400) kind, name = parse_kind_prefix(raw) if kind is None: # No recognized prefix — treat as a user-typed general tag. kind = "user" name = underscores_to_spaces(name) elif kind in ("character", "fandom"): name = normalize_display_name(name) img = ImageRecord.query.get_or_404(image_id) fandom_tag = None if kind == "character": fandom_id = request.form.get("fandom_id", type=int) fandom_name = (request.form.get("fandom_name") or "").strip() or None if fandom_id: fandom_tag = Tag.query.filter_by(id=fandom_id, kind="fandom").first() if fandom_tag is None: return jsonify(ok=False, error="fandom_not_found"), 400 elif fandom_name: fandom_tag = _ensure_fandom_tag(fandom_name) tag = Tag.query.filter_by( kind="character", name=name, fandom_id=fandom_tag.id if fandom_tag else None, ).first() if tag is None: tag = Tag( kind="character", name=name, fandom_id=fandom_tag.id if fandom_tag else None, ) db.session.add(tag) db.session.flush() else: tag = Tag.query.filter_by(kind=kind, name=name).first() if tag is None: tag = Tag(kind=kind, name=name) db.session.add(tag) db.session.flush() if tag not in img.tags: img.tags.append(tag) # Auto-apply fandom tag to the image (preserves today's UX feature). if fandom_tag and fandom_tag not in img.tags: img.tags.append(fandom_tag) db.session.commit() result = { "id": tag.id, "name": tag.name, "display_name": tag.display_name, "kind": tag.kind, } if fandom_tag: result["fandom_tag"] = { "id": fandom_tag.id, "name": fandom_tag.name, "display_name": fandom_tag.display_name, "kind": fandom_tag.kind, } return jsonify(ok=True, tag=result) @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.options(joinedload(Tag.fandom)).get_or_404(tag_id) result = { "id": tag.id, "name": tag.name, "display_name": tag.display_name, "kind": tag.kind, "fandom_id": tag.fandom_id, "fandom_name": tag.fandom.name if tag.fandom else None, } return jsonify(ok=True, tag=result) @main.post("/api/tag//set-fandom") def set_tag_fandom(tag_id): """ Set or clear a character tag's fandom association. Form params: fandom_id: ID of an existing fandom tag (preferred). fandom_name: Name to find/create a fandom tag (used if fandom_id not given). If both omitted, the fandom is cleared. """ tag = Tag.query.get_or_404(tag_id) if tag.kind != "character": return jsonify(ok=False, error="Only character tags can have fandoms"), 400 fandom_id = request.form.get("fandom_id", type=int) fandom_name = (request.form.get("fandom_name") or "").strip() if fandom_id: fandom_tag = Tag.query.filter_by(id=fandom_id, kind="fandom").first() if fandom_tag is None: return jsonify(ok=False, error="Invalid fandom tag"), 400 tag.fandom_id = fandom_tag.id elif fandom_name: fandom_tag = _ensure_fandom_tag(fandom_name) tag.fandom_id = fandom_tag.id else: tag.fandom_id = None fandom_tag = None db.session.commit() # Auto-apply the fandom tag to images already tagged with this character. if fandom_tag is not None: try: db.session.execute( text(""" INSERT INTO image_tags (image_id, tag_id) SELECT it.image_id, :fandom_id FROM image_tags it WHERE it.tag_id = :char_id ON CONFLICT DO NOTHING """), {"char_id": tag.id, "fandom_id": fandom_tag.id}, ) db.session.commit() except Exception as e: db.session.rollback() current_app.logger.warning( "set_tag_fandom auto-apply failed for tag %s -> fandom %s: %s", tag.id, fandom_tag.id, e, ) return jsonify(ok=True, tag={ "id": tag.id, "name": tag.name, "display_name": tag.display_name, "kind": tag.kind, "fandom_id": tag.fandom_id, "fandom_name": fandom_tag.name if fandom_tag else None, }) @main.get("/api/post-metadata/") def get_post_metadata(tag_id): """ Get post metadata for a post tag. Returns title, description, source URL, etc. for display in the gallery. """ from app.models import PostMetadata tag = Tag.query.get_or_404(tag_id) # Only post tags have metadata if tag.kind != 'post': return jsonify(ok=False, error="Not a post tag"), 400 pm = PostMetadata.query.filter_by(tag_id=tag_id).first() if not pm: return jsonify(ok=True, metadata=None) return jsonify(ok=True, metadata={ "id": pm.id, "tag_id": pm.tag_id, "tag_name": tag.name, "platform": pm.platform, "post_id": pm.post_id, "artist": pm.artist, "title": pm.title, "description": pm.description, "source_url": pm.source_url, "attachment_count": pm.attachment_count, "published_at": pm.published_at.isoformat() if pm.published_at else None, }) @main.get("/api/post-metadata/by-tag-name/") def get_post_metadata_by_name(tag_name): """ Get post metadata by tag name (e.g., post:patreon:artist:12345). Useful when filtering gallery by tag name. """ from app.models import PostMetadata tag = Tag.query.filter_by(name=tag_name).first() if not tag: return jsonify(ok=True, metadata=None) if tag.kind != 'post': return jsonify(ok=False, error="Not a post tag"), 400 pm = PostMetadata.query.filter_by(tag_id=tag.id).first() if not pm: return jsonify(ok=True, metadata=None) return jsonify(ok=True, metadata={ "id": pm.id, "tag_id": pm.tag_id, "tag_name": tag.name, "platform": pm.platform, "post_id": pm.post_id, "artist": pm.artist, "title": pm.title, "description": pm.description, "source_url": pm.source_url, "attachment_count": pm.attachment_count, "published_at": pm.published_at.isoformat() if pm.published_at else None, }) @main.post("/api/tag//update") def update_tag(tag_id): """ Update a tag's name and/or kind. For character tags: rename = update Tag.name (bare). Fandom association is NOT changed here — use /api/tag//set-fandom for that. If the new (name, kind [, fandom_id]) combination collides with an existing tag, offer a merge (force_merge=true to commit). """ 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 if new_kind in ("character", "fandom"): new_name = normalize_display_name(new_name) # Check for duplicates using the new uniqueness shape if new_kind == "character": existing_q = Tag.query.filter( Tag.kind == "character", Tag.name == new_name, Tag.fandom_id.is_(tag.fandom_id) if tag.fandom_id is None else Tag.fandom_id == tag.fandom_id, Tag.id != tag_id, ) else: existing_q = Tag.query.filter( Tag.kind == new_kind, Tag.name == new_name, Tag.id != tag_id, ) existing = existing_q.first() if existing: if not force_merge: return jsonify( ok=False, error="A tag with this name already exists", can_merge=True, target_tag={ "id": existing.id, "name": existing.name, "display_name": existing.display_name, "kind": existing.kind, }, ), 409 # Merge: move all images from source tag to target tag source_images = tag.images[:] 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) db.session.delete(tag) db.session.commit() return jsonify(ok=True, merged=True, merged_count=merged_count, tag={ "id": existing.id, "name": existing.name, "display_name": existing.display_name, "kind": existing.kind, }) # Plain rename tag.name = new_name tag.kind = new_kind db.session.commit() return jsonify(ok=True, tag={ "id": tag.id, "name": tag.name, "display_name": tag.display_name, "kind": tag.kind, "fandom_id": tag.fandom_id, }) @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 # ---------------------------- # 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. Supports two modes: - Quick scan (default): Fast O(1) duplicate detection using pre-loaded lookups. Only queues truly new files. Skips pHash comparison for speed. - Deep scan: Full reprocessing of all files. Re-extracts metadata, runs pHash comparison, optionally regenerates thumbnails and re-applies sidecars. POST params: source_dir: Source directory (default: /import) dest_dir: Destination directory (default: /images) deep: Set to 'true' for deep scan mode regenerate_thumbnails: For deep scan, regenerate all thumbnails (default: true) reapply_sidecars: For deep scan, re-apply sidecar metadata (default: true) verify_hashes: For deep scan, verify content hashes (default: true) """ try: from app.tasks.scan import scan_directory, deep_scan_directory source_dir = request.form.get('source_dir', '/import') dest_dir = request.form.get('dest_dir', '/images') is_deep = request.form.get('deep', 'false').lower() == 'true' if is_deep: # Deep scan with full reprocessing regenerate_thumbnails = request.form.get('regenerate_thumbnails', 'true').lower() == 'true' reapply_sidecars = request.form.get('reapply_sidecars', 'true').lower() == 'true' verify_hashes = request.form.get('verify_hashes', 'true').lower() == 'true' result = deep_scan_directory.delay( source_dir, dest_dir, regenerate_thumbnails=regenerate_thumbnails, reapply_sidecars=reapply_sidecars, verify_hashes=verify_hashes ) message = 'Deep import scan started' else: # Quick scan with optimized lookups result = scan_directory.delay(source_dir, dest_dir) message = 'Quick import scan started' return jsonify({ 'ok': True, 'task_id': result.id, 'message': message, 'mode': 'deep' if is_deep else 'quick' }) 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/tasks') def list_import_tasks(): """ List import tasks with filtering and pagination. Query params: status: Filter by status (comma-separated, e.g., 'failed,skipped') task_type: Filter by task type ('import_image', 'import_archive') search: Search in source_path or error_message limit: Max results (default 50, max 200) offset: Skip first N results sort: Sort field ('created_at', 'completed_at', default: 'created_at') order: Sort order ('asc', 'desc', default: 'desc') """ try: # Parse parameters status_filter = request.args.get('status', '') task_type = request.args.get('task_type', '') search = request.args.get('search', '') limit = min(int(request.args.get('limit', 50)), 200) offset = int(request.args.get('offset', 0)) sort_field = request.args.get('sort', 'created_at') sort_order = request.args.get('order', 'desc') # Build query query = ImportTask.query # Filter by status if status_filter: statuses = [s.strip() for s in status_filter.split(',') if s.strip()] if statuses: query = query.filter(ImportTask.status.in_(statuses)) # Filter by task type if task_type: query = query.filter_by(task_type=task_type) # Search in source_path or error_message if search: search_pattern = f'%{search}%' query = query.filter( db.or_( ImportTask.source_path.ilike(search_pattern), ImportTask.error_message.ilike(search_pattern) ) ) # Get total count before pagination total = query.count() # Sort sort_col = getattr(ImportTask, sort_field, ImportTask.created_at) if sort_order == 'asc': query = query.order_by(sort_col.asc()) else: query = query.order_by(sort_col.desc()) # Paginate tasks = query.offset(offset).limit(limit).all() return jsonify({ 'ok': True, 'total': total, 'offset': offset, 'limit': limit, 'tasks': [ { 'id': t.id, 'source_path': t.source_path, 'filename': 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, # Full error message 'file_size': t.file_size, 'retry_count': t.retry_count, 'created_at': t.created_at.isoformat() if t.created_at else None, 'completed_at': t.completed_at.isoformat() if t.completed_at else None } for t in 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 import tasks from the database with optional age filtering. POST params: statuses: Comma-separated statuses to clear (default: complete,skipped) older_than_days: Only clear tasks older than N days (default: 0 = all) """ from datetime import timedelta try: # Parse parameters statuses_param = request.form.get('statuses', 'complete,skipped') older_than_days = int(request.form.get('older_than_days', 0)) # Validate statuses allowed_statuses = {'complete', 'skipped', 'failed'} statuses = [s.strip() for s in statuses_param.split(',') if s.strip() in allowed_statuses] if not statuses: statuses = ['complete', 'skipped'] # Build query query = ImportTask.query.filter(ImportTask.status.in_(statuses)) # Apply age filter if specified if older_than_days > 0: cutoff = datetime.utcnow() - timedelta(days=older_than_days) query = query.filter(ImportTask.completed_at < cutoff) deleted = query.delete(synchronize_session=False) db.session.commit() return jsonify({ 'ok': True, 'deleted': deleted, 'statuses': statuses, 'older_than_days': older_than_days }) except Exception as e: db.session.rollback() return jsonify({'ok': False, 'error': str(e)}), 500 @main.route('/api/import/clear-queue', methods=['POST']) def clear_import_queue(): """ Clear pending and queued import tasks from the queue. This stops tasks that haven't started processing yet. """ try: # Find and delete pending/queued tasks deleted = ImportTask.query.filter( ImportTask.status.in_(['pending', 'queued']) ).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/import/task-stats') def import_task_stats(): """ Get detailed task statistics with age breakdown. """ from datetime import timedelta try: now = datetime.utcnow() today = now - timedelta(days=1) week_ago = now - timedelta(days=7) stats = {} for status in ['complete', 'skipped', 'failed']: total = ImportTask.query.filter_by(status=status).count() today_count = ImportTask.query.filter( ImportTask.status == status, ImportTask.completed_at >= today ).count() week_count = ImportTask.query.filter( ImportTask.status == status, ImportTask.completed_at >= week_ago, ImportTask.completed_at < today ).count() older = total - today_count - week_count stats[status] = { 'total': total, 'today': today_count, 'this_week': week_count, 'older': older } return jsonify({'ok': True, 'stats': stats}) except Exception as e: return jsonify({'ok': False, 'error': str(e)}), 500 @main.route('/api/system/stats') def system_stats(): """ Get system-wide statistics for the dashboard. Returns cached stats computed by the update_system_stats Celery task. Stats are updated every 5 minutes by Celery Beat. Query params: refresh: If 'true', triggers an immediate stats update (async) """ import json try: # Check for refresh request if request.args.get('refresh') == 'true': from app.tasks.scan import update_system_stats update_system_stats.delay() # Get cached stats from AppSettings setting = AppSettings.query.filter_by(key='system_stats_cache').first() if setting and setting.value: stats = json.loads(setting.value) return jsonify({ 'ok': True, 'cached': True, **stats }) # No cached stats - return empty with flag to indicate first run return jsonify({ 'ok': True, 'cached': False, 'images': {'total': 0, 'storage_bytes': 0, 'storage_human': '0 B'}, 'tags': {'total': 0, 'by_kind': {}}, 'import_folder': {'pending_files': 0, 'pending_archives': 0}, 'message': 'Stats not yet computed. Will be available shortly.' }) except Exception as e: 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/reapply-artist-tags', methods=['POST']) def reapply_artist_tags_from_paths(): """ Re-apply artist tags to all images based on their file paths. Artist is extracted from the first directory component after /images/ (e.g., /images/artist_name/file.jpg -> artist:artist_name) """ try: images = ImageRecord.query.all() updated = 0 skipped = 0 errors = [] for img in images: if not img.filepath: skipped += 1 continue # Extract artist from path: /images/{artist}/... # Handle both /images/artist/file.jpg and /images/artist/subdir/file.jpg path_parts = img.filepath.split('/') try: # Find 'images' in path and get the next component images_idx = path_parts.index('images') if images_idx + 1 < len(path_parts): artist_name = path_parts[images_idx + 1] else: skipped += 1 continue except ValueError: # 'images' not in path, try alternative format # Maybe it's a Windows path or different structure skipped += 1 continue if not artist_name or artist_name == '': skipped += 1 continue # Create or get the artist tag tag_name = f"artist:{artist_name}" tag = Tag.query.filter_by(name=tag_name).first() if not tag: tag = Tag(name=tag_name, kind='artist') db.session.add(tag) db.session.flush() # Add tag if not already present if tag not in img.tags: img.tags.append(tag) updated += 1 db.session.commit() return jsonify({ 'ok': True, 'updated': updated, 'skipped': skipped, 'total': len(images), 'errors': errors if errors else None }) except Exception as e: db.session.rollback() return jsonify({'ok': False, 'error': str(e)}), 500 @main.route('/api/cleanup-orphaned-tags', methods=['POST']) def cleanup_orphaned_tags(): """ Delete tags that aren't attached to any images. Returns the count and names of deleted tags. """ from sqlalchemy import func try: # Find tags with no associated images # A tag is orphaned if it has no entries in the image_tags junction table orphaned = ( Tag.query .outerjoin(image_tags) .group_by(Tag.id) .having(func.count(image_tags.c.image_id) == 0) .all() ) deleted_tags = [] for tag in orphaned: deleted_tags.append({'name': tag.name, 'kind': tag.kind}) db.session.delete(tag) db.session.commit() return jsonify({ 'ok': True, 'deleted_count': len(deleted_tags), 'deleted_tags': deleted_tags }) except Exception as e: db.session.rollback() 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(): """ Bulk-apply one tag to many images in a single transaction. Accepts the same 'kind:name' shortcut as /image//tags/add. For character tags, accepts optional fandom_id / fandom_name just like the single-image endpoint. """ from app.utils.tag_prefix import parse_kind_prefix from app.utils.tag_names import underscores_to_spaces data = request.get_json() or {} image_ids = data.get("image_ids") or [] raw = (data.get("tag_name") or "").strip() if not image_ids or not raw: return jsonify(ok=False, error="image_ids and tag_name required"), 400 kind, name = parse_kind_prefix(raw) if kind is None: kind = "user" name = underscores_to_spaces(name) elif kind in ("character", "fandom"): name = normalize_display_name(name) fandom_tag = None if kind == "character": fandom_id = data.get("fandom_id") fandom_name = (data.get("fandom_name") or "").strip() or None if fandom_id: fandom_tag = Tag.query.filter_by(id=fandom_id, kind="fandom").first() if fandom_tag is None: return jsonify(ok=False, error="fandom_not_found"), 400 elif fandom_name: fandom_tag = _ensure_fandom_tag(fandom_name) tag = Tag.query.filter_by( kind="character", name=name, fandom_id=fandom_tag.id if fandom_tag else None, ).first() if tag is None: tag = Tag( kind="character", name=name, fandom_id=fandom_tag.id if fandom_tag else None, ) db.session.add(tag) db.session.flush() else: tag = Tag.query.filter_by(kind=kind, name=name).first() if tag is None: tag = Tag(kind=kind, name=name) db.session.add(tag) db.session.flush() added_count = 0 for image_id in image_ids: img = ImageRecord.query.get(image_id) if img is None: continue if tag not in img.tags: img.tags.append(tag) added_count += 1 if fandom_tag and fandom_tag not in img.tags: img.tags.append(fandom_tag) db.session.commit() return jsonify( ok=True, added_count=added_count, total=len(image_ids), tag={ "id": tag.id, "name": tag.name, "display_name": tag.display_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 }) # ============================================= # Series / Reader API # ============================================= @main.route('/read/') def read_series(tag_id): """ Reader view for a series - displays images in page order. Supports jump-to-page via ?page=N parameter. """ from app.models import SeriesPage tag = Tag.query.get_or_404(tag_id) if tag.kind != 'series': return "Not a series tag", 400 # Get all pages in order pages = SeriesPage.query.filter_by(series_tag_id=tag_id).order_by( SeriesPage.page_number ).all() # Get starting page from query param start_page = request.args.get('page', 1, type=int) # Get series name (strip prefix if present) series_name = tag.name return render_template( 'reader.html', tag=tag, series_name=series_name, pages=pages, start_page=start_page, total_pages=len(pages) ) @main.get('/api/series//pages') def get_series_pages(tag_id): """Get all pages in a series with their images.""" from app.models import SeriesPage tag = Tag.query.get_or_404(tag_id) if tag.kind != 'series': return jsonify(ok=False, error="Not a series tag"), 400 pages = SeriesPage.query.filter_by(series_tag_id=tag_id).order_by( SeriesPage.page_number ).all() return jsonify(ok=True, pages=[ { 'id': p.id, 'page_number': p.page_number, 'image_id': p.image_id, 'image': { 'id': p.image.id, 'filename': p.image.filename, 'filepath': p.image.filepath, 'thumb_path': p.image.thumb_path, 'width': p.image.width, 'height': p.image.height, } } for p in pages ], total_pages=len(pages)) @main.post('/api/series//pages/add') def add_series_page(tag_id): """ Add an image to a series with a specific page number. Expects form data: image_id, page_number """ from app.models import SeriesPage tag = Tag.query.get_or_404(tag_id) if tag.kind != 'series': return jsonify(ok=False, error="Not a series tag"), 400 image_id = request.form.get('image_id', type=int) page_number = request.form.get('page_number', type=int) if not image_id or page_number is None: return jsonify(ok=False, error="image_id and page_number required"), 400 # Check if image exists image = ImageRecord.query.get(image_id) if not image: return jsonify(ok=False, error="Image not found"), 404 # Check if image is already in a series existing = SeriesPage.query.filter_by(image_id=image_id).first() if existing: return jsonify(ok=False, error="Image is already in a series", existing_series_id=existing.series_tag_id), 409 # Check if page number is already taken page_exists = SeriesPage.query.filter_by( series_tag_id=tag_id, page_number=page_number ).first() if page_exists: return jsonify(ok=False, error=f"Page {page_number} already exists"), 409 # Create the series page sp = SeriesPage( series_tag_id=tag_id, image_id=image_id, page_number=page_number ) db.session.add(sp) # Also ensure the image has the series tag if tag not in image.tags: image.tags.append(tag) db.session.commit() return jsonify(ok=True, page={ 'id': sp.id, 'page_number': sp.page_number, 'image_id': sp.image_id }) @main.post('/api/series//pages/bulk-add') def bulk_add_series_pages(tag_id): """ Add multiple images to a series with sequential page numbers. Expects JSON: { image_ids: [id1, id2, ...], start_page: N } Images are added in order starting from start_page. """ from app.models import SeriesPage tag = Tag.query.get_or_404(tag_id) if tag.kind != 'series': return jsonify(ok=False, error="Not a series tag"), 400 data = request.get_json() or {} image_ids = data.get('image_ids', []) start_page = data.get('start_page', 1) if not image_ids: return jsonify(ok=False, error="No image IDs provided"), 400 added = [] skipped = [] current_page = start_page for image_id in image_ids: image = ImageRecord.query.get(image_id) if not image: skipped.append({'image_id': image_id, 'reason': 'not found'}) continue # Check if image is already in a series existing = SeriesPage.query.filter_by(image_id=image_id).first() if existing: skipped.append({'image_id': image_id, 'reason': 'already in series'}) continue # Find next available page number while SeriesPage.query.filter_by( series_tag_id=tag_id, page_number=current_page ).first(): current_page += 1 sp = SeriesPage( series_tag_id=tag_id, image_id=image_id, page_number=current_page ) db.session.add(sp) # Ensure image has the series tag if tag not in image.tags: image.tags.append(tag) added.append({'image_id': image_id, 'page_number': current_page}) current_page += 1 db.session.commit() return jsonify(ok=True, added=added, skipped=skipped) @main.post('/api/series/page//update') def update_series_page(page_id): """Update a series page's page number.""" from app.models import SeriesPage sp = SeriesPage.query.get_or_404(page_id) new_page_number = request.form.get('page_number', type=int) if new_page_number is None: return jsonify(ok=False, error="page_number required"), 400 # Check if the new page number is taken existing = SeriesPage.query.filter( SeriesPage.series_tag_id == sp.series_tag_id, SeriesPage.page_number == new_page_number, SeriesPage.id != page_id ).first() if existing: return jsonify(ok=False, error=f"Page {new_page_number} already exists"), 409 sp.page_number = new_page_number db.session.commit() return jsonify(ok=True, page={ 'id': sp.id, 'page_number': sp.page_number, 'image_id': sp.image_id }) @main.post('/api/series/page//delete') def delete_series_page(page_id): """Remove an image from a series (doesn't delete the image).""" from app.models import SeriesPage sp = SeriesPage.query.get_or_404(page_id) db.session.delete(sp) db.session.commit() return jsonify(ok=True) @main.get('/api/image//series-info') def get_image_series_info(image_id): """Check if an image is part of a series and return its page info.""" from app.models import SeriesPage sp = SeriesPage.query.filter_by(image_id=image_id).first() if not sp: # Return any series tags the image has (for auto-select in dropdown) image = ImageRecord.query.get(image_id) series_tags = [] if image: series_tags = [ {'id': t.id, 'name': t.name} for t in image.tags if t.kind == 'series' ] return jsonify(ok=True, in_series=False, series_tags=series_tags) return jsonify(ok=True, in_series=True, series_page={ 'id': sp.id, 'page_number': sp.page_number, 'series_tag_id': sp.series_tag_id, 'series_name': sp.series_tag.name }) @main.post('/api/image//add-to-series') def add_image_to_series(image_id): """Add or update an image in a series with a specific page number. Handles three cases: 1. Image not in any series -> create new SeriesPage 2. Image already in this series -> update page number 3. Image in a different series -> move to new series """ from app.models import SeriesPage, Tag data = request.get_json() or {} series_tag_id = data.get('series_tag_id') page_number = data.get('page_number') if not series_tag_id: return jsonify(ok=False, error="series_tag_id required"), 400 if not page_number: return jsonify(ok=False, error="page_number required"), 400 # Verify the series tag exists and is a series series_tag = Tag.query.get(series_tag_id) if not series_tag: return jsonify(ok=False, error="Series not found"), 404 if series_tag.kind != 'series': return jsonify(ok=False, error="Tag is not a series"), 400 # Check if image is already in this series existing_in_target = SeriesPage.query.filter_by( series_tag_id=series_tag_id, image_id=image_id ).first() # Check if image is in any other series existing_in_other = SeriesPage.query.filter( SeriesPage.image_id == image_id, SeriesPage.series_tag_id != series_tag_id ).first() if existing_in_target: # Case 2: Already in this series - update page number if existing_in_target.page_number == page_number: # No change needed return jsonify(ok=True, series_page={ 'id': existing_in_target.id, 'page_number': existing_in_target.page_number, 'series_tag_id': existing_in_target.series_tag_id, 'series_name': series_tag.name }) # Check if new page number is taken by another image page_taken = SeriesPage.query.filter( SeriesPage.series_tag_id == series_tag_id, SeriesPage.page_number == page_number, SeriesPage.image_id != image_id ).first() if page_taken: return jsonify(ok=False, error=f"Page {page_number} already exists in this series"), 409 existing_in_target.page_number = page_number db.session.commit() return jsonify(ok=True, series_page={ 'id': existing_in_target.id, 'page_number': existing_in_target.page_number, 'series_tag_id': existing_in_target.series_tag_id, 'series_name': series_tag.name }) # Check if page number is taken in target series page_taken = SeriesPage.query.filter_by( series_tag_id=series_tag_id, page_number=page_number ).first() if page_taken: return jsonify(ok=False, error=f"Page {page_number} already exists in this series"), 409 if existing_in_other: # Case 3: In a different series - move to new series db.session.delete(existing_in_other) # Case 1 & 3: Create new series page sp = SeriesPage( series_tag_id=series_tag_id, image_id=image_id, page_number=page_number ) db.session.add(sp) db.session.commit() return jsonify(ok=True, series_page={ 'id': sp.id, 'page_number': sp.page_number, 'series_tag_id': sp.series_tag_id, 'series_name': series_tag.name })