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

2074 lines
64 KiB
Python

# app/main.py
from flask import Blueprint, render_template, flash, redirect, url_for, abort, send_from_directory, request, jsonify
from sqlalchemy import desc, func, text, extract, and_
from sqlalchemy.orm import joinedload, aliased
from collections import OrderedDict
from datetime import datetime
from app.models import ImageRecord, Tag, ArchiveRecord, image_tags, ImportTask, ImportBatch
from app import db
import os
import shutil
main = Blueprint('main', __name__)
@main.route('/')
def index():
"""
Showcase view: full-bleed masonry collage of random images/videos.
"""
images = (
ImageRecord.query
.options(joinedload(ImageRecord.tags))
.order_by(func.random())
.limit(20)
.all()
)
return render_template('showcase.html', images=images)
@main.route('/api/random-images')
def random_images_api():
"""
JSON endpoint returning random images for shuffle/infinite scroll.
Query params:
- count: number of images to return (default 12)
- exclude: comma-separated list of image IDs to exclude (for infinite scroll)
"""
count = request.args.get('count', 12, type=int)
exclude_str = request.args.get('exclude', '')
# Parse exclusion list
exclude_ids = []
if exclude_str:
try:
exclude_ids = [int(x) for x in exclude_str.split(',') if x.strip()]
except ValueError:
pass
# Build query
q = ImageRecord.query.options(joinedload(ImageRecord.tags))
if exclude_ids:
q = q.filter(~ImageRecord.id.in_(exclude_ids))
images = q.order_by(func.random()).limit(count).all()
result = []
for img in images:
is_video = img.filename.lower().endswith(('.mp4', '.mov'))
thumb_path = (img.thumb_path or img.filepath).replace('/images/', '')
full_path = img.filepath.replace('/images/', '')
result.append({
'id': img.id,
'filename': img.filename,
'thumb_url': url_for('main.serve_image', filename=thumb_path),
'full_url': url_for('main.serve_image', filename=full_path),
'is_video': is_video,
'date': (img.taken_at or img.imported_at).strftime('%Y-%m-%d') if (img.taken_at or img.imported_at) else '',
'tags': [{'name': t.name, 'kind': t.kind} for t in img.tags]
})
return jsonify(images=result)
@main.route('/gallery')
def gallery():
"""
Gallery with 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_name = request.args.get('tag')
# Base query + eager-load tags
q = ImageRecord.query.options(joinedload(ImageRecord.tags))
# Optional tag filter
active_tag_obj = None
if tag_name:
active_tag_obj = Tag.query.filter_by(name=tag_name).first()
if active_tag_obj:
q = q.join(ImageRecord.tags).filter(Tag.name == tag_name)
# 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.split(':', 1)[1] if ':' in active_tag_obj.name else active_tag_obj.name,
'page_count': page_count,
}
return render_template(
'gallery.html',
grouped_images=grouped_images,
active_tag=tag_name,
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_name = request.args.get('tag')
# Build base query
q = ImageRecord.query.options(joinedload(ImageRecord.tags))
# Optional tag filter
if tag_name:
q = q.join(ImageRecord.tags).filter(Tag.name == tag_name)
# 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_name = request.args.get('tag')
# 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_name:
q = q.join(ImageRecord.tags).filter(Tag.name == tag_name)
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_name = request.args.get('tag')
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_name:
q = q.join(ImageRecord.tags).filter(Tag.name == tag_name)
# 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
)
@main.route('/tags')
def tag_list():
"""
Generic tag explorer:
/tags -> all tags
/tags?kind=user -> only user tags
/tags?kind=artist -> only artist tags
/tags?kind=archive -> only archive tags
/tags?kind=character -> only character tags
/tags?kind=series -> only series tags
/tags?kind=rating -> only rating tags
Shows a few preview images per tag.
Optimized to use 2 queries instead of N+1:
1. Get all tags with counts
2. Get preview images for all tags at once using window function
"""
images_per_tag = 3
kind = request.args.get('kind')
# Query 1: Get tags with image counts
q = db.session.query(
Tag,
func.count(image_tags.c.image_id).label('img_count')
).outerjoin(image_tags).group_by(Tag.id)
if kind:
q = q.filter(Tag.kind == kind)
tags_with_counts = q.order_by(Tag.name.asc()).all()
# 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.name.split(":", 1)[1] if (":" in t.name) else t.name
tag_map[t.id] = {
"id": t.id,
"name": label,
"tag": t.name,
"images": [],
"kind": t.kind,
"count": count
}
# Query 2: Get preview images for all tags at once using a subquery with row_number
# This fetches the top N images per tag in a single query
if tag_ids:
# Subquery to rank images within each tag
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()
)
# Get only the top N images per tag
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()
)
# Group images by tag_id
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]
return render_template('tags_list.html',
tag_data=tag_data,
images_per_tag=images_per_tag,
active_kind=kind)
@main.route('/artists')
def artist_list():
# Backward-compatible route that now shows the Tag Explorer filtered to artist tags
return redirect(url_for('main.tag_list', kind='artist'))
@main.route('/artist/<artist_name>')
def artist_gallery(artist_name):
# Backward-compatible: go to gallery filtered by artist tag
return redirect(url_for('main.gallery', tag=f"artist:{artist_name}"))
@main.route('/settings')
def settings():
return render_template('settings.html')
# ----------------------------
# Tag add/remove endpoints
# ----------------------------
@main.get("/api/tags/search")
def search_tags():
"""
Search existing tags for autocomplete.
Query params:
- q: search query (matches tag name, case-insensitive)
- limit: max results (default 10)
- exclude_kind: comma-separated list of tag kinds to exclude (e.g., 'archive')
- kind: filter to only include tags of this kind (e.g., 'series')
"""
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()
if not query:
# Return most-used tags when no query
base_query = (
Tag.query
.join(image_tags)
.group_by(Tag.id)
)
if exclude_kinds:
base_query = base_query.filter(~Tag.kind.in_(exclude_kinds))
if include_kind:
base_query = base_query.filter(Tag.kind == include_kind)
tags = (
base_query
.order_by(func.count(image_tags.c.image_id).desc())
.limit(limit)
.all()
)
else:
base_query = Tag.query.filter(Tag.name.ilike(f'%{query}%'))
if exclude_kinds:
base_query = base_query.filter(~Tag.kind.in_(exclude_kinds))
if include_kind:
base_query = base_query.filter(Tag.kind == include_kind)
tags = (
base_query
.order_by(Tag.name)
.limit(limit)
.all()
)
return jsonify(tags=[{"id": t.id, "name": t.name, "kind": t.kind} for t in tags])
@main.get("/image/<int:image_id>/tags")
def list_tags(image_id):
img = ImageRecord.query.get_or_404(image_id)
return jsonify(ok=True, tags=[{"name": t.name, "kind": t.kind} for t in img.tags])
@main.post("/image/<int:image_id>/tags/add")
def add_tag(image_id):
"""
Minimal JSON endpoint to add a tag to an image.
Accepts either 'artist:NAME' / 'archive:...' or a bare freeform tag ('mytag').
"""
name = (request.form.get("name") or "").strip()
if not name:
abort(400)
# Normalize: keep explicit kind prefixes, otherwise treat as user tag
if ":" in name:
kind = name.split(":", 1)[0]
tag_name = name
else:
kind = "user"
tag_name = name
img = ImageRecord.query.get_or_404(image_id)
tag = Tag.query.filter_by(name=tag_name).first()
if not tag:
tag = Tag(name=tag_name, kind=kind)
db.session.add(tag)
db.session.flush()
if tag not in img.tags:
img.tags.append(tag)
db.session.commit()
return jsonify(ok=True, tag={"name": tag.name, "kind": tag.kind})
@main.post("/image/<int:image_id>/tags/remove")
def remove_tag(image_id):
"""
Minimal JSON endpoint to remove a tag from an image.
"""
name = (request.form.get("name") or "").strip()
img = ImageRecord.query.get_or_404(image_id)
tag = Tag.query.filter_by(name=name).first()
if tag and tag in img.tags:
img.tags.remove(tag)
db.session.commit()
return jsonify(ok=True)
# ----------------------------
# Tag management endpoints
# ----------------------------
@main.get("/api/tag/<int:tag_id>")
def get_tag(tag_id):
"""Get a single tag's details."""
tag = Tag.query.get_or_404(tag_id)
# Get display name (without prefix)
display_name = tag.name.split(":", 1)[1] if ":" in tag.name else tag.name
return jsonify(ok=True, tag={
"id": tag.id,
"name": tag.name,
"display_name": display_name,
"kind": tag.kind
})
@main.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 prefixed kinds (artist, archive, character, series, rating),
the name will be stored as 'kind:name'.
If the new name matches an existing tag, merge them:
- Move all images from source tag to target tag
- Delete the source tag
- Return the target tag info with merged=True
"""
tag = Tag.query.get_or_404(tag_id)
new_name = (request.form.get("name") or "").strip()
new_kind = (request.form.get("kind") or "user").strip()
force_merge = request.form.get("merge") == "true"
if not new_name:
return jsonify(ok=False, error="Name is required"), 400
# Build the full tag name based on kind
prefixed_kinds = ("artist", "archive", "character", "series", "fandom", "rating")
if new_kind in prefixed_kinds:
# Remove any existing prefix from name if present
if ":" in new_name:
new_name = new_name.split(":", 1)[1]
full_name = f"{new_kind}:{new_name}"
else:
# User tags don't have prefixes
full_name = new_name
# Check for duplicates (excluding current tag)
existing = Tag.query.filter(Tag.name == full_name, Tag.id != tag_id).first()
if existing:
if not force_merge:
# Ask user to confirm merge
return jsonify(
ok=False,
error="A tag with this name already exists",
can_merge=True,
target_tag={
"id": existing.id,
"name": existing.name,
"kind": existing.kind
}
), 409 # Conflict status
# Merge: move all images from source tag to target tag
source_images = tag.images[:] # Copy list to avoid mutation during iteration
merged_count = 0
for img in source_images:
if existing not in img.tags:
img.tags.append(existing)
merged_count += 1
img.tags.remove(tag)
# Delete the source tag
db.session.delete(tag)
db.session.commit()
return jsonify(ok=True, merged=True, merged_count=merged_count, tag={
"id": existing.id,
"name": existing.name,
"kind": existing.kind
})
# No conflict - just update the tag
tag.name = full_name
tag.kind = new_kind
db.session.commit()
return jsonify(ok=True, tag={
"id": tag.id,
"name": tag.name,
"kind": tag.kind
})
@main.post("/api/tag/<int:tag_id>/delete")
def delete_tag(tag_id):
"""Delete a tag entirely."""
tag = Tag.query.get_or_404(tag_id)
# Remove from all images first (handled by cascade, but explicit is clearer)
db.session.delete(tag)
db.session.commit()
return jsonify(ok=True)
# ----------------------------
# Filtered deletion endpoints
# ----------------------------
@main.post("/api/delete-by-tag")
def delete_images_by_tag():
"""
Delete all images that have a specific tag.
Also removes the image files and thumbnails from disk.
Query params:
- tag_id: ID of the tag whose images should be deleted
- tag_name: Name of the tag (must match for security validation)
- delete_tag: if "true", also delete the tag itself
"""
tag_id = request.form.get("tag_id", type=int)
tag_name = request.form.get("tag_name", "").strip()
delete_tag_also = request.form.get("delete_tag") == "true"
if not tag_id:
return jsonify(ok=False, error="tag_id is required"), 400
if not tag_name:
return jsonify(ok=False, error="tag_name confirmation is required"), 400
tag = Tag.query.get_or_404(tag_id)
# Security: verify the provided tag name matches the actual tag
if tag.name != tag_name:
return jsonify(ok=False, error="Tag name confirmation does not match"), 403
images_to_delete = list(tag.images)
deleted_count = 0
errors = []
for img in images_to_delete:
try:
# Remove image file from disk
if img.filepath and os.path.exists(img.filepath):
os.remove(img.filepath)
# Remove thumbnail if exists
if img.thumb_path and os.path.exists(img.thumb_path):
os.remove(img.thumb_path)
# Delete DB record
db.session.delete(img)
deleted_count += 1
except Exception as e:
errors.append(f"Failed to delete {img.filename}: {e}")
# Optionally delete the tag itself
if delete_tag_also:
db.session.delete(tag)
db.session.commit()
return jsonify(
ok=True,
deleted_count=deleted_count,
tag_deleted=delete_tag_also,
errors=errors if errors else None
)
@main.get("/api/tag/<int:tag_id>/image-count")
def get_tag_image_count(tag_id):
"""Get the number of images associated with a tag."""
tag = Tag.query.get_or_404(tag_id)
count = len(tag.images)
return jsonify(ok=True, count=count, tag_name=tag.name)
# ----------------------------
# Import settings endpoints
# ----------------------------
@main.get("/api/import-settings")
def get_import_settings():
"""Get current import filter settings 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.
Replaces the old flag-file mechanism.
"""
try:
from app.tasks.scan import scan_directory
source_dir = request.form.get('source_dir', '/import')
dest_dir = request.form.get('dest_dir', '/images')
result = scan_directory.delay(source_dir, dest_dir)
return jsonify({
'ok': True,
'task_id': result.id,
'message': 'Import scan started'
})
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/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 completed and skipped import tasks from the database.
Keeps failed tasks for review.
"""
try:
deleted = ImportTask.query.filter(
ImportTask.status.in_(['complete', 'skipped'])
).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/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_name = f"artist:{artist_name}"
tag = Tag.query.filter_by(name=tag_name).first()
if not tag:
tag = Tag(name=tag_name, kind='artist')
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():
"""
Add a tag to 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
# Determine tag kind from name
if ':' in tag_name:
kind = tag_name.split(':', 1)[0]
else:
kind = 'user'
# Get or create the tag
tag = Tag.query.filter_by(name=tag_name).first()
if not tag:
tag = Tag(name=tag_name, kind=kind)
db.session.add(tag)
db.session.flush()
# Get images that don't already have this tag
images = ImageRecord.query.filter(
ImageRecord.id.in_(image_ids)
).all()
added_count = 0
for img in images:
if tag not in img.tags:
img.tags.append(tag)
added_count += 1
db.session.commit()
return jsonify({
'ok': True,
'added_count': added_count,
'tag': {'id': tag.id, 'name': tag.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.split(':', 1)[1] if ':' in tag.name else 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 jsonify(ok=True, in_series=False)
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
})