This repository has been archived on 2026-05-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
imagerepo/app/main.py
T
2026-01-18 21:57:08 -05:00

717 lines
23 KiB
Python

# 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/<path:filename>')
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/<artist_name>')
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)
- exclude_kind: comma-separated list of tag kinds to exclude (e.g., 'archive')
"""
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()]
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))
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))
tags = (
base_query
.order_by(Tag.name)
.limit(limit)
.all()
)
return jsonify(tags=[{"id": t.id, "name": t.name, "kind": t.kind} for t in tags])
@main.get("/image/<int:image_id>/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/<int:image_id>/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/<int:image_id>/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/<int:tag_id>")
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/<int:tag_id>/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/<int:tag_id>/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/<int:tag_id>/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."""
# Load from file first, fall back to env defaults
import json
settings_path = "/import/settings.json"
settings = {
"min_width": int(os.environ.get("IMPORT_MIN_WIDTH", "0")),
"min_height": int(os.environ.get("IMPORT_MIN_HEIGHT", "0")),
"skip_transparent": os.environ.get("IMPORT_SKIP_TRANSPARENT", "false").lower() == "true",
"transparency_threshold": float(os.environ.get("IMPORT_TRANSPARENCY_THRESHOLD", "0.9")),
"phash_threshold": int(os.environ.get("IMPORT_PHASH_THRESHOLD", "2")),
}
try:
if os.path.exists(settings_path):
with open(settings_path, "r") as f:
file_settings = json.load(f)
settings.update(file_settings)
except Exception:
pass
return jsonify(ok=True, settings=settings)
@main.post("/api/import-settings")
def save_import_settings():
"""
Save import filter settings.
Settings are stored in /import/settings.json and loaded by the importer.
"""
settings = {
"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", 2, type=int),
}
import json
settings_path = "/import/settings.json"
try:
with open(settings_path, "w") as f:
json.dump(settings, f, indent=2)
return jsonify(ok=True, settings=settings)
except Exception as e:
return jsonify(ok=False, error=str(e)), 500
@main.get("/api/import-stats")
def get_import_stats():
"""Get import statistics including filtered image counts."""
import json
stats_path = "/import/stats.json"
try:
if os.path.exists(stats_path):
with open(stats_path, "r") as f:
stats = json.load(f)
else:
stats = {
"total_processed": 0,
"total_imported": 0,
"filtered_by_dimension": 0,
"filtered_by_transparency": 0,
"filtered_by_duplicate": 0,
}
return jsonify(ok=True, stats=stats)
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
)