/results")
-def api_scan_results(job_id: str):
- """Get results of a completed resize job."""
- job = get_job(job_id)
- if not job:
- return jsonify({"error": "Job not found"}), 404
-
- if job.status not in ("done", "error"):
- return jsonify({"error": "Job not finished yet"}), 409
-
- return jsonify(
- {
- "results": job.results,
- "errors": job.errors,
- "total_files": job.total_files,
- "processed_files": job.processed_files,
- }
- )
diff --git a/src/morphic/frontend/routes_shared.py b/src/morphic/frontend/routes_shared.py
deleted file mode 100644
index d4edd32..0000000
--- a/src/morphic/frontend/routes_shared.py
+++ /dev/null
@@ -1,295 +0,0 @@
-"""
-Shared routes — index page, folder browsing, thumbnail & media serving.
-"""
-
-from __future__ import annotations
-
-import importlib
-import mimetypes
-import os
-import shutil
-import subprocess
-import sys
-from pathlib import Path
-
-from flask import (
- Blueprint,
- abort,
- current_app,
- jsonify,
- render_template,
- request,
- send_file,
-)
-
-from morphic.shared.constants import IMAGE_EXTENSIONS, VIDEO_EXTENSIONS
-from morphic.shared.file_browser import open_native_folder_dialog
-from morphic.shared.thumbnails import (
- generate_image_thumbnail,
- generate_video_thumbnail,
-)
-from morphic.shared.utils import normalise_ext
-
-bp = Blueprint("shared", __name__)
-
-
-# ── Page ────────────────────────────────────────────────────────────────────
-
-
-@bp.route("/")
-def index():
- """Serve the single-page application."""
- return render_template(
- "index.html",
- initial_folder=current_app.config.get("INITIAL_FOLDER", ""),
- )
-
-
-# ── Directory browsing ─────────────────────────────────────────────────────
-
-
-@bp.route("/api/browse")
-def browse_directory():
- """List directories for the in-page folder browser."""
- path = request.args.get("path", str(Path.home()))
- try:
- path = os.path.expanduser(path)
- path = os.path.abspath(path)
-
- if not os.path.isdir(path):
- return jsonify({"error": "Not a directory"}), 400
-
- entries = []
- try:
- for entry in sorted(
- os.scandir(path),
- key=lambda e: e.name.lower(),
- ):
- if entry.name.startswith("."):
- continue
- if entry.is_dir(follow_symlinks=False):
- entries.append(
- {
- "name": entry.name,
- "path": entry.path,
- "type": "directory",
- }
- )
- except PermissionError:
- pass
-
- parent = os.path.dirname(path)
- return jsonify(
- {
- "current": path,
- "parent": parent if parent != path else None,
- "entries": entries,
- }
- )
- except Exception as e:
- return jsonify({"error": str(e)}), 500
-
-
-@bp.route("/api/browse/native", methods=["POST"])
-def native_folder_dialog():
- """Open the OS-native folder picker dialog."""
- data = request.get_json(silent=True) or {}
- initial_dir = data.get("initial_dir", str(Path.home()))
- folder = open_native_folder_dialog(initial_dir)
- if folder:
- return jsonify({"folder": folder})
- return jsonify(
- {
- "folder": None,
- "message": "Dialog cancelled or unavailable",
- }
- ), 200
-
-
-@bp.route("/api/system_info")
-def api_system_info():
- """Return diagnostic info about GPU/cuda/ffmpeg availability."""
- info = {
- "python_version": sys.version,
- "torch": {
- "installed": False,
- "version": None,
- "cuda_available": False,
- "cuda_device_count": 0,
- "device_names": [],
- },
- "cupy": {
- "installed": False,
- "version": None,
- "device_count": 0,
- },
- "pyopencl": {
- "installed": False,
- "platforms": [],
- },
- "ffmpeg": {
- "installed": shutil.which("ffmpeg") is not None,
- "hwaccels": [],
- "encoders": [],
- "nvenc_available": False,
- },
- "duplicity_acceleration": {
- "backend": "unknown",
- "gpu_available": False,
- },
- }
-
- try:
- torch = importlib.import_module("torch")
-
- info["torch"].update(
- {
- "installed": True,
- "version": getattr(torch, "__version__", None),
- "cuda_available": bool(
- getattr(
- getattr(torch, "cuda", None),
- "is_available",
- lambda: False,
- )()
- ),
- "cuda_device_count": torch.cuda.device_count()
- if getattr(
- getattr(torch, "cuda", None), "is_available", lambda: False
- )()
- else 0,
- "device_names": [
- torch.cuda.get_device_name(i)
- for i in range(torch.cuda.device_count())
- ]
- if getattr(
- getattr(torch, "cuda", None), "is_available", lambda: False
- )()
- else [],
- }
- )
- except Exception:
- pass
-
- try:
- cp = importlib.import_module("cupy")
-
- info["cupy"].update(
- {
- "installed": True,
- "version": getattr(cp, "__version__", None),
- "device_count": cp.cuda.runtime.getDeviceCount(),
- }
- )
- except Exception:
- pass
-
- try:
- cl = importlib.import_module("pyopencl")
-
- platforms = []
- for plat in cl.get_platforms():
- devices = [
- dev.name
- for dev in plat.get_devices(device_type=cl.device_type.GPU)
- ]
- platforms.append(
- {"name": plat.name, "vendor": plat.vendor, "devices": devices}
- )
- info["pyopencl"].update({"installed": True, "platforms": platforms})
- except Exception:
- pass
-
- if info["ffmpeg"]["installed"]:
- try:
- hw = subprocess.check_output(
- ["ffmpeg", "-hide_banner", "-hwaccels"],
- stderr=subprocess.STDOUT,
- text=True,
- timeout=10,
- )
- info["ffmpeg"]["hwaccels"] = [
- line.strip()
- for line in hw.splitlines()
- if line.strip() and line.strip().isdigit() is False
- ]
- except Exception:
- pass
-
- try:
- enc = subprocess.check_output(
- ["ffmpeg", "-hide_banner", "-encoders"],
- stderr=subprocess.STDOUT,
- text=True,
- timeout=15,
- )
- lines = [
- line.strip()
- for line in enc.splitlines()
- if line.strip() and line.strip()[0] in ("V", "A")
- ]
- info["ffmpeg"]["encoders"] = lines
- nvenc = [
- line
- for line in lines
- if "nvenc" in line
- or "h264_nvenc" in line
- or "hevc_nvenc" in line
- ]
- info["ffmpeg"]["nvenc_available"] = bool(nvenc)
- except Exception:
- pass
-
- try:
- from morphic.dupfinder.accelerator import get_accelerator
-
- acc = get_accelerator()
- info["duplicity_acceleration"]["backend"] = acc.get_backend_name()
- info["duplicity_acceleration"]["gpu_available"] = acc.is_gpu_available
- except Exception:
- pass
-
- return jsonify(info)
-
-
-# ── Thumbnails & media ─────────────────────────────────────────────────────
-
-
-@bp.route("/api/thumbnail")
-def serve_thumbnail():
- """Generate and serve a thumbnail for a media file."""
- file_path = request.args.get("path", "")
- if not file_path or not os.path.isfile(file_path):
- abort(404)
-
- ext = normalise_ext(os.path.splitext(file_path)[1])
-
- try:
- if ext in IMAGE_EXTENSIONS:
- buf = generate_image_thumbnail(file_path)
- return send_file(buf, mimetype="image/jpeg")
- elif ext in VIDEO_EXTENSIONS:
- buf = generate_video_thumbnail(file_path)
- if buf:
- return send_file(buf, mimetype="image/jpeg")
- abort(404)
- else:
- abort(403)
- except Exception:
- abort(500)
-
-
-@bp.route("/api/media")
-def serve_media():
- """Serve a media file for full-size preview."""
- file_path = request.args.get("path", "")
- if not file_path or not os.path.isfile(file_path):
- abort(404)
-
- ext = normalise_ext(os.path.splitext(file_path)[1])
- allowed = IMAGE_EXTENSIONS | VIDEO_EXTENSIONS
- if ext not in allowed:
- abort(403)
-
- mimetype = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
- return send_file(file_path, mimetype=mimetype)
diff --git a/src/morphic/inspector/__init__.py b/src/morphic/inspector/__init__.py
deleted file mode 100644
index aa32505..0000000
--- a/src/morphic/inspector/__init__.py
+++ /dev/null
@@ -1,31 +0,0 @@
-"""
-morphic.inspector - EXIF metadata inspection and file integrity checking.
-
-Provides EXIF read/edit/strip operations for images, and integrity
-validation for both images and videos.
-"""
-
-from morphic.inspector.exif import (
- edit_exif,
- read_exif,
- strip_exif,
- strip_exif_batch,
-)
-from morphic.inspector.integrity import (
- check_files,
- check_image,
- check_video,
-)
-from morphic.inspector.scanner import get_job, start_job
-
-__all__ = [
- "check_files",
- "check_image",
- "check_video",
- "edit_exif",
- "get_job",
- "read_exif",
- "start_job",
- "strip_exif",
- "strip_exif_batch",
-]
diff --git a/src/morphic/inspector/exif.py b/src/morphic/inspector/exif.py
deleted file mode 100644
index 1fa2fa9..0000000
--- a/src/morphic/inspector/exif.py
+++ /dev/null
@@ -1,219 +0,0 @@
-"""
-EXIF metadata operations — read, edit, and strip.
-
-Uses piexif for read/write and Pillow's ExifTags for human-readable
-tag name mapping.
-"""
-
-from __future__ import annotations
-
-import logging
-import os
-from typing import Any
-
-import piexif
-from PIL import Image
-from PIL.ExifTags import GPSTAGS, TAGS
-
-logger = logging.getLogger(__name__)
-
-# ── Tag name mapping ───────────────────────────────────────────────────────
-
-# Build reverse lookup: human name → (ifd_name, tag_id)
-_NAME_TO_TAG: dict[str, tuple[str, int]] = {}
-_IFD_KEYS = {
- "0th": piexif.ImageIFD,
- "Exif": piexif.ExifIFD,
- "GPS": piexif.GPSIFD,
- "1st": piexif.ImageIFD,
-}
-
-for _ifd_name, _ifd_module in [
- ("0th", piexif.ImageIFD),
- ("Exif", piexif.ExifIFD),
- ("GPS", piexif.GPSIFD),
-]:
- for _attr in dir(_ifd_module):
- if _attr.startswith("_"):
- continue
- _tag_id = getattr(_ifd_module, _attr)
- if isinstance(_tag_id, int):
- _human = TAGS.get(_tag_id, _attr)
- _NAME_TO_TAG[_human] = (_ifd_name, _tag_id)
-
-
-def _decode_value(value: Any) -> Any:
- """Decode piexif byte values to strings where possible."""
- if isinstance(value, bytes):
- try:
- return value.decode("utf-8").rstrip("\x00")
- except (UnicodeDecodeError, AttributeError):
- return value.hex()
- if isinstance(value, tuple) and len(value) == 2:
- # Rational number (numerator, denominator)
- num, den = value
- if isinstance(num, int) and isinstance(den, int) and den != 0:
- return round(num / den, 6)
- return value
-
-
-def _gps_to_decimal(
- coords: tuple[tuple[int, int], ...],
- ref: str,
-) -> float:
- """Convert GPS DMS (degrees/minutes/seconds) to decimal degrees."""
- degrees = coords[0][0] / coords[0][1] if coords[0][1] else 0
- minutes = coords[1][0] / coords[1][1] if coords[1][1] else 0
- seconds = coords[2][0] / coords[2][1] if coords[2][1] else 0
- decimal = degrees + minutes / 60 + seconds / 3600
- if ref in ("S", "W"):
- decimal = -decimal
- return round(decimal, 6)
-
-
-# ── Public API ─────────────────────────────────────────────────────────────
-
-
-def read_exif(path: str) -> dict[str, Any]:
- """Read EXIF metadata from an image file.
-
- Parameters
- ----------
- path : str
- Path to the image file.
-
- Returns
- -------
- dict
- Flat dictionary of human-readable tag names to values.
- Includes ``_gps_lat`` and ``_gps_lng`` if GPS data is present.
- """
- if not os.path.isfile(path):
- raise FileNotFoundError(f"File not found: {path}")
-
- try:
- exif_dict = piexif.load(path)
- except piexif.InvalidImageDataError:
- # File exists but has no EXIF
- return {}
- except Exception:
- # Try via Pillow as fallback
- try:
- img = Image.open(path)
- exif_bytes = img.info.get("exif", b"")
- if not exif_bytes:
- return {}
- exif_dict = piexif.load(exif_bytes)
- except Exception:
- return {}
-
- result: dict[str, Any] = {}
-
- for ifd_name in ("0th", "Exif", "1st"):
- ifd_data = exif_dict.get(ifd_name, {})
- if not ifd_data:
- continue
- for tag_id, value in ifd_data.items():
- tag_name = TAGS.get(tag_id, f"Tag_{tag_id}")
- result[tag_name] = _decode_value(value)
-
- # GPS data — special handling for lat/lng
- gps_data = exif_dict.get("GPS", {})
- if gps_data:
- for tag_id, value in gps_data.items():
- tag_name = GPSTAGS.get(tag_id, f"GPSTag_{tag_id}")
- result[tag_name] = _decode_value(value)
-
- # Compute decimal coordinates
- lat_data = gps_data.get(piexif.GPSIFD.GPSLatitude)
- lat_ref = gps_data.get(piexif.GPSIFD.GPSLatitudeRef, b"N")
- lng_data = gps_data.get(piexif.GPSIFD.GPSLongitude)
- lng_ref = gps_data.get(piexif.GPSIFD.GPSLongitudeRef, b"E")
-
- if lat_data and lng_data:
- if isinstance(lat_ref, bytes):
- lat_ref = lat_ref.decode()
- if isinstance(lng_ref, bytes):
- lng_ref = lng_ref.decode()
- result["_gps_lat"] = _gps_to_decimal(lat_data, lat_ref)
- result["_gps_lng"] = _gps_to_decimal(lng_data, lng_ref)
-
- return result
-
-
-def edit_exif(path: str, updates: dict[str, Any]) -> None:
- """Edit EXIF fields on an image file in-place.
-
- Parameters
- ----------
- path : str
- Path to the image file.
- updates : dict
- Mapping of human-readable tag names to new values.
- Example: ``{"Artist": "Alice", "Copyright": "2026"}``
- """
- if not os.path.isfile(path):
- raise FileNotFoundError(f"File not found: {path}")
-
- try:
- exif_dict = piexif.load(path)
- except Exception:
- # Start with empty EXIF
- exif_dict = {"0th": {}, "Exif": {}, "GPS": {}, "1st": {}}
-
- for name, value in updates.items():
- tag_info = _NAME_TO_TAG.get(name)
- if not tag_info:
- logger.warning("Unknown EXIF tag name: %s", name)
- continue
-
- ifd_name, tag_id = tag_info
- # Encode string values to bytes
- if isinstance(value, str):
- value = value.encode("utf-8")
-
- if ifd_name not in exif_dict or exif_dict[ifd_name] is None:
- exif_dict[ifd_name] = {}
- exif_dict[ifd_name][tag_id] = value
-
- exif_bytes = piexif.dump(exif_dict)
- piexif.insert(exif_bytes, path)
-
-
-def strip_exif(path: str) -> None:
- """Remove all EXIF metadata from an image file.
-
- Parameters
- ----------
- path : str
- Path to the image file.
- """
- if not os.path.isfile(path):
- raise FileNotFoundError(f"File not found: {path}")
-
- piexif.remove(path)
-
-
-def strip_exif_batch(
- paths: list[str],
-) -> dict[str, dict[str, str | bool]]:
- """Strip EXIF from multiple files.
-
- Parameters
- ----------
- paths : list[str]
- List of image file paths.
-
- Returns
- -------
- dict
- Per-file results: ``{"path": {"success": True/False, "error": ...}}``
- """
- results: dict[str, dict[str, str | bool]] = {}
- for path in paths:
- try:
- strip_exif(path)
- results[path] = {"success": True}
- except Exception as e:
- results[path] = {"success": False, "error": str(e)}
- return results
diff --git a/src/morphic/inspector/integrity.py b/src/morphic/inspector/integrity.py
deleted file mode 100644
index ac95a21..0000000
--- a/src/morphic/inspector/integrity.py
+++ /dev/null
@@ -1,219 +0,0 @@
-"""
-File integrity checking for images and videos.
-
-Uses Pillow's ``verify()`` / ``load()`` for images and ``ffprobe``
-for videos.
-"""
-
-from __future__ import annotations
-
-import logging
-import os
-import subprocess
-from concurrent.futures import ThreadPoolExecutor, as_completed
-
-from PIL import Image
-
-from morphic.shared.constants import IMAGE_EXTENSIONS, VIDEO_EXTENSIONS
-from morphic.shared.utils import (
- find_files_by_extension,
- format_file_size,
- is_image,
- is_video,
-)
-
-logger = logging.getLogger(__name__)
-
-
-def check_image(path: str) -> dict:
- """Validate an image file's integrity.
-
- Parameters
- ----------
- path : str
- Path to the image file.
-
- Returns
- -------
- dict
- ``{"path", "valid", "error", "size", "size_formatted",
- "width", "height", "format"}``
- """
- result: dict = {
- "path": path,
- "valid": False,
- "error": None,
- "size": 0,
- "size_formatted": "0 B",
- "width": 0,
- "height": 0,
- "format": None,
- "type": "image",
- }
-
- if not os.path.isfile(path):
- result["error"] = "File not found"
- return result
-
- result["size"] = os.path.getsize(path)
- result["size_formatted"] = format_file_size(result["size"])
-
- if result["size"] == 0:
- result["error"] = "Zero-byte file"
- return result
-
- try:
- # First pass: verify structure
- img = Image.open(path)
- result["format"] = img.format
- result["width"] = img.width
- result["height"] = img.height
- img.verify()
-
- # Second pass: actually decode all pixels
- img = Image.open(path)
- img.load()
-
- result["valid"] = True
- except Exception as e:
- result["error"] = str(e)
-
- return result
-
-
-def check_video(path: str) -> dict:
- """Validate a video file using ffprobe.
-
- Parameters
- ----------
- path : str
- Path to the video file.
-
- Returns
- -------
- dict
- ``{"path", "valid", "error", "size", "size_formatted",
- "width", "height", "duration", "codec"}``
- """
- result: dict = {
- "path": path,
- "valid": False,
- "error": None,
- "size": 0,
- "size_formatted": "0 B",
- "width": 0,
- "height": 0,
- "duration": 0.0,
- "codec": None,
- "type": "video",
- }
-
- if not os.path.isfile(path):
- result["error"] = "File not found"
- return result
-
- result["size"] = os.path.getsize(path)
- result["size_formatted"] = format_file_size(result["size"])
-
- if result["size"] == 0:
- result["error"] = "Zero-byte file"
- return result
-
- try:
- cmd = [
- "ffprobe",
- "-v",
- "error",
- "-select_streams",
- "v:0",
- "-show_entries",
- "stream=codec_name,width,height,duration",
- "-of",
- "csv=p=0",
- path,
- ]
- proc = subprocess.run(
- cmd,
- capture_output=True,
- text=True,
- timeout=30,
- )
-
- if proc.returncode != 0:
- stderr = proc.stderr.strip()
- result["error"] = stderr or f"ffprobe exit code {proc.returncode}"
- return result
-
- output = proc.stdout.strip()
- if not output:
- result["error"] = "No video stream found"
- return result
-
- parts = output.split(",")
- if len(parts) >= 1:
- result["codec"] = parts[0]
- if len(parts) >= 2:
- result["width"] = int(parts[1]) if parts[1] else 0
- if len(parts) >= 3:
- result["height"] = int(parts[2]) if parts[2] else 0
- if len(parts) >= 4 and parts[3]:
- try:
- result["duration"] = float(parts[3])
- except ValueError:
- pass
-
- result["valid"] = True
-
- except FileNotFoundError:
- result["error"] = "ffprobe not found (install ffmpeg)"
- except subprocess.TimeoutExpired:
- result["error"] = "ffprobe timed out"
- except Exception as e:
- result["error"] = str(e)
-
- return result
-
-
-def check_files(
- folder: str,
- max_workers: int = 4,
-) -> list[dict]:
- """Check integrity of all media files in a folder.
-
- Parameters
- ----------
- folder : str
- Root folder to scan.
- max_workers : int
- Number of threads for parallel checking.
-
- Returns
- -------
- list[dict]
- Per-file integrity results.
- """
- all_ext = IMAGE_EXTENSIONS | VIDEO_EXTENSIONS
- paths = find_files_by_extension(folder, all_ext)
- results: list[dict] = []
-
- with ThreadPoolExecutor(max_workers=max_workers) as pool:
- futures = {}
- for path in paths:
- if is_image(path):
- futures[pool.submit(check_image, path)] = path
- elif is_video(path):
- futures[pool.submit(check_video, path)] = path
-
- for future in as_completed(futures):
- try:
- results.append(future.result())
- except Exception as e:
- results.append(
- {
- "path": futures[future],
- "valid": False,
- "error": str(e),
- }
- )
-
- return sorted(results, key=lambda r: r["path"])
diff --git a/src/morphic/inspector/scanner.py b/src/morphic/inspector/scanner.py
deleted file mode 100644
index 0f0d6eb..0000000
--- a/src/morphic/inspector/scanner.py
+++ /dev/null
@@ -1,169 +0,0 @@
-"""
-Background scan job management for the inspector module.
-
-Handles EXIF scanning and integrity checking in background threads.
-"""
-
-from __future__ import annotations
-
-import logging
-import os
-import threading
-import time
-import uuid
-from dataclasses import dataclass, field
-
-from morphic.shared.constants import ALL_EXTENSIONS, IMAGE_EXTENSIONS
-from morphic.shared.utils import (
- find_files_by_extension,
- format_duration,
- is_image,
- is_video,
-)
-
-logger = logging.getLogger(__name__)
-
-
-@dataclass
-class ScanJob:
- """Represents a running or completed inspector job."""
-
- id: str
- folder: str
- mode: str # "exif" or "integrity"
- status: str = "pending"
- progress: float = 0.0
- message: str = ""
- error: str | None = None
- results: list[dict] = field(default_factory=list)
- total_files: int = 0
- processed_files: int = 0
- started_at: float = 0.0
- finished_at: float = 0.0
-
-
-# ── Job Registry ───────────────────────────────────────────────────────────
-
-_jobs: dict[str, ScanJob] = {}
-_lock = threading.Lock()
-
-
-def get_job(job_id: str) -> ScanJob | None:
- """Retrieve a scan job by ID."""
- with _lock:
- return _jobs.get(job_id)
-
-
-def start_job(folder: str, mode: str) -> str:
- """Create and launch a new inspector job. Returns the job ID."""
- job_id = str(uuid.uuid4())[:8]
- job = ScanJob(id=job_id, folder=folder, mode=mode)
- with _lock:
- _jobs[job_id] = job
-
- thread = threading.Thread(target=_run_scan, args=(job,), daemon=True)
- thread.start()
- return job_id
-
-
-def _run_scan(job: ScanJob) -> None:
- """Execute the inspector scan in a background thread."""
- try:
- job.status = "scanning"
- job.started_at = time.time()
-
- # Determine extensions to look for
- extensions = IMAGE_EXTENSIONS if job.mode == "exif" else ALL_EXTENSIONS
- job.message = f"Scanning folder: {job.folder}"
- paths = find_files_by_extension(job.folder, extensions)
- job.total_files = len(paths)
-
- if not paths:
- job.status = "done"
- job.progress = 1.0
- job.finished_at = time.time()
- job.message = "No files found."
- return
-
- job.status = "processing"
-
- if job.mode == "exif":
- _scan_exif(job, paths)
- else:
- _scan_integrity(job, paths)
-
- job.status = "done"
- job.progress = 1.0
- job.finished_at = time.time()
- elapsed = job.finished_at - job.started_at
- job.message = (
- f"Done! Processed {job.processed_files} files "
- f"in {format_duration(elapsed)}."
- )
-
- except Exception as e:
- logger.exception("Inspector scan failed")
- job.status = "error"
- job.error = str(e)
- job.message = f"Error: {e}"
- job.finished_at = time.time()
-
-
-def _scan_exif(job: ScanJob, paths: list[str]) -> None:
- """Read EXIF from all image files."""
- from morphic.inspector.exif import read_exif
-
- for i, path in enumerate(paths):
- if not is_image(path):
- continue
- try:
- exif = read_exif(path)
- job.results.append(
- {
- "path": path,
- "filename": os.path.basename(path),
- "directory": os.path.dirname(path),
- "exif": exif,
- "has_exif": bool(exif),
- "has_gps": "_gps_lat" in exif,
- }
- )
- except Exception as e:
- job.results.append(
- {
- "path": path,
- "filename": os.path.basename(path),
- "directory": os.path.dirname(path),
- "exif": {},
- "has_exif": False,
- "has_gps": False,
- "error": str(e),
- }
- )
- job.processed_files = i + 1
- job.progress = (i + 1) / job.total_files
- job.message = f"Reading EXIF: {i + 1}/{job.total_files}"
-
-
-def _scan_integrity(job: ScanJob, paths: list[str]) -> None:
- """Check integrity of all media files."""
- from morphic.inspector.integrity import check_image, check_video
-
- for i, path in enumerate(paths):
- if is_image(path):
- result = check_image(path)
- elif is_video(path):
- result = check_video(path)
- else:
- result = {
- "path": path,
- "valid": False,
- "error": "Unknown file type",
- "type": "unknown",
- }
- result["filename"] = os.path.basename(path)
- result["directory"] = os.path.dirname(path)
- job.results.append(result)
- job.processed_files = i + 1
- job.progress = (i + 1) / job.total_files
- job.message = f"Checking: {i + 1}/{job.total_files}"
diff --git a/src/morphic/organizer/__init__.py b/src/morphic/organizer/__init__.py
deleted file mode 100644
index cfd83f1..0000000
--- a/src/morphic/organizer/__init__.py
+++ /dev/null
@@ -1,27 +0,0 @@
-"""
-morphic.organizer - File organisation: date sorting and batch renaming.
-
-Provides plan→preview→execute workflows for moving or copying media
-files into date-based folder structures or with new naming patterns.
-"""
-
-from morphic.organizer.date_sorter import (
- execute_sort,
- get_file_date,
- plan_sort,
-)
-from morphic.organizer.renamer import (
- execute_rename,
- plan_rename,
-)
-from morphic.organizer.scanner import get_job, start_job
-
-__all__ = [
- "execute_rename",
- "execute_sort",
- "get_file_date",
- "get_job",
- "plan_rename",
- "plan_sort",
- "start_job",
-]
diff --git a/src/morphic/organizer/date_sorter.py b/src/morphic/organizer/date_sorter.py
deleted file mode 100644
index 6898e29..0000000
--- a/src/morphic/organizer/date_sorter.py
+++ /dev/null
@@ -1,191 +0,0 @@
-"""
-Date-based file sorting with configurable folder templates.
-
-Supports EXIF date extraction with fallback to file modification time,
-configurable folder structure templates, and move/copy operations.
-"""
-
-from __future__ import annotations
-
-import logging
-import os
-import shutil
-from datetime import datetime
-
-import piexif
-
-from morphic.shared.constants import ALL_EXTENSIONS
-from morphic.shared.utils import find_files_by_extension
-
-logger = logging.getLogger(__name__)
-
-# ── Date Extraction ────────────────────────────────────────────────────────
-
-
-def get_file_date(path: str) -> datetime:
- """Extract the best available date for a file.
-
- Priority: EXIF DateTimeOriginal → EXIF DateTime → file mtime.
-
- Parameters
- ----------
- path : str
- Path to the file.
-
- Returns
- -------
- datetime
- The extracted date.
- """
- # Try EXIF first (images only)
- try:
- exif_dict = piexif.load(path)
- for ifd_key in ("Exif", "0th"):
- ifd = exif_dict.get(ifd_key, {})
- if not ifd:
- continue
- # DateTimeOriginal = 36867, DateTime = 306
- for tag_id in (36867, 306):
- val = ifd.get(tag_id)
- if val:
- if isinstance(val, bytes):
- val = val.decode("utf-8", errors="ignore")
- val = val.strip().rstrip("\x00")
- if val and val != "0000:00:00 00:00:00":
- return datetime.strptime(
- val,
- "%Y:%m:%d %H:%M:%S",
- )
- except Exception:
- pass
-
- # Fall back to file modification time
- mtime = os.path.getmtime(path)
- return datetime.fromtimestamp(mtime)
-
-
-# ── Template Rendering ─────────────────────────────────────────────────────
-
-
-def _render_template(template: str, dt: datetime) -> str:
- """Expand a folder template with date tokens.
-
- Supported tokens: ``{year}``, ``{month}``, ``{day}``,
- ``{hour}``, ``{minute}``.
- """
- return template.format(
- year=dt.strftime("%Y"),
- month=dt.strftime("%m"),
- day=dt.strftime("%d"),
- hour=dt.strftime("%H"),
- minute=dt.strftime("%M"),
- )
-
-
-# ── Plan & Execute ─────────────────────────────────────────────────────────
-
-
-def plan_sort(
- folder: str,
- template: str = "{year}/{month}/{day}",
- destination: str | None = None,
-) -> list[dict]:
- """Generate a sort plan without executing it.
-
- Parameters
- ----------
- folder : str
- Source folder to scan.
- template : str
- Folder template using ``{year}``, ``{month}``, ``{day}``,
- ``{hour}``, ``{minute}`` tokens.
- destination : str, optional
- Base destination folder. Defaults to *folder* itself.
-
- Returns
- -------
- list[dict]
- List of ``{"source", "destination", "date", "date_formatted"}``
- entries.
- """
- base = destination or folder
- paths = find_files_by_extension(folder, ALL_EXTENSIONS)
- plan: list[dict] = []
-
- for path in paths:
- dt = get_file_date(path)
- sub_path = _render_template(template, dt)
- dest_dir = os.path.join(base, sub_path)
- dest_file = os.path.join(dest_dir, os.path.basename(path))
-
- plan.append(
- {
- "source": path,
- "destination": dest_file,
- "date": dt.isoformat(),
- "date_formatted": dt.strftime("%Y-%m-%d %H:%M:%S"),
- }
- )
-
- return plan
-
-
-def execute_sort(
- plan: list[dict],
- operation: str = "copy",
-) -> dict:
- """Execute a previously generated sort plan.
-
- Parameters
- ----------
- plan : list[dict]
- Plan from :func:`plan_sort`.
- operation : str
- ``"move"`` or ``"copy"``.
-
- Returns
- -------
- dict
- ``{"completed", "errors", "total", "results"}``
- """
- if operation not in ("move", "copy"):
- raise ValueError("operation must be 'move' or 'copy'")
-
- results: list[dict] = []
- completed = 0
- errors = 0
-
- for entry in plan:
- src = entry["source"]
- dest = entry["destination"]
- try:
- os.makedirs(os.path.dirname(dest), exist_ok=True)
- if operation == "move":
- shutil.move(src, dest)
- else:
- shutil.copy2(src, dest)
- completed += 1
- results.append(
- {
- "source": src,
- "destination": dest,
- "status": "ok",
- }
- )
- except Exception as e:
- errors += 1
- results.append(
- {
- "source": src,
- "destination": dest,
- "status": "error",
- "error": str(e),
- }
- )
-
- return {
- "completed": completed,
- "errors": errors,
- "total": len(plan),
- "results": results,
- }
diff --git a/src/morphic/organizer/renamer.py b/src/morphic/organizer/renamer.py
deleted file mode 100644
index cff5e6a..0000000
--- a/src/morphic/organizer/renamer.py
+++ /dev/null
@@ -1,204 +0,0 @@
-"""
-Batch file renaming with fixed token templates.
-
-Supported tokens: ``{date}``, ``{datetime}``, ``{seq}``, ``{seq:N}``,
-``{original}``, ``{ext}``.
-"""
-
-from __future__ import annotations
-
-import logging
-import os
-import re
-import shutil
-from pathlib import Path
-
-from morphic.organizer.date_sorter import get_file_date
-from morphic.shared.constants import ALL_EXTENSIONS
-from morphic.shared.utils import find_files_by_extension
-
-logger = logging.getLogger(__name__)
-
-
-def _render_name(
- template: str,
- path: str,
- seq: int,
-) -> str:
- """Expand a rename template for a single file.
-
- Tokens
- ------
- ``{date}``
- ``YYYY-MM-DD`` from EXIF or mtime.
- ``{datetime}``
- ``YYYY-MM-DD_HH-MM-SS``.
- ``{seq}``
- Zero-padded sequence number (default 4 digits).
- ``{seq:N}``
- Sequence number padded to *N* digits.
- ``{original}``
- Original filename without extension.
- ``{ext}``
- Original extension including the dot.
- """
- dt = get_file_date(path)
- p = Path(path)
-
- result = template
- result = result.replace("{date}", dt.strftime("%Y-%m-%d"))
- result = result.replace("{datetime}", dt.strftime("%Y-%m-%d_%H-%M-%S"))
- result = result.replace("{original}", p.stem)
- result = result.replace("{ext}", p.suffix)
-
- # Handle {seq:N} patterns
- seq_pattern = re.compile(r"\{seq:(\d+)\}")
- match = seq_pattern.search(result)
- if match:
- pad = int(match.group(1))
- result = seq_pattern.sub(str(seq).zfill(pad), result)
- # Handle plain {seq} (default 4-digit padding)
- result = result.replace("{seq}", str(seq).zfill(4))
-
- return result
-
-
-def plan_rename(
- folder: str,
- template: str = "{date}_{seq}_{original}{ext}",
- operation: str = "move",
- start_seq: int = 1,
- output_folder: str | None = None,
-) -> list[dict]:
- """Generate a rename plan without executing it.
-
- Parameters
- ----------
- folder : str
- Source folder to scan.
- template : str
- Naming template with tokens.
- operation : str
- ``"move"`` (rename in place) or ``"copy"`` (write to
- *output_folder*).
- start_seq : int
- Starting sequence number.
- output_folder : str, optional
- Destination folder for copies. Defaults to *folder*.
-
- Returns
- -------
- list[dict]
- List of ``{"source", "new_name", "destination", "conflict"}``
- entries.
- """
- dest_base = output_folder or folder
- paths = find_files_by_extension(folder, ALL_EXTENSIONS)
-
- # Sort by date then name for consistent sequencing
- dated = []
- for path in paths:
- dt = get_file_date(path)
- dated.append((dt, path))
- dated.sort(key=lambda x: (x[0], x[1]))
-
- plan: list[dict] = []
- seen_destinations: set[str] = set()
-
- for i, (dt, path) in enumerate(dated):
- seq = start_seq + i
- new_name = _render_name(template, path, seq)
- dest = os.path.join(dest_base, new_name)
-
- conflict = dest in seen_destinations or (
- os.path.exists(dest)
- and os.path.abspath(dest) != os.path.abspath(path)
- )
- seen_destinations.add(dest)
-
- plan.append(
- {
- "source": path,
- "new_name": new_name,
- "destination": dest,
- "conflict": conflict,
- }
- )
-
- return plan
-
-
-def execute_rename(
- plan: list[dict],
- operation: str = "move",
-) -> dict:
- """Execute a previously generated rename plan.
-
- Parameters
- ----------
- plan : list[dict]
- Plan from :func:`plan_rename`.
- operation : str
- ``"move"`` or ``"copy"``.
-
- Returns
- -------
- dict
- ``{"completed", "errors", "skipped", "total", "results"}``
- """
- if operation not in ("move", "copy"):
- raise ValueError("operation must be 'move' or 'copy'")
-
- results: list[dict] = []
- completed = 0
- errors = 0
- skipped = 0
-
- for entry in plan:
- src = entry["source"]
- dest = entry["destination"]
-
- if entry.get("conflict"):
- skipped += 1
- results.append(
- {
- "source": src,
- "destination": dest,
- "status": "skipped",
- "reason": "name conflict",
- }
- )
- continue
-
- try:
- os.makedirs(os.path.dirname(dest), exist_ok=True)
- if operation == "move":
- shutil.move(src, dest)
- else:
- shutil.copy2(src, dest)
- completed += 1
- results.append(
- {
- "source": src,
- "destination": dest,
- "status": "ok",
- }
- )
- except Exception as e:
- errors += 1
- results.append(
- {
- "source": src,
- "destination": dest,
- "status": "error",
- "error": str(e),
- }
- )
-
- return {
- "completed": completed,
- "errors": errors,
- "skipped": skipped,
- "total": len(plan),
- "results": results,
- }
diff --git a/src/morphic/organizer/scanner.py b/src/morphic/organizer/scanner.py
deleted file mode 100644
index 06cf4ed..0000000
--- a/src/morphic/organizer/scanner.py
+++ /dev/null
@@ -1,179 +0,0 @@
-"""
-Background job management for the organizer module.
-
-Handles plan→preview→execute workflows for date sorting and batch
-renaming in background threads.
-"""
-
-from __future__ import annotations
-
-import logging
-import threading
-import time
-import uuid
-from dataclasses import dataclass, field
-
-from morphic.shared.utils import format_duration
-
-logger = logging.getLogger(__name__)
-
-
-@dataclass
-class ScanJob:
- """Represents a running or completed organizer job."""
-
- id: str
- folder: str
- mode: str # "sort" or "rename"
- operation: str = "copy" # "move" or "copy"
- template: str = "{year}/{month}/{day}"
- destination: str | None = None
- start_seq: int = 1
- status: str = "pending"
- phase: str = "idle" # "planning", "executing", "done"
- progress: float = 0.0
- message: str = ""
- error: str | None = None
- plan: list[dict] = field(default_factory=list)
- execution_result: dict = field(default_factory=dict)
- started_at: float = 0.0
- finished_at: float = 0.0
-
-
-# ── Job Registry ───────────────────────────────────────────────────────────
-
-_jobs: dict[str, ScanJob] = {}
-_lock = threading.Lock()
-
-
-def get_job(job_id: str) -> ScanJob | None:
- """Retrieve an organizer job by ID."""
- with _lock:
- return _jobs.get(job_id)
-
-
-def start_job(
- folder: str,
- mode: str,
- operation: str = "copy",
- template: str = "{year}/{month}/{day}",
- destination: str | None = None,
- start_seq: int = 1,
-) -> str:
- """Create and launch a planning job. Returns the job ID."""
- job_id = str(uuid.uuid4())[:8]
- job = ScanJob(
- id=job_id,
- folder=folder,
- mode=mode,
- operation=operation,
- template=template,
- destination=destination,
- start_seq=start_seq,
- )
- with _lock:
- _jobs[job_id] = job
-
- thread = threading.Thread(target=_run_plan, args=(job,), daemon=True)
- thread.start()
- return job_id
-
-
-def execute_job(job_id: str) -> bool:
- """Execute a previously planned job. Returns False if not found."""
- with _lock:
- job = _jobs.get(job_id)
- if not job or job.phase != "planned":
- return False
-
- thread = threading.Thread(
- target=_run_execute,
- args=(job,),
- daemon=True,
- )
- thread.start()
- return True
-
-
-def _run_plan(job: ScanJob) -> None:
- """Generate the plan in a background thread."""
- try:
- job.status = "scanning"
- job.phase = "planning"
- job.started_at = time.time()
- job.message = f"Planning {job.mode} for: {job.folder}"
-
- if job.mode == "sort":
- from morphic.organizer.date_sorter import plan_sort
-
- job.plan = plan_sort(
- job.folder,
- template=job.template,
- destination=job.destination,
- )
- else:
- from morphic.organizer.renamer import plan_rename
-
- job.plan = plan_rename(
- job.folder,
- template=job.template,
- operation=job.operation,
- start_seq=job.start_seq,
- output_folder=job.destination,
- )
-
- job.phase = "planned"
- job.status = "planned"
- job.progress = 0.5
- job.message = (
- f"Plan ready: {len(job.plan)} file(s) to {job.operation}."
- )
-
- except Exception as e:
- logger.exception("Organizer planning failed")
- job.status = "error"
- job.error = str(e)
- job.message = f"Error: {e}"
- job.finished_at = time.time()
-
-
-def _run_execute(job: ScanJob) -> None:
- """Execute the plan in a background thread."""
- try:
- job.status = "processing"
- job.phase = "executing"
- job.message = f"Executing {job.operation}..."
-
- if job.mode == "sort":
- from morphic.organizer.date_sorter import execute_sort
-
- job.execution_result = execute_sort(
- job.plan,
- operation=job.operation,
- )
- else:
- from morphic.organizer.renamer import execute_rename
-
- job.execution_result = execute_rename(
- job.plan,
- operation=job.operation,
- )
-
- job.phase = "done"
- job.status = "done"
- job.progress = 1.0
- job.finished_at = time.time()
- elapsed = job.finished_at - job.started_at
- res = job.execution_result
- job.message = (
- f"Done! {res.get('completed', 0)} files "
- f"{job.operation}d, {res.get('errors', 0)} error(s) "
- f"in {format_duration(elapsed)}."
- )
-
- except Exception as e:
- logger.exception("Organizer execution failed")
- job.status = "error"
- job.error = str(e)
- job.message = f"Error: {e}"
- job.finished_at = time.time()
diff --git a/src/morphic/resizer/__init__.py b/src/morphic/resizer/__init__.py
deleted file mode 100644
index 14df605..0000000
--- a/src/morphic/resizer/__init__.py
+++ /dev/null
@@ -1,15 +0,0 @@
-"""
-morphic.resizer - Batch image resizing with multiple modes.
-
-Supports fit, fill, stretch, and pad operations with configurable
-output format and background colour.
-"""
-
-from morphic.resizer.operations import resize_image
-from morphic.resizer.scanner import get_job, start_job
-
-__all__ = [
- "get_job",
- "resize_image",
- "start_job",
-]
diff --git a/src/morphic/resizer/operations.py b/src/morphic/resizer/operations.py
deleted file mode 100644
index aaacb03..0000000
--- a/src/morphic/resizer/operations.py
+++ /dev/null
@@ -1,119 +0,0 @@
-"""
-Image resize operations — fit, fill, stretch, and pad.
-
-All operations preserve the original format by default and support
-configurable output quality and format override.
-"""
-
-from __future__ import annotations
-
-import logging
-import os
-from pathlib import Path
-
-from PIL import Image, ImageOps
-
-logger = logging.getLogger(__name__)
-
-# Valid resize modes
-RESIZE_MODES = ("fit", "fill", "stretch", "pad")
-
-
-def resize_image(
- path: str,
- width: int,
- height: int,
- mode: str = "fit",
- output_folder: str | None = None,
- bg_color: str = "#000000",
- quality: int = 90,
- output_format: str | None = None,
-) -> str:
- """Resize a single image.
-
- Parameters
- ----------
- path : str
- Path to the source image.
- width : int
- Target width in pixels.
- height : int
- Target height in pixels.
- mode : str
- Resize mode: ``"fit"`` (within bounds), ``"fill"`` (cover + crop),
- ``"stretch"`` (ignore ratio), ``"pad"`` (fit + pad borders).
- output_folder : str, optional
- Write output here instead of overwriting. Creates the folder
- if needed.
- bg_color : str
- Background colour for pad mode (CSS hex, default black).
- quality : int
- JPEG/WebP quality (1-100).
- output_format : str, optional
- Force output format (e.g. ``".png"``). Uses original if *None*.
-
- Returns
- -------
- str
- Path to the output file.
-
- Raises
- ------
- FileNotFoundError
- If the source file does not exist.
- ValueError
- If an invalid mode is given.
- """
- if not os.path.isfile(path):
- raise FileNotFoundError(f"File not found: {path}")
- if mode not in RESIZE_MODES:
- raise ValueError(
- f"Invalid mode '{mode}'. Must be one of {RESIZE_MODES}"
- )
- if width <= 0 or height <= 0:
- raise ValueError("Width and height must be positive integers")
-
- img = Image.open(path)
-
- # Convert palette/LA images to RGBA/RGB for processing
- if img.mode in ("P", "LA"):
- img = img.convert("RGBA")
- elif img.mode == "L":
- img = img.convert("RGB")
-
- size = (width, height)
-
- if mode == "fit":
- img.thumbnail(size, Image.Resampling.LANCZOS)
- elif mode == "fill":
- img = ImageOps.fit(img, size, Image.Resampling.LANCZOS)
- elif mode == "stretch":
- img = img.resize(size, Image.Resampling.LANCZOS)
- elif mode == "pad":
- img = ImageOps.pad(img, size, Image.Resampling.LANCZOS, color=bg_color)
-
- # Determine output path
- src = Path(path)
- ext = output_format if output_format else src.suffix
- if not ext.startswith("."):
- ext = f".{ext}"
-
- if output_folder:
- os.makedirs(output_folder, exist_ok=True)
- dest = Path(output_folder) / f"{src.stem}{ext}"
- else:
- dest = src.with_suffix(ext)
-
- # Convert RGBA to RGB for formats that don't support alpha
- if img.mode == "RGBA" and ext.lower() in (".jpg", ".jpeg", ".bmp"):
- img = img.convert("RGB")
-
- save_kwargs: dict = {}
- if ext.lower() in (".jpg", ".jpeg", ".webp"):
- save_kwargs["quality"] = quality
- if ext.lower() == ".png":
- save_kwargs["optimize"] = True
-
- img.save(str(dest), **save_kwargs)
- logger.info("Resized %s → %s (%s)", path, dest, mode)
- return str(dest)
diff --git a/src/morphic/resizer/scanner.py b/src/morphic/resizer/scanner.py
deleted file mode 100644
index 70bfc68..0000000
--- a/src/morphic/resizer/scanner.py
+++ /dev/null
@@ -1,168 +0,0 @@
-"""
-Background scan job management for the resizer module.
-
-Discovers images in a folder and resizes them in a background thread.
-"""
-
-from __future__ import annotations
-
-import logging
-import os
-import threading
-import time
-import uuid
-from dataclasses import dataclass, field
-
-from morphic.shared.constants import IMAGE_EXTENSIONS
-from morphic.shared.utils import (
- find_files_by_extension,
- format_duration,
- format_file_size,
-)
-
-logger = logging.getLogger(__name__)
-
-
-@dataclass
-class ScanJob:
- """Represents a running or completed resize job."""
-
- id: str
- folder: str
- width: int
- height: int
- mode: str
- output_folder: str | None = None
- bg_color: str = "#000000"
- quality: int = 90
- status: str = "pending"
- progress: float = 0.0
- message: str = ""
- error: str | None = None
- total_files: int = 0
- processed_files: int = 0
- errors: list[dict] = field(default_factory=list)
- results: list[dict] = field(default_factory=list)
- started_at: float = 0.0
- finished_at: float = 0.0
-
-
-# ── Job Registry ───────────────────────────────────────────────────────────
-
-_jobs: dict[str, ScanJob] = {}
-_lock = threading.Lock()
-
-
-def get_job(job_id: str) -> ScanJob | None:
- """Retrieve a resize job by ID."""
- with _lock:
- return _jobs.get(job_id)
-
-
-def start_job(
- folder: str,
- width: int,
- height: int,
- mode: str,
- output_folder: str | None = None,
- bg_color: str = "#000000",
- quality: int = 90,
-) -> str:
- """Create and launch a new resize job. Returns the job ID."""
- job_id = str(uuid.uuid4())[:8]
- job = ScanJob(
- id=job_id,
- folder=folder,
- width=width,
- height=height,
- mode=mode,
- output_folder=output_folder,
- bg_color=bg_color,
- quality=quality,
- )
- with _lock:
- _jobs[job_id] = job
-
- thread = threading.Thread(target=_run_resize, args=(job,), daemon=True)
- thread.start()
- return job_id
-
-
-def _run_resize(job: ScanJob) -> None:
- """Execute the resize operation in a background thread."""
- from morphic.resizer.operations import resize_image
-
- try:
- job.status = "scanning"
- job.started_at = time.time()
- job.message = f"Scanning folder: {job.folder}"
-
- paths = find_files_by_extension(job.folder, IMAGE_EXTENSIONS)
- job.total_files = len(paths)
-
- if not paths:
- job.status = "done"
- job.progress = 1.0
- job.finished_at = time.time()
- job.message = "No image files found."
- return
-
- job.status = "processing"
- for i, path in enumerate(paths):
- try:
- original_size = os.path.getsize(path)
- dest = resize_image(
- path,
- job.width,
- job.height,
- mode=job.mode,
- output_folder=job.output_folder,
- bg_color=job.bg_color,
- quality=job.quality,
- )
- new_size = os.path.getsize(dest) if os.path.isfile(dest) else 0
- job.results.append(
- {
- "source": path,
- "destination": dest,
- "status": "ok",
- "original_size": original_size,
- "new_size": new_size,
- "original_size_fmt": format_file_size(original_size),
- "new_size_fmt": format_file_size(new_size),
- }
- )
- except Exception as e:
- job.errors.append({"path": path, "error": str(e)})
- job.results.append(
- {
- "source": path,
- "destination": None,
- "status": "error",
- "error": str(e),
- }
- )
-
- job.processed_files = i + 1
- job.progress = (i + 1) / job.total_files
- job.message = (
- f"Resizing: {i + 1}/{job.total_files} "
- f"({len(job.errors)} errors)"
- )
-
- job.status = "done"
- job.progress = 1.0
- job.finished_at = time.time()
- elapsed = job.finished_at - job.started_at
- job.message = (
- f"Done! Resized {job.processed_files} images "
- f"in {format_duration(elapsed)}. "
- f"{len(job.errors)} error(s)."
- )
-
- except Exception as e:
- logger.exception("Resize job failed")
- job.status = "error"
- job.error = str(e)
- job.message = f"Error: {e}"
- job.finished_at = time.time()
diff --git a/src/morphic/shared/__init__.py b/src/morphic/shared/__init__.py
deleted file mode 100644
index 3576858..0000000
--- a/src/morphic/shared/__init__.py
+++ /dev/null
@@ -1,33 +0,0 @@
-"""
-morphic.shared - Constants, utilities, and helpers shared across modules.
-"""
-
-from morphic.shared.constants import (
- ALL_EXTENSIONS,
- EXCLUDED_FOLDERS,
- IMAGE_EXTENSIONS,
- VIDEO_EXTENSIONS,
-)
-from morphic.shared.utils import (
- find_files_by_extension,
- format_duration,
- format_file_size,
- is_excluded_path,
- is_image,
- is_video,
- normalise_ext,
-)
-
-__all__ = [
- "ALL_EXTENSIONS",
- "EXCLUDED_FOLDERS",
- "IMAGE_EXTENSIONS",
- "VIDEO_EXTENSIONS",
- "find_files_by_extension",
- "format_duration",
- "format_file_size",
- "is_excluded_path",
- "is_image",
- "is_video",
- "normalise_ext",
-]
diff --git a/src/morphic/shared/constants.py b/src/morphic/shared/constants.py
deleted file mode 100644
index a499725..0000000
--- a/src/morphic/shared/constants.py
+++ /dev/null
@@ -1,126 +0,0 @@
-"""
-Shared constants for morphic – extension sets, exclusion lists, defaults.
-
-Merges constants from both the converter and dupfinder modules so that
-every part of the project works with a single canonical set of
-supported file types.
-"""
-
-from __future__ import annotations
-
-# ── Supported extensions ───────────────────────────────────────────────────
-# Union of converter + dupfinder sets.
-
-IMAGE_EXTENSIONS: frozenset[str] = frozenset(
- {
- ".jpg",
- ".jpeg",
- ".png",
- ".tif",
- ".tiff",
- ".bmp",
- ".webp",
- ".gif",
- ".ico",
- ".heic",
- ".heif",
- ".avif",
- # Extra formats from dupfinder (raw / vector)
- ".svg",
- ".raw",
- ".cr2",
- ".nef",
- ".arw",
- ".dng",
- ".orf",
- ".rw2",
- ".pef",
- ".srw",
- }
-)
-
-VIDEO_EXTENSIONS: frozenset[str] = frozenset(
- {
- ".mp4",
- ".mov",
- ".avi",
- ".mkv",
- ".webm",
- ".flv",
- ".wmv",
- ".m4v",
- ".mpeg",
- ".mpg",
- ".3gp",
- ".ts",
- # Extra formats from dupfinder
- ".ogv",
- ".mts",
- ".m2ts",
- ".vob",
- ".divx",
- ".xvid",
- ".asf",
- ".rm",
- ".rmvb",
- }
-)
-
-ALL_EXTENSIONS: frozenset[str] = IMAGE_EXTENSIONS | VIDEO_EXTENSIONS
-
-# ── Folders to skip when scanning ──────────────────────────────────────────
-
-EXCLUDED_FOLDERS: frozenset[str] = frozenset(
- {
- # Windows
- "$recycle.bin",
- "$recycle",
- "recycler",
- "recycled",
- "system volume information",
- "windows",
- "appdata",
- # macOS
- ".trash",
- ".trashes",
- ".spotlight-v100",
- ".fseventsd",
- ".ds_store",
- # Linux
- "lost+found",
- "trash",
- # Thumbnails
- ".thumbnails",
- ".thumb",
- "thumbs",
- # NAS
- "@eadir",
- # Version control
- ".git",
- ".svn",
- ".hg",
- # Development
- "__pycache__",
- ".cache",
- "node_modules",
- ".venv",
- "venv",
- }
-)
-
-# ── Alias resolution ──────────────────────────────────────────────────────
-
-ALIASES: dict[str, str] = {
- ".jpeg": ".jpg",
- ".tiff": ".tif",
- ".mpg": ".mpeg",
-}
-
-# ── Dupfinder default thresholds ──────────────────────────────────────────
-
-DEFAULT_IMAGE_THRESHOLD: float = 0.90
-DEFAULT_VIDEO_THRESHOLD: float = 0.85
-DEFAULT_HASH_SIZE: int = 16
-DEFAULT_NUM_FRAMES: int = 10
-DEFAULT_NUM_WORKERS: int = 4
-DEFAULT_BATCH_SIZE: int = 1000
diff --git a/src/morphic/shared/file_browser.py b/src/morphic/shared/file_browser.py
deleted file mode 100644
index 12121e3..0000000
--- a/src/morphic/shared/file_browser.py
+++ /dev/null
@@ -1,181 +0,0 @@
-"""
-Native OS file/folder dialog support.
-
-Attempts to open a native folder picker on Linux, macOS, and Windows.
-Falls back gracefully when no GUI toolkit is available (e.g. headless server).
-"""
-
-from __future__ import annotations
-
-import logging
-import os
-import platform
-import subprocess
-from pathlib import Path
-
-logger = logging.getLogger(__name__)
-
-
-def open_native_folder_dialog(
- initial_dir: str | None = None,
-) -> str | None:
- """
- Open the native OS folder picker dialog.
-
- Returns the selected folder path, or ``None`` if cancelled / unavailable.
-
- Tries, in order:
-
- 1. **tkinter** ``filedialog.askdirectory()``
- 2. **zenity** — GNOME / GTK-based Linux
- 3. **kdialog** — KDE Linux
- 4. **osascript** — macOS
- 5. **powershell** — Windows
- """
- # In test mode we prefer a headless preset path to avoid GUI dialog popups.
- test_folder = os.environ.get("MORPHIC_TEST_FOLDER")
- if test_folder and os.path.isdir(test_folder):
- return test_folder
-
- if os.environ.get("PYTEST_CURRENT_TEST"):
- asset_folder = Path(__file__).resolve().parents[2] / "assets" / "test"
- if asset_folder.exists():
- return str(asset_folder)
-
- initial_dir = initial_dir or str(os.path.expanduser("~"))
-
- result = _try_tkinter(initial_dir)
- if result is not None:
- return result
-
- system = platform.system()
-
- if system == "Linux":
- result = _try_zenity(initial_dir)
- if result is not None:
- return result
- result = _try_kdialog(initial_dir)
- if result is not None:
- return result
-
- if system == "Darwin":
- result = _try_osascript(initial_dir)
- if result is not None:
- return result
-
- if system == "Windows":
- result = _try_powershell(initial_dir)
- if result is not None:
- return result
-
- logger.debug("No native folder dialog available on this system")
- return None
-
-
-# ── Backend implementations ────────────────────────────────────────────────
-
-
-def _try_tkinter(initial_dir: str) -> str | None:
- try:
- import tkinter as tk
- from tkinter import filedialog
-
- root = tk.Tk()
- root.withdraw()
- root.attributes("-topmost", True)
- folder = filedialog.askdirectory(
- initialdir=initial_dir,
- title="Select folder to scan",
- )
- root.destroy()
- return folder if folder else None
- except Exception as exc:
- logger.debug("tkinter dialog failed: %s", exc)
- return None
-
-
-def _try_zenity(initial_dir: str) -> str | None:
- try:
- result = subprocess.run(
- [
- "zenity",
- "--file-selection",
- "--directory",
- f"--filename={initial_dir}/",
- "--title=Select folder to scan",
- ],
- capture_output=True,
- text=True,
- timeout=120,
- )
- if result.returncode == 0 and result.stdout.strip():
- return result.stdout.strip()
- return None
- except (FileNotFoundError, subprocess.TimeoutExpired):
- return None
-
-
-def _try_kdialog(initial_dir: str) -> str | None:
- try:
- result = subprocess.run(
- [
- "kdialog",
- "--getexistingdirectory",
- initial_dir,
- "--title",
- "Select folder to scan",
- ],
- capture_output=True,
- text=True,
- timeout=120,
- )
- if result.returncode == 0 and result.stdout.strip():
- return result.stdout.strip()
- return None
- except (FileNotFoundError, subprocess.TimeoutExpired):
- return None
-
-
-def _try_osascript(initial_dir: str) -> str | None:
- try:
- script = (
- f'set defaultDir to POSIX file "{initial_dir}"\n'
- f"set chosenDir to choose folder with prompt "
- f'"Select folder to scan" default location defaultDir\n'
- f"return POSIX path of chosenDir"
- )
- result = subprocess.run(
- ["osascript", "-e", script],
- capture_output=True,
- text=True,
- timeout=120,
- )
- if result.returncode == 0 and result.stdout.strip():
- return result.stdout.strip().rstrip("/")
- return None
- except (FileNotFoundError, subprocess.TimeoutExpired):
- return None
-
-
-def _try_powershell(initial_dir: str) -> str | None:
- try:
- script = (
- "[System.Reflection.Assembly]::LoadWithPartialName("
- "'System.Windows.Forms') | Out-Null; "
- "$dialog = New-Object System.Windows.Forms.FolderBrowserDialog; "
- f"$dialog.SelectedPath = '{initial_dir}'; "
- "$dialog.Description = 'Select folder to scan'; "
- "if ($dialog.ShowDialog() -eq 'OK') "
- "{ $dialog.SelectedPath }"
- )
- result = subprocess.run(
- ["powershell", "-Command", script],
- capture_output=True,
- text=True,
- timeout=120,
- )
- if result.returncode == 0 and result.stdout.strip():
- return result.stdout.strip()
- return None
- except (FileNotFoundError, subprocess.TimeoutExpired):
- return None
diff --git a/src/morphic/shared/thumbnails.py b/src/morphic/shared/thumbnails.py
deleted file mode 100644
index d183e43..0000000
--- a/src/morphic/shared/thumbnails.py
+++ /dev/null
@@ -1,107 +0,0 @@
-"""
-Thumbnail generation shared by converter and dupfinder frontends.
-
-Generates JPEG thumbnails for images (Pillow) and videos (ffmpeg subprocess).
-"""
-
-from __future__ import annotations
-
-import io
-import logging
-import subprocess
-
-from PIL import Image
-
-logger = logging.getLogger(__name__)
-
-
-def generate_image_thumbnail(
- file_path: str,
- size: int = 300,
-) -> io.BytesIO:
- """
- Create a JPEG thumbnail for an image file.
-
- Parameters
- ----------
- file_path : str
- Absolute path to the image.
- size : int
- Maximum width/height in pixels.
-
- Returns
- -------
- io.BytesIO
- JPEG image bytes (seeked to 0).
- """
- img = Image.open(file_path)
- img.thumbnail((size, size), Image.Resampling.LANCZOS)
-
- if img.mode in ("RGBA", "P", "LA"):
- img = img.convert("RGB")
-
- buf = io.BytesIO()
- img.save(buf, format="JPEG", quality=80)
- buf.seek(0)
- return buf
-
-
-def generate_video_thumbnail(
- file_path: str,
- size: int = 300,
-) -> io.BytesIO | None:
- """
- Extract a single frame from a video and return it as a JPEG thumbnail.
-
- Uses ``ffmpeg`` piped to stdout. Returns ``None`` on failure.
-
- Parameters
- ----------
- file_path : str
- Absolute path to the video.
- size : int
- Maximum width/height in pixels.
-
- Returns
- -------
- io.BytesIO | None
- JPEG image bytes (seeked to 0), or ``None``.
- """
- cmd = [
- "ffmpeg",
- "-y",
- "-i",
- file_path,
- "-ss",
- "00:00:01",
- "-vframes",
- "1",
- "-vf",
- (f"scale={size}:{size}:force_original_aspect_ratio=decrease"),
- "-f",
- "image2pipe",
- "-vcodec",
- "mjpeg",
- "-q:v",
- "5",
- "pipe:1",
- ]
- result = subprocess.run(
- cmd,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- timeout=10,
- )
- if result.returncode != 0 or not result.stdout:
- # Retry at 0s for very short clips
- cmd[5] = "00:00:00"
- result = subprocess.run(
- cmd,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- timeout=10,
- )
- if result.stdout:
- buf = io.BytesIO(result.stdout)
- return buf
- return None
diff --git a/src/morphic/shared/utils.py b/src/morphic/shared/utils.py
deleted file mode 100644
index 21a5ceb..0000000
--- a/src/morphic/shared/utils.py
+++ /dev/null
@@ -1,141 +0,0 @@
-"""
-Shared utility helpers used across morphic modules.
-"""
-
-from __future__ import annotations
-
-import logging
-import os
-from contextlib import contextmanager
-from pathlib import Path
-from typing import Generator
-
-
-from morphic.shared.constants import (
- ALIASES,
- EXCLUDED_FOLDERS,
- IMAGE_EXTENSIONS,
- VIDEO_EXTENSIONS,
-)
-
-logger = logging.getLogger(__name__)
-
-
-# ── Extension helpers ──────────────────────────────────────────────────────
-
-
-def normalise_ext(ext: str) -> str:
- """Lowercase and unify aliases (.jpeg -> .jpg, .tiff -> .tif)."""
- ext = ext.lower()
- return ALIASES.get(ext, ext)
-
-
-def is_image(path: str) -> bool:
- """Return True if the file extension is an image type."""
- return normalise_ext(Path(path).suffix) in IMAGE_EXTENSIONS
-
-
-def is_video(path: str) -> bool:
- """Return True if the file extension is a video type."""
- return normalise_ext(Path(path).suffix) in VIDEO_EXTENSIONS
-
-
-# ── Formatting helpers ─────────────────────────────────────────────────────
-
-
-def format_file_size(size_bytes: int) -> str:
- """Format file size in human-readable format."""
- size = float(size_bytes)
- for unit in ["B", "KB", "MB", "GB"]:
- if size < 1024:
- return f"{size:.2f} {unit}"
- size /= 1024
- return f"{size:.2f} TB"
-
-
-def format_duration(seconds: float) -> str:
- """Format duration in human-readable format."""
- hours = int(seconds // 3600)
- minutes = int((seconds % 3600) // 60)
- secs = int(seconds % 60)
- if hours > 0:
- return f"{hours}h {minutes}m {secs}s"
- if minutes > 0:
- return f"{minutes}m {secs}s"
- return f"{secs}s"
-
-
-# ── File scanning helpers ──────────────────────────────────────────────────
-
-
-def is_excluded_path(
- file_path: str,
- excluded_folders: frozenset[str] = EXCLUDED_FOLDERS,
-) -> bool:
- """Check if a file path contains any excluded folder."""
- path_parts = Path(file_path).parts
- return any(
- excluded in part.lower()
- for part in path_parts
- for excluded in excluded_folders
- )
-
-
-def find_files_by_extension(
- folder: str,
- extensions: frozenset[str] | set[str],
- excluded_folders: frozenset[str] = EXCLUDED_FOLDERS,
-) -> list[str]:
- """
- Find all files with given extensions in *folder* recursively.
-
- Parameters
- ----------
- folder : str
- Root folder to search.
- extensions : set[str]
- File extensions to match (with dot, e.g. ``".jpg"``).
- excluded_folders : set[str]
- Folder names to exclude.
-
- Returns
- -------
- list[str]
- Sorted list of absolute file paths.
- """
- files: list[str] = []
- folder_path = Path(folder)
- logger.info("Scanning for files in: %s", folder)
-
- for ext in extensions:
- files.extend(str(p) for p in folder_path.rglob(f"*{ext}"))
- files.extend(str(p) for p in folder_path.rglob(f"*{ext.upper()}"))
-
- # De-duplicate and filter
- files = sorted(
- {f for f in files if not is_excluded_path(f, excluded_folders)}
- )
- logger.info("Found %d files", len(files))
- return files
-
-
-# ── stderr suppression (for OpenCV/ffmpeg) ─────────────────────────────────
-
-
-@contextmanager
-def suppress_stderr() -> Generator[None, None, None]:
- """
- Suppress stderr output at the file-descriptor level.
-
- Silences low-level library warnings (e.g. ffmpeg/OpenCV codec messages)
- that cannot be caught by Python's logging framework.
- """
- devnull_fd = os.open(os.devnull, os.O_WRONLY)
- old_stderr_fd = os.dup(2)
- os.dup2(devnull_fd, 2)
- try:
- yield
- finally:
- os.dup2(old_stderr_fd, 2)
- os.close(devnull_fd)
- os.close(old_stderr_fd)
diff --git a/tests/conftest.py b/tests/conftest.py
deleted file mode 100644
index 3dfd9ea..0000000
--- a/tests/conftest.py
+++ /dev/null
@@ -1,86 +0,0 @@
-"""Shared test fixtures."""
-
-from __future__ import annotations
-
-from pathlib import Path
-
-import pytest
-from PIL import Image
-
-from morphic.frontend.app import create_app
-
-ASSETS_TEST_DIR = Path(__file__).resolve().parents[1] / "assets" / "test"
-
-
-@pytest.fixture()
-def app(monkeypatch):
- """Create a Flask app for testing."""
- if ASSETS_TEST_DIR.exists():
- monkeypatch.setenv("MORPHIC_TEST_FOLDER", str(ASSETS_TEST_DIR))
-
- initial_folder = (
- str(ASSETS_TEST_DIR) if ASSETS_TEST_DIR.exists() else "/tmp"
- )
- application = create_app(initial_folder=initial_folder)
- application.config["TESTING"] = True
- return application
-
-
-@pytest.fixture()
-def client(app):
- """Flask test client."""
- with app.test_client() as c:
- yield c
-
-
-@pytest.fixture()
-def tmp_media(tmp_path):
- """Create a temp directory with sample image/video files."""
-
- # Create images
- for name in ["photo.jpg", "image.png", "pic.tif"]:
- img = Image.new("RGB", (10, 10), color="red")
- img.save(str(tmp_path / name))
-
- # Create fake video files (0-byte placeholders)
- for name in ["clip.mp4", "movie.mov"]:
- (tmp_path / name).write_bytes(b"\x00" * 100)
-
- # Create a non-media file
- (tmp_path / "readme.txt").write_text("hello")
-
- # Subfolder with more files
- sub = tmp_path / "sub"
- sub.mkdir()
- img = Image.new("RGB", (20, 20), color="blue")
- img.save(str(sub / "deep.jpg"))
- (sub / "deep.mp4").write_bytes(b"\x00" * 50)
-
- return tmp_path
-
-
-@pytest.fixture()
-def test_image(tmp_path):
- """Create a single test image and return its path."""
- path = tmp_path / "test.jpg"
- img = Image.new("RGB", (100, 100), color="green")
- img.save(str(path))
- return str(path)
-
-
-@pytest.fixture()
-def rgba_image(tmp_path):
- """Create an RGBA test image and return its path."""
- path = tmp_path / "test_rgba.png"
- img = Image.new("RGBA", (50, 50), color=(255, 0, 0, 128))
- img.save(str(path))
- return str(path)
-
-
-@pytest.fixture()
-def palette_image(tmp_path):
- """Create a palette (P mode) test image and return its path."""
- path = tmp_path / "test_palette.png"
- img = Image.new("P", (50, 50))
- img.save(str(path))
- return str(path)
diff --git a/tests/converter/__init__.py b/tests/converter/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/tests/converter/test_constants.py b/tests/converter/test_constants.py
deleted file mode 100644
index 2c73126..0000000
--- a/tests/converter/test_constants.py
+++ /dev/null
@@ -1,93 +0,0 @@
-"""Tests for morphic.converter.constants."""
-
-from morphic.converter.constants import (
- IMAGE_CONVERSIONS,
- VIDEO_CONVERSIONS,
- _CANONICAL_IMAGE,
- _CANONICAL_VIDEO,
- _normalise,
-)
-from morphic.shared.constants import IMAGE_EXTENSIONS
-
-
-class TestNormalise:
- def test_aliases(self) -> None:
- assert _normalise(".jpeg") == ".jpg"
- assert _normalise(".tiff") == ".tif"
-
- def test_passthrough(self) -> None:
- assert _normalise(".png") == ".png"
-
- def test_case_insensitive(self) -> None:
- assert _normalise(".JPEG") == ".jpg"
-
-
-class TestCanonicalSets:
- def test_canonical_image_not_empty(self) -> None:
- assert len(_CANONICAL_IMAGE) > 0
-
- def test_canonical_video_not_empty(self) -> None:
- assert len(_CANONICAL_VIDEO) > 0
-
- def test_canonical_are_subsets(self) -> None:
- normed_img = {_normalise(e) for e in IMAGE_EXTENSIONS}
- for ext in _CANONICAL_IMAGE:
- assert ext in normed_img
-
- def test_common_image_canonicals(self) -> None:
- for ext in [".jpg", ".png", ".webp", ".bmp", ".gif"]:
- assert ext in _CANONICAL_IMAGE
-
- def test_common_video_canonicals(self) -> None:
- for ext in [".mp4", ".mov", ".avi", ".mkv", ".webm"]:
- assert ext in _CANONICAL_VIDEO
-
-
-class TestImageConversions:
- def test_not_empty(self) -> None:
- assert len(IMAGE_CONVERSIONS) > 0
-
- def test_image_target_does_not_include_self(self) -> None:
- for ext, targets in IMAGE_CONVERSIONS.items():
- norm = _normalise(ext)
- assert norm not in targets, f"{norm} in targets for {ext}"
-
- def test_all_targets_are_canonical(self) -> None:
- for ext, targets in IMAGE_CONVERSIONS.items():
- for t in targets:
- assert t in _CANONICAL_IMAGE, f"{t} not canonical"
-
- def test_jpg_can_convert_to_png(self) -> None:
- targets = IMAGE_CONVERSIONS.get(".jpg", [])
- assert ".png" in targets
-
- def test_png_can_convert_to_jpg(self) -> None:
- targets = IMAGE_CONVERSIONS.get(".png", [])
- assert ".jpg" in targets
-
- def test_targets_are_sorted(self) -> None:
- for ext, targets in IMAGE_CONVERSIONS.items():
- assert targets == sorted(targets), f"targets for {ext} not sorted"
-
-
-class TestVideoConversions:
- def test_not_empty(self) -> None:
- assert len(VIDEO_CONVERSIONS) > 0
-
- def test_video_target_does_not_include_self(self) -> None:
- for ext, targets in VIDEO_CONVERSIONS.items():
- norm = _normalise(ext)
- assert norm not in targets, f"{norm} in targets for {ext}"
-
- def test_all_targets_are_canonical(self) -> None:
- for ext, targets in VIDEO_CONVERSIONS.items():
- for t in targets:
- assert t in _CANONICAL_VIDEO, f"{t} not canonical"
-
- def test_mp4_can_convert_to_mkv(self) -> None:
- targets = VIDEO_CONVERSIONS.get(".mp4", [])
- assert ".mkv" in targets
-
- def test_targets_are_sorted(self) -> None:
- for ext, targets in VIDEO_CONVERSIONS.items():
- assert targets == sorted(targets), f"targets for {ext} not sorted"
diff --git a/tests/converter/test_converter.py b/tests/converter/test_converter.py
deleted file mode 100644
index a5f5e1f..0000000
--- a/tests/converter/test_converter.py
+++ /dev/null
@@ -1,280 +0,0 @@
-"""Tests for morphic.converter.converter."""
-
-from __future__ import annotations
-
-import os
-from unittest.mock import MagicMock, patch
-
-import pytest
-from PIL import Image
-
-from morphic.converter import converter
-from morphic.converter.converter import (
- _ffmpeg_available,
- convert_file,
- convert_image,
- convert_video,
-)
-
-
-class TestFfmpegAvailable:
- def test_returns_bool(self) -> None:
- result = _ffmpeg_available()
- assert isinstance(result, bool)
-
-
-class TestConvertImage:
- def test_jpg_to_png(self, tmp_path) -> None:
- src = tmp_path / "test.jpg"
- Image.new("RGB", (50, 50), "red").save(str(src))
-
- dest = convert_image(str(src), ".png")
- assert os.path.isfile(dest)
- assert dest.endswith(".png")
-
- def test_png_to_jpg(self, tmp_path) -> None:
- src = tmp_path / "test.png"
- Image.new("RGB", (50, 50), "blue").save(str(src))
-
- dest = convert_image(str(src), ".jpg")
- assert os.path.isfile(dest)
- assert dest.endswith(".jpg")
-
- def test_rgba_to_jpg(self, tmp_path) -> None:
- src = tmp_path / "rgba.png"
- Image.new("RGBA", (50, 50), (255, 0, 0, 128)).save(str(src))
-
- dest = convert_image(str(src), ".jpg")
- assert os.path.isfile(dest)
- img = Image.open(dest)
- assert img.mode == "RGB"
-
- def test_palette_to_jpg(self, tmp_path) -> None:
- src = tmp_path / "palette.png"
- Image.new("P", (50, 50)).save(str(src))
-
- dest = convert_image(str(src), ".jpg")
- assert os.path.isfile(dest)
- img = Image.open(dest)
- assert img.mode == "RGB"
-
- def test_output_dir(self, tmp_path) -> None:
- src = tmp_path / "test.jpg"
- Image.new("RGB", (50, 50), "green").save(str(src))
- out_dir = tmp_path / "output"
-
- dest = convert_image(str(src), ".png", output_dir=str(out_dir))
- assert os.path.isfile(dest)
- assert str(out_dir) in dest
-
- def test_avoid_overwrite(self, tmp_path) -> None:
- src = tmp_path / "img.jpg"
- Image.new("RGB", (50, 50), "red").save(str(src))
-
- existing = tmp_path / "img.png"
- existing.write_text("existing")
-
- dest = convert_image(str(src), ".png")
- assert os.path.isfile(dest)
- assert "converted" in os.path.basename(dest)
-
- def test_ext_without_dot(self, tmp_path) -> None:
- src = tmp_path / "test.jpg"
- Image.new("RGB", (50, 50), "red").save(str(src))
-
- dest = convert_image(str(src), "png")
- assert dest.endswith(".png")
-
- def test_webp_quality(self, tmp_path) -> None:
- src = tmp_path / "test.jpg"
- Image.new("RGB", (50, 50), "red").save(str(src))
-
- dest = convert_image(str(src), ".webp")
- assert os.path.isfile(dest)
- assert dest.endswith(".webp")
-
- def test_tiff_compression(self, tmp_path) -> None:
- src = tmp_path / "test.jpg"
- Image.new("RGB", (50, 50), "red").save(str(src))
-
- dest = convert_image(str(src), ".tif")
- assert os.path.isfile(dest)
-
- def test_bmp_conversion(self, tmp_path) -> None:
- src = tmp_path / "test.png"
- Image.new("RGBA", (50, 50), (255, 0, 0, 128)).save(str(src))
-
- dest = convert_image(str(src), ".bmp")
- assert os.path.isfile(dest)
- img = Image.open(dest)
- assert img.mode == "RGB"
-
- def test_ico_conversion(self, tmp_path) -> None:
- src = tmp_path / "test.png"
- Image.new("RGBA", (32, 32), (0, 255, 0, 200)).save(str(src))
-
- dest = convert_image(str(src), ".ico")
- assert os.path.isfile(dest)
-
-
-class TestConvertVideo:
- @patch("morphic.converter.converter._ffmpeg_available", return_value=False)
- def test_no_ffmpeg(self, mock_ffmpeg, tmp_path) -> None:
- src = tmp_path / "test.mp4"
- src.write_bytes(b"\x00" * 100)
-
- with pytest.raises(RuntimeError, match="ffmpeg is not installed"):
- convert_video(str(src), ".avi")
-
- @patch("morphic.converter.converter.subprocess.run")
- @patch("morphic.converter.converter._ffmpeg_available", return_value=True)
- def test_successful_conversion(
- self, mock_ffmpeg, mock_run, tmp_path
- ) -> None:
- src = tmp_path / "test.mp4"
- src.write_bytes(b"\x00" * 100)
-
- mock_run.return_value = MagicMock(returncode=0, stderr="")
-
- expected_dest = tmp_path / "test.avi"
- expected_dest.write_bytes(b"\x00" * 50)
-
- dest = convert_video(str(src), ".avi")
- assert dest.endswith(".avi")
- mock_run.assert_called_once()
-
- @patch("morphic.converter.converter.subprocess.run")
- @patch("morphic.converter.converter._ffmpeg_available", return_value=True)
- def test_ffmpeg_error(self, mock_ffmpeg, mock_run, tmp_path) -> None:
- src = tmp_path / "test.mp4"
- src.write_bytes(b"\x00" * 100)
-
- mock_run.return_value = MagicMock(
- returncode=1,
- stderr="conversion error",
- )
-
- with pytest.raises(RuntimeError, match="ffmpeg error"):
- convert_video(str(src), ".avi")
-
- @patch("morphic.converter.converter.subprocess.run")
- @patch("morphic.converter.converter._ffmpeg_available", return_value=True)
- def test_mkv_stream_copy(self, mock_ffmpeg, mock_run, tmp_path) -> None:
- src = tmp_path / "test.mp4"
- src.write_bytes(b"\x00" * 100)
-
- mock_run.return_value = MagicMock(returncode=0, stderr="")
-
- expected_dest = tmp_path / "test.mkv"
- expected_dest.write_bytes(b"\x00" * 50)
-
- dest = convert_video(str(src), ".mkv")
- assert dest.endswith(".mkv")
- cmd_args = mock_run.call_args[0][0]
- assert "-c" in cmd_args
- assert "copy" in cmd_args
-
- @patch("morphic.converter.converter.subprocess.run")
- @patch("morphic.converter.converter._ffmpeg_available", return_value=True)
- def test_ts_stream_copy(self, mock_ffmpeg, mock_run, tmp_path) -> None:
- src = tmp_path / "test.mp4"
- src.write_bytes(b"\x00" * 100)
-
- mock_run.return_value = MagicMock(returncode=0, stderr="")
-
- expected_dest = tmp_path / "test.ts"
- expected_dest.write_bytes(b"\x00" * 50)
-
- dest = convert_video(str(src), ".ts")
- assert dest.endswith(".ts")
-
- @patch("morphic.converter.converter.subprocess.run")
- @patch("morphic.converter.converter._ffmpeg_available", return_value=True)
- def test_output_dir(self, mock_ffmpeg, mock_run, tmp_path) -> None:
- src = tmp_path / "test.mp4"
- src.write_bytes(b"\x00" * 100)
- out_dir = tmp_path / "output"
-
- mock_run.return_value = MagicMock(returncode=0, stderr="")
-
- out_dir.mkdir()
- (out_dir / "test.avi").write_bytes(b"\x00" * 50)
-
- dest = convert_video(str(src), ".avi", output_dir=str(out_dir))
- assert str(out_dir) in dest
-
- @patch("morphic.converter.converter.subprocess.run")
- @patch("morphic.converter.converter._ffmpeg_available", return_value=True)
- def test_avoid_overwrite(self, mock_ffmpeg, mock_run, tmp_path) -> None:
- src = tmp_path / "test.mp4"
- src.write_bytes(b"\x00" * 100)
-
- existing = tmp_path / "test.avi"
- existing.write_bytes(b"\x00" * 50)
-
- mock_run.return_value = MagicMock(returncode=0, stderr="")
-
- dest = convert_video(str(src), ".avi")
- assert "converted" in os.path.basename(dest)
-
-
-class TestConvertFile:
- def test_image_dispatch(self, tmp_path) -> None:
- src = tmp_path / "test.jpg"
- Image.new("RGB", (50, 50), "red").save(str(src))
-
- dest = convert_file(str(src), ".png")
- assert dest.endswith(".png")
-
- @patch("morphic.converter.converter.convert_video")
- def test_video_dispatch(self, mock_convert, tmp_path) -> None:
- src = tmp_path / "test.mp4"
- src.write_bytes(b"\x00" * 100)
- mock_convert.return_value = str(tmp_path / "test.avi")
-
- _ = convert_file(str(src), ".avi")
- mock_convert.assert_called_once()
-
- def test_unsupported_type(self, tmp_path) -> None:
- src = tmp_path / "test.txt"
- src.write_text("hello")
-
- with pytest.raises(ValueError, match="Unsupported"):
- convert_file(str(src), ".jpg")
-
- def test_ext_normalization(self, tmp_path) -> None:
- src = tmp_path / "test.jpeg"
- Image.new("RGB", (50, 50), "red").save(str(src), format="JPEG")
-
- dest = convert_file(str(src), "png")
- assert dest.endswith(".png")
-
-
-class TestConvertHelperFunctions:
- def test_get_video_encoder_fallbacks(self, monkeypatch) -> None:
- monkeypatch.setattr(
- converter, "_is_torch_cuda_available", lambda: True
- )
- monkeypatch.setattr(converter, "_ffmpeg_has_hwaccel", lambda x: True)
- monkeypatch.setattr(
- converter, "_ffmpeg_has_encoder", lambda e: e == "h264_nvenc"
- )
-
- encoder, hw, out = converter._get_video_encoder(".mp4")
- assert encoder == "h264_nvenc"
- assert hw is True
- assert out == "mp4"
-
- monkeypatch.setattr(converter, "_ffmpeg_has_encoder", lambda e: False)
- encoder, hw, out = converter._get_video_encoder(".avi")
- assert encoder == "mpeg4"
- assert hw is False
-
- monkeypatch.setattr(
- converter,
- "_ffmpeg_has_encoder",
- lambda e: e in ("libsvtav1", "libaom-av1"),
- )
- encoder, hw, out = converter._get_video_encoder(".webm-av1")
- assert out == "webm"
- assert encoder in ("libsvtav1", "libaom-av1", "libvpx-vp9")
diff --git a/tests/converter/test_scanner.py b/tests/converter/test_scanner.py
deleted file mode 100644
index 7590ee2..0000000
--- a/tests/converter/test_scanner.py
+++ /dev/null
@@ -1,75 +0,0 @@
-"""Tests for morphic.converter.scanner."""
-
-from __future__ import annotations
-
-import pytest
-
-from morphic.converter.scanner import get_compatible_targets, scan_folder
-
-
-@pytest.mark.parametrize(
- "filter_type,expected_names",
- [
- (
- "images",
- {"photo.jpg", "image.png", "pic.tif", "deep.jpg"},
- ),
- ("videos", {"clip.mp4", "movie.mov"}),
- ],
-)
-def test_scan_folder_filter_types(
- tmp_media, filter_type, expected_names
-) -> None:
- result = scan_folder(str(tmp_media), filter_type=filter_type)
- names = {f["name"] for f in result["files"]}
- assert expected_names <= names
-
-
-def test_scan_folder_both_and_subfolder_control(tmp_media) -> None:
- both = scan_folder(str(tmp_media), filter_type="both")
- types = {f["type"] for f in both["files"]}
- assert "image" in types
- assert "video" in types
-
- without_sub = scan_folder(str(tmp_media), include_subfolders=False)
- names = {f["name"] for f in without_sub["files"]}
- assert "deep.jpg" not in names
-
- with_sub = scan_folder(str(tmp_media), include_subfolders=True)
- names = {f["name"] for f in with_sub["files"]}
- assert "deep.jpg" in names
-
-
-def test_scan_folder_summary_and_meta(tmp_media) -> None:
- result = scan_folder(str(tmp_media), filter_type="images")
- assert ".jpg" in result["summary"]
- assert result["summary"][".jpg"] >= 2
-
- full = scan_folder(str(tmp_media))
- assert full["folder"] == str(tmp_media)
- for f in full["files"]:
- assert all(
- k in f for k in ["path", "name", "ext", "size", "type", "targets"]
- )
- names = {f["name"] for f in full["files"]}
- assert "readme.txt" not in names
-
-
-@pytest.mark.parametrize(
- "filename,expected_substr",
- [
- ("photo.jpg", ".png"),
- ("clip.mp4", ".mov"),
- ("file.xyz", ""),
- ("photo.JPG", ""),
- ],
-)
-def test_compatible_targets(filename, expected_substr) -> None:
- targets = get_compatible_targets(filename)
- if expected_substr:
- assert expected_substr in targets
- else:
- if filename == "file.xyz":
- assert targets == []
- else:
- assert len(targets) > 0
diff --git a/tests/dupfinder/__init__.py b/tests/dupfinder/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/tests/dupfinder/test_accelerator.py b/tests/dupfinder/test_accelerator.py
deleted file mode 100644
index ae4ee42..0000000
--- a/tests/dupfinder/test_accelerator.py
+++ /dev/null
@@ -1,299 +0,0 @@
-"""Tests for morphic.dupfinder.accelerator."""
-
-from __future__ import annotations
-
-from typing import cast
-from unittest.mock import patch
-
-import numpy as np
-import pytest
-
-from morphic.dupfinder.accelerator import (
- AcceleratorType,
- GPUAccelerator,
- compute_phash_gpu,
- compute_similarity_matrix_gpu,
- get_accelerator,
-)
-
-
-class TestGPUAcceleratorProperties:
- def test_is_gpu_available_on_cpu(self) -> None:
- acc = GPUAccelerator()
- if acc.backend == AcceleratorType.CPU:
- assert acc.is_gpu_available is False
- else:
- assert acc.is_gpu_available is True
-
- def test_get_backend_name(self) -> None:
- acc = GPUAccelerator()
- name = acc.get_backend_name()
- assert isinstance(name, str)
- assert len(name) > 0
- expected = {
- "CUDA (NVIDIA GPU)",
- "ROCm (AMD GPU)",
- "OpenCL (GPU)",
- "CPU Multiprocessing",
- }
- assert name in expected
-
- def test_num_cpus(self) -> None:
- acc = GPUAccelerator()
- assert acc.num_cpus >= 1
-
-
-class TestResizeImageBatch:
- def test_empty_batch(self) -> None:
- acc = GPUAccelerator()
- result = acc.resize_image_batch([], (32, 32))
- assert result == []
-
- def test_single_image(self) -> None:
- acc = GPUAccelerator()
- img = np.random.randint(0, 255, (100, 80, 3), dtype=np.uint8)
- result = acc.resize_image_batch([img], (32, 32))
- assert len(result) == 1
- assert result[0].shape[0] == 32
- assert result[0].shape[1] == 32
-
- def test_multiple_images(self) -> None:
- acc = GPUAccelerator()
- imgs = [
- np.random.randint(0, 255, (100, 80, 3), dtype=np.uint8)
- for _ in range(3)
- ]
- result = acc.resize_image_batch(imgs, (64, 64))
- assert len(result) == 3
- for r in result:
- assert r.shape[:2] == (64, 64)
-
- def test_grayscale_image(self) -> None:
- acc = GPUAccelerator()
- img = np.random.randint(0, 255, (100, 80), dtype=np.uint8)
- result = acc.resize_image_batch([img], (32, 32))
- assert len(result) == 1
-
-
-class TestComputeDctBatch:
- def test_single_image(self) -> None:
- acc = GPUAccelerator()
- img = np.random.rand(32, 32).astype(np.float32)
- result = acc.compute_dct_batch([img])
- assert len(result) == 1
- assert result[0].shape == (32, 32)
-
- def test_multiple_images(self) -> None:
- acc = GPUAccelerator()
- imgs = [np.random.rand(16, 16).astype(np.float32) for _ in range(3)]
- result = acc.compute_dct_batch(imgs)
- assert len(result) == 3
-
-
-class TestComputeSimilarityMatrix:
- def test_empty_hashes(self) -> None:
- acc = GPUAccelerator()
- result = acc.compute_similarity_matrix([])
- assert result.size == 0
-
- def test_identical_hashes(self) -> None:
- acc = GPUAccelerator()
- h = np.array([1, 0, 1, 0, 1, 0, 1, 0], dtype=np.float32)
- result = acc.compute_similarity_matrix([h, h])
- assert result.shape == (2, 2)
- assert result[0, 1] == pytest.approx(1.0)
- assert result[1, 0] == pytest.approx(1.0)
-
- def test_different_hashes(self) -> None:
- acc = GPUAccelerator()
- h1 = np.array([1, 0, 1, 0, 1, 0, 1, 0], dtype=np.float32)
- h2 = np.array([0, 1, 0, 1, 0, 1, 0, 1], dtype=np.float32)
- result = acc.compute_similarity_matrix([h1, h2])
- assert result.shape == (2, 2)
- assert result[0, 1] < 0.5
-
-
-class TestBatchHammingDistance:
- def test_identical_hashes(self) -> None:
- acc = GPUAccelerator()
- hashes = ["abcdef01", "abcdef01"]
- result = acc.batch_hamming_distance(hashes, hashes)
- assert result.shape == (2, 2)
- assert result[0, 0] == pytest.approx(0.0)
- assert result[1, 1] == pytest.approx(0.0)
-
- def test_different_hashes(self) -> None:
- acc = GPUAccelerator()
- h1 = ["ff000000"]
- h2 = ["00ffffff"]
- result = acc.batch_hamming_distance(h1, h2)
- assert result.shape == (1, 1)
- assert result[0, 0] > 0
-
-
-class TestGetAccelerator:
- def test_returns_gpu_accelerator(self) -> None:
- acc = get_accelerator()
- assert isinstance(acc, GPUAccelerator)
-
- def test_returns_same_instance(self) -> None:
- a1 = get_accelerator()
- a2 = get_accelerator()
- assert a1 is a2
-
-
-class TestComputePhashGpu:
- def test_empty_list(self) -> None:
- result = compute_phash_gpu([])
- assert result == []
-
- def test_single_image(self) -> None:
- img = np.random.randint(0, 255, (100, 100, 3), dtype=np.uint8)
- result = compute_phash_gpu([img], hash_size=8)
- assert len(result) == 1
- assert isinstance(result[0], np.ndarray)
-
- def test_grayscale_image(self) -> None:
- img = np.random.randint(0, 255, (100, 100), dtype=np.uint8)
- result = compute_phash_gpu([img], hash_size=8)
- assert len(result) == 1
-
-
-class TestComputeSimilarityMatrixGpu:
- def test_empty(self) -> None:
- result = compute_similarity_matrix_gpu([])
- assert result.size == 0
-
- def test_hex_strings(self) -> None:
- hashes = ["abcdef0123456789", "abcdef0123456789"]
- result = compute_similarity_matrix_gpu(
- cast(list[str | np.ndarray], hashes), hash_size=4
- )
- assert result.shape == (2, 2)
- assert result[0, 1] == pytest.approx(1.0)
-
- def test_numpy_arrays(self) -> None:
- h1 = np.array([1, 0, 1, 0], dtype=np.uint8)
- h2 = np.array([1, 0, 1, 0], dtype=np.uint8)
- result = compute_similarity_matrix_gpu([h1, h2], hash_size=2)
- assert result.shape == (2, 2)
-
- def test_invalid_hex_string(self) -> None:
- """Non-hex strings should trigger ValueError and return zeros."""
- result = compute_similarity_matrix_gpu(["gg", "hh"], hash_size=2)
- assert result.shape == (2, 2)
-
- def test_all_invalid_hex(self) -> None:
- """All invalid hex strings should all map to zeros."""
- result = compute_similarity_matrix_gpu(["zz", "xx"], hash_size=2)
- assert result.shape == (2, 2)
-
-
-class TestAcceleratorCPUMethods:
- def test_resize_batch_cpu_grayscale(self) -> None:
- acc = GPUAccelerator()
- img = np.random.randint(0, 255, (100, 80), dtype=np.uint8)
- result = acc._resize_batch_cpu([img], (32, 32))
- assert len(result) == 1
-
- def test_dct_batch_cpu_multiple(self) -> None:
- acc = GPUAccelerator()
- imgs = [np.random.rand(16, 16).astype(np.float32) for _ in range(5)]
- result = acc._dct_batch_cpu(imgs)
- assert len(result) == 5
-
- def test_similarity_matrix_cpu(self) -> None:
- acc = GPUAccelerator()
- h1 = np.array([1, 0, 1, 0, 1, 0, 1, 0], dtype=np.float32)
- h2 = np.array([0, 1, 0, 1, 0, 1, 0, 1], dtype=np.float32)
- matrix = np.vstack([h1, h2])
- result = acc._similarity_matrix_cpu(matrix, 2)
- assert result.shape == (2, 2)
-
- def test_batch_hamming_cpu(self) -> None:
- acc = GPUAccelerator()
- arr1 = np.array([[1, 0, 1, 0]], dtype=np.float32)
- arr2 = np.array([[1, 0, 1, 0]], dtype=np.float32)
- result = acc._batch_hamming_cpu(arr1, arr2)
- assert result.shape == (1, 1)
- assert result[0, 0] == pytest.approx(0.0)
-
-
-class TestAcceleratorTorchPath:
- def test_try_cuda_no_torch(self) -> None:
- acc = GPUAccelerator()
- original_backend = acc.backend
- with patch.dict("sys.modules", {"torch": None}):
- result = acc._try_cuda()
- assert isinstance(result, bool)
- acc.backend = original_backend
-
- def test_try_rocm_no_torch(self) -> None:
- acc = GPUAccelerator()
- with patch.dict("sys.modules", {"torch": None}):
- result = acc._try_rocm()
- assert result is False
-
- def test_try_opencl_no_pyopencl(self) -> None:
- acc = GPUAccelerator()
- with patch.dict("sys.modules", {"pyopencl": None}):
- result = acc._try_opencl()
- assert result is False
-
- def test_setup_cpu(self) -> None:
- acc = GPUAccelerator()
- acc._setup_cpu()
- assert acc.backend == AcceleratorType.CPU
-
-
-class TestAcceleratorBranchSelection:
- def test_resize_routes_to_cpu_when_cpu_backend(self) -> None:
- acc = GPUAccelerator()
- original = acc.backend
- acc.backend = AcceleratorType.CPU
- acc._torch = None
- acc._cp = None
-
- img = np.random.randint(0, 255, (50, 50, 3), dtype=np.uint8)
- result = acc.resize_image_batch([img], (16, 16))
- assert len(result) == 1
-
- acc.backend = original
-
- def test_dct_routes_to_cpu_when_cpu_backend(self) -> None:
- acc = GPUAccelerator()
- original = acc.backend
- acc.backend = AcceleratorType.CPU
- acc._torch = None
- acc._cp = None
-
- img = np.random.rand(16, 16).astype(np.float32)
- result = acc.compute_dct_batch([img])
- assert len(result) == 1
-
- acc.backend = original
-
- def test_similarity_routes_to_cpu_when_cpu_backend(self) -> None:
- acc = GPUAccelerator()
- original = acc.backend
- acc.backend = AcceleratorType.CPU
- acc._torch = None
- acc._cp = None
-
- h = np.array([1, 0, 1, 0], dtype=np.float32)
- result = acc.compute_similarity_matrix([h, h])
- assert result.shape == (2, 2)
-
- acc.backend = original
-
- def test_hamming_routes_to_cpu_when_cpu_backend(self) -> None:
- acc = GPUAccelerator()
- original = acc.backend
- acc.backend = AcceleratorType.CPU
- acc._torch = None
- acc._cp = None
-
- result = acc.batch_hamming_distance(["abcd"], ["abcd"])
- assert result.shape == (1, 1)
-
- acc.backend = original
diff --git a/tests/dupfinder/test_images.py b/tests/dupfinder/test_images.py
deleted file mode 100644
index 594b7d8..0000000
--- a/tests/dupfinder/test_images.py
+++ /dev/null
@@ -1,401 +0,0 @@
-"""Tests for morphic.dupfinder.images."""
-
-from __future__ import annotations
-
-import os
-from unittest.mock import patch
-
-import numpy as np
-import pytest
-from PIL import Image
-
-from morphic.dupfinder.images import (
- ImageDuplicateFinder,
- ImageHasher,
- ImageInfo,
-)
-
-
-# ── ImageInfo ──────────────────────────────────────────────────────────────
-
-
-class TestImageInfoToDict:
- def test_to_dict_keys(self) -> None:
- info = ImageInfo(
- path="/img.jpg",
- width=100,
- height=200,
- file_size=999,
- format="JPEG",
- mode="RGB",
- phash="abc",
- ahash="def",
- dhash="ghi",
- )
- d = info.to_dict()
- assert d["path"] == "/img.jpg"
- assert d["width"] == 100
- assert d["height"] == 200
- assert d["file_size"] == 999
- assert d["format"] == "JPEG"
- assert d["mode"] == "RGB"
- assert d["phash"] == "abc"
- assert d["ahash"] == "def"
- assert d["dhash"] == "ghi"
-
- def test_to_dict_none_hashes(self) -> None:
- info = ImageInfo(path="/x.jpg")
- d = info.to_dict()
- assert d["phash"] is None
- assert d["ahash"] is None
- assert d["dhash"] is None
-
- def test_defaults(self) -> None:
- info = ImageInfo(path="/test.jpg")
- assert info.path == "/test.jpg"
- assert info.width == 0
- assert info.height == 0
- assert info.file_size == 0
- assert info.phash is None
-
- def test_custom_values(self) -> None:
- info = ImageInfo(
- path="/a.jpg",
- width=1920,
- height=1080,
- format="JPEG",
- file_size=5000,
- phash="abc123",
- )
- assert info.width == 1920
- assert info.format == "JPEG"
- assert info.phash == "abc123"
-
-
-# ── ImageHasher ────────────────────────────────────────────────────────────
-
-
-class TestImageHasher:
- def test_default_hash_size(self) -> None:
- hasher = ImageHasher()
- assert hasher.hash_size == 16
-
- def test_custom_hash_size(self) -> None:
- hasher = ImageHasher(hash_size=8)
- assert hasher.hash_size == 8
-
- def test_compute_hashes_valid_image(self, tmp_path) -> None:
- img_path = tmp_path / "test.jpg"
- Image.new("RGB", (100, 100), "red").save(str(img_path))
-
- hasher = ImageHasher(hash_size=8)
- info = hasher.compute_hashes(str(img_path))
-
- assert info.path == str(img_path)
- assert info.width == 100
- assert info.height == 100
- assert info.file_size > 0
- assert info.format == "JPEG"
- assert info.mode == "RGB"
- assert info.phash is not None
- assert info.ahash is not None
- assert info.dhash is not None
- assert info.whash is not None
-
- def test_compute_hashes_rgba_image(self, tmp_path) -> None:
- img_path = tmp_path / "rgba.png"
- Image.new("RGBA", (50, 50), (255, 0, 0, 128)).save(str(img_path))
-
- hasher = ImageHasher(hash_size=8)
- info = hasher.compute_hashes(str(img_path))
- assert info.phash is not None
-
- def test_compute_hashes_palette_image(self, tmp_path) -> None:
- img_path = tmp_path / "palette.png"
- Image.new("P", (50, 50)).save(str(img_path))
-
- hasher = ImageHasher(hash_size=8)
- info = hasher.compute_hashes(str(img_path))
- assert info.phash is not None
-
- def test_compute_hashes_grayscale(self, tmp_path) -> None:
- img_path = tmp_path / "gray.png"
- Image.new("L", (50, 50), 128).save(str(img_path))
-
- hasher = ImageHasher(hash_size=8)
- info = hasher.compute_hashes(str(img_path))
- assert info.phash is not None
- assert info.mode == "L"
-
- def test_compute_hashes_nonexistent(self) -> None:
- hasher = ImageHasher(hash_size=8)
- info = hasher.compute_hashes("/nonexistent/file.jpg")
- assert info.phash is None
- assert info.file_size == 0
-
- def test_compute_hashes_corrupt_file(self, tmp_path) -> None:
- img_path = tmp_path / "corrupt.jpg"
- img_path.write_bytes(b"not an image")
-
- hasher = ImageHasher(hash_size=8)
- info = hasher.compute_hashes(str(img_path))
- assert info.phash is None
-
- def test_cmyk_image(self, tmp_path) -> None:
- img_path = tmp_path / "cmyk.jpg"
- img = Image.new("CMYK", (50, 50), (0, 0, 0, 0))
- img.save(str(img_path))
-
- hasher = ImageHasher(hash_size=8)
- info = hasher.compute_hashes(str(img_path))
- assert info.phash is not None
-
-
-# ── ImageDuplicateFinder ───────────────────────────────────────────────────
-
-
-class TestImageDuplicateFinder:
- def test_init_defaults(self) -> None:
- finder = ImageDuplicateFinder(use_gpu=False)
- assert finder.similarity_threshold == 0.90
- assert finder.hash_type == "combined"
- assert finder.use_gpu is False
-
- def test_find_images(self, tmp_path) -> None:
- (tmp_path / "a.jpg").write_bytes(b"\xff\xd8\xff\xe0")
- Image.new("RGB", (10, 10), "red").save(str(tmp_path / "b.png"))
- (tmp_path / "c.txt").write_text("hello")
-
- finder = ImageDuplicateFinder(use_gpu=False)
- files = finder.find_images(str(tmp_path))
- exts = {os.path.splitext(f)[1].lower() for f in files}
- assert ".txt" not in exts
-
- def test_process_images(self, tmp_path) -> None:
- for name in ["a.jpg", "b.jpg", "c.jpg"]:
- Image.new("RGB", (50, 50), "red").save(str(tmp_path / name))
-
- finder = ImageDuplicateFinder(use_gpu=False, hash_size=8)
- files = [str(tmp_path / n) for n in ["a.jpg", "b.jpg", "c.jpg"]]
- infos = finder.process_images(files)
- assert len(infos) == 3
- for info in infos.values():
- assert info.phash is not None
-
- def test_compute_similarity_identical(self, tmp_path) -> None:
- img_path = tmp_path / "test.jpg"
- Image.new("RGB", (50, 50), "red").save(str(img_path))
-
- finder = ImageDuplicateFinder(use_gpu=False, hash_size=8)
- info = finder.hasher.compute_hashes(str(img_path))
- similarity = finder.compute_similarity(info, info)
- assert similarity == pytest.approx(1.0)
-
- def test_compute_similarity_different(self, tmp_path) -> None:
- img1_path = tmp_path / "red.jpg"
- img2_path = tmp_path / "noise.jpg"
- Image.new("RGB", (50, 50), "red").save(str(img1_path))
- noise_arr = np.random.randint(0, 255, (50, 50, 3), dtype=np.uint8)
- Image.fromarray(noise_arr).save(str(img2_path))
-
- finder = ImageDuplicateFinder(use_gpu=False, hash_size=8)
- info1 = finder.hasher.compute_hashes(str(img1_path))
- info2 = finder.hasher.compute_hashes(str(img2_path))
- similarity = finder.compute_similarity(info1, info2)
- assert 0.0 <= similarity <= 1.0
-
- def test_compute_similarity_no_hashes(self) -> None:
- finder = ImageDuplicateFinder(use_gpu=False, hash_size=8)
- info1 = ImageInfo(path="/a.jpg")
- info2 = ImageInfo(path="/b.jpg")
- assert finder.compute_similarity(info1, info2) == 0.0
-
- def test_compute_similarity_phash_only(self, tmp_path) -> None:
- img_path = tmp_path / "test.jpg"
- Image.new("RGB", (50, 50), "red").save(str(img_path))
-
- finder = ImageDuplicateFinder(
- use_gpu=False,
- hash_size=8,
- hash_type="phash",
- )
- info = finder.hasher.compute_hashes(str(img_path))
- similarity = finder.compute_similarity(info, info)
- assert similarity == pytest.approx(1.0)
-
- def test_compute_similarity_ahash_only(self, tmp_path) -> None:
- img_path = tmp_path / "test.jpg"
- Image.new("RGB", (50, 50), "red").save(str(img_path))
-
- finder = ImageDuplicateFinder(
- use_gpu=False,
- hash_size=8,
- hash_type="ahash",
- )
- info = finder.hasher.compute_hashes(str(img_path))
- similarity = finder.compute_similarity(info, info)
- assert similarity == pytest.approx(1.0)
-
- def test_compute_similarity_dhash_only(self, tmp_path) -> None:
- img_path = tmp_path / "test.jpg"
- Image.new("RGB", (50, 50), "red").save(str(img_path))
-
- finder = ImageDuplicateFinder(
- use_gpu=False,
- hash_size=8,
- hash_type="dhash",
- )
- info = finder.hasher.compute_hashes(str(img_path))
- similarity = finder.compute_similarity(info, info)
- assert similarity == pytest.approx(1.0)
-
- def test_compute_similarity_whash_only(self, tmp_path) -> None:
- img_path = tmp_path / "test.jpg"
- Image.new("RGB", (50, 50), "red").save(str(img_path))
-
- finder = ImageDuplicateFinder(
- use_gpu=False,
- hash_size=8,
- hash_type="whash",
- )
- info = finder.hasher.compute_hashes(str(img_path))
- similarity = finder.compute_similarity(info, info)
- assert similarity == pytest.approx(1.0)
-
- def test_find_duplicates_identical_images(self, tmp_path) -> None:
- for name in ["a.jpg", "b.jpg", "c.jpg"]:
- Image.new("RGB", (50, 50), "red").save(str(tmp_path / name))
-
- finder = ImageDuplicateFinder(
- use_gpu=False,
- hash_size=8,
- similarity_threshold=0.9,
- )
- files = [str(tmp_path / n) for n in ["a.jpg", "b.jpg", "c.jpg"]]
- finder.process_images(files)
- groups = finder.find_duplicates()
- assert len(groups) >= 1
-
- def test_find_duplicates_fast_identical(self, tmp_path) -> None:
- for name in ["a.jpg", "b.jpg"]:
- Image.new("RGB", (50, 50), "red").save(str(tmp_path / name))
-
- finder = ImageDuplicateFinder(
- use_gpu=False,
- hash_size=8,
- similarity_threshold=0.9,
- )
- files = [str(tmp_path / n) for n in ["a.jpg", "b.jpg"]]
- finder.process_images(files)
- groups = finder.find_duplicates_fast()
- assert len(groups) >= 1
-
- def test_find_duplicates_no_images(self) -> None:
- finder = ImageDuplicateFinder(use_gpu=False, hash_size=8)
- groups = finder.find_duplicates()
- assert groups == []
-
- def test_find_near_duplicates_empty(self) -> None:
- finder = ImageDuplicateFinder(use_gpu=False, hash_size=8)
- groups = finder._find_near_duplicates([])
- assert groups == []
-
- def test_find_duplicates_fast_with_near_dups(self, tmp_path) -> None:
- img1_path = tmp_path / "a.jpg"
- img2_path = tmp_path / "b.jpg"
-
- arr = np.ones((50, 50, 3), dtype=np.uint8) * 128
- Image.fromarray(arr).save(str(img1_path))
- arr[25, 25] = [255, 0, 0]
- Image.fromarray(arr).save(str(img2_path))
-
- finder = ImageDuplicateFinder(
- use_gpu=False,
- hash_size=8,
- similarity_threshold=0.8,
- )
- files = [str(img1_path), str(img2_path)]
- finder.process_images(files)
- groups = finder.find_duplicates_fast()
- assert isinstance(groups, list)
-
- @patch("morphic.dupfinder.images._compute_similarity_matrix_gpu")
- @patch("morphic.dupfinder.images._gpu_available", True)
- def test_find_near_duplicates_gpu(self, mock_sim, tmp_path) -> None:
- for name in ["a.jpg", "b.jpg", "c.jpg"]:
- Image.new("RGB", (50, 50), "red").save(str(tmp_path / name))
-
- finder = ImageDuplicateFinder(
- use_gpu=False,
- hash_size=8,
- similarity_threshold=0.9,
- )
- files = [str(tmp_path / n) for n in ["a.jpg", "b.jpg", "c.jpg"]]
- finder.process_images(files)
- finder.use_gpu = True
-
- n = len(finder.image_infos)
- sim_matrix = np.ones((n, n), dtype=np.float32)
- mock_sim.return_value = sim_matrix
-
- paths = list(finder.image_infos.keys())
- result = finder._find_near_duplicates_gpu(paths)
- assert isinstance(result, list)
-
- @patch("morphic.dupfinder.images._compute_similarity_matrix_gpu")
- @patch("morphic.dupfinder.images._gpu_available", True)
- def test_find_near_duplicates_gpu_fallback(
- self, mock_sim, tmp_path
- ) -> None:
- for name in ["a.jpg", "b.jpg"]:
- Image.new("RGB", (50, 50), "red").save(str(tmp_path / name))
-
- finder = ImageDuplicateFinder(
- use_gpu=False,
- hash_size=8,
- similarity_threshold=0.9,
- )
- files = [str(tmp_path / n) for n in ["a.jpg", "b.jpg"]]
- finder.process_images(files)
- finder.use_gpu = True
-
- mock_sim.side_effect = RuntimeError("GPU failed")
- paths = list(finder.image_infos.keys())
- result = finder._find_near_duplicates_gpu(paths)
- assert isinstance(result, list)
-
- def test_find_near_duplicates_gpu_empty(self) -> None:
- finder = ImageDuplicateFinder(use_gpu=False, hash_size=8)
- result = finder._find_near_duplicates_gpu([])
- assert result == []
-
- def test_find_duplicates_uses_fast_for_large(self, tmp_path) -> None:
- finder = ImageDuplicateFinder(use_gpu=False, hash_size=8)
- for i in range(105):
- path = f"/fake/img_{i}.jpg"
- finder.image_infos[path] = ImageInfo(
- path=path,
- phash=f"hash{i:04d}",
- ahash=f"ahash{i:04d}",
- dhash=f"dhash{i:04d}",
- )
-
- with patch.object(
- finder, "find_duplicates_fast", return_value=[]
- ) as mock:
- finder.find_duplicates()
- mock.assert_called_once()
-
- def test_find_duplicates_regular_path(self, tmp_path) -> None:
- for name in ["a.jpg", "b.jpg"]:
- Image.new("RGB", (50, 50), "red").save(str(tmp_path / name))
-
- finder = ImageDuplicateFinder(
- use_gpu=False,
- hash_size=8,
- similarity_threshold=0.9,
- )
- files = [str(tmp_path / n) for n in ["a.jpg", "b.jpg"]]
- finder.process_images(files)
- groups = finder.find_duplicates()
- assert isinstance(groups, list)
diff --git a/tests/dupfinder/test_scanner.py b/tests/dupfinder/test_scanner.py
deleted file mode 100644
index 3615fb9..0000000
--- a/tests/dupfinder/test_scanner.py
+++ /dev/null
@@ -1,382 +0,0 @@
-"""Tests for morphic.dupfinder.scanner."""
-
-from __future__ import annotations
-
-import time
-
-from PIL import Image
-
-from morphic.dupfinder.images import ImageInfo
-from morphic.dupfinder.scanner import (
- ScanJob,
- _calculate_space_savings,
- _format_image_groups,
- _format_video_groups,
- _run_scan,
- get_job,
- start_job,
-)
-from morphic.dupfinder.videos import VideoInfo
-
-
-# ── ScanJob ────────────────────────────────────────────────────────────────
-
-
-class TestScanJob:
- def test_defaults(self) -> None:
- job = ScanJob(id="test", folder="/tmp", scan_type="both")
- assert job.status == "pending"
- assert job.progress == 0.0
- assert job.image_groups == []
- assert job.video_groups == []
-
- def test_custom_thresholds(self) -> None:
- job = ScanJob(
- id="test",
- folder="/tmp",
- scan_type="images",
- image_threshold=0.95,
- video_threshold=0.80,
- )
- assert job.image_threshold == 0.95
- assert job.video_threshold == 0.80
-
-
-# ── get_job / start_job ────────────────────────────────────────────────────
-
-
-class TestGetJob:
- def test_nonexistent_job(self) -> None:
- result = get_job("nonexistent-id-xyz")
- assert result is None
-
-
-class TestStartJob:
- def test_returns_job_id(self, tmp_path) -> None:
- job_id = start_job(
- folder=str(tmp_path),
- scan_type="images",
- )
- assert isinstance(job_id, str)
- assert len(job_id) == 8
-
- def test_job_is_retrievable(self, tmp_path) -> None:
- job_id = start_job(
- folder=str(tmp_path),
- scan_type="images",
- )
- time.sleep(0.2)
- job = get_job(job_id)
- assert job is not None
- assert job.folder == str(tmp_path)
- assert job.scan_type == "images"
-
- def test_custom_thresholds(self, tmp_path) -> None:
- job_id = start_job(
- folder=str(tmp_path),
- scan_type="both",
- image_threshold=0.95,
- video_threshold=0.80,
- )
- job = get_job(job_id)
- assert job is not None
- assert job.image_threshold == 0.95
- assert job.video_threshold == 0.80
-
-
-# ── _run_scan ──────────────────────────────────────────────────────────────
-
-
-class TestRunScan:
- def test_scan_empty_folder(self, tmp_path) -> None:
- job = ScanJob(
- id="test-empty",
- folder=str(tmp_path),
- scan_type="images",
- )
- _run_scan(job)
- assert job.status == "done"
- assert job.progress == 1.0
- assert len(job.image_groups) == 0
- assert job.finished_at > 0
-
- def test_scan_videos_type(self, tmp_path) -> None:
- job = ScanJob(
- id="test-vid",
- folder=str(tmp_path),
- scan_type="videos",
- )
- _run_scan(job)
- assert job.status == "done"
-
- def test_scan_both_type(self, tmp_path) -> None:
- job = ScanJob(
- id="test-both",
- folder=str(tmp_path),
- scan_type="both",
- )
- _run_scan(job)
- assert job.status == "done"
- assert "Done!" in job.message
-
- def test_scan_nonexistent_folder(self) -> None:
- job = ScanJob(
- id="test-bad",
- folder="/nonexistent_folder_xyz",
- scan_type="images",
- )
- _run_scan(job)
- assert job.status in ("done", "error")
-
- def test_scan_folder_with_images(self, tmp_path) -> None:
- for name in ["a.jpg", "b.jpg", "c.jpg"]:
- Image.new("RGB", (50, 50), "red").save(str(tmp_path / name))
-
- job = ScanJob(
- id="test-imgs",
- folder=str(tmp_path),
- scan_type="images",
- )
- _run_scan(job)
-
- assert job.status == "done"
- assert job.total_files_found >= 3
- assert job.total_files_processed >= 3
- assert job.finished_at > job.started_at
- assert "Done!" in job.message
-
- def test_scan_folder_with_duplicates(self, tmp_path) -> None:
- for name in ["a.jpg", "b.jpg"]:
- Image.new("RGB", (50, 50), "red").save(str(tmp_path / name))
-
- job = ScanJob(
- id="test-dups",
- folder=str(tmp_path),
- scan_type="images",
- image_threshold=0.9,
- )
- _run_scan(job)
-
- assert job.status == "done"
- assert len(job.image_groups) >= 1
-
- def test_scan_both_image_and_video(self, tmp_path) -> None:
- Image.new("RGB", (50, 50), "red").save(str(tmp_path / "img.jpg"))
- (tmp_path / "vid.mp4").write_bytes(b"\x00" * 100)
-
- job = ScanJob(
- id="test-both",
- folder=str(tmp_path),
- scan_type="both",
- )
- _run_scan(job)
-
- assert job.status == "done"
- assert job.progress == 1.0
-
- def test_scan_progress_tracking(self, tmp_path) -> None:
- Image.new("RGB", (10, 10), "red").save(str(tmp_path / "a.jpg"))
-
- job = ScanJob(
- id="test-prog",
- folder=str(tmp_path),
- scan_type="images",
- )
- _run_scan(job)
-
- assert job.progress == 1.0
- assert job.total_files_found >= 1
- assert job.space_savings >= 0
-
- def test_scan_message_updates(self, tmp_path) -> None:
- for i in range(3):
- Image.new("RGB", (50, 50), "blue").save(
- str(tmp_path / f"img{i}.jpg"),
- )
-
- job = ScanJob(
- id="test-msg",
- folder=str(tmp_path),
- scan_type="images",
- )
- _run_scan(job)
-
- assert "Done!" in job.message or "Error" in job.message
-
-
-# ── _format_image_groups ───────────────────────────────────────────────────
-
-
-class TestFormatImageGroups:
- def test_empty_groups(self) -> None:
- result = _format_image_groups([], {})
- assert result == []
-
- def test_formats_group(self) -> None:
- infos = {
- "/a.jpg": ImageInfo(
- path="/a.jpg",
- width=1920,
- height=1080,
- format="JPEG",
- file_size=100000,
- ),
- "/b.jpg": ImageInfo(
- path="/b.jpg",
- width=1920,
- height=1080,
- format="JPEG",
- file_size=50000,
- ),
- }
- groups = [[("/a.jpg", 1.0), ("/b.jpg", 0.95)]]
- result = _format_image_groups(groups, infos)
- assert len(result) == 1
- assert len(result[0]) == 2
- assert result[0][0]["path"] == "/a.jpg"
- assert result[0][0]["type"] == "image"
- assert result[0][1]["similarity"] == 95.0
-
- def test_single_item_group_filtered(self) -> None:
- infos = {
- "/a.jpg": ImageInfo(
- path="/a.jpg",
- width=100,
- height=100,
- format="JPEG",
- file_size=1000,
- ),
- }
- groups = [[("/a.jpg", 1.0)]]
- result = _format_image_groups(groups, infos)
- assert result == []
-
- def test_missing_info_skipped(self) -> None:
- infos = {
- "/a.jpg": ImageInfo(
- path="/a.jpg",
- width=100,
- height=100,
- format="JPEG",
- file_size=1000,
- ),
- }
- groups = [[("/a.jpg", 1.0), ("/b.jpg", 0.95)]]
- result = _format_image_groups(groups, infos)
- assert result == []
-
- def test_three_items(self) -> None:
- infos = {
- f"/{n}.jpg": ImageInfo(
- path=f"/{n}.jpg",
- width=100,
- height=100,
- format="JPEG",
- file_size=i * 1000,
- )
- for i, n in enumerate(["a", "b", "c"], 1)
- }
- groups = [[("/a.jpg", 1.0), ("/b.jpg", 0.95), ("/c.jpg", 0.92)]]
- result = _format_image_groups(groups, infos)
- assert len(result) == 1
- assert len(result[0]) == 3
-
-
-# ── _format_video_groups ───────────────────────────────────────────────────
-
-
-class TestFormatVideoGroups:
- def test_empty_groups(self) -> None:
- result = _format_video_groups([], {})
- assert result == []
-
- def test_formats_group(self) -> None:
- infos = {
- "/a.mp4": VideoInfo(
- path="/a.mp4",
- width=1920,
- height=1080,
- duration=120.0,
- fps=30.0,
- file_size=5000000,
- ),
- "/b.mp4": VideoInfo(
- path="/b.mp4",
- width=1920,
- height=1080,
- duration=120.0,
- fps=30.0,
- file_size=3000000,
- ),
- }
- groups = [[("/a.mp4", 1.0), ("/b.mp4", 0.88)]]
- result = _format_video_groups(groups, infos)
- assert len(result) == 1
- assert len(result[0]) == 2
- assert result[0][0]["path"] == "/a.mp4"
- assert result[0][0]["type"] == "video"
- assert "duration_formatted" in result[0][0]
-
- def test_missing_info_skipped(self) -> None:
- infos = {
- "/a.mp4": VideoInfo(
- path="/a.mp4",
- width=1920,
- height=1080,
- duration=60.0,
- fps=30.0,
- file_size=5000000,
- ),
- }
- groups = [[("/a.mp4", 1.0), ("/b.mp4", 0.88)]]
- result = _format_video_groups(groups, infos)
- assert result == []
-
-
-# ── _calculate_space_savings ───────────────────────────────────────────────
-
-
-class TestCalculateSpaceSavings:
- def test_no_groups(self) -> None:
- job = ScanJob(id="x", folder="/tmp", scan_type="both")
- assert _calculate_space_savings(job) == 0
-
- def test_with_groups(self) -> None:
- job = ScanJob(id="x", folder="/tmp", scan_type="both")
- job.image_groups = [
- [
- {"file_size": 100000, "path": "/a.jpg"},
- {"file_size": 50000, "path": "/b.jpg"},
- ]
- ]
- savings = _calculate_space_savings(job)
- assert savings == 50000
-
- def test_multiple_groups(self) -> None:
- job = ScanJob(id="x", folder="/tmp", scan_type="both")
- job.image_groups = [
- [
- {"file_size": 10000, "path": "/a.jpg"},
- {"file_size": 5000, "path": "/b.jpg"},
- ],
- ]
- job.video_groups = [
- [
- {"file_size": 20000, "path": "/a.mp4"},
- {"file_size": 15000, "path": "/b.mp4"},
- ],
- ]
- savings = _calculate_space_savings(job)
- assert savings == 5000 + 15000
-
- def test_three_files_in_group(self) -> None:
- job = ScanJob(id="x", folder="/tmp", scan_type="both")
- job.image_groups = [
- [
- {"file_size": 10000, "path": "/a.jpg"},
- {"file_size": 8000, "path": "/b.jpg"},
- {"file_size": 5000, "path": "/c.jpg"},
- ],
- ]
- savings = _calculate_space_savings(job)
- assert savings == 13000
diff --git a/tests/dupfinder/test_videos.py b/tests/dupfinder/test_videos.py
deleted file mode 100644
index c2ac8eb..0000000
--- a/tests/dupfinder/test_videos.py
+++ /dev/null
@@ -1,553 +0,0 @@
-"""Tests for morphic.dupfinder.videos."""
-
-from __future__ import annotations
-
-import os
-from contextlib import contextmanager
-from unittest.mock import MagicMock, patch
-
-import numpy as np
-import pytest
-
-from morphic.dupfinder.videos import (
- VideoDuplicateFinder,
- VideoHasher,
- VideoInfo,
-)
-
-
-# ── VideoInfo ──────────────────────────────────────────────────────────────
-
-
-class TestVideoInfoToDict:
- def test_to_dict_keys(self) -> None:
- info = VideoInfo(
- path="/v.mp4",
- duration=120.5,
- fps=30.0,
- frame_count=3615,
- width=1920,
- height=1080,
- file_size=50000000,
- average_hash="abc123",
- )
- d = info.to_dict()
- assert d["path"] == "/v.mp4"
- assert d["duration"] == 120.5
- assert d["fps"] == 30.0
- assert d["frame_count"] == 3615
- assert d["width"] == 1920
- assert d["height"] == 1080
- assert d["file_size"] == 50000000
- assert d["average_hash"] == "abc123"
-
- def test_to_dict_defaults(self) -> None:
- info = VideoInfo(path="/x.avi")
- d = info.to_dict()
- assert d["duration"] == 0.0
- assert d["average_hash"] is None
-
- def test_frame_hashes_default_empty(self) -> None:
- info = VideoInfo(path="/v.mp4")
- assert info.frame_hashes == []
-
- def test_defaults(self) -> None:
- info = VideoInfo(path="/test.mp4")
- assert info.path == "/test.mp4"
- assert info.width == 0
- assert info.height == 0
- assert info.duration == 0.0
- assert info.fps == 0.0
-
- def test_custom_values(self) -> None:
- info = VideoInfo(
- path="/v.mp4",
- width=3840,
- height=2160,
- duration=120.5,
- fps=60.0,
- file_size=10000000,
- )
- assert info.duration == 120.5
- assert info.fps == 60.0
-
-
-# ── VideoHasher ────────────────────────────────────────────────────────────
-
-
-class TestVideoHasher:
- def test_default_params(self) -> None:
- hasher = VideoHasher()
- assert hasher.num_frames == 10
- assert hasher.hash_size == 16
-
- def test_custom_params(self) -> None:
- hasher = VideoHasher(num_frames=5, hash_size=8)
- assert hasher.num_frames == 5
- assert hasher.hash_size == 8
-
- def test_compute_frame_hash(self) -> None:
- hasher = VideoHasher(hash_size=8)
- frame = np.random.randint(0, 255, (100, 100, 3), dtype=np.uint8)
- result = hasher.compute_frame_hash(frame)
- assert isinstance(result, str)
- assert len(result) > 0
-
- def test_compute_frame_hash_invalid(self) -> None:
- hasher = VideoHasher(hash_size=8)
- result = hasher.compute_frame_hash(np.array([]))
- assert result == "" or isinstance(result, str)
-
- def test_extract_frames_nonexistent(self) -> None:
- hasher = VideoHasher(hash_size=8)
- frames, info = hasher.extract_frames("/nonexistent/video.mp4")
- assert frames == []
- assert info.path == "/nonexistent/video.mp4"
-
- def test_compute_video_hashes_nonexistent(self) -> None:
- hasher = VideoHasher(hash_size=8)
- info = hasher.compute_video_hashes("/nonexistent/video.mp4")
- assert info.frame_hashes == []
- assert info.average_hash is None
-
- def test_compute_video_hashes_with_mock_frames(self) -> None:
- hasher = VideoHasher(num_frames=3, hash_size=8)
- frames = [
- np.random.randint(0, 255, (64, 64, 3), dtype=np.uint8)
- for _ in range(3)
- ]
- hashes = [hasher.compute_frame_hash(f) for f in frames]
- assert all(isinstance(h, str) and len(h) > 0 for h in hashes)
-
- def test_manual_frame_hash_building(self) -> None:
- hasher = VideoHasher(num_frames=3, hash_size=8)
- frames = [
- np.random.randint(0, 255, (64, 64, 3), dtype=np.uint8)
- for _ in range(3)
- ]
- info = VideoInfo(path="/test.mp4", file_size=1000)
- for frame in frames:
- h = hasher.compute_frame_hash(frame)
- if h:
- info.frame_hashes.append(h)
- assert len(info.frame_hashes) == 3
-
-
-# ── VideoDuplicateFinder ───────────────────────────────────────────────────
-
-
-class TestVideoDuplicateFinder:
- def test_init_defaults(self) -> None:
- finder = VideoDuplicateFinder(use_gpu=False)
- assert finder.similarity_threshold == 0.85
- assert finder.use_gpu is False
-
- def test_find_videos(self, tmp_path) -> None:
- (tmp_path / "a.mp4").write_bytes(b"\x00" * 100)
- (tmp_path / "b.avi").write_bytes(b"\x00" * 100)
- (tmp_path / "c.txt").write_text("hello")
-
- finder = VideoDuplicateFinder(use_gpu=False)
- files = finder.find_videos(str(tmp_path))
- exts = {os.path.splitext(f)[1].lower() for f in files}
- assert ".txt" not in exts
-
- def test_find_videos_empty_folder(self, tmp_path) -> None:
- finder = VideoDuplicateFinder(use_gpu=False)
- result = finder.find_videos(str(tmp_path))
- assert result == []
-
- def test_compute_similarity_no_hashes(self) -> None:
- finder = VideoDuplicateFinder(use_gpu=False)
- info1 = VideoInfo(path="/a.mp4")
- info2 = VideoInfo(path="/b.mp4")
- assert finder.compute_similarity(info1, info2) == 0.0
-
- def test_compute_similarity_identical_hashes(self) -> None:
- finder = VideoDuplicateFinder(use_gpu=False, hash_size=8)
- hasher = VideoHasher(hash_size=8)
-
- frame = np.zeros((100, 100, 3), dtype=np.uint8)
- h = hasher.compute_frame_hash(frame)
-
- info1 = VideoInfo(path="/a.mp4", frame_hashes=[h, h])
- info2 = VideoInfo(path="/b.mp4", frame_hashes=[h, h])
- similarity = finder.compute_similarity(info1, info2)
- assert similarity == pytest.approx(1.0)
-
- def test_compute_similarity_different_frames(self) -> None:
- hasher = VideoHasher(hash_size=8)
- finder = VideoDuplicateFinder(use_gpu=False, hash_size=8)
-
- frame1 = np.zeros((64, 64, 3), dtype=np.uint8)
- frame2 = np.ones((64, 64, 3), dtype=np.uint8) * 255
- h1 = hasher.compute_frame_hash(frame1)
- h2 = hasher.compute_frame_hash(frame2)
-
- info1 = VideoInfo(path="/a.mp4", frame_hashes=[h1])
- info2 = VideoInfo(path="/b.mp4", frame_hashes=[h2])
-
- sim = finder.compute_similarity(info1, info2)
- assert 0.0 <= sim <= 1.0
-
- def test_find_duplicates_empty(self) -> None:
- finder = VideoDuplicateFinder(use_gpu=False)
- groups = finder.find_duplicates()
- assert groups == []
-
- def test_find_duplicates_cpu_with_infos(self) -> None:
- finder = VideoDuplicateFinder(use_gpu=False, hash_size=8)
- hasher = VideoHasher(hash_size=8)
-
- frame = np.zeros((100, 100, 3), dtype=np.uint8)
- h = hasher.compute_frame_hash(frame)
-
- finder.video_infos = {
- "/a.mp4": VideoInfo(
- path="/a.mp4",
- frame_hashes=[h],
- file_size=1000,
- ),
- "/b.mp4": VideoInfo(
- path="/b.mp4",
- frame_hashes=[h],
- file_size=1000,
- ),
- }
- groups = finder.find_duplicates()
- assert len(groups) >= 1
-
- def test_find_duplicates_cpu(self) -> None:
- hasher = VideoHasher(hash_size=8)
- finder = VideoDuplicateFinder(
- use_gpu=False,
- hash_size=8,
- similarity_threshold=0.9,
- )
-
- frame = np.zeros((64, 64, 3), dtype=np.uint8)
- h = hasher.compute_frame_hash(frame)
-
- finder.video_infos = {
- "/a.mp4": VideoInfo(
- path="/a.mp4",
- frame_hashes=[h],
- file_size=1000,
- ),
- "/b.mp4": VideoInfo(
- path="/b.mp4",
- frame_hashes=[h],
- file_size=1000,
- ),
- "/c.mp4": VideoInfo(
- path="/c.mp4",
- frame_hashes=[h],
- file_size=1000,
- ),
- }
-
- groups = finder._find_duplicates_cpu(list(finder.video_infos.keys()))
- assert len(groups) >= 1
-
- def test_find_duplicates_cpu_no_match(self) -> None:
- hasher = VideoHasher(hash_size=8)
- finder = VideoDuplicateFinder(
- use_gpu=False,
- hash_size=8,
- similarity_threshold=0.99,
- )
-
- frames = [
- np.random.randint(0, 255, (64, 64, 3), dtype=np.uint8)
- for _ in range(3)
- ]
-
- for i, frame in enumerate(frames):
- h = hasher.compute_frame_hash(frame)
- finder.video_infos[f"/v{i}.mp4"] = VideoInfo(
- path=f"/v{i}.mp4",
- frame_hashes=[h],
- file_size=1000,
- )
-
- groups = finder._find_duplicates_cpu(list(finder.video_infos.keys()))
- assert isinstance(groups, list)
-
- def test_process_videos_empty(self) -> None:
- finder = VideoDuplicateFinder(use_gpu=False)
- result = finder.process_videos([])
- assert result == {}
-
- @patch("morphic.dupfinder.videos._compute_similarity_matrix_gpu")
- @patch("morphic.dupfinder.videos._gpu_available", True)
- def test_find_duplicates_gpu_path(self, mock_sim) -> None:
- hasher = VideoHasher(hash_size=8)
- finder = VideoDuplicateFinder(use_gpu=False, hash_size=8)
-
- frame = np.zeros((64, 64, 3), dtype=np.uint8)
- h = hasher.compute_frame_hash(frame)
-
- finder.video_infos = {
- "/a.mp4": VideoInfo(path="/a.mp4", frame_hashes=[h]),
- "/b.mp4": VideoInfo(path="/b.mp4", frame_hashes=[h]),
- }
-
- finder.use_gpu = True
- sim_matrix = np.ones((2, 2), dtype=np.float32)
- mock_sim.return_value = sim_matrix
-
- result = finder._find_duplicates_gpu(list(finder.video_infos.keys()))
- assert isinstance(result, list)
-
- @patch("morphic.dupfinder.videos._compute_similarity_matrix_gpu")
- @patch("morphic.dupfinder.videos._gpu_available", True)
- def test_find_duplicates_gpu_fallback(self, mock_sim) -> None:
- hasher = VideoHasher(hash_size=8)
- finder = VideoDuplicateFinder(use_gpu=False, hash_size=8)
-
- frame = np.zeros((64, 64, 3), dtype=np.uint8)
- h = hasher.compute_frame_hash(frame)
-
- finder.video_infos = {
- "/a.mp4": VideoInfo(path="/a.mp4", frame_hashes=[h]),
- "/b.mp4": VideoInfo(path="/b.mp4", frame_hashes=[h]),
- }
- finder.use_gpu = True
-
- mock_sim.side_effect = RuntimeError("GPU failed")
- result = finder._find_duplicates_gpu(list(finder.video_infos.keys()))
- assert isinstance(result, list)
-
-
-# ── Video extraction (cv2) ─────────────────────────────────────────────────
-
-
-@contextmanager
-def _noop_ctx():
- yield
-
-
-_PATCH_GETSIZE = patch(
- "morphic.dupfinder.videos.os.path.getsize", return_value=1024
-)
-_PATCH_SUPPRESS = patch(
- "morphic.dupfinder.videos.suppress_stderr",
- side_effect=_noop_ctx,
-)
-
-
-class TestVideoExtraction:
- @_PATCH_SUPPRESS
- @_PATCH_GETSIZE
- @patch("morphic.dupfinder.videos.cv2.cvtColor")
- @patch("morphic.dupfinder.videos.cv2.VideoCapture")
- def test_extract_frames_success(
- self,
- mock_vc_cls,
- mock_cvt,
- _mock_gs,
- _mock_ss,
- ) -> None:
- mock_cap = MagicMock()
- mock_vc_cls.return_value = mock_cap
- mock_cap.isOpened.return_value = True
-
- def get_side_effect(prop):
- mapping = {5: 30.0, 7: 300, 3: 640, 4: 480}
- return mapping.get(prop, 0)
-
- mock_cap.get.side_effect = get_side_effect
-
- frame = np.zeros((480, 640, 3), dtype=np.uint8)
- mock_cap.read.return_value = (True, frame)
- mock_cvt.return_value = frame
-
- hasher = VideoHasher(num_frames=3, hash_size=8)
- frames, info = hasher.extract_frames("/test/video.mp4")
-
- assert info.fps == 30.0
- assert info.frame_count == 300
- assert info.width == 640
- assert info.height == 480
- assert len(frames) > 0
- mock_cap.release.assert_called_once()
-
- @_PATCH_SUPPRESS
- @_PATCH_GETSIZE
- @patch("morphic.dupfinder.videos.cv2.VideoCapture")
- def test_extract_frames_not_opened(
- self,
- mock_vc_cls,
- _mock_gs,
- _mock_ss,
- ) -> None:
- mock_cap = MagicMock()
- mock_vc_cls.return_value = mock_cap
- mock_cap.isOpened.return_value = False
-
- hasher = VideoHasher(hash_size=8)
- frames, info = hasher.extract_frames("/test/video.mp4")
- assert frames == []
-
- @_PATCH_SUPPRESS
- @_PATCH_GETSIZE
- @patch("morphic.dupfinder.videos.cv2.VideoCapture")
- def test_extract_frames_zero_frame_count(
- self,
- mock_vc_cls,
- _mock_gs,
- _mock_ss,
- ) -> None:
- mock_cap = MagicMock()
- mock_vc_cls.return_value = mock_cap
- mock_cap.isOpened.return_value = True
-
- def get_side_effect(prop):
- mapping = {5: 30.0, 7: 0, 3: 640, 4: 480}
- return mapping.get(prop, 0)
-
- mock_cap.get.side_effect = get_side_effect
-
- hasher = VideoHasher(hash_size=8)
- frames, info = hasher.extract_frames("/test/short.mp4")
- assert frames == []
- mock_cap.release.assert_called_once()
-
- @_PATCH_SUPPRESS
- @_PATCH_GETSIZE
- @patch("morphic.dupfinder.videos.cv2.cvtColor")
- @patch("morphic.dupfinder.videos.cv2.VideoCapture")
- def test_extract_frames_read_fails(
- self,
- mock_vc_cls,
- mock_cvt,
- _mock_gs,
- _mock_ss,
- ) -> None:
- mock_cap = MagicMock()
- mock_vc_cls.return_value = mock_cap
- mock_cap.isOpened.return_value = True
-
- def get_side_effect(prop):
- mapping = {5: 30.0, 7: 100, 3: 320, 4: 240}
- return mapping.get(prop, 0)
-
- mock_cap.get.side_effect = get_side_effect
- mock_cap.read.return_value = (False, None)
-
- hasher = VideoHasher(num_frames=3, hash_size=8)
- frames, info = hasher.extract_frames("/test/video.mp4")
- assert frames == []
-
-
-class TestComputeVideoHashes:
- @_PATCH_SUPPRESS
- @_PATCH_GETSIZE
- @patch("morphic.dupfinder.videos.cv2.cvtColor")
- @patch("morphic.dupfinder.videos.cv2.VideoCapture")
- def test_full_pipeline(
- self,
- mock_vc_cls,
- mock_cvt,
- _mock_gs,
- _mock_ss,
- ) -> None:
- mock_cap = MagicMock()
- mock_vc_cls.return_value = mock_cap
- mock_cap.isOpened.return_value = True
-
- def get_side_effect(prop):
- return {5: 30.0, 7: 300, 3: 64, 4: 64}.get(prop, 0)
-
- mock_cap.get.side_effect = get_side_effect
-
- frame = np.random.randint(0, 255, (64, 64, 3), dtype=np.uint8)
- mock_cap.read.return_value = (True, frame)
- mock_cvt.return_value = frame
-
- hasher = VideoHasher(num_frames=3, hash_size=8)
- info = hasher.compute_video_hashes("/test/video.mp4")
-
- assert len(info.frame_hashes) > 0
- assert info.average_hash is not None
-
- @_PATCH_SUPPRESS
- @_PATCH_GETSIZE
- @patch("morphic.dupfinder.videos.cv2.VideoCapture")
- def test_no_frames_extracted(
- self,
- mock_vc_cls,
- _mock_gs,
- _mock_ss,
- ) -> None:
- mock_cap = MagicMock()
- mock_vc_cls.return_value = mock_cap
- mock_cap.isOpened.return_value = False
-
- hasher = VideoHasher(hash_size=8)
- info = hasher.compute_video_hashes("/test/bad_video.mp4")
- assert info.frame_hashes == []
- assert info.average_hash is None
-
-
-class TestVideoProcessing:
- @_PATCH_SUPPRESS
- @_PATCH_GETSIZE
- @patch("morphic.dupfinder.videos.cv2.cvtColor")
- @patch("morphic.dupfinder.videos.cv2.VideoCapture")
- def test_process_videos_with_mocked_cv2(
- self,
- mock_vc_cls,
- mock_cvt,
- _mock_gs,
- _mock_ss,
- ) -> None:
- mock_cap = MagicMock()
- mock_vc_cls.return_value = mock_cap
- mock_cap.isOpened.return_value = True
-
- def get_side_effect(prop):
- return {5: 30.0, 7: 100, 3: 64, 4: 64}.get(prop, 0)
-
- mock_cap.get.side_effect = get_side_effect
-
- frame = np.random.randint(0, 255, (64, 64, 3), dtype=np.uint8)
- mock_cap.read.return_value = (True, frame)
- mock_cvt.return_value = frame
-
- finder = VideoDuplicateFinder(
- use_gpu=False,
- hash_size=8,
- num_workers=1,
- )
- result = finder.process_videos(["/test/a.mp4", "/test/b.mp4"])
- assert len(result) >= 0
-
- @_PATCH_SUPPRESS
- @_PATCH_GETSIZE
- @patch("morphic.dupfinder.videos.cv2.cvtColor")
- @patch("morphic.dupfinder.videos.cv2.VideoCapture")
- def test_duration_calculation(
- self,
- mock_vc_cls,
- mock_cvt,
- _mock_gs,
- _mock_ss,
- ) -> None:
- mock_cap = MagicMock()
- mock_vc_cls.return_value = mock_cap
- mock_cap.isOpened.return_value = True
-
- def get_side_effect(prop):
- return {5: 25.0, 7: 250, 3: 320, 4: 240}.get(prop, 0)
-
- mock_cap.get.side_effect = get_side_effect
-
- frame = np.zeros((240, 320, 3), dtype=np.uint8)
- mock_cap.read.return_value = (True, frame)
- mock_cvt.return_value = frame
-
- hasher = VideoHasher(num_frames=2, hash_size=8)
- frames, info = hasher.extract_frames("/test/video.avi")
-
- assert info.duration == pytest.approx(10.0)
diff --git a/tests/frontend/__init__.py b/tests/frontend/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/tests/frontend/test_app.py b/tests/frontend/test_app.py
deleted file mode 100644
index 9616b82..0000000
--- a/tests/frontend/test_app.py
+++ /dev/null
@@ -1,1023 +0,0 @@
-"""Tests for morphic.frontend — app factory and routes."""
-
-from __future__ import annotations
-
-import importlib
-import json
-import os
-import time
-
-from PIL import Image
-
-from morphic.frontend.app import create_app
-
-
-# ── App factory ────────────────────────────────────────────────────────────
-
-
-class TestCreateApp:
- def test_creates_flask_app(self) -> None:
- app = create_app()
- assert app is not None
- assert app.name == "morphic.frontend.app"
-
- def test_initial_folder_config(self) -> None:
- app = create_app(initial_folder="/test/path")
- assert app.config["INITIAL_FOLDER"] == "/test/path"
-
- def test_no_initial_folder(self) -> None:
- app = create_app()
- assert app.config["INITIAL_FOLDER"] == ""
-
-
-# ── __main__.py ────────────────────────────────────────────────────────────
-
-
-class TestMain:
- def test_main_module_exists(self) -> None:
- spec = importlib.util.find_spec("morphic.frontend.__main__")
- assert spec is not None
-
-
-# ── Index ──────────────────────────────────────────────────────────────────
-
-
-class TestIndexRoute:
- def test_returns_html(self, client) -> None:
- resp = client.get("/")
- assert resp.status_code == 200
- assert b"Morphic" in resp.data
-
- def test_has_tabs(self, client) -> None:
- resp = client.get("/")
- assert b"Converter" in resp.data
- assert b"Dupfinder" in resp.data
- assert b"Inspector" in resp.data
- assert b"Resizer" in resp.data
- assert b"Organizer" in resp.data
-
- def test_no_cache_headers(self, client) -> None:
- resp = client.get("/")
- assert "no-cache" in resp.headers.get("Cache-Control", "")
-
-
-# ── Browse ─────────────────────────────────────────────────────────────────
-
-
-class TestBrowseRoute:
- def test_browse_home(self, client) -> None:
- resp = client.get("/api/browse")
- assert resp.status_code == 200
- data = resp.get_json()
- assert "current" in data
- assert "entries" in data
-
- def test_browse_specific_dir(self, client, tmp_path) -> None:
- sub = tmp_path / "testdir"
- sub.mkdir()
- resp = client.get(f"/api/browse?path={tmp_path}")
- assert resp.status_code == 200
- data = resp.get_json()
- assert data["current"] == str(tmp_path)
- names = [e["name"] for e in data["entries"]]
- assert "testdir" in names
-
- def test_browse_invalid_dir(self, client) -> None:
- resp = client.get("/api/browse?path=/nonexistent_xyz_path")
- assert resp.status_code == 400
-
- def test_browse_parent(self, client, tmp_path) -> None:
- resp = client.get(f"/api/browse?path={tmp_path}")
- data = resp.get_json()
- assert data["parent"] is not None or data["parent"] is None
-
- def test_native_browse_returns_json(self, client) -> None:
- resp = client.post(
- "/api/browse/native",
- data=json.dumps({}),
- content_type="application/json",
- )
- assert resp.status_code == 200
- data = resp.get_json()
- assert "folder" in data
-
- def test_system_info(self, client) -> None:
- resp = client.get("/api/system_info")
- assert resp.status_code == 200
- data = resp.get_json()
- assert "ffmpeg" in data
-
- def test_browse_hidden_dirs_excluded(self, client, tmp_path) -> None:
- hidden = tmp_path / ".hidden"
- hidden.mkdir()
- visible = tmp_path / "visible"
- visible.mkdir()
-
- resp = client.get(f"/api/browse?path={tmp_path}")
- data = resp.get_json()
- names = [e["name"] for e in data["entries"]]
- assert "visible" in names
- assert ".hidden" not in names
-
- def test_browse_permission_error(self, client) -> None:
- resp = client.get("/api/browse?path=/root")
- assert resp.status_code in (200, 400, 500)
-
- def test_browse_tilde_expansion(self, client) -> None:
- resp = client.get("/api/browse?path=~")
- assert resp.status_code == 200
- data = resp.get_json()
- assert "current" in data
-
-
-# ── Thumbnail ──────────────────────────────────────────────────────────────
-
-
-class TestThumbnailRoute:
- def test_nonexistent_file(self, client) -> None:
- resp = client.get("/api/thumbnail?path=/nonexistent/file.jpg")
- assert resp.status_code == 404
-
- def test_no_path_param(self, client) -> None:
- resp = client.get("/api/thumbnail")
- assert resp.status_code == 404
-
- def test_valid_image(self, client, test_image) -> None:
- resp = client.get(f"/api/thumbnail?path={test_image}")
- assert resp.status_code == 200
- assert resp.content_type == "image/jpeg"
-
- def test_forbidden_extension(self, client, tmp_path) -> None:
- txt = tmp_path / "test.txt"
- txt.write_text("hello")
- resp = client.get(f"/api/thumbnail?path={txt}")
- assert resp.status_code in (403, 500)
-
- def test_rgba_image_thumbnail(self, client, tmp_path) -> None:
- img_path = tmp_path / "rgba.png"
- Image.new("RGBA", (100, 100), (255, 0, 0, 128)).save(str(img_path))
-
- resp = client.get(f"/api/thumbnail?path={img_path}")
- assert resp.status_code == 200
- assert resp.content_type == "image/jpeg"
-
- def test_palette_image_thumbnail(self, client, tmp_path) -> None:
- img_path = tmp_path / "palette.gif"
- Image.new("P", (100, 100)).save(str(img_path))
-
- resp = client.get(f"/api/thumbnail?path={img_path}")
- assert resp.status_code == 200
-
- def test_thumbnail_video_file(self, client, tmp_path) -> None:
- vid = tmp_path / "test.mp4"
- vid.write_bytes(b"\x00" * 100)
-
- resp = client.get(f"/api/thumbnail?path={vid}")
- assert resp.status_code in (200, 404, 500)
-
-
-# ── Media ──────────────────────────────────────────────────────────────────
-
-
-class TestMediaRoute:
- def test_nonexistent_file(self, client) -> None:
- resp = client.get("/api/media?path=/nonexistent/file.jpg")
- assert resp.status_code == 404
-
- def test_valid_image(self, client, test_image) -> None:
- resp = client.get(f"/api/media?path={test_image}")
- assert resp.status_code == 200
-
- def test_forbidden_extension(self, client, tmp_path) -> None:
- txt = tmp_path / "test.txt"
- txt.write_text("hello")
- resp = client.get(f"/api/media?path={txt}")
- assert resp.status_code == 403
-
- def test_media_no_path(self, client) -> None:
- resp = client.get("/api/media")
- assert resp.status_code == 404
-
- def test_media_empty_path(self, client) -> None:
- resp = client.get("/api/media?path=")
- assert resp.status_code == 404
-
- def test_media_video_file(self, client, tmp_path) -> None:
- vid = tmp_path / "test.mp4"
- vid.write_bytes(b"\x00" * 100)
-
- resp = client.get(f"/api/media?path={vid}")
- assert resp.status_code == 200
-
-
-class TestInspectorRoute:
- def test_inspector_scan(self, client, tmp_path) -> None:
- resp = client.post("/api/inspector/scan", json={})
- assert resp.status_code == 400
-
- resp = client.post(
- "/api/inspector/scan",
- json={"folder": str(tmp_path), "mode": "exif"},
- )
- assert resp.status_code == 202
- job_id = resp.get_json()["job_id"]
-
- status = client.get(f"/api/inspector/scan/{job_id}/status")
- assert status.status_code == 200
-
- results = client.get(f"/api/inspector/scan/{job_id}/results")
- assert results.status_code in (200, 409)
-
- def test_exif_edit_strip(self, client, tmp_path) -> None:
- resp = client.post("/api/inspector/exif/edit", json={})
- assert resp.status_code == 400
-
- resp = client.post("/api/inspector/exif/strip", json={})
- assert resp.status_code == 400
-
-
-class TestOrganizerRoute:
- def test_organizer_plan_invalid(self, client, tmp_path) -> None:
- resp = client.post("/api/organizer/plan", json={})
- assert resp.status_code == 400
-
- resp = client.post(
- "/api/organizer/plan",
- json={
- "folder": str(tmp_path),
- "mode": "sort",
- "operation": "copy",
- },
- )
- assert resp.status_code == 202
-
- def test_organizer_status_not_found(self, client) -> None:
- resp = client.get("/api/organizer/status/notfound")
- assert resp.status_code == 404
-
-
-class TestResizerRoute:
- def test_resizer_scan_invalid(self, client, tmp_path) -> None:
- resp = client.post("/api/resizer/scan", json={})
- assert resp.status_code == 400
-
- resp = client.post(
- "/api/resizer/scan",
- json={"folder": str(tmp_path), "width": 100, "height": 100},
- )
- assert resp.status_code == 202
-
- def test_resizer_status_results(self, client) -> None:
- resp = client.get("/api/resizer/scan/notfound/status")
- assert resp.status_code == 404
-
- resp = client.get("/api/resizer/scan/notfound/results")
- assert resp.status_code == 404
-
-
-# ── Converter — scan ───────────────────────────────────────────────────────
-
-
-class TestConverterScanRoute:
- def test_missing_folder(self, client) -> None:
- resp = client.post(
- "/api/converter/scan",
- data=json.dumps({"folder": ""}),
- content_type="application/json",
- )
- assert resp.status_code == 400
-
- def test_invalid_folder(self, client) -> None:
- resp = client.post(
- "/api/converter/scan",
- data=json.dumps({"folder": "/nonexistent_xyz"}),
- content_type="application/json",
- )
- assert resp.status_code == 400
-
- def test_valid_scan(self, client, tmp_media) -> None:
- resp = client.post(
- "/api/converter/scan",
- data=json.dumps({"folder": str(tmp_media)}),
- content_type="application/json",
- )
- assert resp.status_code == 200
- data = resp.get_json()
- assert data["total"] > 0
-
- def test_no_body(self, client) -> None:
- resp = client.post("/api/converter/scan")
- assert resp.status_code in (400, 415)
-
- def test_scan_images_only(self, client, tmp_media) -> None:
- resp = client.post(
- "/api/converter/scan",
- data=json.dumps(
- {
- "folder": str(tmp_media),
- "filter_type": "images",
- }
- ),
- content_type="application/json",
- )
- assert resp.status_code == 200
- data = resp.get_json()
- for f in data.get("files", []):
- ext = os.path.splitext(f["name"])[1].lower()
- assert ext not in {".mp4", ".mov", ".avi"}
-
- def test_scan_videos_only(self, client, tmp_media) -> None:
- resp = client.post(
- "/api/converter/scan",
- data=json.dumps(
- {
- "folder": str(tmp_media),
- "filter_type": "videos",
- }
- ),
- content_type="application/json",
- )
- assert resp.status_code == 200
-
- def test_scan_no_subfolders(self, client, tmp_media) -> None:
- resp = client.post(
- "/api/converter/scan",
- data=json.dumps(
- {
- "folder": str(tmp_media),
- "include_subfolders": False,
- }
- ),
- content_type="application/json",
- )
- assert resp.status_code == 200
- data = resp.get_json()
- for f in data.get("files", []):
- assert "/sub/" not in f["path"]
-
- def test_scan_invalid_filter_type(self, client, tmp_media) -> None:
- resp = client.post(
- "/api/converter/scan",
- data=json.dumps(
- {
- "folder": str(tmp_media),
- "filter_type": "invalid",
- }
- ),
- content_type="application/json",
- )
- assert resp.status_code == 200
-
-
-# ── Converter — formats ────────────────────────────────────────────────────
-
-
-class TestConverterFormatsRoute:
- def test_returns_json(self, client) -> None:
- resp = client.get("/api/converter/formats")
- assert resp.status_code == 200
- data = resp.get_json()
- assert "image" in data
- assert "video" in data
-
- def test_formats_structure(self, client) -> None:
- resp = client.get("/api/converter/formats")
- data = resp.get_json()
- assert isinstance(data["image"], dict)
- assert isinstance(data["video"], dict)
- for targets in data["image"].values():
- assert isinstance(targets, list)
-
-
-# ── Converter — convert ────────────────────────────────────────────────────
-
-
-class TestConverterConvertRoute:
- def test_no_files(self, client) -> None:
- resp = client.post(
- "/api/converter/convert",
- data=json.dumps({"files": [], "target_ext": ".png"}),
- content_type="application/json",
- )
- assert resp.status_code == 400
-
- def test_no_body(self, client) -> None:
- resp = client.post("/api/converter/convert")
- assert resp.status_code in (400, 415)
-
- def test_convert_single_file(self, client, test_image) -> None:
- resp = client.post(
- "/api/converter/convert",
- data=json.dumps(
- {
- "files": [test_image],
- "target_ext": ".png",
- }
- ),
- content_type="application/json",
- )
- assert resp.status_code == 202
- data = resp.get_json()
- assert "job_id" in data
-
- def test_convert_progress(self, client, test_image) -> None:
- resp = client.post(
- "/api/converter/convert",
- data=json.dumps(
- {
- "files": [test_image],
- "target_ext": ".png",
- }
- ),
- content_type="application/json",
- )
- job_id = resp.get_json()["job_id"]
-
- time.sleep(0.5)
- resp = client.get(f"/api/converter/progress/{job_id}")
- assert resp.status_code == 200
- data = resp.get_json()
- assert "status" in data
- assert "completed" in data
-
- def test_missing_target_ext(self, client, test_image) -> None:
- resp = client.post(
- "/api/converter/convert",
- data=json.dumps(
- {
- "files": [test_image],
- "target_ext": "",
- }
- ),
- content_type="application/json",
- )
- assert resp.status_code == 400
-
- def test_convert_with_delete(self, client, tmp_path) -> None:
- src = tmp_path / "test.jpg"
- Image.new("RGB", (50, 50), "red").save(str(src))
-
- resp = client.post(
- "/api/converter/convert",
- data=json.dumps(
- {
- "files": [str(src)],
- "target_ext": ".png",
- "delete_original": True,
- }
- ),
- content_type="application/json",
- )
- assert resp.status_code == 202
-
- job_id = resp.get_json()["job_id"]
- time.sleep(1)
-
- resp = client.get(f"/api/converter/progress/{job_id}")
- data = resp.get_json()
- assert data["status"] == "done"
-
- def test_progress_poll(self, client, test_image) -> None:
- resp = client.post(
- "/api/converter/convert",
- data=json.dumps(
- {
- "files": [test_image],
- "target_ext": ".png",
- }
- ),
- content_type="application/json",
- )
- job_id = resp.get_json()["job_id"]
- time.sleep(0.5)
-
- resp = client.get(
- f"/api/converter/progress/{job_id}/poll?last=0",
- )
- assert resp.status_code == 200
-
- def test_progress_poll_nonexistent(self, client) -> None:
- resp = client.get("/api/converter/progress/nonexistent/poll")
- assert resp.status_code == 404
-
- def test_convert_nonexistent_source(self, client) -> None:
- resp = client.post(
- "/api/converter/convert",
- data=json.dumps(
- {
- "files": ["/nonexistent/file.jpg"],
- "target_ext": ".png",
- }
- ),
- content_type="application/json",
- )
- assert resp.status_code == 202
- job_id = resp.get_json()["job_id"]
-
- time.sleep(1)
- resp = client.get(f"/api/converter/progress/{job_id}")
- data = resp.get_json()
- assert data["status"] == "done"
- assert data["results"][0]["status"] == "error"
-
- def test_convert_multiple_files(self, client, tmp_path) -> None:
- files = []
- for i in range(3):
- src = tmp_path / f"img{i}.jpg"
- Image.new("RGB", (50, 50), "red").save(str(src))
- files.append(str(src))
-
- resp = client.post(
- "/api/converter/convert",
- data=json.dumps(
- {
- "files": files,
- "target_ext": ".png",
- }
- ),
- content_type="application/json",
- )
- assert resp.status_code == 202
- job_id = resp.get_json()["job_id"]
-
- for _ in range(20):
- time.sleep(0.3)
- resp = client.get(f"/api/converter/progress/{job_id}")
- data = resp.get_json()
- if data["status"] == "done":
- break
-
- assert data["completed"] == 3
- assert all(r["status"] == "ok" for r in data["results"])
-
- def test_convert_with_size_info(self, client, tmp_path) -> None:
- src = tmp_path / "test.jpg"
- Image.new("RGB", (100, 100), "blue").save(str(src))
-
- resp = client.post(
- "/api/converter/convert",
- data=json.dumps(
- {
- "files": [str(src)],
- "target_ext": ".png",
- }
- ),
- content_type="application/json",
- )
- job_id = resp.get_json()["job_id"]
-
- time.sleep(1)
- resp = client.get(f"/api/converter/progress/{job_id}")
- data = resp.get_json()
- result = data["results"][0]
- assert "original_size" in result
- assert "new_size" in result
- assert "original_size_fmt" in result
- assert "new_size_fmt" in result
-
-
-# ── Converter — delete ─────────────────────────────────────────────────────
-
-
-class TestConverterDeleteRoute:
- def test_no_files(self, client) -> None:
- resp = client.post(
- "/api/converter/delete",
- data=json.dumps({"files": []}),
- content_type="application/json",
- )
- assert resp.status_code == 400
-
- def test_delete_nonexistent(self, client) -> None:
- resp = client.post(
- "/api/converter/delete",
- data=json.dumps({"files": ["/nonexistent/file.jpg"]}),
- content_type="application/json",
- )
- assert resp.status_code == 200
- data = resp.get_json()
- assert data["results"][0]["status"] == "not_found"
-
- def test_delete_real_file(self, client, tmp_path) -> None:
- f = tmp_path / "deleteme.jpg"
- Image.new("RGB", (10, 10), "red").save(str(f))
- assert f.exists()
-
- resp = client.post(
- "/api/converter/delete",
- data=json.dumps({"files": [str(f)]}),
- content_type="application/json",
- )
- assert resp.status_code == 200
- data = resp.get_json()
- assert data["results"][0]["status"] == "deleted"
- assert not f.exists()
-
- def test_delete_no_body(self, client) -> None:
- resp = client.post("/api/converter/delete")
- assert resp.status_code in (400, 415)
-
- def test_delete_without_files_key(self, client) -> None:
- resp = client.post(
- "/api/converter/delete",
- data=json.dumps({}),
- content_type="application/json",
- )
- assert resp.status_code == 400
-
- def test_delete_mixed_results(self, client, tmp_path) -> None:
- real = tmp_path / "real.jpg"
- Image.new("RGB", (10, 10), "red").save(str(real))
-
- resp = client.post(
- "/api/converter/delete",
- data=json.dumps(
- {
- "files": [str(real), "/nonexistent/file.jpg"],
- }
- ),
- content_type="application/json",
- )
- assert resp.status_code == 200
- data = resp.get_json()
- assert data["results"][0]["status"] == "deleted"
- assert data["results"][1]["status"] == "not_found"
- assert data["total_freed"] > 0
-
-
-# ── Converter — progress ───────────────────────────────────────────────────
-
-
-class TestConverterProgressRoute:
- def test_nonexistent_job(self, client) -> None:
- resp = client.get("/api/converter/progress/nonexistent")
- assert resp.status_code == 404
-
-
-# ── Dupfinder — scan ───────────────────────────────────────────────────────
-
-
-class TestDupfinderScanRoute:
- def test_missing_folder(self, client) -> None:
- resp = client.post(
- "/api/dupfinder/scan",
- data=json.dumps({"folder": ""}),
- content_type="application/json",
- )
- assert resp.status_code == 400
-
- def test_invalid_scan_type(self, client, tmp_path) -> None:
- resp = client.post(
- "/api/dupfinder/scan",
- data=json.dumps(
- {
- "folder": str(tmp_path),
- "type": "invalid",
- }
- ),
- content_type="application/json",
- )
- assert resp.status_code == 400
-
- def test_no_body(self, client) -> None:
- resp = client.post("/api/dupfinder/scan")
- assert resp.status_code in (400, 415)
-
- def test_valid_scan_start(self, client, tmp_path) -> None:
- resp = client.post(
- "/api/dupfinder/scan",
- data=json.dumps(
- {
- "folder": str(tmp_path),
- "type": "images",
- }
- ),
- content_type="application/json",
- )
- assert resp.status_code == 202
- data = resp.get_json()
- assert "job_id" in data
-
- def test_scan_with_thresholds(self, client, tmp_path) -> None:
- resp = client.post(
- "/api/dupfinder/scan",
- data=json.dumps(
- {
- "folder": str(tmp_path),
- "type": "both",
- "image_threshold": 0.95,
- "video_threshold": 0.80,
- }
- ),
- content_type="application/json",
- )
- assert resp.status_code == 202
-
- def test_scan_status_after_start(self, client, tmp_path) -> None:
- resp = client.post(
- "/api/dupfinder/scan",
- data=json.dumps(
- {
- "folder": str(tmp_path),
- "type": "images",
- }
- ),
- content_type="application/json",
- )
- job_id = resp.get_json()["job_id"]
-
- resp = client.get(f"/api/dupfinder/scan/{job_id}/status")
- assert resp.status_code == 200
- data = resp.get_json()
- assert "status" in data
- assert "progress" in data
-
- def test_scan_results_not_done(self, client, tmp_path) -> None:
- resp = client.post(
- "/api/dupfinder/scan",
- data=json.dumps(
- {
- "folder": str(tmp_path),
- "type": "images",
- }
- ),
- content_type="application/json",
- )
- job_id = resp.get_json()["job_id"]
-
- resp = client.get(f"/api/dupfinder/scan/{job_id}/results")
- assert resp.status_code in (200, 409)
-
- def test_scan_results_after_completion(self, client, tmp_path) -> None:
- resp = client.post(
- "/api/dupfinder/scan",
- data=json.dumps(
- {
- "folder": str(tmp_path),
- "type": "images",
- }
- ),
- content_type="application/json",
- )
- job_id = resp.get_json()["job_id"]
-
- for _ in range(20):
- time.sleep(0.5)
- resp = client.get(f"/api/dupfinder/scan/{job_id}/status")
- data = resp.get_json()
- if data["status"] in ("done", "error"):
- break
-
- resp = client.get(f"/api/dupfinder/scan/{job_id}/results")
- assert resp.status_code == 200
- data = resp.get_json()
- assert "image_groups" in data
- assert "space_savings" in data
-
- def test_scan_videos_only(self, client, tmp_path) -> None:
- resp = client.post(
- "/api/dupfinder/scan",
- data=json.dumps(
- {
- "folder": str(tmp_path),
- "type": "videos",
- }
- ),
- content_type="application/json",
- )
- assert resp.status_code == 202
-
- def test_scan_both_types(self, client, tmp_path) -> None:
- resp = client.post(
- "/api/dupfinder/scan",
- data=json.dumps(
- {
- "folder": str(tmp_path),
- "type": "both",
- }
- ),
- content_type="application/json",
- )
- assert resp.status_code == 202
-
-
-# ── Dupfinder — status / results ───────────────────────────────────────────
-
-
-class TestDupfinderStatusRoute:
- def test_nonexistent_job(self, client) -> None:
- resp = client.get("/api/dupfinder/scan/nonexistent/status")
- assert resp.status_code == 404
-
-
-class TestDupfinderResultsRoute:
- def test_nonexistent_job(self, client) -> None:
- resp = client.get("/api/dupfinder/scan/nonexistent/results")
- assert resp.status_code == 404
-
-
-# ── Dupfinder — delete ─────────────────────────────────────────────────────
-
-
-class TestDupfinderDeleteRoute:
- def test_no_files(self, client) -> None:
- resp = client.post(
- "/api/dupfinder/delete",
- data=json.dumps({"files": []}),
- content_type="application/json",
- )
- assert resp.status_code == 400
-
- def test_delete_nonexistent(self, client) -> None:
- resp = client.post(
- "/api/dupfinder/delete",
- data=json.dumps({"files": ["/nonexistent/file.jpg"]}),
- content_type="application/json",
- )
- assert resp.status_code == 200
- data = resp.get_json()
- assert data["results"][0]["status"] == "not_found"
-
- def test_no_body(self, client) -> None:
- resp = client.post("/api/dupfinder/delete")
- assert resp.status_code in (400, 415)
-
- def test_delete_without_files_key(self, client) -> None:
- resp = client.post(
- "/api/dupfinder/delete",
- data=json.dumps({}),
- content_type="application/json",
- )
- assert resp.status_code == 400
-
- def test_delete_real_file(self, client, tmp_path) -> None:
- f = tmp_path / "dup.jpg"
- Image.new("RGB", (10, 10), "red").save(str(f))
-
- resp = client.post(
- "/api/dupfinder/delete",
- data=json.dumps({"files": [str(f)]}),
- content_type="application/json",
- )
- assert resp.status_code == 200
- data = resp.get_json()
- assert data["results"][0]["status"] == "deleted"
- assert not f.exists()
-
- def test_delete_mixed(self, client, tmp_path) -> None:
- f = tmp_path / "real.jpg"
- Image.new("RGB", (10, 10), "red").save(str(f))
-
- resp = client.post(
- "/api/dupfinder/delete",
- data=json.dumps(
- {
- "files": [str(f), "/nonexistent.jpg"],
- }
- ),
- content_type="application/json",
- )
- assert resp.status_code == 200
- data = resp.get_json()
- assert len(data["results"]) == 2
-
-
-# ── Inspector Routes ──────────────────────────────────────────────────────
-
-
-class TestInspectorRoutes:
- def test_scan_requires_folder(self, client) -> None:
- resp = client.post(
- "/api/inspector/scan",
- data=json.dumps({"mode": "exif"}),
- content_type="application/json",
- )
- assert resp.status_code == 400
- data = resp.get_json()
- assert "error" in data
-
- def test_scan_exif(self, client, tmp_path) -> None:
- Image.new("RGB", (10, 10)).save(str(tmp_path / "a.jpg"), "JPEG")
- resp = client.post(
- "/api/inspector/scan",
- data=json.dumps({"folder": str(tmp_path), "mode": "exif"}),
- content_type="application/json",
- )
- assert resp.status_code == 202
- data = resp.get_json()
- assert "job_id" in data
-
- def test_scan_integrity(self, client, tmp_path) -> None:
- Image.new("RGB", (10, 10)).save(str(tmp_path / "a.jpg"), "JPEG")
- resp = client.post(
- "/api/inspector/scan",
- data=json.dumps({"folder": str(tmp_path), "mode": "integrity"}),
- content_type="application/json",
- )
- assert resp.status_code == 202
- data = resp.get_json()
- assert "job_id" in data
-
- def test_status_unknown_job(self, client) -> None:
- resp = client.get("/api/inspector/scan/fakeid/status")
- assert resp.status_code == 404
- data = resp.get_json()
- assert "error" in data
-
- def test_strip_requires_files(self, client) -> None:
- resp = client.post(
- "/api/inspector/exif/strip",
- data=json.dumps({}),
- content_type="application/json",
- )
- assert resp.status_code == 400
- data = resp.get_json()
- assert "error" in data
-
-
-# ── Resizer Routes ────────────────────────────────────────────────────────
-
-
-class TestResizerRoutes:
- def test_scan_requires_folder(self, client) -> None:
- resp = client.post(
- "/api/resizer/scan",
- data=json.dumps({"width": 100, "height": 100}),
- content_type="application/json",
- )
- assert resp.status_code == 400
- data = resp.get_json()
- assert "error" in data
-
- def test_scan_starts_job(self, client, tmp_path) -> None:
- Image.new("RGB", (100, 100)).save(str(tmp_path / "img.png"))
- resp = client.post(
- "/api/resizer/scan",
- data=json.dumps(
- {
- "folder": str(tmp_path),
- "width": 50,
- "height": 50,
- "mode": "fit",
- }
- ),
- content_type="application/json",
- )
- assert resp.status_code == 202
- data = resp.get_json()
- assert "job_id" in data
-
- def test_status_unknown_job(self, client) -> None:
- resp = client.get("/api/resizer/scan/fakeid/status")
- assert resp.status_code == 404
- data = resp.get_json()
- assert "error" in data
-
-
-# ── Organizer Routes ──────────────────────────────────────────────────────
-
-
-class TestOrganizerRoutes:
- def test_plan_requires_folder(self, client) -> None:
- resp = client.post(
- "/api/organizer/plan",
- data=json.dumps({"mode": "sort"}),
- content_type="application/json",
- )
- assert resp.status_code == 400
- data = resp.get_json()
- assert "error" in data
-
- def test_plan_sort(self, client, tmp_path) -> None:
- Image.new("RGB", (10, 10)).save(str(tmp_path / "photo.jpg"), "JPEG")
- resp = client.post(
- "/api/organizer/plan",
- data=json.dumps(
- {
- "folder": str(tmp_path),
- "mode": "sort",
- "operation": "copy",
- "template": "{year}/{month}",
- }
- ),
- content_type="application/json",
- )
- assert resp.status_code == 202
- data = resp.get_json()
- assert "job_id" in data
-
- def test_execute_requires_job_id(self, client) -> None:
- resp = client.post(
- "/api/organizer/execute",
- data=json.dumps({}),
- content_type="application/json",
- )
- assert resp.status_code == 400
- data = resp.get_json()
- assert "error" in data
-
- def test_status_unknown_job(self, client) -> None:
- resp = client.get("/api/organizer/status/fakeid")
- assert resp.status_code == 404
- data = resp.get_json()
- assert "error" in data
diff --git a/tests/inspector/__init__.py b/tests/inspector/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/tests/inspector/test_exif.py b/tests/inspector/test_exif.py
deleted file mode 100644
index f9d36c4..0000000
--- a/tests/inspector/test_exif.py
+++ /dev/null
@@ -1,88 +0,0 @@
-"""Tests for morphic.inspector.exif."""
-
-from __future__ import annotations
-
-
-import pytest
-from PIL import Image
-
-from morphic.inspector.exif import (
- edit_exif,
- read_exif,
- strip_exif,
- strip_exif_batch,
-)
-
-
-def _make_jpeg(path: str, size: tuple[int, int] = (50, 50)) -> str:
- """Create a minimal JPEG file."""
- img = Image.new("RGB", size, "red")
- img.save(path, "JPEG")
- return path
-
-
-class TestReadExif:
- def test_returns_dict(self, tmp_path) -> None:
- path = _make_jpeg(str(tmp_path / "photo.jpg"))
- result = read_exif(path)
- assert isinstance(result, dict)
-
- def test_nonexistent_file_raises(self, tmp_path) -> None:
- with pytest.raises((FileNotFoundError, Exception)):
- read_exif(str(tmp_path / "nope.jpg"))
-
- def test_png_returns_empty_or_dict(self, tmp_path) -> None:
- """PNG files may have no EXIF — should not crash."""
- p = tmp_path / "test.png"
- Image.new("RGB", (10, 10), "blue").save(str(p))
- result = read_exif(str(p))
- assert isinstance(result, dict)
-
-
-class TestEditExif:
- def test_edit_roundtrip(self, tmp_path) -> None:
- path = _make_jpeg(str(tmp_path / "edit.jpg"))
- # Write some EXIF first so piexif can work with it
- edit_exif(path, {"ImageDescription": "hello world"})
- data = read_exif(path)
- assert data.get("ImageDescription") == "hello world"
-
- def test_edit_nonexistent_key_is_ignored(self, tmp_path) -> None:
- path = _make_jpeg(str(tmp_path / "edit2.jpg"))
- # Unknown key should be silently ignored
- edit_exif(path, {"TotallyFakeTag12345": "value"})
-
-
-class TestStripExif:
- def test_strip_removes_data(self, tmp_path) -> None:
- path = _make_jpeg(str(tmp_path / "strip.jpg"))
- edit_exif(path, {"ImageDescription": "to be removed"})
- strip_exif(path)
- data = read_exif(path)
- assert data.get("ImageDescription") in (None, "")
-
- def test_strip_preserves_image(self, tmp_path) -> None:
- path = _make_jpeg(str(tmp_path / "strip2.jpg"))
- strip_exif(path)
- img = Image.open(path)
- assert img.size == (50, 50)
-
-
-class TestStripExifBatch:
- def test_batch_returns_dict(self, tmp_path) -> None:
- paths = [_make_jpeg(str(tmp_path / f"img{i}.jpg")) for i in range(3)]
- results = strip_exif_batch(paths)
- assert isinstance(results, dict)
- assert len(results) == 3
- for path, info in results.items():
- assert "success" in info
- assert info["success"] is True
-
- def test_batch_with_bad_file(self, tmp_path) -> None:
- good = _make_jpeg(str(tmp_path / "good.jpg"))
- bad = str(tmp_path / "nonexistent.jpg")
- results = strip_exif_batch([good, bad])
- assert len(results) == 2
- assert results[good]["success"] is True
- assert results[bad]["success"] is False
- assert "error" in results[bad]
diff --git a/tests/inspector/test_integrity.py b/tests/inspector/test_integrity.py
deleted file mode 100644
index e6c91d0..0000000
--- a/tests/inspector/test_integrity.py
+++ /dev/null
@@ -1,71 +0,0 @@
-"""Tests for morphic.inspector.integrity."""
-
-from __future__ import annotations
-
-
-from PIL import Image
-
-from morphic.inspector.integrity import check_files, check_image, check_video
-
-
-def _make_jpeg(path: str) -> str:
- Image.new("RGB", (20, 20), "green").save(path, "JPEG")
- return path
-
-
-class TestCheckImage:
- def test_valid_image_ok(self, tmp_path) -> None:
- path = _make_jpeg(str(tmp_path / "ok.jpg"))
- result = check_image(path)
- assert result["valid"] is True
- assert result["path"] == path
-
- def test_truncated_image(self, tmp_path) -> None:
- path = str(tmp_path / "bad.jpg")
- _make_jpeg(path)
- # Truncate the file
- with open(path, "r+b") as f:
- f.truncate(10)
- result = check_image(path)
- assert result["valid"] is False
- assert result["error"] is not None
-
- def test_zero_byte(self, tmp_path) -> None:
- path = str(tmp_path / "empty.jpg")
- open(path, "w").close()
- result = check_image(path)
- assert result["valid"] is False
-
- def test_nonexistent(self, tmp_path) -> None:
- result = check_image(str(tmp_path / "nope.jpg"))
- assert result["valid"] is False
-
-
-class TestCheckVideo:
- def test_fake_video_fails(self, tmp_path) -> None:
- path = str(tmp_path / "fake.mp4")
- with open(path, "wb") as f:
- f.write(b"\x00" * 100)
- result = check_video(path)
- # Fake video should fail ffprobe (or return valid=False if no ffprobe)
- assert isinstance(result["valid"], bool)
- assert "path" in result
-
-
-class TestCheckFiles:
- def test_scans_folder(self, tmp_path) -> None:
- _make_jpeg(str(tmp_path / "a.jpg"))
- _make_jpeg(str(tmp_path / "b.png"))
- # Non-media file should be ignored
- (tmp_path / "readme.txt").write_text("hello")
-
- results = check_files(str(tmp_path))
- # Should have at least the 2 images
- image_results = [
- r for r in results if r["path"].endswith((".jpg", ".png"))
- ]
- assert len(image_results) >= 2
-
- def test_empty_folder(self, tmp_path) -> None:
- results = check_files(str(tmp_path))
- assert results == []
diff --git a/tests/inspector/test_scanner.py b/tests/inspector/test_scanner.py
deleted file mode 100644
index c7382a1..0000000
--- a/tests/inspector/test_scanner.py
+++ /dev/null
@@ -1,58 +0,0 @@
-"""Tests for morphic.inspector.scanner."""
-
-from __future__ import annotations
-
-from PIL import Image
-
-from morphic.inspector.scanner import get_job, start_job
-
-
-def _make_jpeg(path, size=(20, 20)):
- Image.new("RGB", size, "red").save(str(path), "JPEG")
-
-
-class TestInspectorScanner:
- def test_start_exif_scan(self, tmp_path) -> None:
- _make_jpeg(tmp_path / "a.jpg")
- _make_jpeg(tmp_path / "b.jpg")
-
- job_id = start_job(str(tmp_path), mode="exif")
- assert isinstance(job_id, str)
-
- # Poll until done
- import time
-
- for _ in range(50):
- job = get_job(job_id)
- if job and job.status in ("done", "error"):
- break
- time.sleep(0.1)
-
- job = get_job(job_id)
- assert job is not None
- assert job.status == "done"
- assert len(job.results) == 2
-
- def test_start_integrity_scan(self, tmp_path) -> None:
- _make_jpeg(tmp_path / "ok.jpg")
- # Truncated file
- bad = tmp_path / "bad.jpg"
- bad.write_bytes(b"\xff\xd8" + b"\x00" * 5)
-
- job_id = start_job(str(tmp_path), mode="integrity")
-
- import time
-
- for _ in range(50):
- job = get_job(job_id)
- if job and job.status in ("done", "error"):
- break
- time.sleep(0.1)
-
- job = get_job(job_id)
- assert job is not None
- assert job.status == "done"
- assert len(job.results) >= 2
-
- def test_get_nonexistent_job(self) -> None:
- assert get_job("nonexistent-id") is None
diff --git a/tests/organizer/__init__.py b/tests/organizer/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/tests/organizer/test_date_sorter.py b/tests/organizer/test_date_sorter.py
deleted file mode 100644
index 480a74a..0000000
--- a/tests/organizer/test_date_sorter.py
+++ /dev/null
@@ -1,92 +0,0 @@
-"""Tests for morphic.organizer.date_sorter."""
-
-from __future__ import annotations
-
-import os
-
-import pytest
-from PIL import Image
-
-from morphic.organizer.date_sorter import (
- execute_sort,
- get_file_date,
- plan_sort,
-)
-
-
-def _make_jpeg(path: str) -> str:
- Image.new("RGB", (10, 10), "red").save(path, "JPEG")
- return path
-
-
-class TestGetFileDate:
- def test_returns_datetime(self, tmp_path) -> None:
- path = _make_jpeg(str(tmp_path / "a.jpg"))
- dt = get_file_date(path)
- assert dt is not None
- assert dt.year >= 2020
-
- def test_fallback_to_mtime(self, tmp_path) -> None:
- # PNG has no EXIF — should fall back to mtime
- p = str(tmp_path / "test.png")
- Image.new("RGB", (10, 10)).save(p)
- dt = get_file_date(p)
- assert dt is not None
-
-
-class TestPlanSort:
- def test_plan_returns_list(self, tmp_path) -> None:
- _make_jpeg(str(tmp_path / "a.jpg"))
- _make_jpeg(str(tmp_path / "b.jpg"))
- plan = plan_sort(str(tmp_path))
- assert isinstance(plan, list)
- assert len(plan) == 2
- for entry in plan:
- assert "source" in entry
- assert "destination" in entry
- assert "date" in entry
-
- def test_plan_with_template(self, tmp_path) -> None:
- _make_jpeg(str(tmp_path / "photo.jpg"))
- plan = plan_sort(str(tmp_path), template="{year}/{month}")
- assert len(plan) == 1
- # Destination should contain year/month path
- dest = plan[0]["destination"]
- parts = dest.replace("\\", "/").split("/")
- # Should have numeric year and month somewhere in path
- assert any(p.isdigit() and len(p) == 4 for p in parts)
-
- def test_plan_with_destination(self, tmp_path) -> None:
- src = tmp_path / "src"
- src.mkdir()
- _make_jpeg(str(src / "a.jpg"))
-
- dest = str(tmp_path / "dest")
- plan = plan_sort(str(src), destination=dest)
- assert len(plan) == 1
- assert plan[0]["destination"].startswith(dest)
-
-
-class TestExecuteSort:
- def test_copy(self, tmp_path) -> None:
- _make_jpeg(str(tmp_path / "orig.jpg"))
- plan = plan_sort(str(tmp_path), destination=str(tmp_path / "sorted"))
- result = execute_sort(plan, operation="copy")
- assert result["completed"] == 1
- assert result["errors"] == 0
- # Original still exists (copy)
- assert os.path.isfile(str(tmp_path / "orig.jpg"))
- # Destination exists
- assert os.path.isfile(plan[0]["destination"])
-
- def test_move(self, tmp_path) -> None:
- _make_jpeg(str(tmp_path / "orig.jpg"))
- plan = plan_sort(str(tmp_path), destination=str(tmp_path / "sorted"))
- result = execute_sort(plan, operation="move")
- assert result["completed"] == 1
- # Original should be gone
- assert not os.path.isfile(str(tmp_path / "orig.jpg"))
-
- def test_invalid_operation(self, tmp_path) -> None:
- with pytest.raises(ValueError, match="move.*copy"):
- execute_sort([], operation="bad")
diff --git a/tests/organizer/test_renamer.py b/tests/organizer/test_renamer.py
deleted file mode 100644
index 8c2dfe8..0000000
--- a/tests/organizer/test_renamer.py
+++ /dev/null
@@ -1,96 +0,0 @@
-"""Tests for morphic.organizer.renamer."""
-
-from __future__ import annotations
-
-import os
-
-from PIL import Image
-
-from morphic.organizer.renamer import execute_rename, plan_rename
-
-
-def _make_jpeg(path: str) -> str:
- Image.new("RGB", (10, 10), "red").save(path, "JPEG")
- return path
-
-
-class TestPlanRename:
- def test_basic_plan(self, tmp_path) -> None:
- _make_jpeg(str(tmp_path / "a.jpg"))
- _make_jpeg(str(tmp_path / "b.jpg"))
- plan = plan_rename(str(tmp_path), template="{seq:3}{ext}")
- assert len(plan) == 2
- for entry in plan:
- assert "source" in entry
- assert "new_name" in entry
- assert "destination" in entry
- assert "conflict" in entry
-
- def test_seq_token(self, tmp_path) -> None:
- _make_jpeg(str(tmp_path / "photo.jpg"))
- plan = plan_rename(str(tmp_path), template="{seq:4}{ext}", start_seq=1)
- assert plan[0]["new_name"] == "0001.jpg"
-
- def test_original_token(self, tmp_path) -> None:
- _make_jpeg(str(tmp_path / "nice_photo.jpg"))
- plan = plan_rename(str(tmp_path), template="{original}_renamed{ext}")
- assert "nice_photo_renamed.jpg" in plan[0]["new_name"]
-
- def test_date_token(self, tmp_path) -> None:
- _make_jpeg(str(tmp_path / "photo.jpg"))
- plan = plan_rename(str(tmp_path), template="{date}_{seq}{ext}")
- # Should have date-like prefix
- name = plan[0]["new_name"]
- assert name.count("-") >= 2 # YYYY-MM-DD has 2 dashes
-
- def test_conflict_detection(self, tmp_path) -> None:
- # Create 2 files that would get the same name
- _make_jpeg(str(tmp_path / "a.jpg"))
- _make_jpeg(str(tmp_path / "b.jpg"))
- # Template without seq = all get same name
- plan = plan_rename(str(tmp_path), template="same{ext}")
- conflicts = [p for p in plan if p["conflict"]]
- # At least one conflict expected
- assert len(conflicts) >= 1
-
- def test_output_folder(self, tmp_path) -> None:
- _make_jpeg(str(tmp_path / "photo.jpg"))
- out = str(tmp_path / "renamed")
- plan = plan_rename(
- str(tmp_path), template="{seq}{ext}", output_folder=out
- )
- assert plan[0]["destination"].startswith(out)
-
-
-class TestExecuteRename:
- def test_move(self, tmp_path) -> None:
- _make_jpeg(str(tmp_path / "orig.jpg"))
- plan = plan_rename(
- str(tmp_path),
- template="renamed_{seq:2}{ext}",
- output_folder=str(tmp_path / "out"),
- )
- result = execute_rename(plan, operation="move")
- assert result["completed"] == 1
- assert result["errors"] == 0
- assert not os.path.isfile(str(tmp_path / "orig.jpg"))
-
- def test_copy(self, tmp_path) -> None:
- _make_jpeg(str(tmp_path / "orig.jpg"))
- plan = plan_rename(
- str(tmp_path),
- template="renamed{ext}",
- output_folder=str(tmp_path / "out"),
- )
- result = execute_rename(plan, operation="copy")
- assert result["completed"] == 1
- # Original still exists
- assert os.path.isfile(str(tmp_path / "orig.jpg"))
-
- def test_skips_conflicts(self, tmp_path) -> None:
- _make_jpeg(str(tmp_path / "a.jpg"))
- _make_jpeg(str(tmp_path / "b.jpg"))
- plan = plan_rename(str(tmp_path), template="same{ext}")
- result = execute_rename(plan, operation="copy")
- # Should skip at least 1 conflict
- assert result["skipped"] >= 1
diff --git a/tests/resizer/__init__.py b/tests/resizer/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/tests/resizer/test_operations.py b/tests/resizer/test_operations.py
deleted file mode 100644
index a9859a2..0000000
--- a/tests/resizer/test_operations.py
+++ /dev/null
@@ -1,119 +0,0 @@
-"""Tests for morphic.resizer.operations."""
-
-from __future__ import annotations
-
-import os
-
-import pytest
-from PIL import Image
-
-from morphic.resizer.operations import resize_image
-
-
-def _make_image(path: str, size: tuple[int, int] = (200, 100)) -> str:
- Image.new("RGB", size, "blue").save(path)
- return path
-
-
-class TestResizeModes:
- def test_fit(self, tmp_path) -> None:
- src = _make_image(str(tmp_path / "src.png"), (400, 200))
- dest = resize_image(
- src, 100, 100, mode="fit", output_folder=str(tmp_path / "out")
- )
- img = Image.open(dest)
- # fit keeps aspect ratio, so 100x50
- assert img.width <= 100
- assert img.height <= 100
-
- def test_fill(self, tmp_path) -> None:
- src = _make_image(str(tmp_path / "src.png"), (400, 200))
- dest = resize_image(
- src, 100, 100, mode="fill", output_folder=str(tmp_path / "out")
- )
- img = Image.open(dest)
- assert img.size == (100, 100)
-
- def test_stretch(self, tmp_path) -> None:
- src = _make_image(str(tmp_path / "src.png"), (400, 200))
- dest = resize_image(
- src, 100, 50, mode="stretch", output_folder=str(tmp_path / "out")
- )
- img = Image.open(dest)
- assert img.size == (100, 50)
-
- def test_pad(self, tmp_path) -> None:
- src = _make_image(str(tmp_path / "src.png"), (400, 200))
- dest = resize_image(
- src, 100, 100, mode="pad", output_folder=str(tmp_path / "out")
- )
- img = Image.open(dest)
- assert img.size == (100, 100)
-
-
-class TestResizeErrors:
- def test_invalid_mode(self, tmp_path) -> None:
- src = _make_image(str(tmp_path / "src.png"))
- with pytest.raises(ValueError, match="Invalid mode"):
- resize_image(src, 100, 100, mode="bad")
-
- def test_nonexistent_file(self, tmp_path) -> None:
- with pytest.raises(FileNotFoundError):
- resize_image(str(tmp_path / "nope.png"), 100, 100)
-
- def test_zero_dimensions(self, tmp_path) -> None:
- src = _make_image(str(tmp_path / "src.png"))
- with pytest.raises(ValueError, match="positive"):
- resize_image(src, 0, 100)
-
-
-class TestResizeOutput:
- def test_output_folder_created(self, tmp_path) -> None:
- src = _make_image(str(tmp_path / "src.png"))
- out = str(tmp_path / "new_dir")
- dest = resize_image(src, 50, 50, output_folder=out)
- assert os.path.isdir(out)
- assert os.path.isfile(dest)
-
- def test_format_override(self, tmp_path) -> None:
- src = _make_image(str(tmp_path / "src.png"))
- dest = resize_image(
- src,
- 50,
- 50,
- output_format=".jpg",
- output_folder=str(tmp_path / "out"),
- )
- assert dest.endswith(".jpg")
- img = Image.open(dest)
- assert img.mode == "RGB"
-
- def test_quality_param(self, tmp_path) -> None:
- src = _make_image(str(tmp_path / "src.jpg"))
- dest_high = resize_image(
- src, 50, 50, quality=95, output_folder=str(tmp_path / "hi")
- )
- dest_low = resize_image(
- src, 50, 50, quality=10, output_folder=str(tmp_path / "lo")
- )
- # Lower quality should be smaller file
- assert os.path.getsize(dest_low) <= os.path.getsize(dest_high)
-
- def test_rgba_to_jpg(self, tmp_path) -> None:
- src = str(tmp_path / "rgba.png")
- Image.new("RGBA", (50, 50), (255, 0, 0, 128)).save(src)
- dest = resize_image(
- src,
- 30,
- 30,
- output_format=".jpg",
- output_folder=str(tmp_path / "out"),
- )
- img = Image.open(dest)
- assert img.mode == "RGB"
-
- def test_palette_mode(self, tmp_path) -> None:
- src = str(tmp_path / "pal.png")
- Image.new("P", (50, 50)).save(src)
- dest = resize_image(src, 30, 30, output_folder=str(tmp_path / "out"))
- assert os.path.isfile(dest)
diff --git a/tests/resizer/test_scanner.py b/tests/resizer/test_scanner.py
deleted file mode 100644
index 5ea7229..0000000
--- a/tests/resizer/test_scanner.py
+++ /dev/null
@@ -1,45 +0,0 @@
-"""Tests for morphic.resizer.scanner."""
-
-from __future__ import annotations
-
-import time
-
-from PIL import Image
-
-from morphic.resizer.scanner import get_job, start_job
-
-
-def _make_images(tmp_path, count=3):
- for i in range(count):
- Image.new("RGB", (200, 100), "green").save(
- str(tmp_path / f"img{i}.png")
- )
-
-
-class TestResizerScanner:
- def test_start_job(self, tmp_path) -> None:
- _make_images(tmp_path)
- out = tmp_path / "output"
-
- job_id = start_job(
- folder=str(tmp_path),
- width=50,
- height=50,
- mode="fit",
- output_folder=str(out),
- )
- assert isinstance(job_id, str)
-
- for _ in range(50):
- job = get_job(job_id)
- if job and job.status in ("done", "error"):
- break
- time.sleep(0.1)
-
- job = get_job(job_id)
- assert job is not None
- assert job.status == "done"
- assert len(job.results) == 3
-
- def test_nonexistent_job(self) -> None:
- assert get_job("fake-id") is None
diff --git a/tests/shared/__init__.py b/tests/shared/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/tests/shared/test_constants.py b/tests/shared/test_constants.py
deleted file mode 100644
index 27f1fc0..0000000
--- a/tests/shared/test_constants.py
+++ /dev/null
@@ -1,99 +0,0 @@
-"""Tests for morphic.shared.constants."""
-
-from morphic.shared.constants import (
- ALIASES,
- ALL_EXTENSIONS,
- DEFAULT_BATCH_SIZE,
- DEFAULT_HASH_SIZE,
- DEFAULT_IMAGE_THRESHOLD,
- DEFAULT_NUM_FRAMES,
- DEFAULT_NUM_WORKERS,
- DEFAULT_VIDEO_THRESHOLD,
- EXCLUDED_FOLDERS,
- IMAGE_EXTENSIONS,
- VIDEO_EXTENSIONS,
-)
-
-
-class TestExtensionSets:
- def test_image_extensions_not_empty(self) -> None:
- assert len(IMAGE_EXTENSIONS) > 0
-
- def test_video_extensions_not_empty(self) -> None:
- assert len(VIDEO_EXTENSIONS) > 0
-
- def test_all_extensions_is_union(self) -> None:
- assert ALL_EXTENSIONS == IMAGE_EXTENSIONS | VIDEO_EXTENSIONS
-
- def test_no_overlap_between_image_and_video(self) -> None:
- assert IMAGE_EXTENSIONS & VIDEO_EXTENSIONS == set()
-
- def test_all_extensions_start_with_dot(self) -> None:
- for ext in ALL_EXTENSIONS:
- assert ext.startswith("."), f"{ext} missing leading dot"
-
- def test_extensions_are_lowercase(self) -> None:
- for ext in ALL_EXTENSIONS:
- assert ext == ext.lower(), f"{ext} not lowercase"
-
- def test_image_extensions_are_frozenset(self) -> None:
- assert isinstance(IMAGE_EXTENSIONS, frozenset)
-
- def test_video_extensions_are_frozenset(self) -> None:
- assert isinstance(VIDEO_EXTENSIONS, frozenset)
-
- def test_common_image_formats_present(self) -> None:
- for ext in [".jpg", ".png", ".gif", ".bmp", ".webp", ".tif"]:
- assert ext in IMAGE_EXTENSIONS
-
- def test_common_video_formats_present(self) -> None:
- for ext in [".mp4", ".mov", ".avi", ".mkv", ".webm"]:
- assert ext in VIDEO_EXTENSIONS
-
-
-class TestAliases:
- def test_jpeg_alias(self) -> None:
- assert ALIASES[".jpeg"] == ".jpg"
-
- def test_tiff_alias(self) -> None:
- assert ALIASES[".tiff"] == ".tif"
-
- def test_mpg_alias(self) -> None:
- assert ALIASES[".mpg"] == ".mpeg"
-
- def test_aliases_are_lowercase(self) -> None:
- for key, val in ALIASES.items():
- assert key == key.lower()
- assert val == val.lower()
-
-
-class TestExcludedFolders:
- def test_excluded_folders_not_empty(self) -> None:
- assert len(EXCLUDED_FOLDERS) > 0
-
- def test_common_exclusions_present(self) -> None:
- for name in ["node_modules", ".git", "__pycache__"]:
- assert name in EXCLUDED_FOLDERS
-
- def test_excluded_folders_are_frozenset(self) -> None:
- assert isinstance(EXCLUDED_FOLDERS, frozenset)
-
-
-class TestDefaults:
- def test_image_threshold_range(self) -> None:
- assert 0 < DEFAULT_IMAGE_THRESHOLD <= 1.0
-
- def test_video_threshold_range(self) -> None:
- assert 0 < DEFAULT_VIDEO_THRESHOLD <= 1.0
-
- def test_hash_size_positive(self) -> None:
- assert DEFAULT_HASH_SIZE > 0
-
- def test_num_frames_positive(self) -> None:
- assert DEFAULT_NUM_FRAMES > 0
-
- def test_num_workers_positive(self) -> None:
- assert DEFAULT_NUM_WORKERS > 0
-
- def test_batch_size_positive(self) -> None:
- assert DEFAULT_BATCH_SIZE > 0
diff --git a/tests/shared/test_file_browser.py b/tests/shared/test_file_browser.py
deleted file mode 100644
index 7e3a8b3..0000000
--- a/tests/shared/test_file_browser.py
+++ /dev/null
@@ -1,224 +0,0 @@
-"""Tests for morphic.shared.file_browser — all fallback paths."""
-
-from __future__ import annotations
-
-from unittest.mock import MagicMock, patch
-
-from morphic.shared.file_browser import (
- _try_kdialog,
- _try_osascript,
- _try_powershell,
- _try_tkinter,
- _try_zenity,
- open_native_folder_dialog,
-)
-
-
-class TestTryTkinter:
- @patch("tkinter.filedialog.askdirectory", return_value="/selected/folder")
- @patch("tkinter.Tk")
- def test_success(self, mock_tk_cls, mock_askdir) -> None:
- mock_root = MagicMock()
- mock_tk_cls.return_value = mock_root
-
- result = _try_tkinter("/home/user")
- assert result == "/selected/folder"
-
- @patch("tkinter.filedialog.askdirectory", return_value="")
- @patch("tkinter.Tk")
- def test_cancelled(self, mock_tk_cls, mock_askdir) -> None:
- mock_root = MagicMock()
- mock_tk_cls.return_value = mock_root
-
- result = _try_tkinter("/home/user")
- assert result is None
-
- @patch("builtins.__import__", side_effect=ImportError("No tkinter"))
- def test_import_error(self, mock_import) -> None:
- result = _try_tkinter("/home/user")
- assert result is None
-
-
-class TestTryZenity:
- @patch("morphic.shared.file_browser.subprocess.run")
- def test_success(self, mock_run) -> None:
- mock_run.return_value = MagicMock(
- returncode=0,
- stdout="/selected/folder\n",
- )
- result = _try_zenity("/home/user")
- assert result == "/selected/folder"
-
- @patch("morphic.shared.file_browser.subprocess.run")
- def test_cancelled(self, mock_run) -> None:
- mock_run.return_value = MagicMock(returncode=1, stdout="")
- result = _try_zenity("/home/user")
- assert result is None
-
- @patch(
- "morphic.shared.file_browser.subprocess.run",
- side_effect=FileNotFoundError,
- )
- def test_not_found(self, mock_run) -> None:
- result = _try_zenity("/home/user")
- assert result is None
-
-
-class TestTryKdialog:
- @patch("morphic.shared.file_browser.subprocess.run")
- def test_success(self, mock_run) -> None:
- mock_run.return_value = MagicMock(
- returncode=0,
- stdout="/selected/folder\n",
- )
- result = _try_kdialog("/home/user")
- assert result == "/selected/folder"
-
- @patch("morphic.shared.file_browser.subprocess.run")
- def test_cancelled(self, mock_run) -> None:
- mock_run.return_value = MagicMock(returncode=1, stdout="")
- result = _try_kdialog("/home/user")
- assert result is None
-
- @patch(
- "morphic.shared.file_browser.subprocess.run",
- side_effect=FileNotFoundError,
- )
- def test_not_found(self, mock_run) -> None:
- result = _try_kdialog("/home/user")
- assert result is None
-
-
-class TestTryOsascript:
- @patch("morphic.shared.file_browser.subprocess.run")
- def test_success(self, mock_run) -> None:
- mock_run.return_value = MagicMock(
- returncode=0,
- stdout="/Users/test/folder/\n",
- )
- result = _try_osascript("/Users/test")
- assert result == "/Users/test/folder"
-
- @patch("morphic.shared.file_browser.subprocess.run")
- def test_cancelled(self, mock_run) -> None:
- mock_run.return_value = MagicMock(returncode=1, stdout="")
- result = _try_osascript("/Users/test")
- assert result is None
-
- @patch(
- "morphic.shared.file_browser.subprocess.run",
- side_effect=FileNotFoundError,
- )
- def test_not_found(self, mock_run) -> None:
- result = _try_osascript("/Users/test")
- assert result is None
-
-
-class TestTryPowershell:
- @patch("morphic.shared.file_browser.subprocess.run")
- def test_success(self, mock_run) -> None:
- mock_run.return_value = MagicMock(
- returncode=0,
- stdout="C:\\Users\\test\\folder\n",
- )
- result = _try_powershell("C:\\Users\\test")
- assert result == "C:\\Users\\test\\folder"
-
- @patch("morphic.shared.file_browser.subprocess.run")
- def test_cancelled(self, mock_run) -> None:
- mock_run.return_value = MagicMock(returncode=1, stdout="")
- result = _try_powershell("C:\\Users\\test")
- assert result is None
-
- @patch(
- "morphic.shared.file_browser.subprocess.run",
- side_effect=FileNotFoundError,
- )
- def test_not_found(self, mock_run) -> None:
- result = _try_powershell("C:\\Users\\test")
- assert result is None
-
-
-class TestOpenNativeFolderDialog:
- @patch("morphic.shared.file_browser._try_tkinter", return_value="/chosen")
- def test_tkinter_success(self, mock_tk) -> None:
- result = open_native_folder_dialog()
- assert result == "/chosen"
-
- @patch("morphic.shared.file_browser._try_tkinter", return_value=None)
- @patch("morphic.shared.file_browser.platform.system", return_value="Linux")
- @patch(
- "morphic.shared.file_browser._try_zenity",
- return_value="/zenity_dir",
- )
- def test_linux_zenity_fallback(self, mock_z, mock_sys, mock_tk) -> None:
- result = open_native_folder_dialog()
- assert result == "/zenity_dir"
-
- @patch("morphic.shared.file_browser._try_tkinter", return_value=None)
- @patch("morphic.shared.file_browser.platform.system", return_value="Linux")
- @patch("morphic.shared.file_browser._try_zenity", return_value=None)
- @patch(
- "morphic.shared.file_browser._try_kdialog",
- return_value="/kde_dir",
- )
- def test_linux_kdialog_fallback(
- self,
- mock_k,
- mock_z,
- mock_sys,
- mock_tk,
- ) -> None:
- result = open_native_folder_dialog()
- assert result == "/kde_dir"
-
- @patch("morphic.shared.file_browser._try_tkinter", return_value=None)
- @patch(
- "morphic.shared.file_browser.platform.system",
- return_value="Darwin",
- )
- @patch(
- "morphic.shared.file_browser._try_osascript",
- return_value="/mac_dir",
- )
- def test_macos_fallback(self, mock_osa, mock_sys, mock_tk) -> None:
- result = open_native_folder_dialog()
- assert result == "/mac_dir"
-
- @patch("morphic.shared.file_browser._try_tkinter", return_value=None)
- @patch(
- "morphic.shared.file_browser.platform.system",
- return_value="Windows",
- )
- @patch(
- "morphic.shared.file_browser._try_powershell",
- return_value="C:\\dir",
- )
- def test_windows_fallback(self, mock_ps, mock_sys, mock_tk) -> None:
- result = open_native_folder_dialog()
- assert result == "C:\\dir"
-
- @patch("morphic.shared.file_browser._try_tkinter", return_value=None)
- @patch(
- "morphic.shared.file_browser.platform.system",
- return_value="Linux",
- )
- @patch("morphic.shared.file_browser._try_zenity", return_value=None)
- @patch("morphic.shared.file_browser._try_kdialog", return_value=None)
- def test_all_fail_returns_none(
- self,
- mock_k,
- mock_z,
- mock_sys,
- mock_tk,
- ) -> None:
- result = open_native_folder_dialog()
- assert result is None
-
- def test_default_initial_dir(self) -> None:
- with patch(
- "morphic.shared.file_browser._try_tkinter",
- return_value="/chosen",
- ) as mock_tk:
- open_native_folder_dialog()
- assert mock_tk.called
diff --git a/tests/shared/test_thumbnails.py b/tests/shared/test_thumbnails.py
deleted file mode 100644
index f26d1f9..0000000
--- a/tests/shared/test_thumbnails.py
+++ /dev/null
@@ -1,132 +0,0 @@
-"""Tests for morphic.shared.thumbnails."""
-
-from __future__ import annotations
-
-import io
-from unittest.mock import MagicMock, patch
-
-import pytest
-from PIL import Image
-
-from morphic.shared.thumbnails import (
- generate_image_thumbnail,
- generate_video_thumbnail,
-)
-
-
-class TestGenerateImageThumbnail:
- def test_basic(self, tmp_path) -> None:
- img_path = tmp_path / "test.jpg"
- Image.new("RGB", (500, 500), "red").save(str(img_path))
-
- buf = generate_image_thumbnail(str(img_path), size=100)
- assert isinstance(buf, io.BytesIO)
-
- result = Image.open(buf)
- assert result.format == "JPEG"
- assert max(result.size) <= 100
-
- def test_large_image(self, tmp_path) -> None:
- img_path = tmp_path / "large.png"
- Image.new("RGB", (3000, 2000), "blue").save(str(img_path))
-
- buf = generate_image_thumbnail(str(img_path), size=300)
- result = Image.open(buf)
- assert max(result.size) <= 300
-
- def test_rgba_converts_to_rgb(self, tmp_path) -> None:
- img_path = tmp_path / "rgba.png"
- Image.new("RGBA", (100, 100), (255, 0, 0, 128)).save(str(img_path))
-
- buf = generate_image_thumbnail(str(img_path))
- result = Image.open(buf)
- assert result.mode == "RGB"
-
- def test_palette_converts(self, tmp_path) -> None:
- img_path = tmp_path / "palette.gif"
- Image.new("P", (100, 100)).save(str(img_path))
-
- buf = generate_image_thumbnail(str(img_path))
- result = Image.open(buf)
- assert result.mode == "RGB"
-
- def test_la_mode_converts(self, tmp_path) -> None:
- img_path = tmp_path / "la.png"
- Image.new("LA", (100, 100)).save(str(img_path))
-
- buf = generate_image_thumbnail(str(img_path))
- result = Image.open(buf)
- assert result.mode == "RGB"
-
- def test_custom_size(self, tmp_path) -> None:
- img_path = tmp_path / "test.jpg"
- Image.new("RGB", (1000, 1000), "green").save(str(img_path))
-
- buf = generate_image_thumbnail(str(img_path), size=150)
- result = Image.open(buf)
- assert max(result.size) <= 150
-
- def test_nonexistent_file(self) -> None:
- with pytest.raises(Exception):
- generate_image_thumbnail("/nonexistent/file.jpg")
-
-
-class TestGenerateVideoThumbnail:
- @patch("morphic.shared.thumbnails.subprocess.run")
- def test_success(self, mock_run) -> None:
- img = Image.new("RGB", (100, 100), "red")
- buf = io.BytesIO()
- img.save(buf, format="JPEG")
- jpeg_bytes = buf.getvalue()
-
- mock_run.return_value = MagicMock(
- returncode=0,
- stdout=jpeg_bytes,
- )
- result = generate_video_thumbnail("/test/video.mp4")
- assert result is not None
- assert isinstance(result, io.BytesIO)
-
- @patch("morphic.shared.thumbnails.subprocess.run")
- def test_failure(self, mock_run) -> None:
- mock_run.return_value = MagicMock(returncode=1, stdout=b"")
- result = generate_video_thumbnail("/test/video.mp4")
- assert result is None
-
- @patch("morphic.shared.thumbnails.subprocess.run")
- def test_retry_at_0s(self, mock_run) -> None:
- img = Image.new("RGB", (100, 100), "blue")
- buf = io.BytesIO()
- img.save(buf, format="JPEG")
- jpeg_bytes = buf.getvalue()
-
- mock_run.side_effect = [
- MagicMock(returncode=1, stdout=b""),
- MagicMock(returncode=0, stdout=jpeg_bytes),
- ]
- result = generate_video_thumbnail("/test/short.mp4")
- assert result is not None
- assert mock_run.call_count == 2
-
- @patch("morphic.shared.thumbnails.subprocess.run")
- def test_custom_size(self, mock_run) -> None:
- img = Image.new("RGB", (50, 50), "green")
- buf = io.BytesIO()
- img.save(buf, format="JPEG")
-
- mock_run.return_value = MagicMock(
- returncode=0,
- stdout=buf.getvalue(),
- )
- result = generate_video_thumbnail("/test/video.mp4", size=150)
- assert result is not None
-
- def test_nonexistent_file(self) -> None:
- result = generate_video_thumbnail("/nonexistent/file.mp4")
- assert result is None
-
- def test_invalid_video(self, tmp_path) -> None:
- fake = tmp_path / "fake.mp4"
- fake.write_bytes(b"\x00" * 10)
- result = generate_video_thumbnail(str(fake))
- assert result is None
diff --git a/tests/shared/test_utils.py b/tests/shared/test_utils.py
deleted file mode 100644
index 7a9d4d9..0000000
--- a/tests/shared/test_utils.py
+++ /dev/null
@@ -1,200 +0,0 @@
-"""Tests for morphic.shared.utils."""
-
-from __future__ import annotations
-
-import os
-
-from morphic.shared.utils import (
- find_files_by_extension,
- format_duration,
- format_file_size,
- is_excluded_path,
- is_image,
- is_video,
- normalise_ext,
- suppress_stderr,
-)
-
-
-class TestNormaliseExt:
- def test_jpeg_to_jpg(self) -> None:
- assert normalise_ext(".jpeg") == ".jpg"
- assert normalise_ext(".JPEG") == ".jpg"
-
- def test_tiff_to_tif(self) -> None:
- assert normalise_ext(".tiff") == ".tif"
- assert normalise_ext(".TIFF") == ".tif"
-
- def test_mpg_to_mpeg(self) -> None:
- assert normalise_ext(".mpg") == ".mpeg"
-
- def test_already_canonical(self) -> None:
- assert normalise_ext(".png") == ".png"
- assert normalise_ext(".mp4") == ".mp4"
-
- def test_case_insensitive(self) -> None:
- assert normalise_ext(".PNG") == ".png"
- assert normalise_ext(".Mp4") == ".mp4"
-
- def test_unknown_extension(self) -> None:
- assert normalise_ext(".xyz") == ".xyz"
-
- def test_empty_string(self) -> None:
- assert normalise_ext("") == ""
-
-
-class TestIsImage:
- def test_common_image_extensions(self) -> None:
- for ext in ["jpg", "jpeg", "png", "tif", "tiff", "webp", "gif", "bmp"]:
- assert is_image(f"photo.{ext}"), f".{ext} should be image"
-
- def test_case_insensitive(self) -> None:
- assert is_image("photo.JPG")
- assert is_image("photo.Png")
-
- def test_not_image(self) -> None:
- assert not is_image("video.mp4")
- assert not is_image("document.pdf")
- assert not is_image("noext")
-
- def test_full_path(self) -> None:
- assert is_image("/home/user/photos/test.png")
-
-
-class TestIsVideo:
- def test_common_video_extensions(self) -> None:
- for ext in ["mp4", "mov", "mkv", "avi", "webm"]:
- assert is_video(f"clip.{ext}"), f".{ext} should be video"
-
- def test_case_insensitive(self) -> None:
- assert is_video("clip.MP4")
- assert is_video("clip.Mov")
-
- def test_not_video(self) -> None:
- assert not is_video("photo.jpg")
- assert not is_video("document.pdf")
-
- def test_full_path(self) -> None:
- assert is_video("/home/user/videos/test.mp4")
-
-
-class TestFormatFileSize:
- def test_bytes(self) -> None:
- assert format_file_size(512) == "512.00 B"
-
- def test_kilobytes(self) -> None:
- result = format_file_size(1536)
- assert "KB" in result
-
- def test_megabytes(self) -> None:
- result = format_file_size(2 * 1024 * 1024)
- assert "MB" in result
-
- def test_gigabytes(self) -> None:
- result = format_file_size(3 * 1024**3)
- assert "GB" in result
-
- def test_terabytes(self) -> None:
- result = format_file_size(2 * 1024**4)
- assert "TB" in result
-
- def test_zero(self) -> None:
- assert format_file_size(0) == "0.00 B"
-
-
-class TestFormatDuration:
- def test_seconds_only(self) -> None:
- assert format_duration(45) == "45s"
-
- def test_minutes_and_seconds(self) -> None:
- assert format_duration(125) == "2m 5s"
-
- def test_hours_minutes_seconds(self) -> None:
- assert format_duration(3661) == "1h 1m 1s"
-
- def test_zero(self) -> None:
- assert format_duration(0) == "0s"
-
-
-class TestIsExcludedPath:
- def test_excluded_folder(self) -> None:
- assert is_excluded_path("/home/user/node_modules/lib/file.jpg")
-
- def test_git_folder(self) -> None:
- assert is_excluded_path("/repo/.git/objects/file")
-
- def test_pycache(self) -> None:
- assert is_excluded_path("/project/__pycache__/module.pyc")
-
- def test_normal_path(self) -> None:
- assert not is_excluded_path("/home/user/photos/vacation.jpg")
-
- def test_custom_exclusions(self) -> None:
- assert is_excluded_path(
- "/project/custom/file.jpg",
- excluded_folders=frozenset({"custom"}),
- )
-
-
-class TestFindFilesByExtension:
- def test_finds_images(self, tmp_path) -> None:
- (tmp_path / "a.jpg").touch()
- (tmp_path / "b.png").touch()
- (tmp_path / "c.txt").touch()
-
- result = find_files_by_extension(
- str(tmp_path),
- frozenset({".jpg", ".png"}),
- )
- assert len(result) == 2
-
- def test_recursive(self, tmp_path) -> None:
- (tmp_path / "a.jpg").touch()
- sub = tmp_path / "sub"
- sub.mkdir()
- (sub / "b.jpg").touch()
-
- result = find_files_by_extension(
- str(tmp_path),
- frozenset({".jpg"}),
- )
- assert len(result) == 2
-
- def test_excludes_folders(self, tmp_path) -> None:
- (tmp_path / "a.jpg").touch()
- excluded = tmp_path / "node_modules"
- excluded.mkdir()
- (excluded / "b.jpg").touch()
-
- result = find_files_by_extension(
- str(tmp_path),
- frozenset({".jpg"}),
- )
- assert len(result) == 1
-
- def test_empty_folder(self, tmp_path) -> None:
- result = find_files_by_extension(
- str(tmp_path),
- frozenset({".jpg"}),
- )
- assert result == []
-
- def test_returns_sorted(self, tmp_path) -> None:
- (tmp_path / "c.jpg").touch()
- (tmp_path / "a.jpg").touch()
- (tmp_path / "b.jpg").touch()
-
- result = find_files_by_extension(
- str(tmp_path),
- frozenset({".jpg"}),
- )
- names = [os.path.basename(f) for f in result]
- assert names == sorted(names)
-
-
-class TestSuppressStderr:
- def test_suppresses(self) -> None:
- import sys
-
- with suppress_stderr():
- sys.stderr.write("suppressed\n")
diff --git a/web/routes_converter.go b/web/routes_converter.go
new file mode 100644
index 0000000..cf53fed
--- /dev/null
+++ b/web/routes_converter.go
@@ -0,0 +1,300 @@
+package web
+
+import (
+ "net/http"
+ "os"
+ "time"
+
+ "github.com/exterex/morphic/internal/converter"
+ "github.com/exterex/morphic/internal/shared"
+ "github.com/gin-gonic/gin"
+)
+
+var conversionStore = shared.NewJobStore[conversionJob]()
+
+type conversionJob struct {
+ shared.Job
+ Total int `json:"total"`
+ Completed int `json:"completed"`
+ CurrentFile string `json:"current_file"`
+ Results []map[string]interface{} `json:"results"`
+}
+
+func init() {
+ conversionStore.StartCleanup(30*time.Minute, func(j *conversionJob) time.Time {
+ return j.DoneAt
+ })
+}
+
+func registerConverterRoutes(r *gin.Engine) {
+ g := r.Group("/api/converter")
+ {
+ g.POST("/scan", handleConverterScan)
+ g.GET("/formats", handleConverterFormats)
+ g.POST("/convert", handleConverterConvert)
+ g.GET("/progress/:id", handleConverterProgress)
+ g.GET("/progress/:id/poll", handleConverterPoll)
+ g.POST("/progress/:id/cancel", handleConverterCancel)
+ g.POST("/delete", handleConverterDelete)
+ }
+}
+
+func handleConverterScan(c *gin.Context) {
+ var req struct {
+ Folder string `json:"folder"`
+ IncludeSubfolders *bool `json:"include_subfolders"`
+ FilterType string `json:"filter_type"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ if req.Folder == "" || !isDir(req.Folder) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid folder: " + req.Folder})
+ return
+ }
+ includeSub := true
+ if req.IncludeSubfolders != nil {
+ includeSub = *req.IncludeSubfolders
+ }
+ filterType := req.FilterType
+ if filterType != "images" && filterType != "videos" && filterType != "both" {
+ filterType = "both"
+ }
+
+ result, err := converter.ScanFolder(req.Folder, includeSub, filterType)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, result)
+}
+
+func handleConverterFormats(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{
+ "image": converter.ImageConversions,
+ "video": gin.H{
+ "containers": converter.VideoContainers,
+ },
+ })
+}
+
+func handleConverterConvert(c *gin.Context) {
+ var req struct {
+ Files []string `json:"files"`
+ TargetExt string `json:"target_ext"`
+ Codec string `json:"codec"`
+ DeleteOriginal bool `json:"delete_original"`
+ AV1CRF *int `json:"av1_crf"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ if len(req.Files) == 0 || req.TargetExt == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "files and target_ext required"})
+ return
+ }
+
+ av1CRF := 0
+ if req.AV1CRF != nil {
+ av1CRF = *req.AV1CRF
+ }
+
+ job := &conversionJob{
+ Job: shared.NewJob(),
+ Total: len(req.Files),
+ }
+ job.Status = shared.JobStatusRunning
+ conversionStore.Set(job.ID, job)
+
+ go runConversion(job, req.Files, req.TargetExt, req.Codec, req.DeleteOriginal, av1CRF)
+
+ c.JSON(http.StatusAccepted, gin.H{"job_id": job.ID})
+}
+
+func runConversion(job *conversionJob, files []string, targetExt, codec string, deleteOriginal bool, av1CRF int) {
+ for i, source := range files {
+ // Check for cancellation before each file
+ select {
+ case <-job.Ctx().Done():
+ job.Status = shared.JobStatusCancelled
+ job.CurrentFile = ""
+ job.DoneAt = time.Now()
+ return
+ default:
+ }
+
+ job.CurrentFile = source
+
+ result := map[string]interface{}{
+ "source": source,
+ "source_deleted": false,
+ }
+
+ origSize := int64(0)
+ if info, err := os.Stat(source); err == nil {
+ origSize = info.Size()
+ }
+
+ dest, err := converter.ConvertFile(source, targetExt, codec, "", av1CRF)
+ if err != nil {
+ result["destination"] = nil
+ result["status"] = "error"
+ result["error"] = err.Error()
+ } else {
+ newSize := int64(0)
+ if info, err := os.Stat(dest); err == nil {
+ newSize = info.Size()
+ }
+
+ result["destination"] = dest
+ result["status"] = "ok"
+ result["original_size"] = origSize
+ result["new_size"] = newSize
+ result["original_size_fmt"] = shared.FormatFileSize(origSize)
+ result["new_size_fmt"] = shared.FormatFileSize(newSize)
+
+ // Delete original only if explicitly requested and safe
+ if deleteOriginal && dest != "" {
+ absSrc, _ := absPath(source)
+ absDest, _ := absPath(dest)
+ if absSrc != absDest && newSize > 0 {
+ if err := os.Remove(source); err == nil {
+ result["source_deleted"] = true
+ }
+ }
+ }
+ }
+
+ job.Results = append(job.Results, result)
+ job.Completed = i + 1
+ }
+
+ job.Status = shared.JobStatusDone
+ job.CurrentFile = ""
+ job.DoneAt = time.Now()
+}
+
+func handleConverterProgress(c *gin.Context) {
+ id := c.Param("id")
+ job, ok := conversionStore.Get(id)
+ if !ok {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "id": job.ID,
+ "status": job.Status,
+ "total": job.Total,
+ "completed": job.Completed,
+ "current_file": job.CurrentFile,
+ "results": job.Results,
+ "error": job.Error,
+ })
+}
+
+func handleConverterPoll(c *gin.Context) {
+ id := c.Param("id")
+ job, ok := conversionStore.Get(id)
+ if !ok {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
+ return
+ }
+
+ lastStr := c.Query("last")
+ last := -1
+ if lastStr != "" {
+ for i := 0; i < len(lastStr); i++ {
+ if lastStr[i] >= '0' && lastStr[i] <= '9' {
+ last = last*10 + int(lastStr[i]-'0')
+ }
+ }
+ }
+
+ deadline := time.Now().Add(10 * time.Second)
+ for time.Now().Before(deadline) {
+ if job.Completed != last || job.Status == shared.JobStatusDone {
+ break
+ }
+ time.Sleep(300 * time.Millisecond)
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "id": job.ID,
+ "status": job.Status,
+ "total": job.Total,
+ "completed": job.Completed,
+ "current_file": job.CurrentFile,
+ "results": job.Results,
+ "error": job.Error,
+ })
+}
+
+func handleConverterDelete(c *gin.Context) {
+ var req struct {
+ Files []string `json:"files"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ if len(req.Files) == 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "No files specified"})
+ return
+ }
+
+ var results []map[string]interface{}
+ totalFreed := int64(0)
+
+ for _, fp := range req.Files {
+ info, err := os.Stat(fp)
+ if err != nil {
+ results = append(results, map[string]interface{}{"path": fp, "status": "not_found"})
+ continue
+ }
+ if info.IsDir() {
+ results = append(results, map[string]interface{}{"path": fp, "status": "not_found"})
+ continue
+ }
+ size := info.Size()
+ if err := os.Remove(fp); err != nil {
+ if os.IsPermission(err) {
+ results = append(results, map[string]interface{}{"path": fp, "status": "permission_denied"})
+ } else {
+ results = append(results, map[string]interface{}{"path": fp, "status": "error", "error": err.Error()})
+ }
+ } else {
+ totalFreed += size
+ results = append(results, map[string]interface{}{"path": fp, "status": "deleted", "size_freed": size})
+ }
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "results": results,
+ "total_freed": totalFreed,
+ "total_freed_formatted": shared.FormatFileSize(totalFreed),
+ })
+}
+
+func handleConverterCancel(c *gin.Context) {
+ id := c.Param("id")
+ job, ok := conversionStore.Get(id)
+ if !ok {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
+ return
+ }
+ job.Cancel()
+ c.JSON(http.StatusOK, gin.H{"status": "cancelling"})
+}
+
+func absPath(p string) (string, error) {
+ abs, err := os.Getwd()
+ if err != nil {
+ return p, err
+ }
+ if len(p) > 0 && p[0] == '/' {
+ return p, nil
+ }
+ return abs + "/" + p, nil
+}
diff --git a/web/routes_dupfinder.go b/web/routes_dupfinder.go
new file mode 100644
index 0000000..3b2d0b9
--- /dev/null
+++ b/web/routes_dupfinder.go
@@ -0,0 +1,162 @@
+package web
+
+import (
+ "net/http"
+ "os"
+ "time"
+
+ "github.com/exterex/morphic/internal/dupfinder"
+ "github.com/exterex/morphic/internal/shared"
+ "github.com/gin-gonic/gin"
+)
+
+func registerDupfinderRoutes(r *gin.Engine) {
+ g := r.Group("/api/dupfinder")
+ {
+ g.POST("/scan", handleDupfinderScan)
+ g.GET("/scan/:id/status", handleDupfinderStatus)
+ g.GET("/scan/:id/results", handleDupfinderResults)
+ g.POST("/scan/:id/cancel", handleDupfinderCancel)
+ g.POST("/delete", handleDupfinderDelete)
+ }
+}
+
+func handleDupfinderScan(c *gin.Context) {
+ var req struct {
+ Folder string `json:"folder"`
+ Type string `json:"type"`
+ ImageThreshold float64 `json:"image_threshold"`
+ VideoThreshold float64 `json:"video_threshold"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ if req.Folder == "" || !isDir(req.Folder) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid folder: " + req.Folder})
+ return
+ }
+ if req.Type == "" {
+ req.Type = "both"
+ }
+ if req.Type != "images" && req.Type != "videos" && req.Type != "both" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "type must be images, videos, or both"})
+ return
+ }
+ if req.ImageThreshold == 0 {
+ req.ImageThreshold = shared.DefaultImageThreshold
+ }
+ if req.VideoThreshold == 0 {
+ req.VideoThreshold = shared.DefaultVideoThreshold
+ }
+
+ jobID := dupfinder.StartJob(req.Folder, req.Type, req.ImageThreshold, req.VideoThreshold)
+ c.JSON(http.StatusAccepted, gin.H{"job_id": jobID})
+}
+
+func handleDupfinderStatus(c *gin.Context) {
+ id := c.Param("id")
+ job, ok := dupfinder.GetJob(id)
+ if !ok {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
+ return
+ }
+
+ elapsed := 0.0
+ if !job.StartedAt.IsZero() {
+ end := job.DoneAt
+ if end.IsZero() {
+ end = time.Now()
+ }
+ elapsed = end.Sub(job.StartedAt).Seconds()
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "id": job.ID,
+ "status": job.Status,
+ "progress": job.Progress,
+ "message": job.Message,
+ "error": job.Error,
+ "total_files_found": job.TotalFound,
+ "total_files_processed": job.TotalProcessed,
+ "elapsed_seconds": round1(elapsed),
+ })
+}
+
+func handleDupfinderResults(c *gin.Context) {
+ id := c.Param("id")
+ job, ok := dupfinder.GetJob(id)
+ if !ok {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
+ return
+ }
+
+ if job.Status != "done" && job.Status != "failed" {
+ c.JSON(http.StatusConflict, gin.H{"error": "Scan not finished yet"})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "image_groups": job.ImageGroups,
+ "video_groups": job.VideoGroups,
+ "space_savings": job.SpaceSavings,
+ "space_savings_formatted": shared.FormatFileSize(job.SpaceSavings),
+ })
+}
+
+func handleDupfinderCancel(c *gin.Context) {
+ id := c.Param("id")
+ job, ok := dupfinder.GetJob(id)
+ if !ok {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
+ return
+ }
+ job.Cancel()
+ c.JSON(http.StatusOK, gin.H{"status": "cancelling"})
+}
+
+func handleDupfinderDelete(c *gin.Context) {
+ var req struct {
+ Files []string `json:"files"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ if len(req.Files) == 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "No files specified"})
+ return
+ }
+
+ var results []map[string]interface{}
+ totalFreed := int64(0)
+
+ for _, fp := range req.Files {
+ info, err := os.Stat(fp)
+ if err != nil {
+ results = append(results, map[string]interface{}{"path": fp, "status": "not_found"})
+ continue
+ }
+ if info.IsDir() {
+ results = append(results, map[string]interface{}{"path": fp, "status": "not_found"})
+ continue
+ }
+ size := info.Size()
+ if err := os.Remove(fp); err != nil {
+ if os.IsPermission(err) {
+ results = append(results, map[string]interface{}{"path": fp, "status": "permission_denied"})
+ } else {
+ results = append(results, map[string]interface{}{"path": fp, "status": "error", "error": err.Error()})
+ }
+ } else {
+ totalFreed += size
+ results = append(results, map[string]interface{}{"path": fp, "status": "deleted", "size_freed": size})
+ }
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "results": results,
+ "total_freed": totalFreed,
+ "total_freed_formatted": shared.FormatFileSize(totalFreed),
+ })
+}
diff --git a/web/routes_organizer.go b/web/routes_organizer.go
new file mode 100644
index 0000000..1b1f29f
--- /dev/null
+++ b/web/routes_organizer.go
@@ -0,0 +1,121 @@
+package web
+
+import (
+ "net/http"
+
+ "github.com/exterex/morphic/internal/organizer"
+ "github.com/gin-gonic/gin"
+)
+
+func registerOrganizerRoutes(r *gin.Engine) {
+ g := r.Group("/api/organizer")
+ {
+ g.POST("/plan", handleOrganizerPlan)
+ g.POST("/execute", handleOrganizerExecute)
+ g.GET("/status/:id", handleOrganizerStatus)
+ g.POST("/cancel/:id", handleOrganizerCancel)
+ }
+}
+
+func handleOrganizerPlan(c *gin.Context) {
+ var req struct {
+ Folder string `json:"folder"`
+ Mode string `json:"mode"`
+ Template string `json:"template"`
+ Destination string `json:"destination"`
+ Operation string `json:"operation"`
+ StartSeq int `json:"start_seq"`
+ }
+
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ if req.Folder == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "folder is required"})
+ return
+ }
+ if req.StartSeq <= 0 {
+ req.StartSeq = 1
+ }
+
+ jobID := organizer.StartPlanJob(
+ req.Folder, req.Mode, req.Template,
+ req.Destination, req.Operation, req.StartSeq,
+ )
+
+ c.JSON(http.StatusAccepted, gin.H{"job_id": jobID})
+}
+
+func handleOrganizerExecute(c *gin.Context) {
+ var req struct {
+ JobID string `json:"job_id"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil || req.JobID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "job_id required"})
+ return
+ }
+
+ if !organizer.ExecuteJob(req.JobID) {
+ c.JSON(http.StatusNotFound, gin.H{"error": "job not found or not in planned state"})
+ return
+ }
+
+ c.JSON(http.StatusAccepted, gin.H{"status": "executing", "job_id": req.JobID})
+}
+
+func handleOrganizerStatus(c *gin.Context) {
+ id := c.Param("id")
+ job, ok := organizer.GetJob(id)
+ if !ok {
+ c.JSON(http.StatusNotFound, gin.H{"error": "job not found"})
+ return
+ }
+
+ resp := gin.H{
+ "id": job.ID,
+ "status": job.Status,
+ "phase": job.Phase,
+ "mode": job.Mode,
+ "operation": job.Operation,
+ "progress": job.Progress,
+ "message": job.Message,
+ "error": job.Error,
+ }
+
+ // Include plan when planning is done (matches Python's response)
+ if job.Phase == "planned" || job.Phase == "executing" || job.Phase == "done" {
+ plan := organizer.GetUnifiedPlan(job)
+ resp["plan"] = plan
+ resp["plan_count"] = len(plan)
+
+ conflicts := 0
+ for _, entry := range plan {
+ if _, ok := entry["conflict"]; ok {
+ if entry["conflict"] == true {
+ conflicts++
+ }
+ }
+ }
+ resp["conflicts"] = conflicts
+ }
+
+ // Include execution results when done
+ if job.Phase == "done" {
+ resp["execution"] = organizer.GetExecutionResult(job)
+ }
+
+ c.JSON(http.StatusOK, resp)
+}
+
+func handleOrganizerCancel(c *gin.Context) {
+ id := c.Param("id")
+ job, ok := organizer.GetJob(id)
+ if !ok {
+ c.JSON(http.StatusNotFound, gin.H{"error": "job not found"})
+ return
+ }
+ job.Cancel()
+ c.JSON(http.StatusOK, gin.H{"status": "cancelling"})
+}
diff --git a/web/routes_shared.go b/web/routes_shared.go
new file mode 100644
index 0000000..845934b
--- /dev/null
+++ b/web/routes_shared.go
@@ -0,0 +1,205 @@
+package web
+
+import (
+ "math"
+ "mime"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "sort"
+ "strings"
+
+ "github.com/exterex/morphic/internal/shared"
+ "github.com/gin-gonic/gin"
+)
+
+func registerSharedRoutes(r *gin.Engine) {
+ r.GET("/api/browse", handleBrowseDirectory)
+ r.POST("/api/browse/native", handleBrowseNative)
+ r.GET("/api/thumbnail", handleThumbnail)
+ r.GET("/api/system_info", handleSystemInfo)
+ r.GET("/api/media", handleMedia)
+}
+
+// handleBrowseDirectory lists directories for the in-page folder browser.
+func handleBrowseDirectory(c *gin.Context) {
+ path := c.Query("path")
+ if path == "" {
+ home, _ := os.UserHomeDir()
+ path = home
+ }
+
+ path = filepath.Clean(path)
+ info, err := os.Stat(path)
+ if err != nil || !info.IsDir() {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Not a directory"})
+ return
+ }
+
+ entries, _ := os.ReadDir(path)
+ type dirEntry struct {
+ Name string `json:"name"`
+ Path string `json:"path"`
+ Type string `json:"type"`
+ }
+ var dirs []dirEntry
+ for _, e := range entries {
+ if strings.HasPrefix(e.Name(), ".") {
+ continue
+ }
+ if e.IsDir() {
+ dirs = append(dirs, dirEntry{
+ Name: e.Name(),
+ Path: filepath.Join(path, e.Name()),
+ Type: "directory",
+ })
+ }
+ }
+ sort.Slice(dirs, func(i, j int) bool {
+ return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name)
+ })
+
+ parent := filepath.Dir(path)
+ var parentPtr interface{} = parent
+ if parent == path {
+ parentPtr = nil
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "current": path,
+ "parent": parentPtr,
+ "entries": dirs,
+ })
+}
+
+// handleBrowseNative opens the OS-native folder picker dialog.
+func handleBrowseNative(c *gin.Context) {
+ folder, available, err := shared.OpenNativeFolderDialog()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ if !available {
+ c.JSON(http.StatusOK, gin.H{
+ "folder": nil,
+ "available": false,
+ })
+ return
+ }
+ if folder == "" {
+ c.JSON(http.StatusOK, gin.H{
+ "folder": nil,
+ "available": true,
+ "cancelled": true,
+ })
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"folder": folder, "available": true})
+}
+
+func handleThumbnail(c *gin.Context) {
+ path := c.Query("path")
+ if path == "" {
+ c.Status(http.StatusBadRequest)
+ return
+ }
+
+ var data []byte
+ var err error
+
+ if shared.IsVideoFile(path) {
+ data, err = shared.GenerateVideoThumbnail(path, shared.DefaultThumbnailSize)
+ } else {
+ data, err = shared.GenerateImageThumbnail(path, shared.DefaultThumbnailSize)
+ }
+
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "thumbnail generation failed", "detail": err.Error()})
+ return
+ }
+
+ c.Data(http.StatusOK, "image/jpeg", data)
+}
+
+func handleSystemInfo(c *gin.Context) {
+ ffmpegInfo := gin.H{
+ "installed": false,
+ "hwaccels": []string{},
+ "encoders": []string{},
+ "nvenc_available": false,
+ }
+
+ if _, err := exec.LookPath("ffmpeg"); err == nil {
+ ffmpegInfo["installed"] = true
+
+ if out, err := exec.Command("ffmpeg", "-hide_banner", "-encoders").
+ CombinedOutput(); err == nil {
+ var encoders []string
+ for _, line := range strings.Split(string(out), "\n") {
+ line = strings.TrimSpace(line)
+ if len(line) > 0 && (line[0] == 'V' || line[0] == 'A') {
+ encoders = append(encoders, line)
+ }
+ }
+ ffmpegInfo["encoders"] = encoders
+ for _, e := range encoders {
+ if strings.Contains(e, "nvenc") {
+ ffmpegInfo["nvenc_available"] = true
+ break
+ }
+ }
+ }
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "version": shared.Version,
+ "platform": runtime.GOOS,
+ "arch": runtime.GOARCH,
+ "go": runtime.Version(),
+ "cpus": runtime.NumCPU(),
+ "ffmpeg": ffmpegInfo,
+ })
+}
+
+// handleMedia serves a media file for full-size preview.
+func handleMedia(c *gin.Context) {
+ filePath := c.Query("path")
+ if filePath == "" {
+ c.Status(http.StatusBadRequest)
+ return
+ }
+
+ filePath = filepath.Clean(filePath)
+ info, err := os.Stat(filePath)
+ if err != nil || info.IsDir() {
+ c.Status(http.StatusNotFound)
+ return
+ }
+
+ ext := shared.NormaliseExt(filepath.Ext(filePath))
+ _, isImg := shared.ImageExtensions[ext]
+ _, isVid := shared.VideoExtensions[ext]
+ if !isImg && !isVid {
+ c.Status(http.StatusForbidden)
+ return
+ }
+
+ contentType := mime.TypeByExtension(filepath.Ext(filePath))
+ if contentType == "" {
+ contentType = "application/octet-stream"
+ }
+ c.File(filePath)
+}
+
+// isDir returns true when path exists and is a directory.
+func isDir(path string) bool {
+ info, err := os.Stat(path)
+ return err == nil && info.IsDir()
+}
+
+// round1 rounds f to one decimal place.
+func round1(f float64) float64 {
+ return math.Round(f*10) / 10
+}
diff --git a/web/server.go b/web/server.go
new file mode 100644
index 0000000..1645bca
--- /dev/null
+++ b/web/server.go
@@ -0,0 +1,48 @@
+package web
+
+import (
+ "embed"
+ "html/template"
+ "io/fs"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+)
+
+//go:embed templates static
+var webFS embed.FS
+
+// SetupRouter creates and configures the gin router with all routes.
+func SetupRouter() *gin.Engine {
+ r := gin.Default()
+
+ // Parse templates from embedded FS
+ tmpl := template.Must(template.ParseFS(webFS, "templates/*.html"))
+ r.SetHTMLTemplate(tmpl)
+
+ // Serve static files from embedded FS
+ staticFS, _ := fs.Sub(webFS, "static")
+ r.StaticFS("/static", http.FS(staticFS))
+
+ // No-cache middleware (mirrors Python's @app.after_request)
+ r.Use(func(c *gin.Context) {
+ c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
+ c.Header("Pragma", "no-cache")
+ c.Header("Expires", "0")
+ c.Next()
+ })
+
+ // Index route
+ r.GET("/", func(c *gin.Context) {
+ initialFolder := ""
+ c.HTML(http.StatusOK, "index.html", gin.H{"initial_folder": initialFolder})
+ })
+
+ // Register API route groups
+ registerSharedRoutes(r)
+ registerOrganizerRoutes(r)
+ registerConverterRoutes(r)
+ registerDupfinderRoutes(r)
+
+ return r
+}
diff --git a/src/morphic/frontend/static/app.js b/web/static/app.js
similarity index 66%
rename from src/morphic/frontend/static/app.js
rename to web/static/app.js
index 5261a06..6cedd60 100644
--- a/src/morphic/frontend/static/app.js
+++ b/web/static/app.js
@@ -12,13 +12,21 @@ let convScanData = null; // last scan result
let convFilterType = 'both'; // images|videos|both
let convFilterExt = null; // filter by specific extension
let convSelectedFiles = []; // files selected for conversion
-let convAvailableTargets = []; // all targets currently available for batch conversion
+let convAvailableTargets = []; // image format targets for the image dropdown
let convAv1Available = false; // AV1 support from ffmpeg
-let convLastFailedFiles = new Set(); // set of last conversion failures
+let convVideoFormats = []; // VideoContainerConfig[] from backend
+let convCodecLabels = { // codec ID → display label
+ h264: 'H.264 (AVC)', h265: 'H.265 (HEVC)',
+ av1: 'AV1', vp8: 'VP8', vp9: 'VP9',
+};
+let convFileResults = new Map(); // path → {status:'ok'|'error'|'converting', ...}
let convBatchMode = 'intersection'; // union|intersection
-let convAv1Crf = 32;
+const convAv1Crf = 35;
let convJobId = null;
let convPollTimer = null;
+let convScanning = false; // true while folder scan is in flight
+let convScanController = null; // AbortController for active scan request
+let convScanElapsedTimer = null; // interval for scan elapsed counter
let showFullPaths = false;
async function convLoadFormats() {
@@ -28,22 +36,11 @@ async function convLoadFormats() {
convBatchMode = savedMode;
}
- const savedAv1Crf = Number(localStorage.getItem('convAv1Crf'));
- if (!Number.isNaN(savedAv1Crf) && savedAv1Crf >= 10 && savedAv1Crf <= 63) {
- convAv1Crf = savedAv1Crf;
- }
-
const modeSelect = document.getElementById('convBatchMode');
if (modeSelect) {
modeSelect.value = convBatchMode;
}
- const av1CrfInput = document.getElementById('convAv1Crf');
- if (av1CrfInput) {
- av1CrfInput.value = convAv1Crf;
- document.getElementById('convAv1CrfValue').textContent = convAv1Crf;
- }
-
const resp = await fetch('/api/converter/formats');
const data = await resp.json();
@@ -57,44 +54,182 @@ async function convLoadFormats() {
convAv1Available = false;
}
- const targets = new Set();
- if (data.image) {
- Object.values(data.image).flat().forEach(t => targets.add(t));
- }
- if (data.video) {
- Object.values(data.video).flat().forEach(t => targets.add(t));
- }
+ // Parse structured video container config
+ convVideoFormats = (data.video && data.video.containers) || [];
- if (convAv1Available) {
- targets.add('.mp4-av1');
- targets.add('.mkv-av1');
- targets.add('.webm-av1');
+ // Collect image format targets for the image dropdown
+ const imagetargets = new Set();
+ if (data.image) {
+ Object.values(data.image).flat().forEach(t => imagetargets.add(t));
}
+ convAvailableTargets = [...imagetargets].sort();
- convAvailableTargets = [...targets].sort();
+ convInitBatchVideoDropdowns();
convSetBatchTargets(convAvailableTargets);
} catch (e) {
console.error('Failed to load converter formats:', e);
}
}
+function convInitBatchVideoDropdowns() {
+ const containerSel = document.getElementById('convBatchContainer');
+ if (!containerSel || convVideoFormats.length === 0) return;
+ containerSel.innerHTML = convVideoFormats.map(c =>
+ `${c.name} `
+ ).join('');
+ convOnBatchContainerChange();
+}
+
+function convOnBatchContainerChange() {
+ const containerSel = document.getElementById('convBatchContainer');
+ const codecSel = document.getElementById('convBatchCodec');
+ const extSel = document.getElementById('convBatchExt');
+ if (containerSel && codecSel && extSel) {
+ convFilterCodecExt(containerSel.value, codecSel, extSel);
+ }
+}
+
+function convFilterCodecExt(containerName, codecSel, extSel) {
+ const container = convVideoFormats.find(c => c.name === containerName);
+ if (!container) return;
+
+ const prevCodec = codecSel.value;
+ const prevExt = extSel.value;
+
+ codecSel.innerHTML = container.codecs.map(codec => {
+ const label = convCodecLabels[codec] || codec.toUpperCase();
+ const disabled = codec === 'av1' && !convAv1Available;
+ return `${label}${disabled ? ' (unavailable)' : ''} `;
+ }).join('');
+
+ if (container.codecs.includes(prevCodec) && !(prevCodec === 'av1' && !convAv1Available)) {
+ codecSel.value = prevCodec;
+ }
+
+ extSel.innerHTML = container.extensions.map(ext =>
+ `${ext} `
+ ).join('');
+
+ if (container.extensions.includes(prevExt)) {
+ extSel.value = prevExt;
+ }
+}
+
+function convGetSelectedByType() {
+ if (!convScanData || !convScanData.files) return { videos: [], images: [] };
+ const selectedSet = new Set(convSelectedFiles);
+ const videos = [], images = [];
+ for (const f of convScanData.files) {
+ if (!selectedSet.has(f.path)) continue;
+ if (f.type === 'video') videos.push(f.path);
+ else images.push(f.path);
+ }
+ return { videos, images };
+}
+
+function convUpdateBatchDropdowns() {
+ const { videos, images } = convGetSelectedByType();
+ const videoDiv = document.getElementById('convVideoDropdowns');
+ const imageDiv = document.getElementById('convImageDropdown');
+
+ if (videoDiv) videoDiv.style.display = videos.length > 0 ? 'flex' : 'none';
+ if (imageDiv) imageDiv.style.display = images.length > 0 ? 'flex' : 'none';
+
+ if (images.length > 0) {
+ const targets = convGetBatchTargets();
+ convSetBatchTargets(targets);
+ }
+
+ const batchBtn = document.getElementById('convBatchBtn');
+ if (batchBtn) {
+ batchBtn.disabled = videos.length === 0 && images.length === 0;
+ }
+}
+
+function convOnRowContainerChange(containerSel) {
+ const group = containerSel.closest('.conv-video-format-group');
+ const codecSel = group.querySelector('.conv-vid-codec');
+ const extSel = group.querySelector('.conv-vid-ext');
+ convFilterCodecExt(containerSel.value, codecSel, extSel);
+}
+
+function convBuildVideoSelectsHtml() {
+ if (convVideoFormats.length === 0) {
+ return `Loading… `;
+ }
+ const first = convVideoFormats[0];
+ const selStyle = 'font-size:12px;padding:3px 6px;background:var(--surface2);color:var(--text);border:1px solid var(--border);border-radius:4px;';
+ const containerOpts = convVideoFormats.map(c =>
+ `${c.name} `
+ ).join('');
+ const codecOpts = first.codecs.map(codec => {
+ const label = convCodecLabels[codec] || codec.toUpperCase();
+ const disabled = codec === 'av1' && !convAv1Available;
+ return `${label}${disabled ? ' (N/A)' : ''} `;
+ }).join('');
+ const extOpts = first.extensions.map(ext => `${ext} `).join('');
+ return `
+ ${containerOpts}
+ ${codecOpts}
+ ${extOpts}
+
`;
+}
+
+// Thumbnail lazy-loading helper
+let lazyThumbnailObserver = null;
+
+function initLazyThumbnailObserver() {
+ if (lazyThumbnailObserver) {
+ return;
+ }
+ if (!('IntersectionObserver' in window)) {
+ return;
+ }
+
+ lazyThumbnailObserver = new IntersectionObserver((entries) => {
+ for (const entry of entries) {
+ if (!entry.isIntersecting) {
+ continue;
+ }
+ const img = entry.target;
+ const dataSrc = img.dataset.src;
+ if (dataSrc) {
+ img.src = dataSrc;
+ img.removeAttribute('data-src');
+ }
+ lazyThumbnailObserver.unobserve(img);
+ }
+ }, {
+ rootMargin: '400px',
+ threshold: 0.01,
+ });
+}
+
+function observeThumbnails(container) {
+ if (!lazyThumbnailObserver) {
+ initLazyThumbnailObserver();
+ }
+ if (!lazyThumbnailObserver) {
+ return;
+ }
+ const images = container.querySelectorAll('img[data-src]');
+ images.forEach(img => lazyThumbnailObserver.observe(img));
+}
+
// Dupfinder state
let dupJobId = null;
let dupPollTimer = null;
let dupAllGroups = [];
let dupSelectedFiles = new Set();
-
-// Inspector state
-let inspJobId = null;
-let inspPollTimer = null;
-
-// Resizer state
-let resJobId = null;
-let resPollTimer = null;
+let dupRunning = false;
// Organizer state
let orgJobId = null;
let orgPollTimer = null;
+let orgRunning = false;
+
+// Converter convert state
+let convConvertJobId = null;
// =====================================================================
// Tabs
@@ -116,7 +251,6 @@ function switchTab(tab) {
const _browserPrefixes = {
converter: 'conv', dupfinder: 'dup',
- inspector: 'insp', resizer: 'res',
organizer: 'org',
};
@@ -159,7 +293,7 @@ function _loadLastFolder() {
function loadFolderPreferences() {
lastSelectedFolder = _loadLastFolder();
- const tabs = ['converter', 'dupfinder', 'inspector', 'resizer', 'organizer'];
+ const tabs = ['converter', 'dupfinder', 'organizer'];
for (const tab of tabs) {
const input = document.getElementById(_bp(tab) + 'Folder');
if (!input) continue;
@@ -220,6 +354,11 @@ async function openNativeFolderExplorer(tab) {
});
const data = await resp.json();
+ if (data.available === false) {
+ // No native dialog tool — fall back to in-page browser
+ browseTo(initialDir, tab);
+ return;
+ }
if (data.folder) {
if (input) {
input.value = data.folder;
@@ -227,7 +366,7 @@ async function openNativeFolderExplorer(tab) {
}
showToast('Folder selected: ' + data.folder, 'success');
} else {
- showToast(data.message || 'Native folder dialog was canceled', 'info');
+ showToast('Native folder dialog was cancelled', 'info');
}
} catch (e) {
showToast('Native folder open failed: ' + e.message, 'error');
@@ -287,15 +426,22 @@ async function browseTo(path, tab) {
// =====================================================================
async function convScan() {
+ // Toggle: if scan already running, abort it
+ if (convScanning) {
+ if (convScanController) convScanController.abort();
+ return;
+ }
+
const folder = document.getElementById('convFolder').value.trim();
if (!folder) { showToast('Enter a folder path', 'error'); return; }
_storeFolder('converter', folder);
- convLastFailedFiles = new Set();
+ convFileResults = new Map();
const includeSubfolders = document.getElementById('convSubfolders').checked;
const filterType = document.getElementById('convFilterType').value;
- document.getElementById('convScanBtn').disabled = true;
+ convScanController = new AbortController();
+ convSetScanStopMode();
document.getElementById('convResults').style.display = 'none';
try {
@@ -305,22 +451,67 @@ async function convScan() {
body: JSON.stringify({
folder, include_subfolders: includeSubfolders, filter_type: filterType,
}),
+ signal: convScanController.signal,
});
const data = await resp.json();
- if (data.error) { showToast(data.error, 'error'); return; }
+ if (data.error) { showToast(data.error, 'error'); convRestoreScanBtn(); return; }
convScanData = data;
convFilterType = filterType;
convFilterExt = null;
convSelectedFiles = [];
+ convRestoreScanBtn();
renderConvResults();
} catch (e) {
- showToast('Scan failed: ' + e.message, 'error');
- } finally {
- document.getElementById('convScanBtn').disabled = false;
+ if (e.name === 'AbortError') {
+ convShowScanInterrupted();
+ } else {
+ showToast('Scan failed: ' + e.message, 'error');
+ }
+ convRestoreScanBtn();
}
}
+function convSetScanStopMode() {
+ convScanning = true;
+ const btn = document.getElementById('convScanBtn');
+ btn.textContent = '⏹ Stop Scan';
+ btn.classList.remove('btn-primary');
+ btn.classList.add('btn-stop');
+ btn.disabled = false;
+
+ // Show scan progress panel with elapsed timer
+ let elapsed = 0;
+ document.getElementById('convScanElapsed').textContent = '0s';
+ document.getElementById('convScanProgressMsg').textContent = 'Walking folder tree...';
+ document.getElementById('convScanProgress').classList.add('active');
+ convScanElapsedTimer = setInterval(() => {
+ elapsed++;
+ document.getElementById('convScanElapsed').textContent = formatDuration(elapsed);
+ }, 1000);
+}
+
+function convRestoreScanBtn() {
+ convScanning = false;
+ convScanController = null;
+ clearInterval(convScanElapsedTimer);
+ convScanElapsedTimer = null;
+ document.getElementById('convScanProgress').classList.remove('active');
+ const btn = document.getElementById('convScanBtn');
+ btn.disabled = false;
+ btn.textContent = '🔍 Scan Folder';
+ btn.classList.remove('btn-stop');
+ btn.classList.add('btn-primary');
+}
+
+function convShowScanInterrupted() {
+ document.getElementById('convResults').style.display = 'block';
+ document.getElementById('convSummary').innerHTML =
+ '⏹ Scan was interrupted — no results to display.
';
+ document.getElementById('convFileTable').innerHTML = '';
+ document.getElementById('convBulkBar').style.display = 'none';
+}
+
function renderConvResults() {
if (!convScanData) return;
const section = document.getElementById('convResults');
@@ -363,6 +554,7 @@ function renderConvResults() {
File
Type
Size
+ Result
Convert to
`;
@@ -370,35 +562,45 @@ function renderConvResults() {
for (const f of files) {
const thumbUrl = `/api/thumbnail?path=${encodeURIComponent(f.path)}`;
const displayName = showFullPaths ? f.path : f.name;
- const failed = convLastFailedFiles.has(f.path);
+ const result = convFileResults.get(f.path);
+ const hasError = result?.status === 'error';
+
+ let resultCell = ' ';
+ if (result) {
+ if (result.status === 'converting') {
+ resultCell = 'Converting… ';
+ } else if (result.status === 'ok') {
+ const pct = result.original_size > 0
+ ? Math.round((1 - result.new_size / result.original_size) * 100) : 0;
+ const sign = pct >= 0 ? '−' : '+';
+ resultCell = `✓ ${result.original_size_fmt} → ${result.new_size_fmt} (${sign}${Math.abs(pct)}%) `;
+ } else if (result.status === 'error') {
+ const short = escapeHtml((result.error || 'unknown').slice(0, 80));
+ resultCell = `✗ ${short} `;
+ }
+ }
- thtml += `
-
+ thtml += `
+
${escapeHtml(displayName)}
- ${failed ? 'Failed ' : ''}
${f.ext}
${formatBytes(f.size)}
+ ${resultCell}
-
- ${[...new Set([...f.targets, ...(f.type === 'video' ? ['.mp4-av1', '.mkv-av1', '.webm-av1'] : [])])]
- .map(t => {
- const isAv1 = t.endsWith('-av1');
- const disabled = isAv1 && !convAv1Available;
- const label = isAv1 && !convAv1Available
- ? `${t} (AV1 unavailable)`
- : t;
- return `${label} `;
- }).join('')}
-
+ ${f.type === 'video'
+ ? convBuildVideoSelectsHtml()
+ : `
+ ${(f.targets || []).map(t => `${t} `).join('')}
+ `}
Convert
- ${failed ? `Retry ` : ''}
+ ${hasError ? `Retry ` : ''}
✕
`;
@@ -406,11 +608,13 @@ function renderConvResults() {
thtml += ' ';
table.innerHTML = thtml;
- convUpdateBatchTargets();
+ observeThumbnails(table);
+ convUpdateBatchDropdowns();
}
function convSetBatchTargets(targets) {
const select = document.getElementById('convBatchTarget');
+ if (!select) return;
const prevValue = select.value;
const targetArray = Array.isArray(targets) ? targets.filter(Boolean) : [];
@@ -421,7 +625,7 @@ function convSetBatchTargets(targets) {
select.disabled = true;
const opt = document.createElement('option');
opt.value = '';
- opt.textContent = 'No compatible target formats';
+ opt.textContent = 'No compatible image formats';
opt.disabled = true;
select.appendChild(opt);
return;
@@ -446,73 +650,49 @@ function convGetBatchTargets() {
const selectedFilePaths = new Set(convSelectedFiles || []);
const modeSelect = document.getElementById('convBatchMode');
if (modeSelect) {
- convBatchMode = modeSelect.value || 'union';
+ convBatchMode = modeSelect.value || 'intersection';
localStorage.setItem('convBatchMode', convBatchMode);
}
- const av1CrfInput = document.getElementById('convAv1Crf');
- if (av1CrfInput) {
- const val = Number(av1CrfInput.value);
- if (!Number.isNaN(val) && val >= 10 && val <= 63) {
- convAv1Crf = val;
- localStorage.setItem('convAv1Crf', convAv1Crf);
- const av1CrfValue = document.getElementById('convAv1CrfValue');
- if (av1CrfValue) {
- av1CrfValue.textContent = String(convAv1Crf);
- }
- }
- }
-
const modeHint = document.getElementById('convBatchModeHint');
if (modeHint) {
modeHint.textContent = convBatchMode === 'intersection'
- ? 'Intersection = formats supported by each selected file; Union = any selected file'
- : 'Union = formats supported by at least one selected file; Intersection = common to all';
+ ? 'Image formats: common to all selected images'
+ : 'Image formats: supported by any selected image';
}
- // If no scan data yet, show global format list from converter/formats.
+ // Only image files contribute to the image format dropdown
if (!convScanData || !convScanData.files || convScanData.files.length === 0) {
return [...new Set(convAvailableTargets)].sort();
}
- const files = convScanData.files.filter(f => selectedFilePaths.size === 0 || selectedFilePaths.has(f.path));
- if (files.length === 0) {
+ const imageFiles = convScanData.files.filter(f =>
+ f.type === 'image' && (selectedFilePaths.size === 0 || selectedFilePaths.has(f.path))
+ );
+
+ if (imageFiles.length === 0) {
return [...new Set(convAvailableTargets)].sort();
}
- const hasVideo = files.some(f => f.type === 'video');
- const fileTargets = files.map(f => new Set((f.targets || []).map(t => t.toLowerCase())));
+ const fileTargets = imageFiles.map(f => new Set((f.targets || []).map(t => t.toLowerCase())));
if (convBatchMode === 'intersection') {
let intersection = new Set(fileTargets[0]);
for (let i = 1; i < fileTargets.length; i++) {
intersection = new Set([...intersection].filter(t => fileTargets[i].has(t)));
}
- if (hasVideo) {
- ['.mp4-av1', '.mkv-av1', '.webm-av1'].forEach(v => intersection.add(v));
- }
return [...intersection].sort();
}
- // union
const union = new Set();
for (const targetSet of fileTargets) {
for (const t of targetSet) union.add(t);
}
- if (hasVideo) {
- ['.mp4-av1', '.mkv-av1', '.webm-av1'].forEach(v => union.add(v));
- }
return [...union].sort();
}
function convUpdateBatchTargets() {
- const targets = convGetBatchTargets();
- convSetBatchTargets(targets);
-
- const batchBtn = document.getElementById('convBatchBtn');
- if (batchBtn) {
- batchBtn.disabled = !targets || targets.length === 0;
- }
+ convUpdateBatchDropdowns();
}
function convSetExtFilter(ext) {
@@ -539,7 +719,7 @@ function convUpdateSelection() {
bar.style.display = 'none';
}
- convUpdateBatchTargets();
+ convUpdateBatchDropdowns();
}
function toggleFullPaths() {
@@ -553,30 +733,41 @@ function toggleFullPaths() {
async function convConvertSingle(filePath, btnEl) {
const row = btnEl.closest('tr');
- const targetSelect = row.querySelector('.conv-target');
- const targetExt = targetSelect.value;
const deleteOrig = document.getElementById('convDeleteOrig').checked;
+ let targetExt, codec;
+ const videoContainer = row.querySelector('.conv-vid-container');
+ if (videoContainer) {
+ targetExt = row.querySelector('.conv-vid-ext').value;
+ codec = row.querySelector('.conv-vid-codec').value;
+ } else {
+ targetExt = row.querySelector('.conv-target').value;
+ }
+
btnEl.disabled = true;
btnEl.textContent = '...';
try {
+ const body = {
+ files: [filePath],
+ target_ext: targetExt,
+ delete_original: deleteOrig,
+ av1_crf: convAv1Crf,
+ };
+ if (codec) body.codec = codec;
+
const resp = await fetch('/api/converter/convert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- files: [filePath],
- target_ext: targetExt,
- delete_original: deleteOrig,
- av1_crf: convAv1Crf,
- }),
+ body: JSON.stringify(body),
});
const data = await resp.json();
if (data.job_id) {
await convWaitForJob(data.job_id);
}
} catch (e) {
- showToast('Convert failed: ' + e.message, 'error');
+ convFileResults.set(filePath, { status: 'error', error: e.message });
+ renderConvResults();
} finally {
btnEl.disabled = false;
btnEl.textContent = 'Convert';
@@ -585,64 +776,144 @@ async function convConvertSingle(filePath, btnEl) {
async function convConvertBatch() {
if (convSelectedFiles.length === 0) return;
- const targetExt = document.getElementById('convBatchTarget').value;
- if (!targetExt) {
- showToast('No valid target format available for selected files', 'error');
- return;
- }
+ const { videos, images } = convGetSelectedByType();
const deleteOrig = document.getElementById('convDeleteOrig').checked;
document.getElementById('convBatchBtn').disabled = true;
try {
- const resp = await fetch('/api/converter/convert', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- files: convSelectedFiles,
- target_ext: targetExt,
- delete_original: deleteOrig,
- av1_crf: convAv1Crf,
- }),
- });
- const data = await resp.json();
- if (data.job_id) {
- convShowProgress();
- convPollProgress(data.job_id);
+ if (videos.length > 0) {
+ const targetExt = document.getElementById('convBatchExt').value;
+ const codec = document.getElementById('convBatchCodec').value;
+ if (!targetExt || !codec) {
+ showToast('Please select a video format', 'error');
+ return;
+ }
+ const resp = await fetch('/api/converter/convert', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ files: videos,
+ target_ext: targetExt,
+ codec,
+ delete_original: deleteOrig,
+ av1_crf: convAv1Crf,
+ }),
+ });
+ const data = await resp.json();
+ if (data.job_id) {
+ convConvertJobId = data.job_id;
+ convShowProgress();
+ await convPollProgress(data.job_id);
+ }
+ }
+
+ if (images.length > 0) {
+ const targetExt = document.getElementById('convBatchTarget').value;
+ if (!targetExt) {
+ showToast('No valid image target format selected', 'error');
+ } else {
+ const resp = await fetch('/api/converter/convert', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ files: images,
+ target_ext: targetExt,
+ delete_original: deleteOrig,
+ }),
+ });
+ const data = await resp.json();
+ if (data.job_id) {
+ convConvertJobId = data.job_id;
+ convShowProgress();
+ await convPollProgress(data.job_id);
+ }
+ }
}
} catch (e) {
showToast('Batch convert failed: ' + e.message, 'error');
+ } finally {
document.getElementById('convBatchBtn').disabled = false;
}
}
function convShowProgress() {
+ const stopBtn = document.getElementById('convStopBtn');
+ if (stopBtn) { stopBtn.disabled = false; stopBtn.textContent = '\u23f9 Stop'; }
document.getElementById('convProgress').classList.add('active');
}
function convPollProgress(jobId) {
- let lastCompleted = -1;
- convPollTimer = setInterval(async () => {
- try {
- const resp = await fetch(`/api/converter/progress/${jobId}/poll?last=${lastCompleted}`);
- const data = await resp.json();
- if (data.error) return;
+ return new Promise(resolve => {
+ let lastCompleted = -1;
+ convPollTimer = setInterval(async () => {
+ try {
+ const resp = await fetch(`/api/converter/progress/${jobId}/poll?last=${lastCompleted}`);
+ const data = await resp.json();
+ if (data.error) return;
+
+ lastCompleted = data.completed;
+ const pct = data.total > 0 ? Math.round((data.completed / data.total) * 100) : 0;
+ document.getElementById('convProgressBar').style.width = pct + '%';
+ document.getElementById('convProgressPct').textContent = pct + '%';
+ document.getElementById('convProgressMsg').textContent =
+ data.current_file ? `Converting: ${data.current_file}` : 'Processing...';
+
+ _convSyncResults(data);
+
+ if (data.status === 'done') {
+ clearInterval(convPollTimer);
+ document.getElementById('convProgress').classList.remove('active');
+ convConvertJobId = null;
+ resolve('done');
+ } else if (data.status === 'cancelled') {
+ clearInterval(convPollTimer);
+ document.getElementById('convProgress').classList.remove('active');
+ convConvertJobId = null;
+ showToast('Conversion was stopped', 'warning');
+ resolve('cancelled');
+ }
+ } catch (e) { /* retry */ }
+ }, 500);
+ });
+}
- lastCompleted = data.completed;
- const pct = data.total > 0 ? Math.round((data.completed / data.total) * 100) : 0;
- document.getElementById('convProgressBar').style.width = pct + '%';
- document.getElementById('convProgressPct').textContent = pct + '%';
- document.getElementById('convProgressMsg').textContent =
- data.current_file ? `Converting: ${data.current_file}` : 'Processing...';
+// Sync convFileResults from a job poll/progress response and re-render the table.
+function _convSyncResults(data) {
+ for (const r of data.results || []) {
+ const existing = convFileResults.get(r.source);
+ if (existing && existing.status !== 'converting') continue; // already finalised
+ if (r.status === 'ok') {
+ convFileResults.set(r.source, {
+ status: 'ok',
+ original_size_fmt: r.original_size_fmt,
+ new_size_fmt: r.new_size_fmt,
+ original_size: r.original_size,
+ new_size: r.new_size,
+ });
+ } else {
+ convFileResults.set(r.source, { status: 'error', error: r.error || 'unknown' });
+ }
+ }
+ // Keep at most one 'converting' marker (the current file)
+ for (const [path, res] of convFileResults) {
+ if (res.status === 'converting') convFileResults.delete(path);
+ }
+ if (data.current_file && !convFileResults.has(data.current_file)) {
+ convFileResults.set(data.current_file, { status: 'converting' });
+ }
+ renderConvResults();
+}
- if (data.status === 'done') {
- clearInterval(convPollTimer);
- convShowConversionResults(data);
- document.getElementById('convProgress').classList.remove('active');
- document.getElementById('convBatchBtn').disabled = false;
- }
- } catch (e) { /* retry */ }
- }, 500);
+async function convStopConvert() {
+ if (!convConvertJobId) return;
+ const btn = document.getElementById('convStopBtn');
+ btn.disabled = true;
+ btn.textContent = 'Stopping…';
+ try {
+ await fetch(`/api/converter/progress/${convConvertJobId}/cancel`, { method: 'POST' });
+ } catch (e) { /* ignore */ }
+ // Poll loop will detect 'cancelled'
}
async function convWaitForJob(jobId) {
@@ -653,12 +924,15 @@ async function convWaitForJob(jobId) {
if (data.status === 'done') {
const r = data.results[0];
if (r.status === 'ok') {
- const sizeInfo = r.original_size_fmt + ' → ' + r.new_size_fmt;
- showToast(`Converted! (${sizeInfo})`, 'success');
- convLastFailedFiles.delete(r.source);
+ convFileResults.set(r.source, {
+ status: 'ok',
+ original_size_fmt: r.original_size_fmt,
+ new_size_fmt: r.new_size_fmt,
+ original_size: r.original_size,
+ new_size: r.new_size,
+ });
} else {
- showToast('Error: ' + (r.error || 'unknown'), 'error');
- convLastFailedFiles.add(r.source);
+ convFileResults.set(r.source, { status: 'error', error: r.error || 'unknown' });
}
renderConvResults();
return;
@@ -669,10 +943,17 @@ async function convWaitForJob(jobId) {
async function convRetrySingle(filePath, btnEl) {
const row = btnEl.closest('tr');
- const targetSelect = row.querySelector('.conv-target');
- const targetExt = targetSelect?.value;
const deleteOrig = document.getElementById('convDeleteOrig').checked;
+ let targetExt, codec;
+ const videoContainer = row.querySelector('.conv-vid-container');
+ if (videoContainer) {
+ targetExt = row.querySelector('.conv-vid-ext').value;
+ codec = row.querySelector('.conv-vid-codec').value;
+ } else {
+ targetExt = row.querySelector('.conv-target')?.value;
+ }
+
if (!targetExt) {
showToast('No target selected for retry', 'error');
return;
@@ -682,17 +963,21 @@ async function convRetrySingle(filePath, btnEl) {
btnEl.textContent = 'Retrying...';
try {
+ const body = { files: [filePath], target_ext: targetExt, delete_original: deleteOrig, av1_crf: convAv1Crf };
+ if (codec) body.codec = codec;
+
const resp = await fetch('/api/converter/convert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ files: [filePath], target_ext: targetExt, delete_original: deleteOrig, av1_crf: convAv1Crf }),
+ body: JSON.stringify(body),
});
const data = await resp.json();
if (data.job_id) {
await convWaitForJob(data.job_id);
}
} catch (e) {
- showToast('Retry failed: ' + e.message, 'error');
+ convFileResults.set(filePath, { status: 'error', error: e.message });
+ renderConvResults();
} finally {
btnEl.disabled = false;
btnEl.textContent = 'Retry';
@@ -700,29 +985,8 @@ async function convRetrySingle(filePath, btnEl) {
}
function convShowConversionResults(data) {
- let ok = 0, fail = 0;
- const failedFiles = [];
- convLastFailedFiles = new Set();
-
- for (const r of data.results) {
- if (r.status === 'ok') {
- ok++;
- } else {
- fail++;
- failedFiles.push(`${r.source} (${r.error || 'unknown error'})`);
- }
- }
-
- if (fail > 0) {
- const feed = failedFiles.slice(0, 5).join(', ');
- const rest = failedFiles.length > 5 ? `, +${failedFiles.length - 5} more` : '';
- showToast(`Done! ${ok} converted, ${fail} failed: ${feed}${rest}`, 'error');
- } else {
- showToast(`Done! ${ok} converted`, 'success');
- }
-
- // Refresh list with failure markers
- renderConvResults();
+ // results are already synced live via _convSyncResults during polling;
+ // this is kept as a no-op hook for any future post-completion logic.
}
async function convDeleteSingle(filePath) {
@@ -800,6 +1064,12 @@ function copyPath(path) {
// =====================================================================
async function dupStartScan() {
+ // If a scan is already running, act as stop
+ if (dupRunning) {
+ await dupStopScan();
+ return;
+ }
+
const folder = document.getElementById('dupFolder').value.trim();
if (!folder) { showToast('Enter a folder path', 'error'); return; }
_storeFolder('dupfinder', folder);
@@ -808,7 +1078,7 @@ async function dupStartScan() {
const imgThreshold = parseInt(document.getElementById('dupImgThreshold').value) / 100;
const vidThreshold = parseInt(document.getElementById('dupVidThreshold').value) / 100;
- document.getElementById('dupScanBtn').disabled = true;
+ dupSetStopMode();
document.getElementById('dupResults').classList.remove('active');
document.getElementById('dupProgress').classList.add('active');
document.getElementById('dupProgressBar').style.width = '0%';
@@ -828,17 +1098,56 @@ async function dupStartScan() {
const data = await resp.json();
if (data.error) {
showToast(data.error, 'error');
- dupResetUI();
+ dupRestoreBtn();
return;
}
dupJobId = data.job_id;
dupPollProgress();
} catch (e) {
showToast('Scan failed: ' + e.message, 'error');
- dupResetUI();
+ dupRestoreBtn();
}
}
+async function dupStopScan() {
+ if (!dupJobId) { dupRestoreBtn(); return; }
+ const btn = document.getElementById('dupScanBtn');
+ btn.disabled = true;
+ btn.textContent = 'Stopping…';
+ try {
+ await fetch(`/api/dupfinder/scan/${dupJobId}/cancel`, { method: 'POST' });
+ } catch (e) { /* ignore */ }
+ // Poll loop will detect 'cancelled' and call dupRestoreBtn
+}
+
+function dupSetStopMode() {
+ dupRunning = true;
+ const btn = document.getElementById('dupScanBtn');
+ btn.textContent = '⏹ Stop Scan';
+ btn.classList.remove('btn-primary');
+ btn.classList.add('btn-stop');
+ btn.disabled = false;
+}
+
+function dupRestoreBtn() {
+ dupRunning = false;
+ dupJobId = null;
+ const btn = document.getElementById('dupScanBtn');
+ btn.disabled = false;
+ btn.textContent = '🔍 Start Scan';
+ btn.classList.remove('btn-stop');
+ btn.classList.add('btn-primary');
+}
+
+function dupShowInterrupted() {
+ document.getElementById('dupResults').classList.add('active');
+ document.getElementById('dupGroups').innerHTML = '';
+ document.getElementById('dupNoResults').style.display = 'none';
+ document.getElementById('dupTitle').textContent = '';
+ document.getElementById('dupSummary').innerHTML =
+ '⏹ Scan was interrupted — no results to display.
';
+}
+
function dupPollProgress() {
if (!dupJobId) return;
dupPollTimer = setInterval(async () => {
@@ -855,10 +1164,16 @@ function dupPollProgress() {
if (data.status === 'done') {
clearInterval(dupPollTimer);
await dupLoadResults();
- } else if (data.status === 'error') {
+ } else if (data.status === 'cancelled') {
+ clearInterval(dupPollTimer);
+ document.getElementById('dupProgress').classList.remove('active');
+ dupShowInterrupted();
+ dupRestoreBtn();
+ } else if (data.status === 'failed') {
clearInterval(dupPollTimer);
showToast('Scan failed: ' + (data.error || 'Unknown'), 'error');
- dupResetUI();
+ document.getElementById('dupProgress').classList.remove('active');
+ dupRestoreBtn();
}
} catch (e) { /* retry */ }
}, 500);
@@ -877,18 +1192,18 @@ async function dupLoadResults() {
dupRenderResults(data);
document.getElementById('dupProgress').classList.remove('active');
document.getElementById('dupResults').classList.add('active');
- dupResetUI();
+ dupRestoreBtn();
document.getElementById('dupNoResults').style.display =
dupAllGroups.length === 0 ? 'block' : 'none';
} catch (e) {
showToast('Failed to load results', 'error');
- dupResetUI();
+ dupRestoreBtn();
}
}
function dupResetUI() {
- document.getElementById('dupScanBtn').disabled = false;
+ dupRestoreBtn();
}
// =====================================================================
@@ -913,6 +1228,7 @@ function dupRenderResults(data) {
for (const group of dupAllGroups) {
container.appendChild(dupCreateGroupCard(group));
}
+ observeThumbnails(container);
}
function dupCreateGroupCard(group) {
@@ -948,7 +1264,7 @@ function dupCreateFileCard(item, isBest) {
if (item.type === 'video') {
thumb = ` `;
} else {
- thumb = ` `;
+ thumb = ` `;
}
const badges = [];
@@ -1145,255 +1461,6 @@ async function dupExecuteDelete() {
}
}
-// =====================================================================
-// Inspector Tab
-// =====================================================================
-
-async function inspStartScan() {
- const folder = document.getElementById('inspFolder').value.trim();
- if (!folder) { showToast('Enter a folder path', 'error'); return; }
- _storeFolder('inspector', folder);
- const mode = document.getElementById('inspMode').value;
-
- document.getElementById('inspScanBtn').disabled = true;
- document.getElementById('inspResults').style.display = 'none';
- document.getElementById('inspProgress').style.display = 'block';
- document.getElementById('inspProgressBar').style.width = '0%';
- document.getElementById('inspProgressPct').textContent = '0%';
- document.getElementById('inspProgressMsg').textContent = 'Starting...';
-
- try {
- const resp = await fetch('/api/inspector/scan', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ folder, mode }),
- });
- const data = await resp.json();
- if (data.error) { showToast(data.error, 'error'); return; }
- inspJobId = data.job_id;
- inspPollTimer = setInterval(inspPollProgress, 800);
- } catch (e) {
- showToast('Scan failed: ' + e.message, 'error');
- document.getElementById('inspProgress').style.display = 'none';
- } finally {
- document.getElementById('inspScanBtn').disabled = false;
- }
-}
-
-async function inspPollProgress() {
- try {
- const resp = await fetch(`/api/inspector/scan/${inspJobId}/status`);
- const data = await resp.json();
- const total = data.total_files || 0;
- const processed = data.processed_files || 0;
- const pct = total > 0 ? Math.round((processed / total) * 100) : 0;
- document.getElementById('inspProgressBar').style.width = pct + '%';
- document.getElementById('inspProgressPct').textContent = pct + '%';
- document.getElementById('inspProgressMsg').textContent = `${processed} / ${total} files`;
-
- if (data.status === 'done' || data.status === 'error') {
- clearInterval(inspPollTimer);
- document.getElementById('inspProgress').style.display = 'none';
- if (data.status === 'error') {
- showToast('Scan error: ' + (data.error || 'Unknown'), 'error');
- } else {
- inspLoadResults();
- }
- }
- } catch (e) {
- clearInterval(inspPollTimer);
- showToast('Poll error: ' + e.message, 'error');
- }
-}
-
-async function inspLoadResults() {
- try {
- const resp = await fetch(`/api/inspector/scan/${inspJobId}/results`);
- const data = await resp.json();
- const container = document.getElementById('inspResultsContent');
- const section = document.getElementById('inspResults');
- section.style.display = 'block';
-
- if (!data.results || data.results.length === 0) {
- container.innerHTML = '';
- return;
- }
-
- const mode = document.getElementById('inspMode').value;
- if (mode === 'exif') {
- inspRenderExif(data.results, container);
- } else {
- inspRenderIntegrity(data.results, container);
- }
- } catch (e) {
- showToast('Failed to load results: ' + e.message, 'error');
- }
-}
-
-function inspRenderExif(results, container) {
- let html = `📊 EXIF Results — ${results.length} file(s)
`;
- for (const item of results) {
- const fname = item.file.split('/').pop();
- const exif = item.exif || {};
- const keys = Object.keys(exif).filter(k => !k.startsWith('_'));
- html += `
-
- 📄 ${escapeHtml(fname)} ${keys.length} tags
-
-
-
- 🧹 Strip EXIF
-
-
`;
- for (const k of keys) {
- const val = typeof exif[k] === 'object' ? JSON.stringify(exif[k]) : String(exif[k]);
- html += `${escapeHtml(k)} ${escapeHtml(val.substring(0, 200))} `;
- }
- if (exif._gps_lat && exif._gps_lng) {
- html += `📍 GPS ${exif._gps_lat.toFixed(6)}, ${exif._gps_lng.toFixed(6)} `;
- }
- html += `
`;
- }
- container.innerHTML = html;
-}
-
-function inspRenderIntegrity(results, container) {
- let ok = 0, bad = 0;
- for (const r of results) { if (r.ok) ok++; else bad++; }
-
- let html = ``;
-
- if (bad > 0) {
- html += '⚠️ Failed Files
';
- for (const r of results.filter(x => !x.ok)) {
- html += `
${escapeHtml(r.file.split('/').pop())} ${escapeHtml(r.error || 'Unknown error')}
`;
- }
- html += '
';
- }
- container.innerHTML = html;
-}
-
-async function inspStripOne(filePath) {
- try {
- const resp = await fetch('/api/inspector/exif/strip', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ files: [filePath] }),
- });
- const data = await resp.json();
- if (data.error) { showToast(data.error, 'error'); return; }
- showToast('EXIF stripped from 1 file', 'success');
- } catch (e) {
- showToast('Strip failed: ' + e.message, 'error');
- }
-}
-
-// =====================================================================
-// Resizer Tab
-// =====================================================================
-
-async function resStartResize() {
- const folder = document.getElementById('resFolder').value.trim();
- if (!folder) { showToast('Enter a folder path', 'error'); return; }
- _storeFolder('resizer', folder);
-
- const width = parseInt(document.getElementById('resWidth').value) || 1920;
- const height = parseInt(document.getElementById('resHeight').value) || 1080;
- const mode = document.getElementById('resMode').value;
- const quality = parseInt(document.getElementById('resQuality').value) || 90;
- const bgColor = document.getElementById('resBgColor').value;
- const outputFolder = document.getElementById('resOutput').value.trim() || null;
-
- document.getElementById('resScanBtn').disabled = true;
- document.getElementById('resResults').style.display = 'none';
- document.getElementById('resProgress').style.display = 'block';
- document.getElementById('resProgressBar').style.width = '0%';
- document.getElementById('resProgressPct').textContent = '0%';
- document.getElementById('resProgressMsg').textContent = 'Starting...';
-
- try {
- const resp = await fetch('/api/resizer/scan', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ folder, width, height, mode, quality, bg_color: bgColor, output_folder: outputFolder }),
- });
- const data = await resp.json();
- if (data.error) { showToast(data.error, 'error'); return; }
- resJobId = data.job_id;
- resPollTimer = setInterval(resPollProgress, 800);
- } catch (e) {
- showToast('Resize failed: ' + e.message, 'error');
- document.getElementById('resProgress').style.display = 'none';
- } finally {
- document.getElementById('resScanBtn').disabled = false;
- }
-}
-
-async function resPollProgress() {
- try {
- const resp = await fetch(`/api/resizer/scan/${resJobId}/status`);
- const data = await resp.json();
- const total = data.total_files || 0;
- const processed = data.processed_files || 0;
- const pct = total > 0 ? Math.round((processed / total) * 100) : 0;
- document.getElementById('resProgressBar').style.width = pct + '%';
- document.getElementById('resProgressPct').textContent = pct + '%';
- document.getElementById('resProgressMsg').textContent = `${processed} / ${total} images`;
-
- if (data.status === 'done' || data.status === 'error') {
- clearInterval(resPollTimer);
- document.getElementById('resProgress').style.display = 'none';
- if (data.status === 'error') {
- showToast('Resize error: ' + (data.error || 'Unknown'), 'error');
- } else {
- resLoadResults();
- }
- }
- } catch (e) {
- clearInterval(resPollTimer);
- showToast('Poll error: ' + e.message, 'error');
- }
-}
-
-async function resLoadResults() {
- try {
- const resp = await fetch(`/api/resizer/scan/${resJobId}/results`);
- const data = await resp.json();
- const container = document.getElementById('resResultsContent');
- const section = document.getElementById('resResults');
- section.style.display = 'block';
-
- const results = data.results || [];
- let ok = 0, errors = 0;
- for (const r of results) { if (r.status === 'ok') ok++; else errors++; }
-
- let html = ``;
-
- if (errors > 0) {
- html += '⚠️ Errors
';
- for (const r of results.filter(x => x.status !== 'ok')) {
- html += `
${escapeHtml(r.input.split('/').pop())} ${escapeHtml(r.error || 'Unknown')}
`;
- }
- html += '
';
- }
- container.innerHTML = html;
- } catch (e) {
- showToast('Failed to load results: ' + e.message, 'error');
- }
-}
-
// =====================================================================
// Organizer Tab
// =====================================================================
@@ -1408,6 +1475,12 @@ function orgModeChanged() {
}
async function orgStartPlan() {
+ // If planning is running, act as stop
+ if (orgRunning) {
+ await orgStopScan();
+ return;
+ }
+
const folder = document.getElementById('orgFolder').value.trim();
if (!folder) { showToast('Enter a folder path', 'error'); return; }
_storeFolder('organizer', folder);
@@ -1424,7 +1497,7 @@ async function orgStartPlan() {
body.start_seq = parseInt(document.getElementById('orgStartSeq').value) || 1;
}
- document.getElementById('orgPlanBtn').disabled = true;
+ orgSetStopMode();
document.getElementById('orgExecBtn').style.display = 'none';
document.getElementById('orgPlanResults').style.display = 'none';
document.getElementById('orgExecResults').style.display = 'none';
@@ -1439,17 +1512,52 @@ async function orgStartPlan() {
body: JSON.stringify(body),
});
const data = await resp.json();
- if (data.error) { showToast(data.error, 'error'); return; }
+ if (data.error) { showToast(data.error, 'error'); orgRestoreBtn(); return; }
orgJobId = data.job_id;
orgPollTimer = setInterval(orgPollStatus, 600);
} catch (e) {
showToast('Plan failed: ' + e.message, 'error');
document.getElementById('orgProgress').style.display = 'none';
- } finally {
- document.getElementById('orgPlanBtn').disabled = false;
+ orgRestoreBtn();
}
}
+async function orgStopScan() {
+ if (!orgJobId) { orgRestoreBtn(); return; }
+ const btn = document.getElementById('orgPlanBtn');
+ btn.disabled = true;
+ btn.textContent = 'Stopping…';
+ try {
+ await fetch(`/api/organizer/cancel/${orgJobId}`, { method: 'POST' });
+ } catch (e) { /* ignore */ }
+ // Poll loop will detect 'cancelled' and call orgRestoreBtn
+}
+
+function orgSetStopMode() {
+ orgRunning = true;
+ const btn = document.getElementById('orgPlanBtn');
+ btn.textContent = '⏹ Stop Scan';
+ btn.classList.remove('btn-primary');
+ btn.classList.add('btn-stop');
+ btn.disabled = false;
+}
+
+function orgRestoreBtn() {
+ orgRunning = false;
+ const btn = document.getElementById('orgPlanBtn');
+ btn.disabled = false;
+ btn.textContent = '📋 Preview Plan';
+ btn.classList.remove('btn-stop');
+ btn.classList.add('btn-primary');
+}
+
+function orgShowInterrupted() {
+ document.getElementById('orgPlanResults').style.display = 'block';
+ document.getElementById('orgPlanContent').innerHTML =
+ '⏹ Scan was interrupted — no results to display.
';
+ document.getElementById('orgExecBtn').style.display = 'none';
+}
+
async function orgPollStatus() {
try {
const resp = await fetch(`/api/organizer/status/${orgJobId}`);
@@ -1459,18 +1567,25 @@ async function orgPollStatus() {
document.getElementById('orgProgressBar').style.width = pct + '%';
document.getElementById('orgProgressPct').textContent = pct + '%';
- if (data.phase === 'planned') {
+ if (data.status === 'cancelled') {
+ clearInterval(orgPollTimer);
+ document.getElementById('orgProgress').style.display = 'none';
+ orgShowInterrupted();
+ orgRestoreBtn();
+ } else if (data.phase === 'planned') {
clearInterval(orgPollTimer);
document.getElementById('orgProgress').style.display = 'none';
+ orgRestoreBtn();
orgRenderPlan(data);
} else if (data.phase === 'done') {
clearInterval(orgPollTimer);
document.getElementById('orgProgress').style.display = 'none';
orgRenderExecResults(data);
- } else if (data.phase === 'error' || data.status === 'error') {
+ } else if (data.phase === 'error' || data.status === 'failed') {
clearInterval(orgPollTimer);
document.getElementById('orgProgress').style.display = 'none';
showToast('Error: ' + (data.error || 'Unknown'), 'error');
+ orgRestoreBtn();
} else {
document.getElementById('orgProgressMsg').textContent =
data.phase === 'executing' ? `Executing...` : `Planning...`;
@@ -1478,6 +1593,7 @@ async function orgPollStatus() {
} catch (e) {
clearInterval(orgPollTimer);
showToast('Poll error: ' + e.message, 'error');
+ orgRestoreBtn();
}
}
diff --git a/src/morphic/frontend/static/style.css b/web/static/style.css
similarity index 92%
rename from src/morphic/frontend/static/style.css
rename to web/static/style.css
index 39ecaac..06ab1e0 100644
--- a/src/morphic/frontend/static/style.css
+++ b/web/static/style.css
@@ -297,6 +297,17 @@ input[type="range"] {
width: 0%;
}
+.progress-bar-fill.indeterminate {
+ width: 35%;
+ transition: none;
+ animation: progress-indeterminate 1.4s cubic-bezier(0.4, 0, 0.6, 1) infinite;
+}
+
+@keyframes progress-indeterminate {
+ 0% { transform: translateX(-100%); }
+ 100% { transform: translateX(390%); }
+}
+
.progress-info {
display: flex;
justify-content: space-between;
@@ -407,6 +418,17 @@ input[type="range"] {
/* ── Converter Results ───────────────────────────────────────────── */
+.conv-video-format-group {
+ display: flex;
+ gap: 4px;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.conv-video-format-group select {
+ min-width: 70px;
+}
+
.conv-result {
display: flex;
align-items: center;
@@ -416,14 +438,27 @@ input[type="range"] {
font-size: 13px;
}
+/* Inside table cells the list-style padding/border is not needed */
+td .conv-result {
+ padding: 0;
+ border-bottom: none;
+ font-size: 12px;
+ white-space: nowrap;
+}
+
.conv-result .status-ok { color: var(--success); }
-.conv-result .status-err { color: var(--danger); }
+.conv-result .status-err { color: var(--danger); cursor: help; }
.conv-result .size-change {
font-size: 12px;
color: var(--text-dim);
}
+.result-converting {
+ color: var(--text-dim);
+ font-style: italic;
+}
+
/* ── Dupfinder Results ───────────────────────────────────────────── */
.results-section { display: none; }
@@ -812,6 +847,30 @@ input[type="range"] {
.stat-value { font-size: 28px; font-weight: 700; }
.stat-label { font-size: 12px; color: var(--text-dim); margin-top: 4px; }
+/* ── Stop Scan Button ─────────────────────────────────────────────── */
+.btn-stop {
+ background: var(--danger);
+ color: #fff;
+}
+.btn-stop:hover:not(:disabled) {
+ background: var(--danger-hover, #b91c1c);
+}
+
+/* ── Scan Interrupted Notice ─────────────────────────────────────── */
+.scan-interrupted {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 16px 20px;
+ background: rgba(251, 191, 36, 0.1);
+ border: 1px solid rgba(251, 191, 36, 0.3);
+ border-radius: var(--radius);
+ color: var(--warning, #f59e0b);
+ font-size: 14px;
+ font-weight: 500;
+ margin-bottom: 16px;
+}
+
/* ── Responsive ──────────────────────────────────────────────────── */
@media (max-width: 768px) {
diff --git a/src/morphic/frontend/templates/index.html b/web/templates/index.html
similarity index 68%
rename from src/morphic/frontend/templates/index.html
rename to web/templates/index.html
index eaef53d..92685b8 100644
--- a/src/morphic/frontend/templates/index.html
+++ b/web/templates/index.html
@@ -4,7 +4,7 @@
Morphic
-
+
@@ -14,8 +14,6 @@ ⚡ Morphic
🔄 Converter
🔍 Dupfinder
- 🏷️ Inspector
- 📐 Resizer
📂 Organizer
@@ -37,7 +35,7 @@ ⚡ Morphic
+
+
+
Scanning folder...
+
+
+
+ 0s
+
+
Walking folder tree...
+
+
Converting...
@@ -93,6 +104,9 @@
⚡ Morphic
0%
Starting...
+
+ ⏹ Stop
+
@@ -110,12 +124,22 @@ ⚡ Morphic
0 file(s) selected
-
- Convert to:
+
+
+ Container:
+
+ Codec:
+
+ Extension:
+
+
+
+
+ Convert images to:
- Loading available targets…
+ Loading…
-
+
Show:
@@ -123,12 +147,7 @@ ⚡ Morphic
All compatible
-
- AV1 CRF:
-
- 32
-
- Intersection = formats supported by each selected file; Union = any selected file
+ Image formats: common to all selected images
🔄 Convert Selected
🗑️ Delete Selected
@@ -151,7 +170,7 @@ ⚡ Morphic
-
-
-
-
-
-
-
-
🏷️ Inspect Files
-
-
-
-
-
-
🔍 Start Scan
-
-
-
-
-
Scanning...
-
-
- 0%
-
-
Initializing...
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
📐 Batch Resize
-
-
-
-
-
-
-
-
📐 Start Resize
-
-
-
-
-
Resizing...
-
-
- 0%
-
-
Starting...
-
-
-
-
-
-
-
@@ -399,7 +264,7 @@ No duplicates found!
-
+