ui polish and importer logging tuning

This commit is contained in:
Bryan Van Deusen
2026-01-28 09:36:59 -05:00
parent 0bf1961175
commit d0fcde38e8
12 changed files with 110 additions and 133 deletions
+2 -1
View File
@@ -1,7 +1,8 @@
{
"permissions": {
"allow": [
"Bash(python:*)"
"Bash(python:*)",
"Bash(git mv:*)"
]
}
}
+5
View File
@@ -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
},
},
)
+9 -1
View File
@@ -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,
+13 -7
View File
@@ -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, '&lt;').replace(/>/g, '&gt;');
return `<a class="tag-chip" href="/gallery?tag=${encodedName}" title="Filter by ${escapedName}" onclick="event.stopPropagation()">${escapedDisplay}</a>`;
const prefix = icon !== '#' ? icon + ' ' : '#';
return `<a class="tag-chip" href="/gallery?tag=${encodedName}" title="Filter by ${escapedName}" onclick="event.stopPropagation()">${prefix}${escapedDisplay}</a>`;
}).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
+11
View File
@@ -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
+58
View File
@@ -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):
"""
-80
View File
@@ -1,80 +0,0 @@
<!-- /app/templates/_gallery_grid.html -->
<div class="gallery-grid">
{% for image in images.items %}
<div class="gallery-item img-clickable"
data-id="{{ image.id }}"
data-full="{{ url_for('main.serve_image', filename=image.filepath | replace('/images/', '') ) }}"
data-type="{{ 'video' if image.filename.lower().endswith(('.mp4', '.mov')) else 'image' }}"
data-filename="{{ image.filename }}">
<div class="gallery-thumb"
style="background-image: url('{{ url_for('main.serve_image', filename=(image.thumb_path or image.filepath) | replace('/images/', '') ) }}');"></div>
{% set visible_tags = image.tags | selectattr('kind', 'ne', 'archive') | list %}
{% if visible_tags %}
<div class="tag-overlay">
{% for t in visible_tags %}
<a class="tag-chip"
href="{{ url_for('main.gallery', tag=t.name) }}"
title="Filter by {{ t.name }}"
onclick="event.stopPropagation()">
{% if t.kind == 'artist' %}
🎨 {{ 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 }}
{% elif t.kind == 'series' %}
📺 {{ 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 }}
{% else %}
#{{ t.name }}
{% endif %}
</a>
{% endfor %}
</div>
{% endif %}
{% if image.filename.lower().endswith(('.mp4', '.mov')) %}
<div class="play-overlay"></div>
{% endif %}
<span class="image-date">{{ image.taken_at or image.imported_at | datetimeformat }}</span>
</div>
{% endfor %}
</div>
<!-- Modal -->
<div id="imageModal" class="modal">
<div class="modal-content">
<button id="modalClose" class="modal-close-btn" title="Close (ESC)">×</button>
<button id="modalPrev" class="modal-button modal-prev"></button>
<button id="modalNext" class="modal-button modal-next"></button>
<span class="modal-close-hint">ESC to close</span>
<div class="modal-body">
<div id="modalImageWrapper" class="modal-image-wrapper">
<img id="modalImage" src="" alt="Full View">
</div>
<div id="modalTagEditor" class="tag-editor" data-image-id="">
<div id="modalTagList" class="tags"></div>
<form id="modalTagForm" class="tag-form" autocomplete="off">
<div class="tag-form-wrapper">
<input type="text" id="tagInput" name="name" placeholder="Add tag..." autocomplete="off">
<div id="tagAutocomplete" class="tag-autocomplete"></div>
</div>
<button type="submit">Add</button>
</form>
</div>
</div>
</div>
</div>
<script src="{{ url_for('static', filename='js/modal-pagination.js') }}"></script>
{% if request.args.get('page', '1') == '1' and not request.args.get('per_page') %}
<script src="{{ url_for('static', filename='js/dynamic_gallery_paging.js') }}"></script>
{% endif %}
+5 -5
View File
@@ -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 %}
+1 -1
View File
@@ -176,7 +176,7 @@
</script>
<script src="{{ url_for('static', filename='js/gallery-infinite.js') }}"></script>
<script src="{{ url_for('static', filename='js/modal-pagination.js') }}"></script>
<script src="{{ url_for('static', filename='js/view-modal.js') }}"></script>
<script src="{{ url_for('static', filename='js/bulk-select.js') }}"></script>
{% if series_info %}
+1 -1
View File
@@ -64,7 +64,7 @@
</div>
</div>
<script src="{{ url_for('static', filename='js/modal-pagination.js') }}"></script>
<script src="{{ url_for('static', filename='js/view-modal.js') }}"></script>
<script src="{{ url_for('static', filename='js/showcase.js') }}"></script>
</body>
</html>
+3 -34
View File
@@ -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', ...
+2 -3
View File
@@ -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 |