added dedup tools to settings. first pass of mass tag editting to gallery view.
This commit is contained in:
+264
@@ -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
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user