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()
)
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
+14 -2
View File
@@ -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;
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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:-}"