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:
Bryan Van Deusen
2026-01-31 15:18:43 -05:00
parent d0fcde38e8
commit 042a69f9c3
7 changed files with 595 additions and 171 deletions
+35 -4
View File
@@ -1114,20 +1114,51 @@ def delete_filtered_images():
def trigger_import_celery(): def trigger_import_celery():
""" """
Trigger a directory scan and import via Celery. Trigger a directory scan and import via Celery.
Replaces the old flag-file mechanism.
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: try:
from app.tasks.scan import scan_directory from app.tasks.scan import scan_directory, deep_scan_directory
source_dir = request.form.get('source_dir', '/import') source_dir = request.form.get('source_dir', '/import')
dest_dir = request.form.get('dest_dir', '/images') dest_dir = request.form.get('dest_dir', '/images')
is_deep = request.form.get('deep', 'false').lower() == 'true'
result = scan_directory.delay(source_dir, dest_dir) 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({ return jsonify({
'ok': True, 'ok': True,
'task_id': result.id, 'task_id': result.id,
'message': 'Import scan started' 'message': message,
'mode': 'deep' if is_deep else 'quick'
}) })
except Exception as e: except Exception as e:
return jsonify({'ok': False, 'error': str(e)}), 500 return jsonify({'ok': False, 'error': str(e)}), 500
+4 -2
View File
@@ -555,11 +555,13 @@
item.appendChild(overlay); item.appendChild(overlay);
} }
// Render tags // Render tags with icons
overlay.innerHTML = visibleTags.map(t => { overlay.innerHTML = visibleTags.map(t => {
const displayName = getTagDisplayName(t.name, t.kind); const displayName = getTagDisplayName(t.name, t.kind);
const icon = getTagIcon(t.kind);
const encodedName = encodeURIComponent(t.name); const encodedName = encodeURIComponent(t.name);
return `<a class="tag-chip" href="/gallery?tag=${encodedName}" title="Filter by ${escapeHtml(t.name)}" onclick="event.stopPropagation()">${escapeHtml(displayName)}</a>`; const prefix = icon !== '#' ? icon + ' ' : '#';
return `<a class="tag-chip" href="/gallery?tag=${encodedName}" title="Filter by ${escapeHtml(t.name)}" onclick="event.stopPropagation()">${prefix}${escapeHtml(displayName)}</a>`;
}).join(''); }).join('');
} catch (e) { } catch (e) {
+18 -2
View File
@@ -187,6 +187,21 @@
return section; return section;
} }
// Tag icon mapping (matches showcase.js and bulk-select.js)
function getTagIcon(kind) {
const icons = {
artist: '🎨',
archive: '🗜️',
character: '👤',
series: '📺',
fandom: '🎭',
rating: '⚠️',
source: '🌐',
post: '📌'
};
return icons[kind] || '#';
}
// Create a single image element // Create a single image element
function createImageElement(img) { function createImageElement(img) {
const item = document.createElement('div'); const item = document.createElement('div');
@@ -196,12 +211,13 @@
item.dataset.type = img.is_video ? 'video' : 'image'; item.dataset.type = img.is_video ? 'video' : 'image';
item.dataset.filename = img.filename; item.dataset.filename = img.filename;
// Build tag overlay HTML // Build tag overlay HTML with icons
let tagsHtml = ''; let tagsHtml = '';
if (img.tags && img.tags.length > 0) { if (img.tags && img.tags.length > 0) {
const tagChips = img.tags.map(t => { const tagChips = img.tags.map(t => {
const displayName = t.name.includes(':') ? t.name.split(':')[1] : t.name; const displayName = t.name.includes(':') ? t.name.split(':')[1] : t.name;
const prefix = t.kind === 'user' ? '#' : ''; const icon = getTagIcon(t.kind);
const prefix = icon !== '#' ? icon + ' ' : '#';
return `<a class="tag-chip" href="/gallery?tag=${encodeURIComponent(t.name)}" title="Filter by ${t.name}" onclick="event.stopPropagation()">${prefix}${displayName}</a>`; return `<a class="tag-chip" href="/gallery?tag=${encodeURIComponent(t.name)}" title="Filter by ${t.name}" onclick="event.stopPropagation()">${prefix}${displayName}</a>`;
}).join(''); }).join('');
tagsHtml = `<div class="tag-overlay">${tagChips}</div>`; tagsHtml = `<div class="tag-overlay">${tagChips}</div>`;
+144 -35
View File
@@ -4,12 +4,19 @@ File import tasks for images, videos, and archives.
These tasks handle the actual import of individual files, These tasks handle the actual import of individual files,
including filtering, duplicate detection, and storage. 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 os
import shutil import shutil
import tempfile import tempfile
import logging import logging
from datetime import datetime from datetime import datetime
from functools import lru_cache
import imagehash import imagehash
@@ -19,6 +26,30 @@ from app.models import ImportTask, ImageRecord, Tag, ArchiveRecord
log = logging.getLogger('celery.tasks.import_file') 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', @celery.task(bind=True, name='app.tasks.import_file.import_media_file',
max_retries=3, default_retry_delay=60) 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). 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: Steps:
1. Load ImportTask record 1. Load ImportTask record
2. Apply filters (dimensions, transparency, single-color) 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 4. Copy to content-addressed storage
5. Create ImageRecord 5. Create ImageRecord
6. Queue thumbnail task 6. Generate thumbnail
7. Queue sidecar metadata task 7. Queue sidecar metadata task
8. Update ImportTask status 8. Update ImportTask status
@@ -47,7 +84,7 @@ def import_media_file(self, task_id: int):
from app.utils.image_importer import ( from app.utils.image_importer import (
extract_metadata, calculate_hash, calculate_perceptual_hash, extract_metadata, calculate_hash, calculate_perceptual_hash,
is_mostly_transparent, is_mostly_single_color, find_similar_image, 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, generate_thumbnail, generate_video_thumbnail_mirrored,
transcode_video_to_mp4, needs_transcode transcode_video_to_mp4, needs_transcode
) )
@@ -68,10 +105,16 @@ def import_media_file(self, task_id: int):
artist = context.get('artist', 'unknown') artist = context.get('artist', 'unknown')
dest_dir = context.get('dest_dir', '/images') 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 log.info(f"Processing: {src_path} (deep_scan={is_deep_scan})")
settings = load_import_settings()
# Load settings with caching (avoids repeated DB reads)
settings = _get_cached_settings()
# Validate file exists # Validate file exists
if not os.path.exists(src_path): 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 # Check for exact duplicate by hash
existing = ImageRecord.query.filter_by(hash=file_hash).first() existing = ImageRecord.query.filter_by(hash=file_hash).first()
if existing: 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) 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) 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) return _skip_task(task, 'Duplicate by hash', result_image_id=existing.id)
# Compute pHash for similarity check # pHash similarity check
phash = calculate_perceptual_hash(src_path) # Quick mode: SKIP pHash comparison (relies on content hash for duplicates)
phash_threshold = settings.get('phash_threshold', 10) # 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: if phash and phash_threshold > 0:
# Load existing pHashes for comparison # Load existing pHashes for comparison
existing_phashes = [ # Note: In production with millions of images, consider using
(imagehash.hex_to_hash(img.perceptual_hash), img.width, img.height, img.id) # a spatial index or pre-computed pHash buckets
for img in ImageRecord.query.filter(ImageRecord.perceptual_hash.isnot(None)).all() 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( relationship, similar_id = find_similar_image(
phash, metadata['width'], metadata['height'], phash, metadata['width'], metadata['height'],
existing_phashes, threshold=phash_threshold existing_phashes, threshold=phash_threshold
) )
if relationship == 'larger_exists': if relationship == 'larger_exists':
# Still try to enrich the existing larger image with sidecar metadata # Merge archive tag if this file came from an archive
sidecar_path = find_sidecar_json(src_path) archive_tag_id = context.get('archive_tag_id')
if sidecar_path and similar_id: if archive_tag_id and similar_id:
apply_sidecar_metadata.delay(similar_id, sidecar_path) similar_record = ImageRecord.query.get(similar_id)
return _skip_task(task, 'Similar larger image exists', result_image_id=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 # Still try to enrich the existing larger image with sidecar metadata
if relationship == 'smaller_exists' and settings.get('supersede_smaller', True) and similar_id: sidecar_path = find_sidecar_json(src_path)
old_record = ImageRecord.query.get(similar_id) if sidecar_path and similar_id:
if old_record: apply_sidecar_metadata.delay(similar_id, sidecar_path)
return _supersede_existing( return _skip_task(task, 'Similar larger image exists', result_image_id=similar_id)
task, old_record, src_path, file_hash, phash,
metadata, artist, dest_dir, filename # 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 # Normal import - copy to destination
target_dir = os.path.join(dest_dir, artist) 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) 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, def _supersede_existing(task: ImportTask, old_record: ImageRecord, src_path: str,
file_hash: str, phash, metadata: dict, artist: str, file_hash: str, phash, metadata: dict, artist: str,
dest_dir: str, filename: str) -> dict: dest_dir: str, filename: str) -> dict:
+326 -98
View File
@@ -4,14 +4,21 @@ Directory scanning task for the import pipeline.
This task walks the import directory, detects new/changed files, This task walks the import directory, detects new/changed files,
creates ImportTask records, and queues appropriate processing tasks. creates ImportTask records, and queues appropriate processing tasks.
Two scan modes:
- Quick scan (default): Pre-loads existing task/hash data for O(1) lookups.
Only queues truly new files. Skips pHash comparison for speed.
- Deep scan (on-demand): Full reprocessing of all files. Re-extracts metadata,
recalculates hashes, regenerates thumbnails. For data integrity verification.
""" """
import os import os
import logging import logging
from datetime import datetime from datetime import datetime
from typing import Optional
from app.celery_app import celery from app.celery_app import celery
from app import db from app import db
from app.models import ImportTask, ImportBatch from app.models import ImportTask, ImportBatch, ImageRecord
log = logging.getLogger('celery.tasks.scan') log = logging.getLogger('celery.tasks.scan')
@@ -26,18 +33,81 @@ ALLOWED_ARCHIVE_EXTS = (
) )
def _build_task_lookup() -> dict:
"""
Pre-load all ImportTask records into a lookup dict for O(1) checks.
Key: (source_path, task_type)
Value: dict with 'status', 'file_size' for quick comparison
This replaces per-file database queries with a single upfront query.
"""
lookup = {}
for task in ImportTask.query.with_entities(
ImportTask.source_path,
ImportTask.task_type,
ImportTask.status,
ImportTask.file_size
).all():
key = (task.source_path, task.task_type)
# Keep the most relevant status for each path
# Priority: active > complete > skipped > failed
existing = lookup.get(key)
if existing is None:
lookup[key] = {'status': task.status, 'file_size': task.file_size}
elif task.status in ('pending', 'queued', 'processing'):
# Active task takes priority
lookup[key] = {'status': task.status, 'file_size': task.file_size}
elif existing['status'] not in ('pending', 'queued', 'processing'):
# Replace if existing is not active and new is complete
if task.status == 'complete':
lookup[key] = {'status': task.status, 'file_size': task.file_size}
return lookup
def _should_skip_file(source_path: str, file_size: int, task_type: str,
task_lookup: dict) -> Optional[str]:
"""
Check if file should be skipped using pre-loaded lookup.
Returns:
Skip reason string if should skip, None if should process
"""
key = (source_path, task_type)
existing = task_lookup.get(key)
if existing is None:
return None # New file, process it
status = existing['status']
# Skip if task is currently active
if status in ('pending', 'queued', 'processing'):
return 'already_active'
# Skip if completed/skipped with same size
if status in ('complete', 'skipped') and existing['file_size'] == file_size:
return 'already_processed'
# File size changed - re-process
return None
@celery.task(bind=True, name='app.tasks.scan.scan_directory') @celery.task(bind=True, name='app.tasks.scan.scan_directory')
def scan_directory(self, source_dir: str = '/import', dest_dir: str = '/images'): def scan_directory(self, source_dir: str = '/import', dest_dir: str = '/images'):
""" """
Scan source directory for new/changed files. Quick scan: Efficiently detect and queue new/changed files.
Creates ImportTask records for each file and queues them for processing. Optimizations over naive approach:
- Pre-loads all existing task records in one query (O(1) lookup per file)
- Batches task creation commits
- Skips pHash comparison (relies on content hash for duplicates)
Strategy: Strategy:
1. Walk directory tree under each artist folder 1. Load existing ImportTask lookup dict (single query)
2. For each file, check if already in ImportTask with same path+size 2. Walk directory tree under each artist folder
3. If new/changed, create ImportTask and queue appropriate task 3. For each file, check lookup dict (no DB queries per file)
4. Handle archives specially (first volume only, queue as atomic unit) 4. Queue new/changed files for processing
5. Handle archives specially (first volume only)
Args: Args:
source_dir: Root directory to scan (default: /import) source_dir: Root directory to scan (default: /import)
@@ -49,7 +119,12 @@ def scan_directory(self, source_dir: str = '/import', dest_dir: str = '/images')
from app.tasks.import_file import import_media_file, import_archive from app.tasks.import_file import import_media_file, import_archive
from app.utils.image_importer import is_first_volume from app.utils.image_importer import is_first_volume
log.info(f"Starting directory scan: {source_dir}") log.info(f"Starting quick scan: {source_dir}")
# Pre-load existing tasks for O(1) lookup (single query)
log.debug("Loading existing task lookup...")
task_lookup = _build_task_lookup()
log.debug(f"Loaded {len(task_lookup)} existing task records")
# Create batch record # Create batch record
batch = ImportBatch(source_directory=source_dir) batch = ImportBatch(source_directory=source_dir)
@@ -65,6 +140,10 @@ def scan_directory(self, source_dir: str = '/import', dest_dir: str = '/images')
'skipped_non_media': 0, 'skipped_non_media': 0,
} }
# Collect tasks to create in batches
pending_tasks = []
BATCH_SIZE = 50 # Commit every 50 tasks
try: try:
# Iterate through artist directories # Iterate through artist directories
for artist_dir in os.listdir(source_dir): for artist_dir in os.listdir(source_dir):
@@ -85,6 +164,12 @@ def scan_directory(self, source_dir: str = '/import', dest_dir: str = '/images')
if lower.endswith('.json'): if lower.endswith('.json'):
continue continue
# Get file size once (used for lookup)
try:
file_size = os.path.getsize(full_path)
except OSError:
continue
# Check if archive # Check if archive
if lower.endswith(ALLOWED_ARCHIVE_EXTS): if lower.endswith(ALLOWED_ARCHIVE_EXTS):
# Only process first volume of multi-part archives # Only process first volume of multi-part archives
@@ -92,23 +177,25 @@ def scan_directory(self, source_dir: str = '/import', dest_dir: str = '/images')
stats['skipped_non_first_volume'] += 1 stats['skipped_non_first_volume'] += 1
continue continue
# Create and queue archive task # Check lookup dict (no DB query)
task_record = _create_task_if_new( skip_reason = _should_skip_file(
full_path, file_size, 'import_archive', task_lookup
)
if skip_reason:
stats['skipped_existing'] += 1
continue
# Queue archive task
task = ImportTask(
source_path=full_path, source_path=full_path,
task_type='import_archive', task_type='import_archive',
file_size=file_size,
batch_id=batch.id, batch_id=batch.id,
context={'artist': artist_dir, 'dest_dir': dest_dir} context={'artist': artist_dir, 'dest_dir': dest_dir},
status='pending'
) )
pending_tasks.append(('archive', task))
if task_record: stats['queued_archives'] += 1
result = import_archive.delay(task_record.id)
task_record.celery_task_id = result.id
task_record.status = 'queued'
db.session.commit()
stats['queued_archives'] += 1
log.debug(f"Queued archive: {filename}")
else:
stats['skipped_existing'] += 1
continue continue
# Check if media file # Check if media file
@@ -116,29 +203,40 @@ def scan_directory(self, source_dir: str = '/import', dest_dir: str = '/images')
stats['skipped_non_media'] += 1 stats['skipped_non_media'] += 1
continue continue
# Create and queue media file task # Check lookup dict (no DB query)
task_record = _create_task_if_new( skip_reason = _should_skip_file(
full_path, file_size, 'import_image', task_lookup
)
if skip_reason:
stats['skipped_existing'] += 1
continue
# Queue media file task
task = ImportTask(
source_path=full_path, source_path=full_path,
task_type='import_image', task_type='import_image',
file_size=file_size,
batch_id=batch.id, batch_id=batch.id,
context={'artist': artist_dir, 'dest_dir': dest_dir} context={'artist': artist_dir, 'dest_dir': dest_dir},
status='pending'
) )
pending_tasks.append(('media', task))
stats['queued_media'] += 1
if task_record: # Batch commit
result = import_media_file.delay(task_record.id) if len(pending_tasks) >= BATCH_SIZE:
task_record.celery_task_id = result.id _flush_pending_tasks(pending_tasks)
task_record.status = 'queued' pending_tasks = []
db.session.commit()
stats['queued_media'] += 1 # Flush remaining tasks
log.debug(f"Queued media: {filename}") if pending_tasks:
else: _flush_pending_tasks(pending_tasks)
stats['skipped_existing'] += 1
# Update batch with totals # Update batch with totals
batch.total_files = stats['total_files'] batch.total_files = stats['total_files']
db.session.commit() db.session.commit()
log.info(f"Scan complete. Queued {stats['queued_media']} media, " log.info(f"Quick scan complete. Queued {stats['queued_media']} media, "
f"{stats['queued_archives']} archives. " f"{stats['queued_archives']} archives. "
f"Skipped {stats['skipped_existing']} existing.") f"Skipped {stats['skipped_existing']} existing.")
@@ -154,78 +252,208 @@ def scan_directory(self, source_dir: str = '/import', dest_dir: str = '/images')
raise raise
def _create_task_if_new(source_path: str, task_type: str, batch_id: int, context: dict) -> ImportTask: def _flush_pending_tasks(pending_tasks: list):
""" """
Create ImportTask if no existing task for this file. Batch add tasks to session, commit, and queue Celery jobs.
Uses file path and size for change detection.
Hash is computed later during actual processing.
Args:
source_path: Full path to source file
task_type: Type of task ('import_image', 'import_archive', etc.)
batch_id: ID of the current ImportBatch
context: Context dict with artist, dest_dir, etc.
Returns:
New ImportTask record if created, None if already exists
""" """
try: from app.tasks.import_file import import_media_file, import_archive
file_size = os.path.getsize(source_path)
except OSError as e:
log.warning(f"Cannot access file {source_path}: {e}")
return None
# Check for existing pending/queued/processing task for same file # Add all tasks to session
existing_active = ImportTask.query.filter_by( for task_type, task in pending_tasks:
source_path=source_path, db.session.add(task)
task_type=task_type
).filter(
ImportTask.status.in_(['pending', 'queued', 'processing'])
).first()
if existing_active: # Flush to get IDs
log.debug(f"Task already active for: {source_path}") db.session.flush()
return None
# Check for completed task with same file path and size # Queue Celery jobs
# If size changed, we should re-import for task_type, task in pending_tasks:
completed_same = ImportTask.query.filter_by( if task_type == 'archive':
source_path=source_path, result = import_archive.delay(task.id)
task_type=task_type, else:
file_size=file_size, result = import_media_file.delay(task.id)
status='complete' task.celery_task_id = result.id
).first() task.status = 'queued'
if completed_same: # Commit batch
log.debug(f"Already imported (same size): {source_path}")
return None
# Also check 'skipped' status - don't re-queue skipped files unless size changed
skipped_same = ImportTask.query.filter_by(
source_path=source_path,
task_type=task_type,
file_size=file_size,
status='skipped'
).first()
if skipped_same:
log.debug(f"Previously skipped (same size): {source_path}")
return None
# Create new task
task = ImportTask(
source_path=source_path,
task_type=task_type,
file_size=file_size,
batch_id=batch_id,
context=context,
status='pending'
)
db.session.add(task)
db.session.commit() db.session.commit()
return task
@celery.task(bind=True, name='app.tasks.scan.deep_scan_directory')
def deep_scan_directory(self, source_dir: str = '/import', dest_dir: str = '/images',
regenerate_thumbnails: bool = True,
reapply_sidecars: bool = True,
verify_hashes: bool = True):
"""
Deep scan: Full reprocessing of all files for data integrity verification.
Unlike quick scan, this:
- Ignores existing task status (forces reprocessing)
- Re-extracts all metadata
- Recalculates content hashes (optional verification)
- Regenerates thumbnails (optional)
- Re-applies sidecar metadata (optional)
- Runs full pHash comparison for similarity detection
Use cases:
- Data integrity verification after storage issues
- Picking up metadata from new/updated sidecar files
- Regenerating thumbnails after algorithm changes
- Recovery from partial imports
Args:
source_dir: Root directory to scan (default: /import)
dest_dir: Destination for imported files (default: /images)
regenerate_thumbnails: Regenerate all thumbnails (default: True)
reapply_sidecars: Re-apply metadata from sidecar files (default: True)
verify_hashes: Verify content hashes match stored records (default: True)
Returns:
dict with batch_id and statistics
"""
from app.tasks.import_file import import_media_file, import_archive
from app.utils.image_importer import is_first_volume
log.info(f"Starting DEEP scan: {source_dir}")
log.info(f"Options: thumbnails={regenerate_thumbnails}, sidecars={reapply_sidecars}, verify={verify_hashes}")
# Create batch record with deep scan flag
batch = ImportBatch(source_directory=source_dir)
db.session.add(batch)
db.session.commit()
stats = {
'total_files': 0,
'queued_media': 0,
'queued_archives': 0,
'skipped_active': 0, # Only skip currently processing files
'skipped_non_first_volume': 0,
'skipped_non_media': 0,
}
# For deep scan, only load active tasks to avoid double-processing
active_tasks = set()
for task in ImportTask.query.filter(
ImportTask.status.in_(['pending', 'queued', 'processing'])
).with_entities(ImportTask.source_path, ImportTask.task_type).all():
active_tasks.add((task.source_path, task.task_type))
log.debug(f"Found {len(active_tasks)} currently active tasks to skip")
pending_tasks = []
BATCH_SIZE = 50
try:
# Iterate through artist directories
for artist_dir in os.listdir(source_dir):
artist_path = os.path.join(source_dir, artist_dir)
if not os.path.isdir(artist_path):
continue
log.debug(f"Deep scanning artist directory: {artist_dir}")
# Walk all files in artist directory (including subdirectories)
for root, _, files in os.walk(artist_path):
for filename in files:
full_path = os.path.join(root, filename)
lower = filename.lower()
stats['total_files'] += 1
# Skip sidecar JSON files (handled during import)
if lower.endswith('.json'):
continue
# Get file size
try:
file_size = os.path.getsize(full_path)
except OSError:
continue
# Check if archive
if lower.endswith(ALLOWED_ARCHIVE_EXTS):
if not is_first_volume(full_path):
stats['skipped_non_first_volume'] += 1
continue
# Only skip if currently active (deep scan reprocesses everything else)
if (full_path, 'import_archive') in active_tasks:
stats['skipped_active'] += 1
continue
task = ImportTask(
source_path=full_path,
task_type='import_archive',
file_size=file_size,
batch_id=batch.id,
context={
'artist': artist_dir,
'dest_dir': dest_dir,
'deep_scan': True,
'regenerate_thumbnails': regenerate_thumbnails,
'reapply_sidecars': reapply_sidecars,
'verify_hashes': verify_hashes
},
status='pending'
)
pending_tasks.append(('archive', task))
stats['queued_archives'] += 1
continue
# Check if media file
if not lower.endswith(ALLOWED_MEDIA_EXTS):
stats['skipped_non_media'] += 1
continue
# Only skip if currently active
if (full_path, 'import_image') in active_tasks:
stats['skipped_active'] += 1
continue
task = ImportTask(
source_path=full_path,
task_type='import_image',
file_size=file_size,
batch_id=batch.id,
context={
'artist': artist_dir,
'dest_dir': dest_dir,
'deep_scan': True,
'regenerate_thumbnails': regenerate_thumbnails,
'reapply_sidecars': reapply_sidecars,
'verify_hashes': verify_hashes
},
status='pending'
)
pending_tasks.append(('media', task))
stats['queued_media'] += 1
# Batch commit
if len(pending_tasks) >= BATCH_SIZE:
_flush_pending_tasks(pending_tasks)
pending_tasks = []
# Flush remaining tasks
if pending_tasks:
_flush_pending_tasks(pending_tasks)
# Update batch with totals
batch.total_files = stats['total_files']
db.session.commit()
log.info(f"Deep scan complete. Queued {stats['queued_media']} media, "
f"{stats['queued_archives']} archives. "
f"Skipped {stats['skipped_active']} active.")
return {
'batch_id': batch.id,
'stats': stats,
'mode': 'deep'
}
except Exception as e:
log.error(f"Deep scan failed: {e}", exc_info=True)
batch.status = 'failed'
db.session.commit()
raise
@celery.task(name='app.tasks.scan.recover_interrupted_tasks') @celery.task(name='app.tasks.scan.recover_interrupted_tasks')
+4
View File
@@ -26,6 +26,10 @@
🎭 {{ t.name.split(':', 1)[1] if ':' in t.name else t.name }} 🎭 {{ t.name.split(':', 1)[1] if ':' in t.name else t.name }}
{% elif t.kind == 'rating' %} {% elif t.kind == 'rating' %}
⚠️ {{ t.name.split(':', 1)[1] if ':' in t.name else t.name }} ⚠️ {{ t.name.split(':', 1)[1] if ':' in t.name else t.name }}
{% elif t.kind == 'source' %}
🌐 {{ t.name.split(':', 1)[1] if ':' in t.name else t.name }}
{% elif t.kind == 'post' %}
📌 {{ t.name.split(':', 1)[1] if ':' in t.name else t.name }}
{% else %} {% else %}
#{{ t.name }} #{{ t.name }}
{% endif %} {% endif %}
+64 -30
View File
@@ -2,6 +2,10 @@
A Flask-based image repository with Celery task processing for importing, organizing, and managing images/videos with tagging, duplicate detection, and Gallery-DL metadata integration. A Flask-based image repository with Celery task processing for importing, organizing, and managing images/videos with tagging, duplicate detection, and Gallery-DL metadata integration.
> **IMPORTANT**: This summary must be kept up to date with any code changes. Update the timestamp below when making modifications.
>
> **Last Updated**: 2026-01-31 (Scan optimization: quick/deep scan modes, archive tag merging)
--- ---
## Architecture Overview ## Architecture Overview
@@ -114,23 +118,40 @@ Task types: scan, import_image, import_archive, thumbnail, sidecar
### Scan Tasks (`app/tasks/scan.py`) ### Scan Tasks (`app/tasks/scan.py`)
| Function | Line | Purpose | Two scan modes are available:
|----------|------|---------| - **Quick scan** (default): Pre-loads existing task/hash data for O(1) lookups. Only queues truly new files. Skips pHash comparison for speed.
| `scan_directory()` | 29-154 | Walks `/import` directory, creates ImportTask records, queues import tasks | - **Deep scan** (on-demand): Full reprocessing of all files. Re-extracts metadata, runs pHash comparison, optionally regenerates thumbnails and re-applies sidecars.
| `_create_task_if_new()` | 157-228 | Creates ImportTask if file not already processed |
| `recover_interrupted_tasks()` | 231-302 | Re-queues stuck tasks (called periodically by Beat) | | Function | Purpose |
| `update_batch_stats()` | 305-349 | Updates ImportBatch statistics | |----------|---------|
| `_build_task_lookup()` | Pre-loads all ImportTask records into dict for O(1) checks (single query) |
| `_should_skip_file()` | Check if file should be skipped using pre-loaded lookup |
| `scan_directory()` | Quick scan - walks `/import`, uses O(1) lookups, batches task creation |
| `_flush_pending_tasks()` | Batch add tasks and commit for efficiency |
| `deep_scan_directory()` | Deep scan - full reprocessing, pHash comparison, optional thumbnail regen |
| `recover_interrupted_tasks()` | Re-queues stuck tasks (called periodically by Beat) |
| `cleanup_old_tasks()` | Deletes failed/skipped tasks older than 7 days (daily) |
| `update_batch_stats()` | Updates ImportBatch statistics |
### Import Tasks (`app/tasks/import_file.py`) ### Import Tasks (`app/tasks/import_file.py`)
| Function | Line | Purpose | Import tasks support two modes based on `context.deep_scan`:
|----------|------|---------| - **Quick mode** (default): Fast duplicate detection by content hash only. Skips pHash comparison.
| `import_media_file()` | 23-252 | Main import task - filters, deduplicates, copies, creates thumbnail | - **Deep mode**: Full reprocessing with pHash comparison, optional thumbnail regen, sidecar re-application.
| `import_archive()` | 255-407 | Extracts archive, queues child import tasks |
| `_fail_task()` | 410-425 | Marks task as failed | **Archive tag merging**: When a duplicate is found (by hash or pHash similarity), if the file came from an archive, the archive tag is added to the existing image. This ensures images are tagged with all archives they appear in.
| `_skip_task()` | 428-443 | Marks task as skipped |
| `_add_artist_tag()` | 446-459 | Adds artist:name tag to image | | Function | Purpose |
| `_supersede_existing()` | 472-539 | Replaces smaller image with larger version | |----------|---------|
| `_get_cached_settings()` | Cached import settings (5-min TTL) to avoid repeated DB reads |
| `import_media_file()` | Main import task - supports quick/deep modes, filters, deduplicates |
| `import_archive()` | Extracts archive, queues child import tasks with deep scan context |
| `_fail_task()` | Marks task as failed |
| `_skip_task()` | Marks task as skipped |
| `_add_artist_tag()` | Adds artist:name tag to image |
| `_add_archive_tag()` | Adds archive tag to image (for files from archives) |
| `_regenerate_thumbnail()` | Regenerate thumbnail for existing image (deep scan) |
| `_supersede_existing()` | Replaces smaller image with larger version |
### Thumbnail Tasks (`app/tasks/thumbnail.py`) ### Thumbnail Tasks (`app/tasks/thumbnail.py`)
@@ -231,6 +252,7 @@ Task types: scan, import_image, import_archive, thumbnail, sidecar
| Route | Line | Purpose | | Route | Line | Purpose |
|-------|------|---------| |-------|------|---------|
| `GET /api/tags/search` | 532-579 | Tag autocomplete search (supports `kind` filter) | | `GET /api/tags/search` | 532-579 | Tag autocomplete search (supports `kind` filter) |
| `GET /api/tags/list` | 535-555 | Paginated tag list for infinite scroll |
| `GET /image/<id>/tags` | 581-585 | List tags for image | | `GET /image/<id>/tags` | 581-585 | List tags for image |
| `POST /image/<id>/tags/add` | 587-615 | Add tag to image | | `POST /image/<id>/tags/add` | 587-615 | Add tag to image |
| `POST /image/<id>/tags/remove` | 617-633 | Remove tag from image | | `POST /image/<id>/tags/remove` | 617-633 | Remove tag from image |
@@ -265,14 +287,14 @@ Task types: scan, import_image, import_archive, thumbnail, sidecar
### Import Queue API ### Import Queue API
| Route | Line | Purpose | | Route | Purpose |
|-------|------|---------| |-------|---------|
| `POST /api/import/trigger` | 1069-1090 | Start directory scan | | `POST /api/import/trigger` | Start scan (params: `deep=true` for deep scan, `regenerate_thumbnails`, `reapply_sidecars`, `verify_hashes`) |
| `GET /api/import/status` | 1092-1151 | Queue status and recent tasks | | `GET /api/import/status` | Queue status and recent tasks |
| `GET /api/import/task/<id>` | 1153-1191 | Task details | | `GET /api/import/task/<id>` | Task details |
| `POST /api/import/retry-failed` | 1193-1231 | Retry failed tasks | | `POST /api/import/retry-failed` | Retry failed tasks |
| `POST /api/import/clear-completed` | 1233-1253 | Clear completed tasks | | `POST /api/import/clear-completed` | Clear completed tasks |
| `GET /api/import/batches` | 1432-1465 | List import batches | | `GET /api/import/batches` | List import batches |
### Settings API ### Settings API
@@ -370,14 +392,25 @@ Files are stored with hash suffix: `{filename}__{hash[:10]}.{ext}`
- `rating` - Content rating tags (prefix: `rating:level`) - `rating` - Content rating tags (prefix: `rating:level`)
### Import Pipeline ### Import Pipeline
1. `scan_directory` walks `/import/{artist}/` folders
2. Creates ImportTask for each file **Quick Scan (default)** - Optimized for speed:
3. `import_media_file` or `import_archive` processes file 1. `scan_directory` pre-loads all existing tasks in memory (single query)
4. Applies filters (dimensions, transparency, duplicates) 2. Walks `/import/{artist}/` folders with O(1) duplicate checks
5. Copies to `/images/{artist}/` with hash suffix 3. Batches task creation for efficiency
6. Generates thumbnail 4. `import_media_file` uses cached settings, skips pHash comparison
7. Applies sidecar metadata if found 5. Content hash duplicate detection only
8. Creates ImageRecord with tags 6. Copies new files to `/images/{artist}/` with hash suffix
7. Generates thumbnail and applies sidecar metadata
**Deep Scan (on-demand)** - Full reprocessing:
1. `deep_scan_directory` queues all files (only skips currently active)
2. Full metadata re-extraction
3. pHash calculation and similarity comparison
4. Optional thumbnail regeneration
5. Optional sidecar metadata re-application
6. Content hash verification
Trigger via API: `POST /api/import/trigger` with `deep=true`
### Sidecar Metadata ### Sidecar Metadata
Gallery-DL JSON files provide: Gallery-DL JSON files provide:
@@ -399,6 +432,7 @@ Gallery-DL JSON files provide:
| `DB_NAME` | imagerepo | Database name | | `DB_NAME` | imagerepo | Database name |
| `CELERY_BROKER_URL` | redis://redis:6379/0 | Redis broker | | `CELERY_BROKER_URL` | redis://redis:6379/0 | Redis broker |
| `CELERY_WORKER_CONCURRENCY` | 2 | Worker processes | | `CELERY_WORKER_CONCURRENCY` | 2 | Worker processes |
| `TZ` | America/New_York | Container timezone |
| `IMPORT_EVERY_SECONDS` | 28800 | Auto-scan interval (8h) | | `IMPORT_EVERY_SECONDS` | 28800 | Auto-scan interval (8h) |
| `IMPORT_MIN_WIDTH` | 0 | Minimum image width | | `IMPORT_MIN_WIDTH` | 0 | Minimum image width |
| `IMPORT_MIN_HEIGHT` | 0 | Minimum image height | | `IMPORT_MIN_HEIGHT` | 0 | Minimum image height |