19e1b09f2c
artist_gallery redirect and reapply_artist_tags_from_paths both still
built f"artist:{name}" strings. Last stragglers cleaned up; the
dead-code sweep now returns zero prefix-construction hits.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2823 lines
91 KiB
Python
2823 lines
91 KiB
Python
# 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, contains_eager
|
|
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 shutil
|
|
|
|
main = Blueprint('main', __name__)
|
|
|
|
|
|
def _ensure_fandom_tag(fandom_name):
|
|
"""Find or create a fandom tag by bare name. Returns the Tag object.
|
|
|
|
Normalizes the display name so callers don't have to remember.
|
|
"""
|
|
normalized = normalize_display_name(fandom_name.strip())
|
|
fandom_tag = Tag.query.filter_by(kind='fandom', name=normalized).first()
|
|
if not fandom_tag:
|
|
fandom_tag = Tag(kind='fandom', name=normalized)
|
|
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,
|
|
'width': img.width,
|
|
'height': img.height,
|
|
'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_id = request.args.get('tag_id', type=int)
|
|
active_tag_obj = (
|
|
Tag.query.options(joinedload(Tag.fandom)).get(tag_id)
|
|
if tag_id else None
|
|
)
|
|
|
|
# Base query + eager-load tags
|
|
q = ImageRecord.query.options(joinedload(ImageRecord.tags))
|
|
|
|
if active_tag_obj:
|
|
q = q.join(ImageRecord.tags).filter(Tag.id == active_tag_obj.id)
|
|
|
|
# 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,
|
|
'page_count': page_count,
|
|
}
|
|
|
|
return render_template(
|
|
'gallery.html',
|
|
grouped_images=grouped_images,
|
|
active_tag=active_tag_obj.display_name if active_tag_obj else None,
|
|
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/<path:filename>')
|
|
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_id = request.args.get('tag_id', type=int)
|
|
|
|
# Build base query
|
|
q = ImageRecord.query.options(joinedload(ImageRecord.tags))
|
|
|
|
# Optional tag filter
|
|
if tag_id:
|
|
q = q.join(ImageRecord.tags).filter(Tag.id == tag_id)
|
|
|
|
# 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_id = request.args.get('tag_id', type=int)
|
|
|
|
# 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_id:
|
|
q = q.join(ImageRecord.tags).filter(Tag.id == tag_id)
|
|
|
|
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_id = request.args.get('tag_id', type=int)
|
|
|
|
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_id:
|
|
q = q.join(ImageRecord.tags).filter(Tag.id == tag_id)
|
|
|
|
# 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, null_fandom_only=False):
|
|
"""
|
|
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}%'))
|
|
if null_fandom_only:
|
|
count_q = count_q.filter(Tag.kind == 'character', Tag.fandom_id.is_(None))
|
|
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}%'))
|
|
if null_fandom_only:
|
|
q = q.filter(Tag.kind == 'character', Tag.fandom_id.is_(None))
|
|
|
|
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.display_name
|
|
tag_map[t.id] = {
|
|
"id": t.id,
|
|
"name": label,
|
|
"tag": t.name,
|
|
"images": [],
|
|
"kind": t.kind,
|
|
"fandom_id": t.fandom_id,
|
|
"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
|
|
/tags?null_fandom=1 -> only fandom-less character tags
|
|
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
|
|
null_fandom_only = request.args.get('null_fandom') == '1'
|
|
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, null_fandom_only=null_fandom_only
|
|
)
|
|
|
|
null_fandom_character_count = (
|
|
db.session.query(func.count(Tag.id))
|
|
.filter(Tag.kind == 'character', Tag.fandom_id.is_(None))
|
|
.scalar() or 0
|
|
)
|
|
|
|
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,
|
|
null_fandom_character_count=null_fandom_character_count,
|
|
show_null_fandom_filter=null_fandom_only)
|
|
|
|
|
|
@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
|
|
null_fandom_only = request.args.get('null_fandom') == '1'
|
|
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, null_fandom_only=null_fandom_only
|
|
)
|
|
|
|
# Render tag cards to HTML
|
|
cards_html = render_template('_tag_cards.html', tag_data=tag_data, show_null_fandom_filter=null_fandom_only)
|
|
|
|
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/<artist_name>')
|
|
def artist_gallery(artist_name):
|
|
# Backward-compatible: resolve artist to its tag id and redirect to the id-keyed gallery URL.
|
|
tag = Tag.query.filter_by(kind='artist', name=artist_name).first()
|
|
if tag is None:
|
|
return redirect(url_for('main.tag_list', kind='artist'))
|
|
return redirect(url_for('main.gallery', tag_id=tag.id))
|
|
|
|
|
|
@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'))
|
|
|
|
|
|
# ----------------------------
|
|
# Tag add/remove endpoints
|
|
# ----------------------------
|
|
|
|
@main.get("/api/tags/search")
|
|
def search_tags():
|
|
"""
|
|
Search existing tags for autocomplete.
|
|
Query params:
|
|
- q: search query (matches against display_name, case-insensitive)
|
|
- limit: max results (default 10)
|
|
- exclude_kind: comma-separated list of tag kinds to exclude
|
|
- kind: filter to only include tags of this kind
|
|
"""
|
|
from sqlalchemy import case
|
|
|
|
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()
|
|
|
|
f = aliased(Tag)
|
|
display_expr = case(
|
|
(
|
|
and_(Tag.kind == "character", Tag.fandom_id.isnot(None)),
|
|
func.concat(Tag.name, " (", f.name, ")"),
|
|
),
|
|
else_=Tag.name,
|
|
)
|
|
|
|
base_query = (
|
|
db.session.query(Tag)
|
|
.outerjoin(f, f.id == Tag.fandom_id)
|
|
.options(contains_eager(Tag.fandom, alias=f))
|
|
)
|
|
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)
|
|
|
|
if not query:
|
|
# No query: most-used tags.
|
|
base_query = base_query.join(image_tags, image_tags.c.tag_id == Tag.id).group_by(Tag.id, f.id)
|
|
tags = (
|
|
base_query
|
|
.order_by(func.count(image_tags.c.image_id).desc())
|
|
.limit(limit)
|
|
.all()
|
|
)
|
|
else:
|
|
base_query = base_query.filter(display_expr.ilike(f"%{query}%"))
|
|
tags = (
|
|
base_query
|
|
.order_by(display_expr)
|
|
.limit(limit)
|
|
.all()
|
|
)
|
|
|
|
return jsonify(tags=[
|
|
{
|
|
"id": t.id,
|
|
"name": t.name,
|
|
"display_name": t.display_name,
|
|
"kind": t.kind,
|
|
"fandom_id": t.fandom_id if t.kind == "character" else None,
|
|
"fandom_name": (t.fandom.name if t.fandom else None) if t.kind == "character" else None,
|
|
}
|
|
for t in tags
|
|
])
|
|
|
|
|
|
@main.get("/image/<int:image_id>/tags")
|
|
def list_tags(image_id):
|
|
img = ImageRecord.query.get_or_404(image_id)
|
|
tags_with_fandom = (
|
|
db.session.query(Tag)
|
|
.join(image_tags, image_tags.c.tag_id == Tag.id)
|
|
.filter(image_tags.c.image_id == image_id)
|
|
.options(joinedload(Tag.fandom))
|
|
.order_by(Tag.kind.asc(), Tag.name.asc())
|
|
.all()
|
|
)
|
|
return jsonify(tags=[
|
|
{
|
|
"id": t.id,
|
|
"name": t.name,
|
|
"display_name": t.display_name,
|
|
"kind": t.kind,
|
|
"fandom_id": t.fandom_id if t.kind == "character" else None,
|
|
"fandom_name": (t.fandom.name if t.fandom else None) if t.kind == "character" else None,
|
|
}
|
|
for t in tags_with_fandom
|
|
])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tag suggestions (ML-backed)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@main.get("/image/<int:image_id>/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("/api/suggestions/bulk")
|
|
def get_bulk_suggestions_api():
|
|
"""Consensus ML suggestions across a selection of images.
|
|
|
|
Body: {"image_ids": [...], "threshold": 0.8}
|
|
Returns grouped suggestions where each tag was suggested for or already
|
|
applied to at least `threshold` fraction of the selected images.
|
|
"""
|
|
from app.services.tag_suggestions import get_bulk_suggestions
|
|
payload = request.get_json(force=True, silent=True) or {}
|
|
image_ids = payload.get('image_ids') or []
|
|
try:
|
|
image_ids = [int(x) for x in image_ids]
|
|
except (TypeError, ValueError):
|
|
return jsonify({'ok': False, 'error': 'invalid_image_ids'}), 400
|
|
if not image_ids:
|
|
return jsonify({'ok': False, 'error': 'no_image_ids'}), 400
|
|
|
|
try:
|
|
threshold = float(payload.get('threshold', 0.8))
|
|
except (TypeError, ValueError):
|
|
threshold = 0.8
|
|
threshold = max(0.0, min(1.0, threshold))
|
|
|
|
suggestions = get_bulk_suggestions(image_ids, threshold=threshold)
|
|
return jsonify({'ok': True, 'suggestions': suggestions, 'evaluated': len(image_ids), 'threshold': threshold})
|
|
|
|
|
|
@main.post("/image/<int:image_id>/suggestions/accept")
|
|
def accept_image_suggestion(image_id):
|
|
"""
|
|
Accept a single tag suggestion for an image.
|
|
|
|
Post-refactor resolution:
|
|
- If `tag_id` is provided, attach that exact tag (used by the
|
|
frontend after the user resolves an ambiguous suggestion).
|
|
- Else for character category: search by (kind='character', name=bare).
|
|
0 matches -> create null-fandom tag. 1 match -> attach. N matches ->
|
|
return HTTP 409 with candidate list for the frontend to disambiguate.
|
|
- Else for fandom/general: find-or-create by (kind, name).
|
|
|
|
Records a SuggestionFeedback row on every accepted decision (including
|
|
the explicit_tag_id disambiguation path) and conditionally enqueues a
|
|
centroid recompute when enough new accepts have accumulated on an
|
|
eligible kind. The 409 ambiguous branch does NOT log feedback because
|
|
no decision has been made yet.
|
|
"""
|
|
from app.models import SuggestionFeedback
|
|
from app.services.tag_suggestions import _WD14_CATEGORY_TO_KIND, _canonicalize_wd14_name
|
|
|
|
data = request.get_json() or {}
|
|
category = data.get("category")
|
|
name = (data.get("name") or "").strip()
|
|
explicit_tag_id = data.get("tag_id")
|
|
source = data.get("source") or ""
|
|
confidence = float(data.get("confidence") or 0.0)
|
|
|
|
# Defensive: clients may send raw WD14 names with underscores or off casing.
|
|
if name:
|
|
name = _canonicalize_wd14_name(name, category)
|
|
|
|
img = ImageRecord.query.get_or_404(image_id)
|
|
kind = _WD14_CATEGORY_TO_KIND.get(category)
|
|
|
|
if explicit_tag_id:
|
|
tag = Tag.query.get(explicit_tag_id)
|
|
if tag is None:
|
|
return jsonify(ok=False, error="tag_not_found"), 404
|
|
elif kind == "character":
|
|
candidates = Tag.query.filter_by(kind="character", name=name).all()
|
|
if len(candidates) == 1:
|
|
tag = candidates[0]
|
|
elif len(candidates) == 0:
|
|
tag = Tag(kind="character", name=name, fandom_id=None)
|
|
db.session.add(tag)
|
|
db.session.flush()
|
|
else:
|
|
return jsonify(
|
|
ok=False,
|
|
error="ambiguous",
|
|
candidates=[
|
|
{
|
|
"id": c.id,
|
|
"display_name": c.display_name,
|
|
"fandom_id": c.fandom_id,
|
|
"fandom_name": c.fandom.name if c.fandom else None,
|
|
}
|
|
for c in candidates
|
|
],
|
|
), 409
|
|
else:
|
|
effective_kind = kind if kind else "user"
|
|
tag = Tag.query.filter_by(kind=effective_kind, name=name).first()
|
|
if tag is None:
|
|
tag = Tag(kind=effective_kind, name=name)
|
|
db.session.add(tag)
|
|
db.session.flush()
|
|
|
|
# Auto-apply the character's fandom if set.
|
|
fandom_tag = None
|
|
if tag.kind == "character" and tag.fandom_id:
|
|
fandom_tag = Tag.query.get(tag.fandom_id)
|
|
|
|
if tag not in img.tags:
|
|
img.tags.append(tag)
|
|
if fandom_tag and fandom_tag not in img.tags:
|
|
img.tags.append(fandom_tag)
|
|
|
|
db.session.add(SuggestionFeedback(
|
|
image_id=image_id,
|
|
tag_name=tag.name,
|
|
suggestion_source=source,
|
|
confidence=confidence,
|
|
decision='accepted',
|
|
))
|
|
db.session.commit()
|
|
|
|
# Conditionally schedule centroid recompute for eligible tag kinds.
|
|
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:
|
|
pass
|
|
|
|
return jsonify(
|
|
ok=True,
|
|
tag={
|
|
"id": tag.id,
|
|
"name": tag.name,
|
|
"display_name": tag.display_name,
|
|
"kind": tag.kind,
|
|
},
|
|
fandom_tag=(
|
|
{
|
|
"id": fandom_tag.id,
|
|
"name": fandom_tag.name,
|
|
"display_name": fandom_tag.display_name,
|
|
"kind": fandom_tag.kind,
|
|
}
|
|
if fandom_tag else None
|
|
),
|
|
)
|
|
|
|
|
|
@main.post("/image/<int:image_id>/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/<int:image_id>/tags/add")
|
|
def add_tag(image_id):
|
|
"""
|
|
JSON endpoint to add a tag to an image.
|
|
|
|
Accepts the user-facing 'kind:name' shortcut in the 'name' field
|
|
(e.g. 'character:Saber'). Bare strings without a recognized prefix are
|
|
treated as user tags.
|
|
|
|
For character tags, optional 'fandom_id' (preferred) or 'fandom_name'
|
|
attaches the character to a fandom. Both omitted = null fandom (allowed).
|
|
"""
|
|
from app.utils.tag_prefix import parse_kind_prefix
|
|
from app.utils.tag_names import underscores_to_spaces
|
|
|
|
raw = (request.form.get("name") or "").strip()
|
|
if not raw:
|
|
abort(400)
|
|
|
|
kind, name = parse_kind_prefix(raw)
|
|
if kind is None:
|
|
# No recognized prefix — treat as a user-typed general tag.
|
|
kind = "user"
|
|
name = underscores_to_spaces(name)
|
|
elif kind in ("character", "fandom"):
|
|
name = normalize_display_name(name)
|
|
|
|
img = ImageRecord.query.get_or_404(image_id)
|
|
|
|
fandom_tag = None
|
|
if kind == "character":
|
|
fandom_id = request.form.get("fandom_id", type=int)
|
|
fandom_name = (request.form.get("fandom_name") or "").strip() or None
|
|
|
|
if fandom_id:
|
|
fandom_tag = Tag.query.filter_by(id=fandom_id, kind="fandom").first()
|
|
if fandom_tag is None:
|
|
return jsonify(ok=False, error="fandom_not_found"), 400
|
|
elif fandom_name:
|
|
fandom_tag = _ensure_fandom_tag(fandom_name)
|
|
|
|
tag = Tag.query.filter_by(
|
|
kind="character",
|
|
name=name,
|
|
fandom_id=fandom_tag.id if fandom_tag else None,
|
|
).first()
|
|
if tag is None:
|
|
tag = Tag(
|
|
kind="character",
|
|
name=name,
|
|
fandom_id=fandom_tag.id if fandom_tag else None,
|
|
)
|
|
db.session.add(tag)
|
|
db.session.flush()
|
|
else:
|
|
tag = Tag.query.filter_by(kind=kind, name=name).first()
|
|
if tag is None:
|
|
tag = Tag(kind=kind, name=name)
|
|
db.session.add(tag)
|
|
db.session.flush()
|
|
|
|
if tag not in img.tags:
|
|
img.tags.append(tag)
|
|
|
|
# Auto-apply fandom tag to the image (preserves today's UX feature).
|
|
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,
|
|
"display_name": tag.display_name,
|
|
"kind": tag.kind,
|
|
}
|
|
if fandom_tag:
|
|
result["fandom_tag"] = {
|
|
"id": fandom_tag.id,
|
|
"name": fandom_tag.name,
|
|
"display_name": fandom_tag.display_name,
|
|
"kind": fandom_tag.kind,
|
|
}
|
|
return jsonify(ok=True, tag=result)
|
|
|
|
|
|
@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.options(joinedload(Tag.fandom)).get_or_404(tag_id)
|
|
result = {
|
|
"id": tag.id,
|
|
"name": tag.name,
|
|
"display_name": tag.display_name,
|
|
"kind": tag.kind,
|
|
"fandom_id": tag.fandom_id,
|
|
"fandom_name": tag.fandom.name if tag.fandom else None,
|
|
}
|
|
return jsonify(ok=True, tag=result)
|
|
|
|
|
|
@main.post("/api/tag/<int:tag_id>/set-fandom")
|
|
def set_tag_fandom(tag_id):
|
|
"""
|
|
Set or clear a character tag's fandom association.
|
|
|
|
Form params:
|
|
fandom_id: ID of an existing fandom tag (preferred).
|
|
fandom_name: Name to find/create a fandom tag (used if fandom_id
|
|
not given). If both omitted, the fandom is cleared.
|
|
"""
|
|
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()
|
|
|
|
if fandom_id:
|
|
fandom_tag = Tag.query.filter_by(id=fandom_id, kind="fandom").first()
|
|
if fandom_tag is None:
|
|
return jsonify(ok=False, error="Invalid fandom tag"), 400
|
|
tag.fandom_id = fandom_tag.id
|
|
elif fandom_name:
|
|
fandom_tag = _ensure_fandom_tag(fandom_name)
|
|
tag.fandom_id = fandom_tag.id
|
|
else:
|
|
tag.fandom_id = None
|
|
fandom_tag = None
|
|
|
|
db.session.commit()
|
|
|
|
# Auto-apply the fandom tag to images already tagged with this character.
|
|
if fandom_tag is not None:
|
|
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
|
|
ON CONFLICT DO NOTHING
|
|
"""),
|
|
{"char_id": tag.id, "fandom_id": fandom_tag.id},
|
|
)
|
|
db.session.commit()
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
current_app.logger.warning(
|
|
"set_tag_fandom auto-apply failed for tag %s -> fandom %s: %s",
|
|
tag.id, fandom_tag.id, e,
|
|
)
|
|
|
|
return jsonify(ok=True, tag={
|
|
"id": tag.id,
|
|
"name": tag.name,
|
|
"display_name": tag.display_name,
|
|
"kind": tag.kind,
|
|
"fandom_id": tag.fandom_id,
|
|
"fandom_name": fandom_tag.name if fandom_tag else None,
|
|
})
|
|
|
|
|
|
@main.get("/api/post-metadata/<int:tag_id>")
|
|
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/<path: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/<int:tag_id>/update")
|
|
def update_tag(tag_id):
|
|
"""
|
|
Update a tag's name and/or kind.
|
|
|
|
For character tags: rename = update Tag.name (bare). Fandom association
|
|
is NOT changed here — use /api/tag/<id>/set-fandom for that.
|
|
|
|
If the new (name, kind [, fandom_id]) combination collides with an
|
|
existing tag, offer a merge (force_merge=true to commit).
|
|
"""
|
|
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
|
|
|
|
if new_kind in ("character", "fandom"):
|
|
new_name = normalize_display_name(new_name)
|
|
|
|
# Check for duplicates using the new uniqueness shape
|
|
if new_kind == "character":
|
|
existing_q = Tag.query.filter(
|
|
Tag.kind == "character",
|
|
Tag.name == new_name,
|
|
Tag.fandom_id.is_(tag.fandom_id) if tag.fandom_id is None
|
|
else Tag.fandom_id == tag.fandom_id,
|
|
Tag.id != tag_id,
|
|
)
|
|
else:
|
|
existing_q = Tag.query.filter(
|
|
Tag.kind == new_kind,
|
|
Tag.name == new_name,
|
|
Tag.id != tag_id,
|
|
)
|
|
existing = existing_q.first()
|
|
|
|
if existing:
|
|
if not force_merge:
|
|
return jsonify(
|
|
ok=False,
|
|
error="A tag with this name already exists",
|
|
can_merge=True,
|
|
target_tag={
|
|
"id": existing.id,
|
|
"name": existing.name,
|
|
"display_name": existing.display_name,
|
|
"kind": existing.kind,
|
|
},
|
|
), 409
|
|
|
|
# Merge: move all images from source tag to target tag
|
|
source_images = tag.images[:]
|
|
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)
|
|
db.session.delete(tag)
|
|
db.session.commit()
|
|
return jsonify(ok=True, merged=True, merged_count=merged_count, tag={
|
|
"id": existing.id,
|
|
"name": existing.name,
|
|
"display_name": existing.display_name,
|
|
"kind": existing.kind,
|
|
})
|
|
|
|
# Plain rename
|
|
tag.name = new_name
|
|
tag.kind = new_kind
|
|
db.session.commit()
|
|
return jsonify(ok=True, tag={
|
|
"id": tag.id,
|
|
"name": tag.name,
|
|
"display_name": tag.display_name,
|
|
"kind": tag.kind,
|
|
"fandom_id": tag.fandom_id,
|
|
})
|
|
|
|
|
|
@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 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/<int:task_id>')
|
|
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 = Tag.query.filter_by(kind='artist', name=artist_name).first()
|
|
if not tag:
|
|
tag = Tag(kind='artist', name=artist_name)
|
|
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():
|
|
"""
|
|
Bulk-apply one tag to many images in a single transaction.
|
|
|
|
Accepts the same 'kind:name' shortcut as /image/<id>/tags/add. For
|
|
character tags, accepts optional fandom_id / fandom_name just like
|
|
the single-image endpoint.
|
|
"""
|
|
from app.utils.tag_prefix import parse_kind_prefix
|
|
from app.utils.tag_names import underscores_to_spaces
|
|
|
|
data = request.get_json() or {}
|
|
image_ids = data.get("image_ids") or []
|
|
raw = (data.get("tag_name") or "").strip()
|
|
if not image_ids or not raw:
|
|
return jsonify(ok=False, error="image_ids and tag_name required"), 400
|
|
|
|
kind, name = parse_kind_prefix(raw)
|
|
if kind is None:
|
|
kind = "user"
|
|
name = underscores_to_spaces(name)
|
|
elif kind in ("character", "fandom"):
|
|
name = normalize_display_name(name)
|
|
|
|
fandom_tag = None
|
|
if kind == "character":
|
|
fandom_id = data.get("fandom_id")
|
|
fandom_name = (data.get("fandom_name") or "").strip() or None
|
|
if fandom_id:
|
|
fandom_tag = Tag.query.filter_by(id=fandom_id, kind="fandom").first()
|
|
if fandom_tag is None:
|
|
return jsonify(ok=False, error="fandom_not_found"), 400
|
|
elif fandom_name:
|
|
fandom_tag = _ensure_fandom_tag(fandom_name)
|
|
|
|
tag = Tag.query.filter_by(
|
|
kind="character",
|
|
name=name,
|
|
fandom_id=fandom_tag.id if fandom_tag else None,
|
|
).first()
|
|
if tag is None:
|
|
tag = Tag(
|
|
kind="character",
|
|
name=name,
|
|
fandom_id=fandom_tag.id if fandom_tag else None,
|
|
)
|
|
db.session.add(tag)
|
|
db.session.flush()
|
|
else:
|
|
tag = Tag.query.filter_by(kind=kind, name=name).first()
|
|
if tag is None:
|
|
tag = Tag(kind=kind, name=name)
|
|
db.session.add(tag)
|
|
db.session.flush()
|
|
|
|
added_count = 0
|
|
for image_id in image_ids:
|
|
img = ImageRecord.query.get(image_id)
|
|
if img is None:
|
|
continue
|
|
if tag not in img.tags:
|
|
img.tags.append(tag)
|
|
added_count += 1
|
|
if fandom_tag and fandom_tag not in img.tags:
|
|
img.tags.append(fandom_tag)
|
|
|
|
db.session.commit()
|
|
return jsonify(
|
|
ok=True,
|
|
added_count=added_count,
|
|
total=len(image_ids),
|
|
tag={
|
|
"id": tag.id,
|
|
"name": tag.name,
|
|
"display_name": tag.display_name,
|
|
"kind": tag.kind,
|
|
},
|
|
)
|
|
|
|
|
|
@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/<int:tag_id>')
|
|
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
|
|
|
|
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/<int:tag_id>/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/<int:tag_id>/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/<int:tag_id>/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/<int:page_id>/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/<int:page_id>/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/<int:image_id>/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/<int:image_id>/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
|
|
})
|