3f11dcdc6c
1. Hide 'archive' kind from /tags default view. Archive tags only appear when the user explicitly requests them via ?kind=archive. _get_tags_with_previews gains a default_excluded_kinds tuple that applies when no explicit kind filter is set. 2. Hide the 'Add' submit button on desktop (>=768px). Enter in the tag input (and in the fandom picker when no row is highlighted) already submits; the button is only needed for mobile virtual keyboards where Enter-to-submit is unreliable. 3. Fandom picker keyboard parity: ArrowUp/Down navigate rows, Enter picks a highlighted row or (if no row is highlighted) submits the whole add-tag form. Escape clears the highlight. Mirrors the main tagInput's keyboard block. 4. Tag editor merge bug: the edit modal populated nameInput.value with data.tag.display_name — for a character with a fandom, that value is 'Name (Fandom)'. Saving without stripping the suffix routed to a rename that never collided with the canonical bare name, so the merge offer never fired. Now uses data.tag.name (the bare form). The (Fandom) display is still shown in the fandom input which is rendered separately. 5. Fandom picker styling: match the main tag-form input — same padding, border, background, focus state. Drop the separate 'Fandom' label; the placeholder 'Fandom (optional)' now carries the label role. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2847 lines
92 KiB
Python
2847 lines
92 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,
|
|
default_excluded_kinds=('archive',)):
|
|
"""
|
|
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)
|
|
else:
|
|
# Hide noisy auto-generated kinds (archive, etc.) from the default view;
|
|
# users can still reach them via an explicit ?kind=archive filter.
|
|
if default_excluded_kinds:
|
|
count_q = count_q.filter(~Tag.kind.in_(default_excluded_kinds))
|
|
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)
|
|
else:
|
|
if default_excluded_kinds:
|
|
q = q.filter(~Tag.kind.in_(default_excluded_kinds))
|
|
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(ok=True, 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.
|
|
|
|
Resolution order:
|
|
- If 'tag_id' is provided, attach that exact tag (used when the
|
|
autocomplete picker resolves a specific row). Auto-applies the
|
|
character's fandom if set. Skips parse_kind_prefix entirely.
|
|
- Else parse 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 created via the parse path, 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
|
|
|
|
img = ImageRecord.query.get_or_404(image_id)
|
|
|
|
explicit_tag_id = request.form.get("tag_id", type=int)
|
|
fandom_tag = None
|
|
|
|
if explicit_tag_id:
|
|
# Autocomplete-picker path: attach the exact clicked tag.
|
|
tag = Tag.query.get(explicit_tag_id)
|
|
if tag is None:
|
|
return jsonify(ok=False, error="tag_not_found"), 404
|
|
if tag.kind == "character" and tag.fandom_id:
|
|
fandom_tag = Tag.query.get(tag.fandom_id)
|
|
else:
|
|
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)
|
|
|
|
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
|
|
})
|