additional tag edditing functionality. and delete by tag. added import tuning options to settings
This commit is contained in:
+167
-9
@@ -44,6 +44,68 @@ ALLOWED_ARCHIVE_EXTS = (
|
||||
".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tbz2", ".tar.xz", ".txz"
|
||||
)
|
||||
|
||||
# Import filter settings (loaded from file or env)
|
||||
IMPORT_SETTINGS_PATH = "/import/settings.json"
|
||||
IMPORT_STATS_PATH = "/import/stats.json"
|
||||
|
||||
|
||||
def load_import_settings() -> dict:
|
||||
"""Load import filter settings from file or environment."""
|
||||
defaults = {
|
||||
"min_width": int(os.environ.get("IMPORT_MIN_WIDTH", "0")),
|
||||
"min_height": int(os.environ.get("IMPORT_MIN_HEIGHT", "0")),
|
||||
"skip_transparent": os.environ.get("IMPORT_SKIP_TRANSPARENT", "false").lower() == "true",
|
||||
"transparency_threshold": float(os.environ.get("IMPORT_TRANSPARENCY_THRESHOLD", "0.9")),
|
||||
"phash_threshold": int(os.environ.get("IMPORT_PHASH_THRESHOLD", "2")),
|
||||
}
|
||||
try:
|
||||
if os.path.exists(IMPORT_SETTINGS_PATH):
|
||||
with open(IMPORT_SETTINGS_PATH, "r") as f:
|
||||
file_settings = json.load(f)
|
||||
defaults.update(file_settings)
|
||||
except Exception as e:
|
||||
print(f"[WARN] Failed to load import settings: {e}")
|
||||
return defaults
|
||||
|
||||
|
||||
def save_import_stats(stats: dict) -> None:
|
||||
"""Save import statistics to file."""
|
||||
try:
|
||||
with open(IMPORT_STATS_PATH, "w") as f:
|
||||
json.dump(stats, f, indent=2)
|
||||
except Exception as e:
|
||||
print(f"[WARN] Failed to save import stats: {e}")
|
||||
|
||||
|
||||
def is_mostly_transparent(file_path: str, threshold: float = 0.9) -> bool:
|
||||
"""
|
||||
Check if an image is mostly transparent (> threshold % of pixels are transparent).
|
||||
Returns False for images without alpha channel.
|
||||
"""
|
||||
try:
|
||||
with Image.open(file_path) as img:
|
||||
if img.mode not in ("RGBA", "LA", "P"):
|
||||
return False
|
||||
|
||||
# Convert palette images with transparency
|
||||
if img.mode == "P" and "transparency" in img.info:
|
||||
img = img.convert("RGBA")
|
||||
elif img.mode == "LA":
|
||||
img = img.convert("RGBA")
|
||||
elif img.mode != "RGBA":
|
||||
return False
|
||||
|
||||
# Get alpha channel and count transparent pixels
|
||||
alpha = img.split()[-1]
|
||||
total_pixels = alpha.size[0] * alpha.size[1]
|
||||
# Count pixels where alpha < 128 (more than 50% transparent)
|
||||
transparent_count = sum(1 for p in alpha.getdata() if p < 128)
|
||||
transparency_ratio = transparent_count / total_pixels
|
||||
return transparency_ratio > threshold
|
||||
except Exception as e:
|
||||
print(f"[WARN] Failed to check transparency for {file_path}: {e}")
|
||||
return False
|
||||
|
||||
# Regex for multi-part detection
|
||||
RAR_PART_RE = re.compile(r"\.part(\d+)\.rar$", re.IGNORECASE)
|
||||
SEVENZ_PART_RE = re.compile(r"\.7z\.(\d{3})$", re.IGNORECASE)
|
||||
@@ -165,6 +227,28 @@ def import_images_task(source_dir: str, dest_dir: str) -> str:
|
||||
# Cleanup any stale extraction dirs from previous runs/crashes
|
||||
cleanup_stale_tempdirs()
|
||||
|
||||
# Load import filter settings
|
||||
settings = load_import_settings()
|
||||
min_width = settings.get("min_width", 0)
|
||||
min_height = settings.get("min_height", 0)
|
||||
skip_transparent = settings.get("skip_transparent", False)
|
||||
transparency_threshold = settings.get("transparency_threshold", 0.9)
|
||||
|
||||
phash_threshold = settings.get("phash_threshold", 2)
|
||||
|
||||
print(f"[INFO] Import settings: min_width={min_width}, min_height={min_height}, "
|
||||
f"skip_transparent={skip_transparent}, transparency_threshold={transparency_threshold}, "
|
||||
f"phash_threshold={phash_threshold}")
|
||||
|
||||
# Statistics tracking
|
||||
stats = {
|
||||
"total_processed": 0,
|
||||
"total_imported": 0,
|
||||
"filtered_by_dimension": 0,
|
||||
"filtered_by_transparency": 0,
|
||||
"filtered_by_duplicate": 0,
|
||||
}
|
||||
|
||||
imported: list[str] = []
|
||||
batch_size = 10
|
||||
batch_counter = 0
|
||||
@@ -205,7 +289,9 @@ def import_images_task(source_dir: str, dest_dir: str) -> str:
|
||||
archive_path=full_path,
|
||||
dest_dir=dest_dir,
|
||||
artist=artist_dir,
|
||||
existing_phashes=existing_phashes
|
||||
existing_phashes=existing_phashes,
|
||||
settings=settings,
|
||||
stats=stats
|
||||
)
|
||||
imported.append(f"[archive]{file}:{count}")
|
||||
except Exception as e:
|
||||
@@ -216,14 +302,37 @@ def import_images_task(source_dir: str, dest_dir: str) -> str:
|
||||
if not lower.endswith(ALLOWED_MEDIA_EXTS):
|
||||
continue
|
||||
|
||||
stats["total_processed"] += 1
|
||||
|
||||
# Apply dimension filter
|
||||
if min_width > 0 or min_height > 0:
|
||||
md = extract_metadata(full_path)
|
||||
if md.get("width") and md.get("height"):
|
||||
if md["width"] < min_width or md["height"] < min_height:
|
||||
print(f"[SKIP] Too small ({md['width']}x{md['height']}): {file}")
|
||||
stats["filtered_by_dimension"] += 1
|
||||
continue
|
||||
|
||||
# Apply transparency filter
|
||||
if skip_transparent and lower.endswith((".png", ".gif", ".webp")):
|
||||
if is_mostly_transparent(full_path, transparency_threshold):
|
||||
print(f"[SKIP] Mostly transparent: {file}")
|
||||
stats["filtered_by_transparency"] += 1
|
||||
continue
|
||||
|
||||
added, phash, _rec = import_single_file(
|
||||
src_path=full_path,
|
||||
dest_dir=dest_dir,
|
||||
artist=artist_dir,
|
||||
existing_phashes=existing_phashes
|
||||
existing_phashes=existing_phashes,
|
||||
phash_threshold=phash_threshold
|
||||
)
|
||||
if added:
|
||||
imported.append(file)
|
||||
stats["total_imported"] += 1
|
||||
elif _rec is None and phash is None:
|
||||
# Was a duplicate
|
||||
stats["filtered_by_duplicate"] += 1
|
||||
|
||||
if phash:
|
||||
md = extract_metadata(full_path)
|
||||
@@ -242,17 +351,32 @@ def import_images_task(source_dir: str, dest_dir: str) -> str:
|
||||
db.session.commit()
|
||||
print(f"[INFO] Committed final batch of {batch_counter} items.")
|
||||
|
||||
summary = f"Imported {len(imported)} items."
|
||||
# Save stats
|
||||
save_import_stats(stats)
|
||||
|
||||
summary = f"Imported {len(imported)} items. Filtered: {stats['filtered_by_dimension']} by size, {stats['filtered_by_transparency']} by transparency."
|
||||
print(f"[INFO] {summary}")
|
||||
return summary
|
||||
|
||||
|
||||
def process_archive(archive_path, dest_dir, artist, existing_phashes):
|
||||
def process_archive(archive_path, dest_dir, artist, existing_phashes, settings=None, stats=None):
|
||||
"""
|
||||
Extracts archive to temp dir, imports/tag existing images, and records ArchiveRecord.
|
||||
Creates an archive:* tag ONLY if at least one image was imported or tagged.
|
||||
Returns number of items imported or tagged.
|
||||
"""
|
||||
if settings is None:
|
||||
settings = load_import_settings()
|
||||
if stats is None:
|
||||
stats = {"total_processed": 0, "total_imported": 0, "filtered_by_dimension": 0,
|
||||
"filtered_by_transparency": 0, "filtered_by_duplicate": 0}
|
||||
|
||||
min_width = settings.get("min_width", 0)
|
||||
min_height = settings.get("min_height", 0)
|
||||
skip_transparent = settings.get("skip_transparent", False)
|
||||
transparency_threshold = settings.get("transparency_threshold", 0.9)
|
||||
phash_threshold = settings.get("phash_threshold", 2)
|
||||
|
||||
archive_size = os.path.getsize(archive_path)
|
||||
archive_hash = calculate_hash(archive_path)
|
||||
|
||||
@@ -274,21 +398,43 @@ def process_archive(archive_path, dest_dir, artist, existing_phashes):
|
||||
# Import / collect all media
|
||||
for root, _, files in os.walk(tmpdir):
|
||||
for file in files:
|
||||
if not file.lower().endswith(ALLOWED_MEDIA_EXTS):
|
||||
lower = file.lower()
|
||||
if not lower.endswith(ALLOWED_MEDIA_EXTS):
|
||||
continue
|
||||
src_path = os.path.join(root, file)
|
||||
stats["total_processed"] += 1
|
||||
|
||||
# Apply dimension filter
|
||||
if min_width > 0 or min_height > 0:
|
||||
md = extract_metadata(src_path)
|
||||
if md.get("width") and md.get("height"):
|
||||
if md["width"] < min_width or md["height"] < min_height:
|
||||
print(f"[SKIP] Too small ({md['width']}x{md['height']}): {file}")
|
||||
stats["filtered_by_dimension"] += 1
|
||||
continue
|
||||
|
||||
# Apply transparency filter
|
||||
if skip_transparent and lower.endswith((".png", ".gif", ".webp")):
|
||||
if is_mostly_transparent(src_path, transparency_threshold):
|
||||
print(f"[SKIP] Mostly transparent: {file}")
|
||||
stats["filtered_by_transparency"] += 1
|
||||
continue
|
||||
|
||||
added, _phash, rec = import_single_file(
|
||||
src_path=src_path,
|
||||
dest_dir=dest_dir,
|
||||
artist=artist, # still used for auto artist:<name> tag
|
||||
existing_phashes=existing_phashes,
|
||||
commit=False
|
||||
commit=False,
|
||||
phash_threshold=phash_threshold
|
||||
)
|
||||
if rec is not None:
|
||||
touched_records.append(rec)
|
||||
if added:
|
||||
imported_or_tagged += 1
|
||||
stats["total_imported"] += 1
|
||||
elif rec is None and _phash is None:
|
||||
stats["filtered_by_duplicate"] += 1
|
||||
|
||||
# Only now: create/attach the archive tag if we actually touched images
|
||||
if touched_records:
|
||||
@@ -349,7 +495,7 @@ def build_hashed_dest_path(target_dir: str, original_name: str, content_hash: st
|
||||
i += 1
|
||||
|
||||
|
||||
def import_single_file(src_path: str, dest_dir: str, artist: str, existing_phashes, commit: bool = True):
|
||||
def import_single_file(src_path: str, dest_dir: str, artist: str, existing_phashes, commit: bool = True, phash_threshold: int = 2):
|
||||
"""
|
||||
Import a single media file. If an equivalent file already exists, return that
|
||||
record so callers can tag it (no duplicate import).
|
||||
@@ -378,8 +524,8 @@ def import_single_file(src_path: str, dest_dir: str, artist: str, existing_phash
|
||||
return (False, None, None)
|
||||
|
||||
phash = calculate_perceptual_hash(src_path)
|
||||
if phash and is_similar_image(phash, metadata["width"], metadata["height"], existing_phashes):
|
||||
print(f"[SKIP] {original_name} is visually similar to an existing larger image.")
|
||||
if phash and phash_threshold > 0 and is_similar_image(phash, metadata["width"], metadata["height"], existing_phashes, threshold=phash_threshold):
|
||||
print(f"[SKIP] {original_name} is visually similar to an existing larger image (threshold={phash_threshold}).")
|
||||
return (False, phash, None)
|
||||
|
||||
# Copy into /images/<artist>/<base>__<hash[:10]><ext>
|
||||
@@ -574,6 +720,12 @@ def extract_metadata(file_path: str) -> dict:
|
||||
metadata["file_size"] = os.path.getsize(file_path)
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
|
||||
# Get file modification time as fallback for taken_at
|
||||
try:
|
||||
file_mtime = datetime.fromtimestamp(os.path.getmtime(file_path))
|
||||
except Exception:
|
||||
file_mtime = None
|
||||
|
||||
# Video: use ffprobe
|
||||
if ext in (".mp4", ".mov"):
|
||||
try:
|
||||
@@ -589,6 +741,8 @@ def extract_metadata(file_path: str) -> dict:
|
||||
metadata["width"] = stream.get("width")
|
||||
metadata["height"] = stream.get("height")
|
||||
metadata["format"] = ext.lstrip(".")
|
||||
# Use file mtime for videos since they don't have EXIF
|
||||
metadata["taken_at"] = file_mtime
|
||||
except Exception as e:
|
||||
print(f"[WARN] Failed to extract video metadata: {file_path} – {e}")
|
||||
return metadata
|
||||
@@ -612,6 +766,10 @@ def extract_metadata(file_path: str) -> dict:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback to file modification time if no EXIF date
|
||||
if metadata["taken_at"] is None:
|
||||
metadata["taken_at"] = file_mtime
|
||||
|
||||
return metadata
|
||||
|
||||
def _has_alpha(img: Image.Image) -> bool:
|
||||
|
||||
Reference in New Issue
Block a user