From d0fcde38e815f071cfa73a3fd2582ac43891a7d2 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 28 Jan 2026 09:36:59 -0500 Subject: [PATCH] ui polish and importer logging tuning --- .claude/settings.local.json | 3 +- app/celery_app.py | 5 ++ app/main.py | 10 +++- app/static/js/view-modal.js | 20 +++++--- app/tasks/import_file.py | 11 +++++ app/tasks/scan.py | 58 +++++++++++++++++++++++ app/templates/_gallery_grid.html | 80 -------------------------------- app/templates/_gallery_item.html | 10 ++-- app/templates/gallery.html | 2 +- app/templates/showcase.html | 2 +- app/utils/image_importer.py | 37 ++------------- summary.md | 5 +- 12 files changed, 110 insertions(+), 133 deletions(-) delete mode 100644 app/templates/_gallery_grid.html 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 @@ - - - - - - - - - - -{% if request.args.get('page', '1') == '1' and not request.args.get('per_page') %} - -{% endif %} - diff --git a/app/templates/_gallery_item.html b/app/templates/_gallery_item.html index da8f0b0..d5f698c 100644 --- a/app/templates/_gallery_item.html +++ b/app/templates/_gallery_item.html @@ -17,15 +17,15 @@ title="Filter by {{ t.name }}" onclick="event.stopPropagation()"> {% if t.kind == 'artist' %} - {{ t.name.split(':', 1)[1] if ':' in t.name else t.name }} + 🎨 {{ t.name.split(':', 1)[1] if ':' in t.name else t.name }} {% elif t.kind == 'character' %} - {{ t.name.split(':', 1)[1] if ':' in t.name else t.name }} + 👤 {{ t.name.split(':', 1)[1] if ':' in t.name else t.name }} {% elif t.kind == 'series' %} - {{ t.name.split(':', 1)[1] if ':' in t.name else t.name }} + 📺 {{ t.name.split(':', 1)[1] if ':' in t.name else t.name }} {% elif t.kind == 'fandom' %} - {{ t.name.split(':', 1)[1] if ':' in t.name else t.name }} + 🎭 {{ t.name.split(':', 1)[1] if ':' in t.name else t.name }} {% elif t.kind == 'rating' %} - {{ t.name.split(':', 1)[1] if ':' in t.name else t.name }} + ⚠️ {{ t.name.split(':', 1)[1] if ':' in t.name else t.name }} {% else %} #{{ t.name }} {% endif %} diff --git a/app/templates/gallery.html b/app/templates/gallery.html index 6bd9517..b3bd6d8 100644 --- a/app/templates/gallery.html +++ b/app/templates/gallery.html @@ -176,7 +176,7 @@ - + {% if series_info %} diff --git a/app/templates/showcase.html b/app/templates/showcase.html index aa713c9..6f4bac4 100644 --- a/app/templates/showcase.html +++ b/app/templates/showcase.html @@ -64,7 +64,7 @@ - + diff --git a/app/utils/image_importer.py b/app/utils/image_importer.py index 8669b45..cf19dcc 100644 --- a/app/utils/image_importer.py +++ b/app/utils/image_importer.py @@ -6,6 +6,7 @@ from datetime import datetime from pathlib import Path import hashlib import json +import logging import mimetypes import os import re @@ -14,6 +15,8 @@ import subprocess import tempfile import uuid +log = logging.getLogger(__name__) + from PIL import Image, ImageOps import exifread import imagehash @@ -843,40 +846,6 @@ def is_first_volume(path: str) -> bool: return True -def compute_next_archive_tag_name(artist: str, width: int = ARCHIVE_NUM_WIDTH) -> str: - """ - Return a unique incremental tag name like: 'archive:{artist}/0001', '0002', ... - - Looks only at Tag(kind='archive') with names starting 'archive:{artist}/' - - Finds max numeric suffix and returns next number (zero-padded). - - Ensures no collision even if tags were renamed. - """ - prefix = f"archive:{artist}/" - # Pull existing tag names for this artist/prefix - rows = (Tag.query - .with_entities(Tag.name) - .filter(Tag.kind == 'archive', - Tag.name.like(prefix + '%')) - .all()) - - max_n = 0 - for (name,) in rows: - tail = name[len(prefix):] - if tail.isdigit(): - try: - n = int(tail) - if n > max_n: - max_n = n - except Exception: - pass - - # Propose next number and make sure it doesn't already exist - n = max_n + 1 - while True: - candidate = f"{prefix}{str(n).zfill(width)}" - if not Tag.query.filter_by(name=candidate).first(): - return candidate - n += 1 - def compute_next_archive_tag_name(artist: str, width: int = ARCHIVE_NUM_WIDTH) -> str: """ Return a unique incremental tag name like: 'archive:{artist}/0001', '0002', ... diff --git a/summary.md b/summary.md index c35c7ca..1175349 100644 --- a/summary.md +++ b/summary.md @@ -322,9 +322,8 @@ Task types: scan, import_image, import_archive, thumbnail, sidecar | `reader.html` | Series reader with vertical scroll, page jump, thumbnail navigation | | `tags_list.html` | Tag browser with preview images | | `settings.html` | Import settings, deletion tools, duplicate detection, maintenance tools | -| `_gallery_item.html` | Single gallery item partial (with tag overlay) | +| `_gallery_item.html` | Single gallery item partial (with tag overlay icons) | | `_gallery_modal.html` | Image modal with tag editor and series management | -| `_gallery_grid.html` | Gallery grid partial | | `_pagination.html` | Pagination controls | ### JavaScript Files (`app/static/js/`) @@ -332,7 +331,7 @@ Task types: scan, import_image, import_archive, thumbnail, sidecar | File | Purpose | |------|---------| | `gallery-infinite.js` | Infinite scroll, timeline navigation | -| `modal-pagination.js` | Modal image navigation, tag editing, series management (add/update/move) | +| `view-modal.js` | Modal image navigation, tag editing, series management (add/update/move) | | `bulk-select.js` | Multi-select with ordered selection, bulk tag editing, tag refresh, series bulk-add | | `tag-editor.js` | Tag autocomplete and editing | | `showcase.js` | Showcase shuffle functionality |