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
2026-01-28 09:36:59 -05:00

408 lines
13 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.cleanup_old_tasks')
def cleanup_old_tasks(days: int = 7):
"""
Clean up old failed and skipped import task records.
Called periodically by Celery Beat to prevent database bloat.
Deletes ImportTask records with status 'failed' or 'skipped'
that are older than the specified number of days.
Also cleans up orphaned ImportBatch records that have no tasks.
Args:
days: Number of days to retain failed/skipped tasks (default: 7)
Returns:
dict with counts of deleted records
"""
from datetime import timedelta
cutoff = datetime.utcnow() - timedelta(days=days)
log.info(f"Cleaning up failed/skipped tasks older than {days} days (before {cutoff})")
# Delete old failed tasks
failed_deleted = ImportTask.query.filter(
ImportTask.status == 'failed',
ImportTask.completed_at < cutoff
).delete(synchronize_session=False)
# Delete old skipped tasks
skipped_deleted = ImportTask.query.filter(
ImportTask.status == 'skipped',
ImportTask.completed_at < cutoff
).delete(synchronize_session=False)
db.session.commit()
# Clean up orphaned batches (batches with no tasks)
orphaned_batches = ImportBatch.query.filter(
~ImportBatch.tasks.any()
).all()
batches_deleted = len(orphaned_batches)
for batch in orphaned_batches:
db.session.delete(batch)
db.session.commit()
if failed_deleted or skipped_deleted or batches_deleted:
log.info(f"Cleanup complete: {failed_deleted} failed tasks, "
f"{skipped_deleted} skipped tasks, {batches_deleted} orphaned batches deleted")
return {
'failed_deleted': failed_deleted,
'skipped_deleted': skipped_deleted,
'batches_deleted': batches_deleted
}
@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()