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:
+35
-4
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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')
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user