additional tag edditing functionality. and delete by tag. added import tuning options to settings
This commit is contained in:
+145
-1
@@ -267,7 +267,7 @@ def search_tags():
|
||||
.all()
|
||||
)
|
||||
|
||||
return jsonify(tags=[{"name": t.name, "kind": t.kind} for t in tags])
|
||||
return jsonify(tags=[{"id": t.id, "name": t.name, "kind": t.kind} for t in tags])
|
||||
|
||||
|
||||
@main.get("/image/<int:image_id>/tags")
|
||||
@@ -427,3 +427,147 @@ def delete_tag(tag_id):
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(ok=True)
|
||||
|
||||
|
||||
# ----------------------------
|
||||
# Filtered deletion endpoints
|
||||
# ----------------------------
|
||||
|
||||
@main.post("/api/delete-by-tag")
|
||||
def delete_images_by_tag():
|
||||
"""
|
||||
Delete all images that have a specific tag.
|
||||
Also removes the image files and thumbnails from disk.
|
||||
Query params:
|
||||
- tag_id: ID of the tag whose images should be deleted
|
||||
- tag_name: Name of the tag (must match for security validation)
|
||||
- delete_tag: if "true", also delete the tag itself
|
||||
"""
|
||||
tag_id = request.form.get("tag_id", type=int)
|
||||
tag_name = request.form.get("tag_name", "").strip()
|
||||
delete_tag_also = request.form.get("delete_tag") == "true"
|
||||
|
||||
if not tag_id:
|
||||
return jsonify(ok=False, error="tag_id is required"), 400
|
||||
|
||||
if not tag_name:
|
||||
return jsonify(ok=False, error="tag_name confirmation is required"), 400
|
||||
|
||||
tag = Tag.query.get_or_404(tag_id)
|
||||
|
||||
# Security: verify the provided tag name matches the actual tag
|
||||
if tag.name != tag_name:
|
||||
return jsonify(ok=False, error="Tag name confirmation does not match"), 403
|
||||
|
||||
images_to_delete = list(tag.images)
|
||||
|
||||
deleted_count = 0
|
||||
errors = []
|
||||
|
||||
for img in images_to_delete:
|
||||
try:
|
||||
# Remove image file from disk
|
||||
if img.filepath and os.path.exists(img.filepath):
|
||||
os.remove(img.filepath)
|
||||
# Remove thumbnail if exists
|
||||
if img.thumb_path and os.path.exists(img.thumb_path):
|
||||
os.remove(img.thumb_path)
|
||||
# Delete DB record
|
||||
db.session.delete(img)
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
errors.append(f"Failed to delete {img.filename}: {e}")
|
||||
|
||||
# Optionally delete the tag itself
|
||||
if delete_tag_also:
|
||||
db.session.delete(tag)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(
|
||||
ok=True,
|
||||
deleted_count=deleted_count,
|
||||
tag_deleted=delete_tag_also,
|
||||
errors=errors if errors else None
|
||||
)
|
||||
|
||||
|
||||
@main.get("/api/tag/<int:tag_id>/image-count")
|
||||
def get_tag_image_count(tag_id):
|
||||
"""Get the number of images associated with a tag."""
|
||||
tag = Tag.query.get_or_404(tag_id)
|
||||
count = len(tag.images)
|
||||
return jsonify(ok=True, count=count, tag_name=tag.name)
|
||||
|
||||
|
||||
# ----------------------------
|
||||
# Import settings endpoints
|
||||
# ----------------------------
|
||||
|
||||
@main.get("/api/import-settings")
|
||||
def get_import_settings():
|
||||
"""Get current import filter settings."""
|
||||
# Load from file first, fall back to env defaults
|
||||
import json
|
||||
settings_path = "/import/settings.json"
|
||||
settings = {
|
||||
"min_width": int(os.environ.get("IMPORT_MIN_WIDTH", "0")),
|
||||
"min_height": int(os.environ.get("IMPORT_MIN_HEIGHT", "0")),
|
||||
"skip_transparent": os.environ.get("IMPORT_SKIP_TRANSPARENT", "false").lower() == "true",
|
||||
"transparency_threshold": float(os.environ.get("IMPORT_TRANSPARENCY_THRESHOLD", "0.9")),
|
||||
"phash_threshold": int(os.environ.get("IMPORT_PHASH_THRESHOLD", "2")),
|
||||
}
|
||||
try:
|
||||
if os.path.exists(settings_path):
|
||||
with open(settings_path, "r") as f:
|
||||
file_settings = json.load(f)
|
||||
settings.update(file_settings)
|
||||
except Exception:
|
||||
pass
|
||||
return jsonify(ok=True, settings=settings)
|
||||
|
||||
|
||||
@main.post("/api/import-settings")
|
||||
def save_import_settings():
|
||||
"""
|
||||
Save import filter settings.
|
||||
Settings are stored in /import/settings.json and loaded by the importer.
|
||||
"""
|
||||
settings = {
|
||||
"min_width": request.form.get("min_width", 0, type=int),
|
||||
"min_height": request.form.get("min_height", 0, type=int),
|
||||
"skip_transparent": request.form.get("skip_transparent") == "true",
|
||||
"transparency_threshold": request.form.get("transparency_threshold", 0.9, type=float),
|
||||
"phash_threshold": request.form.get("phash_threshold", 2, type=int),
|
||||
}
|
||||
|
||||
import json
|
||||
settings_path = "/import/settings.json"
|
||||
try:
|
||||
with open(settings_path, "w") as f:
|
||||
json.dump(settings, f, indent=2)
|
||||
return jsonify(ok=True, settings=settings)
|
||||
except Exception as e:
|
||||
return jsonify(ok=False, error=str(e)), 500
|
||||
|
||||
|
||||
@main.get("/api/import-stats")
|
||||
def get_import_stats():
|
||||
"""Get import statistics including filtered image counts."""
|
||||
import json
|
||||
stats_path = "/import/stats.json"
|
||||
try:
|
||||
if os.path.exists(stats_path):
|
||||
with open(stats_path, "r") as f:
|
||||
stats = json.load(f)
|
||||
else:
|
||||
stats = {
|
||||
"total_processed": 0,
|
||||
"total_imported": 0,
|
||||
"filtered_by_dimension": 0,
|
||||
"filtered_by_transparency": 0,
|
||||
"filtered_by_duplicate": 0,
|
||||
}
|
||||
return jsonify(ok=True, stats=stats)
|
||||
except Exception as e:
|
||||
return jsonify(ok=False, error=str(e)), 500
|
||||
|
||||
@@ -115,6 +115,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
autocompleteSelectedIndex = -1;
|
||||
}
|
||||
|
||||
function positionAutocomplete() {
|
||||
if (!tagAutocomplete || !tagInput) return;
|
||||
const rect = tagInput.getBoundingClientRect();
|
||||
tagAutocomplete.style.top = `${rect.bottom + 4}px`;
|
||||
tagAutocomplete.style.left = `${rect.left}px`;
|
||||
tagAutocomplete.style.width = `${rect.width}px`;
|
||||
}
|
||||
|
||||
function renderAutocomplete(tags) {
|
||||
if (!tagAutocomplete) return;
|
||||
autocompleteItems = tags;
|
||||
@@ -122,6 +130,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
if (tags.length === 0) {
|
||||
tagAutocomplete.innerHTML = '<div class="tag-autocomplete-empty">No matching tags</div>';
|
||||
positionAutocomplete();
|
||||
tagAutocomplete.classList.add('active');
|
||||
return;
|
||||
}
|
||||
@@ -134,6 +143,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
<span class="tag-kind">${t.kind || 'user'}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
positionAutocomplete();
|
||||
tagAutocomplete.classList.add('active');
|
||||
}
|
||||
|
||||
@@ -214,9 +224,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
}
|
||||
|
||||
// Click on autocomplete item
|
||||
// Click on autocomplete item - use mousedown to fire before blur
|
||||
if (tagAutocomplete) {
|
||||
tagAutocomplete.addEventListener('click', (e) => {
|
||||
tagAutocomplete.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault(); // Prevent blur from firing
|
||||
e.stopPropagation();
|
||||
const item = e.target.closest('.tag-autocomplete-item');
|
||||
if (!item) return;
|
||||
const name = item.dataset.name;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
const MOBILE_COLUMNS = 2;
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
const SCROLL_THRESHOLD = 1500; // Load more when 1500px from bottom (about 2-3 rows ahead)
|
||||
const LOAD_BATCH_SIZE = 12;
|
||||
const LOAD_BATCH_SIZE = 4;
|
||||
|
||||
// State
|
||||
let columns = [];
|
||||
|
||||
+33
-15
@@ -353,17 +353,25 @@ header {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Modal body */
|
||||
/* Modal body - horizontal layout with image on left, tags on right */
|
||||
.modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
max-width: 1400px;
|
||||
max-width: 1600px;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
/* On smaller screens, stack vertically */
|
||||
@media (max-width: 900px) {
|
||||
.modal-body {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Image wrapper & zoom */
|
||||
.modal-image-wrapper {
|
||||
flex: 1;
|
||||
@@ -494,14 +502,27 @@ header {
|
||||
.tag-chip:hover { background: rgba(255,255,255,1); }
|
||||
|
||||
|
||||
/* Modal tag editor - refined layout */
|
||||
/* Modal tag editor - side panel layout */
|
||||
.tag-editor {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
max-height: calc(100vh - 40px);
|
||||
overflow-y: auto;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* On smaller screens, make tag editor full width */
|
||||
@media (max-width: 900px) {
|
||||
.tag-editor {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
min-width: unset;
|
||||
max-height: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.tag-editor .tags {
|
||||
@@ -578,21 +599,18 @@ header {
|
||||
background: var(--btn-primary-hover);
|
||||
}
|
||||
|
||||
/* Autocomplete dropdown */
|
||||
/* Autocomplete dropdown - uses fixed positioning to escape overflow containers */
|
||||
.tag-autocomplete {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
position: fixed;
|
||||
background: var(--panel);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 6px;
|
||||
margin-top: 4px;
|
||||
max-height: 200px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
z-index: 1010;
|
||||
z-index: 10100;
|
||||
display: none;
|
||||
box-shadow: var(--shadow-3);
|
||||
min-width: 200px;
|
||||
}
|
||||
.tag-autocomplete.active {
|
||||
display: block;
|
||||
|
||||
+338
-2
@@ -16,16 +16,352 @@
|
||||
<button type="submit" class="btn warning-btn">Regenerate Thumbnails</button>
|
||||
</form>
|
||||
|
||||
|
||||
<form action="{{ url_for('main.reset_db') }}" method="post" onsubmit="return confirm('Are you sure you want to delete all image records? This action cannot be undone.');">
|
||||
<button type="submit" class="btn danger-btn">Reset Image Database</button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import Filters Section -->
|
||||
<div class="settings-container" style="margin-top: 1rem;">
|
||||
<div class="settings-section">
|
||||
<h2>Import Filters</h2>
|
||||
<p class="settings-description">Configure filters to skip certain images during import.</p>
|
||||
|
||||
<form id="importSettingsForm" class="form-card">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Minimum Width (px)</label>
|
||||
<input type="number" id="minWidth" name="min_width" class="form-input" min="0" value="0" placeholder="0 = no minimum">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Minimum Height (px)</label>
|
||||
<input type="number" id="minHeight" name="min_height" class="form-input" min="0" value="0" placeholder="0 = no minimum">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label checkbox-label">
|
||||
<input type="checkbox" id="skipTransparent" name="skip_transparent">
|
||||
Skip mostly transparent images
|
||||
</label>
|
||||
<p class="form-help">Useful for filtering out UI elements, overlays, and buttons.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="transparencyThresholdGroup" style="display: none;">
|
||||
<label class="form-label">Transparency Threshold (%)</label>
|
||||
<input type="number" id="transparencyThreshold" name="transparency_threshold" class="form-input" min="50" max="100" value="90">
|
||||
<p class="form-help">Images with more than this % of transparent pixels will be skipped.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Duplicate Detection Sensitivity</label>
|
||||
<select id="phashThreshold" name="phash_threshold" class="form-input">
|
||||
<option value="0">Disabled (import all images)</option>
|
||||
<option value="1">Strict (only exact visual matches)</option>
|
||||
<option value="2">Normal (slight variations filtered)</option>
|
||||
<option value="5">Loose (more variations allowed)</option>
|
||||
<option value="10">Very Loose (only obvious duplicates filtered)</option>
|
||||
</select>
|
||||
<p class="form-help">Controls how similar images must be to be considered duplicates. Higher values allow more variations through.</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn primary-btn" style="width: 100%;">Save Filter Settings</button>
|
||||
</form>
|
||||
|
||||
<div id="importStats" class="import-stats" style="margin-top: 1rem;">
|
||||
<h3>Last Import Statistics</h3>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<span class="stat-value" id="statProcessed">-</span>
|
||||
<span class="stat-label">Processed</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-value" id="statImported">-</span>
|
||||
<span class="stat-label">Imported</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-value" id="statDimension">-</span>
|
||||
<span class="stat-label">Filtered (size)</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-value" id="statTransparency">-</span>
|
||||
<span class="stat-label">Filtered (transparent)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtered Deletion Section -->
|
||||
<div class="settings-container" style="margin-top: 1rem;">
|
||||
<div class="settings-section">
|
||||
<h2>Delete Images by Tag</h2>
|
||||
<p class="settings-description">Permanently delete all images associated with a specific tag (e.g., remove an artist's entire collection).</p>
|
||||
|
||||
<form id="deleteByTagForm" class="form-card">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Select Tag to Delete</label>
|
||||
<select id="deleteTagSelect" name="tag_id" class="form-input" required>
|
||||
<option value="">-- Select a tag --</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="deletePreview" class="delete-preview" style="display: none;">
|
||||
<p>This will permanently delete <strong id="deleteCount">0</strong> images and their files.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="confirmationGroup" style="display: none;">
|
||||
<label class="form-label">Type the tag name to confirm: <code id="confirmTagName"></code></label>
|
||||
<input type="text" id="deleteConfirmInput" class="form-input" placeholder="Type tag name here..." autocomplete="off">
|
||||
<p class="form-help">This action cannot be undone.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label checkbox-label">
|
||||
<input type="checkbox" id="deleteTagAlso" name="delete_tag">
|
||||
Also delete the tag itself
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn danger-btn" style="width: 100%;" disabled id="deleteByTagBtn">Delete Images</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-container" style="margin-top: 1rem;">
|
||||
<div class="settings-info">
|
||||
<p>Use these tools to manually trigger an import or reset the image database. Resetting the database does not delete the actual image files.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.settings-description {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.form-help {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--btn-primary);
|
||||
}
|
||||
.import-stats {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.import-stats h3 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.delete-preview {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--flash-danger);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Load import settings
|
||||
fetch('/api/import-settings')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.ok) {
|
||||
document.getElementById('minWidth').value = data.settings.min_width || 0;
|
||||
document.getElementById('minHeight').value = data.settings.min_height || 0;
|
||||
document.getElementById('skipTransparent').checked = data.settings.skip_transparent || false;
|
||||
document.getElementById('transparencyThreshold').value = (data.settings.transparency_threshold || 0.9) * 100;
|
||||
document.getElementById('phashThreshold').value = data.settings.phash_threshold ?? 2;
|
||||
toggleTransparencyThreshold();
|
||||
}
|
||||
});
|
||||
|
||||
// Load import stats
|
||||
fetch('/api/import-stats')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.ok) {
|
||||
document.getElementById('statProcessed').textContent = data.stats.total_processed || 0;
|
||||
document.getElementById('statImported').textContent = data.stats.total_imported || 0;
|
||||
document.getElementById('statDimension').textContent = data.stats.filtered_by_dimension || 0;
|
||||
document.getElementById('statTransparency').textContent = data.stats.filtered_by_transparency || 0;
|
||||
}
|
||||
});
|
||||
|
||||
// Load tags for deletion dropdown
|
||||
loadTagsForDeletion();
|
||||
|
||||
// Toggle transparency threshold visibility
|
||||
document.getElementById('skipTransparent').addEventListener('change', toggleTransparencyThreshold);
|
||||
|
||||
function toggleTransparencyThreshold() {
|
||||
const show = document.getElementById('skipTransparent').checked;
|
||||
document.getElementById('transparencyThresholdGroup').style.display = show ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Save import settings
|
||||
document.getElementById('importSettingsForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData();
|
||||
fd.append('min_width', document.getElementById('minWidth').value);
|
||||
fd.append('min_height', document.getElementById('minHeight').value);
|
||||
fd.append('skip_transparent', document.getElementById('skipTransparent').checked ? 'true' : 'false');
|
||||
fd.append('transparency_threshold', document.getElementById('transparencyThreshold').value / 100);
|
||||
fd.append('phash_threshold', document.getElementById('phashThreshold').value);
|
||||
|
||||
const r = await fetch('/api/import-settings', { method: 'POST', body: fd });
|
||||
const data = await r.json();
|
||||
if (data.ok) {
|
||||
alert('Settings saved successfully!');
|
||||
} else {
|
||||
alert('Failed to save settings: ' + (data.error || 'Unknown error'));
|
||||
}
|
||||
});
|
||||
|
||||
// Tag selection for deletion
|
||||
let selectedTagName = '';
|
||||
document.getElementById('deleteTagSelect').addEventListener('change', async (e) => {
|
||||
const tagId = e.target.value;
|
||||
const previewEl = document.getElementById('deletePreview');
|
||||
const countEl = document.getElementById('deleteCount');
|
||||
const confirmGroup = document.getElementById('confirmationGroup');
|
||||
const confirmInput = document.getElementById('deleteConfirmInput');
|
||||
const confirmTagName = document.getElementById('confirmTagName');
|
||||
const btn = document.getElementById('deleteByTagBtn');
|
||||
|
||||
// Reset state
|
||||
confirmInput.value = '';
|
||||
btn.disabled = true;
|
||||
selectedTagName = '';
|
||||
|
||||
if (!tagId) {
|
||||
previewEl.style.display = 'none';
|
||||
confirmGroup.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const r = await fetch(`/api/tag/${tagId}/image-count`);
|
||||
const data = await r.json();
|
||||
if (data.ok) {
|
||||
countEl.textContent = data.count;
|
||||
selectedTagName = data.tag_name;
|
||||
confirmTagName.textContent = selectedTagName;
|
||||
previewEl.style.display = 'block';
|
||||
confirmGroup.style.display = data.count > 0 ? 'block' : 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Enable delete button only when confirmation matches
|
||||
document.getElementById('deleteConfirmInput').addEventListener('input', (e) => {
|
||||
const btn = document.getElementById('deleteByTagBtn');
|
||||
const typed = e.target.value.trim();
|
||||
btn.disabled = typed !== selectedTagName;
|
||||
});
|
||||
|
||||
// Delete by tag form
|
||||
document.getElementById('deleteByTagForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const tagId = document.getElementById('deleteTagSelect').value;
|
||||
const deleteTag = document.getElementById('deleteTagAlso').checked;
|
||||
const count = document.getElementById('deleteCount').textContent;
|
||||
const confirmInput = document.getElementById('deleteConfirmInput');
|
||||
|
||||
// Double-check confirmation matches (defense in depth)
|
||||
if (confirmInput.value.trim() !== selectedTagName) {
|
||||
alert('Tag name confirmation does not match.');
|
||||
return;
|
||||
}
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('tag_id', tagId);
|
||||
fd.append('tag_name', selectedTagName); // Server validates this matches the ID
|
||||
fd.append('delete_tag', deleteTag ? 'true' : 'false');
|
||||
|
||||
const r = await fetch('/api/delete-by-tag', { method: 'POST', body: fd });
|
||||
const data = await r.json();
|
||||
if (data.ok) {
|
||||
alert(`Successfully deleted ${data.deleted_count} images.${data.tag_deleted ? ' Tag was also deleted.' : ''}`);
|
||||
loadTagsForDeletion();
|
||||
document.getElementById('deletePreview').style.display = 'none';
|
||||
document.getElementById('confirmationGroup').style.display = 'none';
|
||||
document.getElementById('deleteConfirmInput').value = '';
|
||||
document.getElementById('deleteByTagBtn').disabled = true;
|
||||
selectedTagName = '';
|
||||
} else {
|
||||
alert('Failed to delete images: ' + (data.error || 'Unknown error'));
|
||||
}
|
||||
});
|
||||
|
||||
async function loadTagsForDeletion() {
|
||||
const select = document.getElementById('deleteTagSelect');
|
||||
select.innerHTML = '<option value="">-- Select a tag --</option>';
|
||||
|
||||
// Get all tags (with IDs) from the API
|
||||
const r = await fetch('/api/tags/search?limit=500');
|
||||
const data = await r.json();
|
||||
if (data.tags) {
|
||||
// Group by kind
|
||||
const grouped = {};
|
||||
data.tags.forEach(t => {
|
||||
const kind = t.kind || 'user';
|
||||
if (!grouped[kind]) grouped[kind] = [];
|
||||
grouped[kind].push(t);
|
||||
});
|
||||
|
||||
// Add to select with optgroups
|
||||
Object.keys(grouped).sort().forEach(kind => {
|
||||
const optgroup = document.createElement('optgroup');
|
||||
optgroup.label = kind.charAt(0).toUpperCase() + kind.slice(1) + ' Tags';
|
||||
grouped[kind].sort((a, b) => a.name.localeCompare(b.name)).forEach(tag => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = tag.id; // Use tag ID as value
|
||||
opt.textContent = tag.name;
|
||||
optgroup.appendChild(opt);
|
||||
});
|
||||
select.appendChild(optgroup);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
+167
-9
@@ -44,6 +44,68 @@ ALLOWED_ARCHIVE_EXTS = (
|
||||
".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tbz2", ".tar.xz", ".txz"
|
||||
)
|
||||
|
||||
# Import filter settings (loaded from file or env)
|
||||
IMPORT_SETTINGS_PATH = "/import/settings.json"
|
||||
IMPORT_STATS_PATH = "/import/stats.json"
|
||||
|
||||
|
||||
def load_import_settings() -> dict:
|
||||
"""Load import filter settings from file or environment."""
|
||||
defaults = {
|
||||
"min_width": int(os.environ.get("IMPORT_MIN_WIDTH", "0")),
|
||||
"min_height": int(os.environ.get("IMPORT_MIN_HEIGHT", "0")),
|
||||
"skip_transparent": os.environ.get("IMPORT_SKIP_TRANSPARENT", "false").lower() == "true",
|
||||
"transparency_threshold": float(os.environ.get("IMPORT_TRANSPARENCY_THRESHOLD", "0.9")),
|
||||
"phash_threshold": int(os.environ.get("IMPORT_PHASH_THRESHOLD", "2")),
|
||||
}
|
||||
try:
|
||||
if os.path.exists(IMPORT_SETTINGS_PATH):
|
||||
with open(IMPORT_SETTINGS_PATH, "r") as f:
|
||||
file_settings = json.load(f)
|
||||
defaults.update(file_settings)
|
||||
except Exception as e:
|
||||
print(f"[WARN] Failed to load import settings: {e}")
|
||||
return defaults
|
||||
|
||||
|
||||
def save_import_stats(stats: dict) -> None:
|
||||
"""Save import statistics to file."""
|
||||
try:
|
||||
with open(IMPORT_STATS_PATH, "w") as f:
|
||||
json.dump(stats, f, indent=2)
|
||||
except Exception as e:
|
||||
print(f"[WARN] Failed to save import stats: {e}")
|
||||
|
||||
|
||||
def is_mostly_transparent(file_path: str, threshold: float = 0.9) -> bool:
|
||||
"""
|
||||
Check if an image is mostly transparent (> threshold % of pixels are transparent).
|
||||
Returns False for images without alpha channel.
|
||||
"""
|
||||
try:
|
||||
with Image.open(file_path) as img:
|
||||
if img.mode not in ("RGBA", "LA", "P"):
|
||||
return False
|
||||
|
||||
# Convert palette images with transparency
|
||||
if img.mode == "P" and "transparency" in img.info:
|
||||
img = img.convert("RGBA")
|
||||
elif img.mode == "LA":
|
||||
img = img.convert("RGBA")
|
||||
elif img.mode != "RGBA":
|
||||
return False
|
||||
|
||||
# Get alpha channel and count transparent pixels
|
||||
alpha = img.split()[-1]
|
||||
total_pixels = alpha.size[0] * alpha.size[1]
|
||||
# Count pixels where alpha < 128 (more than 50% transparent)
|
||||
transparent_count = sum(1 for p in alpha.getdata() if p < 128)
|
||||
transparency_ratio = transparent_count / total_pixels
|
||||
return transparency_ratio > threshold
|
||||
except Exception as e:
|
||||
print(f"[WARN] Failed to check transparency for {file_path}: {e}")
|
||||
return False
|
||||
|
||||
# Regex for multi-part detection
|
||||
RAR_PART_RE = re.compile(r"\.part(\d+)\.rar$", re.IGNORECASE)
|
||||
SEVENZ_PART_RE = re.compile(r"\.7z\.(\d{3})$", re.IGNORECASE)
|
||||
@@ -165,6 +227,28 @@ def import_images_task(source_dir: str, dest_dir: str) -> str:
|
||||
# Cleanup any stale extraction dirs from previous runs/crashes
|
||||
cleanup_stale_tempdirs()
|
||||
|
||||
# Load import filter settings
|
||||
settings = load_import_settings()
|
||||
min_width = settings.get("min_width", 0)
|
||||
min_height = settings.get("min_height", 0)
|
||||
skip_transparent = settings.get("skip_transparent", False)
|
||||
transparency_threshold = settings.get("transparency_threshold", 0.9)
|
||||
|
||||
phash_threshold = settings.get("phash_threshold", 2)
|
||||
|
||||
print(f"[INFO] Import settings: min_width={min_width}, min_height={min_height}, "
|
||||
f"skip_transparent={skip_transparent}, transparency_threshold={transparency_threshold}, "
|
||||
f"phash_threshold={phash_threshold}")
|
||||
|
||||
# Statistics tracking
|
||||
stats = {
|
||||
"total_processed": 0,
|
||||
"total_imported": 0,
|
||||
"filtered_by_dimension": 0,
|
||||
"filtered_by_transparency": 0,
|
||||
"filtered_by_duplicate": 0,
|
||||
}
|
||||
|
||||
imported: list[str] = []
|
||||
batch_size = 10
|
||||
batch_counter = 0
|
||||
@@ -205,7 +289,9 @@ def import_images_task(source_dir: str, dest_dir: str) -> str:
|
||||
archive_path=full_path,
|
||||
dest_dir=dest_dir,
|
||||
artist=artist_dir,
|
||||
existing_phashes=existing_phashes
|
||||
existing_phashes=existing_phashes,
|
||||
settings=settings,
|
||||
stats=stats
|
||||
)
|
||||
imported.append(f"[archive]{file}:{count}")
|
||||
except Exception as e:
|
||||
@@ -216,14 +302,37 @@ def import_images_task(source_dir: str, dest_dir: str) -> str:
|
||||
if not lower.endswith(ALLOWED_MEDIA_EXTS):
|
||||
continue
|
||||
|
||||
stats["total_processed"] += 1
|
||||
|
||||
# Apply dimension filter
|
||||
if min_width > 0 or min_height > 0:
|
||||
md = extract_metadata(full_path)
|
||||
if md.get("width") and md.get("height"):
|
||||
if md["width"] < min_width or md["height"] < min_height:
|
||||
print(f"[SKIP] Too small ({md['width']}x{md['height']}): {file}")
|
||||
stats["filtered_by_dimension"] += 1
|
||||
continue
|
||||
|
||||
# Apply transparency filter
|
||||
if skip_transparent and lower.endswith((".png", ".gif", ".webp")):
|
||||
if is_mostly_transparent(full_path, transparency_threshold):
|
||||
print(f"[SKIP] Mostly transparent: {file}")
|
||||
stats["filtered_by_transparency"] += 1
|
||||
continue
|
||||
|
||||
added, phash, _rec = import_single_file(
|
||||
src_path=full_path,
|
||||
dest_dir=dest_dir,
|
||||
artist=artist_dir,
|
||||
existing_phashes=existing_phashes
|
||||
existing_phashes=existing_phashes,
|
||||
phash_threshold=phash_threshold
|
||||
)
|
||||
if added:
|
||||
imported.append(file)
|
||||
stats["total_imported"] += 1
|
||||
elif _rec is None and phash is None:
|
||||
# Was a duplicate
|
||||
stats["filtered_by_duplicate"] += 1
|
||||
|
||||
if phash:
|
||||
md = extract_metadata(full_path)
|
||||
@@ -242,17 +351,32 @@ def import_images_task(source_dir: str, dest_dir: str) -> str:
|
||||
db.session.commit()
|
||||
print(f"[INFO] Committed final batch of {batch_counter} items.")
|
||||
|
||||
summary = f"Imported {len(imported)} items."
|
||||
# Save stats
|
||||
save_import_stats(stats)
|
||||
|
||||
summary = f"Imported {len(imported)} items. Filtered: {stats['filtered_by_dimension']} by size, {stats['filtered_by_transparency']} by transparency."
|
||||
print(f"[INFO] {summary}")
|
||||
return summary
|
||||
|
||||
|
||||
def process_archive(archive_path, dest_dir, artist, existing_phashes):
|
||||
def process_archive(archive_path, dest_dir, artist, existing_phashes, settings=None, stats=None):
|
||||
"""
|
||||
Extracts archive to temp dir, imports/tag existing images, and records ArchiveRecord.
|
||||
Creates an archive:* tag ONLY if at least one image was imported or tagged.
|
||||
Returns number of items imported or tagged.
|
||||
"""
|
||||
if settings is None:
|
||||
settings = load_import_settings()
|
||||
if stats is None:
|
||||
stats = {"total_processed": 0, "total_imported": 0, "filtered_by_dimension": 0,
|
||||
"filtered_by_transparency": 0, "filtered_by_duplicate": 0}
|
||||
|
||||
min_width = settings.get("min_width", 0)
|
||||
min_height = settings.get("min_height", 0)
|
||||
skip_transparent = settings.get("skip_transparent", False)
|
||||
transparency_threshold = settings.get("transparency_threshold", 0.9)
|
||||
phash_threshold = settings.get("phash_threshold", 2)
|
||||
|
||||
archive_size = os.path.getsize(archive_path)
|
||||
archive_hash = calculate_hash(archive_path)
|
||||
|
||||
@@ -274,21 +398,43 @@ def process_archive(archive_path, dest_dir, artist, existing_phashes):
|
||||
# Import / collect all media
|
||||
for root, _, files in os.walk(tmpdir):
|
||||
for file in files:
|
||||
if not file.lower().endswith(ALLOWED_MEDIA_EXTS):
|
||||
lower = file.lower()
|
||||
if not lower.endswith(ALLOWED_MEDIA_EXTS):
|
||||
continue
|
||||
src_path = os.path.join(root, file)
|
||||
stats["total_processed"] += 1
|
||||
|
||||
# Apply dimension filter
|
||||
if min_width > 0 or min_height > 0:
|
||||
md = extract_metadata(src_path)
|
||||
if md.get("width") and md.get("height"):
|
||||
if md["width"] < min_width or md["height"] < min_height:
|
||||
print(f"[SKIP] Too small ({md['width']}x{md['height']}): {file}")
|
||||
stats["filtered_by_dimension"] += 1
|
||||
continue
|
||||
|
||||
# Apply transparency filter
|
||||
if skip_transparent and lower.endswith((".png", ".gif", ".webp")):
|
||||
if is_mostly_transparent(src_path, transparency_threshold):
|
||||
print(f"[SKIP] Mostly transparent: {file}")
|
||||
stats["filtered_by_transparency"] += 1
|
||||
continue
|
||||
|
||||
added, _phash, rec = import_single_file(
|
||||
src_path=src_path,
|
||||
dest_dir=dest_dir,
|
||||
artist=artist, # still used for auto artist:<name> tag
|
||||
existing_phashes=existing_phashes,
|
||||
commit=False
|
||||
commit=False,
|
||||
phash_threshold=phash_threshold
|
||||
)
|
||||
if rec is not None:
|
||||
touched_records.append(rec)
|
||||
if added:
|
||||
imported_or_tagged += 1
|
||||
stats["total_imported"] += 1
|
||||
elif rec is None and _phash is None:
|
||||
stats["filtered_by_duplicate"] += 1
|
||||
|
||||
# Only now: create/attach the archive tag if we actually touched images
|
||||
if touched_records:
|
||||
@@ -349,7 +495,7 @@ def build_hashed_dest_path(target_dir: str, original_name: str, content_hash: st
|
||||
i += 1
|
||||
|
||||
|
||||
def import_single_file(src_path: str, dest_dir: str, artist: str, existing_phashes, commit: bool = True):
|
||||
def import_single_file(src_path: str, dest_dir: str, artist: str, existing_phashes, commit: bool = True, phash_threshold: int = 2):
|
||||
"""
|
||||
Import a single media file. If an equivalent file already exists, return that
|
||||
record so callers can tag it (no duplicate import).
|
||||
@@ -378,8 +524,8 @@ def import_single_file(src_path: str, dest_dir: str, artist: str, existing_phash
|
||||
return (False, None, None)
|
||||
|
||||
phash = calculate_perceptual_hash(src_path)
|
||||
if phash and is_similar_image(phash, metadata["width"], metadata["height"], existing_phashes):
|
||||
print(f"[SKIP] {original_name} is visually similar to an existing larger image.")
|
||||
if phash and phash_threshold > 0 and is_similar_image(phash, metadata["width"], metadata["height"], existing_phashes, threshold=phash_threshold):
|
||||
print(f"[SKIP] {original_name} is visually similar to an existing larger image (threshold={phash_threshold}).")
|
||||
return (False, phash, None)
|
||||
|
||||
# Copy into /images/<artist>/<base>__<hash[:10]><ext>
|
||||
@@ -574,6 +720,12 @@ def extract_metadata(file_path: str) -> dict:
|
||||
metadata["file_size"] = os.path.getsize(file_path)
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
|
||||
# Get file modification time as fallback for taken_at
|
||||
try:
|
||||
file_mtime = datetime.fromtimestamp(os.path.getmtime(file_path))
|
||||
except Exception:
|
||||
file_mtime = None
|
||||
|
||||
# Video: use ffprobe
|
||||
if ext in (".mp4", ".mov"):
|
||||
try:
|
||||
@@ -589,6 +741,8 @@ def extract_metadata(file_path: str) -> dict:
|
||||
metadata["width"] = stream.get("width")
|
||||
metadata["height"] = stream.get("height")
|
||||
metadata["format"] = ext.lstrip(".")
|
||||
# Use file mtime for videos since they don't have EXIF
|
||||
metadata["taken_at"] = file_mtime
|
||||
except Exception as e:
|
||||
print(f"[WARN] Failed to extract video metadata: {file_path} – {e}")
|
||||
return metadata
|
||||
@@ -612,6 +766,10 @@ def extract_metadata(file_path: str) -> dict:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback to file modification time if no EXIF date
|
||||
if metadata["taken_at"] is None:
|
||||
metadata["taken_at"] = file_mtime
|
||||
|
||||
return metadata
|
||||
|
||||
def _has_alpha(img: Image.Image) -> bool:
|
||||
|
||||
+2
-2
@@ -30,8 +30,8 @@ fi
|
||||
|
||||
# --- Start Gunicorn ---
|
||||
GUNICORN_BIND="${GUNICORN_BIND:-0.0.0.0:5000}"
|
||||
GUNICORN_WORKERS="${GUNICORN_WORKERS:-4}"
|
||||
GUNICORN_THREADS="${GUNICORN_THREADS:-8}"
|
||||
GUNICORN_WORKERS="${GUNICORN_WORKERS:-8}"
|
||||
GUNICORN_THREADS="${GUNICORN_THREADS:-4}"
|
||||
GUNICORN_TIMEOUT="${GUNICORN_TIMEOUT:-180}"
|
||||
GUNICORN_OPTS="${GUNICORN_OPTS:-}"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user