# app/main.py from flask import Blueprint, render_template, flash, redirect, url_for, abort, send_from_directory, request, jsonify from sqlalchemy import desc, func, text from sqlalchemy.orm import joinedload from app.models import ImageRecord, Tag, ArchiveRecord, image_tags from app import db import os import shutil main = Blueprint('main', __name__) @main.route('/') def index(): """ Showcase view: full-bleed masonry collage of random images/videos. """ images = ( ImageRecord.query .options(joinedload(ImageRecord.tags)) .order_by(func.random()) .limit(20) .all() ) return render_template('showcase.html', images=images) @main.route('/api/random-images') def random_images_api(): """ JSON endpoint returning random images for shuffle/infinite scroll. Query params: - count: number of images to return (default 12) - exclude: comma-separated list of image IDs to exclude (for infinite scroll) """ count = request.args.get('count', 12, type=int) exclude_str = request.args.get('exclude', '') # Parse exclusion list exclude_ids = [] if exclude_str: try: exclude_ids = [int(x) for x in exclude_str.split(',') if x.strip()] except ValueError: pass # Build query q = ImageRecord.query.options(joinedload(ImageRecord.tags)) if exclude_ids: q = q.filter(~ImageRecord.id.in_(exclude_ids)) images = q.order_by(func.random()).limit(count).all() result = [] for img in images: is_video = img.filename.lower().endswith(('.mp4', '.mov')) thumb_path = (img.thumb_path or img.filepath).replace('/images/', '') full_path = img.filepath.replace('/images/', '') result.append({ 'id': img.id, 'filename': img.filename, 'thumb_url': url_for('main.serve_image', filename=thumb_path), 'full_url': url_for('main.serve_image', filename=full_path), 'is_video': is_video, 'date': (img.taken_at or img.imported_at).strftime('%Y-%m-%d') if (img.taken_at or img.imported_at) else '', 'tags': [{'name': t.name, 'kind': t.kind} for t in img.tags] }) return jsonify(images=result) @main.route('/gallery') def gallery(): """ Gallery with pagination and optional tag filter (?tag=artist:NAME or any tag name). Keeps your pagination shape so modal-pagination.js continues to work. """ page = request.args.get('page', 1, type=int) per_page = request.args.get('per_page', 35, type=int) tag_name = request.args.get('tag') # Base query + eager-load tags (cheap and future-proofs tag chips) q = ImageRecord.query.options(joinedload(ImageRecord.tags)) # Optional tag filter if tag_name: q = q.join(ImageRecord.tags).filter(Tag.name == tag_name) images = q.order_by(desc(func.coalesce(ImageRecord.taken_at, ImageRecord.imported_at))) \ .paginate(page=page, per_page=per_page) # Preserve next/prev URLs (carry tag= so modal pagination fetches stay filtered) next_url = url_for('main.gallery', page=images.next_num, per_page=per_page, tag=tag_name) if images.has_next else '' prev_url = url_for('main.gallery', page=images.prev_num, per_page=per_page, tag=tag_name) if images.has_prev else '' return render_template( 'gallery.html', images=images, active_tag=tag_name, has_next=images.has_next, next_page_url=next_url, has_prev=images.has_prev, prev_page_url=prev_url ) @main.route('/images/') def serve_image(filename): return send_from_directory('/images', filename) @main.route('/tags') def tag_list(): """ Generic tag explorer: /tags -> all tags /tags?kind=user -> only user tags /tags?kind=artist -> only artist tags /tags?kind=archive -> only archive tags /tags?kind=character -> only character tags /tags?kind=series -> only series tags /tags?kind=rating -> only rating tags Shows a few preview images per tag. """ images_per_tag = 3 kind = request.args.get('kind') # 'artist' | 'archive' | 'user' | 'character' | 'series' | 'rating' | None # Get tags with image counts q = db.session.query( Tag, func.count(image_tags.c.image_id).label('img_count') ).outerjoin(image_tags).group_by(Tag.id) if kind: q = q.filter(Tag.kind == kind) tags_with_counts = q.order_by(Tag.name.asc()).all() tag_data = [] for t, count in tags_with_counts: imgs = ( ImageRecord.query .join(ImageRecord.tags) .filter(Tag.id == t.id) .order_by(desc(func.coalesce(ImageRecord.taken_at, ImageRecord.imported_at))) .limit(images_per_tag) .all() ) # Display label: for prefixed kinds use the part after ':', else full name label = t.name.split(":", 1)[1] if (":" in t.name) else t.name tag_data.append({ "id": t.id, "name": label, "tag": t.name, "images": imgs, "kind": t.kind, "count": count }) return render_template('tags_list.html', tag_data=tag_data, images_per_tag=images_per_tag, active_kind=kind) @main.route('/artists') def artist_list(): # Backward-compatible route that now shows the Tag Explorer filtered to artist tags return redirect(url_for('main.tag_list', kind='artist')) @main.route('/artist/') def artist_gallery(artist_name): # Backward-compatible: go to gallery filtered by artist tag return redirect(url_for('main.gallery', tag=f"artist:{artist_name}")) @main.route('/settings') def settings(): return render_template('settings.html') @main.route('/trigger-thumbnail-generation', methods=['POST']) def trigger_thumbnail_generation(): try: with open('/import/thumbnail.flag', 'w') as f: f.write("trigger") flash("Thumbnail regeneration has been triggered.", "success") except Exception as e: flash(f"Failed to trigger thumbnail regeneration: {e}", "danger") return redirect(url_for('main.settings')) @main.route('/import-images') def trigger_image_import(): with open('/import/trigger.flag', 'w') as f: f.write('start') flash("Image import triggered.", "success") return redirect(url_for('main.settings')) @main.route('/reset-db', methods=['POST']) def reset_db(): """ Safe reset that works with Postgres (TRUNCATE ... CASCADE). Falls back to ordered deletes if TRUNCATE isn't supported (e.g., SQLite). """ try: # Postgres fast path db.session.execute(text(""" TRUNCATE TABLE image_tags, archive_record, image_record, tag RESTART IDENTITY CASCADE """)) db.session.commit() except Exception: # Fallback: manual delete order db.session.execute(image_tags.delete()) ArchiveRecord.query.delete() ImageRecord.query.delete() Tag.query.delete() db.session.commit() # Optional: clear thumbs on disk to regenerate fresh shutil.rmtree("/images/thumbs", ignore_errors=True) os.makedirs("/images/thumbs", exist_ok=True) flash("Database has been reset. All records removed.", "info") return redirect(url_for('main.settings')) # ---------------------------- # Tag add/remove endpoints # ---------------------------- @main.get("/api/tags/search") def search_tags(): """ Search existing tags for autocomplete. Query params: - q: search query (matches tag name, case-insensitive) - limit: max results (default 10) """ query = request.args.get('q', '').strip().lower() limit = request.args.get('limit', 10, type=int) if not query: # Return most-used tags when no query tags = ( Tag.query .join(image_tags) .group_by(Tag.id) .order_by(func.count(image_tags.c.image_id).desc()) .limit(limit) .all() ) else: tags = ( Tag.query .filter(Tag.name.ilike(f'%{query}%')) .order_by(Tag.name) .limit(limit) .all() ) return jsonify(tags=[{"name": t.name, "kind": t.kind} for t in tags]) @main.get("/image//tags") def list_tags(image_id): img = ImageRecord.query.get_or_404(image_id) return jsonify(ok=True, tags=[{"name": t.name, "kind": t.kind} for t in img.tags]) @main.post("/image//tags/add") def add_tag(image_id): """ Minimal JSON endpoint to add a tag to an image. Accepts either 'artist:NAME' / 'archive:...' or a bare freeform tag ('mytag'). """ name = (request.form.get("name") or "").strip() if not name: abort(400) # Normalize: keep explicit kind prefixes, otherwise treat as user tag if ":" in name: kind = name.split(":", 1)[0] tag_name = name else: kind = "user" tag_name = name img = ImageRecord.query.get_or_404(image_id) tag = Tag.query.filter_by(name=tag_name).first() if not tag: tag = Tag(name=tag_name, kind=kind) db.session.add(tag) db.session.flush() if tag not in img.tags: img.tags.append(tag) db.session.commit() return jsonify(ok=True, tag={"name": tag.name, "kind": tag.kind}) @main.post("/image//tags/remove") def remove_tag(image_id): """ Minimal JSON endpoint to remove a tag from an image. """ name = (request.form.get("name") or "").strip() img = ImageRecord.query.get_or_404(image_id) tag = Tag.query.filter_by(name=name).first() if tag and tag in img.tags: img.tags.remove(tag) db.session.commit() return jsonify(ok=True) # ---------------------------- # Tag management endpoints # ---------------------------- @main.get("/api/tag/") def get_tag(tag_id): """Get a single tag's details.""" tag = Tag.query.get_or_404(tag_id) # Get display name (without prefix) display_name = tag.name.split(":", 1)[1] if ":" in tag.name else tag.name return jsonify(ok=True, tag={ "id": tag.id, "name": tag.name, "display_name": display_name, "kind": tag.kind }) @main.post("/api/tag//update") def update_tag(tag_id): """ Update a tag's name and/or kind. For prefixed kinds (artist, archive, character, series, rating), the name will be stored as 'kind:name'. If the new name matches an existing tag, merge them: - Move all images from source tag to target tag - Delete the source tag - Return the target tag info with merged=True """ tag = Tag.query.get_or_404(tag_id) new_name = (request.form.get("name") or "").strip() new_kind = (request.form.get("kind") or "user").strip() force_merge = request.form.get("merge") == "true" if not new_name: return jsonify(ok=False, error="Name is required"), 400 # Build the full tag name based on kind prefixed_kinds = ("artist", "archive", "character", "series", "rating") if new_kind in prefixed_kinds: # Remove any existing prefix from name if present if ":" in new_name: new_name = new_name.split(":", 1)[1] full_name = f"{new_kind}:{new_name}" else: # User tags don't have prefixes full_name = new_name # Check for duplicates (excluding current tag) existing = Tag.query.filter(Tag.name == full_name, Tag.id != tag_id).first() if existing: if not force_merge: # Ask user to confirm merge return jsonify( ok=False, error="A tag with this name already exists", can_merge=True, target_tag={ "id": existing.id, "name": existing.name, "kind": existing.kind } ), 409 # Conflict status # Merge: move all images from source tag to target tag source_images = tag.images[:] # Copy list to avoid mutation during iteration merged_count = 0 for img in source_images: if existing not in img.tags: img.tags.append(existing) merged_count += 1 img.tags.remove(tag) # Delete the source tag db.session.delete(tag) db.session.commit() return jsonify(ok=True, merged=True, merged_count=merged_count, tag={ "id": existing.id, "name": existing.name, "kind": existing.kind }) # No conflict - just update the tag tag.name = full_name tag.kind = new_kind db.session.commit() return jsonify(ok=True, tag={ "id": tag.id, "name": tag.name, "kind": tag.kind }) @main.post("/api/tag//delete") def delete_tag(tag_id): """Delete a tag entirely.""" tag = Tag.query.get_or_404(tag_id) # Remove from all images first (handled by cascade, but explicit is clearer) db.session.delete(tag) db.session.commit() return jsonify(ok=True)