This repository has been archived on 2026-05-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
imagerepo/app/tasks/scan.py
T

350 lines
12 KiB
Python

# app/tasks/scan.py
"""
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.
"""
import os
import logging
from datetime import datetime
from app.celery_app import celery
from app import db
from app.models import ImportTask, ImportBatch
log = logging.getLogger('celery.tasks.scan')
# Supported file extensions (imported from image_importer for consistency)
ALLOWED_MEDIA_EXTS = (
'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif',
'.mp4', '.mov', '.webm', '.avi', '.mkv'
)
ALLOWED_ARCHIVE_EXTS = (
'.zip', '.rar', '.7z', '.tar', '.tar.gz', '.tgz', '.tar.bz2', '.tbz2',
'.cbr', '.cbz'
)
@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.
Creates ImportTask records for each file and queues them for processing.
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)
Args:
source_dir: Root directory to scan (default: /import)
dest_dir: Destination for imported files (default: /images)
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 directory scan: {source_dir}")
# Create batch record
batch = ImportBatch(source_directory=source_dir)
db.session.add(batch)
db.session.commit()
stats = {
'total_files': 0,
'queued_media': 0,
'queued_archives': 0,
'skipped_existing': 0,
'skipped_non_first_volume': 0,
'skipped_non_media': 0,
}
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"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
# Check if archive
if lower.endswith(ALLOWED_ARCHIVE_EXTS):
# Only process first volume of multi-part archives
if not is_first_volume(full_path):
stats['skipped_non_first_volume'] += 1
continue
# Create and queue archive task
task_record = _create_task_if_new(
source_path=full_path,
task_type='import_archive',
batch_id=batch.id,
context={'artist': artist_dir, 'dest_dir': dest_dir}
)
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
continue
# Check if media file
if not lower.endswith(ALLOWED_MEDIA_EXTS):
stats['skipped_non_media'] += 1
continue
# Create and queue media file task
task_record = _create_task_if_new(
source_path=full_path,
task_type='import_image',
batch_id=batch.id,
context={'artist': artist_dir, 'dest_dir': dest_dir}
)
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
# Update batch with totals
batch.total_files = stats['total_files']
db.session.commit()
log.info(f"Scan complete. Queued {stats['queued_media']} media, "
f"{stats['queued_archives']} archives. "
f"Skipped {stats['skipped_existing']} existing.")
return {
'batch_id': batch.id,
'stats': stats
}
except Exception as e:
log.error(f"Scan failed: {e}", exc_info=True)
batch.status = 'failed'
db.session.commit()
raise
def _create_task_if_new(source_path: str, task_type: str, batch_id: int, context: dict) -> ImportTask:
"""
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
"""
try:
file_size = os.path.getsize(source_path)
except OSError as e:
log.warning(f"Cannot access file {source_path}: {e}")
return None
# Check for existing pending/queued/processing task for same file
existing_active = ImportTask.query.filter_by(
source_path=source_path,
task_type=task_type
).filter(
ImportTask.status.in_(['pending', 'queued', 'processing'])
).first()
if existing_active:
log.debug(f"Task already active for: {source_path}")
return None
# 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()
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)
db.session.commit()
return task
@celery.task(name='app.tasks.scan.recover_interrupted_tasks')
def recover_interrupted_tasks():
"""
Find and re-queue tasks that were interrupted.
Called periodically by Celery Beat to recover from worker crashes.
Tasks with status 'processing' or 'queued' that have no active
Celery task are re-queued.
Returns:
dict with count of recovered tasks
"""
from app.tasks.import_file import import_media_file, import_archive
from app.tasks.thumbnail import generate_thumbnail_task
log.info("Checking for interrupted tasks...")
# Find tasks that are stuck in processing/queued state
# We look for tasks that have been in this state for more than 10 minutes
cutoff = datetime.utcnow()
interrupted = ImportTask.query.filter(
ImportTask.status.in_(['processing', 'queued'])
).all()
recovered = 0
for task in interrupted:
# Check if Celery task is still active
if task.celery_task_id:
from app.celery_app import celery as celery_app
result = celery_app.AsyncResult(task.celery_task_id)
# If task is still pending/started, don't re-queue
if result.state in ('PENDING', 'STARTED', 'RETRY'):
continue
log.info(f"Recovering task {task.id}: {task.source_path}")
# Reset task state
task.status = 'pending'
task.celery_task_id = None
task.started_at = None
task.retry_count += 1
# Re-queue based on type
if task.task_type == 'import_image':
result = import_media_file.delay(task.id)
elif task.task_type == 'import_archive':
result = import_archive.delay(task.id)
elif task.task_type == 'thumbnail':
# Thumbnail tasks store image_id in context
image_id = task.context.get('image_id') if task.context else None
if image_id:
result = generate_thumbnail_task.delay(image_id)
else:
task.status = 'failed'
task.error_message = 'Missing image_id in context'
db.session.commit()
continue
else:
log.warning(f"Unknown task type: {task.task_type}")
continue
task.celery_task_id = result.id
task.status = 'queued'
recovered += 1
db.session.commit()
if recovered > 0:
log.info(f"Recovered {recovered} interrupted tasks")
return {'recovered': recovered}
@celery.task(name='app.tasks.scan.update_batch_stats')
def update_batch_stats(batch_id: int):
"""
Update statistics for an ImportBatch based on its tasks.
Args:
batch_id: ID of the batch to update
"""
batch = ImportBatch.query.get(batch_id)
if not batch:
return
from sqlalchemy import func
# Count tasks by status
status_counts = dict(
db.session.query(
ImportTask.status,
func.count(ImportTask.id)
).filter(
ImportTask.batch_id == batch_id
).group_by(ImportTask.status).all()
)
batch.processed_files = sum(
status_counts.get(s, 0)
for s in ['complete', 'skipped', 'failed']
)
batch.imported_count = status_counts.get('complete', 0)
batch.skipped_count = status_counts.get('skipped', 0)
batch.failed_count = status_counts.get('failed', 0)
# Check if batch is complete
pending_or_active = sum(
status_counts.get(s, 0)
for s in ['pending', 'queued', 'processing']
)
if pending_or_active == 0 and batch.status == 'running':
batch.status = 'complete'
batch.completed_at = datetime.utcnow()
log.info(f"Batch {batch_id} complete: {batch.imported_count} imported, "
f"{batch.skipped_count} skipped, {batch.failed_count} failed")
db.session.commit()