* feat(maintenance): reviewed, read-only maintenance plans POST /prompt_manager/maintenance/preview reviews duplicate_prompts, optimize, unused_components or missing_links on its own mode=ro snapshot and answers a Plan (scope, protected reasons, backup policy, media, at most 50 detail rows). The fingerprint, targets and judged sources stay in a server-side PlanStore under a single-use token (15 min, 8 plans, 16 MiB, never truncated; a restart forgets them), bound to the database it read. The previews reuse the legacy queries: duplicate groups and the merge are shared with cleanup_duplicates, ORPHANED_PROMPTS_SQL is built from the #239 protection predicates the plan reports, and missing links are judged by the health check's link_states. Legacy /maintenance keeps its shape; vacuum now reports success:false when VACUUM fails, and cleanup_orphaned_images skips links under unavailable sources. * feat(maintenance): apply reviewed plans as Activity jobs POST /prompt_manager/maintenance/apply {token} starts a maintenance job (records admission, not cancellable, groups changed/protected/errors). The token is consumed on the loop turn the job starts: a second apply is 409 plan-used naming the job, a busy refusal keeps the token, an unknown or expired one is 410 plan-expired, and a restore since the review is 409 storage-changed. With the safety backup policy on (maintenance.safety_backup, default true, saved through the settings route with the revision), the job first writes a backup into a temp file beside the database, makes it a standalone rollback-journal file, verifies it, fsyncs it and renames it onto a unique <db>.backup_<YYYYmmdd_HHMMSS_ffffff>[_n] name; any failure leaves no file and nothing changed. The apply then rechecks the reviewed fingerprint inside BEGIN IMMEDIATE (plan-changed writes nothing), deletes in batches of 400 ids, counts changed and protected records, logs the backup by name only and prunes old safety backups. With the policy off the log and the status line say "No safety backup". * feat(maintenance): find and remove duplicate files through Activity Duplicate image files move onto the job registry: - POST /prompt_manager/jobs/duplicates finds content-identical media files over every effective source as a cancellable read job ("Find duplicate image files", group groups_found); the server holds the groups per job and GET /prompt_manager/maintenance/duplicates/{id} pages them ten at a time. - The duplicate_files preview binds a removal to the reviewed keepers. Every keeper and removal is checked again: containment as text before any filesystem access, then not a link, still inside its source, same size and hash. The plan lists the database links that move. - Apply runs duplicate_cleanup (records, not cancellable): the promised safety backup, then per file a recheck, the unlink with every handle closed and its thumbnail. A changed file is kept, the first failed unlink stops the job with a coded message (never the OS text), and only removed files have their image links and favorites moved to the kept copy. - The legacy scan, stream, status and delete routes keep their shapes on the same registry kinds; the hashing, grouping and thumbnail removal are shared with them. Coordinator rulings on Tasks 15-16: - Safety backups share the restore pool of 10 `<db>.backup_*` files; the pool is now also pruned when an apply fails after its backup. - Plans report the safety backup's size and the free disk space, and apply refuses up front (507 insufficient-space) when the backup or VACUUM does not fit, so no job fails halfway for space. * fix(maintenance): never remove the kept copy; move records before unlink Final review of #313: - C1: the duplicate find tells files apart by (st_dev, st_ino), so a source listed twice, a symlinked source inside another and a hardlink are one file. The preview and the job refuse a removal that os.path.samestat matches with its kept copy. - I1: each file's records move to its verified kept copy in one short transaction before that file is unlinked; a failed move deletes nothing and a failed unlink leaves the records on the identical kept copy. The status line counts files removed and not removed. - I2: links are looked up under every spelling of the source (the real path, the configured and monitored folders, ComfyUI's output). - I5: image tags join the kept copy as their union and the removed copy's pending, undecided Needs review items are settled; the plan says so. - I3: the VACUUM disk check measures SQLite's real temporary folder. - M1: the token is looked at again with no await before the job starts. - M2: the backup decision is made once and handed to the job. - M3: backups are linked into place; startup sweeps interrupted temps. - M4: Optimize runs alone (new "exclusive" admission) and its plan says the database is locked while VACUUM runs. - M5: the reviewed duplicate merge is strict: collisions and Library titles are protected, and errors roll back. The legacy cleanup is unchanged. - M7: 2,000 keep entries within a 1 MiB body. M8: event waits in tests. - M9: the preview binds files by size, mtime and inode without hashing; the job hashes right before each unlink. * fix(maintenance): protect one file reached through two mounts Re-review of #313, round 2: - R1: a removal on another device than its kept copy, with the same size and mtimes within 2 s, is protected when its inode is equal or its path within its source is the same ("May be the same file reached through another mount or share"), in the preview and again right before each unlink. The job holds the kept copy open during the unlink, so on Windows deleting the kept copy itself through another path fails. - R2: a file's spellings are found by containment: every configured, monitored, ComfyUI-output or listed folder whose stat identity is a folder above the file. Parent spellings of nested sources and case variants match; favorites move under every listed root holding the file. Lookups stay index searches. - m1: a failed unlink's row says the links now name the kept copy. - m2: a volume with no file ids reads "Can't confirm this is a separate file on this drive". * fix(maintenance): stat duplicate candidates with os.stat Python 3.10's Path.stat uses the os.stat bound when pathlib was imported, so the find and the per-file checks could see different stat calls. The find now calls os.stat directly, like every other check; the mount tests failed on py3.10 only. * fix(maintenance): recheck the kept copy right before each unlink Re-review 2 of #313: - n1: the cross-mount guard also matches when one path within its source ends with the other on a whole folder name (sources listed at different depths, e.g. 2024/x.png and x.png on a FUSE or noserverino mount). - n2: after a file's records move and right before its unlink, the held kept copy must still be the file at its path (fstat of the hold against os.stat) and the cross-mount guard must still hold; otherwise the file is kept, its records move back to it, and it counts as not removed. * test(maintenance): the kept-copy race tests hold on Windows On Windows the removal job holds the kept copy open without FILE_SHARE_DELETE, so the tests' attempt to delete or replace it during the record move is refused. The tests now assert that native outcome: the kept copy stays and the removal goes ahead. * fix(maintenance): restore a backup of any size the disk has room for The legacy restore refused the maintainer's own 1.6 GB safety backup with "File too large. Maximum size is 100MB", a fixed cap since #153. Free disk space now decides: the upload, its staged copy and the swap must fit, under a 64 GiB ceiling. A request with a Content-Length that does not fit is refused before anything is written, one without as soon as its running size does not, with 507 insufficient-space and a message without paths. The upload still streams and its temp file is removed on every path.
452 lines
16 KiB
Python
452 lines
16 KiB
Python
"""Reviewing duplicate image files: the checks and the plan (#249 S6, T17).
|
|
|
|
Pure helpers of ``py/api/duplicate_files.py``: the keep-list parsing, the
|
|
per-file check ``file_problem`` (containment decided as text before any
|
|
filesystem access, then a regular file of the found size and hash) and the
|
|
public ``duplicate_files`` plan.
|
|
"""
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import stat as stat_module
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
from .admin import DELETABLE_MEDIA_EXTENSIONS
|
|
from .maintenance import DUPLICATE_FILES
|
|
|
|
# Keep entries one review takes; with paths of a few hundred characters the
|
|
# list stays inside the preview body cap (DUPLICATE_PREVIEW_BODY_MAX, 1 MiB).
|
|
MAX_KEEP = 2000
|
|
MAX_DETAILS = 50
|
|
MAX_JOB_ID = 64
|
|
HASH_CHUNK = 1024 * 1024
|
|
|
|
# What ``file_problem`` finds, and how a plan counts it as protected.
|
|
OUTSIDE_SOURCE = "outside"
|
|
LINK = "link"
|
|
CHANGED_CONTENT = "changed"
|
|
GONE = "gone"
|
|
UNREADABLE = "unreadable"
|
|
KEEPER = "keeper"
|
|
SAME = "same"
|
|
MAYBE_SAME = "maybe-same"
|
|
NO_FILE_ID = "no-file-id"
|
|
# Two mounts of one share may round mtimes differently (SMB keeps 100 ns,
|
|
# FAT 2 s): within this, equal sizes on another device may be one file.
|
|
MOUNT_MTIME_SLACK_NS = 2_000_000_000
|
|
PROTECTED_REASONS = {
|
|
MAYBE_SAME: (
|
|
"file may be the same file reached through another mount or share",
|
|
"files may be the same file reached through another mount or share",
|
|
),
|
|
NO_FILE_ID: (
|
|
"file: can't confirm this is a separate file on this drive",
|
|
"files: can't confirm these are separate files on this drive",
|
|
),
|
|
SAME: (
|
|
"file is the same file as its kept copy",
|
|
"files are the same file as their kept copy",
|
|
),
|
|
OUTSIDE_SOURCE: (
|
|
"file is not inside a configured source",
|
|
"files are not inside a configured source",
|
|
),
|
|
LINK: ("file is a link, not a regular file", "files are links, not regular files"),
|
|
CHANGED_CONTENT: (
|
|
"file changed since the search",
|
|
"files changed since the search",
|
|
),
|
|
GONE: ("file no longer exists", "files no longer exist"),
|
|
UNREADABLE: ("file could not be read", "files could not be read"),
|
|
KEEPER: (
|
|
"file kept: its kept copy changed or is gone",
|
|
"files kept: their kept copy changed or is gone",
|
|
),
|
|
}
|
|
# Job outcome rows (never an OS error text).
|
|
CHANGED = "The file changed since the review; it was kept."
|
|
KEEPER_CHANGED = "The kept copy changed or is gone; this file was kept."
|
|
SAME_FILE = "This is the same file as its kept copy; it was kept."
|
|
MAYBE_SAME_FILE = (
|
|
"May be the same file reached through another mount or share; it was kept."
|
|
)
|
|
NO_FILE_ID_ROW = "Can't confirm this is a separate file on this drive; it was kept."
|
|
KEEPER_GONE = "keeper-gone"
|
|
KEEPER_WENT = (
|
|
"The kept copy changed or went away during the removal; this file was kept "
|
|
"and its links stay with it."
|
|
)
|
|
ROW_DETAILS = {
|
|
KEEPER_GONE: None, # filled below
|
|
MAYBE_SAME: MAYBE_SAME_FILE,
|
|
NO_FILE_ID: NO_FILE_ID_ROW,
|
|
SAME: SAME_FILE,
|
|
OUTSIDE_SOURCE: "The file is no longer inside its source; it was kept.",
|
|
LINK: "The file is now a link, not a regular file; it was kept.",
|
|
CHANGED_CONTENT: CHANGED,
|
|
GONE: "The file was already gone.",
|
|
UNREADABLE: "The file could not be read; it was kept.",
|
|
KEEPER: KEEPER_CHANGED,
|
|
}
|
|
# An unlink fails only after the file's records moved to its kept copy.
|
|
LOCKED_FILE = (
|
|
"The file is open in another program or read-only; it was not deleted, "
|
|
"and its links now name the kept copy."
|
|
)
|
|
DELETE_FAILED = "The file could not be deleted; its links now name the kept copy."
|
|
NOT_ATTEMPTED = "Not attempted: the job stopped after an error."
|
|
KEPT_COPY = "Kept copy"
|
|
SOURCES_CHANGED = (
|
|
"The sources changed since the review; nothing was deleted. Review again."
|
|
)
|
|
RECORDS_FAILED = (
|
|
"The database could not be updated, so the file was not deleted. "
|
|
"Its links and tags are unchanged."
|
|
)
|
|
FIND_FAILED = "The duplicate search failed."
|
|
FIND_ERROR_EVENT = "An internal error occurred. Check server logs for details."
|
|
FIND_CANCELLED = "Duplicate scan cancelled"
|
|
|
|
|
|
class SourcesChanged(Exception):
|
|
"""A source a review or removal names is no longer listed at its index."""
|
|
|
|
|
|
ROW_DETAILS[KEEPER_GONE] = KEEPER_WENT
|
|
|
|
|
|
def still_kept(target, hold):
|
|
"""Right before an unlink: the held kept copy is still the file at its
|
|
path, and the file is still not the kept copy itself (``keeper_problem``).
|
|
"""
|
|
try:
|
|
if not os.path.samestat(os.fstat(hold.fileno()), os.stat(target["keeper_abs"])):
|
|
return False
|
|
info = os.lstat(target["abs"])
|
|
problem = keeper_problem(
|
|
target["abs"],
|
|
target["root"],
|
|
info,
|
|
target["keeper_abs"],
|
|
target["keeper_root"],
|
|
)
|
|
return problem is None
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def _plural(count, one, many=None):
|
|
return f"{count:,} {one if count == 1 else (many or one + 's')}"
|
|
|
|
|
|
def human_bytes(size):
|
|
"""``10 B``, ``1.5 KB``, ``2.0 MB``…"""
|
|
if size < 1024:
|
|
return f"{size:,} B"
|
|
for unit in ("KB", "MB", "GB", "TB"):
|
|
size /= 1024
|
|
if size < 1024 or unit == "TB":
|
|
return f"{size:,.1f} {unit}"
|
|
return f"{size:,.1f} TB" # pragma: no cover - unreachable
|
|
|
|
|
|
def _hash_file(path):
|
|
digest = hashlib.sha256()
|
|
with open(path, "rb") as handle:
|
|
for chunk in iter(lambda: handle.read(HASH_CHUNK), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def file_problem(
|
|
path,
|
|
root,
|
|
digest,
|
|
size,
|
|
hasher=None,
|
|
*,
|
|
hash_check=True,
|
|
mtime_ns=None,
|
|
inode=None,
|
|
keeper=None,
|
|
keeper_root=None,
|
|
):
|
|
"""Why ``path`` may not be treated as a copy in ``root``, or None (blocking).
|
|
|
|
Containment is decided as text first, with no filesystem access: the
|
|
normalized path must lie below the normalized root, outside
|
|
``thumbnails``, with a media extension. Only then is the file looked at:
|
|
a regular file, not a link, whose resolved path is still inside the
|
|
resolved root and that is not ``keeper`` itself (``keeper_problem``: a
|
|
hardlink, one file reached through two spellings or two mounts, or a
|
|
volume that cannot tell files apart), of ``size`` bytes.
|
|
``mtime_ns`` and ``inode`` (``[st_dev, st_ino]``), when given, bind it to
|
|
what the search saw; ``hash_check`` compares its content hash with
|
|
``digest`` (the removal job, right before an unlink). Every handle is
|
|
closed when this returns.
|
|
"""
|
|
# py.config imports ComfyUI's server module: only when it is in use.
|
|
from ..config import _canonical_path, _lexical_path, _lexically_inside
|
|
|
|
lexical = _lexical_path(str(path))
|
|
base = _lexical_path(str(root))
|
|
if lexical == base or not _lexically_inside(lexical, [base]):
|
|
return OUTSIDE_SOURCE
|
|
parts = Path(os.path.relpath(lexical, base)).parts
|
|
if "thumbnails" in parts or ".." in parts:
|
|
return OUTSIDE_SOURCE
|
|
if os.path.splitext(lexical)[1].lower() not in DELETABLE_MEDIA_EXTENSIONS:
|
|
return OUTSIDE_SOURCE
|
|
try:
|
|
info = os.lstat(path)
|
|
except FileNotFoundError:
|
|
return GONE
|
|
except OSError:
|
|
return UNREADABLE
|
|
if stat_module.S_ISLNK(info.st_mode):
|
|
return LINK
|
|
if not stat_module.S_ISREG(info.st_mode):
|
|
return LINK
|
|
try:
|
|
if not _lexically_inside(_canonical_path(path), [_canonical_path(root)]):
|
|
return OUTSIDE_SOURCE # a linked folder on the way
|
|
if keeper is not None:
|
|
same = keeper_problem(path, root, info, keeper, keeper_root)
|
|
if same:
|
|
return same
|
|
if info.st_size != size:
|
|
return CHANGED_CONTENT
|
|
if mtime_ns is not None and info.st_mtime_ns != mtime_ns:
|
|
return CHANGED_CONTENT
|
|
if inode is not None and [info.st_dev, info.st_ino] != list(inode):
|
|
return CHANGED_CONTENT
|
|
if hash_check and (hasher or _hash_file)(path) != digest:
|
|
return CHANGED_CONTENT
|
|
except FileNotFoundError:
|
|
return GONE
|
|
except OSError:
|
|
return UNREADABLE
|
|
return None
|
|
|
|
|
|
def keeper_problem(path, root, info, keeper, keeper_root=None):
|
|
"""Why ``path`` (its ``lstat`` is ``info``) may be ``keeper`` itself, or None.
|
|
|
|
- ``NO_FILE_ID``: a volume reports no file id (``st_ino`` 0), so the two
|
|
cannot be told apart;
|
|
- ``SAME``: ``os.path.samestat`` (a hardlink, or one file reached through
|
|
two spellings of one mount);
|
|
- ``MAYBE_SAME``: another device, the same size, mtimes within
|
|
``MOUNT_MTIME_SLACK_NS``, and the same ``st_ino`` (NFS and SMB report
|
|
the server's file id) or the same path within their sources, or one
|
|
ending with the other on a folder name (one share mounted twice, a
|
|
FUSE pool over a listed disk, sources listed at different depths).
|
|
Protect, never guess: a true copy on another disk at the same relative
|
|
path is kept too.
|
|
|
|
Raises OSError when the kept copy cannot be read (the caller's GONE or
|
|
UNREADABLE).
|
|
"""
|
|
kept = os.stat(keeper)
|
|
if not info.st_ino or not kept.st_ino:
|
|
return NO_FILE_ID
|
|
if os.path.samestat(info, kept):
|
|
return SAME
|
|
if info.st_dev == kept.st_dev or info.st_size != kept.st_size:
|
|
return None
|
|
if abs(info.st_mtime_ns - kept.st_mtime_ns) > MOUNT_MTIME_SLACK_NS:
|
|
return None
|
|
if info.st_ino == kept.st_ino:
|
|
return MAYBE_SAME
|
|
if keeper_root is not None and _same_tail(
|
|
_relative_key(path, root), _relative_key(keeper, keeper_root)
|
|
):
|
|
return MAYBE_SAME
|
|
return None
|
|
|
|
|
|
def _same_tail(one, other):
|
|
"""Whether one relative path is the other, or ends with it on a folder name.
|
|
|
|
Two mounts of one tree may be listed at different depths (``2024/x.png``
|
|
in one source, ``x.png`` in the other); ``ax.png`` never matches ``x.png``.
|
|
"""
|
|
if one is None or other is None:
|
|
return False
|
|
return one == other or one.endswith("/" + other) or other.endswith("/" + one)
|
|
|
|
|
|
def _relative_key(path, root):
|
|
try:
|
|
relative = os.path.relpath(str(path), str(root))
|
|
except ValueError: # another drive (Windows)
|
|
return None
|
|
return os.path.normcase(relative).replace("\\", "/")
|
|
|
|
|
|
@dataclass
|
|
class HeldFind:
|
|
"""One find: where it looked and, once it finished, what it found."""
|
|
|
|
origin: str # "workspace" or "gallery"
|
|
job_id: str = ""
|
|
roots: list = field(default_factory=list) # [(source index or None, root)]
|
|
legacy_dir: str = None
|
|
groups: list = None # [{group_id, hash, size, files:[record]}]
|
|
|
|
def covers(self, legacy_dir):
|
|
"""Whether its events answer the legacy gallery for ``legacy_dir``."""
|
|
if self.origin == "gallery":
|
|
return True
|
|
if not legacy_dir:
|
|
return False
|
|
wanted = _identity(legacy_dir)
|
|
return any(_identity(root) == wanted for _index, root in self.roots)
|
|
|
|
|
|
def _identity(path):
|
|
return os.path.normcase(os.path.realpath(str(path)))
|
|
|
|
|
|
def _group_order(group):
|
|
return (-(len(group["files"]) - 1) * group["size"], group["hash"])
|
|
|
|
|
|
def _keep_entry(entry):
|
|
"""``((group_id, root_index, path), None)`` or ``(None, message)``."""
|
|
if not isinstance(entry, dict) or set(entry) != {"group_id", "root_index", "path"}:
|
|
return None, "Each keep entry is {group_id, root_index, path}"
|
|
group_id, index, path = entry["group_id"], entry["root_index"], entry["path"]
|
|
if not isinstance(group_id, str) or not 0 < len(group_id) <= 128:
|
|
return None, "group_id must be a group's id"
|
|
if index is not None and (
|
|
isinstance(index, bool) or not isinstance(index, int) or index < 0
|
|
):
|
|
return None, "root_index must be a source index"
|
|
if not isinstance(path, str) or not 0 < len(path) <= 4096:
|
|
return None, "path must be a file's path in its group"
|
|
return (group_id, index, path), None
|
|
|
|
|
|
def _parse_options(options):
|
|
"""``(job_id, keep, None)`` for duplicate_files options, else a message."""
|
|
if not isinstance(options, dict) or set(options) != {"job_id", "keep"}:
|
|
return None, None, "duplicate_files takes options {job_id, keep}"
|
|
job_id, keep = options["job_id"], options["keep"]
|
|
if not isinstance(job_id, str) or not 0 < len(job_id) <= MAX_JOB_ID:
|
|
return None, None, "job_id must be a duplicate search's job id"
|
|
if not isinstance(keep, list) or not 0 < len(keep) <= MAX_KEEP:
|
|
return None, None, f"keep must list 1 to {MAX_KEEP:,} files to keep"
|
|
parsed = []
|
|
for entry in keep:
|
|
key, message = _keep_entry(entry)
|
|
if message:
|
|
return None, None, message
|
|
parsed.append(key)
|
|
return job_id, parsed, None
|
|
|
|
|
|
def _select(held, keep):
|
|
"""``({group_id: (group, [keeper records])}, None)`` or ``(None, message)``."""
|
|
groups = {group["group_id"]: group for group in held.groups}
|
|
selection = {}
|
|
for group_id, index, path in keep:
|
|
group = groups.get(group_id)
|
|
if group is None:
|
|
return None, "A kept file names a group this search did not find"
|
|
match = [
|
|
record
|
|
for record in group["files"]
|
|
if record["root_index"] == index and record["rel"] == path
|
|
]
|
|
if not match:
|
|
return None, "A kept file is not in its group"
|
|
_group, keepers = selection.setdefault(group_id, (group, []))
|
|
if match[0] not in keepers:
|
|
keepers.append(match[0])
|
|
return selection, None
|
|
|
|
|
|
SOURCES_CHANGED_REVIEW = (
|
|
"The sources changed since the search. Search for duplicates again."
|
|
)
|
|
|
|
|
|
def _fingerprint(targets):
|
|
digest = hashlib.sha256()
|
|
for target in sorted(targets, key=lambda t: _identity(t["abs"])):
|
|
part = [
|
|
os.path.normcase(target["abs"]),
|
|
target["hash"],
|
|
target["size"],
|
|
os.path.normcase(target["keeper_abs"]),
|
|
]
|
|
digest.update(json.dumps(part).encode("utf-8") + b"\n")
|
|
return "sha256:" + digest.hexdigest()
|
|
|
|
|
|
def _record_parts(records, target):
|
|
"""What moves with one removal: its links, tags and Needs review items."""
|
|
found = records.get(target["abs"], {})
|
|
links, tags, review = (found.get(key, 0) for key in ("links", "tags", "review"))
|
|
parts = [
|
|
(
|
|
(
|
|
f"{_plural(links, 'database link')} "
|
|
f"{'moves' if links == 1 else 'move'} to the kept copy"
|
|
)
|
|
if links
|
|
else "no database links"
|
|
)
|
|
]
|
|
if tags:
|
|
parts.append("its image tags join the kept copy's")
|
|
if review:
|
|
item = "Needs review item is" if review == 1 else "Needs review items are"
|
|
parts.append(f"{review:,} {item} settled")
|
|
return parts
|
|
|
|
|
|
def _public_plan(targets, protected, records):
|
|
count = len(targets)
|
|
groups = len({t["group_id"] for t in targets})
|
|
size = sum(t["size"] for t in targets)
|
|
reasons = [
|
|
f"{n:,} {PROTECTED_REASONS[code][0 if n == 1 else 1]}"
|
|
for code, n in sorted(protected.items())
|
|
]
|
|
details = [
|
|
{
|
|
"label": target["rel"],
|
|
"detail": f"{target['root_label']} · a copy of {target['keeper_rel']}"
|
|
+ "".join(f" · {part}" for part in _record_parts(records, target)),
|
|
}
|
|
for target in targets[:MAX_DETAILS]
|
|
]
|
|
label = f"{_plural(count, 'duplicate file')} in {_plural(groups, 'group')}"
|
|
media = (
|
|
f"Deletes {_plural(count, 'file')} ({human_bytes(size)}) from disk; the "
|
|
"kept copies stay. The safety backup covers the database, not these files."
|
|
)
|
|
tagged = sum(1 for t in targets if records.get(t["abs"], {}).get("tags"))
|
|
review = sum(records.get(t["abs"], {}).get("review", 0) for t in targets)
|
|
if tagged:
|
|
media += (
|
|
f" The image tags of {_plural(tagged, 'removed copy', 'removed copies')}"
|
|
" join their kept copy's."
|
|
)
|
|
if review:
|
|
media += (
|
|
f" {_plural(review, 'pending Needs review item')} of the removed "
|
|
f"{'copy is' if review == 1 else 'copies are'} settled."
|
|
)
|
|
return {
|
|
"operation": DUPLICATE_FILES,
|
|
"scope": {"count": count, "label": label},
|
|
"protected": {"count": sum(protected.values()), "reasons": reasons},
|
|
"media": {"deletes_files": True, "label": media},
|
|
"details": details,
|
|
}
|