249 lines
7.7 KiB
Python
249 lines
7.7 KiB
Python
# 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():
|
|
return redirect(url_for("main.gallery"))
|
|
# 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/<path:filename>')
|
|
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/<artist_name>')
|
|
# @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/<int:image_id>/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/<int:image_id>/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/<int:image_id>/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)
|