ui polish and importer logging tuning
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(python:*)"
|
||||
"Bash(python:*)",
|
||||
"Bash(git mv:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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, '<').replace(/>/g, '>');
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user