diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 3c31ae0..ada7a03 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,8 @@ { "permissions": { "allow": [ - "Bash(python:*)" + "Bash(python:*)", + "Bash(git mv:*)" ] } } diff --git a/app/celery_app.py b/app/celery_app.py index c86c176..7753481 100644 --- a/app/celery_app.py +++ b/app/celery_app.py @@ -89,6 +89,11 @@ def make_celery(app=None): 'task': 'app.tasks.scan.recover_interrupted_tasks', 'schedule': 300, # Every 5 minutes }, + 'cleanup-old-tasks': { + 'task': 'app.tasks.scan.cleanup_old_tasks', + 'schedule': 86400, # Once per day + 'args': (7,), # Keep tasks for 7 days + }, }, ) diff --git a/app/main.py b/app/main.py index c2b348c..856ad57 100644 --- a/app/main.py +++ b/app/main.py @@ -2009,7 +2009,15 @@ def get_image_series_info(image_id): sp = SeriesPage.query.filter_by(image_id=image_id).first() if not sp: - return jsonify(ok=True, in_series=False) + # Return any series tags the image has (for auto-select in dropdown) + image = ImageRecord.query.get(image_id) + series_tags = [] + if image: + series_tags = [ + {'id': t.id, 'name': t.name} + for t in image.tags if t.kind == 'series' + ] + return jsonify(ok=True, in_series=False, series_tags=series_tags) return jsonify(ok=True, in_series=True, series_page={ 'id': sp.id, diff --git a/app/static/js/view-modal.js b/app/static/js/view-modal.js index ce453c9..d5d2151 100644 --- a/app/static/js/view-modal.js +++ b/app/static/js/view-modal.js @@ -1,4 +1,4 @@ -// /app/static/js/modal-pagination.js +// /app/static/js/view-modal.js document.addEventListener('DOMContentLoaded', () => { const modal = document.getElementById('imageModal'); @@ -140,10 +140,12 @@ document.addEventListener('DOMContentLoaded', () => { overlay.innerHTML = visibleTags.map(t => { const displayName = getTagDisplayNameForOverlay(t.name, t.kind); + const icon = getTagIcon(t.kind); const encodedName = encodeURIComponent(t.name); const escapedName = t.name.replace(/"/g, '"'); const escapedDisplay = displayName.replace(//g, '>'); - return `${escapedDisplay}`; + const prefix = icon !== '#' ? icon + ' ' : '#'; + return `${prefix}${escapedDisplay}`; }).join(''); } catch (e) { console.error('Failed to refresh tags for image', imageId, e); @@ -243,10 +245,8 @@ document.addEventListener('DOMContentLoaded', () => { addToSeriesStatus.textContent = '✓ Saved'; setTimeout(() => { addToSeriesStatus.textContent = ''; }, 2000); } - // Update the series info section with new data - showSeriesInfo(j.series_page); - // Re-enable the button - if (addToSeriesBtn) addToSeriesBtn.disabled = false; + // Fully reload series info to show updated management interface + await loadSeriesInfo(imageId); } else { if (addToSeriesStatus) addToSeriesStatus.textContent = j.error || 'Error'; if (addToSeriesBtn) addToSeriesBtn.disabled = false; @@ -292,7 +292,13 @@ document.addEventListener('DOMContentLoaded', () => { showAddToSeries(j.series_page); } else { // Show add-to-series option when not in a series - showAddToSeries(); + // Auto-select if image has exactly one series tag + const seriesTags = j.series_tags || []; + if (seriesTags.length === 1) { + showAddToSeries({ series_tag_id: seriesTags[0].id, page_number: 1 }); + } else { + showAddToSeries(); + } } } catch { // ignore diff --git a/app/tasks/import_file.py b/app/tasks/import_file.py index 26e5a74..494e185 100644 --- a/app/tasks/import_file.py +++ b/app/tasks/import_file.py @@ -212,6 +212,11 @@ def import_media_file(self, task_id: int): # Add artist tag _add_artist_tag(record, artist) + # Add archive tag if this file came from an archive + archive_tag_id = context.get('archive_tag_id') + if archive_tag_id: + _add_archive_tag(record, archive_tag_id) + db.session.add(record) db.session.commit() @@ -515,6 +520,12 @@ def _supersede_existing(task: ImportTask, old_record: ImageRecord, src_path: str old_record, dest_path, thumb_path, file_hash, phash, metadata, filename ) + # Add archive tag if this file came from an archive + context = task.context or {} + archive_tag_id = context.get('archive_tag_id') + if archive_tag_id: + _add_archive_tag(record, archive_tag_id) + db.session.commit() # Complete task diff --git a/app/tasks/scan.py b/app/tasks/scan.py index f38e673..165ea1d 100644 --- a/app/tasks/scan.py +++ b/app/tasks/scan.py @@ -302,6 +302,64 @@ def recover_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): """ diff --git a/app/templates/_gallery_grid.html b/app/templates/_gallery_grid.html deleted file mode 100644 index 67b8b77..0000000 --- a/app/templates/_gallery_grid.html +++ /dev/null @@ -1,80 +0,0 @@ - -