738 lines
25 KiB
Python
738 lines
25 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.
|
|
|
|
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, ImageRecord
|
|
|
|
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'
|
|
)
|
|
|
|
|
|
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'):
|
|
"""
|
|
Quick scan: Efficiently detect and queue new/changed files.
|
|
|
|
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. 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)
|
|
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 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)
|
|
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,
|
|
}
|
|
|
|
# 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):
|
|
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
|
|
|
|
# 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
|
|
if not is_first_volume(full_path):
|
|
stats['skipped_non_first_volume'] += 1
|
|
continue
|
|
|
|
# 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},
|
|
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
|
|
|
|
# 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},
|
|
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"Quick 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 _flush_pending_tasks(pending_tasks: list):
|
|
"""
|
|
Batch add tasks to session, commit, and queue Celery jobs.
|
|
"""
|
|
from app.tasks.import_file import import_media_file, import_archive
|
|
|
|
# Add all tasks to session
|
|
for task_type, task in pending_tasks:
|
|
db.session.add(task)
|
|
|
|
# Flush to get IDs
|
|
db.session.flush()
|
|
|
|
# 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'
|
|
|
|
# Commit batch
|
|
db.session.commit()
|
|
|
|
|
|
@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')
|
|
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()
|
|
|
|
|
|
@celery.task(name='app.tasks.scan.update_system_stats')
|
|
def update_system_stats():
|
|
"""
|
|
Compute and cache system-wide statistics for the dashboard.
|
|
|
|
Called periodically by Celery Beat to avoid expensive queries on page load.
|
|
Stores results in AppSettings as JSON for quick retrieval.
|
|
|
|
Stats computed:
|
|
- Total images and storage usage
|
|
- Tag counts by kind
|
|
- Pending files/archives in import folder
|
|
|
|
Returns:
|
|
dict with computed stats
|
|
"""
|
|
import json
|
|
from sqlalchemy import func
|
|
from app.models import ImageRecord, Tag, AppSettings
|
|
|
|
log.info("Updating system stats cache...")
|
|
|
|
try:
|
|
# Database stats
|
|
total_images = ImageRecord.query.count()
|
|
total_tags = Tag.query.count()
|
|
|
|
# Tag kind breakdown
|
|
tag_kinds = dict(
|
|
db.session.query(Tag.kind, func.count(Tag.id))
|
|
.group_by(Tag.kind).all()
|
|
)
|
|
|
|
# Storage stats (sum of file_size column)
|
|
storage_result = db.session.query(func.sum(ImageRecord.file_size)).scalar()
|
|
total_storage_bytes = storage_result or 0
|
|
|
|
# Import folder stats
|
|
import_dir = '/import'
|
|
pending_files = 0
|
|
pending_archives = 0
|
|
if os.path.exists(import_dir):
|
|
for artist_dir in os.listdir(import_dir):
|
|
artist_path = os.path.join(import_dir, artist_dir)
|
|
if not os.path.isdir(artist_path):
|
|
continue
|
|
for root, _, files in os.walk(artist_path):
|
|
for f in files:
|
|
lower = f.lower()
|
|
if lower.endswith(ALLOWED_MEDIA_EXTS):
|
|
pending_files += 1
|
|
elif lower.endswith(ALLOWED_ARCHIVE_EXTS):
|
|
pending_archives += 1
|
|
|
|
# Format storage for display
|
|
storage_human = _format_bytes(total_storage_bytes)
|
|
|
|
# Build stats dict
|
|
stats = {
|
|
'images': {
|
|
'total': total_images,
|
|
'storage_bytes': total_storage_bytes,
|
|
'storage_human': storage_human
|
|
},
|
|
'tags': {
|
|
'total': total_tags,
|
|
'by_kind': tag_kinds
|
|
},
|
|
'import_folder': {
|
|
'pending_files': pending_files,
|
|
'pending_archives': pending_archives
|
|
},
|
|
'updated_at': datetime.utcnow().isoformat()
|
|
}
|
|
|
|
# Store in AppSettings
|
|
setting = AppSettings.query.filter_by(key='system_stats_cache').first()
|
|
if not setting:
|
|
setting = AppSettings(key='system_stats_cache')
|
|
db.session.add(setting)
|
|
setting.value = json.dumps(stats)
|
|
db.session.commit()
|
|
|
|
log.info(f"System stats updated: {total_images} images, {total_tags} tags, "
|
|
f"{storage_human} storage, {pending_files + pending_archives} pending")
|
|
|
|
return stats
|
|
|
|
except Exception as e:
|
|
log.error(f"Failed to update system stats: {e}", exc_info=True)
|
|
raise
|
|
|
|
|
|
def _format_bytes(size_bytes: int) -> str:
|
|
"""Format bytes to human readable string."""
|
|
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
|
|
if size_bytes < 1024:
|
|
return f"{size_bytes:.1f} {unit}"
|
|
size_bytes /= 1024
|
|
return f"{size_bytes:.1f} PB"
|