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()
|
.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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:-}"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user