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():
"""
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:
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')
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({
'ok': True,
'task_id': result.id,
'message': 'Import scan started'
'message': message,
'mode': 'deep' if is_deep else 'quick'
})
except Exception as e:
return jsonify({'ok': False, 'error': str(e)}), 500
+4 -2
View File
@@ -555,11 +555,13 @@
item.appendChild(overlay);
}
// Render tags
// Render tags with icons
overlay.innerHTML = visibleTags.map(t => {
const displayName = getTagDisplayName(t.name, t.kind);
const icon = getTagIcon(t.kind);
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('');
} catch (e) {
+18 -2
View File
@@ -187,6 +187,21 @@
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
function createImageElement(img) {
const item = document.createElement('div');
@@ -196,12 +211,13 @@
item.dataset.type = img.is_video ? 'video' : 'image';
item.dataset.filename = img.filename;
// Build tag overlay HTML
// Build tag overlay HTML with icons
let tagsHtml = '';
if (img.tags && img.tags.length > 0) {
const tagChips = img.tags.map(t => {
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>`;
}).join('');
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,
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:
+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,
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 logging
from datetime import datetime
from typing import Optional
from app.celery_app import celery
from app import db
from app.models import ImportTask, ImportBatch
from app.models import ImportTask, ImportBatch, ImageRecord
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')
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:
1. Walk directory tree under each artist folder
2. For each file, check if already in ImportTask with same path+size
3. If new/changed, create ImportTask and queue appropriate task
4. Handle archives specially (first volume only, queue as atomic unit)
1. Load existing ImportTask lookup dict (single query)
2. Walk directory tree under each artist folder
3. For each file, check lookup dict (no DB queries per file)
4. Queue new/changed files for processing
5. Handle archives specially (first volume only)
Args:
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.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
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,
}
# Collect tasks to create in batches
pending_tasks = []
BATCH_SIZE = 50 # Commit every 50 tasks
try:
# Iterate through artist directories
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'):
continue
# Get file size once (used for lookup)
try:
file_size = os.path.getsize(full_path)
except OSError:
continue
# Check if archive
if lower.endswith(ALLOWED_ARCHIVE_EXTS):
# 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
continue
# Create and queue archive task
task_record = _create_task_if_new(
# Check lookup dict (no DB query)
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,
task_type='import_archive',
file_size=file_size,
batch_id=batch.id,
context={'artist': artist_dir, 'dest_dir': dest_dir}
context={'artist': artist_dir, 'dest_dir': dest_dir},
status='pending'
)
if task_record:
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
pending_tasks.append(('archive', task))
stats['queued_archives'] += 1
continue
# 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
continue
# Create and queue media file task
task_record = _create_task_if_new(
# Check lookup dict (no DB query)
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,
task_type='import_image',
file_size=file_size,
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:
result = import_media_file.delay(task_record.id)
task_record.celery_task_id = result.id
task_record.status = 'queued'
db.session.commit()
stats['queued_media'] += 1
log.debug(f"Queued media: {filename}")
else:
stats['skipped_existing'] += 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"Scan complete. Queued {stats['queued_media']} media, "
log.info(f"Quick scan complete. Queued {stats['queued_media']} media, "
f"{stats['queued_archives']} archives. "
f"Skipped {stats['skipped_existing']} existing.")
@@ -154,78 +252,208 @@ def scan_directory(self, source_dir: str = '/import', dest_dir: str = '/images')
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.
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
Batch add tasks to session, commit, and queue Celery jobs.
"""
try:
file_size = os.path.getsize(source_path)
except OSError as e:
log.warning(f"Cannot access file {source_path}: {e}")
return None
from app.tasks.import_file import import_media_file, import_archive
# Check for existing pending/queued/processing task for same file
existing_active = ImportTask.query.filter_by(
source_path=source_path,
task_type=task_type
).filter(
ImportTask.status.in_(['pending', 'queued', 'processing'])
).first()
# Add all tasks to session
for task_type, task in pending_tasks:
db.session.add(task)
if existing_active:
log.debug(f"Task already active for: {source_path}")
return None
# Flush to get IDs
db.session.flush()
# Check for completed task with same file path and size
# If size changed, we should re-import
completed_same = ImportTask.query.filter_by(
source_path=source_path,
task_type=task_type,
file_size=file_size,
status='complete'
).first()
# Queue Celery jobs
for task_type, task in pending_tasks:
if task_type == 'archive':
result = import_archive.delay(task.id)
else:
result = import_media_file.delay(task.id)
task.celery_task_id = result.id
task.status = 'queued'
if completed_same:
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)
# Commit batch
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')
+4
View File
@@ -26,6 +26,10 @@
🎭 {{ t.name.split(':', 1)[1] if ':' in t.name else t.name }}
{% elif t.kind == 'rating' %}
⚠️ {{ 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 %}
#{{ t.name }}
{% 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.
> **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
@@ -114,23 +118,40 @@ Task types: scan, import_image, import_archive, thumbnail, sidecar
### Scan Tasks (`app/tasks/scan.py`)
| Function | Line | Purpose |
|----------|------|---------|
| `scan_directory()` | 29-154 | Walks `/import` directory, creates ImportTask records, queues import tasks |
| `_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) |
| `update_batch_stats()` | 305-349 | Updates ImportBatch statistics |
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.
- **Deep scan** (on-demand): Full reprocessing of all files. Re-extracts metadata, runs pHash comparison, optionally regenerates thumbnails and re-applies sidecars.
| Function | Purpose |
|----------|---------|
| `_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`)
| Function | Line | Purpose |
|----------|------|---------|
| `import_media_file()` | 23-252 | Main import task - filters, deduplicates, copies, creates thumbnail |
| `import_archive()` | 255-407 | Extracts archive, queues child import tasks |
| `_fail_task()` | 410-425 | Marks task as failed |
| `_skip_task()` | 428-443 | Marks task as skipped |
| `_add_artist_tag()` | 446-459 | Adds artist:name tag to image |
| `_supersede_existing()` | 472-539 | Replaces smaller image with larger version |
Import tasks support two modes based on `context.deep_scan`:
- **Quick mode** (default): Fast duplicate detection by content hash only. Skips pHash comparison.
- **Deep mode**: Full reprocessing with pHash comparison, optional thumbnail regen, sidecar re-application.
**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.
| Function | Purpose |
|----------|---------|
| `_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`)
@@ -231,6 +252,7 @@ Task types: scan, import_image, import_archive, thumbnail, sidecar
| Route | Line | Purpose |
|-------|------|---------|
| `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 |
| `POST /image/<id>/tags/add` | 587-615 | Add tag to 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
| Route | Line | Purpose |
|-------|------|---------|
| `POST /api/import/trigger` | 1069-1090 | Start directory scan |
| `GET /api/import/status` | 1092-1151 | Queue status and recent tasks |
| `GET /api/import/task/<id>` | 1153-1191 | Task details |
| `POST /api/import/retry-failed` | 1193-1231 | Retry failed tasks |
| `POST /api/import/clear-completed` | 1233-1253 | Clear completed tasks |
| `GET /api/import/batches` | 1432-1465 | List import batches |
| Route | Purpose |
|-------|---------|
| `POST /api/import/trigger` | Start scan (params: `deep=true` for deep scan, `regenerate_thumbnails`, `reapply_sidecars`, `verify_hashes`) |
| `GET /api/import/status` | Queue status and recent tasks |
| `GET /api/import/task/<id>` | Task details |
| `POST /api/import/retry-failed` | Retry failed tasks |
| `POST /api/import/clear-completed` | Clear completed tasks |
| `GET /api/import/batches` | List import batches |
### Settings API
@@ -370,14 +392,25 @@ Files are stored with hash suffix: `{filename}__{hash[:10]}.{ext}`
- `rating` - Content rating tags (prefix: `rating:level`)
### Import Pipeline
1. `scan_directory` walks `/import/{artist}/` folders
2. Creates ImportTask for each file
3. `import_media_file` or `import_archive` processes file
4. Applies filters (dimensions, transparency, duplicates)
5. Copies to `/images/{artist}/` with hash suffix
6. Generates thumbnail
7. Applies sidecar metadata if found
8. Creates ImageRecord with tags
**Quick Scan (default)** - Optimized for speed:
1. `scan_directory` pre-loads all existing tasks in memory (single query)
2. Walks `/import/{artist}/` folders with O(1) duplicate checks
3. Batches task creation for efficiency
4. `import_media_file` uses cached settings, skips pHash comparison
5. Content hash duplicate detection only
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
Gallery-DL JSON files provide:
@@ -399,6 +432,7 @@ Gallery-DL JSON files provide:
| `DB_NAME` | imagerepo | Database name |
| `CELERY_BROKER_URL` | redis://redis:6379/0 | Redis broker |
| `CELERY_WORKER_CONCURRENCY` | 2 | Worker processes |
| `TZ` | America/New_York | Container timezone |
| `IMPORT_EVERY_SECONDS` | 28800 | Auto-scan interval (8h) |
| `IMPORT_MIN_WIDTH` | 0 | Minimum image width |
| `IMPORT_MIN_HEIGHT` | 0 | Minimum image height |