# app/main.py from flask import Blueprint, render_template, flash, redirect, url_for, abort, send_from_directory, request, jsonify from flask_login import login_required, current_user from sqlalchemy import desc, func, text from sqlalchemy.orm import joinedload from app.utils.image_importer import import_images_task 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(): from random import choice # (kept from your original) image = ImageRecord.query.order_by(func.random()).first() background_url = None if image: path = image.thumb_path or image.filepath filename = path.replace('/images/', '') background_url = url_for('main.serve_image', filename=filename) return render_template('index.html', background_url=background_url) @main.route('/gallery') @login_required 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') @login_required 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 Shows a few preview images per tag. """ images_per_tag = 3 kind = request.args.get('kind') # 'artist' | 'archive' | 'user' | None q = Tag.query if kind: q = q.filter_by(kind=kind) tags = q.order_by(Tag.name.asc()).all() tag_data = [] for t in tags: 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 artist/archive use the part after ':', else full name label = t.name.split(":", 1)[1] if (t.kind in ("artist", "archive") and ":" in t.name) else t.name tag_data.append({"name": label, "tag": t.name, "images": imgs, "kind": t.kind}) return render_template('tags_list.html', tag_data=tag_data, images_per_tag=images_per_tag, active_kind=kind) @main.route('/artists') @login_required 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/') @login_required 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') @login_required def settings(): if not current_user.is_admin: abort(403) return render_template('settings.html') @main.route('/trigger-thumbnail-generation', methods=['POST']) @login_required def trigger_thumbnail_generation(): if not current_user.is_admin: flash("You do not have permission to perform this action.", "danger") return redirect(url_for('main.settings')) 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') @login_required 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']) @login_required def reset_db(): """ Safe reset that works with Postgres (TRUNCATE ... CASCADE). Falls back to ordered deletes if TRUNCATE isn't supported (e.g., SQLite). """ if not current_user.is_admin: abort(403) 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("/image//tags") @login_required def list_tags(image_id): from app.models import ImageRecord 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") @login_required 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") @login_required 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)