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 %}
|
||||
|
||||
Reference in New Issue
Block a user