# 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 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 re import shutil main = Blueprint('main', __name__) def _parse_character_fandom(name_after_prefix): """Parse 'Name (Fandom)' -> ('Name', 'Fandom') or ('Name', None).""" m = re.match(r'^(.+?)\s*\((.+)\)$', name_after_prefix) if m: return m.group(1).strip(), m.group(2).strip() return name_after_prefix, None def _ensure_fandom_tag(fandom_name): """Find or create a fandom tag by name. Returns the Tag object. Normalizes the display name so callers don't have to remember. """ normalized = normalize_display_name(fandom_name.strip()) full_name = f"fandom:{normalized}" fandom_tag = Tag.query.filter_by(name=full_name).first() if not fandom_tag: fandom_tag = Tag(name=full_name, kind="fandom") 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, '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_name = request.args.get('tag') # Base query + eager-load tags q = ImageRecord.query.options(joinedload(ImageRecord.tags)) # Optional tag filter active_tag_obj = None if tag_name: active_tag_obj = Tag.query.filter_by(name=tag_name).first() if active_tag_obj: 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() # 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.split(':', 1)[1] if ':' in active_tag_obj.name else active_tag_obj.name, 'page_count': page_count, } return render_template( 'gallery.html', grouped_images=grouped_images, active_tag=tag_name, 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_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 ) 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.name.split(":", 1)[1] if (":" in t.name) else t.name tag_map[t.id] = { "id": t.id, "name": label, "tag": t.name, "images": [], "kind": t.kind, "count": count } # Query 2: Get preview images for all tags at once using a subquery with row_number 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 tag name, case-insensitive) - limit: max results (default 10) - exclude_kind: comma-separated list of tag kinds to exclude (e.g., 'archive') - kind: filter to only include tags of this kind (e.g., 'series') """ 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() 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)) if include_kind: base_query = base_query.filter(Tag.kind == include_kind) 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)) if include_kind: base_query = base_query.filter(Tag.kind == include_kind) tags = ( base_query .order_by(Tag.name) .limit(limit) .all() ) return jsonify(tags=[ { "id": t.id, "name": t.name, "kind": t.kind, "fandom_id": t.fandom_id if t.kind == "character" else None, "fandom_name": (t.fandom.name.split(":", 1)[1] 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) return jsonify(ok=True, tags=[ { "name": t.name, "kind": t.kind, "fandom_id": t.fandom_id if t.kind == "character" else None, "fandom_name": (t.fandom.name.split(":", 1)[1] if t.fandom else None) if t.kind == "character" else None } for t in img.tags ]) # --------------------------------------------------------------------------- # 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("/image//suggestions/accept") def accept_image_suggestion(image_id): from app.models import SuggestionFeedback from app.services.tag_suggestions import _WD14_CATEGORY_TO_KIND, _canonicalize_wd14_name payload = request.get_json(force=True, silent=True) or {} tag_name = (payload.get('tag_name') or '').strip() source = payload.get('source') or '' category = payload.get('category') or '' confidence = float(payload.get('confidence') or 0.0) if not tag_name or not source or not category: return jsonify({'ok': False, 'error': 'missing_fields'}), 400 image = ImageRecord.query.get(image_id) if image is None: return jsonify({'ok': False, 'error': 'image_not_found'}), 404 # Canonicalize before lookup so an already-attached "big hair" matches a # freshly-POSTed "big_hair" (defensive — the client should already send canonical). tag_name = _canonicalize_wd14_name(tag_name, category) tag = Tag.query.filter_by(name=tag_name).first() if tag is None: kind = _WD14_CATEGORY_TO_KIND.get(category) # None for 'general' tag = Tag(name=tag_name, kind=kind) db.session.add(tag) db.session.flush() if tag not in image.tags: image.tags.append(tag) db.session.add(SuggestionFeedback( image_id=image_id, tag_name=tag_name, suggestion_source=source, confidence=confidence, decision='accepted', )) db.session.commit() # Schedule centroid recompute if this tag's kind is eligible and delta is exceeded. 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: # Don't fail the accept if enqueue fails pass return jsonify({ 'ok': True, 'tag': {'id': tag.id, 'name': tag.name, 'kind': tag.kind}, }) @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): """ Minimal JSON endpoint to add a tag to an image. Accepts either 'artist:NAME' / 'archive:...' or a bare freeform tag ('mytag'). For character tags with a fandom suffix (e.g. 'character:Saber (Fate)'), automatically creates/finds the fandom tag and adds it to the image. """ 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: # No prefix — treat as a user-typed general tag. Strip underscores so # WD14-style names pasted into the add box converge with our convention. from app.utils.tag_names import underscores_to_spaces kind = "user" tag_name = underscores_to_spaces(name) img = ImageRecord.query.get_or_404(image_id) tag = Tag.query.filter_by(name=tag_name).first() fandom_tag = None if not tag: # For new character tags, parse + normalize + link fandom if kind == "character": char_part = tag_name.split(":", 1)[1] char_name, fandom_name = _parse_character_fandom(char_part) norm_char = normalize_display_name(char_name) if fandom_name: fandom_tag = _ensure_fandom_tag(fandom_name) tag_name = f"character:{norm_char} ({fandom_tag.name.split(':', 1)[1]})" else: tag_name = f"character:{norm_char}" elif kind == "fandom": fandom_part = tag_name.split(":", 1)[1] tag_name = f"fandom:{normalize_display_name(fandom_part)}" # Look up again under the normalized name — an existing row may match. tag = Tag.query.filter_by(name=tag_name).first() if not tag: tag = Tag(name=tag_name, kind=kind) if kind == "character" and fandom_name: tag.fandom_id = fandom_tag.id db.session.add(tag) db.session.flush() elif kind == "character" and tag.fandom_id: fandom_tag = Tag.query.get(tag.fandom_id) elif tag.kind == "character" and tag.fandom_id: # Existing character tag with a fandom — grab the fandom tag for auto-apply fandom_tag = Tag.query.get(tag.fandom_id) if tag not in img.tags: img.tags.append(tag) # Auto-apply fandom tag to the image if fandom_tag and fandom_tag not in img.tags: img.tags.append(fandom_tag) db.session.commit() result = {"name": tag.name, "kind": tag.kind} if fandom_tag: result["fandom_tag"] = {"name": fandom_tag.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.get_or_404(tag_id) # Get display name (without prefix) display_name = tag.name.split(":", 1)[1] if ":" in tag.name else tag.name result = { "id": tag.id, "name": tag.name, "display_name": display_name, "kind": tag.kind, "fandom_id": tag.fandom_id, "fandom_name": tag.fandom.name.split(":", 1)[1] 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. Updates the tag name to include/remove the (Fandom) suffix. Form params: fandom_id: ID of an existing fandom tag (optional, clears if absent) fandom_name: Name to find/create a fandom tag (used if fandom_id not given) """ 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() # Get the character's base name (strip existing fandom suffix) and normalize it. char_part = tag.name.split(":", 1)[1] if ":" in tag.name else tag.name char_base, _ = _parse_character_fandom(char_part) char_base = normalize_display_name(char_base) if fandom_id: fandom_tag = Tag.query.get(fandom_id) if not fandom_tag or fandom_tag.kind != "fandom": return jsonify(ok=False, error="Invalid fandom tag"), 400 fandom_display = fandom_tag.name.split(":", 1)[1] if ":" in fandom_tag.name else fandom_tag.name elif fandom_name: # _ensure_fandom_tag normalizes internally. fandom_tag = _ensure_fandom_tag(fandom_name) fandom_display = fandom_tag.name.split(":", 1)[1] else: # Clear fandom tag.fandom_id = None tag.name = f"character:{char_base}" db.session.commit() return jsonify(ok=True, tag={ "id": tag.id, "name": tag.name, "kind": tag.kind, "fandom_id": None, "fandom_name": None }) # Check for name conflict before renaming new_name = f"character:{char_base} ({fandom_display})" conflict = Tag.query.filter(Tag.name == new_name, Tag.id != tag_id).first() if conflict: return jsonify(ok=False, error=f"A tag named '{new_name}' already exists"), 409 tag.fandom_id = fandom_tag.id tag.name = new_name db.session.commit() # Retroactive backfill: every image already tagged with this character should # also have the fandom tag attached. Runs in a second transaction so a failed # backfill doesn't roll back the rename. 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 AND NOT EXISTS ( SELECT 1 FROM image_tags it2 WHERE it2.image_id = it.image_id AND it2.tag_id = :fandom_id ) """), {'char_id': tag.id, 'fandom_id': fandom_tag.id}, ) db.session.commit() except Exception as e: db.session.rollback() # Rename already committed; log and continue. Sweep button recovers. current_app.logger.warning( "set_tag_fandom backfill failed for tag %s -> fandom %s: %s", tag.id, fandom_tag.id, e, ) return jsonify(ok=True, tag={ "id": tag.id, "name": tag.name, "kind": tag.kind, "fandom_id": tag.fandom_id, "fandom_name": fandom_display }) @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 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", "fandom", "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 }) # Handle fandom-related updates old_kind = tag.kind old_name = tag.name # No conflict - just update the tag tag.name = full_name tag.kind = new_kind # If editing a character tag, handle fandom from the name suffix if new_kind == "character": char_part = full_name.split(":", 1)[1] char_name, fandom_name = _parse_character_fandom(char_part) if fandom_name: fandom_tag = _ensure_fandom_tag(fandom_name) tag.fandom_id = fandom_tag.id else: tag.fandom_id = None # If renaming a fandom tag, cascade rename to all character tags that reference it if new_kind == "fandom" and old_kind == "fandom" and full_name != old_name: new_fandom_display = full_name.split(":", 1)[1] old_fandom_display = old_name.split(":", 1)[1] if ":" in old_name else old_name # Find all character tags linked to this fandom linked_chars = Tag.query.filter_by(fandom_id=tag.id, kind="character").all() for char_tag in linked_chars: # Replace old fandom suffix with new one char_part = char_tag.name.split(":", 1)[1] if ":" in char_tag.name else char_tag.name char_base, _ = _parse_character_fandom(char_part) char_tag.name = f"character:{char_base} ({new_fandom_display})" db.session.commit() return jsonify(ok=True, tag={ "id": tag.id, "name": tag.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(): """ Add a tag to multiple images at once. Expects JSON body with 'image_ids' array and 'tag_name' string. For character tags with a fandom, automatically adds the fandom tag too. """ data = request.get_json() or {} image_ids = data.get('image_ids', []) tag_name = (data.get('tag_name') or '').strip() if not image_ids: return jsonify({'ok': False, 'error': 'No image IDs provided'}), 400 if not tag_name: return jsonify({'ok': False, 'error': 'No tag name provided'}), 400 # Determine tag kind from name if ':' in tag_name: kind = tag_name.split(':', 1)[0] else: # No prefix — treat as a user-typed general tag. Strip underscores so # WD14-style names pasted into the bulk box converge with our convention. from app.utils.tag_names import underscores_to_spaces kind = 'user' tag_name = underscores_to_spaces(tag_name) # Get or create the tag tag = Tag.query.filter_by(name=tag_name).first() fandom_tag = None if not tag: if kind == 'character': char_part = tag_name.split(':', 1)[1] char_name, fandom_name = _parse_character_fandom(char_part) norm_char = normalize_display_name(char_name) if fandom_name: fandom_tag = _ensure_fandom_tag(fandom_name) tag_name = f"character:{norm_char} ({fandom_tag.name.split(':', 1)[1]})" else: tag_name = f"character:{norm_char}" elif kind == 'fandom': fandom_part = tag_name.split(':', 1)[1] tag_name = f"fandom:{normalize_display_name(fandom_part)}" tag = Tag.query.filter_by(name=tag_name).first() if not tag: tag = Tag(name=tag_name, kind=kind) if kind == 'character' and fandom_name: tag.fandom_id = fandom_tag.id db.session.add(tag) db.session.flush() elif tag.kind == 'character' and tag.fandom_id: fandom_tag = Tag.query.get(tag.fandom_id) elif tag.kind == 'character' and tag.fandom_id: fandom_tag = Tag.query.get(tag.fandom_id) # Get images images = ImageRecord.query.filter( ImageRecord.id.in_(image_ids) ).all() added_count = 0 for img in images: if tag not in img.tags: img.tags.append(tag) added_count += 1 # Auto-apply fandom tag 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, 'kind': tag.kind} if fandom_tag: result['fandom_tag'] = {'id': fandom_tag.id, 'name': fandom_tag.name, 'kind': fandom_tag.kind} return jsonify({ 'ok': True, 'added_count': added_count, 'tag': result }) @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.split(':', 1)[1] if ':' in tag.name else 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 })