additional tag edditing functionality. and delete by tag. added import tuning options to settings

This commit is contained in:
Bryan Van Deusen
2026-01-18 16:52:32 -05:00
parent 4c56b73bc9
commit b3331bad76
7 changed files with 700 additions and 32 deletions
+145 -1
View File
@@ -267,7 +267,7 @@ def search_tags():
.all() .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") @main.get("/image/<int:image_id>/tags")
@@ -427,3 +427,147 @@ def delete_tag(tag_id):
db.session.commit() db.session.commit()
return jsonify(ok=True) 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
+14 -2
View File
@@ -115,6 +115,14 @@ document.addEventListener('DOMContentLoaded', () => {
autocompleteSelectedIndex = -1; 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) { function renderAutocomplete(tags) {
if (!tagAutocomplete) return; if (!tagAutocomplete) return;
autocompleteItems = tags; autocompleteItems = tags;
@@ -122,6 +130,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (tags.length === 0) { if (tags.length === 0) {
tagAutocomplete.innerHTML = '<div class="tag-autocomplete-empty">No matching tags</div>'; tagAutocomplete.innerHTML = '<div class="tag-autocomplete-empty">No matching tags</div>';
positionAutocomplete();
tagAutocomplete.classList.add('active'); tagAutocomplete.classList.add('active');
return; return;
} }
@@ -134,6 +143,7 @@ document.addEventListener('DOMContentLoaded', () => {
<span class="tag-kind">${t.kind || 'user'}</span> <span class="tag-kind">${t.kind || 'user'}</span>
</div>`; </div>`;
}).join(''); }).join('');
positionAutocomplete();
tagAutocomplete.classList.add('active'); 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) { 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'); const item = e.target.closest('.tag-autocomplete-item');
if (!item) return; if (!item) return;
const name = item.dataset.name; const name = item.dataset.name;
+1 -1
View File
@@ -10,7 +10,7 @@
const MOBILE_COLUMNS = 2; const MOBILE_COLUMNS = 2;
const MOBILE_BREAKPOINT = 768; const MOBILE_BREAKPOINT = 768;
const SCROLL_THRESHOLD = 1500; // Load more when 1500px from bottom (about 2-3 rows ahead) 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 // State
let columns = []; let columns = [];
+33 -15
View File
@@ -353,17 +353,25 @@ header {
padding: 1rem; padding: 1rem;
} }
/* Modal body */ /* Modal body - horizontal layout with image on left, tags on right */
.modal-body { .modal-body {
display: flex; display: flex;
flex-direction: column; flex-direction: row;
align-items: center; align-items: flex-start;
gap: 1rem; gap: 1rem;
width: 100%; width: 100%;
max-width: 1400px; max-width: 1600px;
max-height: 100%; max-height: 100%;
} }
/* On smaller screens, stack vertically */
@media (max-width: 900px) {
.modal-body {
flex-direction: column;
align-items: center;
}
}
/* Image wrapper & zoom */ /* Image wrapper & zoom */
.modal-image-wrapper { .modal-image-wrapper {
flex: 1; flex: 1;
@@ -494,14 +502,27 @@ header {
.tag-chip:hover { background: rgba(255,255,255,1); } .tag-chip:hover { background: rgba(255,255,255,1); }
/* Modal tag editor - refined layout */ /* Modal tag editor - side panel layout */
.tag-editor { .tag-editor {
width: 100%; width: 280px;
max-width: 500px; min-width: 280px;
max-height: calc(100vh - 40px);
overflow-y: auto;
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
border-radius: 8px; border-radius: 8px;
padding: 0.75rem; padding: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.1); 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 { .tag-editor .tags {
@@ -578,21 +599,18 @@ header {
background: var(--btn-primary-hover); background: var(--btn-primary-hover);
} }
/* Autocomplete dropdown */ /* Autocomplete dropdown - uses fixed positioning to escape overflow containers */
.tag-autocomplete { .tag-autocomplete {
position: absolute; position: fixed;
top: 100%;
left: 0;
right: 0;
background: var(--panel); background: var(--panel);
border: 1px solid rgba(255, 255, 255, 0.15); border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px; border-radius: 6px;
margin-top: 4px; max-height: 240px;
max-height: 200px;
overflow-y: auto; overflow-y: auto;
z-index: 1010; z-index: 10100;
display: none; display: none;
box-shadow: var(--shadow-3); box-shadow: var(--shadow-3);
min-width: 200px;
} }
.tag-autocomplete.active { .tag-autocomplete.active {
display: block; display: block;
+338 -2
View File
@@ -16,16 +16,352 @@
<button type="submit" class="btn warning-btn">Regenerate Thumbnails</button> <button type="submit" class="btn warning-btn">Regenerate Thumbnails</button>
</form> </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.');"> <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> <button type="submit" class="btn danger-btn">Reset Image Database</button>
</form> </form>
</div> </div>
</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"> <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> <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>
</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 %} {% endblock %}
+167 -9
View File
@@ -44,6 +44,68 @@ ALLOWED_ARCHIVE_EXTS = (
".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tbz2", ".tar.xz", ".txz" ".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 # Regex for multi-part detection
RAR_PART_RE = re.compile(r"\.part(\d+)\.rar$", re.IGNORECASE) RAR_PART_RE = re.compile(r"\.part(\d+)\.rar$", re.IGNORECASE)
SEVENZ_PART_RE = re.compile(r"\.7z\.(\d{3})$", 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 any stale extraction dirs from previous runs/crashes
cleanup_stale_tempdirs() 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] = [] imported: list[str] = []
batch_size = 10 batch_size = 10
batch_counter = 0 batch_counter = 0
@@ -205,7 +289,9 @@ def import_images_task(source_dir: str, dest_dir: str) -> str:
archive_path=full_path, archive_path=full_path,
dest_dir=dest_dir, dest_dir=dest_dir,
artist=artist_dir, artist=artist_dir,
existing_phashes=existing_phashes existing_phashes=existing_phashes,
settings=settings,
stats=stats
) )
imported.append(f"[archive]{file}:{count}") imported.append(f"[archive]{file}:{count}")
except Exception as e: 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): if not lower.endswith(ALLOWED_MEDIA_EXTS):
continue 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( added, phash, _rec = import_single_file(
src_path=full_path, src_path=full_path,
dest_dir=dest_dir, dest_dir=dest_dir,
artist=artist_dir, artist=artist_dir,
existing_phashes=existing_phashes existing_phashes=existing_phashes,
phash_threshold=phash_threshold
) )
if added: if added:
imported.append(file) 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: if phash:
md = extract_metadata(full_path) md = extract_metadata(full_path)
@@ -242,17 +351,32 @@ def import_images_task(source_dir: str, dest_dir: str) -> str:
db.session.commit() db.session.commit()
print(f"[INFO] Committed final batch of {batch_counter} items.") 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}") print(f"[INFO] {summary}")
return 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. 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. Creates an archive:* tag ONLY if at least one image was imported or tagged.
Returns number of items 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_size = os.path.getsize(archive_path)
archive_hash = calculate_hash(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 # Import / collect all media
for root, _, files in os.walk(tmpdir): for root, _, files in os.walk(tmpdir):
for file in files: for file in files:
if not file.lower().endswith(ALLOWED_MEDIA_EXTS): lower = file.lower()
if not lower.endswith(ALLOWED_MEDIA_EXTS):
continue continue
src_path = os.path.join(root, file) 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( added, _phash, rec = import_single_file(
src_path=src_path, src_path=src_path,
dest_dir=dest_dir, dest_dir=dest_dir,
artist=artist, # still used for auto artist:<name> tag artist=artist, # still used for auto artist:<name> tag
existing_phashes=existing_phashes, existing_phashes=existing_phashes,
commit=False commit=False,
phash_threshold=phash_threshold
) )
if rec is not None: if rec is not None:
touched_records.append(rec) touched_records.append(rec)
if added: if added:
imported_or_tagged += 1 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 # Only now: create/attach the archive tag if we actually touched images
if touched_records: if touched_records:
@@ -349,7 +495,7 @@ def build_hashed_dest_path(target_dir: str, original_name: str, content_hash: st
i += 1 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 Import a single media file. If an equivalent file already exists, return that
record so callers can tag it (no duplicate import). 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) return (False, None, None)
phash = calculate_perceptual_hash(src_path) phash = calculate_perceptual_hash(src_path)
if phash and is_similar_image(phash, metadata["width"], metadata["height"], existing_phashes): 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.") print(f"[SKIP] {original_name} is visually similar to an existing larger image (threshold={phash_threshold}).")
return (False, phash, None) return (False, phash, None)
# Copy into /images/<artist>/<base>__<hash[:10]><ext> # 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) metadata["file_size"] = os.path.getsize(file_path)
ext = os.path.splitext(file_path)[1].lower() 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 # Video: use ffprobe
if ext in (".mp4", ".mov"): if ext in (".mp4", ".mov"):
try: try:
@@ -589,6 +741,8 @@ def extract_metadata(file_path: str) -> dict:
metadata["width"] = stream.get("width") metadata["width"] = stream.get("width")
metadata["height"] = stream.get("height") metadata["height"] = stream.get("height")
metadata["format"] = ext.lstrip(".") metadata["format"] = ext.lstrip(".")
# Use file mtime for videos since they don't have EXIF
metadata["taken_at"] = file_mtime
except Exception as e: except Exception as e:
print(f"[WARN] Failed to extract video metadata: {file_path} {e}") print(f"[WARN] Failed to extract video metadata: {file_path} {e}")
return metadata return metadata
@@ -612,6 +766,10 @@ def extract_metadata(file_path: str) -> dict:
except Exception: except Exception:
pass pass
# Fallback to file modification time if no EXIF date
if metadata["taken_at"] is None:
metadata["taken_at"] = file_mtime
return metadata return metadata
def _has_alpha(img: Image.Image) -> bool: def _has_alpha(img: Image.Image) -> bool:
+2 -2
View File
@@ -30,8 +30,8 @@ fi
# --- Start Gunicorn --- # --- Start Gunicorn ---
GUNICORN_BIND="${GUNICORN_BIND:-0.0.0.0:5000}" GUNICORN_BIND="${GUNICORN_BIND:-0.0.0.0:5000}"
GUNICORN_WORKERS="${GUNICORN_WORKERS:-4}" GUNICORN_WORKERS="${GUNICORN_WORKERS:-8}"
GUNICORN_THREADS="${GUNICORN_THREADS:-8}" GUNICORN_THREADS="${GUNICORN_THREADS:-4}"
GUNICORN_TIMEOUT="${GUNICORN_TIMEOUT:-180}" GUNICORN_TIMEOUT="${GUNICORN_TIMEOUT:-180}"
GUNICORN_OPTS="${GUNICORN_OPTS:-}" GUNICORN_OPTS="${GUNICORN_OPTS:-}"