* refactor(thumbnails): one resolver for where previews live on disk (#320) Add py/api/preview_store.py: the store of a source, a preview's path, URL parts to disk, containment (owns), a write generation and the lock around every preview's final rename. Every preview reader, writer and remover now asks it instead of joining <source>/thumbnails by hand: serve, on-demand, capture (its path resolved when the write starts), the after-scan queue, the thumbnail plans and job payloads, clear, the temp sweep, History covers, the auto-tag listing and the owned-preview lookups. Source mode only: every path is the one built before, so nothing changes on disk or on the wire. A structural test keeps new thumbnails joins out of the backend. * refactor(thumbnails): keep the after-scan queue's store rule and pin the write lock (#320) A planned target keeps the plan's own containment rule (inside the store, as the thumbnail job writes it); captures keep the serve rule. The structural test also covers PREVIEW_FOLDER joins and the repo-root modules; the write-lock test asserts the lock instead of waiting on a timer; new tests cover nested roots in the after-scan queue and a legacy upper-case preview served through the resolver. * feat(thumbnails): keep previews in one folder, set from config.json (#320) thumbnails.location in config.json can now name one folder for every source's previews: <folder>/<source key>/<rel>_thumb<ext>. A source key is a readable slug plus a short hash of the source's identity, stored in the folder's marker (.promptmanager-thumbnails.json) with the install that made it. Serving, on-demand and capture writes, listings, clear, delete, duplicates, the temp sweep and History covers all follow it; a leftover in <source>/thumbnails is never served and other text under thumbnails/ is refused. A missing, unplugged or foreign folder is unavailable (a probe checks it every 5 s within 2 s; a stalled or failing store call flips it at once): originals are served, nothing is written anywhere, the folder is never re-created, and generate, convert and clear answer 409 location-unavailable. A bad config value is unavailable too, never a fall-back to the sources. No UI yet: POST /settings refuses thumbnail_location (409 location-needs-move); GET /settings reports it with its path, the thumbnail status without one. Sources inside or around the folder and a library move into it are refused. * fix(thumbnails): never follow a marker or a link out of the thumbnail folder (#320) Marker keys are data, not paths: only a single <slug>-<hex> folder name is ever joined onto the thumbnail folder; anything else is dropped and logged. Every preview write and delete passes one ownership check (below a key folder, lexically and resolved, no link, junction or reparse point on the way); make_parent refuses anything else and writes nothing while the folder lacks this install's marker (an unmounted mount point). Clear never descends into a junction. The configured location is in force before any request (unchecked folder mode is unavailable), and a startup check that fails leaves it invalid, never the sources. Older listings that run on the event loop no longer touch the store in folder mode. A leftover under another case of thumbnails/ on a case-insensitive disk is refused too. * test(memory): let the musl test fake os.confstr on Windows (#523) The portability run on main fails since #523: Windows has no os.confstr, so patching it without create=True raises before the test runs. * feat(thumbnails): move thumbnails between the sources and one folder (#320) POST /images/thumbnails/plan reviews a move (what is copied, removed and left, free space, too-long Windows paths, sources no longer listed) and hands out a token; POST /jobs/thumbnails {mode: move} runs it as a thumbnails job: hold automatic previews, copy and check every preview on the job pool, switch with one config commit that records the old copies, then remove them. Per-file errors still switch; a folder-wide error, a cancel before the switch or a crash leaves nothing changed (a journal is undone at the next start); old copies that could not go are removed by a finishing job at the next start. Leaving a folder removes the previews of this install's sources that are no longer listed (Q320-8). Recreate folder and Use this folder answer an unavailable folder; the Thumbnail maintenance scope prune removes the thumbnails of removed sources. * feat(system): choose where thumbnails are kept and move them (#320) System › Sources & scans gains the Thumbnail location row (D196): Inside each source or One folder, the folder path, the status line and Review move…, whose review (D197) lists what is copied, removed and left before Move thumbnails starts; a refused folder is explained under the field, a folder of your own asks for a confirmation first. Activity shows a move and a prune with their own tiles and step line (D198). An unavailable folder shows a notice on Overview and Sources & scans with Recreate folder or Use this folder (D199). Thumbnail maintenance offers Remove thumbnails of removed sources in folder mode (D200), and Move library's review says where the thumbnails stay (D201). Closes #320 * fix(thumbnails): keep a move's own previews and never walk out of a store (#320) A cleanup that waited from an earlier move never names a key folder the new move just filled, so moving back keeps the previews. Removing empty folders walks top-down and never into a link or junction. Any error while copying undoes the copies; once the switch is committed, a journal that cannot be removed no longer undoes it. Hand-edited cleanup entries and marker keys never name a folder outside the stores; removal stops at interpreter exit. The path field carries aria-invalid with its reason, and a review that went stale reviews again. * test(thumbnails): cover the prune scope, the Overview notice and the space refusal (#320) The frontend coverage gate counted the new branches of thumbnail maintenance, the Activity views, the notices' Change location and the api-client's space refusal as uncovered; page and unit tests now drive them. * fix(thumbnails): report removals that fail and explain the quiet excepts (#320) An old thumbnail the finishing move cannot remove (held open on Windows) is now an error row of the job with its path, and logged; a failed copy's temp that cannot go is logged with its path. Every except that only passes says why, and the thread runners say their caller re-raises. * fix(thumbnails): never plan or delete through a linked key folder (#320) Review finding P1 on #591: in folder mode a source key folder that is a link or junction to a folder outside was still handed to the planner, so Convert outdated could remove an older-format file outside the thumbnail folder. ensure_store now refuses a linked store before any planning, and each removal of an old preview passes may_write at the moment it runs. * fix(thumbnails): the temp sweep never walks into a link (#320) Review finding P2 on #591: Clear thumbnails and the startup sweep removed crashed-write temps with a plain os.walk, which on Windows walks into a junction even without followlinks. The sweeper now skips a folder that is a link, junction or reparse point (the top one too), and each temp passes may_write before it is removed. * fix(thumbnails): lock the folder marker across processes (#320) Review finding P2 on #591: two installs sharing one thumbnail folder could each read the marker, add a key and replace it, so the last writer dropped the other's key. write_marker now re-reads, changes and replaces the marker under a lock file made with O_CREAT | O_EXCL beside it (atomic on shares, unlike flock); a lock older than 30 s, left by a writer that died, is taken over, and a wait past 1.5 s fails as a TimeoutError like an unreachable folder. Tested with three processes adding keys at once. * test(thumbnails): cover the space refusal, Overview's Use this folder and the prune review (#320) The frontend coverage floors of activity-kinds, system-overview and thumbnail-review: a disk without the space shows the review but cannot move (a fixture route fakes the free space), Use this folder on Overview reloads the host values, the prune review's text and rows are pure functions with unit tests, and Overview takes the System drafts it is always mounted with. * fix(thumbnails): never copy through a linked folder; refuse a target inside the folder in use (#320) Moving back to Inside each source followed an existing linked folder in the destination and could replace a file outside the source (Codex P1). A target nested inside the thumbnail folder in use was accepted, and the old-copy cleanup then removed the previews just copied there (Codex P2). Every copy now checks the whole destination chain for links, also when the folders exist, and the move refuses a folder inside the one in use with location-inside-current. The cancelled branches no longer mix explicit and implicit returns (code-quality note). * fix(system): one full stop in the move-back review (#320) * fix(thumbnails): a folder check answers for the folder as it is now (#320) Probe.check() joined a check already running, or a finished one nobody had collected. A folder deleted right after configure() woke the probe therefore stayed available until the next probe, which failed the Overview notice spec in the merge queue. It now waits for a running check and starts a new one, still never two at once, and a stuck check still times out. The spec also asserts the fixture's removal took effect.
580 lines
24 KiB
Python
580 lines
24 KiB
Python
"""Move thumbnails (#320 Part 3): the reviewed job that changes where previews
|
|
live, the removal of old copies after it, recovery at startup and the
|
|
"Remove thumbnails of removed sources" scope (Part 5).
|
|
|
|
Kind ``thumbnails`` with ``mode: "move"`` (D1A): admission ``guarded``, so
|
|
scans, deletes, tagging, source changes and every other thumbnail job wait
|
|
for it, while the monitor's Needs review recording runs beside it.
|
|
Automatic previews are held for the whole job (D2A, ``_auto_thumbnail_blocker``).
|
|
|
|
Phases: review (``POST /images/thumbnails/plan``, no writes but the
|
|
validation probe), start (a plan token), hold (generation bump, journal,
|
|
target folder), copy (cancellable at every file boundary, on the job pool
|
|
in small chunks, D3A), switch (one config commit sets the location and
|
|
records the old stores under ``cleanup``; not cancellable from here),
|
|
remove old copies, end. Per-file errors switch anyway (D10A); a folder-wide
|
|
error or a cancel before the switch removes what the job wrote and changes
|
|
nothing. The move is the only writer of the setting (D4B).
|
|
|
|
Moving away from a thumbnail folder also removes the previews of this
|
|
install's sources that are no longer listed (Q320-8: remove).
|
|
"""
|
|
|
|
import functools
|
|
import os
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from aiohttp import web
|
|
|
|
from . import preview_location, preview_store
|
|
from . import thumbnail_move_files as files
|
|
from .jobs import THUMBNAILS_JOB_KIND
|
|
from .thumbnail_move_plan import (
|
|
MOVE_GROUPS,
|
|
MOVE_TITLE,
|
|
PLAN_TTL,
|
|
_number,
|
|
_plural,
|
|
_remove,
|
|
_roots_revision,
|
|
_volume,
|
|
_is_network,
|
|
_write_json,
|
|
)
|
|
from . import thumbnail_move_plan as move_plan
|
|
|
|
try:
|
|
from ...utils import exit_signal
|
|
from ...utils.parallel import map_parallel
|
|
except ImportError: # tests import the package top-level
|
|
from utils import exit_signal
|
|
from utils.parallel import map_parallel
|
|
|
|
|
|
class ThumbnailMoveMixin:
|
|
"""Start and run the move job; the removal of old copies."""
|
|
|
|
# ── start ─────────────────────────────────────────────────────────
|
|
|
|
def _take_move_plan(self, body):
|
|
"""The plan a start names, or ``(status, code)``."""
|
|
token = body.get("token")
|
|
plan = (self._move_plans or {}).get(token) if isinstance(token, str) else None
|
|
if plan is None or time.monotonic() - plan.created > PLAN_TTL:
|
|
return 409, "plan-stale"
|
|
roots = [str(root) for root in self._get_all_output_dirs()]
|
|
if _roots_revision(roots) != plan.revision:
|
|
return 409, "plan-stale"
|
|
if preview_store.setting() != plan.old:
|
|
return 409, "plan-stale"
|
|
if plan.confirm_required and body.get("confirmed") is not True:
|
|
return 400, "confirmation-required"
|
|
return plan
|
|
|
|
def start_thumbnail_move(self, body):
|
|
"""Start the move job (sync, on the loop); raises JobBusy. Returns
|
|
the job or a refusal response."""
|
|
if not self._move_recovered:
|
|
blocker = self._jobs.active(THUMBNAILS_JOB_KIND)
|
|
return web.json_response(
|
|
{
|
|
"success": False,
|
|
"code": "busy",
|
|
"error": "Prompt Manager is still checking an earlier move. "
|
|
"Try again in a moment.",
|
|
"running": None if blocker is None else {"id": blocker.id},
|
|
},
|
|
status=409,
|
|
)
|
|
taken = self._take_move_plan(body)
|
|
if isinstance(taken, tuple):
|
|
status, code = taken
|
|
text = {
|
|
"plan-stale": "The review is out of date. Review the move again.",
|
|
"confirmation-required": "Confirm that Prompt Manager may "
|
|
"create its folders there.",
|
|
}[code]
|
|
return web.json_response(
|
|
{"success": False, "code": code, "error": text}, status=status
|
|
)
|
|
plan = taken
|
|
self._move_plans.pop(plan.token, None)
|
|
return self._jobs.start(
|
|
THUMBNAILS_JOB_KIND,
|
|
MOVE_TITLE,
|
|
admission="guarded",
|
|
cancellable=True,
|
|
scope={
|
|
"mode": "move",
|
|
"from": plan.old.mode,
|
|
"to": plan.target["mode"],
|
|
"to_path": plan.target["path"],
|
|
},
|
|
groups=MOVE_GROUPS,
|
|
runner=functools.partial(self._run_thumbnail_move, plan=plan),
|
|
)
|
|
|
|
# ── the job ───────────────────────────────────────────────────────
|
|
|
|
async def _run_thumbnail_move(self, job, *, plan):
|
|
state = {"written": [], "created": [], "created_folder": False}
|
|
try:
|
|
await self._run_in_executor(self._move_hold, job, plan, state)
|
|
except (OSError, ValueError) as error:
|
|
await self._run_in_executor(self._move_rollback, plan, state)
|
|
self._end_move(
|
|
job, "failed", "The new folder could not be made; nothing changed."
|
|
)
|
|
self.logger.warning(f"Move thumbnails could not start: {error}")
|
|
return
|
|
left = [
|
|
{
|
|
"path": "/".join(item),
|
|
"root": s.label,
|
|
"detail": "Not a thumbnail; left in place",
|
|
}
|
|
for s in plan.sources
|
|
if s.scan is not None
|
|
for item in s.scan.left
|
|
]
|
|
if left:
|
|
job.record_counted("left", len(left), left)
|
|
items = [
|
|
(source, item)
|
|
for source in plan.sources
|
|
if source.scan is not None
|
|
for item in source.scan.copy
|
|
]
|
|
total = len(items)
|
|
job.update(total=total, processed=0, progress=0)
|
|
chunk = await self._run_in_executor(self._move_chunk, plan)
|
|
workers = self._move_workers()
|
|
done = 0
|
|
for start in range(0, total, chunk):
|
|
if job.cancelled:
|
|
await self._move_cancelled(job, plan, state)
|
|
return
|
|
batch = items[start : start + chunk]
|
|
try:
|
|
results = await self._run_in_executor(
|
|
map_parallel,
|
|
functools.partial(self._move_copy_one, state=state),
|
|
batch,
|
|
workers,
|
|
)
|
|
except exit_signal.ExitRequested:
|
|
return # exiting: the next start undoes the copies (journal)
|
|
except files.FolderGone as error:
|
|
self.logger.warning(f"Move thumbnails stopped: {error}")
|
|
await self._run_in_executor(self._move_rollback, plan, state)
|
|
self._end_move(
|
|
job,
|
|
"failed",
|
|
"The new folder could not be reached or is full; nothing changed.",
|
|
)
|
|
return
|
|
except Exception:
|
|
# Anything else before the switch: undo, never half a move.
|
|
self.logger.exception("Move thumbnails failed while copying")
|
|
await self._run_in_executor(self._move_rollback, plan, state)
|
|
self._end_move(job, "failed", "The copy failed; nothing changed.")
|
|
return
|
|
for (source, item), (outcome, detail) in zip(batch, results):
|
|
row = {
|
|
"path": "/".join(item[0]),
|
|
"root": source.label,
|
|
"detail": detail,
|
|
}
|
|
job.record(
|
|
"moved" if outcome == "moved" else outcome,
|
|
row,
|
|
f"{outcome}: {source.label} · {'/'.join(item[0])}",
|
|
)
|
|
done += len(batch)
|
|
label = batch[-1][0].label
|
|
job.update(
|
|
processed=done,
|
|
progress=int(done * 90 / total) if total else 90,
|
|
status_line=(
|
|
f"Copying {_number(done)} of {_number(total)} previews · {label}"
|
|
),
|
|
)
|
|
job.publish({"type": "progress", "summary": job.summary()})
|
|
if job.cancelled:
|
|
await self._move_cancelled(job, plan, state)
|
|
return
|
|
# The switch: not cancellable from here (an exit still stops between
|
|
# files and leaves the cleanup to the next start).
|
|
job.cancellable = False
|
|
job.update(status_line="Switching to the new location", progress=92)
|
|
job.publish({"type": "progress", "summary": job.summary()})
|
|
try:
|
|
await self._run_in_executor(self._move_switch, plan)
|
|
except Exception:
|
|
self.logger.exception("Move thumbnails could not switch")
|
|
await self._run_in_executor(self._move_rollback, plan, state)
|
|
self._end_move(
|
|
job, "failed", "The setting could not be saved; nothing changed."
|
|
)
|
|
return
|
|
await self._remove_old_copies(job, plan)
|
|
moved = job.counters.get("moved", 0) + job.counters.get("skipped", 0)
|
|
errors = job.counters.get("errors", 0)
|
|
where = plan.target["path"] or "inside each source"
|
|
parts = [f"{_number(moved)} moved"]
|
|
if errors:
|
|
parts.append(_plural(errors, "error"))
|
|
parts.append(
|
|
"old copies removed"
|
|
if not job.scope.get("cleanup_left")
|
|
else "some old copies are removed at the next start"
|
|
)
|
|
self._end_move(
|
|
job, "finished", f"Thumbnails moved to {where}: " + " · ".join(parts) + "."
|
|
)
|
|
|
|
def _end_move(self, job, state, line):
|
|
job.update(status_line=line, progress=100)
|
|
job.log_line(line)
|
|
job.publish({"type": "complete", "summary": job.summary()})
|
|
job.finish(state)
|
|
|
|
def _move_workers(self):
|
|
from .images import _worker_threads
|
|
|
|
return max(1, int(_worker_threads() or 1))
|
|
|
|
def _move_chunk(self, plan):
|
|
"""2 files at a time across volumes or to/from a share, else 4, never
|
|
more than Worker threads (D3A: the pool keeps its size)."""
|
|
sides = [plan.target["path"] or ""] + [s.old_store or "" for s in plan.sources]
|
|
sides += [s.new_store for s in plan.sources]
|
|
network = any(_is_network(side) for side in sides if side)
|
|
volumes = {_volume(side) for side in sides if side}
|
|
size = 2 if network or len(volumes) > 1 else 4
|
|
return max(1, min(size, self._move_workers()))
|
|
|
|
def _move_hold(self, job, plan, state):
|
|
"""Phase 3: bump the generation, write the journal, make the target."""
|
|
preview_store.bump_generation()
|
|
if plan.to_folder:
|
|
folder = plan.target["path"]
|
|
if not os.path.isdir(folder):
|
|
if plan.target_default:
|
|
os.makedirs(folder, exist_ok=True)
|
|
else:
|
|
os.mkdir(folder)
|
|
state["created_folder"] = True
|
|
plan.target_id = preview_location.init_folder(
|
|
folder, created_by_pm=state["created_folder"]
|
|
)
|
|
install = plan.install
|
|
|
|
def add_keys(marker):
|
|
for source in plan.sources:
|
|
if source.key and source.key not in marker["keys"]:
|
|
marker["keys"][source.key] = {
|
|
"path": source.root,
|
|
"identity": os.path.normcase(os.path.realpath(source.root)),
|
|
"dev_ino": preview_location.file_identity(source.root),
|
|
"install": install,
|
|
}
|
|
|
|
with preview_store.write_lock:
|
|
preview_location.write_marker(folder, add_keys)
|
|
_write_json(
|
|
move_plan.journal_path(),
|
|
{
|
|
"state": "copying",
|
|
"from": plan.old.to_config(),
|
|
"to": plan.target,
|
|
"stores": [s.new_store for s in plan.sources],
|
|
# Stores that do not exist yet are made by this job only:
|
|
# after a crash everything in them goes (B4).
|
|
"created_dirs": [
|
|
s.new_store for s in plan.sources if not os.path.isdir(s.new_store)
|
|
],
|
|
"created_folder": state["created_folder"],
|
|
"install": plan.install,
|
|
},
|
|
)
|
|
job.log_line(
|
|
f"Move thumbnails started: {plan.old.path or 'inside each source'} → "
|
|
f"{plan.target['path'] or 'inside each source'}"
|
|
)
|
|
|
|
def _move_copy_one(self, pair, *, state):
|
|
"""Copy one preview (a pool thread); ``(group, detail)``."""
|
|
source, (relative, size, mtime_ns) = pair
|
|
destination = Path(source.new_store).joinpath(*relative)
|
|
try:
|
|
if not os.path.isdir(destination.parent):
|
|
base = Path(source.new_store)
|
|
if not base.is_dir():
|
|
if plan_parent_missing(base):
|
|
raise files.FolderGone("the new folder is gone")
|
|
created = []
|
|
files._make_folders(base.parent, (base.name,), created)
|
|
state["created"].extend(created)
|
|
created = []
|
|
files._make_folders(base, relative[:-1], created)
|
|
state["created"].extend(created)
|
|
# Also when the folders already exist (Codex P1 on #592).
|
|
files.refuse_links(source.new_store, relative[:-1])
|
|
outcome = files.copy_one(
|
|
Path(source.old_store).joinpath(*relative),
|
|
destination,
|
|
size,
|
|
mtime_ns,
|
|
state["created"],
|
|
)
|
|
except files.CopyFailed as error:
|
|
return "errors", str(error)
|
|
if outcome == "moved":
|
|
state["written"].append(str(destination))
|
|
return "moved", "Copied and checked"
|
|
return "skipped", "Already there"
|
|
|
|
async def _move_cancelled(self, job, plan, state):
|
|
if exit_signal.requested():
|
|
return # the next start removes the partial copies (journal)
|
|
await self._run_in_executor(self._move_rollback, plan, state)
|
|
self._end_move(job, "cancelled", "Move cancelled: nothing changed.")
|
|
|
|
def _move_rollback(self, plan, state):
|
|
"""Remove what this job wrote (files, folders it made, a folder and
|
|
marker it created) and the journal: nothing changed."""
|
|
for path in state["written"]:
|
|
files.remove_file(path)
|
|
for folder in reversed(state["created"]):
|
|
files.remove_empty_folders(folder)
|
|
if plan.to_folder and state.get("created_folder"):
|
|
folder = plan.target["path"]
|
|
try:
|
|
names = os.listdir(folder)
|
|
except OSError:
|
|
names = None
|
|
if names is not None and set(names) <= {preview_location.MARKER_NAME}:
|
|
_remove(preview_location.marker_path(folder))
|
|
files.remove_empty_folders(folder)
|
|
_remove(move_plan.journal_path())
|
|
|
|
def _move_switch(self, plan):
|
|
"""Phase 5: one config commit sets the location and records the old
|
|
stores under ``cleanup``; the runtime follows under the write lock."""
|
|
from ..config import PromptManagerConfig
|
|
from ..config_store import get_config_store
|
|
|
|
entry = {
|
|
"from": plan.old.mode,
|
|
"folder": plan.old.path if plan.from_folder else None,
|
|
"stores": [s.old_store for s in plan.sources if s.old_store],
|
|
"drop_keys": self._old_keys_to_drop(plan),
|
|
"install": plan.install,
|
|
}
|
|
# An older entry never removes a store the move just filled (a move
|
|
# back while old copies still waited).
|
|
filled = {os.path.normcase(s.new_store) for s in plan.sources}
|
|
cleanup = []
|
|
for old_entry in plan.old.cleanup:
|
|
stores = [
|
|
store
|
|
for store in old_entry.get("stores") or []
|
|
if isinstance(store, str) and os.path.normcase(store) not in filled
|
|
]
|
|
folder = old_entry.get("folder")
|
|
drop = [
|
|
key
|
|
for key in old_entry.get("drop_keys") or []
|
|
if not folder
|
|
or os.path.normcase(os.path.join(folder, key)) not in filled
|
|
]
|
|
cleanup.append({**old_entry, "stores": stores, "drop_keys": drop})
|
|
cleanup.append(entry)
|
|
if plan.to_folder:
|
|
value = {
|
|
"mode": preview_store.FOLDER_MODE,
|
|
"path": plan.target["path"],
|
|
"id": plan.target_id,
|
|
"install": plan.install,
|
|
"cleanup": cleanup,
|
|
}
|
|
else:
|
|
value = {
|
|
"mode": preview_store.SOURCE_MODE,
|
|
"install": plan.install,
|
|
"cleanup": cleanup,
|
|
}
|
|
|
|
def mutate(config):
|
|
section = config.get("thumbnails")
|
|
if not isinstance(section, dict):
|
|
section = {}
|
|
section["location"] = value
|
|
config["thumbnails"] = section
|
|
return config
|
|
|
|
def publish(_config):
|
|
PromptManagerConfig.THUMBNAIL_LOCATION = value
|
|
preview_store.configure(value, check=False)
|
|
|
|
get_config_store().commit(mutate, expected_revision=None, publish=publish)
|
|
# The switch is made: what follows never undoes it.
|
|
try:
|
|
preview_store.refresh()
|
|
_remove(move_plan.journal_path())
|
|
except OSError as error:
|
|
self.logger.warning(
|
|
f"The thumbnail move journal stays until the next start: {error}"
|
|
)
|
|
|
|
def _old_keys_to_drop(self, plan):
|
|
if not plan.from_folder:
|
|
return []
|
|
keys = [s.old_store and os.path.basename(s.old_store) for s in plan.sources]
|
|
keys = [key for key in keys if key]
|
|
if plan.to_folder and os.path.normcase(plan.old.path or "") == os.path.normcase(
|
|
plan.target["path"] or ""
|
|
):
|
|
return []
|
|
return keys + [key for key, _store, _scan in plan.unlisted]
|
|
|
|
async def _remove_old_copies(self, job, plan):
|
|
"""Phase 6: run the cleanup the switch recorded (and earlier ones)."""
|
|
job.update(status_line="Removing old copies", progress=95)
|
|
job.publish({"type": "progress", "summary": job.summary()})
|
|
left = await self._run_in_executor(self._run_cleanup, job)
|
|
if left:
|
|
job.scope["cleanup_left"] = True
|
|
|
|
def _run_cleanup(self, job=None):
|
|
"""Remove every recorded old store's previews, temps and empty
|
|
folders; keep in ``cleanup`` what could not go. Returns how many
|
|
entries are left (blocking)."""
|
|
setting = preview_store.setting()
|
|
remaining = []
|
|
for entry in setting.cleanup:
|
|
if not self._cleanup_entry(entry, job):
|
|
remaining.append(entry)
|
|
if list(setting.cleanup) != remaining:
|
|
self._commit_cleanup(remaining)
|
|
return len(remaining)
|
|
|
|
def _cleanup_entry(self, entry, job):
|
|
"""One recorded removal; True when it is complete."""
|
|
folder = entry.get("folder")
|
|
stores = [
|
|
store for store in entry.get("stores") or [] if isinstance(store, str)
|
|
]
|
|
if folder:
|
|
if not os.path.isdir(folder):
|
|
return False # the old folder is not reachable: kept for later
|
|
folder_marker = preview_location.read_marker(folder) or {}
|
|
keys = folder_marker.get("keys") or {}
|
|
stores += [
|
|
os.path.join(folder, key)
|
|
for key in entry.get("drop_keys") or []
|
|
if preview_location.valid_key(key) and key in keys
|
|
]
|
|
complete = True
|
|
for store in dict.fromkeys(stores):
|
|
if not self._remove_store(store, folder, job):
|
|
complete = False
|
|
if folder and complete:
|
|
drop = set(entry.get("drop_keys") or [])
|
|
|
|
def forget(marker):
|
|
for key in drop:
|
|
marker["keys"].pop(key, None)
|
|
|
|
try:
|
|
with preview_store.write_lock:
|
|
marker = preview_location.write_marker(folder, forget)
|
|
except (OSError, ValueError):
|
|
return False
|
|
if not marker["keys"] and marker.get("created_by_pm"):
|
|
names = set(os.listdir(folder))
|
|
rest = {n for n in names if not files.is_os_metadata(n)}
|
|
if rest <= {preview_location.MARKER_NAME}:
|
|
_remove(preview_location.marker_path(folder))
|
|
files.remove_empty_folders(folder)
|
|
return complete
|
|
|
|
def _remove_store(self, store, folder, job):
|
|
"""Remove the previews and temps of one old store; True when done."""
|
|
store = Path(store)
|
|
if folder:
|
|
# A key folder directly in the old thumbnail folder, nothing else.
|
|
if not preview_location.valid_key(store.name) or os.path.normcase(
|
|
str(store.parent)
|
|
) != os.path.normcase(str(folder)):
|
|
return True
|
|
elif store.name != "thumbnails":
|
|
return True # a hand-edited entry never names another folder
|
|
if not store.exists():
|
|
return True
|
|
if preview_location.is_link_like(store):
|
|
return True # never through a link; nothing of ours there
|
|
complete = True
|
|
removed = 0
|
|
for current, dirs, names in os.walk(store):
|
|
dirs[:] = [
|
|
d for d in dirs if not preview_location.is_link_like(Path(current) / d)
|
|
]
|
|
for name in names:
|
|
path = Path(current) / name
|
|
if files.is_temp_name(name) or files._preview_parts(name):
|
|
if preview_location.is_link_like(path):
|
|
continue
|
|
if exit_signal.requested():
|
|
return False # exiting: the next start finishes it
|
|
if files.remove_file(path):
|
|
removed += 1
|
|
else:
|
|
complete = False
|
|
relative = path.relative_to(store).as_posix()
|
|
self.logger.warning(
|
|
f"Could not remove the old thumbnail {path}; it is "
|
|
"removed at the next start"
|
|
)
|
|
if job is not None:
|
|
# D10A: a removal that failed is an error the job lists.
|
|
job.record(
|
|
"errors",
|
|
{
|
|
"path": relative,
|
|
"root": store.name,
|
|
"detail": "In use by another program; removed "
|
|
"at the next start",
|
|
},
|
|
f"In use by another program; removed at the next "
|
|
f"start: {store.name}/{relative}",
|
|
)
|
|
files.remove_empty_folders(store)
|
|
if job is not None and removed:
|
|
job.log_line(f"Removed {_number(removed)} old files from {store.name}")
|
|
return complete
|
|
|
|
def _commit_cleanup(self, remaining):
|
|
from ..config import PromptManagerConfig
|
|
from ..config_store import get_config_store
|
|
|
|
def mutate(config):
|
|
section = config.get("thumbnails")
|
|
if isinstance(section, dict) and isinstance(section.get("location"), dict):
|
|
section["location"]["cleanup"] = remaining
|
|
return config
|
|
|
|
def publish(config):
|
|
value = (config.get("thumbnails") or {}).get("location")
|
|
PromptManagerConfig.THUMBNAIL_LOCATION = value
|
|
preview_store.set_cleanup(remaining)
|
|
|
|
get_config_store().commit(mutate, expected_revision=None, publish=publish)
|
|
|
|
|
|
def plan_parent_missing(store):
|
|
"""A key folder's parent (the thumbnail folder) is gone mid-copy."""
|
|
return not Path(store).parent.is_dir()
|