tuning job log views and deep scan archive processing.
This commit is contained in:
+17
-1
@@ -57,8 +57,20 @@ def make_celery(app=None):
|
|||||||
worker_prefetch_multiplier=1, # One task at a time per worker process
|
worker_prefetch_multiplier=1, # One task at a time per worker process
|
||||||
|
|
||||||
# Task routing - separate queues for different task types
|
# Task routing - separate queues for different task types
|
||||||
|
# Scheduler handles: maintenance (periodic tasks) + scan (directory scans)
|
||||||
|
# Worker handles: import, thumbnail, sidecar, default
|
||||||
task_routes={
|
task_routes={
|
||||||
'app.tasks.scan.*': {'queue': 'scan'},
|
# Scan tasks - handled by scheduler
|
||||||
|
'app.tasks.scan.scan_directory': {'queue': 'scan'},
|
||||||
|
'app.tasks.scan.deep_scan_directory': {'queue': 'scan'},
|
||||||
|
|
||||||
|
# Maintenance tasks - handled by scheduler (periodic/lightweight)
|
||||||
|
'app.tasks.scan.recover_interrupted_tasks': {'queue': 'maintenance'},
|
||||||
|
'app.tasks.scan.cleanup_old_tasks': {'queue': 'maintenance'},
|
||||||
|
'app.tasks.scan.update_system_stats': {'queue': 'maintenance'},
|
||||||
|
'app.tasks.scan.update_batch_stats': {'queue': 'maintenance'},
|
||||||
|
|
||||||
|
# Import tasks - handled by worker (heavy processing)
|
||||||
'app.tasks.import_file.*': {'queue': 'import'},
|
'app.tasks.import_file.*': {'queue': 'import'},
|
||||||
'app.tasks.thumbnail.*': {'queue': 'thumbnail'},
|
'app.tasks.thumbnail.*': {'queue': 'thumbnail'},
|
||||||
'app.tasks.sidecar.*': {'queue': 'sidecar'},
|
'app.tasks.sidecar.*': {'queue': 'sidecar'},
|
||||||
@@ -94,6 +106,10 @@ def make_celery(app=None):
|
|||||||
'schedule': 86400, # Once per day
|
'schedule': 86400, # Once per day
|
||||||
'args': (7,), # Keep tasks for 7 days
|
'args': (7,), # Keep tasks for 7 days
|
||||||
},
|
},
|
||||||
|
'update-system-stats': {
|
||||||
|
'task': 'app.tasks.scan.update_system_stats',
|
||||||
|
'schedule': 21600, # Every 6 hours
|
||||||
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+198
-9
@@ -7,7 +7,7 @@ from sqlalchemy.orm import joinedload, aliased
|
|||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from app.models import ImageRecord, Tag, ArchiveRecord, image_tags, ImportTask, ImportBatch
|
from app.models import ImageRecord, Tag, ArchiveRecord, image_tags, ImportTask, ImportBatch, AppSettings
|
||||||
from app import db
|
from app import db
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
@@ -1225,6 +1225,91 @@ def import_queue_status():
|
|||||||
return jsonify({'ok': False, 'error': str(e)}), 500
|
return jsonify({'ok': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@main.route('/api/import/tasks')
|
||||||
|
def list_import_tasks():
|
||||||
|
"""
|
||||||
|
List import tasks with filtering and pagination.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
status: Filter by status (comma-separated, e.g., 'failed,skipped')
|
||||||
|
task_type: Filter by task type ('import_image', 'import_archive')
|
||||||
|
search: Search in source_path or error_message
|
||||||
|
limit: Max results (default 50, max 200)
|
||||||
|
offset: Skip first N results
|
||||||
|
sort: Sort field ('created_at', 'completed_at', default: 'created_at')
|
||||||
|
order: Sort order ('asc', 'desc', default: 'desc')
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Parse parameters
|
||||||
|
status_filter = request.args.get('status', '')
|
||||||
|
task_type = request.args.get('task_type', '')
|
||||||
|
search = request.args.get('search', '')
|
||||||
|
limit = min(int(request.args.get('limit', 50)), 200)
|
||||||
|
offset = int(request.args.get('offset', 0))
|
||||||
|
sort_field = request.args.get('sort', 'created_at')
|
||||||
|
sort_order = request.args.get('order', 'desc')
|
||||||
|
|
||||||
|
# Build query
|
||||||
|
query = ImportTask.query
|
||||||
|
|
||||||
|
# Filter by status
|
||||||
|
if status_filter:
|
||||||
|
statuses = [s.strip() for s in status_filter.split(',') if s.strip()]
|
||||||
|
if statuses:
|
||||||
|
query = query.filter(ImportTask.status.in_(statuses))
|
||||||
|
|
||||||
|
# Filter by task type
|
||||||
|
if task_type:
|
||||||
|
query = query.filter_by(task_type=task_type)
|
||||||
|
|
||||||
|
# Search in source_path or error_message
|
||||||
|
if search:
|
||||||
|
search_pattern = f'%{search}%'
|
||||||
|
query = query.filter(
|
||||||
|
db.or_(
|
||||||
|
ImportTask.source_path.ilike(search_pattern),
|
||||||
|
ImportTask.error_message.ilike(search_pattern)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get total count before pagination
|
||||||
|
total = query.count()
|
||||||
|
|
||||||
|
# Sort
|
||||||
|
sort_col = getattr(ImportTask, sort_field, ImportTask.created_at)
|
||||||
|
if sort_order == 'asc':
|
||||||
|
query = query.order_by(sort_col.asc())
|
||||||
|
else:
|
||||||
|
query = query.order_by(sort_col.desc())
|
||||||
|
|
||||||
|
# Paginate
|
||||||
|
tasks = query.offset(offset).limit(limit).all()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'ok': True,
|
||||||
|
'total': total,
|
||||||
|
'offset': offset,
|
||||||
|
'limit': limit,
|
||||||
|
'tasks': [
|
||||||
|
{
|
||||||
|
'id': t.id,
|
||||||
|
'source_path': t.source_path,
|
||||||
|
'filename': os.path.basename(t.source_path) if t.source_path else None,
|
||||||
|
'task_type': t.task_type,
|
||||||
|
'status': t.status,
|
||||||
|
'error_message': t.error_message, # Full error message
|
||||||
|
'file_size': t.file_size,
|
||||||
|
'retry_count': t.retry_count,
|
||||||
|
'created_at': t.created_at.isoformat() if t.created_at else None,
|
||||||
|
'completed_at': t.completed_at.isoformat() if t.completed_at else None
|
||||||
|
}
|
||||||
|
for t in tasks
|
||||||
|
]
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'ok': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@main.route('/api/import/task/<int:task_id>')
|
@main.route('/api/import/task/<int:task_id>')
|
||||||
def get_import_task(task_id):
|
def get_import_task(task_id):
|
||||||
"""
|
"""
|
||||||
@@ -1308,25 +1393,129 @@ def retry_failed_import_tasks():
|
|||||||
@main.route('/api/import/clear-completed', methods=['POST'])
|
@main.route('/api/import/clear-completed', methods=['POST'])
|
||||||
def clear_completed_tasks():
|
def clear_completed_tasks():
|
||||||
"""
|
"""
|
||||||
Clear completed and skipped import tasks from the database.
|
Clear import tasks from the database with optional age filtering.
|
||||||
Keeps failed tasks for review.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
deleted = ImportTask.query.filter(
|
|
||||||
ImportTask.status.in_(['complete', 'skipped'])
|
|
||||||
).delete(synchronize_session=False)
|
|
||||||
|
|
||||||
|
POST params:
|
||||||
|
statuses: Comma-separated statuses to clear (default: complete,skipped)
|
||||||
|
older_than_days: Only clear tasks older than N days (default: 0 = all)
|
||||||
|
"""
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Parse parameters
|
||||||
|
statuses_param = request.form.get('statuses', 'complete,skipped')
|
||||||
|
older_than_days = int(request.form.get('older_than_days', 0))
|
||||||
|
|
||||||
|
# Validate statuses
|
||||||
|
allowed_statuses = {'complete', 'skipped', 'failed'}
|
||||||
|
statuses = [s.strip() for s in statuses_param.split(',') if s.strip() in allowed_statuses]
|
||||||
|
if not statuses:
|
||||||
|
statuses = ['complete', 'skipped']
|
||||||
|
|
||||||
|
# Build query
|
||||||
|
query = ImportTask.query.filter(ImportTask.status.in_(statuses))
|
||||||
|
|
||||||
|
# Apply age filter if specified
|
||||||
|
if older_than_days > 0:
|
||||||
|
cutoff = datetime.utcnow() - timedelta(days=older_than_days)
|
||||||
|
query = query.filter(ImportTask.completed_at < cutoff)
|
||||||
|
|
||||||
|
deleted = query.delete(synchronize_session=False)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'ok': True,
|
'ok': True,
|
||||||
'deleted': deleted
|
'deleted': deleted,
|
||||||
|
'statuses': statuses,
|
||||||
|
'older_than_days': older_than_days
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
return jsonify({'ok': False, 'error': str(e)}), 500
|
return jsonify({'ok': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@main.route('/api/import/task-stats')
|
||||||
|
def import_task_stats():
|
||||||
|
"""
|
||||||
|
Get detailed task statistics with age breakdown.
|
||||||
|
"""
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
try:
|
||||||
|
now = datetime.utcnow()
|
||||||
|
today = now - timedelta(days=1)
|
||||||
|
week_ago = now - timedelta(days=7)
|
||||||
|
|
||||||
|
stats = {}
|
||||||
|
for status in ['complete', 'skipped', 'failed']:
|
||||||
|
total = ImportTask.query.filter_by(status=status).count()
|
||||||
|
today_count = ImportTask.query.filter(
|
||||||
|
ImportTask.status == status,
|
||||||
|
ImportTask.completed_at >= today
|
||||||
|
).count()
|
||||||
|
week_count = ImportTask.query.filter(
|
||||||
|
ImportTask.status == status,
|
||||||
|
ImportTask.completed_at >= week_ago,
|
||||||
|
ImportTask.completed_at < today
|
||||||
|
).count()
|
||||||
|
older = total - today_count - week_count
|
||||||
|
|
||||||
|
stats[status] = {
|
||||||
|
'total': total,
|
||||||
|
'today': today_count,
|
||||||
|
'this_week': week_count,
|
||||||
|
'older': older
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonify({'ok': True, 'stats': stats})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'ok': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@main.route('/api/system/stats')
|
||||||
|
def system_stats():
|
||||||
|
"""
|
||||||
|
Get system-wide statistics for the dashboard.
|
||||||
|
|
||||||
|
Returns cached stats computed by the update_system_stats Celery task.
|
||||||
|
Stats are updated every 5 minutes by Celery Beat.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
refresh: If 'true', triggers an immediate stats update (async)
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check for refresh request
|
||||||
|
if request.args.get('refresh') == 'true':
|
||||||
|
from app.tasks.scan import update_system_stats
|
||||||
|
update_system_stats.delay()
|
||||||
|
|
||||||
|
# Get cached stats from AppSettings
|
||||||
|
setting = AppSettings.query.filter_by(key='system_stats_cache').first()
|
||||||
|
|
||||||
|
if setting and setting.value:
|
||||||
|
stats = json.loads(setting.value)
|
||||||
|
return jsonify({
|
||||||
|
'ok': True,
|
||||||
|
'cached': True,
|
||||||
|
**stats
|
||||||
|
})
|
||||||
|
|
||||||
|
# No cached stats - return empty with flag to indicate first run
|
||||||
|
return jsonify({
|
||||||
|
'ok': True,
|
||||||
|
'cached': False,
|
||||||
|
'images': {'total': 0, 'storage_bytes': 0, 'storage_human': '0 B'},
|
||||||
|
'tags': {'total': 0, 'by_kind': {}},
|
||||||
|
'import_folder': {'pending_files': 0, 'pending_archives': 0},
|
||||||
|
'message': 'Stats not yet computed. Will be available shortly.'
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'ok': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@main.route('/api/thumbnails/regenerate', methods=['POST'])
|
@main.route('/api/thumbnails/regenerate', methods=['POST'])
|
||||||
def regenerate_thumbnails_celery():
|
def regenerate_thumbnails_celery():
|
||||||
"""
|
"""
|
||||||
|
|||||||
+150
-42
@@ -51,6 +51,58 @@ def _get_cached_settings() -> dict:
|
|||||||
return _settings_cache
|
return _settings_cache
|
||||||
|
|
||||||
|
|
||||||
|
def _find_best_sidecar(src_path: str, archive_path: str = None,
|
||||||
|
archive_sidecar_path: str = None) -> str | None:
|
||||||
|
"""
|
||||||
|
Find the best sidecar JSON for a file, checking multiple locations.
|
||||||
|
|
||||||
|
For files extracted from archives, checks:
|
||||||
|
1. Per-file sidecar next to extracted file (temp dir)
|
||||||
|
2. Per-file sidecar next to original archive (by filename match)
|
||||||
|
3. Archive's sidecar as fallback (applies to all files in archive)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
src_path: Path to the source file
|
||||||
|
archive_path: Path to original archive (if extracted from archive)
|
||||||
|
archive_sidecar_path: Path to archive's sidecar JSON
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the best sidecar JSON found, or None
|
||||||
|
"""
|
||||||
|
from app.utils.metadata_enrichment import find_sidecar_json
|
||||||
|
import re
|
||||||
|
|
||||||
|
# First try: per-file sidecar next to the source file
|
||||||
|
sidecar = find_sidecar_json(src_path)
|
||||||
|
if sidecar:
|
||||||
|
return sidecar
|
||||||
|
|
||||||
|
# Second try: per-file sidecar next to original archive
|
||||||
|
if archive_path:
|
||||||
|
from pathlib import Path
|
||||||
|
archive_dir = Path(archive_path).parent
|
||||||
|
filename = Path(src_path).name
|
||||||
|
stem = Path(src_path).stem
|
||||||
|
|
||||||
|
# Check for exact stem match
|
||||||
|
per_file_json = archive_dir / f"{stem}.json"
|
||||||
|
if per_file_json.exists():
|
||||||
|
return str(per_file_json)
|
||||||
|
|
||||||
|
# Check for stem without numeric prefix
|
||||||
|
stem_no_prefix = re.sub(r'^\d+_', '', stem)
|
||||||
|
if stem_no_prefix != stem:
|
||||||
|
alt_json = archive_dir / f"{stem_no_prefix}.json"
|
||||||
|
if alt_json.exists():
|
||||||
|
return str(alt_json)
|
||||||
|
|
||||||
|
# Third try: archive-level sidecar (fallback for all files)
|
||||||
|
if archive_sidecar_path and os.path.exists(archive_sidecar_path):
|
||||||
|
return archive_sidecar_path
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@celery.task(bind=True, name='app.tasks.import_file.import_media_file',
|
@celery.task(bind=True, name='app.tasks.import_file.import_media_file',
|
||||||
max_retries=3, default_retry_delay=60)
|
max_retries=3, default_retry_delay=60)
|
||||||
def import_media_file(self, task_id: int):
|
def import_media_file(self, task_id: int):
|
||||||
@@ -88,8 +140,6 @@ def import_media_file(self, task_id: int):
|
|||||||
generate_thumbnail, generate_video_thumbnail_mirrored,
|
generate_thumbnail, generate_video_thumbnail_mirrored,
|
||||||
transcode_video_to_mp4, needs_transcode
|
transcode_video_to_mp4, needs_transcode
|
||||||
)
|
)
|
||||||
from app.utils.metadata_enrichment import find_sidecar_json
|
|
||||||
|
|
||||||
task = ImportTask.query.get(task_id)
|
task = ImportTask.query.get(task_id)
|
||||||
if not task:
|
if not task:
|
||||||
log.error(f"Task {task_id} not found")
|
log.error(f"Task {task_id} not found")
|
||||||
@@ -111,6 +161,10 @@ def import_media_file(self, task_id: int):
|
|||||||
reapply_sidecars = context.get('reapply_sidecars', False)
|
reapply_sidecars = context.get('reapply_sidecars', False)
|
||||||
verify_hashes = context.get('verify_hashes', False)
|
verify_hashes = context.get('verify_hashes', False)
|
||||||
|
|
||||||
|
# Archive sidecar (for files extracted from archives)
|
||||||
|
archive_sidecar_path = context.get('archive_sidecar_path')
|
||||||
|
archive_path = context.get('archive_path')
|
||||||
|
|
||||||
log.info(f"Processing: {src_path} (deep_scan={is_deep_scan})")
|
log.info(f"Processing: {src_path} (deep_scan={is_deep_scan})")
|
||||||
|
|
||||||
# Load settings with caching (avoids repeated DB reads)
|
# Load settings with caching (avoids repeated DB reads)
|
||||||
@@ -166,7 +220,8 @@ def import_media_file(self, task_id: int):
|
|||||||
|
|
||||||
# In deep scan mode with reapply_sidecars, always try to enrich
|
# In deep scan mode with reapply_sidecars, always try to enrich
|
||||||
# In quick mode, also try if sidecar exists
|
# In quick mode, also try if sidecar exists
|
||||||
sidecar_path = find_sidecar_json(src_path)
|
# Uses archive sidecar as fallback for files from archives
|
||||||
|
sidecar_path = _find_best_sidecar(src_path, archive_path, archive_sidecar_path)
|
||||||
if sidecar_path and (is_deep_scan and reapply_sidecars or not is_deep_scan):
|
if sidecar_path and (is_deep_scan and reapply_sidecars or not is_deep_scan):
|
||||||
apply_sidecar_metadata.delay(existing.id, sidecar_path)
|
apply_sidecar_metadata.delay(existing.id, sidecar_path)
|
||||||
|
|
||||||
@@ -209,7 +264,8 @@ def import_media_file(self, task_id: int):
|
|||||||
log.info(f"Added archive tag {archive_tag_id} to similar image {similar_id}")
|
log.info(f"Added archive tag {archive_tag_id} to similar image {similar_id}")
|
||||||
|
|
||||||
# Still try to enrich the existing larger image with sidecar metadata
|
# Still try to enrich the existing larger image with sidecar metadata
|
||||||
sidecar_path = find_sidecar_json(src_path)
|
# Uses archive sidecar as fallback for files from archives
|
||||||
|
sidecar_path = _find_best_sidecar(src_path, archive_path, archive_sidecar_path)
|
||||||
if sidecar_path and similar_id:
|
if sidecar_path and similar_id:
|
||||||
apply_sidecar_metadata.delay(similar_id, sidecar_path)
|
apply_sidecar_metadata.delay(similar_id, sidecar_path)
|
||||||
return _skip_task(task, 'Similar larger image exists', result_image_id=similar_id)
|
return _skip_task(task, 'Similar larger image exists', result_image_id=similar_id)
|
||||||
@@ -220,7 +276,8 @@ def import_media_file(self, task_id: int):
|
|||||||
if old_record:
|
if old_record:
|
||||||
return _supersede_existing(
|
return _supersede_existing(
|
||||||
task, old_record, src_path, file_hash, phash,
|
task, old_record, src_path, file_hash, phash,
|
||||||
metadata, artist, dest_dir, filename
|
metadata, artist, dest_dir, filename,
|
||||||
|
archive_path, archive_sidecar_path
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Quick mode: only compute pHash for storage, not comparison
|
# Quick mode: only compute pHash for storage, not comparison
|
||||||
@@ -302,7 +359,8 @@ def import_media_file(self, task_id: int):
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
# Queue sidecar metadata enrichment
|
# Queue sidecar metadata enrichment
|
||||||
sidecar_path = find_sidecar_json(src_path)
|
# Uses archive sidecar as fallback for files from archives
|
||||||
|
sidecar_path = _find_best_sidecar(src_path, archive_path, archive_sidecar_path)
|
||||||
if sidecar_path:
|
if sidecar_path:
|
||||||
apply_sidecar_metadata.delay(record.id, sidecar_path)
|
apply_sidecar_metadata.delay(record.id, sidecar_path)
|
||||||
|
|
||||||
@@ -341,11 +399,12 @@ def import_archive(self, task_id: int):
|
|||||||
|
|
||||||
Steps:
|
Steps:
|
||||||
1. Check if archive already processed (by hash)
|
1. Check if archive already processed (by hash)
|
||||||
2. Extract to temp directory
|
2. Find archive sidecar JSON if present
|
||||||
3. Create ImportTask for each media file inside
|
3. Extract to temp directory
|
||||||
4. Queue import tasks for extracted files
|
4. Create ImportTask for each media file inside
|
||||||
5. Create archive tag if files were found
|
5. Queue import tasks for extracted files (with archive sidecar path)
|
||||||
6. Clean up is deferred until child tasks complete
|
6. Create archive tag if files were found
|
||||||
|
7. Clean up is deferred until child tasks complete
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
task_id: ID of the ImportTask record
|
task_id: ID of the ImportTask record
|
||||||
@@ -354,8 +413,9 @@ def import_archive(self, task_id: int):
|
|||||||
dict with status and counts
|
dict with status and counts
|
||||||
"""
|
"""
|
||||||
from app.utils.image_importer import (
|
from app.utils.image_importer import (
|
||||||
calculate_hash, compute_next_archive_tag_name, extract_archive_resilient, ExtractError
|
calculate_hash, compute_archive_tag_name, extract_archive_resilient, ExtractError
|
||||||
)
|
)
|
||||||
|
from app.utils.metadata_enrichment import find_archive_sidecar
|
||||||
|
|
||||||
task = ImportTask.query.get(task_id)
|
task = ImportTask.query.get(task_id)
|
||||||
if not task:
|
if not task:
|
||||||
@@ -372,17 +432,31 @@ def import_archive(self, task_id: int):
|
|||||||
artist = context.get('artist', 'unknown')
|
artist = context.get('artist', 'unknown')
|
||||||
dest_dir = context.get('dest_dir', '/images')
|
dest_dir = context.get('dest_dir', '/images')
|
||||||
|
|
||||||
log.info(f"Processing archive: {archive_path}")
|
# Deep scan options
|
||||||
|
is_deep_scan = context.get('deep_scan', False)
|
||||||
|
|
||||||
|
log.info(f"Processing archive: {archive_path} (deep_scan={is_deep_scan})")
|
||||||
|
|
||||||
|
# Find archive sidecar JSON (next to the archive file, not inside it)
|
||||||
|
# This contains post metadata from Gallery-DL for the entire archive
|
||||||
|
archive_sidecar_path = find_archive_sidecar(archive_path)
|
||||||
|
if archive_sidecar_path:
|
||||||
|
log.info(f"Found archive sidecar: {archive_sidecar_path}")
|
||||||
|
else:
|
||||||
|
log.debug(f"No sidecar found for archive: {archive_path}")
|
||||||
|
|
||||||
# Compute archive hash
|
# Compute archive hash
|
||||||
archive_hash = calculate_hash(archive_path)
|
archive_hash = calculate_hash(archive_path)
|
||||||
task.file_hash = archive_hash
|
task.file_hash = archive_hash
|
||||||
|
|
||||||
# Check if already processed
|
# Check if already processed
|
||||||
existing = ArchiveRecord.query.filter_by(hash=archive_hash).first()
|
existing_archive = ArchiveRecord.query.filter_by(hash=archive_hash).first()
|
||||||
if existing:
|
if existing_archive and not is_deep_scan:
|
||||||
return _skip_task(task, 'Archive already imported')
|
return _skip_task(task, 'Archive already imported')
|
||||||
|
|
||||||
|
if existing_archive and is_deep_scan:
|
||||||
|
log.info(f"Deep scan: re-processing already-imported archive to update metadata")
|
||||||
|
|
||||||
# Get archive size
|
# Get archive size
|
||||||
archive_size = os.path.getsize(archive_path)
|
archive_size = os.path.getsize(archive_path)
|
||||||
|
|
||||||
@@ -415,39 +489,68 @@ def import_archive(self, task_id: int):
|
|||||||
|
|
||||||
log.info(f"Found {len(media_files)} media files in archive")
|
log.info(f"Found {len(media_files)} media files in archive")
|
||||||
|
|
||||||
# Create archive tag
|
# Get or create archive tag
|
||||||
tag_name = compute_next_archive_tag_name(artist)
|
# For deep scan of existing archives, reuse the existing tag
|
||||||
archive_tag = Tag.query.filter_by(name=tag_name).first()
|
if existing_archive and existing_archive.tag_id:
|
||||||
if not archive_tag:
|
archive_tag = Tag.query.get(existing_archive.tag_id)
|
||||||
archive_tag = Tag(name=tag_name, kind='archive')
|
if not archive_tag:
|
||||||
db.session.add(archive_tag)
|
# Tag was deleted, recreate it
|
||||||
db.session.flush()
|
archive_filename = os.path.basename(archive_path)
|
||||||
|
tag_name = compute_archive_tag_name(artist, archive_filename)
|
||||||
|
archive_tag = Tag(name=tag_name, kind='archive')
|
||||||
|
db.session.add(archive_tag)
|
||||||
|
db.session.flush()
|
||||||
|
else:
|
||||||
|
# New archive - create tag using artist:archive_name format
|
||||||
|
archive_filename = os.path.basename(archive_path)
|
||||||
|
tag_name = compute_archive_tag_name(artist, archive_filename)
|
||||||
|
archive_tag = Tag.query.filter_by(name=tag_name).first()
|
||||||
|
if not archive_tag:
|
||||||
|
archive_tag = Tag(name=tag_name, kind='archive')
|
||||||
|
db.session.add(archive_tag)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
# Create ArchiveRecord
|
# Create ArchiveRecord only if it doesn't exist
|
||||||
archive_record = ArchiveRecord(
|
if not existing_archive:
|
||||||
filename=archive_path,
|
archive_record = ArchiveRecord(
|
||||||
file_size=archive_size,
|
filename=archive_path,
|
||||||
hash=archive_hash,
|
file_size=archive_size,
|
||||||
artist=artist,
|
hash=archive_hash,
|
||||||
tag_id=archive_tag.id
|
artist=artist,
|
||||||
)
|
tag_id=archive_tag.id
|
||||||
db.session.add(archive_record)
|
)
|
||||||
db.session.commit()
|
db.session.add(archive_record)
|
||||||
|
db.session.commit()
|
||||||
|
else:
|
||||||
|
# Deep scan - commit any tag changes
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
# Queue tasks for each media file
|
# Queue tasks for each media file
|
||||||
queued_count = 0
|
queued_count = 0
|
||||||
for media_path in media_files:
|
for media_path in media_files:
|
||||||
|
# Build child context with archive info
|
||||||
|
child_context = {
|
||||||
|
'artist': artist,
|
||||||
|
'dest_dir': dest_dir,
|
||||||
|
'archive_task_id': task.id,
|
||||||
|
'archive_tag_id': archive_tag.id,
|
||||||
|
'temp_dir': tmpdir,
|
||||||
|
'archive_path': archive_path, # Original archive location (for sidecar lookups)
|
||||||
|
}
|
||||||
|
# Pass archive sidecar path so child tasks can use it as fallback
|
||||||
|
if archive_sidecar_path:
|
||||||
|
child_context['archive_sidecar_path'] = archive_sidecar_path
|
||||||
|
|
||||||
|
# Pass through deep scan flags from parent task
|
||||||
|
for flag in ('deep_scan', 'reapply_sidecars', 'regenerate_thumbnails', 'verify_hashes'):
|
||||||
|
if context.get(flag):
|
||||||
|
child_context[flag] = context[flag]
|
||||||
|
|
||||||
child_task = ImportTask(
|
child_task = ImportTask(
|
||||||
source_path=media_path,
|
source_path=media_path,
|
||||||
task_type='import_image',
|
task_type='import_image',
|
||||||
batch_id=task.batch_id,
|
batch_id=task.batch_id,
|
||||||
context={
|
context=child_context,
|
||||||
'artist': artist,
|
|
||||||
'dest_dir': dest_dir,
|
|
||||||
'archive_task_id': task.id,
|
|
||||||
'archive_tag_id': archive_tag.id,
|
|
||||||
'temp_dir': tmpdir
|
|
||||||
},
|
|
||||||
status='pending'
|
status='pending'
|
||||||
)
|
)
|
||||||
db.session.add(child_task)
|
db.session.add(child_task)
|
||||||
@@ -463,6 +566,8 @@ def import_archive(self, task_id: int):
|
|||||||
task.context['temp_dir'] = tmpdir
|
task.context['temp_dir'] = tmpdir
|
||||||
task.context['archive_tag_id'] = archive_tag.id
|
task.context['archive_tag_id'] = archive_tag.id
|
||||||
task.context['child_count'] = queued_count
|
task.context['child_count'] = queued_count
|
||||||
|
if archive_sidecar_path:
|
||||||
|
task.context['archive_sidecar_path'] = archive_sidecar_path
|
||||||
|
|
||||||
task.status = 'complete'
|
task.status = 'complete'
|
||||||
task.completed_at = datetime.utcnow()
|
task.completed_at = datetime.utcnow()
|
||||||
@@ -585,7 +690,9 @@ def _regenerate_thumbnail(record: ImageRecord) -> bool:
|
|||||||
|
|
||||||
def _supersede_existing(task: ImportTask, old_record: ImageRecord, src_path: str,
|
def _supersede_existing(task: ImportTask, old_record: ImageRecord, src_path: str,
|
||||||
file_hash: str, phash, metadata: dict, artist: str,
|
file_hash: str, phash, metadata: dict, artist: str,
|
||||||
dest_dir: str, filename: str) -> dict:
|
dest_dir: str, filename: str,
|
||||||
|
archive_path: str = None,
|
||||||
|
archive_sidecar_path: str = None) -> dict:
|
||||||
"""
|
"""
|
||||||
Replace an existing smaller image with a larger version.
|
Replace an existing smaller image with a larger version.
|
||||||
|
|
||||||
@@ -595,14 +702,15 @@ def _supersede_existing(task: ImportTask, old_record: ImageRecord, src_path: str
|
|||||||
build_hashed_dest_path, generate_thumbnail, generate_video_thumbnail_mirrored,
|
build_hashed_dest_path, generate_thumbnail, generate_video_thumbnail_mirrored,
|
||||||
supersede_image
|
supersede_image
|
||||||
)
|
)
|
||||||
from app.utils.metadata_enrichment import find_sidecar_json, enrich_from_sidecar
|
from app.utils.metadata_enrichment import enrich_from_sidecar
|
||||||
from app.tasks.sidecar import apply_sidecar_metadata
|
from app.tasks.sidecar import apply_sidecar_metadata
|
||||||
|
|
||||||
log.info(f"Superseding {old_record.filename} ({old_record.width}x{old_record.height}) "
|
log.info(f"Superseding {old_record.filename} ({old_record.width}x{old_record.height}) "
|
||||||
f"with {filename} ({metadata['width']}x{metadata['height']})")
|
f"with {filename} ({metadata['width']}x{metadata['height']})")
|
||||||
|
|
||||||
# Enrich metadata from sidecar if available
|
# Enrich metadata from sidecar if available
|
||||||
sidecar_path = find_sidecar_json(src_path)
|
# Uses archive sidecar as fallback for files from archives
|
||||||
|
sidecar_path = _find_best_sidecar(src_path, archive_path, archive_sidecar_path)
|
||||||
if sidecar_path:
|
if sidecar_path:
|
||||||
enriched = enrich_from_sidecar(sidecar_path, {'taken_at': metadata.get('taken_at')})
|
enriched = enrich_from_sidecar(sidecar_path, {'taken_at': metadata.get('taken_at')})
|
||||||
if enriched.get('taken_at'):
|
if enriched.get('taken_at'):
|
||||||
|
|||||||
@@ -633,3 +633,105 @@ def update_batch_stats(batch_id: int):
|
|||||||
f"{batch.skipped_count} skipped, {batch.failed_count} failed")
|
f"{batch.skipped_count} skipped, {batch.failed_count} failed")
|
||||||
|
|
||||||
db.session.commit()
|
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"
|
||||||
|
|||||||
+457
-40
@@ -4,7 +4,44 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<h1 style="text-align:center; margin-top: 1rem;">Settings</h1>
|
<h1 style="text-align:center; margin-top: 1rem;">Settings</h1>
|
||||||
|
|
||||||
<!-- Import Queue Status (Full Width at Top) -->
|
<!-- System Stats (Full Width at Top) -->
|
||||||
|
<div class="settings-grid-full">
|
||||||
|
<div class="settings-container">
|
||||||
|
<div class="settings-section">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;">
|
||||||
|
<h2 style="margin: 0;">System Overview</h2>
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.75rem;">
|
||||||
|
<span id="statsUpdatedAt" style="font-size: 0.75rem; color: var(--text-muted);"></span>
|
||||||
|
<button id="refreshStatsBtn" class="btn secondary-btn" style="padding: 0.3rem 0.6rem; font-size: 0.8rem;" title="Refresh stats">Refresh</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stats-grid" style="grid-template-columns: repeat(5, 1fr);">
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-value" id="statTotalImages">-</span>
|
||||||
|
<span class="stat-label">Total Images</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-value" id="statTotalTags">-</span>
|
||||||
|
<span class="stat-label">Total Tags</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-value" id="statStorage">-</span>
|
||||||
|
<span class="stat-label">Storage Used</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-value" id="statPendingFiles">-</span>
|
||||||
|
<span class="stat-label">Files to Import</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-value" id="statPendingArchives">-</span>
|
||||||
|
<span class="stat-label">Archives to Import</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Import Queue Status (Full Width) -->
|
||||||
<div class="settings-grid-full">
|
<div class="settings-grid-full">
|
||||||
<div class="settings-container">
|
<div class="settings-container">
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
@@ -25,20 +62,28 @@
|
|||||||
<span class="stat-value" id="queueProcessing">-</span>
|
<span class="stat-value" id="queueProcessing">-</span>
|
||||||
<span class="stat-label">Processing</span>
|
<span class="stat-label">Processing</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-item">
|
<div class="stat-item clickable-stat" id="completeStatItem" title="Click for breakdown">
|
||||||
<span class="stat-value" id="queueComplete">-</span>
|
<span class="stat-value" id="queueComplete">-</span>
|
||||||
<span class="stat-label">Complete</span>
|
<span class="stat-label">Complete</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-item">
|
<div class="stat-item clickable-stat" id="skippedStatItem" title="Click for breakdown">
|
||||||
<span class="stat-value" id="queueSkipped">-</span>
|
<span class="stat-value" id="queueSkipped">-</span>
|
||||||
<span class="stat-label">Skipped</span>
|
<span class="stat-label">Skipped</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-item">
|
<div class="stat-item clickable-stat" id="failedStatItem" title="Click for breakdown">
|
||||||
<span class="stat-value" id="queueFailed">-</span>
|
<span class="stat-value" id="queueFailed">-</span>
|
||||||
<span class="stat-label">Failed</span>
|
<span class="stat-label">Failed</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Age breakdown tooltip -->
|
||||||
|
<div id="ageBreakdown" style="display: none; margin-top: 0.5rem; padding: 0.5rem; background: rgba(255,255,255,0.05); border-radius: 4px; font-size: 0.8rem;">
|
||||||
|
<span id="ageBreakdownTitle" style="color: var(--text-muted);"></span>:
|
||||||
|
<span id="ageBreakdownToday">-</span> today,
|
||||||
|
<span id="ageBreakdownWeek">-</span> this week,
|
||||||
|
<span id="ageBreakdownOlder">-</span> older
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="activeBatchInfo" class="import-stats" style="margin-top: 1rem; display: none;">
|
<div id="activeBatchInfo" class="import-stats" style="margin-top: 1rem; display: none;">
|
||||||
<h3>Active Batch</h3>
|
<h3>Active Batch</h3>
|
||||||
<p>Processing <strong id="batchDir">-</strong></p>
|
<p>Processing <strong id="batchDir">-</strong></p>
|
||||||
@@ -51,9 +96,36 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="queue-actions" style="margin-top: 1rem; display: flex; gap: 0.75rem; flex-wrap: wrap;">
|
<div class="queue-actions" style="margin-top: 1rem; display: flex; gap: 0.75rem; flex-wrap: wrap;">
|
||||||
<button id="triggerImportBtn" class="btn primary-btn">Trigger Import Scan</button>
|
<button id="triggerImportBtn" class="btn primary-btn">Quick Scan</button>
|
||||||
|
<button id="triggerDeepScanBtn" class="btn warning-btn" title="Full reprocessing: re-extracts metadata, runs pHash comparison, merges archive tags">Deep Scan</button>
|
||||||
<button id="retryFailedBtn" class="btn warning-btn">Retry Failed Tasks</button>
|
<button id="retryFailedBtn" class="btn warning-btn">Retry Failed Tasks</button>
|
||||||
<button id="clearCompletedBtn" class="btn secondary-btn">Clear Completed</button>
|
</div>
|
||||||
|
|
||||||
|
<!-- Task Cleanup Controls -->
|
||||||
|
<div class="cleanup-controls" style="margin-top: 1rem; padding: 0.75rem; background: rgba(255,255,255,0.03); border-radius: 6px;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap;">
|
||||||
|
<span style="color: var(--text-muted); font-size: 0.85rem;">Clear tasks:</span>
|
||||||
|
<select id="clearStatusSelect" class="form-input" style="width: auto; min-width: 140px;">
|
||||||
|
<option value="complete,skipped">Complete + Skipped</option>
|
||||||
|
<option value="complete">Complete only</option>
|
||||||
|
<option value="skipped">Skipped only</option>
|
||||||
|
<option value="failed">Failed only</option>
|
||||||
|
<option value="complete,skipped,failed">All finished</option>
|
||||||
|
</select>
|
||||||
|
<select id="clearAgeSelect" class="form-input" style="width: auto; min-width: 120px;">
|
||||||
|
<option value="0">All</option>
|
||||||
|
<option value="1">Older than 1 day</option>
|
||||||
|
<option value="7" selected>Older than 7 days</option>
|
||||||
|
<option value="30">Older than 30 days</option>
|
||||||
|
</select>
|
||||||
|
<button id="clearTasksBtn" class="btn secondary-btn">Clear</button>
|
||||||
|
</div>
|
||||||
|
<p style="color: var(--text-muted); font-size: 0.75rem; margin-top: 0.5rem;">
|
||||||
|
Note: Failed/skipped tasks older than 7 days are auto-cleaned daily.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="queue-actions" style="margin-top: 0.75rem; display: flex; gap: 0.75rem; flex-wrap: wrap;">
|
||||||
<button id="regenMissingThumbsBtn" class="btn secondary-btn">Regenerate Missing Thumbnails</button>
|
<button id="regenMissingThumbsBtn" class="btn secondary-btn">Regenerate Missing Thumbnails</button>
|
||||||
<button id="reapplyArtistTagsBtn" class="btn secondary-btn">Reapply Artist Tags from Paths</button>
|
<button id="reapplyArtistTagsBtn" class="btn secondary-btn">Reapply Artist Tags from Paths</button>
|
||||||
<button id="cleanupOrphanedTagsBtn" class="btn secondary-btn">Cleanup Orphaned Tags</button>
|
<button id="cleanupOrphanedTagsBtn" class="btn secondary-btn">Cleanup Orphaned Tags</button>
|
||||||
@@ -67,25 +139,76 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Recent Tasks Table -->
|
<!-- Task List with Filters -->
|
||||||
<div id="recentTasksSection" style="margin-top: 1.5rem;">
|
<div id="taskListSection" style="margin-top: 1.5rem;">
|
||||||
<h3>Recent Tasks</h3>
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; flex-wrap: wrap; gap: 0.5rem;">
|
||||||
<div class="tasks-table-wrapper" style="max-height: 300px; overflow-y: auto;">
|
<h3 style="margin: 0;">Import Tasks</h3>
|
||||||
|
<div style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
|
||||||
|
<select id="taskStatusFilter" class="form-input" style="width: auto; min-width: 120px; padding: 0.4rem;">
|
||||||
|
<option value="">All statuses</option>
|
||||||
|
<option value="failed">Failed</option>
|
||||||
|
<option value="skipped">Skipped</option>
|
||||||
|
<option value="complete">Complete</option>
|
||||||
|
<option value="processing">Processing</option>
|
||||||
|
<option value="queued">Queued</option>
|
||||||
|
<option value="pending">Pending</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" id="taskSearchInput" class="form-input" placeholder="Search..." style="width: 150px; padding: 0.4rem;">
|
||||||
|
<button id="taskSearchBtn" class="btn secondary-btn" style="padding: 0.4rem 0.75rem;">Search</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="taskListInfo" style="font-size: 0.8rem; color: var(--text-muted); margin-bottom: 0.5rem;">
|
||||||
|
Showing <span id="taskListShowing">0</span> of <span id="taskListTotal">0</span> tasks
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tasks-table-wrapper" style="max-height: 400px; overflow-y: auto;">
|
||||||
<table class="tasks-table">
|
<table class="tasks-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>File</th>
|
<th style="width: 40%;">File</th>
|
||||||
<th>Type</th>
|
<th style="width: 10%;">Type</th>
|
||||||
<th>Status</th>
|
<th style="width: 10%;">Status</th>
|
||||||
<th>Error</th>
|
<th style="width: 30%;">Error/Reason</th>
|
||||||
<th>Time</th>
|
<th style="width: 10%;">Time</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="recentTasksBody">
|
<tbody id="taskListBody">
|
||||||
<tr><td colspan="5" style="text-align: center; color: var(--text-muted);">Loading...</td></tr>
|
<tr><td colspan="5" style="text-align: center; color: var(--text-muted);">Loading...</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 0.75rem; display: flex; gap: 0.5rem;">
|
||||||
|
<button id="loadMoreTasksBtn" class="btn secondary-btn" style="display: none;">Load More</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error Detail Modal -->
|
||||||
|
<div id="errorDetailModal" class="modal" style="display: none;">
|
||||||
|
<div class="modal-content" style="max-width: 600px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Task Details</h3>
|
||||||
|
<button class="modal-close" onclick="document.getElementById('errorDetailModal').style.display='none'">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div style="margin-bottom: 1rem;">
|
||||||
|
<strong>File:</strong>
|
||||||
|
<div id="errorDetailPath" style="word-break: break-all; font-family: monospace; font-size: 0.85rem; background: rgba(0,0,0,0.2); padding: 0.5rem; border-radius: 4px; margin-top: 0.25rem;"></div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom: 1rem;">
|
||||||
|
<strong>Status:</strong> <span id="errorDetailStatus"></span>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom: 1rem;">
|
||||||
|
<strong>Error/Reason:</strong>
|
||||||
|
<div id="errorDetailMessage" style="word-break: break-word; font-family: monospace; font-size: 0.85rem; background: rgba(255,100,100,0.1); padding: 0.5rem; border-radius: 4px; margin-top: 0.25rem; white-space: pre-wrap;"></div>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 1rem; font-size: 0.85rem; color: var(--text-muted);">
|
||||||
|
<span><strong>Retries:</strong> <span id="errorDetailRetries"></span></span>
|
||||||
|
<span><strong>Created:</strong> <span id="errorDetailCreated"></span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -282,6 +405,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.clickable-stat {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.clickable-stat:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
.cleanup-controls select {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
.tasks-table {
|
.tasks-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
@@ -560,6 +695,55 @@
|
|||||||
background: var(--btn-danger);
|
background: var(--btn-danger);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
/* Error detail modal */
|
||||||
|
#errorDetailModal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
#errorDetailModal .modal-content {
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
border-radius: 8px;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
#errorDetailModal .modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
#errorDetailModal .modal-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
#errorDetailModal .modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
#errorDetailModal .modal-body {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.tasks-table .error-cell {
|
||||||
|
max-width: 250px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tasks-table .error-cell:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -595,27 +779,121 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
batchEl.style.display = 'none';
|
batchEl.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update recent tasks table
|
|
||||||
const tbody = document.getElementById('recentTasksBody');
|
|
||||||
if (data.recent_tasks && data.recent_tasks.length > 0) {
|
|
||||||
tbody.innerHTML = data.recent_tasks.map(t => `
|
|
||||||
<tr>
|
|
||||||
<td title="${t.source_path || ''}">${t.source_path || '-'}</td>
|
|
||||||
<td>${t.task_type || '-'}</td>
|
|
||||||
<td class="status-${t.status}">${t.status || '-'}</td>
|
|
||||||
<td title="${t.error_message || ''}">${t.error_message ? t.error_message.substring(0, 30) + '...' : '-'}</td>
|
|
||||||
<td>${t.created_at ? new Date(t.created_at).toLocaleString() : '-'}</td>
|
|
||||||
</tr>
|
|
||||||
`).join('');
|
|
||||||
} else {
|
|
||||||
tbody.innerHTML = '<tr><td colspan="5" style="text-align: center; color: var(--text-muted);">No recent tasks</td></tr>';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load queue status:', e);
|
console.error('Failed to load queue status:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Task List with Filtering
|
||||||
|
// ========================================
|
||||||
|
let taskListOffset = 0;
|
||||||
|
const taskListLimit = 50;
|
||||||
|
let taskListTasks = [];
|
||||||
|
|
||||||
|
async function loadTaskList(append = false) {
|
||||||
|
const status = document.getElementById('taskStatusFilter').value;
|
||||||
|
const search = document.getElementById('taskSearchInput').value;
|
||||||
|
|
||||||
|
if (!append) {
|
||||||
|
taskListOffset = 0;
|
||||||
|
taskListTasks = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
limit: taskListLimit,
|
||||||
|
offset: taskListOffset,
|
||||||
|
order: 'desc'
|
||||||
|
});
|
||||||
|
if (status) params.append('status', status);
|
||||||
|
if (search) params.append('search', search);
|
||||||
|
|
||||||
|
const r = await fetch('/api/import/tasks?' + params.toString());
|
||||||
|
const data = await r.json();
|
||||||
|
|
||||||
|
if (data.ok) {
|
||||||
|
taskListTasks = append ? [...taskListTasks, ...data.tasks] : data.tasks;
|
||||||
|
|
||||||
|
const tbody = document.getElementById('taskListBody');
|
||||||
|
if (taskListTasks.length > 0) {
|
||||||
|
tbody.innerHTML = taskListTasks.map(t => `
|
||||||
|
<tr>
|
||||||
|
<td title="${escapeHtml(t.source_path || '')}">${escapeHtml(t.filename || '-')}</td>
|
||||||
|
<td>${t.task_type === 'import_image' ? 'Image' : t.task_type === 'import_archive' ? 'Archive' : t.task_type || '-'}</td>
|
||||||
|
<td class="status-${t.status}">${t.status || '-'}</td>
|
||||||
|
<td class="error-cell" onclick="window.showErrorDetail(${t.id})" title="${t.error_message ? escapeHtml(t.error_message) : 'Click for details'}">${t.error_message ? escapeHtml(t.error_message.substring(0, 50)) + (t.error_message.length > 50 ? '...' : '') : '-'}</td>
|
||||||
|
<td>${t.completed_at ? new Date(t.completed_at).toLocaleString() : (t.created_at ? new Date(t.created_at).toLocaleString() : '-')}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
} else {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5" style="text-align: center; color: var(--text-muted);">No tasks found</td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update info
|
||||||
|
document.getElementById('taskListShowing').textContent = taskListTasks.length;
|
||||||
|
document.getElementById('taskListTotal').textContent = data.total;
|
||||||
|
|
||||||
|
// Show/hide load more button
|
||||||
|
const loadMoreBtn = document.getElementById('loadMoreTasksBtn');
|
||||||
|
if (taskListTasks.length < data.total) {
|
||||||
|
loadMoreBtn.style.display = 'inline-block';
|
||||||
|
} else {
|
||||||
|
loadMoreBtn.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load task list:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store tasks for detail view
|
||||||
|
let taskDetailCache = {};
|
||||||
|
|
||||||
|
// Make showErrorDetail globally accessible for onclick handlers
|
||||||
|
window.showErrorDetail = function(taskId) {
|
||||||
|
const task = taskListTasks.find(t => t.id === taskId);
|
||||||
|
if (!task) return;
|
||||||
|
|
||||||
|
document.getElementById('errorDetailPath').textContent = task.source_path || '-';
|
||||||
|
document.getElementById('errorDetailStatus').textContent = task.status || '-';
|
||||||
|
document.getElementById('errorDetailStatus').className = 'status-' + task.status;
|
||||||
|
document.getElementById('errorDetailMessage').textContent = task.error_message || 'No error message';
|
||||||
|
document.getElementById('errorDetailRetries').textContent = task.retry_count || 0;
|
||||||
|
document.getElementById('errorDetailCreated').textContent = task.created_at ? new Date(task.created_at).toLocaleString() : '-';
|
||||||
|
|
||||||
|
document.getElementById('errorDetailModal').style.display = 'flex';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close modal on backdrop click
|
||||||
|
document.getElementById('errorDetailModal').addEventListener('click', (e) => {
|
||||||
|
if (e.target.id === 'errorDetailModal') {
|
||||||
|
e.target.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Task list event listeners
|
||||||
|
document.getElementById('taskStatusFilter').addEventListener('change', () => loadTaskList());
|
||||||
|
document.getElementById('taskSearchBtn').addEventListener('click', () => loadTaskList());
|
||||||
|
document.getElementById('taskSearchInput').addEventListener('keypress', (e) => {
|
||||||
|
if (e.key === 'Enter') loadTaskList();
|
||||||
|
});
|
||||||
|
document.getElementById('loadMoreTasksBtn').addEventListener('click', () => {
|
||||||
|
taskListOffset += taskListLimit;
|
||||||
|
loadTaskList(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
loadTaskList();
|
||||||
|
|
||||||
async function loadCeleryStatus() {
|
async function loadCeleryStatus() {
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/celery/status');
|
const r = await fetch('/api/celery/status');
|
||||||
@@ -634,13 +912,106 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// System stats
|
||||||
|
let taskAgeStats = {};
|
||||||
|
|
||||||
|
async function loadSystemStats(triggerRefresh = false) {
|
||||||
|
try {
|
||||||
|
const url = triggerRefresh ? '/api/system/stats?refresh=true' : '/api/system/stats';
|
||||||
|
const r = await fetch(url);
|
||||||
|
const data = await r.json();
|
||||||
|
|
||||||
|
if (data.ok) {
|
||||||
|
document.getElementById('statTotalImages').textContent = data.images.total.toLocaleString();
|
||||||
|
document.getElementById('statTotalTags').textContent = data.tags.total.toLocaleString();
|
||||||
|
document.getElementById('statStorage').textContent = data.images.storage_human;
|
||||||
|
document.getElementById('statPendingFiles').textContent = data.import_folder.pending_files.toLocaleString();
|
||||||
|
document.getElementById('statPendingArchives').textContent = data.import_folder.pending_archives.toLocaleString();
|
||||||
|
|
||||||
|
// Show last updated time
|
||||||
|
const updatedEl = document.getElementById('statsUpdatedAt');
|
||||||
|
if (data.updated_at) {
|
||||||
|
const updated = new Date(data.updated_at);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now - updated;
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
|
||||||
|
if (diffMins < 1) {
|
||||||
|
updatedEl.textContent = 'Updated just now';
|
||||||
|
} else if (diffMins < 60) {
|
||||||
|
updatedEl.textContent = `Updated ${diffMins}m ago`;
|
||||||
|
} else {
|
||||||
|
const diffHours = Math.floor(diffMins / 60);
|
||||||
|
updatedEl.textContent = `Updated ${diffHours}h ago`;
|
||||||
|
}
|
||||||
|
} else if (data.message) {
|
||||||
|
updatedEl.textContent = data.message;
|
||||||
|
} else {
|
||||||
|
updatedEl.textContent = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load system stats:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh stats button handler
|
||||||
|
document.getElementById('refreshStatsBtn').addEventListener('click', async () => {
|
||||||
|
const btn = document.getElementById('refreshStatsBtn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Refreshing...';
|
||||||
|
|
||||||
|
// Trigger refresh and reload after a short delay
|
||||||
|
await loadSystemStats(true);
|
||||||
|
await new Promise(r => setTimeout(r, 2000)); // Wait for task to run
|
||||||
|
await loadSystemStats();
|
||||||
|
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Refresh';
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadTaskAgeStats() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/import/task-stats');
|
||||||
|
const data = await r.json();
|
||||||
|
|
||||||
|
if (data.ok) {
|
||||||
|
taskAgeStats = data.stats;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load task age stats:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAgeBreakdown(status) {
|
||||||
|
const stats = taskAgeStats[status];
|
||||||
|
if (!stats) return;
|
||||||
|
|
||||||
|
const breakdown = document.getElementById('ageBreakdown');
|
||||||
|
const title = status.charAt(0).toUpperCase() + status.slice(1);
|
||||||
|
document.getElementById('ageBreakdownTitle').textContent = title;
|
||||||
|
document.getElementById('ageBreakdownToday').textContent = stats.today;
|
||||||
|
document.getElementById('ageBreakdownWeek').textContent = stats.this_week;
|
||||||
|
document.getElementById('ageBreakdownOlder').textContent = stats.older;
|
||||||
|
breakdown.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add click handlers for stat items
|
||||||
|
document.getElementById('completeStatItem').addEventListener('click', () => showAgeBreakdown('complete'));
|
||||||
|
document.getElementById('skippedStatItem').addEventListener('click', () => showAgeBreakdown('skipped'));
|
||||||
|
document.getElementById('failedStatItem').addEventListener('click', () => showAgeBreakdown('failed'));
|
||||||
|
|
||||||
// Load queue status on page load and refresh periodically
|
// Load queue status on page load and refresh periodically
|
||||||
loadQueueStatus();
|
loadQueueStatus();
|
||||||
loadCeleryStatus();
|
loadCeleryStatus();
|
||||||
|
loadSystemStats();
|
||||||
|
loadTaskAgeStats();
|
||||||
setInterval(loadQueueStatus, 5000); // Refresh every 5 seconds
|
setInterval(loadQueueStatus, 5000); // Refresh every 5 seconds
|
||||||
setInterval(loadCeleryStatus, 10000); // Refresh every 10 seconds
|
setInterval(loadCeleryStatus, 10000); // Refresh every 10 seconds
|
||||||
|
setInterval(loadSystemStats, 30000); // Refresh every 30 seconds
|
||||||
|
setInterval(loadTaskAgeStats, 30000); // Refresh every 30 seconds
|
||||||
|
|
||||||
// Trigger Import Scan
|
// Quick Scan (default)
|
||||||
document.getElementById('triggerImportBtn').addEventListener('click', async () => {
|
document.getElementById('triggerImportBtn').addEventListener('click', async () => {
|
||||||
const btn = document.getElementById('triggerImportBtn');
|
const btn = document.getElementById('triggerImportBtn');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
@@ -651,7 +1022,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
|
|
||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
alert('Import scan started! Task ID: ' + data.task_id);
|
alert('Quick scan started! Task ID: ' + data.task_id);
|
||||||
loadQueueStatus();
|
loadQueueStatus();
|
||||||
} else {
|
} else {
|
||||||
alert('Failed to start import: ' + (data.error || 'Unknown error'));
|
alert('Failed to start import: ' + (data.error || 'Unknown error'));
|
||||||
@@ -660,7 +1031,38 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
alert('Failed to start import: ' + e.message);
|
alert('Failed to start import: ' + e.message);
|
||||||
} finally {
|
} finally {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = 'Trigger Import Scan';
|
btn.textContent = 'Quick Scan';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Deep Scan (full reprocessing)
|
||||||
|
document.getElementById('triggerDeepScanBtn').addEventListener('click', async () => {
|
||||||
|
if (!confirm('Deep scan will reprocess ALL files in the import folder.\n\nThis includes:\n- Full pHash similarity detection\n- Archive tag merging for duplicates\n- Thumbnail regeneration\n- Sidecar metadata re-application\n\nThis may take a long time. Continue?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = document.getElementById('triggerDeepScanBtn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Starting...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('deep', 'true');
|
||||||
|
|
||||||
|
const r = await fetch('/api/import/trigger', { method: 'POST', body: formData });
|
||||||
|
const data = await r.json();
|
||||||
|
|
||||||
|
if (data.ok) {
|
||||||
|
alert('Deep scan started! Task ID: ' + data.task_id + '\n\nThis will reprocess all files and may take a while.');
|
||||||
|
loadQueueStatus();
|
||||||
|
} else {
|
||||||
|
alert('Failed to start deep scan: ' + (data.error || 'Unknown error'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Failed to start deep scan: ' + e.message);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Deep Scan';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -688,19 +1090,34 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear Completed Tasks
|
// Clear Tasks with age/status options
|
||||||
document.getElementById('clearCompletedBtn').addEventListener('click', async () => {
|
document.getElementById('clearTasksBtn').addEventListener('click', async () => {
|
||||||
const btn = document.getElementById('clearCompletedBtn');
|
const btn = document.getElementById('clearTasksBtn');
|
||||||
|
const statuses = document.getElementById('clearStatusSelect').value;
|
||||||
|
const olderThanDays = document.getElementById('clearAgeSelect').value;
|
||||||
|
|
||||||
|
const statusLabel = document.getElementById('clearStatusSelect').selectedOptions[0].text;
|
||||||
|
const ageLabel = document.getElementById('clearAgeSelect').selectedOptions[0].text;
|
||||||
|
|
||||||
|
if (!confirm(`Clear ${statusLabel} tasks (${ageLabel.toLowerCase()})?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = 'Clearing...';
|
btn.textContent = 'Clearing...';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/import/clear-completed', { method: 'POST' });
|
const formData = new FormData();
|
||||||
|
formData.append('statuses', statuses);
|
||||||
|
formData.append('older_than_days', olderThanDays);
|
||||||
|
|
||||||
|
const r = await fetch('/api/import/clear-completed', { method: 'POST', body: formData });
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
|
|
||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
alert(`Cleared ${data.deleted} completed/skipped task(s).`);
|
alert(`Cleared ${data.deleted} task(s).`);
|
||||||
loadQueueStatus();
|
loadQueueStatus();
|
||||||
|
loadTaskAgeStats();
|
||||||
} else {
|
} else {
|
||||||
alert('Failed to clear: ' + (data.error || 'Unknown error'));
|
alert('Failed to clear: ' + (data.error || 'Unknown error'));
|
||||||
}
|
}
|
||||||
@@ -708,7 +1125,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
alert('Failed to clear: ' + e.message);
|
alert('Failed to clear: ' + e.message);
|
||||||
} finally {
|
} finally {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = 'Clear Completed';
|
btn.textContent = 'Clear';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -846,8 +846,69 @@ def is_first_volume(path: str) -> bool:
|
|||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def extract_archive_name(filename: str) -> str:
|
||||||
|
"""
|
||||||
|
Extract a clean archive name from a filename by removing common prefixes.
|
||||||
|
|
||||||
|
Handles patterns like:
|
||||||
|
- "43387617_attachment_83109081_October 2020 Rewards.rar" -> "October 2020 Rewards"
|
||||||
|
- "01_Archive Name.zip" -> "Archive Name"
|
||||||
|
- "Archive Name.rar" -> "Archive Name"
|
||||||
|
|
||||||
|
Returns the cleaned name without extension.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Remove extension
|
||||||
|
stem = Path(filename).stem
|
||||||
|
|
||||||
|
# Pattern 1: Gallery-DL style "{id}_attachment_{id}_{name}" or "{id}_{name}"
|
||||||
|
# Match one or more numeric_prefix patterns at the start
|
||||||
|
cleaned = re.sub(r'^(\d+_)+(attachment_)?(\d+_)?', '', stem)
|
||||||
|
|
||||||
|
# Pattern 2: Simple numeric prefix "01_name" or "001_name"
|
||||||
|
if cleaned == stem: # No match from pattern 1
|
||||||
|
cleaned = re.sub(r'^\d+_', '', stem)
|
||||||
|
|
||||||
|
# If we stripped everything or got empty, fall back to original stem
|
||||||
|
if not cleaned.strip():
|
||||||
|
cleaned = stem
|
||||||
|
|
||||||
|
return cleaned.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def compute_archive_tag_name(artist: str, archive_filename: str) -> str:
|
||||||
|
"""
|
||||||
|
Generate an archive tag name in the format: '{artist}:{archive_name}'
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- artist="InCaseArt", filename="43387617_attachment_83109081_October 2020 Rewards.rar"
|
||||||
|
-> "InCaseArt:October 2020 Rewards"
|
||||||
|
|
||||||
|
If a tag with the same name already exists, appends a numeric suffix.
|
||||||
|
"""
|
||||||
|
archive_name = extract_archive_name(archive_filename)
|
||||||
|
base_tag = f"{artist}:{archive_name}"
|
||||||
|
|
||||||
|
# Check if tag already exists
|
||||||
|
existing = Tag.query.filter_by(name=base_tag, kind='archive').first()
|
||||||
|
if not existing:
|
||||||
|
return base_tag
|
||||||
|
|
||||||
|
# Tag exists, add numeric suffix
|
||||||
|
n = 2
|
||||||
|
while True:
|
||||||
|
candidate = f"{base_tag} ({n})"
|
||||||
|
if not Tag.query.filter_by(name=candidate, kind='archive').first():
|
||||||
|
return candidate
|
||||||
|
n += 1
|
||||||
|
|
||||||
|
|
||||||
def compute_next_archive_tag_name(artist: str, width: int = ARCHIVE_NUM_WIDTH) -> str:
|
def compute_next_archive_tag_name(artist: str, width: int = ARCHIVE_NUM_WIDTH) -> str:
|
||||||
"""
|
"""
|
||||||
|
DEPRECATED: Use compute_archive_tag_name() instead.
|
||||||
|
|
||||||
Return a unique incremental tag name like: 'archive:{artist}/0001', '0002', ...
|
Return a unique incremental tag name like: 'archive:{artist}/0001', '0002', ...
|
||||||
- Looks only at Tag(kind='archive') with names starting 'archive:{artist}/'
|
- Looks only at Tag(kind='archive') with names starting 'archive:{artist}/'
|
||||||
- Finds max numeric suffix and returns next number (zero-padded).
|
- Finds max numeric suffix and returns next number (zero-padded).
|
||||||
|
|||||||
@@ -83,6 +83,72 @@ def find_sidecar_json(image_path: str) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def find_archive_sidecar(archive_path: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Find the Gallery-DL sidecar JSON file for an archive.
|
||||||
|
|
||||||
|
Gallery-DL names sidecars based on the archive's content filename,
|
||||||
|
which may differ from the archive filename due to prefixes added
|
||||||
|
during download (e.g., "02_HolyMeh July 2023.rar" -> "HolyMeh July 2023.json").
|
||||||
|
|
||||||
|
Args:
|
||||||
|
archive_path: Path to the archive file (e.g., "02_Filename.rar")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the sidecar JSON if found, None otherwise.
|
||||||
|
"""
|
||||||
|
archive_path = Path(archive_path)
|
||||||
|
parent_dir = archive_path.parent
|
||||||
|
stem = archive_path.stem # filename without extension
|
||||||
|
|
||||||
|
# Pattern 1: Exact stem match (most common case)
|
||||||
|
# e.g., "HolyMeh July 2023.rar" -> "HolyMeh July 2023.json"
|
||||||
|
stem_json = parent_dir / f"{stem}.json"
|
||||||
|
if stem_json.exists():
|
||||||
|
return str(stem_json)
|
||||||
|
|
||||||
|
# Pattern 2: Strip numeric prefix from archive name
|
||||||
|
# e.g., "02_HolyMeh July 2023.rar" -> "HolyMeh July 2023.json"
|
||||||
|
stem_no_prefix = re.sub(r'^\d+_', '', stem)
|
||||||
|
if stem_no_prefix != stem:
|
||||||
|
alt_json = parent_dir / f"{stem_no_prefix}.json"
|
||||||
|
if alt_json.exists():
|
||||||
|
return str(alt_json)
|
||||||
|
|
||||||
|
# Pattern 3: Look for any JSON in the directory that references this archive
|
||||||
|
# by checking the 'file_name' or 'filename' field in the JSON
|
||||||
|
for json_file in parent_dir.glob("*.json"):
|
||||||
|
try:
|
||||||
|
with open(json_file, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
# Check if this JSON references our archive
|
||||||
|
# Gallery-DL uses 'file.file_name' or 'filename' + 'extension'
|
||||||
|
file_info = data.get("file", {})
|
||||||
|
json_filename = file_info.get("file_name", "")
|
||||||
|
if not json_filename:
|
||||||
|
# Try filename + extension pattern
|
||||||
|
fn = data.get("filename", "")
|
||||||
|
ext = data.get("extension", "")
|
||||||
|
if fn and ext:
|
||||||
|
json_filename = f"{fn}.{ext}"
|
||||||
|
|
||||||
|
# Compare with archive filename (with or without prefix)
|
||||||
|
archive_filename = archive_path.name
|
||||||
|
archive_filename_no_prefix = re.sub(r'^\d+_', '', archive_filename)
|
||||||
|
|
||||||
|
if json_filename and (
|
||||||
|
json_filename == archive_filename or
|
||||||
|
json_filename == archive_filename_no_prefix
|
||||||
|
):
|
||||||
|
return str(json_file)
|
||||||
|
|
||||||
|
except (json.JSONDecodeError, IOError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def load_sidecar_metadata(json_path: str) -> Optional[dict]:
|
def load_sidecar_metadata(json_path: str) -> Optional[dict]:
|
||||||
"""Load and parse a Gallery-DL sidecar JSON file."""
|
"""Load and parse a Gallery-DL sidecar JSON file."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
+16
-8
@@ -54,10 +54,11 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# Celery worker for processing import tasks
|
# Celery worker for processing import tasks (heavy processing)
|
||||||
celery-worker:
|
# Handles: import, thumbnail, sidecar, default queues
|
||||||
|
worker:
|
||||||
build: .
|
build: .
|
||||||
command: celery -A app.celery_app:celery worker --loglevel=info -Q scan,import,thumbnail,sidecar,default --concurrency=${CELERY_WORKER_CONCURRENCY:-2}
|
command: celery -A app.celery_app:celery worker --loglevel=info -Q import,thumbnail,sidecar,default --concurrency=${CELERY_WORKER_CONCURRENCY:-2}
|
||||||
environment:
|
environment:
|
||||||
- TZ=${TZ:-America/New_York}
|
- TZ=${TZ:-America/New_York}
|
||||||
- DB_USER=${DB_USER:-imagerepo}
|
- DB_USER=${DB_USER:-imagerepo}
|
||||||
@@ -78,10 +79,12 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# Celery Beat scheduler for periodic tasks
|
# Celery scheduler: Beat (periodic tasks) + Worker (maintenance/scan queues)
|
||||||
celery-beat:
|
# Handles: maintenance, scan queues + periodic task scheduling
|
||||||
|
# This ensures maintenance tasks aren't blocked by large import queues
|
||||||
|
scheduler:
|
||||||
build: .
|
build: .
|
||||||
command: celery -A app.celery_app:celery beat --loglevel=info
|
command: celery -A app.celery_app:celery worker --beat --loglevel=info -Q maintenance,scan --concurrency=1
|
||||||
environment:
|
environment:
|
||||||
- TZ=${TZ:-America/New_York}
|
- TZ=${TZ:-America/New_York}
|
||||||
- DB_USER=${DB_USER:-imagerepo}
|
- DB_USER=${DB_USER:-imagerepo}
|
||||||
@@ -92,9 +95,14 @@ services:
|
|||||||
- CELERY_BROKER_URL=redis://redis:6379/0
|
- CELERY_BROKER_URL=redis://redis:6379/0
|
||||||
- CELERY_RESULT_BACKEND=redis://redis:6379/0
|
- CELERY_RESULT_BACKEND=redis://redis:6379/0
|
||||||
- IMPORT_EVERY_SECONDS=${IMPORT_EVERY_SECONDS:-28800}
|
- IMPORT_EVERY_SECONDS=${IMPORT_EVERY_SECONDS:-28800}
|
||||||
|
volumes:
|
||||||
|
- ./imagerepo/images:/images
|
||||||
|
- ./import:/import
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
postgres:
|
||||||
- celery-worker
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
+11
-3
@@ -4,7 +4,7 @@ A Flask-based image repository with Celery task processing for importing, organi
|
|||||||
|
|
||||||
> **IMPORTANT**: This summary must be kept up to date with any code changes. Update the timestamp below when making modifications.
|
> **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)
|
> **Last Updated**: 2026-02-01 (Settings dashboard: system stats, task filtering, error details)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -290,12 +290,20 @@ Import tasks support two modes based on `context.deep_scan`:
|
|||||||
| Route | Purpose |
|
| Route | Purpose |
|
||||||
|-------|---------|
|
|-------|---------|
|
||||||
| `POST /api/import/trigger` | Start scan (params: `deep=true` for deep scan, `regenerate_thumbnails`, `reapply_sidecars`, `verify_hashes`) |
|
| `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/status` | Queue status counts and active batch info |
|
||||||
|
| `GET /api/import/tasks` | List tasks with filtering (params: `status`, `task_type`, `search`, `limit`, `offset`) |
|
||||||
| `GET /api/import/task/<id>` | Task details |
|
| `GET /api/import/task/<id>` | Task details |
|
||||||
| `POST /api/import/retry-failed` | Retry failed tasks |
|
| `POST /api/import/retry-failed` | Retry failed tasks |
|
||||||
| `POST /api/import/clear-completed` | Clear completed tasks |
|
| `POST /api/import/clear-completed` | Clear tasks (params: `statuses`, `older_than_days` for age-based cleanup) |
|
||||||
|
| `GET /api/import/task-stats` | Task counts with age breakdown (today, this week, older) |
|
||||||
| `GET /api/import/batches` | List import batches |
|
| `GET /api/import/batches` | List import batches |
|
||||||
|
|
||||||
|
### System Stats API
|
||||||
|
|
||||||
|
| Route | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `GET /api/system/stats` | System overview: total images, tags, storage used, pending imports |
|
||||||
|
|
||||||
### Settings API
|
### Settings API
|
||||||
|
|
||||||
| Route | Line | Purpose |
|
| Route | Line | Purpose |
|
||||||
|
|||||||
Reference in New Issue
Block a user