diff --git a/app/main.py b/app/main.py index f9f8eac..8be0383 100644 --- a/app/main.py +++ b/app/main.py @@ -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//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//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 diff --git a/app/static/js/modal-pagination.js b/app/static/js/modal-pagination.js index 8243d2c..2d47a1c 100644 --- a/app/static/js/modal-pagination.js +++ b/app/static/js/modal-pagination.js @@ -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 = '
No matching tags
'; + positionAutocomplete(); tagAutocomplete.classList.add('active'); return; } @@ -134,6 +143,7 @@ document.addEventListener('DOMContentLoaded', () => { ${t.kind || 'user'} `; }).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; diff --git a/app/static/js/showcase.js b/app/static/js/showcase.js index 3b50429..6530896 100644 --- a/app/static/js/showcase.js +++ b/app/static/js/showcase.js @@ -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 = []; diff --git a/app/static/style.css b/app/static/style.css index a45e8d6..038f6f6 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -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; diff --git a/app/templates/settings.html b/app/templates/settings.html index 2eeffca..0ebb7e1 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -16,16 +16,352 @@ -
- + + +
+
+

Import Filters

+

Configure filters to skip certain images during import.

+ +
+
+ + +
+ +
+ + +
+ +
+ +

Useful for filtering out UI elements, overlays, and buttons.

+
+ + + +
+ + +

Controls how similar images must be to be considered duplicates. Higher values allow more variations through.

+
+ + +
+ +
+

Last Import Statistics

+
+
+ - + Processed +
+
+ - + Imported +
+
+ - + Filtered (size) +
+
+ - + Filtered (transparent) +
+
+
+
+
+ + +
+
+

Delete Images by Tag

+

Permanently delete all images associated with a specific tag (e.g., remove an artist's entire collection).

+ +
+
+ + +
+ + + + + +
+ +
+ + +
+
+
+ +

Use these tools to manually trigger an import or reset the image database. Resetting the database does not delete the actual image files.

+ + + + {% endblock %} diff --git a/app/utils/image_importer.py b/app/utils/image_importer.py index 2763bbf..c1ff9fa 100644 --- a/app/utils/image_importer.py +++ b/app/utils/image_importer.py @@ -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: 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//__ @@ -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: diff --git a/entrypoint.sh b/entrypoint.sh index 4bb2a93..e9a9f6f 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -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:-}"