Optimize scan process with quick/deep modes and archive tag merging
Quick scan (default): - Pre-load all ImportTask records for O(1) duplicate checks - Batch task creation commits (50 at a time) - Cache import settings (5-min TTL) - Skip pHash comparison for speed Deep scan (on-demand): - Full reprocessing of all files - pHash similarity detection - Optional thumbnail regeneration - Optional sidecar re-application Archive tag merging: - Add archive tags to existing images when duplicates found - Works for both hash and pHash duplicate detection Also fixes: - Add missing source/post tag icons to gallery templates
This commit is contained in:
+144
-35
@@ -4,12 +4,19 @@ File import tasks for images, videos, and archives.
|
||||
|
||||
These tasks handle the actual import of individual files,
|
||||
including filtering, duplicate detection, and storage.
|
||||
|
||||
Supports two modes:
|
||||
- Quick import (default): Fast duplicate detection by content hash only.
|
||||
Skips pHash comparison for speed.
|
||||
- Deep import (via deep_scan): Full reprocessing with pHash comparison,
|
||||
optional thumbnail regeneration, and sidecar re-application.
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from functools import lru_cache
|
||||
|
||||
import imagehash
|
||||
|
||||
@@ -19,6 +26,30 @@ from app.models import ImportTask, ImageRecord, Tag, ArchiveRecord
|
||||
|
||||
log = logging.getLogger('celery.tasks.import_file')
|
||||
|
||||
# Cache import settings to avoid repeated DB/file reads
|
||||
_settings_cache = None
|
||||
_settings_cache_time = None
|
||||
SETTINGS_CACHE_TTL = 300 # 5 minutes
|
||||
|
||||
|
||||
def _get_cached_settings() -> dict:
|
||||
"""
|
||||
Get import settings with caching.
|
||||
Caches for 5 minutes to avoid repeated DB/file reads during batch imports.
|
||||
"""
|
||||
global _settings_cache, _settings_cache_time
|
||||
from app.utils.image_importer import load_import_settings
|
||||
|
||||
now = datetime.utcnow()
|
||||
if _settings_cache is None or _settings_cache_time is None:
|
||||
_settings_cache = load_import_settings()
|
||||
_settings_cache_time = now
|
||||
elif (now - _settings_cache_time).total_seconds() > SETTINGS_CACHE_TTL:
|
||||
_settings_cache = load_import_settings()
|
||||
_settings_cache_time = now
|
||||
|
||||
return _settings_cache
|
||||
|
||||
|
||||
@celery.task(bind=True, name='app.tasks.import_file.import_media_file',
|
||||
max_retries=3, default_retry_delay=60)
|
||||
@@ -26,13 +57,19 @@ def import_media_file(self, task_id: int):
|
||||
"""
|
||||
Import a single media file (image or video).
|
||||
|
||||
Supports two modes based on context.deep_scan flag:
|
||||
- Quick mode (default): Fast duplicate detection by content hash only.
|
||||
Skips pHash comparison for speed.
|
||||
- Deep mode: Full reprocessing with pHash comparison, optional
|
||||
thumbnail regeneration, and sidecar re-application.
|
||||
|
||||
Steps:
|
||||
1. Load ImportTask record
|
||||
2. Apply filters (dimensions, transparency, single-color)
|
||||
3. Check for duplicates (hash and pHash)
|
||||
3. Check for duplicates (hash, and pHash in deep mode)
|
||||
4. Copy to content-addressed storage
|
||||
5. Create ImageRecord
|
||||
6. Queue thumbnail task
|
||||
6. Generate thumbnail
|
||||
7. Queue sidecar metadata task
|
||||
8. Update ImportTask status
|
||||
|
||||
@@ -47,7 +84,7 @@ def import_media_file(self, task_id: int):
|
||||
from app.utils.image_importer import (
|
||||
extract_metadata, calculate_hash, calculate_perceptual_hash,
|
||||
is_mostly_transparent, is_mostly_single_color, find_similar_image,
|
||||
build_hashed_dest_path, load_import_settings, supersede_image,
|
||||
build_hashed_dest_path, supersede_image,
|
||||
generate_thumbnail, generate_video_thumbnail_mirrored,
|
||||
transcode_video_to_mp4, needs_transcode
|
||||
)
|
||||
@@ -68,10 +105,16 @@ def import_media_file(self, task_id: int):
|
||||
artist = context.get('artist', 'unknown')
|
||||
dest_dir = context.get('dest_dir', '/images')
|
||||
|
||||
log.info(f"Processing: {src_path}")
|
||||
# Deep scan options
|
||||
is_deep_scan = context.get('deep_scan', False)
|
||||
regenerate_thumbnails = context.get('regenerate_thumbnails', False)
|
||||
reapply_sidecars = context.get('reapply_sidecars', False)
|
||||
verify_hashes = context.get('verify_hashes', False)
|
||||
|
||||
# Load settings
|
||||
settings = load_import_settings()
|
||||
log.info(f"Processing: {src_path} (deep_scan={is_deep_scan})")
|
||||
|
||||
# Load settings with caching (avoids repeated DB reads)
|
||||
settings = _get_cached_settings()
|
||||
|
||||
# Validate file exists
|
||||
if not os.path.exists(src_path):
|
||||
@@ -113,43 +156,75 @@ def import_media_file(self, task_id: int):
|
||||
# Check for exact duplicate by hash
|
||||
existing = ImageRecord.query.filter_by(hash=file_hash).first()
|
||||
if existing:
|
||||
# Still try to enrich with sidecar metadata
|
||||
# Merge archive tag if this file came from an archive
|
||||
# This ensures the existing image gets tagged with all archives it appears in
|
||||
archive_tag_id = context.get('archive_tag_id')
|
||||
if archive_tag_id:
|
||||
_add_archive_tag(existing, archive_tag_id)
|
||||
db.session.commit()
|
||||
log.info(f"Added archive tag {archive_tag_id} to existing image {existing.id}")
|
||||
|
||||
# In deep scan mode with reapply_sidecars, always try to enrich
|
||||
# In quick mode, also try if sidecar exists
|
||||
sidecar_path = find_sidecar_json(src_path)
|
||||
if sidecar_path:
|
||||
if sidecar_path and (is_deep_scan and reapply_sidecars or not is_deep_scan):
|
||||
apply_sidecar_metadata.delay(existing.id, sidecar_path)
|
||||
|
||||
# In deep scan with regenerate_thumbnails, regenerate even for duplicates
|
||||
if is_deep_scan and regenerate_thumbnails:
|
||||
_regenerate_thumbnail(existing)
|
||||
|
||||
return _skip_task(task, 'Duplicate by hash', result_image_id=existing.id)
|
||||
|
||||
# Compute pHash for similarity check
|
||||
phash = calculate_perceptual_hash(src_path)
|
||||
phash_threshold = settings.get('phash_threshold', 10)
|
||||
# pHash similarity check
|
||||
# Quick mode: SKIP pHash comparison (relies on content hash for duplicates)
|
||||
# Deep mode: Full pHash comparison for similarity detection
|
||||
phash = None
|
||||
if is_deep_scan:
|
||||
phash = calculate_perceptual_hash(src_path)
|
||||
phash_threshold = settings.get('phash_threshold', 10)
|
||||
|
||||
if phash and phash_threshold > 0:
|
||||
# Load existing pHashes for comparison
|
||||
existing_phashes = [
|
||||
(imagehash.hex_to_hash(img.perceptual_hash), img.width, img.height, img.id)
|
||||
for img in ImageRecord.query.filter(ImageRecord.perceptual_hash.isnot(None)).all()
|
||||
]
|
||||
if phash and phash_threshold > 0:
|
||||
# Load existing pHashes for comparison
|
||||
# Note: In production with millions of images, consider using
|
||||
# a spatial index or pre-computed pHash buckets
|
||||
existing_phashes = [
|
||||
(imagehash.hex_to_hash(img.perceptual_hash), img.width, img.height, img.id)
|
||||
for img in ImageRecord.query.filter(ImageRecord.perceptual_hash.isnot(None)).all()
|
||||
]
|
||||
|
||||
relationship, similar_id = find_similar_image(
|
||||
phash, metadata['width'], metadata['height'],
|
||||
existing_phashes, threshold=phash_threshold
|
||||
)
|
||||
relationship, similar_id = find_similar_image(
|
||||
phash, metadata['width'], metadata['height'],
|
||||
existing_phashes, threshold=phash_threshold
|
||||
)
|
||||
|
||||
if relationship == 'larger_exists':
|
||||
# Still try to enrich the existing larger image with sidecar metadata
|
||||
sidecar_path = find_sidecar_json(src_path)
|
||||
if sidecar_path and similar_id:
|
||||
apply_sidecar_metadata.delay(similar_id, sidecar_path)
|
||||
return _skip_task(task, 'Similar larger image exists', result_image_id=similar_id)
|
||||
if relationship == 'larger_exists':
|
||||
# Merge archive tag if this file came from an archive
|
||||
archive_tag_id = context.get('archive_tag_id')
|
||||
if archive_tag_id and similar_id:
|
||||
similar_record = ImageRecord.query.get(similar_id)
|
||||
if similar_record:
|
||||
_add_archive_tag(similar_record, archive_tag_id)
|
||||
db.session.commit()
|
||||
log.info(f"Added archive tag {archive_tag_id} to similar image {similar_id}")
|
||||
|
||||
# Handle supersede case - new image is larger than existing
|
||||
if relationship == 'smaller_exists' and settings.get('supersede_smaller', True) and similar_id:
|
||||
old_record = ImageRecord.query.get(similar_id)
|
||||
if old_record:
|
||||
return _supersede_existing(
|
||||
task, old_record, src_path, file_hash, phash,
|
||||
metadata, artist, dest_dir, filename
|
||||
)
|
||||
# Still try to enrich the existing larger image with sidecar metadata
|
||||
sidecar_path = find_sidecar_json(src_path)
|
||||
if sidecar_path and similar_id:
|
||||
apply_sidecar_metadata.delay(similar_id, sidecar_path)
|
||||
return _skip_task(task, 'Similar larger image exists', result_image_id=similar_id)
|
||||
|
||||
# Handle supersede case - new image is larger than existing
|
||||
if relationship == 'smaller_exists' and settings.get('supersede_smaller', True) and similar_id:
|
||||
old_record = ImageRecord.query.get(similar_id)
|
||||
if old_record:
|
||||
return _supersede_existing(
|
||||
task, old_record, src_path, file_hash, phash,
|
||||
metadata, artist, dest_dir, filename
|
||||
)
|
||||
else:
|
||||
# Quick mode: only compute pHash for storage, not comparison
|
||||
phash = calculate_perceptual_hash(src_path)
|
||||
|
||||
# Normal import - copy to destination
|
||||
target_dir = os.path.join(dest_dir, artist)
|
||||
@@ -474,6 +549,40 @@ def _add_archive_tag(record: ImageRecord, archive_tag_id: int):
|
||||
record.tags.append(tag)
|
||||
|
||||
|
||||
def _regenerate_thumbnail(record: ImageRecord) -> bool:
|
||||
"""
|
||||
Regenerate thumbnail for an existing image record.
|
||||
Used by deep scan to refresh thumbnails.
|
||||
|
||||
Returns:
|
||||
True if thumbnail was regenerated, False on error
|
||||
"""
|
||||
from app.utils.image_importer import generate_thumbnail, generate_video_thumbnail_mirrored
|
||||
|
||||
try:
|
||||
filepath = record.filepath
|
||||
if not filepath or not os.path.exists(filepath):
|
||||
log.warning(f"Cannot regenerate thumbnail: file not found {filepath}")
|
||||
return False
|
||||
|
||||
lower = filepath.lower()
|
||||
if lower.endswith(('.mp4', '.mov', '.webm', '.avi', '.mkv')):
|
||||
new_thumb = generate_video_thumbnail_mirrored(filepath)
|
||||
else:
|
||||
new_thumb = generate_thumbnail(filepath, overwrite=True)
|
||||
|
||||
if new_thumb:
|
||||
record.thumb_path = new_thumb
|
||||
db.session.commit()
|
||||
log.info(f"Regenerated thumbnail for image {record.id}")
|
||||
return True
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to regenerate thumbnail for image {record.id}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _supersede_existing(task: ImportTask, old_record: ImageRecord, src_path: str,
|
||||
file_hash: str, phash, metadata: dict, artist: str,
|
||||
dest_dir: str, filename: str) -> dict:
|
||||
|
||||
Reference in New Issue
Block a user