added dedup tools to settings. first pass of mass tag editting to gallery view.

This commit is contained in:
Bryan Van Deusen
2026-01-20 23:45:54 -05:00
parent 87bcb633f6
commit ede1457abc
10 changed files with 1411 additions and 182 deletions
+264
View File
@@ -1316,3 +1316,267 @@ def list_import_batches():
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
})