Compare commits

...

11 Commits

63 changed files with 12466 additions and 142 deletions

View File

@@ -102,3 +102,70 @@ work_item/scripts/python -m pytest tests/e2e -m phase_c -q
The deterministic naming provider is enabled only by test configuration
(`PHOTO_PIPELINE_FAKE_NAMING_LOG`); without it the application falls back to the
offline naming-policy name. Phase A and B suites remain green in the full run above.
### Phase D acceptance gate
Phase D (Epic E04: guarded renaming) is the first phase that changes the library on
disk, so its gate is the strictest. One command runs the rename API journeys, the
filesystem fault injection, and the browser suite:
```bash
work_item/scripts/python -m pytest tests/e2e -m phase_d -q
```
- `tests/e2e/test_phase_d_pipeline.py` drives a real server over HTTP: plan and
export, confirmation with the plan version and checksum (a stale token is refused
without touching disk), a valid apply, the case-only rename procedure, a collision
whose occupant survives, a source that changed after planning, and durability
across a full restart.
- **Fault injection is real.** `PHOTO_PIPELINE_FAULT_AFTER=<journal state>` kills the
server process the instant that state is persisted. The suite crashes it at every
journal transition in turn (`moving`, `moved`, `database_updated`, `verified`),
starts a fresh process against the same database and library, and requires recovery
to converge from journal and disk evidence alone — with the asset set, the stable
IDs, and every content hash unchanged. Ambiguous evidence is never guessed: it stays
classified `manual` and keeps blocking. An unresolved rename is the cancellation
boundary — there is no cancel once a run starts, and unrelated mutations (album
proposal generation and approval) are refused with 409 `rename_recovery_required`
until it is resolved, while reads stay available.
- `tests/e2e/test_renames_ui.py` covers the browser journeys: preview of every
affected path, confirmation carrying the server-issued token, apply with progress
and terminal verification, stale confirmation, collision, interruption, recovery,
rollback, keyboard confirmation, and the view still matching the journal after a
server restart.
The fault barrier is test-only configuration; without `PHOTO_PIPELINE_FAULT_AFTER`
the apply path has no crash points. Phases AC remain green in the full run above.
### Phase E acceptance gate
Phase E (Epic E05: Immich upload) is the one stage the application cannot take back,
so its gate runs the fake-uploader suites, the black-box upload API journeys, and the
browser suite as a single command:
```bash
work_item/scripts/python -m pytest -m phase_e -q
```
- `tests/integration/test_upload_*.py` drive a **real executable** standing in for
`immich-go` through the real adapter and `subprocess` — argument construction,
output bounding, report parsing, verification, and killing a running process.
- `tests/e2e/test_phase_e_pipeline.py` drives a real server and a real durable worker
over HTTP: credential failure and an unreachable server, preflight blockers and the
explicitly approved partial scope, a new album, an exact duplicate, an upgrade, a
retryable failure and its successful retry, a lost acceptance response, verification
against Immich, an inconclusive answer resolved by an operator with evidence, bytes
edited after upload, cancellation and resume, and an interrupted attempt recovered
across a restart.
- **EXIF precedes upload** is asserted, not assumed: an album without its verified
safety and analysis checkpoints cannot be approved, and the uploader's own argv log
proves it was never executed. Each finished upload re-hashes the files in the folder
the uploader was handed and requires the persisted SHA-256/SHA-1 to match.
- **No secret is retained.** The API key is a sentinel string; after a full upload and
verification it must appear in the uploader's argv and nowhere else — not in the
database, the retained report, or any response the browser can read.
- `tests/e2e/test_uploads_ui.py` covers the browser journeys (preflight preview,
confirmation, progress, stopping a run, verification, manual resolution, stale
bytes, and recovery after a restart).
Phases AD remain green in the full run above.

View File

@@ -18,6 +18,7 @@
<a href="#/analyze" data-nav="analyze">Analyze</a>
<a href="#/albums" data-nav="albums">Albums</a>
<a href="#/renames" data-nav="renames">Renames</a>
<a href="#/uploads" data-nav="uploads">Upload</a>
<a href="#/stats" data-nav="stats">Stats</a>
</nav>
</header>

View File

@@ -114,4 +114,28 @@ export const api = {
request(`/rename-plans/${encodeURIComponent(id)}/rollback`, { method: "POST", ...opts }),
renameRecovery: (opts = {}) => request("/rename-recovery", opts),
resolveRecovery: (opts = {}) => request("/rename-recovery/resolve", { method: "POST", ...opts }),
// ── Uploads: preflight, batches, verification ───────────────────────────
// The API key never travels through here: preflight reports only whether one is
// configured, and every command preview arrives already redacted.
uploadPreflight: (payload = {}, opts = {}) =>
request("/upload-preflight", { method: "POST", body: JSON.stringify(payload), ...opts }),
createUploadBatches: (payload, opts = {}) =>
request("/upload-batches", { method: "POST", body: JSON.stringify(payload), ...opts }),
listUploadBatches: (opts = {}) => request("/upload-batches", opts),
getUploadBatch: (id, opts = {}) => request(`/upload-batches/${encodeURIComponent(id)}`, opts),
startUploadBatch: (id, opts = {}) =>
request(`/upload-batches/${encodeURIComponent(id)}/start`, { method: "POST", ...opts }),
cancelUploadBatch: (id, opts = {}) =>
request(`/upload-batches/${encodeURIComponent(id)}/cancel`, { method: "POST", ...opts }),
verifyUploadBatch: (id, opts = {}) =>
request(`/upload-batches/${encodeURIComponent(id)}/verify`, { method: "POST", ...opts }),
resolveUploadItem: (id, payload, opts = {}) =>
request(`/upload-batches/${encodeURIComponent(id)}/resolve`, {
method: "POST",
body: JSON.stringify(payload),
...opts,
}),
uploadVerifications: (id, opts = {}) =>
request(`/upload-batches/${encodeURIComponent(id)}/verifications`, opts),
};

View File

@@ -1,6 +1,7 @@
import { api } from "./api.js";
import { navigate, onRouteChange, parseHash } from "./router.js";
import { renderRenames, setRenamesRender } from "./renames.js";
import { renderUploads, setUploadsRender } from "./uploads.js";
import {
renderAlbums,
renderAnalyze,
@@ -364,6 +365,7 @@ function render() {
else if (path === "/analyze") renderAnalyze(root, params);
else if (path === "/albums") renderAlbums(root, params);
else if (path === "/renames") renderRenames(root, params);
else if (path === "/uploads") renderUploads(root, params);
else if (path === "/stats") renderStats(root, params);
else show(errorBanner("Unknown view"));
}
@@ -371,5 +373,6 @@ function render() {
// Let views re-render the current route after a mutation.
setRender(render);
setRenamesRender(render);
setUploadsRender(render);
onRouteChange(render);
render();

704
frontend/js/uploads.js Normal file
View File

@@ -0,0 +1,704 @@
// Upload view (US05-05): preflight a scope, confirm exactly what will be sent,
// watch the batch run, and resolve whatever the uploader left uncertain.
//
// Two rules shape this file. First, nothing here decides what is safe: blockers,
// the preflight token, and the retry policy all come from the server, and an action
// the server would refuse is not offered at all. Second, the API key never reaches
// the browser — preflight reports only that one is configured, and every command
// preview arrives redacted — so nothing in this view may reconstruct, store, or
// route a secret.
import { api } from "./api.js";
import { el, errorBanner, setActiveNav } from "./dom.js";
import { subscribeJob } from "./events.js";
import { navigate } from "./router.js";
// Result of the last command issued from this tab, and the activity of the last
// upload job. Deliberately not persisted: after a reload the page must show what
// the server says happened, not what this page remembers.
let outcome = null;
let activity = [];
let render = () => {};
export function setUploadsRender(fn) {
render = fn;
}
// Report outcomes (services/upload_reports.py) in the words the operator uses.
const OUTCOME_LABEL = [
["uploaded", "new"],
["upgraded", "upgraded"],
["duplicate", "duplicate"],
["skipped", "skipped"],
["failed", "failed"],
["unknown", "uncertain"],
];
export async function renderUploads(root, params = {}) {
setActiveNav("uploads");
const albums = parseAlbums(params.albums);
const allowPartial = params.partial === "1";
let preflight, listed;
try {
[preflight, listed] = await Promise.all([
api.uploadPreflight({ albums, allow_partial: allowPartial }),
api.listUploadBatches(),
]);
} catch (error) {
root.replaceChildren(errorBanner(`Failed to load uploads: ${error.message}`));
return;
}
const batches = listed.batches;
const selectedId = params.batch || (batches.length ? batches[batches.length - 1].id : null);
let batch = null;
let history = [];
if (selectedId) {
try {
const [detail, verifications] = await Promise.all([
api.getUploadBatch(selectedId),
api.uploadVerifications(selectedId),
]);
batch = detail;
history = verifications.verifications;
} catch (error) {
root.replaceChildren(errorBanner(`Failed to load upload batch: ${error.message}`));
return;
}
}
root.replaceChildren(
...[
el("h1", {}, "Upload"),
configurationCard(preflight),
preflightBlockers(preflight),
scopeSection(preflight, params, albums, allowPartial),
confirmBlock(preflight, params, allowPartial),
outcomeBanner(),
activityLog(),
batchList(batches, selectedId),
batch ? batchDetail(batch, history) : null,
].filter(Boolean)
);
}
// Album names can contain commas, so each one is escaped before the URL joins them.
function parseAlbums(value) {
if (!value) return null;
const names = value.split(",").filter(Boolean).map(decodeURIComponent);
return names.length ? names : null;
}
function albumsParam(names) {
return names.map(encodeURIComponent).join(",");
}
// ── configuration ────────────────────────────────────────────────────────────
// What the upload is aimed at, in the only form the browser is ever given: the
// server URL, whether a key exists, and the uploader's version.
function configurationCard(preflight) {
const credentials = preflight.credentials;
const uploader = preflight.uploader;
return el(
"div",
{ class: "card", "data-testid": "upload-config" },
el("h2", {}, "Configuration"),
el(
"dl",
{},
el("dt", {}, "Immich server"),
el("dd", { "data-testid": "config-server" }, credentials.server_url || "not configured"),
el("dt", {}, "API key"),
// Presence, never the value — and never a length or prefix either.
el(
"dd",
{ "data-testid": "config-key" },
credentials.api_key_configured ? "configured (never shown)" : "missing"
),
el("dt", {}, "Reachable"),
el(
"dd",
{ "data-testid": "config-reachable" },
preflight.server.reachable ? "yes" : `no — ${preflight.server.detail || "unknown"}`
),
el("dt", {}, "Uploader"),
el(
"dd",
{ "data-testid": "config-uploader" },
uploader.installed ? uploader.version || "installed" : `${uploader.binary} is not installed`
)
)
);
}
function preflightBlockers(preflight) {
if (!preflight.blockers.length) return null;
return el(
"div",
{ class: "alert", role: "alert", "data-testid": "preflight-blockers" },
el("strong", {}, "This scope cannot be uploaded yet"),
el(
"ul",
{},
...preflight.blockers.map((blocker) =>
el(
"li",
{ "data-testid": "preflight-blocker", "data-code": blocker.code },
`${blocker.code}: ${blocker.message}`
)
)
)
);
}
// ── scope ────────────────────────────────────────────────────────────────────
function scopeSection(preflight, params, albums, allowPartial) {
const selected = new Set(albums || preflight.albums.map((album) => album.album));
function toggle(name, checked) {
const next = new Set(selected);
if (checked) next.add(name);
else next.delete(name);
// An empty selection means "everything" again, which is also what an absent
// parameter means — there is no way to preflight nothing.
navigate("/uploads", { ...params, albums: albumsParam([...next]), batch: params.batch });
}
const rows = preflight.albums.map((album) =>
el(
"tr",
{ "data-testid": "album-row", "data-album": album.album },
el(
"td",
{},
el("input", {
type: "checkbox",
"data-testid": "album-selected",
"aria-label": `Include ${album.album}`,
checked: selected.has(album.album) ? "checked" : false,
onchange: (event) => toggle(album.album, event.target.checked),
})
),
el("td", { "data-testid": "album-name" }, album.album),
// What Immich will call it, which is not always what the folder is called here.
el("td", { "data-testid": "album-immich-name" }, album.album_name),
el("td", { class: "path", "data-testid": "album-folder" }, album.folder),
el("td", { "data-testid": "album-eligible" }, String(album.eligible_count)),
el("td", { "data-testid": "album-blocked" }, String(album.blocked_count)),
el(
"td",
{},
el("span", { class: `badge ${album.state}`, "data-testid": "album-state" }, album.state),
...album.blockers.map((blocker) =>
el(
"div",
{ class: "blocker", "data-testid": "album-blocker", "data-code": blocker.code },
blocker.message
)
)
),
// The exact invocation, as the server built it. Shown so the upload holds no
// surprises; the key is masked at the source, not here.
el("td", { class: "path", "data-testid": "album-command" }, album.command_preview.join(" "))
)
);
const totals = preflight.totals;
return el(
"div",
{ "data-testid": "upload-scope" },
el("h2", {}, "Scope"),
el(
"div",
{ class: "decision-bar" },
el("span", { class: "badge", "data-testid": "total-albums" }, `${totals.albums} album(s)`),
el("span", { class: "badge", "data-testid": "total-eligible" }, `${totals.eligible} ready`),
totals.blocked
? el(
"span",
{ class: "badge attention", "data-testid": "total-blocked" },
`${totals.blocked} blocked`
)
: null,
el(
"label",
{},
el("input", {
type: "checkbox",
"data-testid": "allow-partial",
checked: allowPartial ? "checked" : false,
onchange: (event) =>
navigate("/uploads", { ...params, partial: event.target.checked ? "1" : "" }),
}),
" Upload ready photos and leave the blocked ones behind"
)
),
rows.length
? el(
"table",
{ class: "grid", "data-testid": "albums" },
el(
"thead",
{},
el(
"tr",
{},
...["", "Album", "Immich album", "Folder", "Ready", "Blocked", "State", "Command"].map(
(label) => el("th", { scope: "col" }, label)
)
)
),
el("tbody", {}, ...rows)
)
: el("p", { class: "muted", "data-testid": "no-albums" }, "No album is ready to upload.")
);
}
// ── confirmation ─────────────────────────────────────────────────────────────
function confirmBlock(preflight, params, allowPartial) {
const ready = preflight.state === "ready";
const totals = preflight.totals;
const albums = preflight.albums.map((album) => album.album);
return el(
"div",
{ class: "card", "data-testid": "confirm" },
el("h2", {}, "Confirm"),
// The token is shown, not merely sent: a confirmation the user cannot see is a
// confirmation they cannot check against the preview above.
el(
"p",
{ class: "muted", "data-testid": "confirm-token" },
`Preflight ${preflight.token.slice(0, 20)}… · ${allowPartial ? "partial" : "complete"} scope`
),
el(
"p",
{ "data-testid": "upload-note" },
"Uploading is not reversible from here: Immich decides what to do with each " +
"file, and the app can only record what it reports. One album is sent at a " +
"time and a running album can be stopped."
),
el(
"div",
{ class: "toolbar" },
el(
"button",
{
class: "primary",
"data-testid": "start-upload",
disabled: ready ? false : "disabled",
title: ready ? false : "resolve the blockers above first",
onclick: () =>
run(async () => {
const created = await api.createUploadBatches({
albums,
token: preflight.token,
allow_partial: allowPartial,
});
// One lane: the first batch starts now, the rest wait with their own
// start buttons rather than queueing behind a lock that would reject
// them.
const first = created.batches.find((batch) => !batch.retry_blockers.length);
if (!first) return { batches: created.batches.length, started: null };
const started = await api.startUploadBatch(first.id);
watch(started.job.id, first.id);
return { batches: created.batches.length, started: first.album };
}),
},
`Upload ${totals.albums} album(s) · ${totals.eligible} photo(s)`
)
)
);
}
// ── batches ──────────────────────────────────────────────────────────────────
function batchList(batches, selectedId) {
if (!batches.length) {
return el("p", { class: "muted", "data-testid": "no-batches" }, "No upload has been started yet.");
}
return el(
"div",
{ "data-testid": "upload-batches" },
el("h2", {}, "Batches"),
el(
"table",
{ class: "grid", "data-testid": "batches" },
el(
"thead",
{},
el(
"tr",
{},
...["Album", "State", "Evidence", "Attempts", "Photos"].map((label) =>
el("th", { scope: "col" }, label)
)
)
),
el(
"tbody",
{},
...batches.map((batch) =>
el(
"tr",
{
"data-testid": "batch-row",
"data-album": batch.album,
"aria-current": batch.id === selectedId ? "true" : false,
},
el(
"td",
{},
el("a", { class: "link", href: `#/uploads?batch=${encodeURIComponent(batch.id)}` }, batch.album)
),
el(
"td",
{},
el("span", { class: `badge ${batch.state}`, "data-testid": "batch-state" }, batch.state)
),
el("td", { "data-testid": "batch-outcome-state" }, batch.outcome_state || "not parsed"),
el("td", {}, String(batch.attempt_count)),
el("td", {}, String(batch.asset_count))
)
)
)
)
);
}
function batchDetail(batch, history) {
// Derived from the items, not from the batch's parsed-report summary: verifying
// or resolving an item changes what is true without re-parsing a report, and the
// progress line must show the current answer rather than the uploader's old one.
const counts = {};
for (const item of batch.items) {
const key = item.outcome || "unknown";
counts[key] = (counts[key] || 0) + 1;
}
const blockers = batch.retry_blockers;
const uncertain = batch.outcome_state === "requires_verification" || counts.unknown > 0;
const nodes = [
el("h2", {}, `${batch.album} — attempt ${batch.attempt_count}`),
el(
"div",
{ class: "decision-bar", "data-testid": "batch-progress" },
el("span", { class: `badge ${batch.state}`, "data-testid": "detail-state" }, batch.state),
...OUTCOME_LABEL.map(([key, label]) =>
el(
"span",
{ class: `badge ${key}`, "data-testid": `count-${label}` },
`${label}: ${counts[key] ?? 0}`
)
)
),
];
if (batch.stale_bytes) {
nodes.push(
el(
"div",
{ class: "alert", role: "alert", "data-testid": "stale-bytes" },
"Files in this batch changed after they were uploaded. Immich still holds the " +
"bytes that were sent; re-approve the album through a fresh preflight rather " +
"than uploading the new bytes over it."
)
);
}
if (batch.error_code) {
nodes.push(
el(
"div",
{ class: "alert", role: "alert", "data-testid": "batch-error" },
`${batch.error_code}: ${batch.error_message || ""}`
)
);
}
if (uncertain) {
nodes.push(
el(
"div",
{ class: "alert", role: "alert", "data-testid": "uncertain" },
el("strong", {}, "This upload's outcome is not fully known"),
el(
"p",
{},
"The uploader's report does not account for every file. Retrying could create " +
"a second copy of something Immich already accepted, so verify it first: the " +
"check asks Immich whether it holds the exact bytes that were sent."
)
)
);
}
nodes.push(
el(
"div",
{ class: "toolbar" },
// A start button exists only when the server would accept one. An uncertain
// outcome and changed bytes therefore offer verification, never a retry.
blockers.length
? el(
"div",
{ class: "blocker", "data-testid": "retry-blocked" },
blockers.map((blocker) => `${blocker.code}: ${blocker.message}`).join("; ")
)
: el(
"button",
{
class: "primary",
"data-testid": "start-batch",
onclick: () =>
run(async () => {
const started = await api.startUploadBatch(batch.id);
watch(started.job.id, batch.id);
return { started: batch.album };
}),
},
batch.attempt_count ? "Run this album again" : "Upload this album"
),
["planned", "running"].includes(batch.state)
? el(
"button",
{
"data-testid": "cancel-batch",
onclick: () => run(() => api.cancelUploadBatch(batch.id)),
},
batch.state === "running" ? "Stop after the current file" : "Cancel this album"
)
: null,
// Offered for anything that has run, not only for uncertain outcomes:
// re-checking is read-only and idempotent, and it is how a file edited after
// its upload is discovered.
batch.attempt_count
? el(
"button",
{
"data-testid": "verify-batch",
onclick: () => run(() => api.verifyUploadBatch(batch.id)),
},
"Verify against Immich"
)
: null
),
itemsTable(batch)
);
if (history.length) nodes.push(historyList(history));
return el(
"div",
{ class: "card", "data-testid": "batch-detail", "data-batch": batch.id },
...nodes
);
}
function itemsTable(batch) {
if (!batch.items.length) {
return el("p", { class: "muted", "data-testid": "no-items" }, "This batch has no photos.");
}
return el(
"table",
{ class: "grid", "data-testid": "items" },
el(
"thead",
{},
el(
"tr",
{},
...["Photo", "Outcome", "Evidence", "Verification", "Bytes now", "Resolve"].map((label) =>
el("th", { scope: "col" }, label)
)
)
),
el(
"tbody",
{},
...batch.items.map((item) =>
el(
"tr",
{ "data-testid": "item-row", "data-asset-id": item.asset_id },
el("td", { class: "path", "data-testid": "item-path" }, item.path),
el(
"td",
{},
el(
"span",
{ class: `badge ${item.outcome || ""}`, "data-testid": "item-outcome" },
item.outcome === "uploaded" ? "new" : item.outcome || "pending"
)
),
el("td", { class: "muted", "data-testid": "item-evidence" }, item.evidence || "—"),
el("td", { "data-testid": "item-verification" }, item.verification || "—"),
el(
"td",
{},
item.changed_after_upload
? el("span", { class: "badge attention", "data-testid": "item-changed" }, "changed")
: el("span", { class: "muted" }, "unchanged")
),
el("td", {}, resolveForm(batch, item))
)
)
)
);
}
// Manual resolution is evidence, not permission: the note and the author are
// required by the server, so the form collects both and offers no default.
function resolveForm(batch, item) {
const unresolved = !item.outcome || item.outcome === "unknown" || item.verification === "inconclusive";
if (!unresolved) return el("span", { class: "muted" }, "—");
const outcomeSelect = el(
"select",
{ "data-testid": "resolve-outcome", "aria-label": `Outcome for ${item.path}` },
...OUTCOME_LABEL.map(([key, label]) => el("option", { value: key }, label))
);
const evidence = el("input", {
type: "text",
"data-testid": "resolve-evidence",
"aria-label": `What you checked for ${item.path}`,
placeholder: "What did you check?",
});
const actor = el("input", {
type: "text",
"data-testid": "resolve-actor",
"aria-label": `Who checked ${item.path}`,
placeholder: "Who are you?",
});
return el(
"div",
{ class: "toolbar" },
outcomeSelect,
evidence,
actor,
el(
"button",
{
"data-testid": "resolve-item",
onclick: () =>
run(() =>
api.resolveUploadItem(batch.id, {
asset_id: item.asset_id,
outcome: outcomeSelect.value,
evidence: evidence.value,
actor: actor.value,
})
),
},
"Record"
)
);
}
function historyList(history) {
return el(
"div",
{ "data-testid": "verification-history" },
el("h3", {}, "Verification history"),
el(
"ul",
{},
...history.map((entry) =>
el(
"li",
{ "data-testid": "history-entry", "data-source": entry.source },
`${entry.created_at || ""} · ${entry.action} · ${entry.source} · ${entry.result}` +
`${entry.outcome || "unresolved"}${entry.evidence}` +
(entry.actor ? ` (${entry.actor})` : "")
)
)
)
);
}
// ── running commands ─────────────────────────────────────────────────────────
async function run(action) {
try {
outcome = { kind: "ok", result: await action() };
} catch (error) {
outcome = error.status === 409 ? { kind: "conflict", error } : { kind: "error", error };
}
render();
}
// Live job activity. Events are appended to the log node and the running batch's
// panel is refreshed on its own tick — the uploader reports per file, not per job
// event, so waiting for the next event would leave the panel behind. Only that
// panel is rebuilt: a full re-render would re-run preflight, which re-hashes the
// library, so that happens once when the job ends.
const REFRESH_MS = 1000;
function watch(jobId, batchId) {
activity = [`Started upload job ${jobId}`];
const tick = setInterval(() => refreshBatch(batchId), REFRESH_MS);
subscribeJob(jobId, {
onEvent: (event) => {
activity.push(`${event.type}${event.message ? ": " + event.message : ""}`);
const log = document.querySelector('[data-testid="upload-activity"]');
if (log) log.textContent = activity.join("\n");
},
onDone: () => {
clearInterval(tick);
activity.push("done");
render();
},
});
}
async function refreshBatch(batchId) {
const node = document.querySelector(`[data-testid="batch-detail"][data-batch="${batchId}"]`);
if (!node) return; // the user navigated away from the running batch
try {
const [batch, verifications] = await Promise.all([
api.getUploadBatch(batchId),
api.uploadVerifications(batchId),
]);
node.replaceWith(batchDetail(batch, verifications.verifications));
} catch (_) {
// Transient: the next tick tries again, and the job's end re-renders anyway.
}
}
function activityLog() {
return el(
"pre",
{
class: "activity-log",
role: "status",
"aria-live": "polite",
"data-testid": "upload-activity",
},
activity.join("\n")
);
}
function outcomeBanner() {
if (!outcome) return null;
if (outcome.kind === "conflict") {
return el(
"div",
{ class: "alert", role: "alert", "data-testid": "conflict" },
`The server refused this: ${outcome.error.message}. Nothing was uploaded; the ` +
"state below is the server's current one — review it and decide again."
);
}
if (outcome.kind === "error") {
return el(
"div",
{ class: "alert", role: "alert", "data-testid": "upload-error" },
`Failed: ${outcome.error.message}`
);
}
const result = outcome.result;
if (result && result.batches !== undefined) {
return el(
"div",
{ class: "alert", role: "status", "data-testid": "upload-result" },
`Approved ${result.batches} album batch(es).` +
(result.started ? ` Uploading ${result.started} now.` : " Nothing could be started yet.")
);
}
return el(
"div",
{ class: "alert", role: "status", "data-testid": "upload-result" },
"Done — the state below is the server's."
);
}

View File

@@ -0,0 +1,91 @@
"""Upload batches and their items (US05-02).
Revision ID: 0008_upload_batches
Revises: 0007_rename_plans
Create Date: 2026-08-16
One row per approved album folder handed to immich-go, plus the per-asset
pre-upload hashes that later stories verify the result against.
"""
import sqlalchemy as sa
from alembic import op
revision = "0008_upload_batches"
down_revision = "0007_rename_plans"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"upload_batches",
sa.Column("id", sa.String(), primary_key=True),
sa.Column("album", sa.String(), nullable=False),
sa.Column("folder", sa.String(), nullable=False),
sa.Column("album_name", sa.String(), nullable=False),
# planned | running | cancelling | cancelled | succeeded | failed
# | unknown_requires_verification
sa.Column("state", sa.String(), nullable=False, server_default="planned"),
# Preflight token this batch was approved against; re-checked before every
# attempt, so changed bytes or decisions cannot be uploaded silently.
sa.Column("preflight_token", sa.String(), nullable=False),
sa.Column("allow_partial", sa.Boolean(), nullable=False, server_default="0"),
sa.Column("command", sa.String(), nullable=True), # JSON array, redacted
sa.Column("uploader_version", sa.String(), nullable=True),
sa.Column("asset_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("attempt_count", sa.Integer(), nullable=False, server_default="0"),
# Bumped on every claim and used as the fencing token.
sa.Column("version", sa.Integer(), nullable=False, server_default="1"),
sa.Column("worker_id", sa.String(), nullable=True),
sa.Column("report_path", sa.String(), nullable=True),
sa.Column("report_bytes", sa.Integer(), nullable=True),
sa.Column("report_truncated", sa.Boolean(), nullable=False, server_default="0"),
sa.Column("exit_code", sa.Integer(), nullable=True),
sa.Column("error_code", sa.String(), nullable=True),
sa.Column("error_message", sa.String(), nullable=True),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
)
op.create_index("ix_upload_batches_album", "upload_batches", ["album"])
op.create_index("ix_upload_batches_state", "upload_batches", ["state"])
op.create_table(
"upload_items",
sa.Column(
"batch_id",
sa.String(),
sa.ForeignKey("upload_batches.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column("asset_id", sa.String(), primary_key=True),
sa.Column("path", sa.String(), nullable=False),
# The bytes as they were when the batch was created; SHA-1 is what Immich
# uses to recognise a file it already holds.
sa.Column("sha256", sa.String(), nullable=True),
sa.Column("sha1", sa.String(), nullable=True),
sa.Column("state", sa.String(), nullable=False, server_default="pending"),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
)
def downgrade() -> None:
op.drop_table("upload_items")
op.drop_table("upload_batches")

View File

@@ -0,0 +1,52 @@
"""Parsed uploader outcomes (US05-03).
Revision ID: 0009_upload_report_outcomes
Revises: 0008_upload_batches
Create Date: 2026-08-16
Per-item outcomes parsed from the immich-go report plus the parser evidence that
produced them, so "what did Immich do with this file?" survives a restart.
"""
import sqlalchemy as sa
from alembic import op
revision = "0009_upload_report_outcomes"
down_revision = "0008_upload_batches"
branch_labels = None
depends_on = None
def upgrade() -> None:
# NULL parser = the uploader version has no pinned grammar; the batch is then
# requires_verification however the process exited.
op.add_column("upload_batches", sa.Column("parser", sa.String(), nullable=True))
op.add_column("upload_batches", sa.Column("parser_version", sa.Integer(), nullable=True))
op.add_column(
"upload_batches", sa.Column("parsed_at", sa.DateTime(timezone=True), nullable=True)
)
# verified | requires_verification
op.add_column("upload_batches", sa.Column("outcome_state", sa.String(), nullable=True))
op.add_column("upload_batches", sa.Column("outcome_counts", sa.String(), nullable=True))
op.add_column("upload_batches", sa.Column("report_counts", sa.String(), nullable=True))
# uploaded | upgraded | duplicate | skipped | failed | unknown
op.add_column("upload_items", sa.Column("outcome", sa.String(), nullable=True))
op.add_column("upload_items", sa.Column("evidence", sa.String(), nullable=True))
op.add_column(
"upload_items", sa.Column("outcome_at", sa.DateTime(timezone=True), nullable=True)
)
def downgrade() -> None:
for column in ("outcome_at", "evidence", "outcome"):
op.drop_column("upload_items", column)
for column in (
"report_counts",
"outcome_counts",
"outcome_state",
"parsed_at",
"parser_version",
"parser",
):
op.drop_column("upload_batches", column)

View File

@@ -0,0 +1,70 @@
"""Upload verification and manual resolution (US05-04).
Revision ID: 0010_upload_verification
Revises: 0009_upload_report_outcomes
Create Date: 2026-08-16
Per-item server evidence plus the append-only history that produced it, so an
uncertain upload can be resolved without ever guessing that it succeeded.
"""
import sqlalchemy as sa
from alembic import op
revision = "0010_upload_verification"
down_revision = "0009_upload_report_outcomes"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"upload_batches", sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True)
)
# At least one uploaded file was edited afterwards: a visible warning that also
# blocks re-running the batch.
op.add_column(
"upload_batches",
sa.Column("stale_bytes", sa.Boolean(), nullable=False, server_default=sa.false()),
)
# present | absent | inconclusive | manual
op.add_column("upload_items", sa.Column("verification", sa.String(), nullable=True))
op.add_column(
"upload_items", sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True)
)
op.add_column("upload_items", sa.Column("observed_sha256", sa.String(), nullable=True))
op.add_column(
"upload_items",
sa.Column("changed_after_upload", sa.Boolean(), nullable=False, server_default=sa.false()),
)
op.create_table(
"upload_verifications",
sa.Column("id", sa.String(), primary_key=True),
sa.Column(
"batch_id",
sa.String(),
sa.ForeignKey("upload_batches.id", ondelete="CASCADE"),
nullable=False,
index=True,
),
sa.Column("asset_id", sa.String(), nullable=False),
sa.Column("action", sa.String(), nullable=False),
sa.Column("source", sa.String(), nullable=False),
sa.Column("result", sa.String(), nullable=False),
sa.Column("outcome", sa.String(), nullable=True),
sa.Column("evidence", sa.String(), nullable=False),
sa.Column("actor", sa.String(), nullable=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
)
def downgrade() -> None:
op.drop_table("upload_verifications")
for column in ("changed_after_upload", "observed_sha256", "verified_at", "verification"):
op.drop_column("upload_items", column)
for column in ("stale_bytes", "verified_at"):
op.drop_column("upload_batches", column)

View File

@@ -0,0 +1,40 @@
"""Archive locations (US06-01).
Revision ID: 0011_archive_locations
Revises: 0010_upload_verification
Create Date: 2026-08-16
Configured archive destinations with their stable media identity, last probed
capabilities, and state.
"""
import sqlalchemy as sa
from alembic import op
revision = "0011_archive_locations"
down_revision = "0010_upload_verification"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"archive_locations",
sa.Column("id", sa.String(), primary_key=True),
sa.Column("name", sa.String(), nullable=False, unique=True),
sa.Column("root", sa.String(), nullable=False),
sa.Column("media_id", sa.String(), nullable=False, unique=True),
sa.Column("capabilities", sa.String(), nullable=True),
sa.Column("state", sa.String(), nullable=False, server_default="offline"),
sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
)
def downgrade() -> None:
op.drop_table("archive_locations")

View File

@@ -0,0 +1,96 @@
"""Archive plans, per-item transfer journal, and archived asset location (US06-02).
Revision ID: 0012_archive_plans
Revises: 0011_archive_locations
Create Date: 2026-08-16
The journal is what makes removing an original recoverable: every item records its
source, destination, expected hash, and the state it had reached before the process
died. Assets gain the archive location and relative path so an offline original is
still explained rather than looking missing.
"""
import sqlalchemy as sa
from alembic import op
revision = "0012_archive_plans"
down_revision = "0011_archive_locations"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"archive_plans",
sa.Column("id", sa.String(), primary_key=True),
sa.Column(
"location_id",
sa.String(),
sa.ForeignKey("archive_locations.id"),
nullable=False,
index=True,
),
# The preflight token this plan was approved against.
sa.Column("token", sa.String(), nullable=False),
sa.Column("albums", sa.String(), nullable=True), # JSON array
# planned | applying | complete | failed
sa.Column("state", sa.String(), nullable=False, server_default="planned"),
sa.Column("schema_version", sa.Integer(), nullable=False, server_default="1"),
sa.Column("asset_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("byte_size", sa.Integer(), nullable=False, server_default="0"),
# Bumped on every claim and used as the fencing token.
sa.Column("version", sa.Integer(), nullable=False, server_default="1"),
sa.Column("worker_id", sa.String(), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
)
op.create_table(
"archive_operations",
sa.Column("id", sa.String(), primary_key=True),
sa.Column(
"plan_id",
sa.String(),
sa.ForeignKey("archive_plans.id", ondelete="CASCADE"),
nullable=False,
index=True,
),
sa.Column("sequence", sa.Integer(), nullable=False),
sa.Column("album", sa.String(), nullable=False),
sa.Column("asset_id", sa.String(), sa.ForeignKey("assets.id"), nullable=False, index=True),
sa.Column("source_path", sa.String(), nullable=False),
sa.Column("destination_path", sa.String(), nullable=False),
# Relative to the location root: the medium can be mounted anywhere later.
sa.Column("archive_path", sa.String(), nullable=False),
sa.Column("expected_sha256", sa.String(), nullable=False),
sa.Column("byte_size", sa.Integer(), nullable=True),
sa.Column("same_filesystem", sa.Boolean(), nullable=True),
# planned | transferring | verified | removing | complete | failed
sa.Column("journal_state", sa.String(), nullable=False, server_default="planned"),
sa.Column("attempt_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("fencing_token", sa.Integer(), nullable=True),
sa.Column("worker_id", sa.String(), nullable=True),
sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("removed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("error_code", sa.String(), nullable=True),
sa.Column("error_message", sa.String(), nullable=True),
sa.Column(
"updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.UniqueConstraint("plan_id", "sequence", name="uq_archive_operations_plan_sequence"),
)
# Plain columns: SQLite cannot ALTER a table to add a foreign key, and the
# relationship is enforced by the service that writes them.
op.add_column("assets", sa.Column("archive_location_id", sa.String(), nullable=True))
op.add_column("assets", sa.Column("archive_path", sa.String(), nullable=True))
def downgrade() -> None:
op.drop_column("assets", "archive_path")
op.drop_column("assets", "archive_location_id")
op.drop_table("archive_operations")
op.drop_table("archive_plans")

View File

@@ -0,0 +1,29 @@
"""Protected thumbnails (US06-03).
Revision ID: 0013_protected_thumbnails
Revises: 0012_archive_plans
Create Date: 2026-08-16
A protected thumbnail is the durable comparison preview of an asset whose
original has left active storage. It is evidence rather than cache, so the LRU
quota must not evict it: the archive medium may be offline when it is needed.
"""
import sqlalchemy as sa
from alembic import op
revision = "0013_protected_thumbnails"
down_revision = "0012_archive_plans"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"thumbnails",
sa.Column("protected", sa.Boolean(), nullable=False, server_default=sa.false()),
)
def downgrade() -> None:
op.drop_column("thumbnails", "protected")

View File

@@ -0,0 +1,37 @@
"""Restore plans and archive divergence (US06-04).
Revision ID: 0014_restore_plans
Revises: 0013_protected_thumbnails
Create Date: 2026-08-16
Restore reuses the archive plan and journal tables: the crash-safe question is the
same one in the opposite direction (copy, verify, publish, register), so the rows
gain a ``direction`` instead of a parallel pair of tables. ``archive_divergent_at``
records the moment an archived copy was proven to hold bytes that are not the ones
the database recorded — a restore must never silently accept a different file.
"""
import sqlalchemy as sa
from alembic import op
revision = "0014_restore_plans"
down_revision = "0013_protected_thumbnails"
branch_labels = None
depends_on = None
def upgrade() -> None:
for table in ("archive_plans", "archive_operations"):
op.add_column(
table,
sa.Column("direction", sa.String(), nullable=False, server_default="archive"),
)
op.add_column(
"assets", sa.Column("archive_divergent_at", sa.DateTime(timezone=True), nullable=True)
)
def downgrade() -> None:
op.drop_column("assets", "archive_divergent_at")
for table in ("archive_plans", "archive_operations"):
op.drop_column(table, "direction")

View File

@@ -36,7 +36,7 @@ def main(argv: Sequence[str] | None = None) -> int:
run_migrations(config.database_url)
engine = create_db_engine(config.database_url)
Worker(create_session_factory(engine), worker_id=args.id).run_forever()
Worker(create_session_factory(engine), worker_id=args.id, config=config).run_forever()
return 0
import uvicorn

View File

@@ -17,6 +17,7 @@ from fastapi.staticfiles import StaticFiles
from photo_pipeline.api.routes import (
albums,
analysis,
archives,
duplicates,
health,
inventory,
@@ -25,6 +26,7 @@ from photo_pipeline.api.routes import (
renames,
safety,
thumbnails,
uploads,
workflow,
)
@@ -33,6 +35,7 @@ import photo_pipeline.jobs.domain_handlers # noqa: F401
from photo_pipeline.config import Config
from photo_pipeline.db import create_db_engine, create_session_factory, run_migrations
from photo_pipeline.logging import configure_logging
from photo_pipeline.services.upload_batches import UploadBatchService
FRONTEND_DIR = Path(__file__).resolve().parents[2] / "frontend"
@@ -49,6 +52,9 @@ def create_app(config: Config | None = None) -> FastAPI:
app.state.config = config
app.state.engine = engine
app.state.session_factory = create_session_factory(engine)
# An upload whose process died left no outcome behind; resolve it now so the
# uploader lane is free and the uncertain batch is visible (US05-02).
UploadBatchService(app.state.session_factory, config=config).recover()
try:
yield
finally:
@@ -67,6 +73,8 @@ def create_app(config: Config | None = None) -> FastAPI:
app.include_router(library.router, prefix="/api/v1")
app.include_router(albums.router, prefix="/api/v1")
app.include_router(renames.router, prefix="/api/v1")
app.include_router(uploads.router, prefix="/api/v1")
app.include_router(archives.router, prefix="/api/v1")
# Static single-page app (hash-routed). Mounted last so /api/v1 wins.
if FRONTEND_DIR.is_dir():
app.mount("/app", StaticFiles(directory=FRONTEND_DIR, html=True), name="app")

View File

@@ -0,0 +1,234 @@
"""Archive location, preflight, plan, and restore API (US06-01, US06-02, US06-04).
Registering a location writes a marker onto the medium; preflight is a command
rather than a read, because it probes the destination, hashes the scope, and issues
the token an archive plan must present. Creating a plan writes only database rows —
the transfer itself runs on the durable ``archive`` lane, never in the request
thread, because it removes originals.
"""
from __future__ import annotations
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from photo_pipeline.jobs.domain_handlers import ARCHIVE_LOCK, ARCHIVE_PLAN, RESTORE_PLAN
from photo_pipeline.services.archives import ArchiveError, ArchiveService
from photo_pipeline.services.archive_transfer import ArchiveTransferService
from photo_pipeline.services.jobs import JobBlocked, JobService
from photo_pipeline.services.restores import RestoreService
router = APIRouter(tags=["archives"])
# Which failures are the caller's request (422), a missing thing (404), or state
# that changed under the caller (409).
NOT_FOUND_CODES = {"unknown_location", "unknown_plan"}
CONFLICT_CODES = {"stale_token", "stale_plan", "archive_pending"}
class RegisterLocationRequest(BaseModel):
name: str
root: str
class PreflightRequest(BaseModel):
location_id: str
# ``None`` means every album; an explicit list scopes the check.
albums: list[str] | None = None
class CreatePlanRequest(PreflightRequest):
# The token of the preflight the user approved; a stale one is refused.
token: str
class RestoreRequest(BaseModel):
location_id: str
# ``None`` means every asset archived at this location.
asset_ids: list[str] | None = None
class CreateRestoreRequest(RestoreRequest):
token: str
def _service(request: Request) -> ArchiveService:
return ArchiveService(request.app.state.session_factory, config=request.app.state.config)
def _restores(request: Request) -> RestoreService:
return RestoreService(request.app.state.session_factory, config=request.app.state.config)
def _transfers(request: Request) -> ArchiveTransferService:
return ArchiveTransferService(
request.app.state.session_factory, config=request.app.state.config
)
def _error(error: ArchiveError) -> JSONResponse:
if error.code in NOT_FOUND_CODES:
status = 404
elif error.code in CONFLICT_CODES:
status = 409
else:
status = 422
return JSONResponse(
status_code=status, content={"error": {"code": error.code, "message": str(error)}}
)
@router.post("/archive-locations", status_code=201)
def register_location(body: RegisterLocationRequest, request: Request):
try:
return _service(request).register(body.name, body.root)
except ArchiveError as error:
return _error(error)
@router.get("/archive-locations")
def list_locations(request: Request) -> dict:
return {"locations": _service(request).locations()}
@router.post("/archive-preflight")
def preflight(body: PreflightRequest, request: Request):
try:
return _service(request).preflight(body.location_id, body.albums)
except ArchiveError as error:
return _error(error)
@router.post("/archive-plans", status_code=201)
def create_plan(body: CreatePlanRequest, request: Request):
"""Turn an approved preflight into a durable, journaled plan. Nothing moves."""
try:
return _transfers(request).create(body.location_id, body.albums, token=body.token)
except ArchiveError as error:
return _error(error)
@router.get("/archive-plans")
def list_plans(request: Request) -> dict:
return {"plans": _transfers(request).list()}
@router.get("/archive-plans/{plan_id}")
def get_plan(plan_id: str, request: Request):
plan = _transfers(request).get(plan_id)
if plan is None:
return _error(ArchiveError("unknown_plan", f"unknown archive plan {plan_id}"))
return plan
@router.post("/archive-plans/{plan_id}/apply")
def apply_plan(plan_id: str, request: Request):
"""Queue the transfer on the archiver lane. The worker removes the sources."""
service = _transfers(request)
plan = service.get(plan_id)
if plan is None:
return _error(ArchiveError("unknown_plan", f"unknown archive plan {plan_id}"))
if service.journal.blocks_mutation():
unresolved = [row for row in service.journal.incomplete() if row["plan_id"] != plan_id]
if unresolved:
return _error(
ArchiveError(
"archive_pending",
f"an unresolved archive operation ({unresolved[0]['id']}) must be recovered",
)
)
try:
job = JobService(request.app.state.session_factory).enqueue(
ARCHIVE_PLAN,
lock=ARCHIVE_LOCK,
# One queued attempt per plan version: a double-clicked apply reuses it.
idempotency_key=f"archive:{plan_id}:{plan['version']}",
items=[plan_id],
)
except JobBlocked as error:
return JSONResponse(
status_code=409, content={"error": {"code": error.code, "message": str(error)}}
)
return {"plan_id": plan_id, "job": job}
@router.post("/restore-preflight")
def restore_preflight(body: RestoreRequest, request: Request):
"""Validate restoring archived assets back into the library. Nothing moves."""
try:
return _restores(request).preflight(body.location_id, body.asset_ids)
except ArchiveError as error:
return _error(error)
@router.post("/restore-plans", status_code=201)
def create_restore_plan(body: CreateRestoreRequest, request: Request):
try:
return _restores(request).create(body.location_id, body.asset_ids, token=body.token)
except ArchiveError as error:
return _error(error)
@router.get("/restore-plans")
def list_restore_plans(request: Request) -> dict:
return {"plans": _restores(request).list()}
@router.get("/restore-plans/{plan_id}")
def get_restore_plan(plan_id: str, request: Request):
plan = _restores(request).get(plan_id)
if plan is None:
return _error(ArchiveError("unknown_plan", f"unknown restore plan {plan_id}"))
return plan
@router.post("/restore-plans/{plan_id}/apply")
def apply_restore_plan(plan_id: str, request: Request):
"""Queue the restore on the archiver lane — the same single lane as archiving,
because both move the same originals."""
service = _restores(request)
plan = service.get(plan_id)
if plan is None:
return _error(ArchiveError("unknown_plan", f"unknown restore plan {plan_id}"))
unresolved = [row for row in service.journal.incomplete() if row["plan_id"] != plan_id]
if unresolved:
return _error(
ArchiveError(
"archive_pending",
f"an unresolved archive operation ({unresolved[0]['id']}) must be recovered",
)
)
try:
job = JobService(request.app.state.session_factory).enqueue(
RESTORE_PLAN,
lock=ARCHIVE_LOCK,
idempotency_key=f"restore:{plan_id}:{plan['version']}",
items=[plan_id],
)
except JobBlocked as error:
return JSONResponse(
status_code=409, content={"error": {"code": error.code, "message": str(error)}}
)
return {"plan_id": plan_id, "job": job}
@router.get("/restore-recovery")
def restore_recovery_status(request: Request) -> dict:
return _restores(request).recovery_status()
@router.post("/restore-recovery/resolve")
def resolve_restore_recovery(request: Request) -> dict:
return _restores(request).recover()
@router.get("/archive-recovery")
def recovery_status(request: Request) -> dict:
"""What an interrupted transfer left behind, straight from journal + disk."""
return _transfers(request).recovery_status()
@router.post("/archive-recovery/resolve")
def resolve_recovery(request: Request) -> dict:
return _transfers(request).recover()

View File

@@ -0,0 +1,176 @@
"""Upload preflight, batch, and verification API (US05-01, US05-02, US05-04).
Preflight is a command, not a resource read: it contacts the Immich server, hashes
the current bytes, and issues a token. Creating a batch requires that token, and
starting one enqueues a durable job on the single ``upload`` lane — the API never
runs the uploader in the request thread.
``verify`` and ``resolve`` are the way out of an uncertain outcome; ``start``
refuses one with ``409`` rather than letting the browser retry it.
"""
from __future__ import annotations
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from photo_pipeline.jobs.domain_handlers import UPLOAD_BATCH, UPLOAD_LOCK
from photo_pipeline.services.jobs import JobBlocked, JobService
from photo_pipeline.services.upload_batches import (
BatchConflict,
BatchError,
UploadBatchService,
)
from photo_pipeline.services.upload_verification import (
UploadVerificationService,
VerificationError,
retry_blockers,
)
from photo_pipeline.services.uploads import UploadError, UploadService
router = APIRouter(tags=["uploads"])
class PreflightRequest(BaseModel):
# ``None`` means every album; an explicit list scopes the check.
albums: list[str] | None = None
# Approving a partial upload is an explicit act, never a default.
allow_partial: bool = False
class CreateBatchRequest(PreflightRequest):
# The token of the preflight the user approved; a stale one is refused.
token: str
class ResolveRequest(BaseModel):
asset_id: str
# uploaded | upgraded | duplicate | skipped | failed | unknown
outcome: str
# What the operator actually checked, and who they are — both mandatory so a
# manual resolution can never look like server evidence.
evidence: str
actor: str
def _service(request: Request) -> UploadService:
return UploadService(request.app.state.session_factory, config=request.app.state.config)
def _batches(request: Request) -> UploadBatchService:
return UploadBatchService(request.app.state.session_factory, config=request.app.state.config)
def _verification(request: Request) -> UploadVerificationService:
return UploadVerificationService(
request.app.state.session_factory, config=request.app.state.config
)
def _error(status: int, code: str, message: str) -> JSONResponse:
return JSONResponse(status_code=status, content={"error": {"code": code, "message": message}})
@router.post("/upload-preflight")
def preflight(request: Request, body: PreflightRequest | None = None):
body = body or PreflightRequest()
try:
return _service(request).preflight(body.albums, allow_partial=body.allow_partial)
except UploadError as error:
return _error(422, "unknown_album", str(error))
@router.post("/upload-batches", status_code=201)
def create_batches(body: CreateBatchRequest, request: Request):
"""Turn an approved preflight into one durable batch per album."""
try:
return {
"batches": _batches(request).create(
body.albums, token=body.token, allow_partial=body.allow_partial
)
}
except UploadError as error:
return _error(422, "unknown_album", str(error))
except BatchConflict as error:
return _error(409, error.code, str(error))
except BatchError as error:
return _error(422, error.code, str(error))
@router.get("/upload-batches")
def list_batches(request: Request) -> dict:
return {"batches": _batches(request).list()}
@router.get("/upload-batches/{batch_id}")
def get_batch(batch_id: str, request: Request):
batch = _batches(request).get(batch_id)
if batch is None:
return _error(404, "not_found", f"unknown upload batch {batch_id}")
return batch
@router.post("/upload-batches/{batch_id}/start")
def start_batch(batch_id: str, request: Request):
"""Queue the batch on the uploader lane. The worker performs the upload."""
service = _batches(request)
batch = service.get(batch_id)
if batch is None:
return _error(404, "not_found", f"unknown upload batch {batch_id}")
# An uncertain outcome or bytes changed after upload must be resolved first
# (US05-04); the worker refuses them too, but the user is told here.
blocked = retry_blockers(batch)
if blocked:
return _error(409, blocked[0]["code"], blocked[0]["message"])
try:
job = JobService(request.app.state.session_factory).enqueue(
UPLOAD_BATCH,
lock=UPLOAD_LOCK,
# One queued attempt per batch attempt: a double-clicked start reuses it.
idempotency_key=f"upload:{batch_id}:{batch['attempt_count']}",
items=[batch_id],
)
except JobBlocked as error:
return _error(409, error.code, str(error))
return {"batch_id": batch_id, "job": job}
@router.post("/upload-batches/{batch_id}/verify")
def verify_batch(batch_id: str, request: Request):
"""Check the batch against Immich and the bytes on disk (US05-04)."""
try:
return _verification(request).verify(batch_id)
except VerificationError as error:
return _error(404 if error.code == "not_found" else 422, error.code, str(error))
@router.post("/upload-batches/{batch_id}/resolve")
def resolve_item(batch_id: str, body: ResolveRequest, request: Request):
"""Record an operator's own verification of one item. Evidence is mandatory."""
try:
return _verification(request).resolve(
batch_id,
body.asset_id,
outcome=body.outcome,
evidence=body.evidence,
actor=body.actor,
)
except VerificationError as error:
return _error(404 if error.code == "not_found" else 422, error.code, str(error))
@router.get("/upload-batches/{batch_id}/verifications")
def list_verifications(batch_id: str, request: Request):
batch = _batches(request).get(batch_id)
if batch is None:
return _error(404, "not_found", f"unknown upload batch {batch_id}")
return {"verifications": _verification(request).history(batch_id)}
@router.post("/upload-batches/{batch_id}/cancel")
def cancel_batch(batch_id: str, request: Request):
try:
return _batches(request).cancel(batch_id)
except BatchError as error:
return _error(404 if error.code == "not_found" else 409, error.code, str(error))

View File

@@ -35,8 +35,13 @@ class Config(BaseModel):
thumbnail_cache_quota_bytes: int = 500_000_000
thumbnail_max_pixels: int = 100_000_000
# Free space an archive destination must keep beyond the transfer itself.
archive_free_space_reserve_bytes: int = 1_000_000_000
vision_api_key: SecretStr | None = None
immich_api_key: SecretStr | None = None
immich_server_url: str = ""
immich_go_binary: str = "immich-go"
@property
def database_path(self) -> Path:

View File

@@ -0,0 +1,295 @@
"""immich-go adapter: binary discovery, version, and redacted command preview.
Upload is the only stage that needs credentials, so this module is also the single
place that knows an API key exists. It never returns, logs, or renders the secret:
:func:`build_command` produces the real argument list for the uploader, and
:func:`redact` produces the copy that is safe for the API, the browser, and the
activity log. Both come from the same builder so the preview can never drift from
the command that would actually run.
Server reachability uses ``/api/server/ping`` through stdlib ``urllib`` — the app
has no HTTP client dependency and this is one request. :func:`bulk_upload_check`
uses the same client to ask Immich which uploaded bytes it already holds, which is
the authoritative evidence behind upload verification (US05-04).
:func:`run_upload` is the only place the uploader is actually executed. It never
uses a shell (the argument list goes straight to ``execve``, so no path or album
name can be interpreted), streams the report to a file with a byte cap so a chatty
or looping uploader cannot fill the disk, scrubs the API key out of anything the
process echoes back, and polls a cancellation callback so a running upload can be
stopped without killing the worker.
"""
from __future__ import annotations
import json
import os
import shutil
import signal
import subprocess
import threading
import time
import urllib.error
import urllib.request
from collections.abc import Callable
from pathlib import Path
REDACTED = "***"
PING_PATH = "/api/server/ping"
PING_TIMEOUT_SECONDS = 5.0
# Immich's own deduplication endpoint: the authoritative answer to "do you already
# have these exact bytes?" used to verify uncertain uploads (US05-04).
BULK_CHECK_PATH = "/api/assets/bulk-upload-check"
CHECK_TIMEOUT_SECONDS = 30.0
CHECK_BATCH_SIZE = 500
# Reports are kept in full up to this size; beyond it the tail is dropped and the
# result is flagged truncated rather than growing without bound (concept §17).
MAX_REPORT_BYTES = 4_000_000
UPLOAD_TIMEOUT_SECONDS = 6 * 60 * 60
POLL_SECONDS = 0.05
# Grace period between asking the uploader to stop and killing it.
TERMINATE_GRACE_SECONDS = 10.0
# How long to wait for the last output after the process is gone. A child that
# outlived its parent can still hold the pipe; the report is not worth hanging for.
DRAIN_SECONDS = 2.0
def find_binary(binary: str = "immich-go") -> str | None:
"""Absolute path of the uploader, or ``None`` when it is not installed."""
return shutil.which(binary)
def version(binary: str = "immich-go") -> str | None:
"""Reported uploader version, or ``None`` when it is missing or unusable.
The version is persisted with every batch (concept §8) because immich-go's
flags and report text change between releases.
"""
path = find_binary(binary)
if path is None:
return None
try:
result = subprocess.run([path, "--version"], capture_output=True, text=True, timeout=30)
except (OSError, subprocess.SubprocessError):
return None
output = (result.stdout or result.stderr or "").strip()
return output.splitlines()[0].strip() if output else None
def ping(server_url: str, *, timeout: float = PING_TIMEOUT_SECONDS) -> tuple[bool, str | None]:
"""``(reachable, detail)`` for the configured Immich server.
A reachable Immich answers ``{"res": "pong"}``. Anything else — wrong host, no
Immich, HTTP error — is a blocker with a short human detail. The detail never
carries the URL's credentials because the key travels in a header, not the URL.
"""
if not server_url:
return False, "no server URL configured"
url = server_url.rstrip("/") + PING_PATH
try:
with urllib.request.urlopen(url, timeout=timeout) as response: # noqa: S310
payload = json.loads(response.read().decode("utf-8") or "{}")
except (urllib.error.URLError, OSError, ValueError, TimeoutError) as error:
return False, f"{type(error).__name__}: {error}"
if payload.get("res") == "pong":
return True, None
return False, "server did not answer with pong"
def bulk_upload_check(
server_url: str,
api_key: str | None,
checksums: dict[str, str],
*,
timeout: float = CHECK_TIMEOUT_SECONDS,
) -> dict:
"""Ask Immich which of these exact bytes it already holds (US05-04).
``checksums`` maps an application key (the asset id) to the SHA-1 of the bytes
that were uploaded — the digest Immich itself deduplicates on. The answer is
``{"reachable", "detail", "present"}`` where ``present`` maps each key to
``True`` (the server rejected it as a duplicate, so it holds those bytes),
``False`` (the server would accept it, so it does not), or ``None`` (the server
answered something this adapter will not interpret).
An unreachable or unparsable server is reported, never guessed at: the caller
must treat it as uncertainty rather than absence.
"""
if not server_url or not api_key:
return {"reachable": False, "detail": "no Immich credentials configured", "present": {}}
keys = list(checksums)
present: dict[str, bool | None] = {}
for start in range(0, len(keys), CHECK_BATCH_SIZE):
# ponytail: fixed chunk size; make it configurable if a server ever rejects it.
chunk = keys[start : start + CHECK_BATCH_SIZE]
payload = {"assets": [{"id": key, "checksum": checksums[key]} for key in chunk]}
request = urllib.request.Request( # noqa: S310 — http(s) URL from configuration
server_url.rstrip("/") + BULK_CHECK_PATH,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json", "x-api-key": api_key},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
body = json.loads(response.read().decode("utf-8") or "{}")
except (urllib.error.URLError, OSError, ValueError, TimeoutError) as error:
return {"reachable": False, "detail": f"{type(error).__name__}: {error}", "present": {}}
results = body.get("results")
if not isinstance(results, list):
return {
"reachable": False,
"detail": "unrecognised bulk-upload-check response",
"present": {},
}
for result in results:
if not isinstance(result, dict) or result.get("id") not in checksums:
continue
present[result["id"]] = _holds_bytes(result)
return {"reachable": True, "detail": None, "present": present}
def _holds_bytes(result: dict) -> bool | None:
"""Whether one bulk-upload-check result means the server already has the file."""
action, reason = result.get("action"), result.get("reason")
if action == "reject":
# Only a duplicate proves possession; "unsupported-format" and friends say
# nothing about whether the bytes are there.
return True if reason == "duplicate" else None
return False if action == "accept" else None
def build_command(
*,
binary: str,
server_url: str,
api_key: str,
album_name: str,
folder: Path | str,
) -> list[str]:
"""The exact upload invocation for one folder-as-album batch."""
return [
binary,
"upload",
"from-folder",
f"--server={server_url}",
f"--api-key={api_key}",
f"--album-name={album_name}",
str(folder),
]
def redact(command: list[str]) -> list[str]:
"""The same command with every secret-bearing argument masked."""
return [f"--api-key={REDACTED}" if arg.startswith("--api-key=") else arg for arg in command]
def run_upload(
command: list[str],
*,
report_path: Path | str,
secret: str | None = None,
max_report_bytes: int | None = None, # resolved at call time; see MAX_REPORT_BYTES
cancelled: Callable[[], bool] | None = None,
timeout: float = UPLOAD_TIMEOUT_SECONDS,
) -> dict:
"""Run one upload and return its outcome.
``{"exit_code", "cancelled", "timed_out", "report_path", "report_bytes",
"report_truncated"}``. Output is streamed to ``report_path`` with ``secret``
masked and the file capped at ``max_report_bytes``; the pipe keeps being drained
after the cap so the child never blocks on a full buffer. ``cancelled`` is polled
while the process runs: when it returns true the uploader is asked to stop, then
killed if it does not.
"""
report_path = Path(report_path)
report_path.parent.mkdir(parents=True, exist_ok=True)
max_report_bytes = MAX_REPORT_BYTES if max_report_bytes is None else max_report_bytes
needle = (secret or "").encode() or None
written = 0
truncated = False
process = subprocess.Popen( # noqa: S603 — argv list, never a shell string
command,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
shell=False,
# Own process group: stopping the upload must stop whatever the uploader
# spawned too, not leave orphans holding the pipe open.
start_new_session=True,
)
def _drain() -> None:
nonlocal written, truncated
with open(report_path, "wb") as report:
for line in process.stdout: # line granularity keeps the mask reliable
if needle:
line = line.replace(needle, REDACTED.encode())
if written >= max_report_bytes:
truncated = True
continue # keep draining the pipe, stop growing the file
room = max_report_bytes - written
report.write(line[:room])
written += min(len(line), room)
truncated = truncated or len(line) > room
report.flush()
reader = threading.Thread(target=_drain, daemon=True)
reader.start()
stopped = timed_out = False
deadline = time.monotonic() + timeout
while process.poll() is None:
if cancelled is not None and cancelled():
stopped = True
elif time.monotonic() >= deadline:
timed_out = True
if stopped or timed_out:
_stop(process)
break
time.sleep(POLL_SECONDS)
exit_code = process.wait()
reader.join(timeout=DRAIN_SECONDS)
if process.stdout is not None:
process.stdout.close()
return {
"exit_code": exit_code,
"cancelled": stopped,
"timed_out": timed_out,
"report_path": str(report_path),
"report_bytes": written,
"report_truncated": truncated,
}
def _stop(process: subprocess.Popen) -> None:
"""Ask the uploader's whole process group to stop, then kill what remains."""
_signal_group(process, signal.SIGTERM)
try:
process.wait(timeout=TERMINATE_GRACE_SECONDS)
except subprocess.TimeoutExpired:
_signal_group(process, signal.SIGKILL)
process.wait()
def _signal_group(process: subprocess.Popen, sig: int) -> None:
try:
os.killpg(os.getpgid(process.pid), sig)
except (ProcessLookupError, PermissionError, OSError):
# No group (already reaped, or a platform without them): signal the child.
process.send_signal(sig)
def preview_command(
*, binary: str, server_url: str, album_name: str, folder: Path | str
) -> list[str]:
"""Redacted preview built without ever handling the real key."""
return redact(
build_command(
binary=binary,
server_url=server_url,
api_key=REDACTED,
album_name=album_name,
folder=folder,
)
)

View File

@@ -0,0 +1,204 @@
"""immich-go report parsing (US05-03).
The uploader's output is the only local evidence of what Immich did with each file,
and its wording changes between releases (concept §15 "external integration
risks"). So parsing is deliberately conservative:
- **the parser is chosen by uploader version, not by guessing the format.** Each
supported version family maps to one adapter with a pinned line grammar. An
unrecognised version yields no adapter at all, which makes every item uncertain
rather than optimistically successful.
- **an unmatched line is never success.** Lines the adapter does not recognise are
counted (``unparsed``) and kept as evidence; they never classify an item.
- **evidence is bounded.** Each entry keeps a trimmed copy of the line that
classified it, and the number of entries is capped, so a looping uploader cannot
turn the report into unbounded database rows.
Two grammars are supported. ``text-v1`` is the default human-readable log of the
0.21/0.22 line, ``json-v1`` the structured log (``--log-type=json``) of 0.23/0.24.
Both classify through one shared phrase table, so the vocabulary cannot drift
between them:
```text
text-v1 INFO uploaded /lib/rome/a.jpg
INFO server has the same file /lib/rome/b.jpg
ERROR error uploading /lib/rome/e.jpg: connection reset
Uploaded 1, duplicates 1, errors 1
json-v1 {"level":"INFO","msg":"uploaded","file":"/lib/rome/a.jpg"}
{"level":"INFO","msg":"report","counts":{"uploaded":1}}
```
Nothing here touches the database; :mod:`photo_pipeline.services.upload_reports`
turns a parse result into durable per-item outcomes.
"""
from __future__ import annotations
import json
import re
PARSER_VERSION = 1
# Per-item outcomes. ``UNKNOWN`` is the safe default everywhere: it means the app
# does not know what happened to that file and US05-04 must verify it.
UPLOADED = "uploaded"
UPGRADED = "upgraded"
DUPLICATE = "duplicate"
SKIPPED = "skipped"
FAILED = "failed"
UNKNOWN = "unknown"
OUTCOMES = (UPLOADED, UPGRADED, DUPLICATE, SKIPPED, FAILED, UNKNOWN)
# Longest/most specific phrases first: "server has an older file" also contains
# "server has", and an error line about uploading also contains "upload".
_PHRASES: tuple[tuple[str, str], ...] = (
("server has the same file", DUPLICATE),
("server has an older file", UPGRADED),
("upgraded", UPGRADED),
("duplicate", DUPLICATE),
("discarded", SKIPPED),
("skipped", SKIPPED),
("error", FAILED),
("failed", FAILED),
("uploaded", UPLOADED),
("upload", UPLOADED),
)
# Summary line of the text grammar: "Uploaded 3, duplicates 1, errors 2".
_SUMMARY_WORDS = {
"uploaded": UPLOADED,
"upgraded": UPGRADED,
"duplicates": DUPLICATE,
"duplicate": DUPLICATE,
"skipped": SKIPPED,
"discarded": SKIPPED,
"errors": FAILED,
"error": FAILED,
}
_SUMMARY_PAIR = re.compile(r"([A-Za-z]+)\s+(\d+)")
# A path is anything that looks absolute, up to an explanatory ": reason" tail.
_PATH = re.compile(r"(/[^\s:][^:]*?)(?=:|$)")
MAX_EVIDENCE_CHARS = 300
# The report file is already byte-capped; this caps the rows it can produce.
MAX_ENTRIES = 20_000
def parser_for(uploader_version: str | None) -> str | None:
"""Adapter name for a recorded uploader version, or ``None`` when unsupported.
Unsupported is not an error — it is the honest answer that this build's output
format was never pinned, and it makes the whole report uncertain.
"""
match = re.search(r"(\d+)\.(\d+)", uploader_version or "")
if match is None:
return None
return _SUPPORTED.get(f"{match.group(1)}.{match.group(2)}")
def parse(report: str, uploader_version: str | None) -> dict:
"""Classify a raw report.
Returns ``{"parser", "parser_version", "supported", "entries", "counts",
"unparsed", "entries_truncated"}`` where ``entries`` is a list of
``{"path", "outcome", "evidence"}`` and ``counts`` is the uploader's own
summary when it printed one (``None`` otherwise, never invented).
"""
parser = parser_for(uploader_version)
result = {
"parser": parser,
"parser_version": PARSER_VERSION,
"supported": parser is not None,
"entries": [],
"counts": None,
"unparsed": 0,
"entries_truncated": False,
}
if parser is None:
return result
read_line = _text_line if parser == "text-v1" else _json_line
for raw in report.splitlines():
line = raw.strip()
if not line:
continue
path, outcome, counts = read_line(line)
if counts is not None:
# Later summaries win: the uploader prints its totals once, at the end.
result["counts"] = counts
continue
if path is None or outcome is None:
result["unparsed"] += 1
continue
if len(result["entries"]) >= MAX_ENTRIES:
result["entries_truncated"] = True
continue
result["entries"].append(
{"path": path, "outcome": outcome, "evidence": line[:MAX_EVIDENCE_CHARS]}
)
return result
def _classify(text: str) -> str | None:
lowered = text.lower()
for phrase, outcome in _PHRASES:
if phrase in lowered:
return outcome
return None
def _summary(text: str) -> dict[str, int] | None:
"""Totals from a summary line, or ``None`` when the line is not one."""
counts: dict[str, int] = {}
for word, number in _SUMMARY_PAIR.findall(text):
outcome = _SUMMARY_WORDS.get(word.lower())
if outcome is None:
return None # an unknown noun means this is not the summary grammar
counts[outcome] = counts.get(outcome, 0) + int(number)
return counts or None
def _text_line(line: str) -> tuple[str | None, str | None, dict | None]:
path_match = _PATH.search(line)
if path_match is None:
return None, None, _summary(line)
path = path_match.group(1).strip()
# Classify from the words around the path, never from the path itself: an
# album called "errors" must not turn an upload into a failure.
context = line.replace(path, " ")
return path, _classify(context), None
def _json_line(line: str) -> tuple[str | None, str | None, dict | None]:
try:
record = json.loads(line)
except ValueError:
return None, None, None
if not isinstance(record, dict):
return None, None, None
counts = record.get("counts")
if isinstance(counts, dict):
totals = {
_SUMMARY_WORDS[key.lower()]: int(value)
for key, value in counts.items()
if key.lower() in _SUMMARY_WORDS and isinstance(value, int)
}
return None, None, totals or None
path = record.get("file")
message = record.get("msg")
if not isinstance(path, str) or not isinstance(message, str):
return None, None, None
return path, _classify(message), None
# Version family → adapter. Pinning this is the point: a build outside the list is
# uncertain by construction (concept §15 "pin and record supported immich-go
# versions").
_SUPPORTED = {
"0.21": "text-v1",
"0.22": "text-v1",
"0.23": "json-v1",
"0.24": "json-v1",
}
SUPPORTED_VERSIONS = tuple(sorted(_SUPPORTED))

View File

@@ -1,10 +1,12 @@
"""Domain job handlers: safety scoring and content analysis (US02-06).
"""Domain job handlers: safety scoring, content analysis, uploads, archive
transfers, restores (US02-06, US05-02, US06-02, US06-04).
Importing this module registers the ``safety_score`` and ``analysis`` job types so
the generic worker can run them per item (one item = one ``asset_id``). Each handler
delegates to its service, which owns the real work and the privacy gate. Handlers
are idempotent: re-scoring or re-analyzing one asset is safe after an interrupted
attempt.
Importing this module registers the ``safety_score``, ``analysis``,
``upload_batch``, ``archive_plan``, and ``restore_plan`` job types so the generic
worker can run them per item. Each handler delegates to its service, which owns the real work and the
privacy gate. Handlers are idempotent: re-scoring or re-analyzing one asset is safe
after an interrupted attempt, an upload batch refuses to re-run an attempt whose
outcome is unknown, and an archive plan skips items it already completed.
Providers/models are the service defaults here (real NsfwModel / vision provider);
tests exercise the services directly with injected fakes rather than the worker.
@@ -12,12 +14,19 @@ tests exercise the services directly with injected fakes rather than the worker.
from __future__ import annotations
from photo_pipeline.jobs.handlers import JobContext, register
from photo_pipeline.jobs.handlers import Cancelled, JobContext, register
SAFETY_SCORE = "safety_score"
ANALYSIS = "analysis"
UPLOAD_BATCH = "upload_batch"
ARCHIVE_PLAN = "archive_plan"
RESTORE_PLAN = "restore_plan"
# Both mutate the library's metadata/derived state; one at a time (concept §one job).
LIBRARY_WRITE_LOCK = "library_write"
# The uploader lane: one album batch at a time (concept §16).
UPLOAD_LOCK = "upload"
# The archiver lane: one archive/restore plan at a time (concept §16).
ARCHIVE_LOCK = "archive"
def _safety_score_item(asset_id: str, ctx: JobContext) -> None:
@@ -32,5 +41,52 @@ def _analysis_item(asset_id: str, ctx: JobContext) -> None:
AnalysisService(ctx.session_factory).run([asset_id])
def _upload_batch_item(batch_id: str, ctx: JobContext) -> None:
"""One item = one album batch. The upload itself is long and external, so the
handler hands the job's cancellation check to the service, which stops the
uploader and leaves a resumable batch."""
from photo_pipeline.config import Config
from photo_pipeline.services.upload_batches import BatchState, UploadBatchService
config = ctx.config if ctx.config is not None else Config.from_env()
service = UploadBatchService(ctx.session_factory, config=config)
batch = service.run(batch_id, worker_id=ctx.worker_id, cancelled=ctx.cancelled)
if batch["state"] == BatchState.CANCELLED:
raise Cancelled(f"upload batch {batch_id} was cancelled")
if batch["state"] in (BatchState.FAILED, BatchState.UNKNOWN):
raise RuntimeError(f"upload batch {batch_id} is {batch['state']}: {batch['error_code']}")
def _archive_plan_item(plan_id: str, ctx: JobContext) -> None:
"""One item = one archive plan. Item-level failures stay in the journal (the
source is then still there); only an unusable plan fails the job."""
from photo_pipeline.config import Config
from photo_pipeline.services.archive_transfer import ArchiveTransferService
config = ctx.config if ctx.config is not None else Config.from_env()
result = ArchiveTransferService(ctx.session_factory, config=config).apply(
plan_id, worker_id=ctx.worker_id
)
if result["failed"]:
raise RuntimeError(f"archive plan {plan_id}: {result['failed']} item(s) failed")
def _restore_plan_item(plan_id: str, ctx: JobContext) -> None:
"""One item = one restore plan. A restore removes nothing, so an item failure
simply leaves that asset archived (US06-04)."""
from photo_pipeline.config import Config
from photo_pipeline.services.restores import RestoreService
config = ctx.config if ctx.config is not None else Config.from_env()
result = RestoreService(ctx.session_factory, config=config).apply(
plan_id, worker_id=ctx.worker_id
)
if result["failed"]:
raise RuntimeError(f"restore plan {plan_id}: {result['failed']} item(s) failed")
register(SAFETY_SCORE, _safety_score_item)
register(ANALYSIS, _analysis_item)
register(UPLOAD_BATCH, _upload_batch_item)
register(ARCHIVE_PLAN, _archive_plan_item)
register(RESTORE_PLAN, _restore_plan_item)

View File

@@ -34,6 +34,9 @@ class JobContext:
fencing_token: int
service: "JobService"
session_factory: object | None = None
# Handlers that talk to an external tool (uploads) need the typed configuration;
# the worker passes its own so a test stack is never read from the environment.
config: object | None = None
def cancelled(self) -> bool:
from photo_pipeline.services.jobs import JobState

View File

@@ -30,6 +30,7 @@ class Worker:
*,
job_types: Sequence[str] | None = None,
lease_seconds: int = 60,
config: object | None = None,
) -> None:
self._session_factory = session_factory
self.service = JobService(session_factory)
@@ -37,6 +38,7 @@ class Worker:
self.worker_id = worker_id
self.job_types = list(job_types if job_types is not None else self.handlers.keys())
self.lease_seconds = lease_seconds
self.config = config
def run_once(self) -> str | None:
"""Recover stragglers, then claim and fully process one job. Returns its id."""
@@ -51,7 +53,9 @@ class Worker:
def _process(self, job_id: str, job_type: str, token: int) -> None:
handler = self.handlers[job_type]
ctx = JobContext(job_id, self.worker_id, token, self.service, self._session_factory)
ctx = JobContext(
job_id, self.worker_id, token, self.service, self._session_factory, self.config
)
self._reset_interrupted_items(job_id, token)
cancelled = False
@@ -92,6 +96,16 @@ class Worker:
return
try:
if cancelled or snapshot["state"] == JobState.CANCELLING:
# A handler may stop on its own — an upload batch cancelled through
# its own API never touches the job — so the job can still be
# ``running`` here. Record the request before the outcome: a stop is
# always observable as cancelling → cancelled, and ``running ->
# cancelled`` is not a legal jump. Without this hop the transition
# is rejected and the job keeps its lock forever.
if snapshot["state"] == JobState.RUNNING:
self.service.transition(
job_id, JobState.CANCELLING, worker_id=self.worker_id, fencing_token=token
)
self.service.transition(
job_id, JobState.CANCELLED, worker_id=self.worker_id, fencing_token=token
)

View File

@@ -5,6 +5,7 @@ Alembic environment relies on.
"""
from photo_pipeline.models.albums import AlbumProposal
from photo_pipeline.models.archives import ArchiveLocation, ArchiveOperation, ArchivePlan
from photo_pipeline.models.assets import Asset, AssetPath
from photo_pipeline.models.duplicates import (
DuplicateCluster,
@@ -14,10 +15,14 @@ from photo_pipeline.models.duplicates import (
from photo_pipeline.models.jobs import Job, JobEvent, JobItem
from photo_pipeline.models.renames import RenameOperation, RenamePlan
from photo_pipeline.models.thumbnails import Thumbnail
from photo_pipeline.models.uploads import UploadBatch, UploadItem, UploadVerification
from photo_pipeline.models.workflow import AnalysisResult, SafetyReview
__all__ = [
"AlbumProposal",
"ArchiveLocation",
"ArchiveOperation",
"ArchivePlan",
"Asset",
"AssetPath",
"DuplicateCluster",
@@ -29,6 +34,9 @@ __all__ = [
"RenamePlan",
"RenameOperation",
"Thumbnail",
"UploadBatch",
"UploadItem",
"UploadVerification",
"SafetyReview",
"AnalysisResult",
]

View File

@@ -0,0 +1,127 @@
"""Archive location, plan, and transfer-journal persistence (US06-01, US06-02).
An archive location is a *medium*, not a path. External disks get mounted at
different mountpoints, and a different disk can be mounted at the same one, so a
recorded root alone can never prove "these bytes went to that volume". Each
location therefore owns a marker file written onto the medium itself; its
``media_id`` is the stable identity, and the root is only where it was last seen.
``capabilities`` and ``state`` are the last probe result, kept so the UI can list
locations without touching a sleeping disk. Preflight always re-probes — a stored
state is a hint, never evidence.
An ``ArchivePlan`` is one approved preflight turned into durable work, and each
``ArchiveOperation`` is one file's crash-safe journal row (US06-02). The row records
what the transfer *intends* to do before it does it — source, destination, expected
hash — because after a crash that intent plus the files on disk is the only evidence
available for deciding whether an original may be removed.
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Integer,
String,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column
from photo_pipeline.db import Base
class ArchiveLocation(Base):
__tablename__ = "archive_locations"
id: Mapped[str] = mapped_column(String, primary_key=True)
name: Mapped[str] = mapped_column(String, nullable=False, unique=True)
root: Mapped[str] = mapped_column(String, nullable=False)
# Written into the marker file on the medium; proves the right volume is mounted.
media_id: Mapped[str] = mapped_column(String, nullable=False, unique=True)
capabilities: Mapped[str | None] = mapped_column(String) # JSON, last probe
# online | offline | wrong_volume | unwritable — the last probe's verdict.
state: Mapped[str] = mapped_column(String, nullable=False, default="offline")
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
class ArchivePlan(Base):
__tablename__ = "archive_plans"
id: Mapped[str] = mapped_column(String, primary_key=True)
location_id: Mapped[str] = mapped_column(
ForeignKey("archive_locations.id"), nullable=False, index=True
)
# The preflight token this plan was approved against; re-verified before apply.
token: Mapped[str] = mapped_column(String, nullable=False)
albums: Mapped[str | None] = mapped_column(String) # JSON array
# archive | restore — the same journal read in the opposite direction (US06-04).
direction: Mapped[str] = mapped_column(String, nullable=False, default="archive")
# planned | applying | complete | failed
state: Mapped[str] = mapped_column(String, nullable=False, default="planned")
schema_version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
asset_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
byte_size: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Bumped on every claim and used as the fencing token, so a superseded attempt
# cannot commit.
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
worker_id: Mapped[str | None] = mapped_column(String)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
class ArchiveOperation(Base):
__tablename__ = "archive_operations"
__table_args__ = (
UniqueConstraint("plan_id", "sequence", name="uq_archive_operations_plan_sequence"),
)
id: Mapped[str] = mapped_column(String, primary_key=True)
plan_id: Mapped[str] = mapped_column(
ForeignKey("archive_plans.id", ondelete="CASCADE"), nullable=False, index=True
)
sequence: Mapped[int] = mapped_column(Integer, nullable=False)
album: Mapped[str] = mapped_column(String, nullable=False)
asset_id: Mapped[str] = mapped_column(ForeignKey("assets.id"), nullable=False, index=True)
# archive: library → medium. restore: medium → library (US06-04). ``source_path``
# and ``destination_path`` always mean "from" and "to" for this direction.
direction: Mapped[str] = mapped_column(String, nullable=False, default="archive")
source_path: Mapped[str] = mapped_column(String, nullable=False)
destination_path: Mapped[str] = mapped_column(String, nullable=False)
# Relative to the location root, because the medium can be mounted elsewhere.
archive_path: Mapped[str] = mapped_column(String, nullable=False)
expected_sha256: Mapped[str] = mapped_column(String, nullable=False)
byte_size: Mapped[int | None] = mapped_column(Integer)
same_filesystem: Mapped[bool | None] = mapped_column(Boolean)
# planned | transferring | verified | removing | complete | failed
journal_state: Mapped[str] = mapped_column(String, nullable=False, default="planned")
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
fencing_token: Mapped[int | None] = mapped_column(Integer)
worker_id: Mapped[str | None] = mapped_column(String)
verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
removed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
error_code: Mapped[str | None] = mapped_column(String)
error_message: Mapped[str | None] = mapped_column(String)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)

View File

@@ -35,7 +35,18 @@ class Asset(Base):
discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
missing_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# active | archiving | archived_online | archived_offline | restoring |
# missing_unexpected (concept §9). ``current_path`` is NULL once archived; the
# original is then explained by the location plus its relative archive path.
availability_state: Mapped[str] = mapped_column(String, nullable=False, default="active")
# Not a declared foreign key: SQLite cannot add one to an existing table, so the
# link is written and read by the archive service (US06-02).
archive_location_id: Mapped[str | None] = mapped_column(String)
archive_path: Mapped[str | None] = mapped_column(String)
# Set when the archived copy was proven to hold bytes other than the recorded
# ones (US06-04). Restore refuses such an asset instead of accepting a different
# file; cleared as soon as a verification matches again.
archive_divergent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Duplicate canonical link: NULL when the asset is itself canonical or undecided.
canonical_asset_id: Mapped[str | None] = mapped_column(ForeignKey("assets.id"))

View File

@@ -11,7 +11,7 @@ from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from photo_pipeline.db import Base
@@ -29,6 +29,9 @@ class Thumbnail(Base):
width: Mapped[int | None] = mapped_column(Integer)
height: Mapped[int | None] = mapped_column(Integer)
format: Mapped[str | None] = mapped_column(String)
# Durable comparison evidence for an archived asset: never evicted by the LRU
# quota, because the original may be on a medium that is no longer reachable.
protected: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)

View File

@@ -0,0 +1,151 @@
"""Upload batch persistence (US05-02).
One batch is one approved album folder handed to ``immich-go``. It is the durable
record of an irreversible external action, so it stores everything needed to answer
"which exact bytes did we send, with which command, and how did it end?" after a
crash: the preflight token that authorised it, the redacted command, the uploader
version, the per-asset pre-upload hashes, every attempt, and where the raw report
was written.
Item rows keep both digests: SHA-256 is the app's byte identity and SHA-1 is what
Immich/immich-go use to recognise a file it already has (concept §8).
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Integer,
String,
func,
)
from sqlalchemy.orm import Mapped, mapped_column
from photo_pipeline.db import Base
class UploadBatch(Base):
__tablename__ = "upload_batches"
id: Mapped[str] = mapped_column(String, primary_key=True)
album: Mapped[str] = mapped_column(String, nullable=False, index=True)
folder: Mapped[str] = mapped_column(String, nullable=False)
album_name: Mapped[str] = mapped_column(String, nullable=False)
# planned | running | cancelling | cancelled | succeeded | failed
# | unknown_requires_verification
state: Mapped[str] = mapped_column(String, nullable=False, default="planned")
# The preflight token this batch was approved against; re-checked before every
# attempt so changed bytes or decisions cannot be uploaded silently.
preflight_token: Mapped[str] = mapped_column(String, nullable=False)
allow_partial: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
command: Mapped[str | None] = mapped_column(String) # JSON array, redacted
uploader_version: Mapped[str | None] = mapped_column(String)
asset_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Bumped on every claim and used as the fencing token, so a superseded attempt
# cannot commit its outcome.
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
worker_id: Mapped[str | None] = mapped_column(String)
report_path: Mapped[str | None] = mapped_column(String)
report_bytes: Mapped[int | None] = mapped_column(Integer)
report_truncated: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
exit_code: Mapped[int | None] = mapped_column(Integer)
# Parsed report evidence (US05-03). ``parser`` is NULL when the uploader's
# version has no pinned grammar; ``outcome_state`` is then
# ``requires_verification`` regardless of how the process exited.
parser: Mapped[str | None] = mapped_column(String)
parser_version: Mapped[int | None] = mapped_column(Integer)
parsed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# verified | requires_verification; NULL until a report has been parsed.
outcome_state: Mapped[str | None] = mapped_column(String)
outcome_counts: Mapped[str | None] = mapped_column(String) # JSON, from the items
report_counts: Mapped[str | None] = mapped_column(String) # JSON, uploader's own
# Verification (US05-04). ``stale_bytes`` means at least one uploaded file has
# been edited since: the batch carries a visible warning and cannot be re-run.
verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
stale_bytes: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
error_code: Mapped[str | None] = mapped_column(String)
error_message: Mapped[str | None] = mapped_column(String)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
class UploadItem(Base):
__tablename__ = "upload_items"
batch_id: Mapped[str] = mapped_column(
ForeignKey("upload_batches.id", ondelete="CASCADE"), primary_key=True
)
asset_id: Mapped[str] = mapped_column(String, primary_key=True)
path: Mapped[str] = mapped_column(String, nullable=False)
# Hashes of the bytes as they were when the batch was created.
sha256: Mapped[str | None] = mapped_column(String)
sha1: Mapped[str | None] = mapped_column(String)
# pending | sent | failed — what the batch *process* did with this item;
# ``sent`` only means the uploader exited successfully.
state: Mapped[str] = mapped_column(String, nullable=False, default="pending")
# What the uploader's report says happened (US05-03): uploaded | upgraded |
# duplicate | skipped | failed | unknown. NULL before the report is parsed;
# ``unknown`` whenever the report does not classify this file — never success.
outcome: Mapped[str | None] = mapped_column(String)
evidence: Mapped[str | None] = mapped_column(String) # the bounded report line
outcome_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Verification against the server (US05-04): present | absent | inconclusive |
# manual. NULL until the item has been verified; ``inconclusive`` whenever the
# server could not answer — which is never treated as success.
verification: Mapped[str | None] = mapped_column(String)
verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# The bytes on disk at verification time, and whether they still are the bytes
# this batch uploaded.
observed_sha256: Mapped[str | None] = mapped_column(String)
changed_after_upload: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
class UploadVerification(Base):
"""Append-only evidence for every verification and manual resolution (US05-04).
The item row is a projection of the latest answer; this table is the history
that answers "who decided this, on what evidence, and when?". Rows are never
updated or deleted, so a manual resolution can always be told apart from
server evidence.
"""
__tablename__ = "upload_verifications"
id: Mapped[str] = mapped_column(String, primary_key=True)
batch_id: Mapped[str] = mapped_column(
ForeignKey("upload_batches.id", ondelete="CASCADE"), nullable=False, index=True
)
asset_id: Mapped[str] = mapped_column(String, nullable=False)
action: Mapped[str] = mapped_column(String, nullable=False) # verify | resolve
source: Mapped[str] = mapped_column(String, nullable=False) # immich_api | operator
# present | absent | inconclusive for a verify; the recorded outcome for a resolve.
result: Mapped[str] = mapped_column(String, nullable=False)
outcome: Mapped[str | None] = mapped_column(String)
evidence: Mapped[str] = mapped_column(String, nullable=False)
actor: Mapped[str | None] = mapped_column(String)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)

View File

@@ -0,0 +1,359 @@
"""Archive transfer journal — the durable record of every per-file transition
(US06-02).
Archiving is the only stage that deletes an original, so the journal exists to make
one question answerable after any crash: *may this source file be removed?* Intent
is written before the mutation it describes, and the recorded state plus the real
files on disk are the sole basis for answering it later. This module owns the state
machine, the durable writes, and the evidence table; it never touches a photo
(:mod:`photo_pipeline.services.archive_transfer` does).
Per-item state machine (concept §9 "Transfer and removal semantics"):
```
planned → transferring → verified → removing → complete
↘ ↘ ↘ failed
```
A restore (US06-04) uses the same rows with ``direction='restore'``: it copies from
the medium back into the library and removes nothing, so it goes ``verified →
complete`` directly. ``source_path``/``destination_path`` always mean "from"/"to",
which is why the evidence table below needs no direction of its own.
- ``transferring`` — intent recorded; a temporary copy may exist, the destination
may or may not have been published. Nothing has been removed.
- ``verified`` — the archived bytes exist at their final path, hash exactly as
recorded, and the manifest entry is durable. Only from here may a source go.
- ``removing`` — the source removal is committed to; the source may already be
gone while the database still points at it.
- ``complete`` — source absent, database updated, availability recorded.
``classify`` labels each incomplete item from the journal plus disk evidence:
- ``resumable`` — nothing was published; the source is intact, so applying again is
safe.
- ``forward`` — the archived copy exists and matches its recorded hash, so the
remaining steps (manifest, removal, bookkeeping) can be finished deterministically.
- ``manual`` — the evidence contradicts the journal (missing archive copy, wrong
bytes, source and archive both gone). Nothing is guessed and nothing is removed;
the item blocks unrelated mutations until a human decides.
"""
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.orm import sessionmaker
from photo_pipeline.models import ArchiveOperation, ArchivePlan
from photo_pipeline.services.hashing import sha256_file
class ArchiveState:
PLANNED = "planned"
TRANSFERRING = "transferring"
VERIFIED = "verified"
REMOVING = "removing"
COMPLETE = "complete"
FAILED = "failed"
ALLOWED_TRANSITIONS = {
ArchiveState.PLANNED: {ArchiveState.TRANSFERRING, ArchiveState.FAILED},
# From `transferring` the outcome is unknown until evidence is gathered, so it
# may resolve forward, back to planned (proven nothing was published), or fail.
ArchiveState.TRANSFERRING: {
ArchiveState.VERIFIED,
ArchiveState.PLANNED,
ArchiveState.FAILED,
},
ArchiveState.VERIFIED: {ArchiveState.REMOVING, ArchiveState.FAILED},
# No path back: once the source may be gone, only finishing is safe.
ArchiveState.REMOVING: {ArchiveState.COMPLETE, ArchiveState.FAILED},
ArchiveState.COMPLETE: set(),
# A retry re-enters `transferring`, which rechecks every precondition from
# scratch; recovery may also reset a failed item to `planned`.
ArchiveState.FAILED: {ArchiveState.PLANNED, ArchiveState.TRANSFERRING},
}
# A restore removes nothing, so it has no ``removing`` step: a verified published
# copy is the whole job (US06-04). Keeping this as a separate table means the
# archive direction still cannot reach ``complete`` without going through removal.
RESTORE_TRANSITIONS = {
**ALLOWED_TRANSITIONS,
ArchiveState.VERIFIED: {ArchiveState.COMPLETE, ArchiveState.FAILED},
}
TERMINAL_STATES = frozenset({ArchiveState.COMPLETE})
# States where this item may already have touched the filesystem.
UNSAFE_STATES = frozenset({ArchiveState.TRANSFERRING, ArchiveState.VERIFIED, ArchiveState.REMOVING})
# Which way the bytes move. Same rows, same evidence table, opposite direction.
ARCHIVE = "archive"
RESTORE = "restore"
RESUMABLE = "resumable"
FORWARD = "forward"
MANUAL = "manual"
class JournalError(RuntimeError):
pass
class InvalidTransition(JournalError):
pass
class JournalConflict(JournalError):
"""Fencing check failed; a newer owner has taken over this operation."""
def can_transition(current: str, target: str, direction: str = ARCHIVE) -> bool:
table = RESTORE_TRANSITIONS if direction == RESTORE else ALLOWED_TRANSITIONS
return target in table.get(current, set())
def _now() -> datetime:
return datetime.now(timezone.utc)
class ArchiveJournal:
def __init__(self, session_factory: sessionmaker) -> None:
self._session_factory = session_factory
# ── intent ────────────────────────────────────────────────────────────────
def begin(self, operation_id: str, *, worker_id: str, fencing_token: int) -> dict:
"""Record the intent to transfer **before** touching the filesystem."""
with self._session_factory() as session:
row = self._require(session, operation_id)
if row.fencing_token is not None and fencing_token < row.fencing_token:
raise JournalConflict(
f"stale fencing token {fencing_token} (current {row.fencing_token})"
)
if row.journal_state in TERMINAL_STATES:
raise InvalidTransition(f"{row.journal_state} is terminal")
if row.journal_state != ArchiveState.TRANSFERRING and not can_transition(
row.journal_state, ArchiveState.TRANSFERRING, row.direction
):
raise InvalidTransition(f"{row.journal_state} -> {ArchiveState.TRANSFERRING}")
if row.journal_state != ArchiveState.TRANSFERRING:
row.attempt_count += 1
row.journal_state = ArchiveState.TRANSFERRING
row.worker_id = worker_id
row.fencing_token = fencing_token
row.error_code = row.error_message = None
row.updated_at = _now()
session.commit()
return _operation_dict(row)
# ── transitions ───────────────────────────────────────────────────────────
def transition(
self,
operation_id: str,
target: str,
*,
fencing_token: int | None = None,
error: tuple[str, str] | None = None,
same_filesystem: bool | None = None,
) -> dict:
"""Move one operation to ``target``, enforcing the state machine.
Re-entering the state an operation already holds is a no-op, which is what
makes recovery idempotent across repeated restarts.
"""
with self._session_factory() as session:
row = self._require(session, operation_id)
if fencing_token is not None and row.fencing_token is not None:
if fencing_token < row.fencing_token:
raise JournalConflict(
f"stale fencing token {fencing_token} (current {row.fencing_token})"
)
if same_filesystem is not None:
row.same_filesystem = same_filesystem
if row.journal_state == target:
session.commit()
return _operation_dict(row) # idempotent
if not can_transition(row.journal_state, target, row.direction):
raise InvalidTransition(f"{row.journal_state} -> {target}")
row.journal_state = target
row.updated_at = _now()
if target == ArchiveState.VERIFIED:
row.verified_at = _now()
if target == ArchiveState.COMPLETE:
row.removed_at = _now()
if error:
row.error_code, row.error_message = error[0], error[1][:500]
elif target != ArchiveState.FAILED:
row.error_code = row.error_message = None
session.commit()
return _operation_dict(row)
# ── reads ─────────────────────────────────────────────────────────────────
def get(self, operation_id: str) -> dict | None:
with self._session_factory() as session:
row = session.get(ArchiveOperation, operation_id)
return _operation_dict(row) if row else None
def operations(self, plan_id: str) -> list[dict]:
with self._session_factory() as session:
rows = session.scalars(
select(ArchiveOperation)
.where(ArchiveOperation.plan_id == plan_id)
.order_by(ArchiveOperation.sequence)
)
return [_operation_dict(row) for row in rows]
def incomplete(self, *, direction: str | None = None) -> list[dict]:
"""Every operation left in a non-terminal, non-planned state — the work a
restart has to reason about. Without ``direction`` this spans archives and
restores, because either one half-done blocks the other."""
with self._session_factory() as session:
stmt = select(ArchiveOperation).where(
ArchiveOperation.journal_state.not_in([*TERMINAL_STATES, ArchiveState.PLANNED])
)
if direction is not None:
stmt = stmt.where(ArchiveOperation.direction == direction)
rows = session.scalars(
stmt.order_by(ArchiveOperation.plan_id, ArchiveOperation.sequence)
)
return [_operation_dict(row) for row in rows]
# ── startup classification ────────────────────────────────────────────────
def classify(self, operation_id: str) -> dict:
"""Classify one incomplete operation from the journal plus disk evidence.
Hashes the archived copy when one exists: "a file is at the destination" is
not evidence that the *right* bytes are, and only the right bytes justify
removing an original. Never mutates anything.
"""
row = self.get(operation_id)
if row is None:
raise JournalError(f"unknown archive operation {operation_id!r}")
source = Path(row["source_path"])
destination = Path(row["destination_path"])
source_exists = source.exists()
destination_exists = destination.exists()
destination_matches = (
destination_exists and sha256_file(destination) == row["expected_sha256"]
)
classification, reason = _classify(
row["journal_state"], source_exists, destination_exists, destination_matches
)
return {
"operation_id": operation_id,
"plan_id": row["plan_id"],
"direction": row["direction"],
"album": row["album"],
"asset_id": row["asset_id"],
"source_path": row["source_path"],
"destination_path": row["destination_path"],
"journal_state": row["journal_state"],
"classification": classification,
"reason": reason,
"source_exists": source_exists,
"destination_exists": destination_exists,
"destination_matches": destination_matches,
}
def classify_all(self, *, direction: str | None = None) -> list[dict]:
return [self.classify(row["id"]) for row in self.incomplete(direction=direction)]
def blocks_mutation(self) -> bool:
"""True when any item may have the library half-archived."""
return any(row["journal_state"] in UNSAFE_STATES for row in self.incomplete())
# ── plan-level ────────────────────────────────────────────────────────────
def plan_state(self, plan_id: str) -> str:
"""Derive the plan's state from its items, so the summary can never disagree
with the journal."""
states = {row["journal_state"] for row in self.operations(plan_id)}
if not states:
return "planned"
if states <= {ArchiveState.COMPLETE}:
return "complete"
if states & {ArchiveState.FAILED}:
return "failed"
if states & UNSAFE_STATES:
return "applying"
return "planned"
def sync_plan_state(self, plan_id: str) -> str:
state = self.plan_state(plan_id)
with self._session_factory() as session:
plan = session.get(ArchivePlan, plan_id)
if plan is None:
raise JournalError(f"unknown archive plan {plan_id!r}")
if plan.state != state:
plan.state = state
plan.version += 1
plan.updated_at = _now()
if state == "complete" and plan.completed_at is None:
plan.completed_at = _now()
session.commit()
return state
@staticmethod
def _require(session, operation_id: str) -> ArchiveOperation:
row = session.get(ArchiveOperation, operation_id)
if row is None:
raise JournalError(f"unknown archive operation {operation_id!r}")
return row
def _classify(
state: str, source_exists: bool, destination_exists: bool, destination_matches: bool
) -> tuple[str, str]:
"""The evidence table. Kept a pure function so every combination is testable."""
if destination_exists and not destination_matches and state != ArchiveState.PLANNED:
# Someone else's file, or a partial/edited copy: never overwrite it, and
# never treat it as the durable archive that justifies a deletion.
return MANUAL, "the archived path holds bytes that are not the recorded ones"
if state in (ArchiveState.TRANSFERRING, ArchiveState.FAILED):
if destination_matches:
return FORWARD, "the archived copy is durable; finish the remaining steps"
if source_exists:
return RESUMABLE, "nothing was published; the source is intact"
return MANUAL, "neither the source nor a verified archive copy is present"
if state in (ArchiveState.VERIFIED, ArchiveState.REMOVING):
if destination_matches:
return FORWARD, "the archived copy is durable; finish the remaining steps"
return MANUAL, f"journal says {state} but the archived copy is missing"
return MANUAL, f"unhandled journal state {state}"
def _operation_dict(row: ArchiveOperation) -> dict:
return {
"id": row.id,
"plan_id": row.plan_id,
"direction": row.direction,
"sequence": row.sequence,
"album": row.album,
"asset_id": row.asset_id,
"source_path": row.source_path,
"destination_path": row.destination_path,
"archive_path": row.archive_path,
"expected_sha256": row.expected_sha256,
"byte_size": row.byte_size,
"same_filesystem": row.same_filesystem,
"journal_state": row.journal_state,
"attempt_count": row.attempt_count,
"fencing_token": row.fencing_token,
"worker_id": row.worker_id,
"verified_at": row.verified_at.isoformat() if row.verified_at else None,
"removed_at": row.removed_at.isoformat() if row.removed_at else None,
"error_code": row.error_code,
"error_message": row.error_message,
}

View File

@@ -0,0 +1,644 @@
"""Transfer an approved archive plan, verify it, and remove the active sources
(US06-02).
This is the only module that deletes originals from the photo library, so every
step exists to make one promise keepable: **a source is removed only after the
archived bytes are durable and proven identical.** The journal (US06-02,
:mod:`photo_pipeline.services.archive_journal`) records intent before each mutation;
this module performs the mutations and the recovery that reads that intent back.
Per file the sequence is:
```
journal.begin (transferring) ← intent persisted BEFORE any disk change
recheck preconditions ← source hash, free destination, no symlink
copy to a temporary file ← same directory, so the publish is atomic
fsync, close, hash it back ← read from disk; the write is not the evidence
atomically publish ← rename onto the final archive path
append the manifest entry ← durable on the medium itself, fsynced
journal → verified
journal → removing ← intent to delete, persisted first
re-verify the archive copy, remove the source, verify its absence
current_path=NULL, close the path occurrence, availability + location recorded
journal → complete
```
Same-filesystem albums may skip the copy and use an atomic ``rename`` instead
(concept §9), but only when the shared device is proven at run time — never from the
plan's stored guess — and the published file is hashed afterwards exactly as in the
copy path.
Rules that are never relaxed:
- An occupied destination is never overwritten; the item fails with the source
untouched.
- A source whose bytes no longer match the plan is never archived and never removed.
- A crash resolves from journal + disk evidence only: an archive copy that is
missing or hashes differently blocks the item for a human instead of being
retried or, worse, treated as a successful archive.
- Recovery is idempotent — repeated passes converge on the same state.
"""
from __future__ import annotations
import json
import os
import shutil
import uuid
from datetime import datetime, timezone
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.orm import sessionmaker
from photo_pipeline.config import Config
from photo_pipeline.models import ArchiveLocation, ArchiveOperation, ArchivePlan, Asset, AssetPath
from photo_pipeline.services.archive_journal import (
ARCHIVE,
MANUAL,
RESUMABLE,
ArchiveJournal,
ArchiveState,
)
from photo_pipeline.services.archives import MARKER_NAME, ArchiveError, ArchiveService
from photo_pipeline.services.duplicates import DuplicateService
from photo_pipeline.services.hashing import sha256_file
from photo_pipeline.services.rename_apply import PreconditionFailed, maybe_fault
from photo_pipeline.services.thumbnails import ThumbnailService
# The per-medium manifest: one JSON line per archived file, appended and fsynced
# before its source is removed. It lives with the bytes so the archive can still be
# read back if the database is lost.
MANIFEST_NAME = "archive-manifest.jsonl"
MANIFEST_VERSION = 1
TEMP_SUFFIX = ".part"
TEMP_PREFIX = ".archive-"
# Fault barrier between "the source is gone" and "the database knows it" — not a
# journal state, but the transition crash tests care about most.
SOURCE_REMOVED = "source_removed"
APPLYABLE_PLAN_STATES = frozenset({"planned", "applying", "failed", "complete"})
def _now() -> datetime:
return datetime.now(timezone.utc)
class ArchiveTransferService:
def __init__(self, session_factory: sessionmaker, *, config: Config) -> None:
self._session_factory = session_factory
self._config = config
self.journal = ArchiveJournal(session_factory)
# ── plans ─────────────────────────────────────────────────────────────────
def create(self, location_id: str, albums: list[str] | None = None, *, token: str) -> dict:
"""Turn an approved preflight into a durable plan.
The token is re-derived from a fresh preflight, so a plan can only be
created for the exact scope, bytes, and destination the user approved.
"""
preflight = ArchiveService(self._session_factory, config=self._config).preflight(
location_id, albums
)
if not token or token != preflight["token"]:
raise ArchiveError("stale_token", "the archive preflight changed since it was approved")
if preflight["state"] != "ready":
codes = ", ".join(sorted({issue["code"] for issue in preflight["blockers"]})) or "-"
raise ArchiveError("blocked", f"the archive scope is blocked: {codes}")
root = Path(preflight["location"]["root"])
plan_id = str(uuid.uuid4())
with self._session_factory() as session:
session.add(
ArchivePlan(
id=plan_id,
location_id=location_id,
token=token,
albums=json.dumps(albums) if albums is not None else None,
direction=ARCHIVE,
state="planned",
schema_version=MANIFEST_VERSION,
asset_count=preflight["totals"]["assets"],
byte_size=preflight["totals"]["bytes"],
)
)
session.flush() # the plan row must exist before its items reference it
sequence = 0
for album in preflight["albums"]:
destination_dir = Path(album["destination"])
for asset in album["assets"]:
source = Path(asset["current_path"])
destination = destination_dir / source.name
session.add(
ArchiveOperation(
id=str(uuid.uuid4()),
plan_id=plan_id,
direction=ARCHIVE,
sequence=sequence,
album=album["album"],
asset_id=asset["asset_id"],
source_path=str(source),
destination_path=str(destination),
archive_path=str(destination.relative_to(root)),
expected_sha256=asset["current_sha256"],
byte_size=asset["byte_size"],
# Recorded as a preview only; the real decision is made
# against the devices at apply time.
same_filesystem=album["transfer_method"] == "move",
journal_state=ArchiveState.PLANNED,
)
)
sequence += 1
session.commit()
return self.get(plan_id)
def get(self, plan_id: str) -> dict | None:
with self._session_factory() as session:
plan = session.get(ArchivePlan, plan_id)
if plan is None:
return None
report = _plan_dict(plan)
report["operations"] = self.journal.operations(plan_id)
return report
def list(self) -> list[dict]:
with self._session_factory() as session:
rows = session.scalars(
select(ArchivePlan)
.where(ArchivePlan.direction == ARCHIVE)
.order_by(ArchivePlan.created_at)
)
return [_plan_dict(row) for row in rows]
# ── apply ─────────────────────────────────────────────────────────────────
def apply(
self,
plan_id: str,
*,
expected_version: int | None = None,
worker_id: str = "archive",
) -> dict:
"""Archive every item of a plan, then report what happened.
Items are independent: one failure records its reason and leaves that
source in place; the rest of the album continues.
"""
plan = self._require_plan(plan_id)
if expected_version is not None and plan["version"] != expected_version:
raise ArchiveError(
"stale_plan",
f"plan {plan_id} is at version {plan['version']}, expected {expected_version}",
)
if plan["state"] not in APPLYABLE_PLAN_STATES:
raise ArchiveError("invalid_state", f"plan {plan_id} is {plan['state']}")
# One archiver lane: never start while another plan may be half-archived
# (concept §16 lock hierarchy).
blocking = [row for row in self.journal.incomplete() if row["plan_id"] != plan_id]
if blocking:
raise ArchiveError(
"archive_pending",
f"another archive operation is unresolved ({blocking[0]['id']}); recover it first",
)
token = self._claim_plan(plan_id)
location = self._location(plan["location_id"])
archived = failed = skipped = 0
for operation in self.journal.operations(plan_id):
if operation["journal_state"] == ArchiveState.COMPLETE:
skipped += 1 # repeated apply is a no-op for finished work
continue
try:
if operation["journal_state"] in (ArchiveState.VERIFIED, ArchiveState.REMOVING):
# The bytes are already archived; never transfer them twice.
self._finish(operation, location, token=token, worker_id=worker_id)
else:
self._archive_one(operation, location, token=token, worker_id=worker_id)
archived += 1
except PreconditionFailed as error:
self._fail(operation, token, error.code, str(error))
failed += 1
except Exception as error: # unexpected: record and stop touching disk
self._fail(operation, token, "archive_error", str(error))
failed += 1
self._prune_empty_sources(plan_id)
state = self.journal.sync_plan_state(plan_id)
return {
"plan_id": plan_id,
"archived": archived,
"failed": failed,
"skipped": skipped,
"state": state,
}
def _prune_empty_sources(self, plan_id: str) -> None:
"""Drop an album folder once every one of its files is archived.
``rmdir`` only: a folder that still holds anything at all — an unarchived
file, someone else's file, a subfolder — is left exactly as it is.
"""
folders: dict[Path, set[str]] = {}
for operation in self.journal.operations(plan_id):
folders.setdefault(Path(operation["source_path"]).parent, set()).add(
operation["journal_state"]
)
for folder, states in folders.items():
if states == {ArchiveState.COMPLETE}:
try:
folder.rmdir()
except OSError:
pass # not empty, or gone already; either way, leave it alone
def _archive_one(self, operation: dict, location: dict, *, token: int, worker_id: str) -> None:
source = Path(operation["source_path"])
destination = Path(operation["destination_path"])
# 1. Intent first — after this point a crash is recoverable from evidence.
self.journal.begin(operation["id"], worker_id=worker_id, fencing_token=token)
maybe_fault(ArchiveState.TRANSFERRING)
# 2. Recheck immediately before mutating; the plan's snapshot is not trusted.
self._recheck(operation, source, destination, location)
destination.parent.mkdir(parents=True, exist_ok=True)
# 3. Transfer. Same-filesystem is an optimisation, so it has to be proven
# here rather than believed from the plan.
same_filesystem = _same_filesystem(source, destination.parent)
if same_filesystem:
os.rename(source, destination)
else:
copy_verify_publish(source, destination, operation["expected_sha256"])
_fsync_dir(destination.parent)
# 4. The published file is the archive only once it hashes as recorded.
if sha256_file(destination) != operation["expected_sha256"]:
raise PreconditionFailed(
"archive_mismatch", f"{destination} does not hold the expected bytes"
)
_append_manifest(destination.parent, _manifest_entry(operation, location))
self.journal.transition(
operation["id"],
ArchiveState.VERIFIED,
fencing_token=token,
same_filesystem=same_filesystem,
)
maybe_fault(ArchiveState.VERIFIED)
# 5. Only now may the active source go.
self._finish(self.journal.get(operation["id"]), location, token=token, worker_id=worker_id)
def _finish(self, operation: dict, location: dict, *, token: int, worker_id: str) -> None:
"""Drive an item whose archive copy is durable through removal and
bookkeeping. Every step is idempotent, so recovery may replay it."""
destination = Path(operation["destination_path"])
source = Path(operation["source_path"])
state = operation["journal_state"]
if state == ArchiveState.FAILED:
# The archive copy is durable even though the attempt ended badly:
# re-enter the transfer state so the remaining steps can run.
self.journal.transition(operation["id"], ArchiveState.TRANSFERRING, fencing_token=token)
state = ArchiveState.TRANSFERRING
if state == ArchiveState.TRANSFERRING:
if sha256_file(destination) != operation["expected_sha256"]:
raise PreconditionFailed(
"archive_mismatch", f"{destination} does not hold the expected bytes"
)
_append_manifest(destination.parent, _manifest_entry(operation, location))
self.journal.transition(operation["id"], ArchiveState.VERIFIED, fencing_token=token)
state = ArchiveState.VERIFIED
if state == ArchiveState.VERIFIED:
self.journal.transition(operation["id"], ArchiveState.REMOVING, fencing_token=token)
maybe_fault(ArchiveState.REMOVING)
state = ArchiveState.REMOVING
if state == ArchiveState.REMOVING:
# Re-verify the archived bytes immediately before deleting the original:
# this check is the entire justification for the removal.
if not destination.exists() or sha256_file(destination) != operation["expected_sha256"]:
raise PreconditionFailed(
"archive_unverified", f"{destination} is not a verified archive copy"
)
self._require_evidence(operation["asset_id"], destination)
if source.exists():
if source.is_symlink():
raise PreconditionFailed("symlink", f"{source} became a symlink")
if sha256_file(source) != operation["expected_sha256"]:
raise PreconditionFailed(
"source_changed", f"{source} changed; it is not ours to remove"
)
source.unlink()
if source.exists():
raise PreconditionFailed("removal_failed", f"{source} is still present")
# The dangerous window: active storage no longer holds the file while the
# database still points at it.
maybe_fault(SOURCE_REMOVED)
self._record_archived(operation, location, destination)
self.journal.transition(operation["id"], ArchiveState.COMPLETE, fencing_token=token)
maybe_fault(ArchiveState.COMPLETE)
def _recheck(self, operation: dict, source: Path, destination: Path, location: dict) -> None:
if not source.exists():
raise PreconditionFailed("source_missing", f"source {source} disappeared")
if source.is_symlink() or destination.is_symlink():
raise PreconditionFailed("symlink", "refusing to archive through a symlink")
if destination.exists():
raise PreconditionFailed("destination_exists", f"destination {destination} is occupied")
root = Path(location["root"])
if root not in destination.parents:
raise PreconditionFailed(
"destination_escape", f"{destination} is outside the archive location {root}"
)
if not root.is_dir() or not (root / MARKER_NAME).exists():
raise PreconditionFailed("location_offline", f"{root} is not the archive medium")
if sha256_file(source) != operation["expected_sha256"]:
raise PreconditionFailed(
"source_changed", f"{source} changed since the plan was approved"
)
with self._session_factory() as session:
asset = session.get(Asset, operation["asset_id"])
if asset is None or asset.current_path != str(source):
raise PreconditionFailed(
"asset_moved", f"asset {operation['asset_id']} is no longer at {source}"
)
def _require_evidence(self, asset_id: str, source: Path) -> dict:
"""Review evidence must be durable before the original goes.
The perceptual hash keeps the asset in the fuzzy index once its bytes are
unreachable, and the protected preview is what duplicate review can still
look at. Both are read from the freshly verified archive copy, which holds
exactly the bytes being archived. A file that cannot be decoded has neither
— recorded, not fatal, since its exact hashes remain — but failing to
produce a preview from a decodable original stops the removal (concept §9).
"""
DuplicateService(self._session_factory).ensure_phash(asset_id, source=source)
preview = ThumbnailService(self._session_factory, self._config).ensure_protected(
asset_id, source=source
)
if preview["state"] == "unavailable":
raise PreconditionFailed(
"preview_unavailable",
f"no durable comparison preview for asset {asset_id} "
f"({preview['error_code']})",
)
return preview
# ── database ──────────────────────────────────────────────────────────────
def _record_archived(self, operation: dict, location: dict, destination: Path) -> None:
"""The original is gone from active storage: drop ``current_path``, close its
occurrence, record where the bytes now live, and set availability."""
now = _now()
with self._session_factory() as session:
asset = session.get(Asset, operation["asset_id"])
if asset is None:
raise PreconditionFailed(
"asset_missing", f"asset {operation['asset_id']} no longer exists"
)
if asset.current_path:
for row in session.scalars(
select(AssetPath).where(
AssetPath.asset_id == asset.id,
AssetPath.path == asset.current_path,
AssetPath.valid_until.is_(None),
)
):
row.valid_until = now
recorded = session.scalar(
select(AssetPath).where(
AssetPath.asset_id == asset.id, AssetPath.path == str(destination)
)
)
if recorded is None: # idempotent: recovery may replay this
session.add(
AssetPath(
asset_id=asset.id,
path=str(destination),
valid_from=now,
reason="archive",
)
)
asset.current_path = None
asset.availability_state = (
"archived_online" if destination.exists() else "archived_offline"
)
asset.archive_location_id = location["id"]
asset.archive_path = operation["archive_path"]
asset.state_version += 1
asset.updated_at = now
session.commit()
# ── recovery ──────────────────────────────────────────────────────────────
def recover(self, *, worker_id: str = "archive-recovery") -> dict:
"""Resolve every incomplete item from journal + disk evidence.
Idempotent: running it repeatedly converges. Ambiguous (``manual``) work is
left exactly as found and keeps blocking unrelated mutations.
"""
results = {"resumed": 0, "completed": 0, "manual": 0}
touched: set[str] = set()
for verdict in self.journal.classify_all(direction=ARCHIVE):
operation = self.journal.get(verdict["operation_id"])
touched.add(operation["plan_id"])
token = (operation["fencing_token"] or 0) + 1
if verdict["classification"] == MANUAL:
results["manual"] += 1
continue
if verdict["classification"] == RESUMABLE:
# Nothing was published: discard the debris and let a later apply
# retry the item cleanly.
_clean_temp_files(Path(operation["destination_path"]).parent)
self.journal.transition(operation["id"], ArchiveState.PLANNED, fencing_token=token)
results["resumed"] += 1
continue
location = self._location(self._require_plan(operation["plan_id"])["location_id"])
try:
self._finish(operation, location, token=token, worker_id=worker_id)
results["completed"] += 1
except PreconditionFailed as error:
self._fail(operation, token, error.code, str(error))
results["manual"] += 1
for plan_id in touched:
self._prune_empty_sources(plan_id)
self.journal.sync_plan_state(plan_id)
return results
def recovery_status(self) -> dict:
verdicts = self.journal.classify_all(direction=ARCHIVE)
return {
"operations": verdicts,
"manual": [v for v in verdicts if v["classification"] == MANUAL],
"blocks_mutation": self.journal.blocks_mutation(),
}
# ── helpers ───────────────────────────────────────────────────────────────
def _fail(self, operation: dict, token: int, code: str, message: str) -> None:
self.journal.transition(
operation["id"], ArchiveState.FAILED, fencing_token=token, error=(code, message)
)
def _require_plan(self, plan_id: str) -> dict:
with self._session_factory() as session:
plan = session.get(ArchivePlan, plan_id)
if plan is None:
raise ArchiveError("unknown_plan", f"unknown archive plan {plan_id!r}")
return _plan_dict(plan)
def _location(self, location_id: str) -> dict:
with self._session_factory() as session:
location = session.get(ArchiveLocation, location_id)
if location is None:
raise ArchiveError("unknown_location", f"unknown archive location {location_id!r}")
return {"id": location.id, "root": location.root, "media_id": location.media_id}
def _claim_plan(self, plan_id: str) -> int:
"""Bump the plan version and use it as this attempt's fencing token, so a
worker from a superseded attempt cannot commit."""
with self._session_factory() as session:
plan = session.get(ArchivePlan, plan_id)
plan.version += 1
plan.state = "applying"
plan.updated_at = _now()
token = plan.version
session.commit()
return token
# ── module helpers ───────────────────────────────────────────────────────────
def copy_verify_publish(source: Path, destination: Path, expected_sha256: str) -> None:
"""Copy to a temporary file beside the destination, prove its bytes, then publish
it atomically. The source is never touched, so a failure costs nothing.
Shared by archiving (library → medium) and restoring (medium → library, US06-04):
both need the same promise that a published file is either complete and correct
or not there at all.
"""
temp = destination.with_name(f"{TEMP_PREFIX}{uuid.uuid4().hex}{TEMP_SUFFIX}")
try:
with open(source, "rb") as src, open(temp, "wb") as out:
shutil.copyfileobj(src, out, 1024 * 1024)
out.flush()
os.fsync(out.fileno())
if sha256_file(temp) != expected_sha256:
raise PreconditionFailed("copy_mismatch", f"{source} copied with wrong bytes")
if destination.exists():
raise PreconditionFailed(
"destination_exists", f"{destination} appeared during the transfer"
)
# ponytail: rename after an exists() check. The archiver lane is single and
# local; use O_EXCL/link-based publish if a second writer ever exists.
os.rename(temp, destination)
finally:
temp.unlink(missing_ok=True)
def _same_filesystem(source: Path, destination_dir: Path) -> bool:
"""Proven at run time from the actual devices, never from the plan's preview."""
try:
return source.stat().st_dev == destination_dir.stat().st_dev
except OSError:
return False
def _fsync_dir(path: Path) -> None:
"""Make the directory entry itself durable, so the published name survives a
power loss and not just the file's data."""
fd = os.open(path, os.O_RDONLY)
try:
os.fsync(fd)
except OSError:
pass # some filesystems refuse directory fsync; the data is already synced
finally:
os.close(fd)
def _manifest_entry(operation: dict, location: dict) -> dict:
return {
"schema_version": MANIFEST_VERSION,
"plan_id": operation["plan_id"],
"asset_id": operation["asset_id"],
"album": operation["album"],
"archive_path": operation["archive_path"],
"source_path": operation["source_path"],
"sha256": operation["expected_sha256"],
"byte_size": operation["byte_size"],
"media_id": location["media_id"],
"archived_at": _now().isoformat(),
}
def _append_manifest(directory: Path, entry: dict) -> None:
"""Append one durable manifest line, skipping an entry that is already there.
The manifest is written before the source is removed, so it is the medium's own
record of what it holds even if the database is lost.
"""
path = directory / MANIFEST_NAME
# ponytail: rereads the album manifest per file (O(n²) lines for one album).
# Keep an in-memory index per plan if an album ever holds enough files to matter.
if path.exists():
for line in path.read_text(encoding="utf-8").splitlines():
try:
existing = json.loads(line)
except ValueError:
continue
if (existing.get("asset_id"), existing.get("sha256")) == (
entry["asset_id"],
entry["sha256"],
):
return
with open(path, "a", encoding="utf-8") as handle:
handle.write(json.dumps(entry, sort_keys=True) + "\n")
handle.flush()
os.fsync(handle.fileno())
_fsync_dir(directory)
def read_manifest(directory: Path) -> list[dict]:
"""Every manifest entry an archived album directory holds."""
path = directory / MANIFEST_NAME
if not path.exists():
return []
entries = []
for line in path.read_text(encoding="utf-8").splitlines():
try:
entries.append(json.loads(line))
except ValueError:
continue
return entries
def _clean_temp_files(directory: Path) -> None:
"""Remove this application's own abandoned transfer temporaries — never any
other file (concept §17: startup cleans only recognised stale temporaries)."""
if not directory.is_dir():
return
for temp in directory.glob(f"{TEMP_PREFIX}*{TEMP_SUFFIX}"):
temp.unlink(missing_ok=True)
def _plan_dict(plan: ArchivePlan) -> dict:
return {
"id": plan.id,
"location_id": plan.location_id,
"token": plan.token,
"direction": plan.direction,
"albums": json.loads(plan.albums) if plan.albums else None,
"state": plan.state,
"schema_version": plan.schema_version,
"asset_count": plan.asset_count,
"byte_size": plan.byte_size,
"version": plan.version,
"worker_id": plan.worker_id,
"completed_at": plan.completed_at.isoformat() if plan.completed_at else None,
"created_at": plan.created_at.isoformat() if plan.created_at else None,
}

View File

@@ -0,0 +1,637 @@
"""ArchiveService — destinations and archive preflight (US06-01).
Archive is the only stage that *removes* originals from the active library, so
this service does the opposite of removing anything: it registers destinations and
proves, before a single byte moves, that an album could be archived safely. The
transfer itself is US06-02.
An archive location is a medium, not a path (see :class:`ArchiveLocation`). A
marker file on the medium carries its ``media_id``, so a disk mounted at the
recorded root but holding a different marker is ``wrong_volume`` rather than
silently accepted — the classic "the external disk came back at the same
mountpoint" data-loss path.
Preflight proves, per concept §9 "Archive preflight":
- the album's upload is *verified*, not merely process-successful, and its bytes on
disk still hash to exactly what was uploaded;
- the destination medium is mounted, is the right one, is writable, lies outside
every library root and every ``_IGNORE/`` tree, and has room for the scope plus a
configured reserve;
- nothing already occupies the destination;
- no rename/upload/archive lease is held, and no rename is half-applied;
- a database backup and the archive manifest can really be written — both are
probed by writing them, not assumed.
Blocker codes: ``no_library_root``, ``location_offline``, ``wrong_volume``,
``archive_pending``,
``unsafe_destination``, ``destination_not_writable``, ``manifest_unwritable``,
``insufficient_capacity``, ``backup_unavailable``, ``lock_conflict``,
``rename_pending``, ``empty_scope``, ``destination_collision``,
``upload_unverified``, ``bytes_changed``, ``file_missing``, ``preview_unavailable``.
Preflight also *creates* the durable comparison preview of every asset in scope
(US06-03): it is the evidence duplicate review falls back on once the original is
on a medium that may be offline, so it has to exist before the original leaves.
Like upload preflight, the confirmation token is *derived* from the report rather
than stored: any change to the scope, the bytes, the destination, or the blockers
produces a different token, so a stale browser confirmation can never apply. Values
that drift without meaning anything (free space, backup size, timestamps) are left
out of the digest.
"""
from __future__ import annotations
import hashlib
import json
import os
import shutil
import sqlite3
import uuid
from contextlib import closing
from datetime import datetime, timezone
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.orm import sessionmaker
from photo_pipeline.config import Config
from photo_pipeline.integrations import immich_go_report as report_parser
from photo_pipeline.jobs.domain_handlers import ARCHIVE_LOCK, LIBRARY_WRITE_LOCK, UPLOAD_LOCK
from photo_pipeline.models import ArchiveLocation, Asset, UploadBatch, UploadItem
from photo_pipeline.path_policy import PathPolicyError, is_excluded, normalize_root, resolve_within
from photo_pipeline.services.albums import album_label
from photo_pipeline.services.archive_journal import ArchiveJournal
from photo_pipeline.services.availability import MARKER_NAME, read_marker as _read_marker
from photo_pipeline.services.availability import refresh as refresh_availability
from photo_pipeline.services.hashing import sha256_file
from photo_pipeline.services.jobs import JobService
from photo_pipeline.services.rename_journal import RenameJournal
from photo_pipeline.services.thumbnails import ThumbnailService
from photo_pipeline.services.upload_reports import VERIFIED
PREFLIGHT_VERSION = 1
TOKEN_PREFIX = f"v{PREFLIGHT_VERSION}"
MANIFEST_NAME = "archive-manifest.json"
# Upload outcomes that prove Immich holds these exact bytes. ``skipped``/``failed``/
# ``unknown`` never qualify: archiving on them would remove the only copy.
ARCHIVED_OUTCOMES = frozenset(
{report_parser.UPLOADED, report_parser.UPGRADED, report_parser.DUPLICATE}
)
LOCKS = (LIBRARY_WRITE_LOCK, UPLOAD_LOCK, ARCHIVE_LOCK)
class ArchiveError(RuntimeError):
"""The request cannot be carried out (unknown location/album, unsafe root)."""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
def _now() -> datetime:
return datetime.now(timezone.utc)
def _issue(code: str, message: str) -> dict:
return {"code": code, "message": message}
class ArchiveService:
def __init__(self, session_factory: sessionmaker, *, config: Config) -> None:
self._session_factory = session_factory
self._config = config
self._roots = tuple(normalize_root(root) for root in config.library_roots)
# ── locations ─────────────────────────────────────────────────────────────
def register(self, name: str, root: str) -> dict:
"""Register an archive destination and stamp its medium with a marker.
The marker is what makes the location identifiable later, so registering is
the one archive operation that writes to the destination up front.
"""
name = (name or "").strip()
if not name:
raise ArchiveError("name_required", "an archive location needs a name")
path = Path(root).expanduser()
if not path.is_dir():
raise ArchiveError("root_missing", f"{path} is not an existing directory")
path = normalize_root(path)
unsafe = self._unsafe_destination(path)
if unsafe:
raise ArchiveError("unsafe_destination", unsafe)
marker = _read_marker(path)
with self._session_factory() as session:
if session.scalar(select(ArchiveLocation).where(ArchiveLocation.name == name)):
raise ArchiveError("duplicate_name", f"an archive location named {name!r} exists")
if marker and session.scalar(
select(ArchiveLocation).where(ArchiveLocation.media_id == marker.get("media_id"))
):
raise ArchiveError(
"already_registered", f"{path} already belongs to another archive location"
)
media_id = marker.get("media_id") if marker else str(uuid.uuid4())
error = _probe_write(
path / MARKER_NAME,
json.dumps({"media_id": media_id, "name": name}, indent=2).encode("utf-8"),
keep=True,
)
if error:
raise ArchiveError("destination_not_writable", error)
location = ArchiveLocation(
id=str(uuid.uuid4()),
name=name,
root=str(path),
media_id=media_id,
state="online",
last_seen_at=_now(),
)
location.capabilities = json.dumps(_capabilities(path))
session.add(location)
session.commit()
return self._location_report(location, probe=_probe_location(location))
def locations(self) -> list[dict]:
"""Every configured location with a fresh probe of its medium."""
with self._session_factory() as session:
rows = list(session.scalars(select(ArchiveLocation).order_by(ArchiveLocation.name)))
reports = []
for location in rows:
probe = _probe_location(location)
location.state = probe["state"]
if probe["state"] == "online":
location.last_seen_at = _now()
location.capabilities = json.dumps(probe["capabilities"])
reports.append(self._location_report(location, probe=probe))
session.commit()
# A medium that just appeared or vanished changes what is readable, so the
# archived assets are re-derived from the same probe (US06-03).
refresh_availability(self._session_factory)
return reports
# ── preflight ─────────────────────────────────────────────────────────────
def preflight(self, location_id: str, albums: list[str] | None = None) -> dict:
"""Validate an archive scope against a destination and issue its token.
Read-only with respect to the library: it hashes files, probes the
destination with its own temporary files, and writes nothing else.
"""
with self._session_factory() as session:
location = session.get(ArchiveLocation, location_id)
if location is None:
raise ArchiveError("unknown_location", f"unknown archive location {location_id!r}")
probe = _probe_location(location)
location.state = probe["state"]
if probe["state"] == "online":
location.last_seen_at = _now()
location.capabilities = json.dumps(probe["capabilities"])
report = {
"schema_version": PREFLIGHT_VERSION,
"location": self._location_report(location, probe=probe),
"blockers": [],
}
root = Path(location.root)
session.commit()
report["blockers"] += self._destination_blockers(root, probe)
report["blockers"] += self._lock_blockers()
report["albums"] = self._albums(albums, root, reachable=probe["state"] == "online")
report["totals"] = _totals(report["albums"])
report["capacity"] = self._capacity(report["totals"]["bytes"], probe)
if not report["capacity"]["sufficient"]:
report["blockers"].append(
_issue(
"insufficient_capacity",
f"{report['totals']['bytes']} B plus a "
f"{self._config.archive_free_space_reserve_bytes} B reserve do not fit in "
f"{report['capacity']['free_bytes']} B of free space",
)
)
report["backup"] = self._backup_probe()
if not report["backup"]["ok"]:
report["blockers"].append(
_issue(
"backup_unavailable",
f"a database backup could not be written: {report['backup']['detail']}",
)
)
report["manifest"] = self._manifest_probe(
root, report["albums"], writable=probe["writable"]
)
if not report["manifest"]["ok"]:
report["blockers"].append(
_issue(
"manifest_unwritable",
f"the archive manifest could not be written: {report['manifest']['detail']}",
)
)
if not report["albums"]:
report["blockers"].append(
_issue("empty_scope", "no canonical, active assets are in the selected scope")
)
report["state"] = (
"ready"
if not report["blockers"] and all(a["state"] == "ready" for a in report["albums"])
else "blocked"
)
report["token"] = _token(report)
report["generated_at"] = _now().isoformat()
return report
def verify_token(self, token: str, location_id: str, albums: list[str] | None = None) -> bool:
"""True when ``token`` still describes this scope and this destination.
Recomputed, never looked up: an edited source file, a swapped medium, or a
newly occupied destination invalidates it without anything writing to the
database.
"""
return bool(token) and token == self.preflight(location_id, albums)["token"]
# ── destination ───────────────────────────────────────────────────────────
def _unsafe_destination(self, root: Path) -> str | None:
"""Why this root may never hold archived originals, or ``None``."""
if is_excluded(root):
return f"{root} is inside an excluded (_IGNORE/) tree"
for library in self._roots:
if root == library or library in root.parents or root in library.parents:
return f"{root} overlaps the active library root {library}"
return None
def _destination_blockers(self, root: Path, probe: dict) -> list[dict]:
blockers: list[dict] = []
if not self._roots:
blockers.append(_issue("no_library_root", "no library root is configured"))
if probe["state"] == "offline":
blockers.append(
_issue("location_offline", f"the archive medium is not mounted at {root}")
)
elif probe["state"] == "wrong_volume":
blockers.append(
_issue(
"wrong_volume",
f"{root} holds a different archive medium ({probe['detail']})",
)
)
unsafe = self._unsafe_destination(root)
if unsafe:
blockers.append(_issue("unsafe_destination", unsafe))
if probe["state"] == "unwritable":
blockers.append(
_issue("destination_not_writable", f"{root} is not writable: {probe['detail']}")
)
return blockers
def _lock_blockers(self) -> list[dict]:
"""Archive is blocked by any lease that may still be moving bytes or metadata."""
blockers: list[dict] = []
jobs = JobService(self._session_factory)
for lock in LOCKS:
held = jobs.blockers(lock)
if held:
blockers.append(
_issue("lock_conflict", f"the {lock} lane is busy: job {held[0]['id']}")
)
if RenameJournal(self._session_factory).blocks_mutation():
blockers.append(
_issue("rename_pending", "an unresolved rename must be recovered before archiving")
)
if ArchiveJournal(self._session_factory).blocks_mutation():
blockers.append(
_issue(
"archive_pending",
"an unresolved archive transfer must be recovered before archiving again",
)
)
return blockers
def _capacity(self, required: int, probe: dict) -> dict:
reserve = self._config.archive_free_space_reserve_bytes
free = probe["free_bytes"]
return {
"required_bytes": required,
"reserve_bytes": reserve,
"free_bytes": free,
"sufficient": free is not None and free >= required + reserve,
}
def _backup_probe(self) -> dict:
"""Write a real online backup of the database, then discard it.
A backup that is merely assumed to be possible is worth nothing on the day
the archive removes the originals, so this actually runs SQLite's backup API.
"""
source = self._config.database_path
target = source.parent / f".archive-preflight-backup-{uuid.uuid4()}.db"
try:
with closing(sqlite3.connect(source)) as src, closing(sqlite3.connect(target)) as dst:
src.backup(dst)
size = target.stat().st_size
except (sqlite3.Error, OSError) as error:
return {"ok": False, "bytes": None, "detail": str(error)}
finally:
target.unlink(missing_ok=True)
return {"ok": True, "bytes": size, "detail": None}
def _manifest_probe(self, root: Path, albums: list[dict], *, writable: bool) -> dict:
"""Prove the manifest can be created by writing this exact content and
removing it again. The real manifest is written by the transfer (US06-02)."""
manifest = {
"schema_version": PREFLIGHT_VERSION,
"albums": [
{
"album": album["album"],
"destination": album["destination"],
"files": [
{
"asset_id": asset["asset_id"],
"source": asset["current_path"],
"sha256": asset["current_sha256"],
"byte_size": asset["byte_size"],
}
for asset in album["assets"]
],
}
for album in albums
],
}
payload = json.dumps(manifest, indent=2, sort_keys=True).encode("utf-8")
if not writable:
return {"ok": False, "bytes": len(payload), "detail": "the destination is unavailable"}
error = _probe_write(root / f".{MANIFEST_NAME}.probe-{uuid.uuid4()}", payload)
return {"ok": error is None, "bytes": len(payload), "detail": error}
def _location_report(self, location: ArchiveLocation, *, probe: dict) -> dict:
return {
"id": location.id,
"name": location.name,
"root": location.root,
"media_id": location.media_id,
"state": probe["state"],
"writable": probe["writable"],
"device_id": probe["device_id"],
"detail": probe["detail"],
"last_seen_at": location.last_seen_at.isoformat() if location.last_seen_at else None,
}
# ── scope ─────────────────────────────────────────────────────────────────
def _albums(self, requested: list[str] | None, root: Path, *, reachable: bool) -> list[dict]:
by_album = self._scope()
if requested is not None:
unknown = sorted(set(requested) - set(by_album))
if unknown:
raise ArchiveError("unknown_album", f"unknown album(s): {', '.join(unknown)}")
by_album = {name: by_album[name] for name in sorted(set(requested))}
return [
self._album(name, rows, root, reachable=reachable)
for name, rows in sorted(by_album.items())
]
def _scope(self) -> dict[str, list[dict]]:
"""Canonical, active assets grouped by album, each with its upload evidence."""
with self._session_factory() as session:
assets = list(
session.scalars(
select(Asset).where(
Asset.canonical_asset_id.is_(None),
Asset.availability_state == "active",
Asset.current_path.is_not(None),
)
)
)
uploads: dict[str, UploadItem] = {}
for item, batch in session.execute(
select(UploadItem, UploadBatch)
.join(UploadBatch, UploadBatch.id == UploadItem.batch_id)
.order_by(UploadBatch.created_at)
):
if _proves_upload(item, batch):
uploads[item.asset_id] = item # the latest verified batch wins
by_album: dict[str, list[dict]] = {}
for asset in assets:
by_album.setdefault(album_label(asset.current_path, self._roots), []).append(
{
"asset_id": asset.id,
"path": asset.current_path,
"byte_size": asset.byte_size,
"upload": uploads.get(asset.id),
}
)
return by_album
def _album(self, name: str, rows: list[dict], root: Path, *, reachable: bool) -> dict:
folder = Path(rows[0]["path"]).parent
items = sorted(
(self._with_preview(_item(row)) for row in rows),
key=lambda item: item["current_path"],
)
blocked = [item for item in items if item["blockers"]]
blockers: list[dict] = []
destination = root / name
try:
resolve_within(root, destination)
except PathPolicyError as error:
blockers.append(_issue("unsafe_destination", str(error)))
if reachable and destination.exists() and any(destination.iterdir()):
blockers.append(
_issue("destination_collision", f"{destination} already exists and is not empty")
)
if blocked:
blockers.append(
_issue(
"partial_scope",
f"{len(blocked)} of {len(items)} asset(s) are not archivable; an album is "
"archived whole or not at all",
)
)
return {
"album": name,
"folder": str(folder),
"destination": str(destination),
# Same filesystem means the transfer can be an atomic move; anything else
# is copy-verify-remove (concept §9).
"transfer_method": _transfer_method(folder, root),
"asset_count": len(items),
"blocked_count": len(blocked),
"reclaimable_bytes": sum(item["byte_size"] or 0 for item in items),
"state": "blocked" if blockers else "ready",
"blockers": blockers,
"assets": items,
}
def _with_preview(self, item: dict) -> dict:
"""Create the durable comparison preview while the original is still here.
This is the last moment it can be made: once the file is archived and the
medium leaves, only the retained preview can answer "is this new photo the
same picture?". An original that cannot be decoded at all has no preview to
keep — its hashes and metadata stay the evidence — but a preview that fails
for any other reason blocks the archive (concept §9).
"""
preview = self._previews().ensure_protected(item["asset_id"])
item["preview"] = preview
if preview["state"] == "unavailable" and not item["blockers"]:
item["blockers"].append(
_issue(
"preview_unavailable",
f"a durable comparison preview of {item['current_path']} could not be "
f"created ({preview['error_code']})",
)
)
return item
def _previews(self) -> ThumbnailService:
return ThumbnailService(self._session_factory, self._config)
# ── internals ────────────────────────────────────────────────────────────────
def _item(row: dict) -> dict:
"""One asset's archivability: verified upload plus the bytes on disk right now."""
path = Path(row["path"])
upload: UploadItem | None = row["upload"]
blockers: list[dict] = []
current_sha256 = None
if not path.exists():
blockers.append(_issue("file_missing", f"{path} is missing"))
else:
# ponytail: full re-hash of the scope. Gate on (size, mtime_ns) first if a
# large album makes this slow — the hash stays the authority.
current_sha256 = sha256_file(path)
if upload is None:
blockers.append(
_issue("upload_unverified", "a verified Immich upload of these bytes is required")
)
elif current_sha256 is not None and upload.sha256 and current_sha256 != upload.sha256:
blockers.append(
_issue("bytes_changed", f"{path} changed since it was uploaded; re-upload it first")
)
return {
"asset_id": row["asset_id"],
"current_path": str(path),
"byte_size": row["byte_size"],
"current_sha256": current_sha256,
"uploaded_sha256": upload.sha256 if upload else None,
"blockers": blockers,
}
def _proves_upload(item: UploadItem, batch: UploadBatch) -> bool:
"""Whether this upload item is evidence that Immich holds these exact bytes."""
return (
batch.outcome_state == VERIFIED
and not batch.stale_bytes
and not item.changed_after_upload
and item.outcome in ARCHIVED_OUTCOMES
)
def _transfer_method(folder: Path, root: Path) -> str:
try:
if folder.stat().st_dev == root.stat().st_dev:
return "move"
except OSError:
pass
return "copy_verify_remove"
def _probe_write(path: Path, payload: bytes, *, keep: bool = False) -> str | None:
"""Write ``payload`` to ``path``; return the failure detail or ``None``."""
try:
path.write_bytes(payload)
except OSError as error:
return str(error)
if not keep:
try:
path.unlink()
except OSError as error:
return str(error)
return None
def _capabilities(root: Path) -> dict:
usage = shutil.disk_usage(root)
return {
"device_id": root.stat().st_dev,
"total_bytes": usage.total,
"writable": os.access(root, os.W_OK),
}
def _probe_location(location: ArchiveLocation) -> dict:
"""Is the right medium mounted, and can it take bytes right now?"""
root = Path(location.root)
blank = {"device_id": None, "free_bytes": None, "total_bytes": None, "capabilities": {}}
if not root.is_dir():
return {"state": "offline", "writable": False, "detail": f"{root} is not mounted", **blank}
marker = _read_marker(root)
if marker is None:
return {
"state": "offline",
"writable": False,
"detail": f"no archive marker found at {root}",
**blank,
}
if marker.get("media_id") != location.media_id:
return {
"state": "wrong_volume",
"writable": False,
"detail": f"marker media_id {marker.get('media_id')!r}",
**blank,
}
capabilities = _capabilities(root)
usage = shutil.disk_usage(root)
# os.access lies on some filesystems; a real write is the only proof.
detail = _probe_write(root / f".archive-write-probe-{uuid.uuid4()}", b"")
return {
"state": "online" if detail is None else "unwritable",
"writable": detail is None,
"detail": detail,
"device_id": capabilities["device_id"],
"free_bytes": usage.free,
"total_bytes": usage.total,
"capabilities": capabilities,
}
def _totals(albums: list[dict]) -> dict:
return {
"albums": len(albums),
"ready_albums": sum(1 for album in albums if album["state"] == "ready"),
"assets": sum(album["asset_count"] for album in albums),
"blocked": sum(album["blocked_count"] for album in albums),
"bytes": sum(album["reclaimable_bytes"] for album in albums),
}
def _token(report: dict) -> str:
"""Digest of everything the report asserts about the scope and the destination.
Values that drift without changing what would happen — free space, backup size,
timestamps — are excluded so the same situation always yields the same token.
"""
payload = {key: value for key, value in report.items() if key not in ("generated_at", "token")}
payload["location"] = {
key: value for key, value in payload["location"].items() if key != "last_seen_at"
}
payload["capacity"] = {
key: value for key, value in payload["capacity"].items() if key != "free_bytes"
}
payload["backup"] = {key: value for key, value in payload["backup"].items() if key != "bytes"}
digest = hashlib.sha256(
json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str).encode("utf-8")
).hexdigest()
return f"{TOKEN_PREFIX}:{digest}"

View File

@@ -0,0 +1,124 @@
"""Where an asset's bytes are right now (US06-03).
Archiving removes the original from the active library but never removes the
asset: its identity, hashes, decisions, and evidence stay. This module is the one
place that answers "can these bytes be read, and if not, why" so inventory,
duplicate review, thumbnails, and the archive service all give the same answer.
States (concept §9):
- ``active`` — the original is in the active library;
- ``archived_online`` — the recorded medium is mounted and holds the file;
- ``archived_offline`` — archived, but the medium is not available right now;
- ``missing_unexpected`` — neither an active path nor the recorded archive
location explains the absence. This is the state that must never be confused
with ``archived_offline``: an unmounted disk is normal, a mounted disk with a
hole in it is not.
A medium is identified by its marker file, never by its mountpoint, so a
different disk mounted at the recorded root is offline rather than accepted.
"""
from __future__ import annotations
import json
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from photo_pipeline.models import ArchiveLocation, Asset
ACTIVE = "active"
ARCHIVED_ONLINE = "archived_online"
ARCHIVED_OFFLINE = "archived_offline"
MISSING_UNEXPECTED = "missing_unexpected"
ARCHIVED = (ARCHIVED_ONLINE, ARCHIVED_OFFLINE)
MARKER_NAME = ".photo-pipeline-archive.json"
def read_marker(root: Path) -> dict | None:
"""The medium's identity marker, or ``None`` when it is not readable."""
try:
return json.loads((root / MARKER_NAME).read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
def location_online(location: ArchiveLocation) -> bool:
"""True only when the *recorded* medium is mounted at its root."""
marker = read_marker(Path(location.root))
return bool(marker) and marker.get("media_id") == location.media_id
def archive_file(session: Session, asset: Asset) -> Path | None:
"""The archived file's absolute path, whether or not the medium is mounted."""
if not asset.archive_location_id or not asset.archive_path:
return None
location = session.get(ArchiveLocation, asset.archive_location_id)
if location is None:
return None
return Path(location.root) / asset.archive_path
def readable_path(session: Session, asset: Asset) -> Path | None:
"""A path whose bytes can be read now: the active file, else the archive copy."""
if asset.current_path and Path(asset.current_path).exists():
return Path(asset.current_path)
archived = archive_file(session, asset)
if archived is None:
return None
location = session.get(ArchiveLocation, asset.archive_location_id)
if not location_online(location) or not archived.exists():
return None
return archived
def state_of(session: Session, asset: Asset, *, online: dict[str, bool] | None = None) -> str:
"""The availability this asset's storage actually justifies right now."""
if asset.current_path:
return ACTIVE if Path(asset.current_path).exists() else MISSING_UNEXPECTED
if not asset.archive_location_id:
return MISSING_UNEXPECTED if asset.availability_state != ACTIVE else ACTIVE
location = session.get(ArchiveLocation, asset.archive_location_id)
if location is None:
return MISSING_UNEXPECTED
reachable = (
online[location.id] if online and location.id in online else location_online(location)
)
if not reachable:
return ARCHIVED_OFFLINE
archived = archive_file(session, asset)
# The medium is mounted and identified: the file is either there, or it is
# genuinely gone — that is not "offline", it needs a human.
return ARCHIVED_ONLINE if archived and archived.exists() else MISSING_UNEXPECTED
def refresh(session_factory: sessionmaker) -> dict[str, int]:
"""Re-derive availability for every archived asset from the media themselves.
Only archived assets are probed: whether an *active* file is present is the
inventory scan's job and costs one stat per library file. Each medium is
probed once, not once per asset.
"""
counts: Counter[str] = Counter()
now = datetime.now(timezone.utc)
with session_factory() as session:
online = {
location.id: location_online(location)
for location in session.scalars(select(ArchiveLocation))
}
for asset in session.scalars(
select(Asset).where(Asset.archive_location_id.is_not(None))
):
state = state_of(session, asset, online=online)
counts[state] += 1
if state != asset.availability_state:
asset.availability_state = state
asset.state_version += 1
asset.updated_at = now
session.commit()
return dict(counts)

View File

@@ -11,6 +11,12 @@ Detection runs in two categories:
band (NEAR/SIMILAR). These are review candidates: never decided automatically, and
negative-linked pairs are suppressed so a rejected pair is not re-suggested.
Archived assets stay in both indexes (US06-03): a new active copy of an archived
original is recognised through its hashes even while the medium is offline, and
cluster review falls back to the retained protected preview plus hash evidence.
An exact/pixel match links straight to the archived canonical; a perceptual match
is a review candidate that names the medium to mount for a pixel-level decision.
Decisions (``canonical`` / ``not_duplicate`` / ``deferred``) persist with evidence,
use optimistic version checks, are reversible, and can never form a canonical cycle.
A new content-identical member of an already-decided cluster inherits the established
@@ -34,12 +40,14 @@ from sqlalchemy import func, select
from sqlalchemy.orm import sessionmaker
from photo_pipeline.models import (
ArchiveLocation,
Asset,
DuplicateCluster,
DuplicateMember,
DuplicateNegativeLink,
Thumbnail,
)
from photo_pipeline.services import hashing
from photo_pipeline.services import availability, hashing
NEAR_MAX = 5
SIMILAR_MAX = 10
@@ -127,18 +135,18 @@ class DuplicateService:
# ── perceptual hash backfill ───────────────────────────────────────────
def ensure_phashes(self) -> int:
"""Hash whatever is readable now — an archived asset keeps the hash it
already has, and gains one whenever its medium happens to be mounted."""
updated = 0
with self._session_factory() as session:
assets = session.execute(
select(Asset).where(
Asset.availability_state == "active",
Asset.current_path.isnot(None),
)
).scalars()
assets = session.execute(select(Asset)).scalars()
for asset in assets:
if asset.phash is not None and asset.phash_version == hashing.PHASH_VERSION:
continue
value = hashing.safe_phash(asset.current_path)
source = availability.readable_path(session, asset)
if source is None:
continue
value = hashing.safe_phash(str(source))
if value is not None:
asset.phash = value
asset.phash_version = hashing.PHASH_VERSION
@@ -146,20 +154,40 @@ class DuplicateService:
session.commit()
return updated
def ensure_phash(self, asset_id: str, *, source=None) -> str | None:
"""Backfill one asset's perceptual hash while its bytes are still readable.
Archiving calls this before the original leaves — passing the archive copy
as ``source``, since the database does not point at it yet — because an
asset without a pHash silently drops out of the fuzzy index the moment its
medium is away.
"""
with self._session_factory() as session:
asset = session.get(Asset, asset_id)
if asset is None:
return None
if asset.phash is not None and asset.phash_version == hashing.PHASH_VERSION:
return asset.phash
source = source or availability.readable_path(session, asset)
if source is None:
return None
value = hashing.safe_phash(str(source))
if value is not None:
asset.phash = value
asset.phash_version = hashing.PHASH_VERSION
session.commit()
return value
# ── detection ──────────────────────────────────────────────────────────
def detect(self) -> DetectionReport:
self.ensure_phashes()
now = datetime.now(timezone.utc)
report = DetectionReport()
with self._session_factory() as session:
assets = list(
session.execute(
select(Asset).where(
Asset.availability_state == "active",
Asset.current_path.isnot(None),
)
).scalars()
)
# Every known asset stays in the indexes, archived or not: a copy of an
# archived original must be recognised as a duplicate rather than
# treated as a new photo (concept §9, invariant 12).
assets = list(session.execute(select(Asset)).scalars())
by_id = {a.id: a for a in assets}
negatives = {
_pair(link.asset_a, link.asset_b)
@@ -431,10 +459,19 @@ class DuplicateService:
@staticmethod
def _recommend_canonical(ids, by_id) -> str:
# ponytail: largest file, path as deterministic tie-break. The concept's
# richer policy (resolution, least recompression, metadata richness) lands
# with the review UI story.
return max(ids, key=lambda i: (by_id[i].byte_size or 0, by_id[i].current_path or ""))
# ponytail: largest file, then the archived copy, then path as a
# deterministic tie-break. Archived wins ties because it is the reviewed,
# uploaded original — a fresh active copy must not demote it to a variant.
# The concept's richer policy (resolution, least recompression, metadata
# richness) lands with the review UI story.
return max(
ids,
key=lambda i: (
by_id[i].byte_size or 0,
by_id[i].availability_state in availability.ARCHIVED,
by_id[i].current_path or by_id[i].archive_path or "",
),
)
def _apply_canonical(self, session, cluster, ids, canonical_id):
for member in session.execute(
@@ -507,9 +544,15 @@ class DuplicateService:
"current_path": asset.current_path if asset else None,
"byte_size": asset.byte_size if asset else None,
"phash": asset.phash if asset else None,
**self._offline_evidence(session, asset),
}
)
members.sort(key=lambda m: m["asset_id"])
# A full-resolution comparison of an offline original is impossible; the
# UI asks for that named medium instead of guessing (concept §9).
mount_required = sorted(
{m["archive_location"] for m in members if m["requires_mount"]}
)
return {
"id": cluster.id,
"method": cluster.method,
@@ -519,9 +562,54 @@ class DuplicateService:
"canonical_asset_id": cluster.canonical_asset_id,
"version": cluster.version,
"requires_confirmation": cluster.method == Method.PERCEPTUAL.value,
"mount_required": mount_required,
"members": members,
}
def _offline_evidence(self, session, asset: Asset | None) -> dict:
"""What review can still rely on when a member's original is not readable."""
if asset is None:
return {
"availability_state": None,
"archive_location": None,
"archive_location_id": None,
"archive_path": None,
"preview": {"state": "missing", "protected": False},
"requires_mount": False,
}
location = (
session.get(ArchiveLocation, asset.archive_location_id)
if asset.archive_location_id
else None
)
preview = self._preview_evidence(session, asset.id)
archived = asset.availability_state in availability.ARCHIVED
return {
"availability_state": asset.availability_state,
"archive_location": location.name if location else None,
"archive_location_id": asset.archive_location_id,
"archive_path": asset.archive_path,
"preview": preview,
# Offline archived members can still be compared through their retained
# preview and hash evidence; only pixel-level review needs the medium.
"requires_mount": archived
and asset.availability_state == availability.ARCHIVED_OFFLINE
and bool(location),
}
@staticmethod
def _preview_evidence(session, asset_id: str) -> dict:
rows = list(
session.execute(select(Thumbnail).where(Thumbnail.asset_id == asset_id)).scalars()
)
ready = [r for r in rows if r.state == "ready" and r.path]
if ready:
best = max(ready, key=lambda r: (bool(r.protected), r.size or 0))
return {"state": "ready", "protected": bool(best.protected), "size": best.size}
if rows:
return {"state": "unsupported", "protected": False, "size": rows[0].size}
return {"state": "missing", "protected": False, "size": None}
# ── decisions ────────────────────────────────────────────────────────────
def decide(
self,

View File

@@ -24,7 +24,16 @@ _CHUNK = 1 << 20
def sha256_file(path: Path | str) -> str:
digest = hashlib.sha256()
return _digest_file(path, hashlib.sha256())
def sha1_file(path: Path | str) -> str:
"""SHA-1 of the file bytes. Not an identity hash here — it is the checksum
Immich/immich-go use to recognise an asset they already hold (concept §8)."""
return _digest_file(path, hashlib.sha1())
def _digest_file(path: Path | str, digest) -> str:
with open(path, "rb") as handle:
for chunk in iter(lambda: handle.read(_CHUNK), b""):
digest.update(chunk)

View File

@@ -13,8 +13,11 @@ renames. Every discovered or absent path is classified as one occurrence:
- ``missing`` — a known active asset whose file is gone (kept, flagged).
Missing files are never pruned (that would break identity); the asset is retained
with ``missing_at`` set. Archived assets are left untouched. Rescanning unchanged
input makes no durable change.
with ``missing_at`` set and its availability becomes ``missing_unexpected`` —
nothing explains where the bytes went. Archived assets are left untouched: their
absence from the active roots is expected, and each scan re-derives whether their
medium is reachable (:mod:`photo_pipeline.services.availability`). Rescanning
unchanged input makes no durable change.
Extracted from photo_analyzer.discover_photos/reconcile_moved/prune_missing
(see donor_ledger.yaml: pa-discovery, pa-prune-missing).
@@ -30,12 +33,12 @@ from enum import Enum
from pathlib import Path
from typing import Iterable
from sqlalchemy import func, select
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session, sessionmaker
from photo_pipeline import path_policy
from photo_pipeline.models import Asset, AssetPath
from photo_pipeline.services import hashing
from photo_pipeline.services import availability, hashing
class Occurrence(str, Enum):
@@ -60,6 +63,8 @@ def _asset_dict(asset: Asset) -> dict:
"id": asset.id,
"current_path": asset.current_path,
"availability_state": asset.availability_state,
"archive_location_id": asset.archive_location_id,
"archive_path": asset.archive_path,
"byte_size": asset.byte_size,
"current_sha256": asset.current_sha256,
"pixel_sha256": asset.pixel_sha256,
@@ -101,7 +106,9 @@ class InventoryService:
result.asset_ids[str(path)] = asset.id
for asset in assets:
if asset.availability_state != "active" or asset.id in seen_ids:
# Archived assets are explained by their location, not by the active
# roots: a scan must never prune or flag them (concept §9).
if asset.availability_state in availability.ARCHIVED or asset.id in seen_ids:
continue
if asset.current_path and asset.current_path not in discovered_paths:
if not Path(asset.current_path).exists():
@@ -109,10 +116,18 @@ class InventoryService:
asset.missing_at = now
asset.state_version += 1
asset.updated_at = now
# Nothing explains this absence — it is not an offline medium.
if asset.availability_state != availability.MISSING_UNEXPECTED:
asset.availability_state = availability.MISSING_UNEXPECTED
asset.state_version += 1
asset.updated_at = now
result.occurrences[asset.current_path] = Occurrence.MISSING.value
session.commit()
# Media may have been mounted or removed since the last scan.
availability.refresh(self._session_factory)
result.counts = dict(Counter(result.occurrences.values()))
return result
@@ -132,7 +147,11 @@ class InventoryService:
if availability:
stmt = stmt.where(Asset.availability_state == availability)
if query:
stmt = stmt.where(Asset.current_path.like(f"%{query}%"))
like = f"%{query}%"
# An archived asset has no active path; it is searched where it lives.
stmt = stmt.where(
or_(Asset.current_path.like(like), Asset.archive_path.like(like))
)
total = session.scalar(select(func.count()).select_from(stmt.subquery()))
rows = session.execute(
stmt.order_by(Asset.current_path).limit(limit).offset(offset)
@@ -169,6 +188,7 @@ class InventoryService:
self._open_path(session, existing.id, path_str, now, occ.value)
if existing.missing_at is not None:
existing.missing_at = None
existing.availability_state = availability.ACTIVE
existing.state_version += 1
existing.updated_at = now
return existing, occ
@@ -189,6 +209,7 @@ class InventoryService:
moved_from.current_path = path_str
moved_from.byte_size = size
moved_from.missing_at = None
moved_from.availability_state = availability.ACTIVE
moved_from.state_version += 1
moved_from.updated_at = now
self._open_path(session, moved_from.id, path_str, now, Occurrence.MOVED.value)

View File

@@ -83,12 +83,13 @@ def _now() -> datetime:
return datetime.now(timezone.utc)
def _maybe_fault(state: str) -> None:
def maybe_fault(state: str) -> None:
"""Test-only crash barrier (concept §18 fault injection).
When ``PHOTO_PIPELINE_FAULT_AFTER`` names a journal state, the process dies
abruptly the moment that state has been persisted — modelling a real kill at
exactly that transition. Never set outside tests.
exactly that transition. Never set outside tests. Shared with the archive
transfer journal (US06-02), which uses the same env var and its own state names.
"""
if os.environ.get("PHOTO_PIPELINE_FAULT_AFTER") == state:
os._exit(9)
@@ -172,7 +173,7 @@ class RenameApplyService:
# 1. Intent first — after this point a crash is recoverable from evidence.
self.journal.begin(operation["id"], worker_id=worker_id, fencing_token=token)
_maybe_fault(JournalState.MOVING)
maybe_fault(JournalState.MOVING)
# 2. Recheck preconditions immediately before mutating, never trusting the
# plan's snapshot: files can change between preview and confirmation.
@@ -187,21 +188,21 @@ class RenameApplyService:
os.rename(source, destination)
self.journal.transition(operation["id"], JournalState.MOVED, fencing_token=token)
_maybe_fault(JournalState.MOVED)
maybe_fault(JournalState.MOVED)
# 4. Database: stable IDs keep their identity, paths are re-pointed and the
# old occurrence is closed — all in one transaction.
self._reconcile_paths(operation, source, destination)
self.journal.transition(operation["id"], JournalState.DATABASE_UPDATED, fencing_token=token)
_maybe_fault(JournalState.DATABASE_UPDATED)
maybe_fault(JournalState.DATABASE_UPDATED)
# 5. Postconditions: the bytes really are at the new paths.
self._verify(operation, destination)
self.journal.transition(operation["id"], JournalState.VERIFIED, fencing_token=token)
_maybe_fault(JournalState.VERIFIED)
maybe_fault(JournalState.VERIFIED)
self.journal.transition(operation["id"], JournalState.COMPLETE, fencing_token=token)
_maybe_fault(JournalState.COMPLETE)
maybe_fault(JournalState.COMPLETE)
def _recheck(self, operation: dict, source: Path, destination: Path) -> None:
if not source.exists():

View File

@@ -0,0 +1,648 @@
"""RestoreService — plan and execute safe restores (US06-04).
Restore is archiving read backwards, with one decisive difference: it removes
nothing. The archived copy stays on its medium, so every failure mode here costs
at most a discarded temporary file. What restore must never do is *lose identity*
— the asset that comes back is the same asset, with its duplicate decision, safety
review, analysis, and upload history intact — or *overwrite* something in the
active library.
Preflight proves, per concept §9 "Restore":
- the recorded medium is mounted and is the right one (marker ``media_id``);
- every selected asset is archived, its archive copy exists, and it hashes to
exactly the bytes the database recorded — a mismatch is ``divergent`` and is
refused, never silently accepted as "the file";
- the destination lies inside the library, outside ``_IGNORE/``, and is free; a
taken path is answered with a collision-free name, never an overwrite;
- the library filesystem has room for the scope plus the configured reserve;
- no rename, archive, or restore lease is holding the lane.
Blocker codes: ``no_library_root``, ``location_offline``, ``wrong_volume``,
``unsafe_destination``, ``library_not_writable``, ``insufficient_capacity``,
``lock_conflict``, ``rename_pending``, ``archive_pending``, ``empty_scope``,
``not_archived``, ``archive_missing``, ``bytes_changed``.
Per item the sequence is:
```
journal.begin (transferring) ← intent persisted BEFORE any disk change
recheck: medium, hash, free destination, asset still archived
copy to a temporary file beside the destination, fsync, hash it back
atomically publish into the library
journal → verified
current_path = destination, availability = active, path occurrence opened
journal → complete
```
Like archiving, the confirmation token is derived from the report, so a changed
scope, a swapped medium, or a destination that filled up invalidates it.
"""
from __future__ import annotations
import hashlib
import json
import os
import shutil
import uuid
from datetime import datetime, timezone
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.orm import sessionmaker
from photo_pipeline.config import Config
from photo_pipeline.jobs.domain_handlers import ARCHIVE_LOCK, LIBRARY_WRITE_LOCK, UPLOAD_LOCK
from photo_pipeline.models import ArchiveLocation, ArchiveOperation, ArchivePlan, Asset, AssetPath
from photo_pipeline.path_policy import PathPolicyError, is_excluded, normalize_root, resolve_within
from photo_pipeline.services import availability
from photo_pipeline.services.archive_journal import (
MANUAL,
RESTORE,
RESUMABLE,
ArchiveJournal,
ArchiveState,
)
from photo_pipeline.services.archive_transfer import (
_clean_temp_files,
_fsync_dir,
_plan_dict,
copy_verify_publish,
)
from photo_pipeline.services.archives import ArchiveError
from photo_pipeline.services.hashing import sha256_file
from photo_pipeline.services.jobs import JobService
from photo_pipeline.services.rename_apply import PreconditionFailed, maybe_fault
from photo_pipeline.services.rename_journal import RenameJournal
PREFLIGHT_VERSION = 1
TOKEN_PREFIX = f"r{PREFLIGHT_VERSION}"
# What a restored file is called when its original name is taken. The suffix is
# visible on purpose: a restore that quietly reuses a name is indistinguishable
# from an overwrite.
RESTORED_SUFFIX = "restored"
LOCKS = (LIBRARY_WRITE_LOCK, UPLOAD_LOCK, ARCHIVE_LOCK)
APPLYABLE_PLAN_STATES = frozenset({"planned", "applying", "failed", "complete"})
def _now() -> datetime:
return datetime.now(timezone.utc)
def _issue(code: str, message: str) -> dict:
return {"code": code, "message": message}
class RestoreService:
def __init__(self, session_factory: sessionmaker, *, config: Config) -> None:
self._session_factory = session_factory
self._config = config
self._roots = tuple(normalize_root(root) for root in config.library_roots)
self.journal = ArchiveJournal(session_factory)
# ── preflight ─────────────────────────────────────────────────────────────
def preflight(self, location_id: str, asset_ids: list[str] | None = None) -> dict:
"""Validate a restore scope and issue its token. Nothing is written."""
with self._session_factory() as session:
location = session.get(ArchiveLocation, location_id)
if location is None:
raise ArchiveError("unknown_location", f"unknown archive location {location_id!r}")
root = Path(location.root)
online = availability.location_online(location)
marker = availability.read_marker(root)
report = {
"schema_version": PREFLIGHT_VERSION,
"location": {
"id": location.id,
"name": location.name,
"root": str(root),
"media_id": location.media_id,
"state": _location_state(root, marker, location.media_id),
},
"blockers": [],
}
items = self._items(session, location, asset_ids, reachable=online)
report["blockers"] += self._destination_blockers(report["location"]["state"], root)
report["blockers"] += self._lock_blockers()
report["items"] = items
report["totals"] = {
"assets": len(items),
"blocked": sum(1 for item in items if item["blockers"]),
"bytes": sum(item["byte_size"] or 0 for item in items),
}
report["capacity"] = self._capacity(report["totals"]["bytes"])
if not report["capacity"]["sufficient"]:
report["blockers"].append(
_issue(
"insufficient_capacity",
f"{report['totals']['bytes']} B plus a "
f"{self._config.archive_free_space_reserve_bytes} B reserve do not fit in "
f"{report['capacity']['free_bytes']} B of free space",
)
)
if not items:
report["blockers"].append(
_issue("empty_scope", "no archived assets are in the selected scope")
)
report["state"] = (
"ready"
if not report["blockers"] and not report["totals"]["blocked"]
else "blocked"
)
report["token"] = _token(report)
report["generated_at"] = _now().isoformat()
return report
def verify_token(self, token: str, location_id: str, asset_ids: list[str] | None = None) -> bool:
return bool(token) and token == self.preflight(location_id, asset_ids)["token"]
def _items(
self, session, location: ArchiveLocation, asset_ids: list[str] | None, *, reachable: bool
) -> list[dict]:
stmt = select(Asset).where(Asset.archive_location_id == location.id)
if asset_ids is None:
# A restored asset keeps its archive link; the default scope is only what
# is still archived, so restoring twice is an empty scope, not a blocker.
stmt = stmt.where(Asset.availability_state.in_(availability.ARCHIVED))
else:
stmt = stmt.where(Asset.id.in_(asset_ids))
assets = list(session.scalars(stmt.order_by(Asset.archive_path)))
if asset_ids is not None:
unknown = sorted(set(asset_ids) - {asset.id for asset in assets})
if unknown:
raise ArchiveError(
"unknown_asset", f"not archived at this location: {', '.join(unknown)}"
)
taken: set[str] = set()
return [self._item(asset, location, reachable=reachable, taken=taken) for asset in assets]
def _item(self, asset: Asset, location: ArchiveLocation, *, reachable: bool, taken: set) -> dict:
source = Path(location.root) / (asset.archive_path or "")
blockers: list[dict] = []
archive_sha256 = None
if asset.availability_state not in availability.ARCHIVED:
blockers.append(
_issue("not_archived", f"asset {asset.id} is {asset.availability_state}")
)
if reachable:
if not source.exists():
blockers.append(_issue("archive_missing", f"{source} is not on the medium"))
else:
archive_sha256 = sha256_file(source)
if asset.current_sha256 and archive_sha256 != asset.current_sha256:
blockers.append(
_issue(
"bytes_changed",
f"{source} holds bytes that are not the recorded ones; "
"the archived copy is divergent",
)
)
destination, destination_blockers = self._destination(asset, taken)
blockers += destination_blockers
if destination is not None:
taken.add(str(destination))
return {
"asset_id": asset.id,
"archive_path": asset.archive_path,
"source_path": str(source),
"destination_path": str(destination) if destination else None,
"expected_sha256": asset.current_sha256,
"archive_sha256": archive_sha256,
"byte_size": asset.byte_size,
"availability_state": asset.availability_state,
"blockers": blockers,
}
def _destination(self, asset: Asset, taken: set) -> tuple[Path | None, list[dict]]:
"""A free path inside the library that mirrors the archived layout.
Restoring onto an existing file is never an option, so a taken name is
answered with ``name (restored).ext`` — visible, ordinary, and impossible to
confuse with an overwrite.
"""
if not self._roots:
return None, [_issue("no_library_root", "no library root is configured")]
root = self._roots[0]
try:
candidate = resolve_within(root, root / (asset.archive_path or ""))
except PathPolicyError as error:
return None, [_issue("unsafe_destination", str(error))]
if is_excluded(candidate):
return None, [
_issue("unsafe_destination", f"{candidate} is inside an excluded (_IGNORE/) tree")
]
return _free_path(candidate, taken), []
def _destination_blockers(self, state: str, root: Path) -> list[dict]:
blockers: list[dict] = []
if not self._roots:
blockers.append(_issue("no_library_root", "no library root is configured"))
elif not os.access(self._roots[0], os.W_OK):
blockers.append(
_issue("library_not_writable", f"{self._roots[0]} is not writable")
)
if state == "offline":
blockers.append(
_issue("location_offline", f"the archive medium is not mounted at {root}")
)
elif state == "wrong_volume":
blockers.append(_issue("wrong_volume", f"{root} holds a different archive medium"))
return blockers
def _lock_blockers(self) -> list[dict]:
blockers: list[dict] = []
jobs = JobService(self._session_factory)
for lock in LOCKS:
held = jobs.blockers(lock)
if held:
blockers.append(
_issue("lock_conflict", f"the {lock} lane is busy: job {held[0]['id']}")
)
if RenameJournal(self._session_factory).blocks_mutation():
blockers.append(
_issue("rename_pending", "an unresolved rename must be recovered before restoring")
)
if self.journal.blocks_mutation():
blockers.append(
_issue(
"archive_pending",
"an unresolved archive or restore must be recovered before restoring",
)
)
return blockers
def _capacity(self, required: int) -> dict:
reserve = self._config.archive_free_space_reserve_bytes
free = shutil.disk_usage(self._roots[0]).free if self._roots else None
return {
"required_bytes": required,
"reserve_bytes": reserve,
"free_bytes": free,
"sufficient": free is not None and free >= required + reserve,
}
# ── plans ─────────────────────────────────────────────────────────────────
def create(self, location_id: str, asset_ids: list[str] | None = None, *, token: str) -> dict:
preflight = self.preflight(location_id, asset_ids)
if not token or token != preflight["token"]:
raise ArchiveError("stale_token", "the restore preflight changed since it was approved")
if preflight["state"] != "ready":
codes = ", ".join(sorted({issue["code"] for issue in preflight["blockers"]})) or "-"
blocked = sorted(
{issue["code"] for item in preflight["items"] for issue in item["blockers"]}
)
raise ArchiveError(
"blocked", f"the restore scope is blocked: {', '.join(blocked) or codes}"
)
plan_id = str(uuid.uuid4())
with self._session_factory() as session:
session.add(
ArchivePlan(
id=plan_id,
location_id=location_id,
token=token,
albums=json.dumps(asset_ids) if asset_ids is not None else None,
direction=RESTORE,
state="planned",
schema_version=PREFLIGHT_VERSION,
asset_count=preflight["totals"]["assets"],
byte_size=preflight["totals"]["bytes"],
)
)
session.flush()
for sequence, item in enumerate(preflight["items"]):
session.add(
ArchiveOperation(
id=str(uuid.uuid4()),
plan_id=plan_id,
direction=RESTORE,
sequence=sequence,
album=Path(item["archive_path"]).parent.name or "(root)",
asset_id=item["asset_id"],
source_path=item["source_path"],
destination_path=item["destination_path"],
archive_path=item["archive_path"],
expected_sha256=item["expected_sha256"],
byte_size=item["byte_size"],
journal_state=ArchiveState.PLANNED,
)
)
session.commit()
return self.get(plan_id)
def get(self, plan_id: str) -> dict | None:
with self._session_factory() as session:
plan = session.get(ArchivePlan, plan_id)
if plan is None or plan.direction != RESTORE:
return None
report = _plan_dict(plan)
report["operations"] = self.journal.operations(plan_id)
return report
def list(self) -> list[dict]:
with self._session_factory() as session:
rows = session.scalars(
select(ArchivePlan)
.where(ArchivePlan.direction == RESTORE)
.order_by(ArchivePlan.created_at)
)
return [_plan_dict(row) for row in rows]
# ── apply ─────────────────────────────────────────────────────────────────
def apply(
self, plan_id: str, *, expected_version: int | None = None, worker_id: str = "restore"
) -> dict:
plan = self._require_plan(plan_id)
if expected_version is not None and plan["version"] != expected_version:
raise ArchiveError(
"stale_plan",
f"plan {plan_id} is at version {plan['version']}, expected {expected_version}",
)
if plan["state"] not in APPLYABLE_PLAN_STATES:
raise ArchiveError("invalid_state", f"plan {plan_id} is {plan['state']}")
blocking = [row for row in self.journal.incomplete() if row["plan_id"] != plan_id]
if blocking:
raise ArchiveError(
"archive_pending",
f"another archive operation is unresolved ({blocking[0]['id']}); recover it first",
)
token = self._claim_plan(plan_id)
location = self._location(plan["location_id"])
restored = failed = skipped = 0
for operation in self.journal.operations(plan_id):
if operation["journal_state"] == ArchiveState.COMPLETE:
skipped += 1
continue
try:
if operation["journal_state"] == ArchiveState.VERIFIED:
self._finish(operation, token=token)
else:
self._restore_one(operation, location, token=token, worker_id=worker_id)
restored += 1
except PreconditionFailed as error:
self._fail(operation, token, error.code, str(error))
failed += 1
except Exception as error: # unexpected: record and stop touching disk
self._fail(operation, token, "restore_error", str(error))
failed += 1
state = self.journal.sync_plan_state(plan_id)
return {
"plan_id": plan_id,
"restored": restored,
"failed": failed,
"skipped": skipped,
"state": state,
}
def _restore_one(self, operation: dict, location: dict, *, token: int, worker_id: str) -> None:
source = Path(operation["source_path"])
destination = Path(operation["destination_path"])
# 1. Intent first; from here a crash is resolvable from journal + disk.
self.journal.begin(operation["id"], worker_id=worker_id, fencing_token=token)
maybe_fault(ArchiveState.TRANSFERRING)
# 2. Recheck against the medium and the library as they are right now.
self._recheck(operation, source, destination, location)
destination.parent.mkdir(parents=True, exist_ok=True)
# 3. Always copy: the archived original stays on its medium.
copy_verify_publish(source, destination, operation["expected_sha256"])
_fsync_dir(destination.parent)
if sha256_file(destination) != operation["expected_sha256"]:
raise PreconditionFailed(
"restore_mismatch", f"{destination} does not hold the expected bytes"
)
self.journal.transition(operation["id"], ArchiveState.VERIFIED, fencing_token=token)
maybe_fault(ArchiveState.VERIFIED)
self._finish(self.journal.get(operation["id"]), token=token)
def _finish(self, operation: dict, *, token: int) -> None:
"""Publish the restored file to the database. Idempotent, so recovery may
replay it after a crash between the copy and the bookkeeping."""
destination = Path(operation["destination_path"])
if not destination.exists() or sha256_file(destination) != operation["expected_sha256"]:
raise PreconditionFailed(
"restore_unverified", f"{destination} is not a verified restored copy"
)
self._record_restored(operation, destination)
self.journal.transition(operation["id"], ArchiveState.COMPLETE, fencing_token=token)
maybe_fault(ArchiveState.COMPLETE)
def _recheck(self, operation: dict, source: Path, destination: Path, location: dict) -> None:
root = Path(location["root"])
if not root.is_dir() or not (root / availability.MARKER_NAME).exists():
raise PreconditionFailed("location_offline", f"{root} is not the archive medium")
if not source.exists():
raise PreconditionFailed("archive_missing", f"{source} is not on the medium")
if source.is_symlink() or destination.is_symlink():
raise PreconditionFailed("symlink", "refusing to restore through a symlink")
if destination.exists():
# Never overwrite: the plan's free path was taken since it was made.
raise PreconditionFailed(
"destination_exists", f"destination {destination} is occupied"
)
if not self._inside_library(destination):
raise PreconditionFailed(
"destination_escape", f"{destination} is outside the library roots"
)
if sha256_file(source) != operation["expected_sha256"]:
self._mark_divergent(operation["asset_id"])
raise PreconditionFailed(
"bytes_changed", f"{source} changed since the plan was approved"
)
with self._session_factory() as session:
asset = session.get(Asset, operation["asset_id"])
if asset is None or asset.availability_state not in availability.ARCHIVED:
raise PreconditionFailed(
"not_archived", f"asset {operation['asset_id']} is no longer archived"
)
def _inside_library(self, destination: Path) -> bool:
for root in self._roots:
try:
resolve_within(root, destination)
return True
except PathPolicyError:
continue
return False
# ── database ──────────────────────────────────────────────────────────────
def _record_restored(self, operation: dict, destination: Path) -> None:
"""The bytes are back in the library: open the new active occurrence and set
availability. Identity, decisions, and history are untouched — that is the
entire point of restoring rather than re-importing."""
now = _now()
with self._session_factory() as session:
asset = session.get(Asset, operation["asset_id"])
if asset is None:
raise PreconditionFailed(
"asset_missing", f"asset {operation['asset_id']} no longer exists"
)
# A restored asset may be returning to a path it once held, so only an
# *open* occurrence counts as already registered — that is what keeps
# recovery idempotent without collapsing the path history.
recorded = session.scalar(
select(AssetPath).where(
AssetPath.asset_id == asset.id,
AssetPath.path == str(destination),
AssetPath.valid_until.is_(None),
)
)
if recorded is None: # idempotent: recovery may replay this
session.add(
AssetPath(
asset_id=asset.id,
path=str(destination),
valid_from=now,
reason="restore",
)
)
asset.current_path = str(destination)
asset.availability_state = availability.ACTIVE
asset.missing_at = None
# The archive copy stays where it is; keeping the link means a restored
# asset still knows which medium holds its archived bytes.
asset.archive_divergent_at = None
asset.state_version += 1
asset.updated_at = now
session.commit()
def _mark_divergent(self, asset_id: str) -> None:
"""Record that the archived copy is not the recorded file. Durable, because
the next restore attempt must not rediscover this from scratch."""
with self._session_factory() as session:
asset = session.get(Asset, asset_id)
if asset is None:
return
asset.archive_divergent_at = _now()
asset.state_version += 1
session.commit()
# ── recovery ──────────────────────────────────────────────────────────────
def recover(self, *, worker_id: str = "restore-recovery") -> dict:
"""Resolve every incomplete restore from journal + disk evidence.
A restore never removed anything, so ``resumable`` simply discards the
temporary debris and re-plans the item; ``forward`` finishes the bookkeeping
for a published file; ``manual`` is left untouched and keeps blocking.
"""
results = {"resumed": 0, "completed": 0, "manual": 0}
touched: set[str] = set()
for verdict in self.journal.classify_all(direction=RESTORE):
operation = self.journal.get(verdict["operation_id"])
touched.add(operation["plan_id"])
token = (operation["fencing_token"] or 0) + 1
if verdict["classification"] == MANUAL:
results["manual"] += 1
continue
if verdict["classification"] == RESUMABLE:
_clean_temp_files(Path(operation["destination_path"]).parent)
self.journal.transition(operation["id"], ArchiveState.PLANNED, fencing_token=token)
results["resumed"] += 1
continue
try:
self._finish(operation, token=token)
results["completed"] += 1
except PreconditionFailed as error:
self._fail(operation, token, error.code, str(error))
results["manual"] += 1
for plan_id in touched:
self.journal.sync_plan_state(plan_id)
return results
def recovery_status(self) -> dict:
verdicts = self.journal.classify_all(direction=RESTORE)
return {
"operations": verdicts,
"manual": [v for v in verdicts if v["classification"] == MANUAL],
"blocks_mutation": self.journal.blocks_mutation(),
}
# ── helpers ───────────────────────────────────────────────────────────────
def _fail(self, operation: dict, token: int, code: str, message: str) -> None:
self.journal.transition(
operation["id"], ArchiveState.FAILED, fencing_token=token, error=(code, message)
)
def _require_plan(self, plan_id: str) -> dict:
plan = self.get(plan_id)
if plan is None:
raise ArchiveError("unknown_plan", f"unknown restore plan {plan_id!r}")
return plan
def _location(self, location_id: str) -> dict:
with self._session_factory() as session:
location = session.get(ArchiveLocation, location_id)
if location is None:
raise ArchiveError("unknown_location", f"unknown archive location {location_id!r}")
return {"id": location.id, "root": location.root, "media_id": location.media_id}
def _claim_plan(self, plan_id: str) -> int:
with self._session_factory() as session:
plan = session.get(ArchivePlan, plan_id)
plan.version += 1
plan.state = "applying"
plan.updated_at = _now()
token = plan.version
session.commit()
return token
# ── module helpers ───────────────────────────────────────────────────────────
def _location_state(root: Path, marker: dict | None, media_id: str) -> str:
if not root.is_dir() or marker is None:
return "offline"
return "online" if marker.get("media_id") == media_id else "wrong_volume"
def _free_path(candidate: Path, taken: set) -> Path:
"""``a.jpg`` → ``a (restored).jpg`` → ``a (restored 2).jpg`` …
``taken`` holds the destinations already claimed by earlier items of the same
plan, so two restores in one scope cannot plan the same path.
"""
if not candidate.exists() and str(candidate) not in taken:
return candidate
stem, suffix = candidate.stem, candidate.suffix
attempt = 1
while True:
label = RESTORED_SUFFIX if attempt == 1 else f"{RESTORED_SUFFIX} {attempt}"
alternative = candidate.with_name(f"{stem} ({label}){suffix}")
if not alternative.exists() and str(alternative) not in taken:
return alternative
attempt += 1
def _token(report: dict) -> str:
"""Digest of everything the report asserts about the scope and the medium.
Free space is excluded: it drifts constantly without changing what a restore
would do, and the capacity verdict itself is part of the digest.
"""
payload = {key: value for key, value in report.items() if key not in ("generated_at", "token")}
payload["capacity"] = {
key: value for key, value in payload["capacity"].items() if key != "free_bytes"
}
digest = hashlib.sha256(
json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str).encode("utf-8")
).hexdigest()
return f"{TOKEN_PREFIX}:{digest}"

View File

@@ -8,6 +8,11 @@ an EXIF-only edit reuses the file while a real pixel change invalidates it; writ
are atomic and the cache is bounded by an LRU quota. Failures are persisted as typed
errors so a broken original is not retried on every request.
An archived asset is served from its medium when that medium is mounted, and from
its *protected* preview when it is not (US06-03). Protected previews are evidence,
not cache: the quota never evicts them, because the original they describe may be
unreachable when duplicate review needs it.
Reuses photo_analyzer.prepare_image decode/resize/HEIC handling, adding the missing
EXIF-orientation step, WebP output, and a managed cache (donor_ledger.yaml:
pa-imaging).
@@ -26,6 +31,7 @@ from sqlalchemy.orm import sessionmaker
from photo_pipeline import path_policy
from photo_pipeline.config import Config
from photo_pipeline.models import Asset, Thumbnail
from photo_pipeline.services import availability
# Best-effort HEIC support: registered only if the optional decoder is installed.
try: # pragma: no cover - depends on an optional native dependency
@@ -38,6 +44,8 @@ except Exception: # pragma: no cover
SIZES = (256, 512, 1280)
THUMB_VERSION = 1
THUMB_FORMAT = "webp"
# The size kept as durable comparison evidence for archived assets (concept §9).
PROTECTED_SIZE = 1280
class ThumbnailError(RuntimeError):
@@ -86,7 +94,12 @@ class ThumbnailService:
self._config = config
self._cache_dir = config.thumbnail_cache_dir
def generate(self, asset_id: str, size: int) -> Path:
def generate(
self, asset_id: str, size: int, *, protected: bool = False, source: Path | None = None
) -> Path:
"""Render (or reuse) a preview. ``source`` overrides where the bytes are read
from — the archiver passes its verified archive copy, which the database does
not yet point at while the transfer is still in flight."""
if size not in SIZES:
raise InvalidSize(f"size must be one of {SIZES}")
@@ -94,9 +107,7 @@ class ThumbnailService:
asset = session.get(Asset, asset_id)
if asset is None:
raise ThumbnailNotFound(f"unknown asset {asset_id}")
if asset.availability_state != "active" or not asset.current_path:
raise ThumbnailUnavailable(f"asset {asset_id} has no active file")
self._validate_path(asset.current_path)
archived = asset.availability_state in availability.ARCHIVED
cache_key = self._cache_key(asset, size)
row = session.get(Thumbnail, cache_key)
@@ -107,9 +118,18 @@ class ThumbnailService:
)
if row.path and Path(row.path).exists():
_touch(row.path)
if protected and not row.protected:
self._protect(cache_key)
return Path(row.path)
source = asset.current_path
# An archived original is read from its medium; when that medium is not
# mounted the retained preview above is the only evidence there is.
source = source or availability.readable_path(session, asset)
if source is None:
raise ThumbnailUnavailable(f"asset {asset_id} has no readable file")
source = str(source)
if source == asset.current_path:
self._validate_path(source) # archive roots lie outside the library
# Rendering happens outside the DB session (no transaction held during I/O).
try:
@@ -119,10 +139,61 @@ class ThumbnailService:
self._record_error(cache_key, asset_id, size, error.code)
raise
self._record_ready(cache_key, asset_id, size, rendered)
# Archived assets keep their preview permanently: it is the comparison
# evidence that survives the original leaving active storage.
self._record_ready(cache_key, asset_id, size, rendered, protected=protected or archived)
self._enforce_quota(keep=rendered["path"])
return Path(rendered["path"])
def ensure_protected(self, asset_id: str, *, source: Path | None = None) -> dict:
"""Produce (or confirm) the durable comparison preview for an asset.
Returns evidence rather than raising, because the caller — archive
preflight and the transfer itself — decides what an unrenderable original
means. ``unsupported`` is a recorded property of the file, not a failure of
the policy: its hashes and metadata remain the comparison evidence.
"""
try:
path = self.generate(asset_id, PROTECTED_SIZE, protected=True, source=source)
except tuple(_PERSISTED_ERRORS) as error:
return {"state": "unsupported", "error_code": error.code, "path": None}
except ThumbnailError as error:
return {"state": "unavailable", "error_code": error.code, "path": None}
return {"state": "ready", "error_code": None, "path": str(path)}
def evidence(self, asset_id: str) -> dict:
"""What durable preview this asset has right now, without rendering."""
with self._session_factory() as session:
rows = list(
session.execute(
select(Thumbnail).where(Thumbnail.asset_id == asset_id)
).scalars()
)
for row in rows:
if row.state == "ready" and row.path and Path(row.path).exists():
return {
"state": "ready",
"protected": bool(row.protected),
"size": row.size,
"error_code": None,
}
for row in rows:
if row.state == "error":
return {
"state": "unsupported",
"protected": False,
"size": row.size,
"error_code": row.error_code,
}
return {"state": "missing", "protected": False, "size": None, "error_code": None}
def _protect(self, cache_key: str) -> None:
with self._session_factory() as session:
row = session.get(Thumbnail, cache_key)
if row is not None:
row.protected = True
session.commit()
# ── path safety ──────────────────────────────────────────────────────────
def _validate_path(self, current_path: str) -> None:
path = Path(current_path)
@@ -184,7 +255,9 @@ class ThumbnailService:
}
# ── persistence ────────────────────────────────────────────────────────────
def _record_ready(self, cache_key: str, asset_id: str, size: int, rendered: dict) -> None:
def _record_ready(
self, cache_key: str, asset_id: str, size: int, rendered: dict, *, protected: bool = False
) -> None:
with self._session_factory() as session:
session.merge(
Thumbnail(
@@ -197,6 +270,7 @@ class ThumbnailService:
width=rendered["width"],
height=rendered["height"],
format=rendered["format"],
protected=protected,
)
)
try:
@@ -230,6 +304,10 @@ class ThumbnailService:
if total <= quota:
return
files.sort(key=lambda f: f.stat().st_mtime) # least-recently-used first
# Protected previews are evidence, not cache: an archived original cannot be
# re-rendered once its medium is away, so eviction never touches them.
protected = self._protected_paths()
files = [f for f in files if str(f) not in protected]
keep_path = str(Path(keep)) if keep else None
evicted: list[str] = []
for f in files:
@@ -247,6 +325,16 @@ class ThumbnailService:
if evicted:
self._forget(evicted)
def _protected_paths(self) -> set[str]:
with self._session_factory() as session:
return {
row.path
for row in session.execute(
select(Thumbnail).where(Thumbnail.protected.is_(True))
).scalars()
if row.path
}
def _forget(self, paths: list[str]) -> None:
with self._session_factory() as session:
rows = session.execute(

View File

@@ -0,0 +1,456 @@
"""UploadBatchService — one approved album at a time through immich-go (US05-02).
Preflight (US05-01) proves a scope is safe and issues a token; this service turns
that approval into a durable batch and runs it. Upload is the one stage the app
cannot undo, so the discipline is:
- **the approval is re-proved, not remembered.** Before every attempt the batch's
preflight token is recomputed from the current library state. Bytes edited after
approval, a withdrawn safety decision, or a server that stopped answering all
produce a different token and the attempt is refused, never run "optimistically".
- **one lane.** A batch can only start while no other batch is running; the album
scope of a batch never widens after creation (concept §16 uploader lane).
- **cancellation is cooperative and durable.** ``cancel`` writes ``cancelling``;
the running attempt observes it through the database, stops the uploader, and
records ``cancelled``. A cancelled batch is re-runnable from its own boundary.
- **an interrupted attempt is uncertain, not failed.** Immich may have accepted
files the app never saw a report for, so ``recover`` marks a batch whose worker
vanished ``unknown_requires_verification`` (concept §15) instead of retrying it
blindly. :func:`~photo_pipeline.services.upload_verification.retry_blockers`
(US05-04) is what decides whether an attempt may start at all.
Per-asset upload *results* are not interpreted here: after the attempt ends the
report is handed to :class:`~photo_pipeline.services.upload_reports.
UploadReportService` (US05-03), which classifies every file. The batch ``state``
stays an honest description of what the *process* did; ``outcome_state`` says
whether the report's evidence is complete enough to trust.
"""
from __future__ import annotations
import json
import uuid
from datetime import datetime, timezone
from pathlib import Path
from sqlalchemy import select, update
from sqlalchemy.orm import sessionmaker
from photo_pipeline.config import Config
from photo_pipeline.integrations import immich_go
from photo_pipeline.models import UploadBatch, UploadItem
from photo_pipeline.services.hashing import sha1_file
from photo_pipeline.services.upload_reports import UploadReportService
from photo_pipeline.services.upload_verification import retry_blockers
from photo_pipeline.services.uploads import UploadService
class BatchState:
PLANNED = "planned"
RUNNING = "running"
CANCELLING = "cancelling"
CANCELLED = "cancelled"
SUCCEEDED = "succeeded"
FAILED = "failed"
# The attempt died without a parsed outcome: the server may hold the files.
UNKNOWN = "unknown_requires_verification"
# States that occupy the single uploader lane.
LANE_STATES = frozenset({BatchState.RUNNING, BatchState.CANCELLING})
# States a batch may be (re)started from. ``unknown_requires_verification`` is not
# among them: an uncertain upload must be verified (US05-04), never blindly retried.
RUNNABLE_STATES = frozenset({BatchState.PLANNED, BatchState.FAILED, BatchState.CANCELLED})
# States whose batch is still the live one for its album.
OPEN_STATES = frozenset({BatchState.PLANNED, BatchState.RUNNING, BatchState.CANCELLING})
class ItemState:
PENDING = "pending"
# ``sent`` means the batch process exited cleanly, not that Immich confirmed the
# asset — the per-item outcome comes from the report in US05-03/US05-04.
SENT = "sent"
FAILED = "failed"
class BatchError(RuntimeError):
"""The request cannot be carried out (unknown batch, wrong state, blocked)."""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
class BatchConflict(BatchError):
"""The lane is busy or the approval is stale — retry after resolving it."""
def _now() -> datetime:
return datetime.now(timezone.utc)
class UploadBatchService:
def __init__(self, session_factory: sessionmaker, *, config: Config) -> None:
self._session_factory = session_factory
self._config = config
self._preflight = UploadService(session_factory, config=config)
# ── creation ──────────────────────────────────────────────────────────────
def create(
self, albums: list[str] | None = None, *, token: str, allow_partial: bool = False
) -> list[dict]:
"""Turn a *ready* preflight into one durable batch per album.
``token`` must be the token of the current preflight for the same scope and
policy; anything else means the browser is acting on a stale preview.
"""
report = self._preflight.preflight(albums, allow_partial=allow_partial)
if token != report["token"]:
raise BatchConflict(
"stale_preflight", "the preflight token does not describe the current state"
)
if report["state"] != "ready":
raise BatchError("not_ready", "the scope has unresolved upload blockers")
created: list[dict] = []
for album in report["albums"]:
existing = self._open_batch_for(album["album"])
if existing is not None:
created.append(existing) # idempotent: one open batch per album
continue
created.append(self._create_one(album, token=token, allow_partial=allow_partial))
return created
def _create_one(self, album: dict, *, token: str, allow_partial: bool) -> dict:
eligible = [asset for asset in album["assets"] if not asset["blockers"]]
batch_id = str(uuid.uuid4())
command = immich_go.preview_command(
binary=self._config.immich_go_binary,
server_url=self._config.immich_server_url,
album_name=album["album_name"],
folder=album["folder"],
)
with self._session_factory() as session:
session.add(
UploadBatch(
id=batch_id,
album=album["album"],
folder=album["folder"],
album_name=album["album_name"],
state=BatchState.PLANNED,
preflight_token=token,
allow_partial=allow_partial,
command=json.dumps(command),
uploader_version=immich_go.version(self._config.immich_go_binary),
asset_count=len(eligible),
)
)
for asset in eligible:
session.add(
UploadItem(
batch_id=batch_id,
asset_id=asset["asset_id"],
path=asset["current_path"],
sha256=asset["current_sha256"],
sha1=sha1_file(asset["current_path"]),
state=ItemState.PENDING,
)
)
session.commit()
return self.get(batch_id)
# ── running ───────────────────────────────────────────────────────────────
def run(self, batch_id: str, *, worker_id: str = "uploader", cancelled=None) -> dict:
"""Run one batch to completion. Blocks for the duration of the upload."""
batch = self._require(batch_id)
# Retry policy (US05-04): a safe failure may run again, an uncertain outcome
# or bytes edited after upload may not.
blocked = retry_blockers(batch)
if blocked:
raise BatchError(blocked[0]["code"], blocked[0]["message"])
busy = [row for row in self.list() if row["id"] != batch_id and row["state"] in LANE_STATES]
if busy:
raise BatchConflict("lane_busy", f"upload batch {busy[0]['id']} is still running")
# The approval is re-proved here, immediately before the irreversible act.
if not self._preflight.verify_token(
batch["preflight_token"], [batch["album"]], allow_partial=batch["allow_partial"]
):
self._finish(
batch_id,
token=batch["version"],
state=BatchState.FAILED,
error=("stale_preflight", "the library changed after this batch was approved"),
)
raise BatchConflict(
"stale_preflight", "the library changed after this batch was approved"
)
token = self._claim(batch_id, worker_id=worker_id)
attempt = self.get(batch_id)["attempt_count"]
report_path = Path(self._config.data_dir) / "uploads" / f"{batch_id}-attempt-{attempt}.log"
key = self._config.immich_api_key.get_secret_value() if self._config.immich_api_key else ""
command = immich_go.build_command(
binary=self._config.immich_go_binary,
server_url=self._config.immich_server_url,
api_key=key,
album_name=batch["album_name"],
folder=batch["folder"],
)
def _stop_requested() -> bool:
if cancelled is not None and cancelled():
return True
current = self.get(batch_id)
return current is None or current["state"] == BatchState.CANCELLING
try:
result = immich_go.run_upload(
command,
report_path=report_path,
secret=key or None,
cancelled=_stop_requested,
)
except OSError as error: # uploader vanished between preflight and exec
self._finish(
batch_id,
token=token,
state=BatchState.FAILED,
error=("uploader_failed", str(error)),
)
return self.get(batch_id)
if result["cancelled"]:
state, error, item_state = BatchState.CANCELLED, None, None
elif result["timed_out"]:
# Killed mid-flight: the server may already hold some of the files.
state = BatchState.UNKNOWN
error = ("timeout", "the uploader exceeded its time limit and was stopped")
item_state = None
elif result["exit_code"] == 0:
state, error, item_state = BatchState.SUCCEEDED, None, ItemState.SENT
else:
state = BatchState.FAILED
error = ("uploader_failed", f"immich-go exited with {result['exit_code']}")
item_state = ItemState.FAILED
self._finish(batch_id, token=token, state=state, error=error, result=result)
if item_state:
self._set_items(batch_id, item_state)
# The report is the only evidence of what happened per file, so it is read
# while it is fresh (US05-03). A parse failure must not lose the batch
# outcome that was just recorded; the items simply stay unknown.
try:
UploadReportService(self._session_factory).ingest(batch_id)
except OSError:
pass
return self.get(batch_id)
def cancel(self, batch_id: str) -> dict:
"""Request a stop. A running attempt drains; a planned batch stops outright."""
batch = self._require(batch_id)
if batch["state"] == BatchState.PLANNED:
target = BatchState.CANCELLED
elif batch["state"] == BatchState.RUNNING:
target = BatchState.CANCELLING
else:
raise BatchError("not_cancellable", f"batch {batch_id} is {batch['state']}")
with self._session_factory() as session:
row = session.get(UploadBatch, batch_id)
row.state = target
row.updated_at = _now()
if target == BatchState.CANCELLED:
row.finished_at = _now()
session.commit()
return self.get(batch_id)
# ── recovery ──────────────────────────────────────────────────────────────
def recover(self) -> dict:
"""Resolve batches whose attempt died with the process.
A batch that never started is left ``planned`` and simply runs later. One
that was mid-upload cannot be classified from local state — immich-go may
have transferred everything before the crash — so it becomes
``unknown_requires_verification`` and releases the lane rather than being
retried or declared failed.
"""
interrupted = 0
with self._session_factory() as session:
for row in session.scalars(
select(UploadBatch).where(UploadBatch.state.in_(LANE_STATES))
):
row.state = BatchState.UNKNOWN
row.error_code = "interrupted"
row.error_message = "the uploader process ended without a recorded outcome"
row.finished_at = _now()
row.updated_at = _now()
row.version += 1
interrupted += 1
session.commit()
return {"interrupted": interrupted}
# ── reads ─────────────────────────────────────────────────────────────────
def get(self, batch_id: str) -> dict | None:
with self._session_factory() as session:
row = session.get(UploadBatch, batch_id)
if row is None:
return None
items = list(
session.scalars(
select(UploadItem)
.where(UploadItem.batch_id == batch_id)
.order_by(UploadItem.path)
)
)
return _batch_dict(row, items)
def list(self) -> list[dict]:
with self._session_factory() as session:
rows = list(session.scalars(select(UploadBatch).order_by(UploadBatch.created_at)))
return [_batch_dict(row, []) for row in rows]
def report(self, batch_id: str) -> str:
"""The raw uploader output kept for this batch, or ``""`` when there is none."""
batch = self._require(batch_id)
path = Path(batch["report_path"]) if batch["report_path"] else None
if path is None or not path.exists():
return ""
return path.read_text(errors="replace")
# ── internals ─────────────────────────────────────────────────────────────
def _open_batch_for(self, album: str) -> dict | None:
with self._session_factory() as session:
row = session.scalar(
select(UploadBatch).where(
UploadBatch.album == album, UploadBatch.state.in_(OPEN_STATES)
)
)
return self.get(row.id) if row else None
def _claim(self, batch_id: str, *, worker_id: str) -> int:
"""Take ownership: bump the version (the fencing token) and start an attempt."""
with self._session_factory() as session:
row = session.get(UploadBatch, batch_id)
row.version += 1
row.attempt_count += 1
row.state = BatchState.RUNNING
row.worker_id = worker_id
row.error_code = row.error_message = None
row.started_at = _now()
row.finished_at = None
row.updated_at = _now()
token = row.version
session.commit()
return token
def _finish(
self,
batch_id: str,
*,
token: int,
state: str,
error: tuple[str, str] | None = None,
result: dict | None = None,
) -> None:
"""Record the outcome, but only for the attempt that still owns the batch."""
values = {
"state": state,
"finished_at": _now(),
"updated_at": _now(),
"error_code": error[0] if error else None,
"error_message": error[1][:500] if error else None,
}
if result is not None:
values |= {
"exit_code": result["exit_code"],
"report_path": result["report_path"],
"report_bytes": result["report_bytes"],
"report_truncated": result["report_truncated"],
}
with self._session_factory() as session:
session.execute(
update(UploadBatch)
.where(UploadBatch.id == batch_id, UploadBatch.version == token)
.values(**values)
)
session.commit()
def _set_items(self, batch_id: str, state: str) -> None:
with self._session_factory() as session:
session.execute(
update(UploadItem)
.where(UploadItem.batch_id == batch_id)
.values(state=state, updated_at=_now())
)
session.commit()
def _require(self, batch_id: str) -> dict:
batch = self.get(batch_id)
if batch is None:
raise BatchError("not_found", f"unknown upload batch {batch_id!r}")
return batch
def _batch_dict(row: UploadBatch, items: list[UploadItem]) -> dict:
batch = {
"id": row.id,
"album": row.album,
"folder": row.folder,
"album_name": row.album_name,
"state": row.state,
"preflight_token": row.preflight_token,
"allow_partial": row.allow_partial,
"command": json.loads(row.command or "[]"),
"uploader_version": row.uploader_version,
"asset_count": row.asset_count,
"attempt_count": row.attempt_count,
"version": row.version,
"worker_id": row.worker_id,
"report_path": row.report_path,
"report_bytes": row.report_bytes,
"report_truncated": row.report_truncated,
"exit_code": row.exit_code,
"error_code": row.error_code,
"error_message": row.error_message,
# Parsed report evidence (US05-03): what the uploader said per file, and
# whether that evidence is complete enough to be trusted.
"parser": row.parser,
"parser_version": row.parser_version,
"parsed_at": row.parsed_at.isoformat() if row.parsed_at else None,
"outcome_state": row.outcome_state,
"outcome_counts": json.loads(row.outcome_counts) if row.outcome_counts else None,
"report_counts": json.loads(row.report_counts) if row.report_counts else None,
# Verification evidence (US05-04). ``stale_bytes`` is the visible warning
# that an uploaded file has since been edited.
"verified_at": row.verified_at.isoformat() if row.verified_at else None,
"stale_bytes": row.stale_bytes,
"started_at": row.started_at.isoformat() if row.started_at else None,
"finished_at": row.finished_at.isoformat() if row.finished_at else None,
"items": [
{
"asset_id": item.asset_id,
"path": item.path,
"sha256": item.sha256,
"sha1": item.sha1,
"state": item.state,
"outcome": item.outcome,
"evidence": item.evidence,
"outcome_at": item.outcome_at.isoformat() if item.outcome_at else None,
"verification": item.verification,
"verified_at": item.verified_at.isoformat() if item.verified_at else None,
"observed_sha256": item.observed_sha256,
"changed_after_upload": item.changed_after_upload,
}
for item in items
],
}
# Why this batch may not be (re)started, from the one place that decides it
# (US05-04). Carried in the record so the browser can hide an action the server
# would refuse instead of re-implementing the policy (US05-05).
batch["retry_blockers"] = retry_blockers(batch)
return batch

View File

@@ -0,0 +1,140 @@
"""UploadReportService — durable per-item upload outcomes (US05-03).
US05-02 proves the uploader ran and kept a bounded, credential-free report. This
service is the step that reads that report and answers the operator's actual
question — *what happened to each photo?* — without ever guessing in the app's
favour:
- **an item is ``unknown`` until the report says otherwise.** A file the report
never mentions, a report from an unpinned uploader version, and a line the
grammar does not recognise all leave the item ``unknown``. Exit code 0 is
evidence the *process* ended well, never evidence that a particular file reached
Immich (concept §15 "wrong upload state").
- **counts are reconciled, not trusted.** The uploader's own summary is stored
next to the totals derived from the items. A disagreement makes the batch
``requires_verification`` even when every line parsed.
- **reprocessing is idempotent.** Outcomes are keyed by ``(batch_id, asset_id)``
and rewritten in place, so parsing the same report again — after a restart, or
because the operator asked — converges on the same rows and the same counts.
The batch's own ``state`` still describes the process (US05-02's contract);
``outcome_state`` describes the evidence. Turning ``requires_verification`` into
verification, retry, and manual resolution is US05-04.
"""
from __future__ import annotations
import json
import os
from datetime import datetime, timezone
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.orm import sessionmaker
from photo_pipeline.integrations import immich_go_report as report_parser
from photo_pipeline.models import UploadBatch, UploadItem
VERIFIED = "verified"
REQUIRES_VERIFICATION = "requires_verification"
def _now() -> datetime:
return datetime.now(timezone.utc)
def _key(path: str) -> str:
return os.path.normpath(path)
class UploadReportService:
def __init__(self, session_factory: sessionmaker) -> None:
self._session_factory = session_factory
def ingest(self, batch_id: str) -> dict:
"""Parse this batch's report and persist an outcome for every item.
Returns ``{"parser", "parser_version", "outcome_state", "counts",
"report_counts", "unmatched", "unparsed"}``. Safe to call repeatedly.
"""
with self._session_factory() as session:
batch = session.get(UploadBatch, batch_id)
if batch is None:
raise KeyError(f"unknown upload batch {batch_id!r}")
items = list(session.scalars(select(UploadItem).where(UploadItem.batch_id == batch_id)))
parsed = report_parser.parse(_read_report(batch.report_path), batch.uploader_version)
by_path = {_key(entry["path"]): entry for entry in parsed["entries"]}
# Basenames are the fallback: an uploader may log a path relative to the
# folder it was given. Ambiguous basenames are dropped rather than
# guessed at.
by_name: dict[str, dict] = {}
for key, entry in by_path.items():
name = os.path.basename(key)
by_name[name] = None if name in by_name else entry
matched: set[str] = set()
counts = {outcome: 0 for outcome in report_parser.OUTCOMES}
for item in items:
key = _key(item.path)
entry = by_path.get(key) or by_name.get(os.path.basename(key))
if entry is not None:
matched.add(_key(entry["path"]))
item.outcome = entry["outcome"] if entry else report_parser.UNKNOWN
item.evidence = entry["evidence"] if entry else None
item.outcome_at = _now()
counts[item.outcome] += 1
unmatched = sorted(set(by_path) - matched)
reconciled = _reconciles(parsed["counts"], counts)
state = (
VERIFIED
if (
parsed["supported"]
and items
and not counts[report_parser.UNKNOWN]
and not unmatched
and not parsed["entries_truncated"]
and reconciled
)
else REQUIRES_VERIFICATION
)
batch.parser = parsed["parser"]
batch.parser_version = parsed["parser_version"]
batch.parsed_at = _now()
batch.outcome_state = state
batch.outcome_counts = json.dumps(counts, sort_keys=True)
batch.report_counts = (
json.dumps(parsed["counts"], sort_keys=True) if parsed["counts"] else None
)
session.commit()
return {
"parser": parsed["parser"],
"parser_version": parsed["parser_version"],
"outcome_state": state,
"counts": counts,
"report_counts": parsed["counts"],
"unmatched": unmatched,
"unparsed": parsed["unparsed"],
}
def _read_report(report_path: str | None) -> str:
"""The raw report, or ``""`` when the attempt never produced one."""
if not report_path:
return ""
path = Path(report_path)
return path.read_text(errors="replace") if path.exists() else ""
def _reconciles(report_counts: dict | None, derived: dict) -> bool:
"""Whether the uploader's own totals agree with the per-item outcomes.
No summary is not a disagreement — most of the uncertainty it would catch is
already caught by unmatched entries and unknown items.
"""
if not report_counts:
return True
return all(derived.get(outcome) == total for outcome, total in report_counts.items())

View File

@@ -0,0 +1,349 @@
"""UploadVerificationService — resolving uncertain uploads (US05-04).
US05-03 leaves a batch honest but sometimes uncertain: a killed uploader, an
unpinned report grammar, or a file the report never mentioned all end as
``unknown``. Retrying such a batch is the dangerous move — Immich may already hold
the files, and a blind retry is how a lost response turns into a second server
asset. So this service resolves uncertainty *before* anything is re-run:
- **evidence beats the report.** Verification asks Immich itself whether it holds
the exact SHA-1 the batch recorded before uploading. That answer is authoritative
over the uploader's text, in both directions: present makes an unknown item
``uploaded``, absent makes it ``failed`` and therefore safe to retry.
- **no answer is never "no".** An unreachable server, missing credentials, or a
response this adapter will not interpret leave the item ``inconclusive``. The
batch stays ``unknown_requires_verification`` and stays un-runnable.
- **changed bytes are a stale warning, not a silent re-upload.** Verification
re-hashes what is on disk. A file edited after its upload is flagged, the batch
is marked ``stale_bytes``, and re-running it is refused: uploading again would
create or upgrade a server asset the user never approved (concept §8).
- **manual resolution is evidence, not permission.** An operator may record what
they checked in Immich, but only with a non-empty note and their identity, and
every decision is appended to an immutable history alongside the server answers.
Retry policy lives in :func:`retry_blockers`, which :class:`UploadBatchService`
enforces before every attempt and the API surfaces as ``409``.
"""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.orm import sessionmaker
from photo_pipeline.config import Config
from photo_pipeline.integrations import immich_go
from photo_pipeline.integrations import immich_go_report as report_parser
from photo_pipeline.models import UploadBatch, UploadItem, UploadVerification
from photo_pipeline.services.hashing import sha256_file
from photo_pipeline.services.upload_reports import REQUIRES_VERIFICATION, VERIFIED
PRESENT = "present"
ABSENT = "absent"
INCONCLUSIVE = "inconclusive"
MANUAL = "manual"
MAX_EVIDENCE_CHARS = 500
def _now() -> datetime:
return datetime.now(timezone.utc)
class VerificationError(RuntimeError):
"""The request cannot be carried out (unknown batch/item, missing evidence)."""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
class UploadVerificationService:
def __init__(self, session_factory: sessionmaker, *, config: Config) -> None:
self._session_factory = session_factory
self._config = config
# ── verification ──────────────────────────────────────────────────────────
def verify(self, batch_id: str) -> dict:
"""Check every item of a batch against the server and the bytes on disk.
Returns ``{"batch_id", "state", "outcome_state", "stale_bytes",
"server_reachable", "detail", "counts", "items"}``. Safe to call
repeatedly: the same evidence produces the same rows, and each run appends
its own history entries.
"""
with self._session_factory() as session:
batch = session.get(UploadBatch, batch_id)
if batch is None:
raise VerificationError("not_found", f"unknown upload batch {batch_id!r}")
items = list(
session.scalars(
select(UploadItem)
.where(UploadItem.batch_id == batch_id)
.order_by(UploadItem.path)
)
)
answer = immich_go.bulk_upload_check(
self._config.immich_server_url,
self._config.immich_api_key.get_secret_value()
if self._config.immich_api_key
else None,
{item.asset_id: item.sha1 for item in items if item.sha1},
)
detail = answer["detail"]
for item in items:
observed, changed = _current_bytes(item)
held = answer["present"].get(item.asset_id)
if item.sha1 is None:
result, evidence = INCONCLUSIVE, "no upload hash was recorded for this file"
elif held is True:
result, evidence = PRESENT, f"Immich holds sha1 {item.sha1}"
elif held is False:
result, evidence = ABSENT, f"Immich does not hold sha1 {item.sha1}"
else:
result = INCONCLUSIVE
evidence = detail or "the server did not classify these bytes"
item.verification = result
item.verified_at = _now()
item.observed_sha256 = observed
item.changed_after_upload = changed
# The server is authoritative over the report text — but only when
# it actually answered.
if result == PRESENT:
item.outcome = report_parser.UPLOADED
item.evidence = evidence
item.outcome_at = _now()
elif result == ABSENT:
item.outcome = report_parser.FAILED
item.evidence = evidence
item.outcome_at = _now()
session.add(
_event(
batch_id,
item.asset_id,
action="verify",
source="immich_api",
result=result,
outcome=item.outcome,
evidence=evidence,
)
)
_resolve_batch(batch, items)
session.commit()
return _report(batch, items, server_reachable=answer["reachable"], detail=detail)
# ── manual resolution ─────────────────────────────────────────────────────
def resolve(
self, batch_id: str, asset_id: str, *, outcome: str, evidence: str, actor: str
) -> dict:
"""Record an operator's own verification of one item.
``evidence`` and ``actor`` are mandatory: a manual resolution is only worth
keeping if it says what was checked and who checked it.
"""
if outcome not in report_parser.OUTCOMES:
raise VerificationError("invalid_outcome", f"unknown upload outcome {outcome!r}")
evidence = (evidence or "").strip()
actor = (actor or "").strip()
if not evidence:
raise VerificationError("evidence_required", "a manual resolution must record evidence")
if not actor:
raise VerificationError("actor_required", "a manual resolution must record its author")
with self._session_factory() as session:
batch = session.get(UploadBatch, batch_id)
if batch is None:
raise VerificationError("not_found", f"unknown upload batch {batch_id!r}")
item = session.get(UploadItem, {"batch_id": batch_id, "asset_id": asset_id})
if item is None:
raise VerificationError("not_found", f"{asset_id!r} is not part of this batch")
item.outcome = outcome
item.evidence = evidence[:MAX_EVIDENCE_CHARS]
item.outcome_at = _now()
item.verification = MANUAL
item.verified_at = _now()
session.add(
_event(
batch_id,
asset_id,
action="resolve",
source="operator",
result=MANUAL,
outcome=outcome,
evidence=evidence,
actor=actor,
)
)
items = list(
session.scalars(
select(UploadItem)
.where(UploadItem.batch_id == batch_id)
.order_by(UploadItem.path)
)
)
_resolve_batch(batch, items)
session.commit()
return _report(batch, items, server_reachable=None, detail=None)
# ── history ───────────────────────────────────────────────────────────────
def history(self, batch_id: str) -> list[dict]:
"""Every verification and resolution recorded for this batch, oldest first."""
with self._session_factory() as session:
rows = session.scalars(
select(UploadVerification)
.where(UploadVerification.batch_id == batch_id)
.order_by(UploadVerification.created_at, UploadVerification.id)
)
return [
{
"id": row.id,
"asset_id": row.asset_id,
"action": row.action,
"source": row.source,
"result": row.result,
"outcome": row.outcome,
"evidence": row.evidence,
"actor": row.actor,
"created_at": row.created_at.isoformat() if row.created_at else None,
}
for row in rows
]
# ── retry policy ─────────────────────────────────────────────────────────────
def retry_blockers(batch: dict) -> list[dict]:
"""Why this batch may not be (re)run, in the order the user should fix them.
A plain uploader failure is a *safe* failure: nothing uncertain happened, so it
is retryable. An uncertain outcome and edited bytes are not.
"""
from photo_pipeline.services.upload_batches import BatchState, RUNNABLE_STATES
blockers: list[dict] = []
if batch["state"] == BatchState.UNKNOWN:
blockers.append(
{
"code": "requires_verification",
"message": "this upload's outcome is uncertain; verify it before retrying",
}
)
if batch.get("stale_bytes"):
blockers.append(
{
"code": "changed_after_upload",
"message": "files in this batch changed after they were uploaded; "
"re-approve them through a fresh preflight",
}
)
if not blockers and batch["state"] not in RUNNABLE_STATES:
blockers.append({"code": "not_runnable", "message": f"batch is {batch['state']}"})
return blockers
# ── internals ────────────────────────────────────────────────────────────────
def _current_bytes(item: UploadItem) -> tuple[str | None, bool]:
"""``(hash on disk now, changed since upload)``. A missing file counts as changed."""
path = Path(item.path)
if not path.exists():
return None, item.sha256 is not None
observed = sha256_file(path)
return observed, bool(item.sha256 and observed != item.sha256)
def _event(
batch_id: str,
asset_id: str,
*,
action: str,
source: str,
result: str,
outcome: str | None,
evidence: str,
actor: str | None = None,
) -> UploadVerification:
return UploadVerification(
id=str(uuid.uuid4()),
batch_id=batch_id,
asset_id=asset_id,
action=action,
source=source,
result=result,
outcome=outcome,
evidence=evidence[:MAX_EVIDENCE_CHARS],
actor=actor,
created_at=_now(),
)
def _resolve_batch(batch: UploadBatch, items: list[UploadItem]) -> None:
"""Fold the item evidence back into the batch's own state.
An uncertain batch only leaves that state once every item is accounted for:
all present makes it succeeded, any absent makes it a safe failure to retry,
and a single inconclusive item keeps it uncertain.
"""
from photo_pipeline.services.upload_batches import BatchState
batch.stale_bytes = any(item.changed_after_upload for item in items)
batch.verified_at = _now()
unresolved = [item for item in items if item.outcome in (None, report_parser.UNKNOWN)]
batch.outcome_state = VERIFIED if items and not unresolved else REQUIRES_VERIFICATION
if batch.state != BatchState.UNKNOWN or unresolved:
return
if any(item.outcome == report_parser.FAILED for item in items):
batch.state = BatchState.FAILED
batch.error_code = "verified_incomplete"
batch.error_message = "verification proved some files never reached Immich"
else:
batch.state = BatchState.SUCCEEDED
batch.error_code = batch.error_message = None
def _report(
batch: UploadBatch,
items: list[UploadItem],
*,
server_reachable: bool | None,
detail: str | None,
) -> dict:
counts: dict[str, int] = {}
for item in items:
key = item.verification or "unverified"
counts[key] = counts.get(key, 0) + 1
return {
"batch_id": batch.id,
"state": batch.state,
"outcome_state": batch.outcome_state,
"stale_bytes": batch.stale_bytes,
"server_reachable": server_reachable,
"detail": detail,
"counts": counts,
"items": [
{
"asset_id": item.asset_id,
"path": item.path,
"verification": item.verification,
"outcome": item.outcome,
"evidence": item.evidence,
"changed_after_upload": item.changed_after_upload,
"verified_at": item.verified_at.isoformat() if item.verified_at else None,
}
for item in items
],
}

View File

@@ -0,0 +1,306 @@
"""UploadService — preflight for Immich upload (US05-01).
Upload is the first stage that sends the library somewhere the app cannot take it
back from, so nothing here uploads: preflight only *proves* a scope is safe and
issues a token that a later start command must present (US05-02).
What it proves (concept §8 "preflight"):
- credentials are configured and the Immich server answers, without ever putting
the API key in a response, a preview, or a log line;
- ``immich-go`` is installed and its version is recorded;
- no rename is half-applied — the journal must not block library mutation;
- every asset in scope is canonical, present, and decided ``sfw``/``nsfw`` with a
verified safety EXIF checkpoint;
- every SFW asset also has a completed analysis EXIF checkpoint;
- the bytes on disk right now still hash to what inventory recorded, so the album
preview describes the exact bytes that would be uploaded.
Blocker codes are structured, never prose the UI has to parse:
``no_library_root``, ``credentials_missing``, ``server_unreachable``,
``immich_go_missing``, ``rename_pending``, ``unknown_album``, ``empty_scope``,
``file_missing``, ``bytes_changed``, ``safety_undecided``, ``safety_deferred``,
``safety_exif_unverified``, ``analysis_incomplete``, ``partial_scope``.
**Partial scope is a blocker, not a default.** An album with any blocked asset is
refused unless the caller passes the explicit ``allow_partial`` policy, which is
itself part of the token — a token issued for a partial upload can never be
replayed as a full one.
The token is derived, not stored: it is a digest of the whole report (minus its
timestamp), so any change that matters — a new decision, edited bytes, a different
server, a resolved blocker, a different policy — produces a different token and the
old one is stale by construction. No table, no invalidation bookkeeping.
"""
from __future__ import annotations
import hashlib
import json
from datetime import datetime, timezone
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.orm import sessionmaker
from photo_pipeline.config import Config
from photo_pipeline.integrations import immich_go
from photo_pipeline.models import AnalysisResult, Asset, SafetyReview
from photo_pipeline.services.albums import album_label
from photo_pipeline.services.hashing import sha256_file
from photo_pipeline.services.rename_journal import RenameJournal
PREFLIGHT_VERSION = 1
TOKEN_PREFIX = f"v{PREFLIGHT_VERSION}"
SFW = "sfw"
NSFW = "nsfw"
ANALYZED = "analyzed"
class UploadError(RuntimeError):
"""Invalid preflight request (unknown album in the requested scope)."""
def _now() -> datetime:
return datetime.now(timezone.utc)
def _issue(code: str, message: str) -> dict:
return {"code": code, "message": message}
class UploadService:
def __init__(self, session_factory: sessionmaker, *, config: Config) -> None:
self._session_factory = session_factory
self._config = config
self._roots = tuple(Path(root) for root in config.library_roots)
# ── preflight ─────────────────────────────────────────────────────────────
def preflight(self, albums: list[str] | None = None, *, allow_partial: bool = False) -> dict:
"""Validate an upload scope and issue its token. Read-only, no upload."""
report = {
"schema_version": PREFLIGHT_VERSION,
"policy": {"allow_partial": allow_partial},
"blockers": [],
"credentials": self._credentials(),
"server": self._server(),
"uploader": self._uploader(),
}
report["blockers"] += self._environment_blockers(report)
report["albums"] = self._albums(albums, allow_partial=allow_partial)
report["totals"] = _totals(report["albums"])
if not report["albums"]:
report["blockers"].append(
_issue("empty_scope", "no canonical, active assets are in the selected scope")
)
report["state"] = (
"ready"
if not report["blockers"] and all(a["state"] == "ready" for a in report["albums"])
else "blocked"
)
report["token"] = _token(report)
report["generated_at"] = _now().isoformat()
return report
def verify_token(
self, token: str, albums: list[str] | None = None, *, allow_partial: bool = False
) -> bool:
"""True when ``token`` still describes the current state of that scope.
Recomputed rather than looked up, so an externally edited file or a changed
decision invalidates it even though nothing wrote to the database.
"""
return bool(token) and token == self.preflight(albums, allow_partial=allow_partial)["token"]
# ── environment ───────────────────────────────────────────────────────────
def _credentials(self) -> dict:
"""Presence only — the key itself never leaves configuration."""
return {
"server_url": self._config.immich_server_url,
"api_key_configured": self._config.immich_api_key is not None,
}
def _server(self) -> dict:
reachable, detail = immich_go.ping(self._config.immich_server_url)
return {"reachable": reachable, "detail": detail}
def _uploader(self) -> dict:
binary = self._config.immich_go_binary
return {
"binary": binary,
"installed": immich_go.find_binary(binary) is not None,
"version": immich_go.version(binary),
}
def _environment_blockers(self, report: dict) -> list[dict]:
blockers: list[dict] = []
if not self._roots:
blockers.append(_issue("no_library_root", "no library root is configured"))
if not report["credentials"]["api_key_configured"] or not self._config.immich_server_url:
blockers.append(
_issue("credentials_missing", "an Immich server URL and API key are required")
)
elif not report["server"]["reachable"]:
blockers.append(
_issue(
"server_unreachable", f"Immich did not respond: {report['server']['detail']}"
)
)
if not report["uploader"]["installed"]:
blockers.append(
_issue("immich_go_missing", f"{self._config.immich_go_binary} is not installed")
)
if RenameJournal(self._session_factory).blocks_mutation():
blockers.append(
_issue("rename_pending", "an unresolved rename must be recovered before upload")
)
return blockers
# ── scope ─────────────────────────────────────────────────────────────────
def _albums(self, requested: list[str] | None, *, allow_partial: bool) -> list[dict]:
by_album = self._scope()
if requested is not None:
unknown = sorted(set(requested) - set(by_album))
if unknown:
raise UploadError(f"unknown album(s): {', '.join(unknown)}")
by_album = {name: by_album[name] for name in sorted(set(requested))}
return [
self._album(name, rows, allow_partial=allow_partial)
for name, rows in sorted(by_album.items())
]
def _scope(self) -> dict[str, list[dict]]:
"""Canonical, active assets grouped by album, each with its stage evidence."""
with self._session_factory() as session:
assets = list(
session.scalars(
select(Asset).where(
Asset.canonical_asset_id.is_(None),
Asset.availability_state == "active",
Asset.current_path.is_not(None),
)
)
)
reviews: dict[str, SafetyReview] = {}
for review in session.scalars(select(SafetyReview).order_by(SafetyReview.created_at)):
reviews[review.asset_id] = review # latest row per asset wins
analyses = {
result.asset_id: result for result in session.scalars(select(AnalysisResult))
}
by_album: dict[str, list[dict]] = {}
for asset in assets:
by_album.setdefault(album_label(asset.current_path, self._roots), []).append(
{
"asset_id": asset.id,
"path": asset.current_path,
"expected_sha256": asset.current_sha256,
"review": reviews.get(asset.id),
"analysis": analyses.get(asset.id),
}
)
return by_album
def _album(self, name: str, rows: list[dict], *, allow_partial: bool) -> dict:
folder = Path(rows[0]["path"]).parent
items = sorted((self._item(row) for row in rows), key=lambda item: item["current_path"])
blocked = [item for item in items if item["blockers"]]
eligible = [item for item in items if not item["blockers"]]
blockers: list[dict] = []
if blocked and not allow_partial:
blockers.append(
_issue(
"partial_scope",
f"{len(blocked)} of {len(items)} asset(s) are not upload-ready; resolve them "
"or approve a partial upload explicitly",
)
)
if not eligible:
blockers.append(_issue("empty_scope", "no upload-ready asset remains in this album"))
# Folder-as-album: Immich names the album after the leaf folder, so the
# preview shows exactly what the server will create.
album_name = folder.name
return {
"album": name,
"folder": str(folder),
"album_name": album_name,
"asset_count": len(items),
"eligible_count": len(eligible),
"blocked_count": len(blocked),
"partial": bool(blocked),
"state": "blocked" if blockers else "ready",
"blockers": blockers,
"assets": items,
"command_preview": immich_go.preview_command(
binary=self._config.immich_go_binary,
server_url=self._config.immich_server_url,
album_name=album_name,
folder=folder,
),
}
def _item(self, row: dict) -> dict:
"""One asset's readiness, including its hash *as it is on disk right now*."""
path = Path(row["path"])
review: SafetyReview | None = row["review"]
analysis: AnalysisResult | None = row["analysis"]
decision = review.decision if review else None
blockers: list[dict] = []
current_sha256 = None
if not path.exists():
blockers.append(_issue("file_missing", f"{path} is missing"))
else:
# ponytail: full re-hash every preflight. Gate on (size, mtime_ns) first
# if a large library makes this slow — the hash stays authoritative.
current_sha256 = sha256_file(path)
if row["expected_sha256"] and current_sha256 != row["expected_sha256"]:
blockers.append(
_issue("bytes_changed", f"{path} changed since its last verified checkpoint")
)
if decision not in (SFW, NSFW):
code = "safety_deferred" if decision == "deferred" else "safety_undecided"
blockers.append(_issue(code, "a confirmed sfw/nsfw safety decision is required"))
elif review.exif_verified_at is None:
blockers.append(
_issue("safety_exif_unverified", "the safety EXIF checkpoint is not verified")
)
elif decision == SFW and not (
analysis and analysis.status == ANALYZED and analysis.exif_written_at is not None
):
blockers.append(
_issue("analysis_incomplete", "SFW assets need a verified analysis EXIF checkpoint")
)
return {
"asset_id": row["asset_id"],
"current_path": str(path),
"safety_decision": decision,
"current_sha256": current_sha256,
"blockers": blockers,
}
def _totals(albums: list[dict]) -> dict:
return {
"albums": len(albums),
"ready_albums": sum(1 for album in albums if album["state"] == "ready"),
"assets": sum(album["asset_count"] for album in albums),
"eligible": sum(album["eligible_count"] for album in albums),
"blocked": sum(album["blocked_count"] for album in albums),
}
def _token(report: dict) -> str:
"""Digest of everything the report asserts. Volatile fields are excluded so the
same state always yields the same token; every relevant change breaks it."""
payload = {key: value for key, value in report.items() if key not in ("generated_at", "token")}
digest = hashlib.sha256(
json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str).encode("utf-8")
).hexdigest()
return f"{TOKEN_PREFIX}:{digest}"

View File

@@ -31,4 +31,6 @@ markers = [
"phase_b: Phase B end-to-end acceptance (US02-07) — API, worker-recovery, and browser journeys",
"phase_c: Phase C end-to-end acceptance (US03-05) — album proposal API and browser journeys",
"phase_d: Phase D end-to-end acceptance (US04-06) — guarded rename API, fault, and browser journeys",
"phase_e: Phase E end-to-end acceptance (US05-06) — upload preflight, uploader, and browser journeys",
"phase_f: Phase F end-to-end acceptance (US06-06) — archive destination, transfer, and restore journeys",
]

View File

@@ -9,12 +9,18 @@ and worker, never mocked inside a test.
from __future__ import annotations
import json
import os
import socket
import stat
import subprocess
import sys
import threading
import time
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
import httpx
@@ -133,15 +139,27 @@ class Server:
self.proc = None
def start_worker(seeded: Seeded, *, fake_vision_log: Path) -> subprocess.Popen:
"""Launch a real durable worker wired to the recording vision fake."""
def start_worker(
seeded: Seeded,
*,
fake_vision_log: Path | None = None,
extra_env: dict[str, str] | None = None,
) -> subprocess.Popen:
"""Launch a real durable worker wired to the recording vision fake.
``extra_env`` carries whatever else the job under test needs — the Immich
credentials and uploader path, for the upload lane.
"""
extra = dict(extra_env or {})
if fake_vision_log is not None:
extra["PHOTO_PIPELINE_FAKE_VISION_LOG"] = str(fake_vision_log)
return subprocess.Popen(
[sys.executable, "-m", "photo_pipeline", "worker", "--id", "e2e-worker"],
cwd=str(REPO),
env=_env(
seeded,
free_port(), # unused by the worker, but keeps the env shape uniform
extra={"PHOTO_PIPELINE_FAKE_VISION_LOG": str(fake_vision_log)},
extra=extra,
),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
@@ -159,3 +177,267 @@ def wait_until(predicate, *, timeout: float = 20, interval: float = 0.1):
return value
time.sleep(interval)
raise AssertionError("condition not met before timeout")
# ── Phase D: an analysed album, ready to be named and renamed ────────────────
NOW = datetime(2026, 1, 1, tzinfo=timezone.utc)
class session_factory:
"""Session factory against a seeded database, for the few things a test has to
set up or inspect below the API — journal states, mainly."""
def __init__(self, seeded: Seeded) -> None:
self._seeded = seeded
def __enter__(self):
from photo_pipeline.config import Config
from photo_pipeline.db import create_db_engine, create_session_factory, run_migrations
config = Config.from_env(
{
"PHOTO_PIPELINE_DATA_DIR": str(self._seeded.data),
"PHOTO_PIPELINE_LIBRARY_ROOTS": str(self._seeded.lib),
}
)
run_migrations(config.database_url)
self._engine = create_db_engine(config.database_url)
return create_session_factory(self._engine)
def __exit__(self, *_):
self._engine.dispose()
return False
def seed_album(tmp_path: Path, album: str = "rome", names: tuple[str, ...] = ("a.jpg", "b.jpg")):
"""A library holding one album folder whose photos are confirmed SFW and analysed
— the state a naming proposal, and therefore a rename plan, is built from."""
from sqlalchemy import select
from photo_pipeline.models import AnalysisResult, Asset, SafetyReview
from photo_pipeline.services.inventory import InventoryService
seeded = seed_library(tmp_path, {}, {})
folder = seeded.lib / album
folder.mkdir(parents=True)
for index, name in enumerate(names):
image(folder / name, index + 1)
with session_factory(seeded) as sf:
InventoryService(sf).scan(seeded.lib)
with sf() as session:
rows = list(session.execute(select(Asset.id, Asset.current_path)).all())
for asset_id, path in rows:
session.add(
SafetyReview(
id=str(uuid.uuid4()), asset_id=asset_id, decision="sfw", created_at=NOW
)
)
session.add(
AnalysisResult(
asset_id=asset_id,
status="analyzed",
description=f"a view of {path}",
tags='["ruins", "city"]',
approx_year=2019,
location_hint="Rome",
)
)
session.commit()
seeded.asset_ids.update({Path(path).stem: aid for aid, path in rows})
return seeded
def approve_album(base: str, *, album: str = "rome", name: str) -> None:
"""Generate a proposal, set its final name, and approve it over HTTP."""
httpx.post(f"{base}/api/v1/albums/proposals", json={}, timeout=10).raise_for_status()
for payload, route in (
({"name": name}, "edit"),
({}, "approve"),
):
current = httpx.get(f"{base}/api/v1/albums/proposals/{album}", timeout=10).json()
httpx.post(
f"{base}/api/v1/albums/proposals/{album}/{route}",
json={**payload, "expected_version": current["version"]},
timeout=10,
).raise_for_status()
# ── Phase E: an upload-ready album, a fake Immich, and a real fake uploader ───
SENTINEL_KEY = "immich-sentinel-9f3a2b"
UPLOADER_VERSION = "immich-go 0.21.0" # a pinned family, so reports are parsable
# Uploader bodies for the pinned ``text-v1`` grammar. ``$6`` is the folder argument
# of ``upload from-folder``.
REPORTING_UPLOADER = (
'echo "INFO uploaded $6/a.jpg"\n'
'echo "INFO server has the same file $6/b.jpg"\n'
'echo "Uploaded 1, duplicates 1"\n'
"exit 0\n"
)
# Exits cleanly but says nothing about any file: the process succeeded, the
# per-file outcome is unknown.
SILENT_UPLOADER = "exit 0\n"
def _immich_handler(state: dict):
class Handler(BaseHTTPRequestHandler):
def do_GET(self): # noqa: N802 (BaseHTTPRequestHandler API)
self._json(200, {"res": "pong"})
def do_POST(self): # noqa: N802
length = int(self.headers.get("Content-Length", 0))
payload = json.loads(self.rfile.read(length) or b"{}")
if state["mode"] == "broken":
self.send_error(500, "bulk-upload-check is unavailable")
return
reject = state["mode"] == "present"
self._json(
200,
{
"results": [
{
"id": asset["id"],
"action": "reject" if reject else "accept",
"reason": "duplicate" if reject else None,
}
for asset in payload.get("assets", [])
]
},
)
def _json(self, code: int, body: dict) -> None:
raw = json.dumps(body).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
self.wfile.write(raw)
def log_message(self, *args):
pass
return Handler
class FakeImmich:
"""An Immich that answers ping, and says whether it holds the exact bytes.
``mode`` is what the next verification will find: ``present`` (the server
deduplicates them, so it has them), ``absent`` (it would accept them, so it does
not), or ``broken`` (no usable answer at all).
"""
def __init__(self) -> None:
self.state = {"mode": "present"}
self._server = HTTPServer(("127.0.0.1", 0), _immich_handler(self.state))
threading.Thread(target=self._server.serve_forever, daemon=True).start()
self.url = f"http://127.0.0.1:{self._server.server_port}"
self._running = True
def mode(self, mode: str) -> None:
self.state["mode"] = mode
def stop(self) -> None:
"""Idempotent, so a test may take Immich away mid-journey."""
if not self._running:
return
self._running = False
self._server.shutdown()
self._server.server_close()
def fake_uploader(tmp_path: Path, body: str) -> Path:
"""A real executable standing in for immich-go.
``--version`` answers like the real tool; any other invocation appends its
complete argv to ``immich-go.argv`` — which is how a test proves the uploader
ran, what folder it was handed, or that it never ran at all.
"""
path = tmp_path / "immich-go"
path.write_text(
"#!/bin/sh\n"
f'if [ "$1" = "--version" ]; then echo "{UPLOADER_VERSION}"; exit 0; fi\n'
f'printf "%s\\n" "$*" >> "{tmp_path / "immich-go.argv"}"\n'
f"{body}"
)
path.chmod(path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
return path
def uploader_argv(tmp_path: Path) -> list[str]:
"""Every upload invocation the fake uploader saw, oldest first."""
log = tmp_path / "immich-go.argv"
return log.read_text().splitlines() if log.exists() else []
def mark_upload_ready(seeded: Seeded, *, unverified: tuple[str, ...] = ()) -> None:
"""Give every seeded photo the verified EXIF checkpoints upload requires.
``unverified`` names stems whose analysis checkpoint stays incomplete, which is
what makes an album partially blocked.
"""
from sqlalchemy import select
from photo_pipeline.models import AnalysisResult, SafetyReview
blocked = {seeded.asset_ids[stem] for stem in unverified}
with session_factory(seeded) as sf:
with sf() as session:
for review in session.scalars(select(SafetyReview)):
review.exif_verified_at = NOW
for analysis in session.scalars(select(AnalysisResult)):
analysis.exif_written_at = None if analysis.asset_id in blocked else NOW
session.commit()
class UploadStack:
"""A seeded, upload-ready library plus the server, worker, and fake Immich."""
def __init__(self, tmp_path: Path, seeded: Seeded) -> None:
self.tmp_path = tmp_path
self.seeded = seeded
self.immich = FakeImmich()
self.server: Server | None = None
self.worker: subprocess.Popen | None = None
self.base = ""
def start(
self,
*,
uploader: str = REPORTING_UPLOADER,
worker: bool = True,
credentials: bool = True,
) -> "UploadStack":
env = {
"PHOTO_PIPELINE_IMMICH_SERVER_URL": self.immich.url if credentials else "",
"PHOTO_PIPELINE_IMMICH_GO_BINARY": str(fake_uploader(self.tmp_path, uploader)),
}
if credentials:
env["PHOTO_PIPELINE_IMMICH_API_KEY"] = SENTINEL_KEY
self.server = Server(self.seeded, extra_env=env).start()
self.base = self.server.base
if worker:
self.worker = start_worker(self.seeded, extra_env=env)
return self
def restart_server(self) -> None:
"""A genuinely fresh process against the same database and library."""
self.server.stop()
self.server.start()
def batches(self) -> list[dict]:
return httpx.get(f"{self.base}/api/v1/upload-batches", timeout=20).json()["batches"]
def argv(self) -> list[str]:
return uploader_argv(self.tmp_path)
def stop(self) -> None:
if self.worker is not None:
self.worker.kill()
self.worker.wait(timeout=10)
if self.server is not None:
self.server.stop()
self.immich.stop()

View File

@@ -0,0 +1,377 @@
"""Phase D end-to-end acceptance (US04-06): guarded renaming, black box.
Every journey here drives a real ``photo_pipeline serve`` child process over HTTP —
plan, export, confirm, apply, collide, go stale, crash, recover, roll back. The
crashes are real: the server is killed by the ``PHOTO_PIPELINE_FAULT_AFTER`` barrier
at each persisted journal transition in turn, then a fresh process is started against
the same database and library and has to reconcile the wreckage from evidence alone.
Photos really move. After every journey the assertions read the filesystem and the
inventory back: the asset set, the stable IDs, and the content hashes must be exactly
what they were before, only at new paths.
"""
from __future__ import annotations
import httpx
import pytest
from tests.e2e._pipeline_harness import Server, approve_album, seed_album, session_factory
pytestmark = pytest.mark.phase_d
TIMEOUT = 10
APPROVED = "2019 Rome"
CRASH_POINTS = ["moving", "moved", "database_updated", "verified"]
@pytest.fixture
def server(tmp_path):
seeded = seed_album(tmp_path)
running = Server(seeded).start()
running.seeded = seeded
try:
yield running
finally:
running.stop()
# ── helpers ──────────────────────────────────────────────────────────────────
def _plan(base) -> dict:
response = httpx.post(f"{base}/api/v1/rename-plans", timeout=TIMEOUT)
response.raise_for_status()
return response.json()
def _get_plan(base, plan_id) -> dict:
return httpx.get(f"{base}/api/v1/rename-plans/{plan_id}", timeout=TIMEOUT).json()
def _apply(base, plan, **body):
payload = {"expected_version": plan["version"], **body}
return httpx.post(
f"{base}/api/v1/rename-plans/{plan['id']}/apply", json=payload, timeout=TIMEOUT
)
def _inventory(base) -> dict[str, dict]:
"""Every asset by stable ID, so identity can be compared across a rename."""
items = httpx.get(
f"{base}/api/v1/inventory/assets", params={"limit": 200}, timeout=TIMEOUT
).json()["items"]
return {item["id"]: item for item in items}
def _content(root) -> dict[str, bytes]:
return {
str(path.relative_to(root)): path.read_bytes()
for path in sorted(root.rglob("*"))
if path.is_file()
}
def _recovery(base) -> dict:
return httpx.get(f"{base}/api/v1/rename-recovery", timeout=TIMEOUT).json()
def _journal_states(seeded, plan_id) -> list[str]:
from photo_pipeline.services.rename_journal import RenameJournal
with session_factory(seeded) as sf:
return [row["journal_state"] for row in RenameJournal(sf).operations(plan_id)]
# ── US04-01: plan and export ─────────────────────────────────────────────────
def test_plan_and_export_describe_every_move_without_touching_the_library(server):
before = _content(server.seeded.lib)
approve_album(server.base, name=APPROVED)
plan = _plan(server.base)
assert plan["state"] == "validated" and plan["operation_count"] == 1
operation = plan["operations"][0]
assert operation["source_path"].endswith("/rome")
assert operation["destination_path"].endswith(f"/{APPROVED}")
assert operation["asset_count"] == 2
assert set(operation["asset_ids"]) == set(_inventory(server.base))
export = httpx.get(
f"{server.base}/api/v1/rename-plans/{plan['id']}/export", timeout=TIMEOUT
).json()
assert export["schema_version"] == 1
assert export["checksum"] == plan["checksum"]
assert [op["source_path"] for op in export["operations"]] == [operation["source_path"]]
# Portable evidence must not smuggle out anything sensitive.
assert "token" not in str(export).lower() and "key" not in str(export).lower()
# Planning is a preview: not one byte moved.
assert _content(server.seeded.lib) == before
# ── US04-03: confirmation and apply ──────────────────────────────────────────
def test_apply_requires_the_current_confirmation_token(server):
approve_album(server.base, name=APPROVED)
plan = _plan(server.base)
stale = _apply(server.base, {**plan, "version": plan["version"] + 7})
assert stale.status_code == 409 and stale.json()["error"]["code"] == "version_conflict"
wrong_checksum = _apply(server.base, plan, expected_checksum="0" * 64)
assert wrong_checksum.status_code == 409
assert (server.seeded.lib / "rome").is_dir(), "a refused confirmation moves nothing"
def test_a_valid_apply_preserves_ids_hashes_and_the_asset_set(server):
approve_album(server.base, name=APPROVED)
before = _inventory(server.base)
before_content = _content(server.seeded.lib)
plan = _plan(server.base)
applied = _apply(server.base, plan, expected_checksum=plan["checksum"]).json()
assert applied["applied"] == 1 and applied["failed"] == 0 and applied["state"] == "applied"
after = _inventory(server.base)
assert set(after) == set(before), "renaming must not change asset identity"
assert {item["current_sha256"] for item in after.values()} == {
item["current_sha256"] for item in before.values()
}
assert all(APPROVED in item["current_path"] for item in after.values())
# Same bytes, new folder — nothing was rewritten in the move.
assert _content(server.seeded.lib) == {
key.replace("rome/", f"{APPROVED}/"): value for key, value in before_content.items()
}
assert _journal_states(server.seeded, plan["id"]) == ["complete"]
def test_a_case_only_rename_applies_on_a_case_insensitive_filesystem(tmp_path):
seeded = seed_album(tmp_path, album="rome")
running = Server(seeded).start()
try:
approve_album(running.base, name="Rome")
plan = _plan(running.base)
assert plan["operations"][0]["case_only"] is True
assert _apply(running.base, plan).json()["applied"] == 1
entries = {path.name for path in seeded.lib.iterdir()}
assert "Rome" in entries
# The staged intermediate name must not survive the procedure.
assert not any(name.startswith(".rename-") for name in entries)
assert all("/Rome/" in item["current_path"] for item in _inventory(running.base).values())
finally:
running.stop()
def test_a_collision_is_refused_and_the_occupant_survives(server):
approve_album(server.base, name=APPROVED)
occupied = server.seeded.lib / APPROVED
occupied.mkdir()
(occupied / "precious.jpg").write_bytes(b"do not lose me")
plan = _plan(server.base)
assert plan["state"] == "invalid" and "destination_exists" in plan["blockers"]
refused = _apply(server.base, plan)
assert refused.status_code == 422 and refused.json()["error"]["code"] == "cannot_apply"
assert (occupied / "precious.jpg").read_bytes() == b"do not lose me"
assert (server.seeded.lib / "rome").is_dir()
def test_a_source_that_changed_after_planning_is_refused(server):
approve_album(server.base, name=APPROVED)
plan = _plan(server.base)
# The plan recorded per-asset hashes; the file changes before confirmation.
(server.seeded.lib / "rome" / "a.jpg").write_bytes(b"tampered")
result = _apply(server.base, plan).json()
assert result["failed"] == 1 and result["applied"] == 0
assert (server.seeded.lib / "rome").is_dir(), "a failed precondition leaves the source alone"
operation = _get_plan(server.base, plan["id"])["operations"][0]
assert operation["journal_state"] == "failed"
assert operation["error_code"] == "source_changed"
# ── US04-04: crash points, recovery, rollback ────────────────────────────────
def _crash_during_apply(seeded, plan, state):
"""Apply with the fault barrier armed: the server dies at ``state``, mid-move."""
crashing = Server(seeded, extra_env={"PHOTO_PIPELINE_FAULT_AFTER": state}).start()
try:
with pytest.raises(httpx.HTTPError):
_apply(crashing.base, plan)
finally:
crashing.stop()
assert crashing.proc is None
@pytest.mark.parametrize("crash_point", CRASH_POINTS)
def test_every_journal_crash_point_recovers_without_losing_content(tmp_path, crash_point):
seeded = seed_album(tmp_path)
first = Server(seeded).start()
try:
approve_album(first.base, name=APPROVED)
before = _inventory(first.base)
before_bytes = sorted(_content(seeded.lib).values())
plan = _plan(first.base)
finally:
first.stop()
_crash_during_apply(seeded, plan, crash_point)
# A brand-new process, no in-memory state: everything comes from the journal.
restarted = Server(seeded).start()
try:
recovery = _recovery(restarted.base)
assert recovery["items"], f"a crash at {crash_point} must leave visible evidence"
assert recovery["items"][0]["journal_state"] == crash_point
# Only a crash that could have left the library half-renamed blocks other
# work. `verified` is past every filesystem and database change — the move
# is done and checked, just not flagged complete — so it blocks nothing.
assert recovery["blocks_mutation"] is (crash_point != "verified")
resolved = httpx.post(
f"{restarted.base}/api/v1/rename-recovery/resolve", timeout=TIMEOUT
).json()
assert resolved["manual"] == 0, "an interrupted rename must be decidable from evidence"
# Recovery is idempotent: running it again changes nothing.
assert _recovery(restarted.base)["blocks_mutation"] is False
httpx.post(f"{restarted.base}/api/v1/rename-recovery/resolve", timeout=TIMEOUT)
# A resumable crash is left ready to run again; finish it so every crash
# point converges on the same observable end state.
current = _get_plan(restarted.base, plan["id"])
if current["state"] != "applied":
_apply(restarted.base, current)
after = _inventory(restarted.base)
assert set(after) == set(before), "no asset may be lost or invented by a crash"
assert {item["current_sha256"] for item in after.values()} == {
item["current_sha256"] for item in before.values()
}
assert sorted(_content(seeded.lib).values()) == before_bytes
assert (seeded.lib / APPROVED).is_dir() and not (seeded.lib / "rome").exists()
assert all(APPROVED in item["current_path"] for item in after.values())
assert _journal_states(seeded, plan["id"]) == ["complete"]
finally:
restarted.stop()
def test_ambiguous_evidence_is_kept_for_a_human_and_keeps_blocking(tmp_path):
seeded = seed_album(tmp_path)
first = Server(seeded).start()
try:
approve_album(first.base, name=APPROVED)
plan = _plan(first.base)
finally:
first.stop()
_crash_during_apply(seeded, plan, "moving")
# Someone creates the destination while the operation is unresolved: now both
# paths exist and nothing can tell which one holds the truth.
(seeded.lib / APPROVED).mkdir(exist_ok=True)
restarted = Server(seeded).start()
try:
assert _recovery(restarted.base)["items"][0]["classification"] == "manual"
resolved = httpx.post(
f"{restarted.base}/api/v1/rename-recovery/resolve", timeout=TIMEOUT
).json()
assert resolved["manual"] == 1 and resolved["resumed"] == 0 and resolved["completed"] == 0
# Still blocking, and still nothing guessed.
assert _recovery(restarted.base)["blocks_mutation"] is True
assert (seeded.lib / "rome").is_dir()
finally:
restarted.stop()
def test_an_unresolved_rename_is_the_cancellation_boundary(tmp_path):
"""There is no cancel button once a rename starts. The boundary is that nothing
else may mutate the library until the interrupted work is resolved."""
seeded = seed_album(tmp_path)
first = Server(seeded).start()
try:
approve_album(first.base, name=APPROVED)
plan = _plan(first.base)
finally:
first.stop()
_crash_during_apply(seeded, plan, "moving")
restarted = Server(seeded).start()
try:
assert _recovery(restarted.base)["blocks_mutation"] is True
refused = httpx.post(f"{restarted.base}/api/v1/albums/proposals", json={}, timeout=TIMEOUT)
assert refused.status_code == 409
assert refused.json()["error"]["code"] == "rename_recovery_required"
# Reading stays available throughout — only mutation is paused.
assert httpx.get(f"{restarted.base}/api/v1/albums/evidence", timeout=TIMEOUT).status_code
assert len(_inventory(restarted.base)) == 2
finally:
restarted.stop()
def test_rollback_returns_an_interrupted_move_to_its_source(tmp_path):
seeded = seed_album(tmp_path)
first = Server(seeded).start()
try:
approve_album(first.base, name=APPROVED)
before = _inventory(first.base)
plan = _plan(first.base)
finally:
first.stop()
# Crash after the content moved but before the database caught up: the operation
# is still reversible, which is exactly when rollback is defined.
_crash_during_apply(seeded, plan, "moved")
restarted = Server(seeded).start()
try:
rolled = httpx.post(
f"{restarted.base}/api/v1/rename-plans/{plan['id']}/rollback", timeout=TIMEOUT
).json()
assert rolled["rolled_back"] == 1 and rolled["state"] == "rolled_back"
assert (seeded.lib / "rome" / "a.jpg").exists()
assert not (seeded.lib / APPROVED).exists()
after = _inventory(restarted.base)
assert set(after) == set(before)
assert all(item["current_path"].endswith(".jpg") for item in after.values())
assert _recovery(restarted.base)["blocks_mutation"] is False
finally:
restarted.stop()
# ── durability ───────────────────────────────────────────────────────────────
def test_the_applied_state_survives_a_full_restart(tmp_path):
seeded = seed_album(tmp_path)
first = Server(seeded).start()
try:
approve_album(first.base, name=APPROVED)
plan = _plan(first.base)
_apply(first.base, plan, expected_checksum=plan["checksum"]).raise_for_status()
expected = _inventory(first.base)
finally:
first.stop()
restarted = Server(seeded).start()
try:
assert _inventory(restarted.base) == expected
after = _get_plan(restarted.base, plan["id"])
assert after["state"] == "applied"
assert after["checksum"] == plan["checksum"], "the plan's evidence is immutable"
assert [op["journal_state"] for op in after["operations"]] == ["complete"]
assert all(op["verified_at"] for op in after["operations"])
assert _recovery(restarted.base) == {"blocks_mutation": False, "items": []}
finally:
restarted.stop()

View File

@@ -0,0 +1,568 @@
"""Phase E end-to-end acceptance (US05-06): Immich upload, black box.
Every journey drives a real ``photo_pipeline serve`` child process and a real durable
worker over HTTP — preflight, approve, upload, duplicate, upgrade, fail, retry, lose
the acceptance response, verify, cancel, crash, restart. Nothing external is mocked
inside the application: ``immich-go`` is a real executable that records the argv it
was handed, and Immich is a real HTTP server answering the same ``ping`` and
``bulk-upload-check`` endpoints the adapter calls in production.
Two invariants are asserted in every relevant journey, because they are what make an
irreversible stage safe:
- **EXIF precedes upload.** An album without its verified safety and analysis
checkpoints cannot be approved, and the uploader's argv log proves it was never
even executed.
- **The persisted hashes are the submitted bytes.** After each upload the recorded
SHA-256/SHA-1 of every item is recomputed from the files in the folder the uploader
was actually given.
The API key is a sentinel string, so the last journey can prove it reached the
uploader and nothing else that was retained.
"""
from __future__ import annotations
import hashlib
from pathlib import Path
import httpx
import pytest
from tests.e2e._pipeline_harness import (
SENTINEL_KEY,
SILENT_UPLOADER,
UploadStack,
mark_upload_ready,
seed_album,
session_factory,
wait_until,
)
pytestmark = pytest.mark.phase_e
TIMEOUT = 20
ALBUM = "rome"
TERMINAL = {"succeeded", "failed", "cancelled", "unknown_requires_verification"}
# Uploader bodies in the pinned ``text-v1`` grammar. ``$6`` is the folder argument of
# ``upload from-folder``, so each line names the real path of a real file.
ALL_NEW = (
'echo "INFO uploaded $6/a.jpg"\n'
'echo "INFO uploaded $6/b.jpg"\n'
'echo "Uploaded 2"\n'
"exit 0\n"
)
EXACT_DUPLICATES = (
'echo "INFO server has the same file $6/a.jpg"\n'
'echo "INFO server has the same file $6/b.jpg"\n'
'echo "Duplicates 2"\n'
"exit 0\n"
)
UPGRADES = (
'echo "INFO server has an older file $6/a.jpg"\n'
'echo "INFO server has an older file $6/b.jpg"\n'
'echo "Upgraded 2"\n'
"exit 0\n"
)
def _once_then(tmp_path: Path, first: str, rest: str) -> str:
"""An uploader that behaves one way on its first attempt and another afterwards.
The flag file is the attempt counter, so retry and resume journeys are
deterministic without any test reaching into the running application.
"""
flag = tmp_path / "first-attempt.flag"
return f'if [ ! -f "{flag}" ]; then\n touch "{flag}"\n{first}fi\n{rest}'
FAILING_FIRST = (' echo "ERROR error uploading $6/a.jpg: connection reset"\n exit 1\n', ALL_NEW)
SLOW_FIRST = (' echo "INFO starting"\n sleep 30\n exit 0\n', ALL_NEW)
@pytest.fixture
def stack(tmp_path):
"""An analysed album whose EXIF checkpoints are already verified."""
seeded = seed_album(tmp_path)
mark_upload_ready(seeded)
running = UploadStack(tmp_path, seeded)
try:
yield running
finally:
running.stop()
@pytest.fixture
def unfinished(tmp_path):
"""The same album *before* its EXIF checkpoints were written."""
seeded = seed_album(tmp_path)
running = UploadStack(tmp_path, seeded)
try:
yield running
finally:
running.stop()
# ── helpers ──────────────────────────────────────────────────────────────────
def _preflight(base: str, **body) -> dict:
response = httpx.post(f"{base}/api/v1/upload-preflight", json=body, timeout=TIMEOUT)
response.raise_for_status()
return response.json()
def _create(base: str, report: dict, **body) -> httpx.Response:
return httpx.post(
f"{base}/api/v1/upload-batches",
json={"token": report["token"], **body},
timeout=TIMEOUT,
)
def _get(base: str, batch_id: str) -> dict:
return httpx.get(f"{base}/api/v1/upload-batches/{batch_id}", timeout=TIMEOUT).json()
def _start(base: str, batch_id: str) -> httpx.Response:
return httpx.post(f"{base}/api/v1/upload-batches/{batch_id}/start", timeout=TIMEOUT)
def _start_accepted(base: str, batch_id: str) -> httpx.Response:
"""Start, waiting out the uploader lane the previous attempt still holds.
A stopped attempt releases its job a moment after the batch itself reaches
``cancelled``; ``lock_held`` is that gap, not a refusal of this batch.
"""
def _attempt():
response = _start(base, batch_id)
if response.status_code == 409 and response.json()["error"]["code"] == "lock_held":
return None
response.raise_for_status()
return response
return wait_until(_attempt)
def _verify(base: str, batch_id: str) -> dict:
response = httpx.post(f"{base}/api/v1/upload-batches/{batch_id}/verify", timeout=TIMEOUT)
response.raise_for_status()
return response.json()
def _await_state(base: str, batch_id: str, states: set[str], *, timeout: float = 60) -> dict:
return wait_until(
lambda: (lambda b: b if b.get("state") in states else None)(_get(base, batch_id)),
timeout=timeout,
)
def _await_report(base: str, batch_id: str, states: set[str] = TERMINAL) -> dict:
"""Wait for a finished attempt *and* the report that explains it.
The batch state is recorded a moment before its report is parsed — the outcome of
the process and the outcome of each file are deliberately separate facts — so a
journey that reads per-item evidence must wait for the second one too.
"""
return wait_until(
lambda: (lambda b: b if b.get("state") in states and b.get("parsed_at") else None)(
_get(base, batch_id)
),
timeout=60,
)
def _approve(stack, **body) -> dict:
"""Preflight, approve exactly that report, and return the created batch."""
report = _preflight(stack.base, **body)
response = _create(stack.base, report, **body)
response.raise_for_status()
return response.json()["batches"][0]
def _upload(stack, **body) -> dict:
"""The whole approved journey, up to whatever terminal state it reaches."""
batch = _approve(stack, **body)
_start(stack.base, batch["id"]).raise_for_status()
return _await_report(stack.base, batch["id"])
def _outcomes(batch: dict) -> dict[str, str]:
return {Path(item["path"]).name: item["outcome"] for item in batch["items"]}
def _uploaded_folder(stack) -> Path:
"""The folder the uploader was actually handed, from its own argv log."""
invocations = stack.argv()
assert invocations, "the uploader was never executed"
return Path(invocations[-1].split()[-1])
def _assert_hashes_match_submitted_bytes(stack, batch: dict) -> None:
folder = _uploaded_folder(stack)
for item in batch["items"]:
submitted = folder / Path(item["path"]).name
raw = submitted.read_bytes()
assert item["sha256"] == hashlib.sha256(raw).hexdigest(), submitted
assert item["sha1"] == hashlib.sha1(raw).hexdigest(), submitted # noqa: S324 — Immich's
# ── credentials ──────────────────────────────────────────────────────────────
def test_missing_credentials_block_the_preflight_and_no_upload_is_attempted(stack):
stack.start(worker=False, credentials=False)
report = _preflight(stack.base)
assert report["state"] == "blocked"
assert [issue["code"] for issue in report["blockers"]] == ["credentials_missing"]
assert report["credentials"]["api_key_configured"] is False
# A blocked scope still issues a token; approving it is what is refused.
refused = _create(stack.base, report)
assert refused.status_code == 422
assert refused.json()["error"]["code"] == "not_ready"
assert stack.batches() == []
assert stack.argv() == [], "the uploader must not run without credentials"
def test_a_server_that_stops_answering_blocks_the_preflight(stack):
stack.start(worker=False)
assert _preflight(stack.base)["state"] == "ready"
stack.immich.stop() # Immich goes away between one preview and the next
report = _preflight(stack.base)
assert report["state"] == "blocked"
assert [issue["code"] for issue in report["blockers"]] == ["server_unreachable"]
assert report["server"]["reachable"] is False
assert _create(stack.base, report).status_code == 422
assert stack.argv() == []
# ── EXIF precedes upload ─────────────────────────────────────────────────────
def test_exif_checkpoints_must_be_written_before_anything_is_uploaded(unfinished):
stack = unfinished
stack.start()
report = _preflight(stack.base)
assert report["state"] == "blocked"
codes = {
issue["code"]
for album in report["albums"]
for asset in album["assets"]
for issue in asset["blockers"]
}
assert codes == {"safety_exif_unverified"}
assert _create(stack.base, report).status_code == 422
assert stack.argv() == [], "the uploader must not run before the EXIF checkpoints"
mark_upload_ready(stack.seeded) # the checkpoints are written
batch = _upload(stack)
assert batch["state"] == "succeeded"
assert stack.argv(), "the same scope uploads once its checkpoints exist"
# The recorded checkpoints predate the attempt that was allowed to run.
from sqlalchemy import select
from photo_pipeline.models import AnalysisResult, SafetyReview
with session_factory(stack.seeded) as sf, sf() as session:
checkpoints = [
*[row.exif_verified_at for row in session.scalars(select(SafetyReview))],
*[row.exif_written_at for row in session.scalars(select(AnalysisResult))],
]
started = batch["started_at"]
assert checkpoints and all(stamp.isoformat() < started for stamp in checkpoints)
def test_an_unfinished_photo_blocks_its_album_until_a_partial_upload_is_approved(tmp_path):
seeded = seed_album(tmp_path)
mark_upload_ready(seeded, unverified=("b",))
stack = UploadStack(tmp_path, seeded)
try:
stack.start()
report = _preflight(stack.base)
assert report["state"] == "blocked"
assert [issue["code"] for issue in report["albums"][0]["blockers"]] == ["partial_scope"]
assert report["totals"] == {
"albums": 1,
"ready_albums": 0,
"assets": 2,
"eligible": 1,
"blocked": 1,
}
assert _create(stack.base, report).status_code == 422
partial = _preflight(stack.base, allow_partial=True)
assert partial["state"] == "ready"
assert partial["token"] != report["token"]
# A full-scope approval can never be replayed as a partial one.
replayed = _create(stack.base, report, allow_partial=True)
assert replayed.status_code == 409
assert replayed.json()["error"]["code"] == "stale_preflight"
batch = _upload(stack, allow_partial=True)
assert batch["state"] == "succeeded"
assert batch["allow_partial"] is True
assert [Path(item["path"]).name for item in batch["items"]] == ["a.jpg"]
finally:
stack.stop()
# ── outcomes ─────────────────────────────────────────────────────────────────
def test_a_new_album_uploads_and_persists_the_hashes_that_were_submitted(stack):
stack.start(uploader=ALL_NEW)
batch = _upload(stack)
assert batch["state"] == "succeeded"
assert batch["outcome_state"] == "verified"
assert _outcomes(batch) == {"a.jpg": "uploaded", "b.jpg": "uploaded"}
assert batch["outcome_counts"]["uploaded"] == 2
assert batch["report_counts"] == {"uploaded": 2}
assert batch["parser"] == "text-v1"
_assert_hashes_match_submitted_bytes(stack, batch)
# One album, one invocation, scoped to that album's own folder.
assert len(stack.argv()) == 1
assert f"--album-name={ALBUM}" in stack.argv()[0]
assert _uploaded_folder(stack) == stack.seeded.lib / ALBUM
def test_an_exact_duplicate_is_recorded_as_a_duplicate_not_a_new_asset(stack):
stack.start(uploader=EXACT_DUPLICATES)
batch = _upload(stack)
assert batch["state"] == "succeeded"
assert batch["outcome_state"] == "verified"
assert set(_outcomes(batch).values()) == {"duplicate"}
assert batch["outcome_counts"]["uploaded"] == 0
_assert_hashes_match_submitted_bytes(stack, batch)
def test_a_better_copy_is_recorded_as_an_upgrade(stack):
stack.start(uploader=UPGRADES)
batch = _upload(stack)
assert batch["state"] == "succeeded"
assert batch["outcome_state"] == "verified"
assert set(_outcomes(batch).values()) == {"upgraded"}
assert batch["report_counts"] == {"upgraded": 2}
# ── failure and retry ────────────────────────────────────────────────────────
def test_a_plain_uploader_failure_is_retryable_and_the_retry_succeeds(stack, tmp_path):
stack.start(uploader=_once_then(tmp_path, *FAILING_FIRST))
failed = _upload(stack)
assert failed["state"] == "failed"
assert failed["error_code"] == "uploader_failed"
assert failed["exit_code"] == 1
assert _outcomes(failed)["a.jpg"] == "failed"
# Nothing uncertain happened, so the batch is offered again rather than blocked.
assert failed["retry_blockers"] == []
_start_accepted(stack.base, failed["id"])
retried = _await_report(stack.base, failed["id"], {"succeeded"})
assert retried["attempt_count"] == 2
assert _outcomes(retried) == {"a.jpg": "uploaded", "b.jpg": "uploaded"}
assert len(stack.argv()) == 2
# ── uncertainty ──────────────────────────────────────────────────────────────
def test_a_lost_acceptance_response_stays_uncertain_and_is_never_retried_blindly(stack):
stack.start(uploader=SILENT_UPLOADER)
batch = _upload(stack)
# The process succeeded; what happened to each file is simply not known.
assert batch["state"] == "succeeded"
assert batch["outcome_state"] == "requires_verification"
assert set(_outcomes(batch).values()) == {"unknown"}
refused = _start(stack.base, batch["id"])
assert refused.status_code == 409
assert refused.json()["error"]["code"] == "not_runnable"
assert len(stack.argv()) == 1, "a blind retry must not reach the uploader"
def test_verification_asks_immich_and_resolves_every_uncertain_item(stack):
stack.start(uploader=SILENT_UPLOADER)
stack.immich.mode("present") # Immich holds exactly the bytes that were sent
batch = _upload(stack)
result = _verify(stack.base, batch["id"])
assert result["counts"] == {"present": 2}
assert result["outcome_state"] == "verified"
verified = _get(stack.base, batch["id"])
assert set(_outcomes(verified).values()) == {"uploaded"}
history = httpx.get(
f"{stack.base}/api/v1/upload-batches/{batch['id']}/verifications", timeout=TIMEOUT
).json()["verifications"]
assert {entry["source"] for entry in history} == {"immich_api"}
def test_an_unusable_answer_stays_uncertain_until_an_operator_records_evidence(stack):
stack.start(uploader=SILENT_UPLOADER)
stack.immich.mode("broken") # answers, but nothing this adapter will interpret
batch = _upload(stack)
result = _verify(stack.base, batch["id"])
# No answer is never "no": the items stay uncertain rather than being called failed.
assert result["counts"] == {"inconclusive": 2}
assert result["outcome_state"] == "requires_verification"
assert set(_outcomes(_get(stack.base, batch["id"])).values()) == {"unknown"}
asset_id = batch["items"][0]["asset_id"]
incomplete = httpx.post(
f"{stack.base}/api/v1/upload-batches/{batch['id']}/resolve",
json={"asset_id": asset_id, "outcome": "uploaded", "evidence": "", "actor": "dom"},
timeout=TIMEOUT,
)
assert incomplete.status_code == 422
assert incomplete.json()["error"]["code"] == "evidence_required"
resolved = httpx.post(
f"{stack.base}/api/v1/upload-batches/{batch['id']}/resolve",
json={
"asset_id": asset_id,
"outcome": "uploaded",
"evidence": "found it in Immich by checksum",
"actor": "dom",
},
timeout=TIMEOUT,
)
resolved.raise_for_status()
assert _outcomes(_get(stack.base, batch["id"]))["a.jpg"] == "uploaded"
manual = httpx.get(
f"{stack.base}/api/v1/upload-batches/{batch['id']}/verifications", timeout=TIMEOUT
).json()["verifications"][-1]
assert manual["source"] == "operator"
assert manual["actor"] == "dom"
def test_bytes_edited_after_the_upload_are_flagged_and_block_another_run(stack):
stack.start(uploader=ALL_NEW)
batch = _upload(stack)
assert batch["state"] == "succeeded"
(stack.seeded.lib / ALBUM / "a.jpg").write_bytes(b"edited after the upload")
result = _verify(stack.base, batch["id"])
assert result["stale_bytes"] is True
changed = [item for item in result["items"] if item["changed_after_upload"]]
assert [Path(item["path"]).name for item in changed] == ["a.jpg"]
refused = _start(stack.base, batch["id"])
assert refused.status_code == 409
assert refused.json()["error"]["code"] == "changed_after_upload"
# The preflight agrees: those bytes are no longer approved for any new upload.
report = _preflight(stack.base)
assert report["state"] == "blocked"
assert "bytes_changed" in {
issue["code"]
for album in report["albums"]
for asset in album["assets"]
for issue in asset["blockers"]
}
# ── cancellation and resume ──────────────────────────────────────────────────
def test_a_running_album_can_be_stopped_and_run_again_from_that_boundary(stack, tmp_path):
stack.start(uploader=_once_then(tmp_path, *SLOW_FIRST))
batch = _approve(stack)
_start(stack.base, batch["id"]).raise_for_status()
_await_state(stack.base, batch["id"], {"running"})
httpx.post(
f"{stack.base}/api/v1/upload-batches/{batch['id']}/cancel", timeout=TIMEOUT
).raise_for_status()
cancelled = _await_state(stack.base, batch["id"], {"cancelled"})
# A stopped album is a clean boundary, not an uncertain one.
assert cancelled["retry_blockers"] == []
_start_accepted(stack.base, batch["id"])
resumed = _await_report(stack.base, batch["id"], {"succeeded"})
assert resumed["attempt_count"] == 2
assert _outcomes(resumed) == {"a.jpg": "uploaded", "b.jpg": "uploaded"}
def test_an_interrupted_attempt_is_uncertain_after_a_restart_and_stays_blocked(stack, tmp_path):
"""The worker vanishes mid-upload: Immich may hold the files, so the outcome is
unknown. Startup recovery must say so, refuse a retry, and survive the restart."""
stack.start(uploader=_once_then(tmp_path, *SLOW_FIRST))
batch = _approve(stack)
_start(stack.base, batch["id"]).raise_for_status()
_await_state(stack.base, batch["id"], {"running"})
stack.worker.kill() # no chance to record any outcome
stack.worker.wait(timeout=10)
stack.restart_server()
recovered = _get(stack.base, batch["id"])
assert recovered["state"] == "unknown_requires_verification"
assert recovered["error_code"] == "interrupted"
assert recovered["attempt_count"] == 1
refused = _start(stack.base, batch["id"])
assert refused.status_code == 409
assert refused.json()["error"]["code"] == "requires_verification"
# Verification is the only way out, and it is what makes the batch certain again.
stack.immich.mode("present")
result = _verify(stack.base, batch["id"])
assert result["state"] == "succeeded"
assert result["outcome_state"] == "verified"
stack.restart_server() # the resolution is durable, not in-process memory
after = _get(stack.base, batch["id"])
assert after["state"] == "succeeded"
assert set(_outcomes(after).values()) == {"uploaded"}
# ── privacy ──────────────────────────────────────────────────────────────────
def test_no_secret_appears_in_any_retained_artifact(stack):
stack.start(uploader=ALL_NEW)
planned = _approve(stack)
job = _start(stack.base, planned["id"]).json()["job"]
batch = _await_report(stack.base, planned["id"])
stack.immich.mode("present")
_verify(stack.base, batch["id"])
# The uploader really was given the key…
assert f"--api-key={SENTINEL_KEY}" in stack.argv()[0]
# …and it is in nothing that was kept: not the database, not the retained report,
# not any response the browser can read.
retained = [path for path in stack.seeded.data.rglob("*") if path.is_file()]
assert any(path.suffix == ".log" for path in retained), "the report was not retained"
for path in retained:
assert SENTINEL_KEY.encode() not in path.read_bytes(), path
for url in (
f"{stack.base}/api/v1/upload-batches",
f"{stack.base}/api/v1/upload-batches/{batch['id']}",
f"{stack.base}/api/v1/upload-batches/{batch['id']}/verifications",
f"{stack.base}/api/v1/jobs/{job['id']}",
f"{stack.base}/api/v1/jobs/{job['id']}/events",
):
assert SENTINEL_KEY not in httpx.get(url, timeout=TIMEOUT).text, url
assert SENTINEL_KEY not in httpx.post(
f"{stack.base}/api/v1/upload-preflight", json={}, timeout=TIMEOUT
).text
assert "--api-key=***" in " ".join(_get(stack.base, batch["id"])["command"])

View File

@@ -11,89 +11,22 @@ Renames really happen here: the assertions read the filesystem afterwards.
from __future__ import annotations
import uuid
from datetime import datetime, timezone
import httpx
import pytest
from playwright.sync_api import expect
from tests.e2e._pipeline_harness import Server, image, seed_library
from tests.e2e._pipeline_harness import Server, approve_album, seed_album, session_factory
# Part of the Phase D acceptance command (US04-06); mapped to US04-05 for traceability.
pytestmark = pytest.mark.phase_d
NOW = datetime(2026, 1, 1, tzinfo=timezone.utc)
TIMEOUT = 10
APPROVED = "2019 Rome"
def _seed(tmp_path):
"""A library with one album ("rome") whose two photos are SFW and analysed."""
seeded = seed_library(tmp_path, {}, {})
album = seeded.lib / "rome"
album.mkdir()
image(album / "a.jpg", 1)
image(album / "b.jpg", 2)
from sqlalchemy import select
from photo_pipeline.models import AnalysisResult, Asset, SafetyReview
from photo_pipeline.services.inventory import InventoryService
with _factory(seeded) as sf:
InventoryService(sf).scan(seeded.lib)
with sf() as session:
rows = list(session.execute(select(Asset.id, Asset.current_path)).all())
for asset_id, path in rows:
session.add(
SafetyReview(
id=str(uuid.uuid4()), asset_id=asset_id, decision="sfw", created_at=NOW
)
)
session.add(
AnalysisResult(
asset_id=asset_id,
status="analyzed",
description=f"a view of {path}",
tags='["ruins", "city"]',
approx_year=2019,
location_hint="Rome",
)
)
session.commit()
return seeded
class _factory:
"""Session factory against the seeded database, for the few things a test has to
set up or inspect below the API (journal states, mainly)."""
def __init__(self, seeded):
self._seeded = seeded
def __enter__(self):
from photo_pipeline.config import Config
from photo_pipeline.db import create_db_engine, create_session_factory, run_migrations
config = Config.from_env(
{
"PHOTO_PIPELINE_DATA_DIR": str(self._seeded.data),
"PHOTO_PIPELINE_LIBRARY_ROOTS": str(self._seeded.lib),
}
)
run_migrations(config.database_url)
self._engine = create_db_engine(config.database_url)
return create_session_factory(self._engine)
def __exit__(self, *_):
self._engine.dispose()
return False
@pytest.fixture
def server(tmp_path):
seeded = _seed(tmp_path)
seeded = seed_album(tmp_path)
running = Server(seeded).start()
running.seeded = seeded
try:
@@ -105,22 +38,8 @@ def server(tmp_path):
# ── helpers ──────────────────────────────────────────────────────────────────
def _approve(base, *, album="rome", name=APPROVED):
"""Generate a proposal, set its final name, and approve it — the state a rename
plan is built from."""
httpx.post(f"{base}/api/v1/albums/proposals", json={}, timeout=TIMEOUT).raise_for_status()
current = httpx.get(f"{base}/api/v1/albums/proposals/{album}", timeout=TIMEOUT).json()
httpx.post(
f"{base}/api/v1/albums/proposals/{album}/edit",
json={"name": name, "expected_version": current["version"]},
timeout=TIMEOUT,
).raise_for_status()
current = httpx.get(f"{base}/api/v1/albums/proposals/{album}", timeout=TIMEOUT).json()
httpx.post(
f"{base}/api/v1/albums/proposals/{album}/approve",
json={"expected_version": current["version"]},
timeout=TIMEOUT,
).raise_for_status()
def _approve(base, *, name=APPROVED):
approve_album(base, name=name)
def _build(base):
@@ -145,7 +64,7 @@ def _interrupt(seeded, plan, *, state="moving", make_destination=False):
"""
from photo_pipeline.services.rename_journal import RenameJournal
with _factory(seeded) as sf:
with session_factory(seeded) as sf:
journal = RenameJournal(sf)
operation = journal.operations(plan["id"])[0]
journal.begin(operation["id"], worker_id="crashed", fencing_token=1)
@@ -263,6 +182,22 @@ def test_the_applied_plan_survives_a_reload(page, server):
expect(page.get_by_test_id("apply-result")).to_have_count(0)
def test_the_view_matches_the_journal_after_a_server_restart(page, server):
_open_plan(page, server)
page.get_by_test_id("apply-plan").click()
expect(page.get_by_test_id("plan-state")).to_have_text("applied")
server.stop()
server.start() # same port, so the page reconnects to a genuinely fresh process
page.reload()
expect(page.get_by_test_id("plan-state")).to_have_text("applied")
expect(page.get_by_test_id("operation-row").first.get_by_test_id("op-state")).to_have_text(
"complete"
)
expect(page.get_by_test_id("op-verified")).to_be_visible()
def test_apply_can_be_confirmed_from_the_keyboard(page, server):
_open_plan(page, server)
page.get_by_test_id("apply-plan").focus()
@@ -287,7 +222,7 @@ def test_rollback_returns_an_unfinished_move_to_its_source(page, server):
# the destination and the operation is still reversible.
_interrupt(server.seeded, plan, state="moving")
(server.seeded.lib / "rome").rename(server.seeded.lib / APPROVED)
with _factory(server.seeded) as sf:
with session_factory(server.seeded) as sf:
from photo_pipeline.services.rename_journal import RenameJournal
journal = RenameJournal(sf)

View File

@@ -12,12 +12,26 @@ from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
MAP = json.loads((REPO / "tests" / "story_traceability.json").read_text())["stories"]
PHASE_A_STORIES = {f"US01-0{n}" for n in range(1, 8)}
PHASE_D_STORIES = {f"US04-0{n}" for n in range(1, 7)}
PHASE_E_STORIES = {f"US05-0{n}" for n in range(1, 7)}
def test_all_phase_a_stories_are_mapped():
assert PHASE_A_STORIES <= set(MAP)
def test_all_phase_d_stories_are_mapped():
"""US04-06 acceptance: every guarded-rename story, plan through browser, is tied
to automated tests — renaming is the first thing that mutates the real library."""
assert PHASE_D_STORIES <= set(MAP)
def test_all_phase_e_stories_are_mapped():
"""US05-06 acceptance: every upload story, preflight through browser, is tied to
automated tests — upload is the one stage the app cannot take back."""
assert PHASE_E_STORIES <= set(MAP)
def test_every_mapped_test_file_exists_and_is_nonempty():
for story, files in MAP.items():
assert files, f"{story} maps to no tests"

View File

@@ -0,0 +1,283 @@
"""Browser journeys for the upload view (US05-05).
Covers the preflight preview (scope, redacted configuration, blockers, exact
confirmation), a real upload through the real worker with per-outcome progress,
stopping a running album, and the two ways out of an uncertain outcome —
verification against Immich and a manual resolution that records its evidence.
Nothing external is mocked inside the browser: the uploader is a real executable
driven by the real worker process, and Immich is a real HTTP server answering the
same ``ping``/``bulk-upload-check`` endpoints the adapter calls in production. The
API key is a sentinel string, so the last test can prove it never reached the page.
"""
from __future__ import annotations
import httpx
import pytest
from playwright.sync_api import expect
from tests.e2e._pipeline_harness import (
SENTINEL_KEY,
SILENT_UPLOADER,
UPLOADER_VERSION,
UploadStack,
mark_upload_ready,
seed_album,
session_factory,
wait_until,
)
pytestmark = pytest.mark.phase_e
TIMEOUT = 10
@pytest.fixture
def stack(tmp_path):
seeded = seed_album(tmp_path)
mark_upload_ready(seeded)
running = UploadStack(tmp_path, seeded)
try:
yield running
finally:
running.stop()
def _open(page, stack) -> None:
page.goto(f"{stack.base}/app/#/uploads")
page.get_by_test_id("upload-scope").wait_for()
def _upload(page, stack) -> None:
"""Confirm the upload and wait for the worker to finish the album."""
_open(page, stack)
page.get_by_test_id("start-upload").click()
expect(page.get_by_test_id("detail-state")).not_to_have_text("planned", timeout=30_000)
# ── preflight ────────────────────────────────────────────────────────────────
def test_the_preview_shows_scope_configuration_and_an_exact_confirmation(page, stack):
errors = []
page.on("console", lambda m: errors.append(m.text) if m.type == "error" else None)
stack.start(worker=False)
_open(page, stack)
expect(page.get_by_test_id("config-server")).to_have_text(stack.immich.url)
expect(page.get_by_test_id("config-key")).to_have_text("configured (never shown)")
expect(page.get_by_test_id("config-reachable")).to_have_text("yes")
expect(page.get_by_test_id("config-uploader")).to_have_text(UPLOADER_VERSION)
row = page.get_by_test_id("album-row").first
expect(row.get_by_test_id("album-name")).to_have_text("rome")
expect(row.get_by_test_id("album-immich-name")).to_have_text("rome")
expect(row.get_by_test_id("album-eligible")).to_have_text("2")
expect(row.get_by_test_id("album-state")).to_have_text("ready")
# The exact invocation is previewed, with the key masked at the source.
command = row.get_by_test_id("album-command").inner_text()
assert "upload from-folder" in command and "--album-name=rome" in command
assert "--api-key=***" in command
# The confirmation names the scope it is about to send, not just "Upload".
expect(page.get_by_test_id("start-upload")).to_have_text("Upload 1 album(s) · 2 photo(s)")
expect(page.get_by_test_id("start-upload")).to_be_enabled()
assert errors == [], f"console errors: {errors}"
def test_an_unfinished_photo_blocks_its_album_and_the_confirmation(page, stack):
mark_upload_ready(stack.seeded, unverified=("b",))
stack.start(worker=False)
_open(page, stack)
expect(page.get_by_test_id("album-state")).to_have_text("blocked")
expect(page.get_by_test_id("album-blocker")).to_have_attribute("data-code", "partial_scope")
expect(page.get_by_test_id("album-eligible")).to_have_text("1")
expect(page.get_by_test_id("album-blocked")).to_have_text("1")
expect(page.get_by_test_id("start-upload")).to_be_disabled()
# Partial upload exists, but only as a deliberate act: ticking it re-runs the
# preflight under that policy and the confirmation then names the smaller scope.
page.get_by_test_id("allow-partial").check()
expect(page.get_by_test_id("album-state")).to_have_text("ready")
expect(page.get_by_test_id("start-upload")).to_have_text("Upload 1 album(s) · 1 photo(s)")
assert stack.batches() == [], "nothing may be created by previewing"
# ── uploading ────────────────────────────────────────────────────────────────
def test_a_confirmed_upload_runs_and_reports_each_outcome(page, stack):
stack.start()
_upload(page, stack)
expect(page.get_by_test_id("detail-state")).to_have_text("succeeded")
expect(page.get_by_test_id("count-new")).to_have_text("new: 1")
expect(page.get_by_test_id("count-duplicate")).to_have_text("duplicate: 1")
expect(page.get_by_test_id("count-uncertain")).to_have_text("uncertain: 0")
expect(page.get_by_test_id("count-failed")).to_have_text("failed: 0")
expect(page.get_by_test_id("batch-outcome-state")).to_have_text("verified")
outcomes = sorted(page.get_by_test_id("item-outcome").all_inner_texts())
assert outcomes == ["duplicate", "new"]
# A finished album offers no restart: the server would refuse one.
expect(page.get_by_test_id("retry-blocked")).to_contain_text("not_runnable")
expect(page.get_by_test_id("start-batch")).to_have_count(0)
def test_a_running_album_can_be_stopped(page, stack):
stack.start(uploader='echo "INFO starting"; sleep 20; exit 0\n')
_open(page, stack)
page.get_by_test_id("start-upload").click()
stop = page.get_by_test_id("cancel-batch")
expect(stop).to_have_text("Stop after the current file", timeout=30_000)
stop.click()
expect(page.get_by_test_id("detail-state")).to_have_text("cancelled", timeout=30_000)
# A stopped album is a clean boundary, not an uncertain one: it can run again.
expect(page.get_by_test_id("start-batch")).to_be_visible()
def test_the_finished_upload_survives_a_reload(page, stack):
stack.start()
_upload(page, stack)
expect(page.get_by_test_id("detail-state")).to_have_text("succeeded")
page.reload()
expect(page.get_by_test_id("detail-state")).to_have_text("succeeded")
expect(page.get_by_test_id("count-new")).to_have_text("new: 1")
# The result banner is this tab's memory, not server state, so it stays gone.
expect(page.get_by_test_id("upload-result")).to_have_count(0)
# ── uncertainty ──────────────────────────────────────────────────────────────
def test_an_uncertain_outcome_offers_verification_and_no_retry(page, stack):
stack.start(uploader=SILENT_UPLOADER)
_upload(page, stack)
expect(page.get_by_test_id("detail-state")).to_have_text("succeeded")
expect(page.get_by_test_id("batch-outcome-state")).to_have_text("requires_verification")
expect(page.get_by_test_id("count-uncertain")).to_have_text("uncertain: 2")
expect(page.get_by_test_id("uncertain")).to_be_visible()
# The point of the story: verification is offered, a retry is not.
expect(page.get_by_test_id("verify-batch")).to_be_visible()
expect(page.get_by_test_id("start-batch")).to_have_count(0)
def test_verification_asks_immich_and_resolves_the_uncertain_items(page, stack):
stack.start(uploader=SILENT_UPLOADER)
stack.immich.mode("present") # Immich holds exactly the bytes that were sent
_upload(page, stack)
page.get_by_test_id("verify-batch").click()
expect(page.get_by_test_id("batch-outcome-state")).to_have_text("verified")
expect(page.get_by_test_id("count-new")).to_have_text("new: 2")
expect(page.get_by_test_id("count-uncertain")).to_have_text("uncertain: 0")
expect(page.get_by_test_id("item-verification").first).to_have_text("present")
expect(page.get_by_test_id("history-entry").first).to_have_attribute(
"data-source", "immich_api"
)
expect(page.get_by_test_id("uncertain")).to_have_count(0)
def test_an_unusable_answer_stays_uncertain_until_someone_records_evidence(page, stack):
stack.start(uploader=SILENT_UPLOADER)
stack.immich.mode("broken") # answers, but nothing this adapter will interpret
_upload(page, stack)
page.get_by_test_id("verify-batch").click()
# No answer is never "no": the items stay uncertain rather than being called failed.
expect(page.get_by_test_id("item-verification").first).to_have_text("inconclusive")
expect(page.get_by_test_id("uncertain")).to_be_visible()
expect(page.get_by_test_id("start-batch")).to_have_count(0)
row = page.get_by_test_id("item-row").first
row.get_by_test_id("resolve-outcome").select_option("uploaded")
row.get_by_test_id("resolve-evidence").fill("found it in Immich by checksum")
row.get_by_test_id("resolve-actor").fill("dom")
row.get_by_test_id("resolve-item").click()
expect(page.get_by_test_id("item-row").first.get_by_test_id("item-outcome")).to_have_text("new")
manual = page.get_by_test_id("history-entry").last
expect(manual).to_have_attribute("data-source", "operator")
expect(manual).to_contain_text("found it in Immich by checksum")
expect(manual).to_contain_text("dom")
def test_bytes_changed_after_upload_are_flagged_and_block_another_run(page, stack):
stack.start()
_upload(page, stack)
expect(page.get_by_test_id("detail-state")).to_have_text("succeeded")
# The user edits a photo after it was uploaded; Immich still holds the old bytes.
(stack.seeded.lib / "rome" / "a.jpg").write_bytes(b"edited after the upload")
page.get_by_test_id("verify-batch").click()
expect(page.get_by_test_id("stale-bytes")).to_be_visible()
expect(page.get_by_test_id("item-changed")).to_have_count(1)
expect(page.get_by_test_id("retry-blocked")).to_contain_text("changed_after_upload")
expect(page.get_by_test_id("start-batch")).to_have_count(0)
# The preflight agrees: those bytes are no longer approved for any new upload.
expect(page.get_by_test_id("album-state")).to_have_text("blocked")
expect(page.get_by_test_id("album-blocked")).to_have_text("1")
# ── privacy ──────────────────────────────────────────────────────────────────
def test_the_api_key_never_reaches_the_browser(page, stack):
logs = []
page.on("console", lambda message: logs.append(message.text))
stack.start()
_upload(page, stack)
page.get_by_test_id("verify-batch").click()
expect(page.get_by_test_id("item-verification").first).to_have_text("present")
storage = page.evaluate(
"() => JSON.stringify([{...localStorage}, {...sessionStorage}, document.cookie])"
)
assert SENTINEL_KEY not in page.content()
assert SENTINEL_KEY not in page.url
assert SENTINEL_KEY not in storage
assert SENTINEL_KEY not in "\n".join(logs)
# The uploader was given the real key even though nothing on the page shows it.
report = wait_until(lambda: sorted((stack.seeded.data / "uploads").glob("*.log")))[0]
assert SENTINEL_KEY not in report.read_text()
# ── recovery ─────────────────────────────────────────────────────────────────
def test_an_interrupted_attempt_is_shown_as_uncertain_after_a_restart(page, stack):
"""A worker that vanished mid-upload leaves a batch whose outcome nobody knows.
Startup recovery marks it uncertain, and the view must not offer to retry it."""
from photo_pipeline.models import UploadBatch
stack.start(worker=False)
_open(page, stack)
token = httpx.post(f"{stack.base}/api/v1/upload-preflight", json={}, timeout=TIMEOUT).json()[
"token"
]
created = httpx.post(
f"{stack.base}/api/v1/upload-batches", json={"token": token}, timeout=TIMEOUT
).json()["batches"][0]
with session_factory(stack.seeded) as sf: # what a killed worker leaves behind
with sf() as session:
session.get(UploadBatch, created["id"]).state = "running"
session.commit()
stack.restart_server() # the same port, so recovery runs in a genuinely fresh process
page.reload()
expect(page.get_by_test_id("detail-state")).to_have_text("unknown_requires_verification")
expect(page.get_by_test_id("batch-error")).to_contain_text("interrupted")
expect(page.get_by_test_id("uncertain")).to_be_visible()
expect(page.get_by_test_id("start-batch")).to_have_count(0)
expect(page.get_by_test_id("retry-blocked")).to_contain_text("requires_verification")

View File

@@ -0,0 +1,592 @@
"""Archive destinations and preflight (US06-01).
Archive is the only stage that removes originals, so every case here asks the same
question: would this preflight let an album leave active storage when it should
not? The destinations are real directories on real filesystems — mounted, missing,
swapped for another medium, read-only, or full — and preflight itself must stay
non-destructive: the library snapshot is asserted unchanged.
"""
import json
import os
import stat
import uuid
from datetime import datetime, timedelta, timezone
import pytest
from fastapi.testclient import TestClient
from photo_pipeline.api.app import create_app
from photo_pipeline.config import Config
from photo_pipeline.db import create_db_engine, create_session_factory, run_migrations
from photo_pipeline.jobs.domain_handlers import ARCHIVE_LOCK, UPLOAD_LOCK
from photo_pipeline.models import (
Asset,
RenameOperation,
RenamePlan,
UploadBatch,
UploadItem,
)
from photo_pipeline.services.archives import MARKER_NAME, ArchiveError, ArchiveService
from photo_pipeline.services.hashing import sha256_file
from photo_pipeline.services.jobs import JobService
pytestmark = pytest.mark.phase_f # part of the Phase F acceptance gate (US06-06)
NOW = datetime(2026, 1, 1, tzinfo=timezone.utc)
# ── environment ──────────────────────────────────────────────────────────────
def _env(tmp_path, *, reserve=0):
(tmp_path / "data").mkdir(exist_ok=True)
lib = tmp_path / "lib"
lib.mkdir(exist_ok=True)
archive = tmp_path / "archive"
archive.mkdir(exist_ok=True)
config = Config.from_env(
{
"PHOTO_PIPELINE_DATA_DIR": str(tmp_path / "data"),
"PHOTO_PIPELINE_LIBRARY_ROOTS": str(lib),
"PHOTO_PIPELINE_ARCHIVE_FREE_SPACE_RESERVE_BYTES": str(reserve),
}
)
run_migrations(config.database_url)
return config, create_session_factory(create_db_engine(config.database_url)), lib, archive
def _album(
sf,
lib,
album="rome",
names=("a.jpg", "b.jpg"),
*,
uploaded=True,
outcome="uploaded",
outcome_state="verified",
stale_bytes=False,
):
"""A real album folder whose assets carry their upload evidence."""
folder = lib / album
folder.mkdir(parents=True, exist_ok=True)
ids = []
with sf() as session:
batch_id = str(uuid.uuid4())
if uploaded:
session.add(
UploadBatch(
id=batch_id,
album=album,
folder=str(folder),
album_name=album,
state="succeeded",
preflight_token="v1:test",
outcome_state=outcome_state,
stale_bytes=stale_bytes,
created_at=NOW,
)
)
for name in names:
path = folder / name
path.write_bytes(name.encode() * 16)
asset_id = str(uuid.uuid4())
ids.append(asset_id)
session.add(
Asset(
id=asset_id,
original_path=str(path),
current_path=str(path),
discovered_at=NOW,
hash_version=1,
byte_size=path.stat().st_size,
current_sha256=sha256_file(path),
)
)
if uploaded:
session.add(
UploadItem(
batch_id=batch_id,
asset_id=asset_id,
path=str(path),
sha256=sha256_file(path),
sha1="0" * 40,
state="sent",
outcome=outcome,
)
)
session.commit()
return folder, ids
def _service(sf, config):
return ArchiveService(sf, config=config)
def _location(sf, config, archive, name="external"):
return _service(sf, config).register(name, str(archive))
def _snapshot(lib):
return {
str(p.relative_to(lib)): (p.read_bytes() if p.is_file() else None)
for p in sorted(lib.rglob("*"))
}
def _codes(report):
return (
{issue["code"] for issue in report["blockers"]}
| {issue["code"] for album in report["albums"] for issue in album["blockers"]}
| {
issue["code"]
for album in report["albums"]
for asset in album["assets"]
for issue in asset["blockers"]
}
)
# ── locations ────────────────────────────────────────────────────────────────
def test_registering_a_location_stamps_the_medium_with_its_identity(tmp_path):
config, sf, _, archive = _env(tmp_path)
location = _location(sf, config, archive)
marker = json.loads((archive / MARKER_NAME).read_text())
assert marker["media_id"] == location["media_id"]
assert location["state"] == "online" and location["writable"] is True
assert location["root"] == str(archive.resolve())
listed = _service(sf, config).locations()
assert [(row["id"], row["media_id"], row["state"]) for row in listed] == [
(location["id"], location["media_id"], "online")
]
def test_a_second_location_cannot_claim_the_same_medium(tmp_path):
config, sf, _, archive = _env(tmp_path)
_location(sf, config, archive)
with pytest.raises(ArchiveError) as error:
_location(sf, config, archive, name="second")
assert error.value.code == "already_registered"
@pytest.mark.parametrize("inside", ["", "sub"])
def test_a_destination_inside_the_library_is_refused(tmp_path, inside):
"""The library may never archive into itself: the 'reclaimed' bytes would still
be in the active tree, and a later scan would rediscover them."""
config, sf, lib, _ = _env(tmp_path)
root = lib / inside if inside else lib
root.mkdir(exist_ok=True)
with pytest.raises(ArchiveError) as error:
_service(sf, config).register("bad", str(root))
assert error.value.code == "unsafe_destination"
def test_an_ignored_destination_is_refused(tmp_path):
config, sf, _, _ = _env(tmp_path)
root = tmp_path / "_IGNORE" / "archive"
root.mkdir(parents=True)
with pytest.raises(ArchiveError) as error:
_service(sf, config).register("ignored", str(root))
assert error.value.code == "unsafe_destination"
def test_listing_reports_an_unmounted_medium_as_offline(tmp_path):
config, sf, _, archive = _env(tmp_path)
_location(sf, config, archive)
(archive / MARKER_NAME).unlink()
assert [row["state"] for row in _service(sf, config).locations()] == ["offline"]
# ── happy path ───────────────────────────────────────────────────────────────
def test_ready_preflight_previews_scope_method_and_reclaimable_bytes(tmp_path):
config, sf, lib, archive = _env(tmp_path)
folder, ids = _album(sf, lib)
location = _location(sf, config, archive)
before = _snapshot(lib)
report = _service(sf, config).preflight(location["id"])
assert report["state"] == "ready" and report["blockers"] == []
album = report["albums"][0]
assert album["album"] == "rome" and album["folder"] == str(folder)
assert album["destination"] == str(archive.resolve() / "rome")
assert album["transfer_method"] in ("move", "copy_verify_remove")
assert album["reclaimable_bytes"] == sum(p.stat().st_size for p in folder.iterdir())
assert sorted(a["asset_id"] for a in album["assets"]) == sorted(ids)
assert report["totals"]["bytes"] == album["reclaimable_bytes"]
assert report["capacity"]["sufficient"] is True
# Both must be proven by writing, not assumed.
assert report["backup"]["ok"] is True and report["backup"]["bytes"] > 0
assert report["manifest"]["ok"] is True
assert report["token"].startswith("v1:")
assert _snapshot(lib) == before, "preflight must not touch the library"
assert not list(archive.glob("*probe*")), "probe files must be cleaned up"
def test_same_filesystem_destination_is_previewed_as_a_move(tmp_path):
config, sf, lib, archive = _env(tmp_path)
_album(sf, lib)
location = _location(sf, config, archive)
report = _service(sf, config).preflight(location["id"])
# tmp_path is one filesystem, so this is the same-filesystem case by construction.
assert report["albums"][0]["transfer_method"] == "move"
def test_scoping_to_one_album_excludes_the_others(tmp_path):
config, sf, lib, archive = _env(tmp_path)
_album(sf, lib, "rome")
_album(sf, lib, "paris", names=("c.jpg",))
location = _location(sf, config, archive)
report = _service(sf, config).preflight(location["id"], ["paris"])
assert [album["album"] for album in report["albums"]] == ["paris"]
assert report["totals"]["assets"] == 1
def test_unknown_album_and_unknown_location_are_refused(tmp_path):
config, sf, lib, archive = _env(tmp_path)
_album(sf, lib)
location = _location(sf, config, archive)
service = _service(sf, config)
with pytest.raises(ArchiveError) as unknown_album:
service.preflight(location["id"], ["atlantis"])
with pytest.raises(ArchiveError) as unknown_location:
service.preflight("nope")
assert unknown_album.value.code == "unknown_album"
assert unknown_location.value.code == "unknown_location"
def test_empty_scope_is_a_blocker(tmp_path):
config, sf, _, archive = _env(tmp_path)
location = _location(sf, config, archive)
report = _service(sf, config).preflight(location["id"])
assert report["state"] == "blocked" and "empty_scope" in _codes(report)
# ── destination ──────────────────────────────────────────────────────────────
def test_offline_medium_blocks(tmp_path):
config, sf, lib, archive = _env(tmp_path)
_album(sf, lib)
location = _location(sf, config, archive)
(archive / MARKER_NAME).unlink() # the disk went away
report = _service(sf, config).preflight(location["id"])
assert report["state"] == "blocked" and "location_offline" in _codes(report)
assert report["location"]["state"] == "offline"
def test_a_different_medium_at_the_same_mountpoint_blocks(tmp_path):
"""The mountpoint is right, the disk is not — never write the archive here."""
config, sf, lib, archive = _env(tmp_path)
_album(sf, lib)
location = _location(sf, config, archive)
(archive / MARKER_NAME).write_text(json.dumps({"media_id": "some-other-disk"}))
report = _service(sf, config).preflight(location["id"])
assert "wrong_volume" in _codes(report)
assert report["location"]["state"] == "wrong_volume"
def test_read_only_destination_blocks_and_cannot_write_the_manifest(tmp_path):
config, sf, lib, archive = _env(tmp_path)
_album(sf, lib)
location = _location(sf, config, archive)
mode = archive.stat().st_mode
archive.chmod(mode & ~stat.S_IWUSR & ~stat.S_IWGRP & ~stat.S_IWOTH)
try:
report = _service(sf, config).preflight(location["id"])
finally:
archive.chmod(mode)
assert {"destination_not_writable", "manifest_unwritable"} <= _codes(report)
assert report["manifest"]["ok"] is False
@pytest.mark.skipif(os.geteuid() == 0, reason="root ignores directory permissions")
def test_read_only_destination_is_detected_by_a_real_write(tmp_path):
config, sf, lib, archive = _env(tmp_path)
_album(sf, lib)
location = _location(sf, config, archive)
mode = archive.stat().st_mode
archive.chmod(stat.S_IRUSR | stat.S_IXUSR)
try:
report = _service(sf, config).preflight(location["id"])
finally:
archive.chmod(mode)
assert report["location"]["writable"] is False
def test_insufficient_capacity_blocks(tmp_path):
"""The reserve is what stops an archive from filling its own destination."""
config, sf, lib, archive = _env(tmp_path, reserve=10**15)
_album(sf, lib)
location = _location(sf, config, archive)
report = _service(sf, config).preflight(location["id"])
assert report["state"] == "blocked" and "insufficient_capacity" in _codes(report)
assert report["capacity"]["sufficient"] is False
assert report["capacity"]["reserve_bytes"] == 10**15
def test_an_occupied_destination_blocks_that_album(tmp_path):
config, sf, lib, archive = _env(tmp_path)
_album(sf, lib)
location = _location(sf, config, archive)
(archive / "rome").mkdir()
(archive / "rome" / "a.jpg").write_bytes(b"something already here")
report = _service(sf, config).preflight(location["id"])
assert "destination_collision" in _codes(report)
assert report["albums"][0]["state"] == "blocked"
def test_a_destination_moved_into_the_library_blocks_even_though_it_registered(tmp_path):
"""Registration validated the root once; preflight validates it again, because a
mountpoint can be moved after the fact."""
config, sf, lib, archive = _env(tmp_path)
_album(sf, lib)
location = _location(sf, config, archive)
with sf() as session:
from photo_pipeline.models import ArchiveLocation
session.get(ArchiveLocation, location["id"]).root = str(lib / "inside")
session.commit()
(lib / "inside").mkdir()
(lib / "inside" / MARKER_NAME).write_text(json.dumps({"media_id": location["media_id"]}))
report = _service(sf, config).preflight(location["id"])
assert "unsafe_destination" in _codes(report)
# ── source readiness ─────────────────────────────────────────────────────────
@pytest.mark.parametrize(
"kwargs,code",
[
({"uploaded": False}, "upload_unverified"),
({"outcome_state": "requires_verification"}, "upload_unverified"),
({"outcome": "failed"}, "upload_unverified"),
({"outcome": "skipped"}, "upload_unverified"),
({"stale_bytes": True}, "upload_unverified"),
],
)
def test_an_unverified_upload_blocks_the_album(tmp_path, kwargs, code):
config, sf, lib, archive = _env(tmp_path)
_album(sf, lib, **kwargs)
location = _location(sf, config, archive)
report = _service(sf, config).preflight(location["id"])
assert report["state"] == "blocked" and code in _codes(report)
assert report["albums"][0]["blocked_count"] == 2
@pytest.mark.parametrize("outcome", ["upgraded", "duplicate"])
def test_upgraded_and_duplicate_uploads_are_evidence_enough(tmp_path, outcome):
"""Immich already holds these exact bytes; that is what archiving requires."""
config, sf, lib, archive = _env(tmp_path)
_album(sf, lib, outcome=outcome)
location = _location(sf, config, archive)
assert _service(sf, config).preflight(location["id"])["state"] == "ready"
def test_bytes_changed_since_upload_block_the_album(tmp_path):
config, sf, lib, archive = _env(tmp_path)
folder, _ = _album(sf, lib)
location = _location(sf, config, archive)
(folder / "a.jpg").write_bytes(b"edited after the upload")
report = _service(sf, config).preflight(location["id"])
assert {"bytes_changed", "partial_scope"} <= _codes(report)
assert report["albums"][0]["blocked_count"] == 1
def test_a_missing_source_file_blocks_the_album(tmp_path):
config, sf, lib, archive = _env(tmp_path)
folder, _ = _album(sf, lib)
location = _location(sf, config, archive)
(folder / "a.jpg").unlink()
assert "file_missing" in _codes(_service(sf, config).preflight(location["id"]))
# ── leases ───────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("lock", [UPLOAD_LOCK, ARCHIVE_LOCK])
def test_a_held_lease_blocks_archiving(tmp_path, lock):
config, sf, lib, archive = _env(tmp_path)
_album(sf, lib)
location = _location(sf, config, archive)
JobService(sf).enqueue("upload_batch", lock=lock, items=["x"])
report = _service(sf, config).preflight(location["id"])
assert report["state"] == "blocked" and "lock_conflict" in _codes(report)
def test_a_half_applied_rename_blocks_archiving(tmp_path):
config, sf, lib, archive = _env(tmp_path)
folder, _ = _album(sf, lib)
location = _location(sf, config, archive)
with sf() as session:
plan_id = str(uuid.uuid4())
session.add(RenamePlan(id=plan_id, state="applying", operation_count=1))
session.flush()
session.add(
RenameOperation(
id=str(uuid.uuid4()),
plan_id=plan_id,
sequence=0,
operation="move_folder",
source_path=str(folder),
destination_path=str(lib / "2019 Rome"),
journal_state="moving",
)
)
session.commit()
report = _service(sf, config).preflight(location["id"])
assert report["state"] == "blocked" and "rename_pending" in _codes(report)
# ── token ────────────────────────────────────────────────────────────────────
def test_token_is_stable_while_nothing_relevant_changes(tmp_path):
config, sf, lib, archive = _env(tmp_path)
_album(sf, lib)
location = _location(sf, config, archive)
service = _service(sf, config)
first = service.preflight(location["id"])["token"]
assert service.preflight(location["id"])["token"] == first
assert service.verify_token(first, location["id"]) is True
def test_an_edited_source_makes_the_token_stale(tmp_path):
config, sf, lib, archive = _env(tmp_path)
folder, _ = _album(sf, lib)
location = _location(sf, config, archive)
service = _service(sf, config)
token = service.preflight(location["id"])["token"]
(folder / "b.jpg").write_bytes(b"edited outside the app")
assert service.verify_token(token, location["id"]) is False
def test_a_changed_destination_makes_the_token_stale(tmp_path):
config, sf, lib, archive = _env(tmp_path)
_album(sf, lib)
location = _location(sf, config, archive)
service = _service(sf, config)
token = service.preflight(location["id"])["token"]
(archive / "rome").mkdir()
(archive / "rome" / "a.jpg").write_bytes(b"appeared after approval")
assert service.verify_token(token, location["id"]) is False
def test_a_token_from_another_scope_or_medium_is_rejected(tmp_path):
config, sf, lib, archive = _env(tmp_path)
_album(sf, lib, "rome")
_album(sf, lib, "paris", names=("c.jpg",))
other = tmp_path / "archive2"
other.mkdir()
location = _location(sf, config, archive)
second = _location(sf, config, other, name="second")
service = _service(sf, config)
rome = service.preflight(location["id"], ["rome"])["token"]
assert service.verify_token(rome, location["id"], ["paris"]) is False
assert service.verify_token(rome, second["id"], ["rome"]) is False
assert service.verify_token("v1:not-a-real-token", location["id"]) is False
assert service.verify_token("", location["id"]) is False
def test_the_token_survives_free_space_and_timestamp_drift(tmp_path):
"""Free space changes constantly on a live disk; a token that expired on every
byte written elsewhere would train users to ignore it."""
config, sf, lib, archive = _env(tmp_path)
_album(sf, lib)
location = _location(sf, config, archive)
service = _service(sf, config)
token = service.preflight(location["id"])["token"]
(tmp_path / "unrelated.bin").write_bytes(b"0" * 100_000)
with sf() as session:
from photo_pipeline.models import ArchiveLocation
session.get(ArchiveLocation, location["id"]).last_seen_at = NOW - timedelta(days=5)
session.commit()
assert service.verify_token(token, location["id"]) is True
# ── API surface ──────────────────────────────────────────────────────────────
def test_api_registers_a_location_and_returns_a_preflight_report(tmp_path):
config, sf, lib, archive = _env(tmp_path)
_album(sf, lib)
with TestClient(create_app(config)) as client:
created = client.post(
"/api/v1/archive-locations", json={"name": "external", "root": str(archive)}
)
listed = client.get("/api/v1/archive-locations")
report = client.post(
"/api/v1/archive-preflight", json={"location_id": created.json()["id"]}
)
assert created.status_code == 201
assert [row["name"] for row in listed.json()["locations"]] == ["external"]
assert report.status_code == 200
assert report.json()["state"] == "ready" and report.json()["token"].startswith("v1:")
def test_api_rejects_an_unknown_location_and_an_unsafe_root(tmp_path):
config, sf, lib, _ = _env(tmp_path)
with TestClient(create_app(config)) as client:
unknown = client.post("/api/v1/archive-preflight", json={"location_id": "nope"})
unsafe = client.post("/api/v1/archive-locations", json={"name": "bad", "root": str(lib)})
assert unknown.status_code == 404 and unknown.json()["error"]["code"] == "unknown_location"
assert unsafe.status_code == 422 and unsafe.json()["error"]["code"] == "unsafe_destination"

View File

@@ -0,0 +1,313 @@
"""Recovering interrupted archive transfers (US06-02).
The crash tests are real: a child process applies an archive plan and is killed by
the ``PHOTO_PIPELINE_FAULT_AFTER`` barrier at each persisted transition in turn —
including the moment immediately after an active source has been unlinked. The
parent then reopens the database and asserts the one invariant archiving exists to
uphold: **no verified file is ever lost, and no source is removed without a durable,
byte-identical archive copy.**
Both transfer paths are exercised: the same-filesystem atomic move and (with the
child forcing the device comparison) the cross-filesystem copy/verify/publish.
"""
import os
import subprocess
import sys
import uuid
from datetime import datetime, timezone
from pathlib import Path
import pytest
from sqlalchemy import select
from photo_pipeline.config import Config
from photo_pipeline.db import create_db_engine, create_session_factory, run_migrations
from photo_pipeline.models import Asset, UploadBatch, UploadItem
from photo_pipeline.services.archive_journal import FORWARD, MANUAL, RESUMABLE, ArchiveState
from photo_pipeline.services.archive_transfer import (
MANIFEST_NAME,
ArchiveTransferService,
read_manifest,
)
from photo_pipeline.services.archives import ArchiveService
from photo_pipeline.services.hashing import sha256_file
pytestmark = pytest.mark.phase_f
REPO = Path(__file__).resolve().parents[2]
NOW = datetime(2026, 1, 1, tzinfo=timezone.utc)
# Barriers, in the order the transfer persists them. ``source_removed`` is the one
# that matters most: the original is already gone at that point.
BARRIERS = [
ArchiveState.TRANSFERRING,
ArchiveState.VERIFIED,
ArchiveState.REMOVING,
"source_removed",
ArchiveState.COMPLETE,
]
# A child that applies the plan and dies at the configured barrier. ``force_copy``
# makes it take the cross-filesystem path without a second real volume.
APPLY_SCRIPT = """
import sys
sys.path.insert(0, {repo!r})
from photo_pipeline.config import Config
from photo_pipeline.db import create_db_engine, create_session_factory
from photo_pipeline.services import archive_transfer
db_url, data_dir, lib, plan_id, force_copy = sys.argv[1:6]
if force_copy == "1":
archive_transfer._same_filesystem = lambda *args: False
config = Config.from_env(
{{"PHOTO_PIPELINE_DATA_DIR": data_dir, "PHOTO_PIPELINE_LIBRARY_ROOTS": lib}}
)
sf = create_session_factory(create_db_engine(db_url))
archive_transfer.ArchiveTransferService(sf, config=config).apply(plan_id)
"""
# ── environment ──────────────────────────────────────────────────────────────
def _env(tmp_path):
(tmp_path / "data").mkdir(exist_ok=True)
lib = tmp_path / "lib"
lib.mkdir(exist_ok=True)
archive = tmp_path / "archive"
archive.mkdir(exist_ok=True)
config = Config.from_env(
{
"PHOTO_PIPELINE_DATA_DIR": str(tmp_path / "data"),
"PHOTO_PIPELINE_LIBRARY_ROOTS": str(lib),
"PHOTO_PIPELINE_ARCHIVE_FREE_SPACE_RESERVE_BYTES": "0",
}
)
run_migrations(config.database_url)
return config, create_session_factory(create_db_engine(config.database_url)), lib, archive
def _album(sf, lib, album="rome", names=("a.jpg", "b.jpg")):
folder = lib / album
folder.mkdir(parents=True, exist_ok=True)
with sf() as session:
batch_id = str(uuid.uuid4())
session.add(
UploadBatch(
id=batch_id,
album=album,
folder=str(folder),
album_name=album,
state="succeeded",
preflight_token="v1:test",
outcome_state="verified",
created_at=NOW,
)
)
for name in names:
path = folder / name
path.write_bytes(f"{album}/{name} content".encode() * 8)
asset_id = str(uuid.uuid4())
session.add(
Asset(
id=asset_id,
original_path=str(path),
current_path=str(path),
discovered_at=NOW,
hash_version=1,
byte_size=path.stat().st_size,
current_sha256=sha256_file(path),
)
)
session.add(
UploadItem(
batch_id=batch_id,
asset_id=asset_id,
path=str(path),
sha256=sha256_file(path),
sha1="0" * 40,
state="sent",
outcome="uploaded",
)
)
session.commit()
return folder
def _plan(sf, config, archive, albums=None):
location = ArchiveService(sf, config=config).register("external", str(archive))
token = ArchiveService(sf, config=config).preflight(location["id"], albums)["token"]
service = ArchiveTransferService(sf, config=config)
return service, service.create(location["id"], albums, token=token)
def _crash_during_apply(config, tmp_path, lib, plan, barrier, *, force_copy=False):
script = tmp_path / f"apply_{barrier}.py"
script.write_text(APPLY_SCRIPT.format(repo=str(REPO)))
env = dict(os.environ)
env["PHOTO_PIPELINE_FAULT_AFTER"] = barrier
result = subprocess.run(
[
sys.executable,
str(script),
config.database_url,
str(tmp_path / "data"),
str(lib),
plan["id"],
"1" if force_copy else "0",
],
env=env,
capture_output=True,
)
assert result.returncode in (9, -9), (
f"child should have been killed at {barrier}, got {result.returncode}: "
f"{result.stderr.decode(errors='replace')[-400:]}"
)
def _contents(*roots):
return sorted(
path.read_bytes()
for root in roots
for path in root.rglob("*")
if path.is_file() and path.name != MANIFEST_NAME and not path.name.startswith(".")
)
def _reopen(config):
return create_session_factory(create_db_engine(config.database_url))
# ── fault injection at every persisted transition ────────────────────────────
@pytest.mark.parametrize("barrier", BARRIERS)
@pytest.mark.parametrize("force_copy", [False, True], ids=["move", "copy"])
def test_a_crash_at_every_transition_loses_nothing_and_recovers(tmp_path, barrier, force_copy):
config, sf, lib, archive = _env(tmp_path)
_album(sf, lib)
service, plan = _plan(sf, config, archive)
before = _contents(lib)
_crash_during_apply(config, tmp_path, lib, plan, barrier, force_copy=force_copy)
# Every file still exists somewhere: the crash may not have cost a single byte.
assert set(before) <= set(_contents(lib, archive)), f"content lost at {barrier}"
reopened = _reopen(config)
recovery = ArchiveTransferService(reopened, config=config)
# Whatever the crash interrupted is recoverable from evidence — never ambiguous.
# (A crash on the last barrier can land on a terminal item and leave none.)
verdicts = recovery.journal.classify_all()
assert all(v["classification"] in {RESUMABLE, FORWARD} for v in verdicts), verdicts
recovery.recover()
recovery.apply(plan["id"]) # finish whatever the crash never started
assert _contents(archive) == before
assert _contents(lib) == []
assert recovery.journal.blocks_mutation() is False
assert recovery.journal.plan_state(plan["id"]) == "complete"
@pytest.mark.parametrize("barrier", [ArchiveState.VERIFIED, "source_removed"])
def test_recovery_is_idempotent_across_repeated_restarts(tmp_path, barrier):
config, sf, lib, archive = _env(tmp_path)
_album(sf, lib)
service, plan = _plan(sf, config, archive)
_crash_during_apply(config, tmp_path, lib, plan, barrier)
reopened = _reopen(config)
recovery = ArchiveTransferService(reopened, config=config)
recovery.recover()
settled = (_contents(lib), _contents(archive), read_manifest(archive / "rome"))
for _ in range(2):
recovery.recover()
assert (_contents(lib), _contents(archive), read_manifest(archive / "rome")) == settled
def test_a_crash_before_anything_was_published_leaves_the_source_intact(tmp_path):
config, sf, lib, archive = _env(tmp_path)
folder = _album(sf, lib, names=("a.jpg",))
service, plan = _plan(sf, config, archive)
_crash_during_apply(config, tmp_path, lib, plan, ArchiveState.TRANSFERRING)
recovery = ArchiveTransferService(_reopen(config), config=config)
verdict = recovery.journal.classify_all()[0]
assert verdict["classification"] == RESUMABLE
assert (folder / "a.jpg").exists()
assert not (archive / "rome" / "a.jpg").exists()
assert recovery.recover() == {"resumed": 1, "completed": 0, "manual": 0}
assert recovery.journal.operations(plan["id"])[0]["journal_state"] == ArchiveState.PLANNED
def test_a_crash_after_the_source_was_removed_finishes_the_bookkeeping(tmp_path):
"""The dangerous window: the original is gone and the database still points at
it. Recovery must complete the record, never re-transfer or report loss."""
config, sf, lib, archive = _env(tmp_path)
folder = _album(sf, lib, names=("a.jpg",))
service, plan = _plan(sf, config, archive)
expected = service.journal.operations(plan["id"])[0]["expected_sha256"]
_crash_during_apply(config, tmp_path, lib, plan, "source_removed")
reopened = _reopen(config)
recovery = ArchiveTransferService(reopened, config=config)
verdict = recovery.journal.classify_all()[0]
assert verdict["classification"] == FORWARD
assert not (folder / "a.jpg").exists()
assert sha256_file(archive / "rome" / "a.jpg") == expected
with reopened() as session:
stranded = session.scalar(select(Asset))
assert stranded.current_path is not None, "the crash happened before the DB update"
assert recovery.recover() == {"resumed": 0, "completed": 1, "manual": 0}
with reopened() as session:
asset = session.scalar(select(Asset))
assert asset.current_path is None
assert asset.availability_state == "archived_online"
assert asset.archive_path == "rome/a.jpg"
assert recovery.journal.operations(plan["id"])[0]["journal_state"] == ArchiveState.COMPLETE
assert len(read_manifest(archive / "rome")) == 1, "the manifest is not duplicated"
def test_an_archive_copy_that_changed_after_the_crash_blocks_for_a_human(tmp_path):
"""Wrong bytes at the destination can never justify deleting the original, and
are never overwritten either."""
config, sf, lib, archive = _env(tmp_path)
folder = _album(sf, lib, names=("a.jpg",))
service, plan = _plan(sf, config, archive)
_crash_during_apply(config, tmp_path, lib, plan, ArchiveState.VERIFIED, force_copy=True)
(archive / "rome" / "a.jpg").write_bytes(b"tampered with while the app was down")
recovery = ArchiveTransferService(_reopen(config), config=config)
verdict = recovery.journal.classify_all()[0]
assert verdict["classification"] == MANUAL
assert recovery.recover() == {"resumed": 0, "completed": 0, "manual": 1}
assert (folder / "a.jpg").exists(), "the source is kept while the archive is unproven"
assert (archive / "rome" / "a.jpg").read_bytes() == b"tampered with while the app was down"
# And the unresolved item keeps blocking further archiving.
assert recovery.journal.blocks_mutation() is True
report = ArchiveService(_reopen(config), config=config).preflight(plan["location_id"])
assert "archive_pending" in {issue["code"] for issue in report["blockers"]}
def test_a_verified_archive_whose_copy_vanished_is_never_reported_as_archived(tmp_path):
config, sf, lib, archive = _env(tmp_path)
folder = _album(sf, lib, names=("a.jpg",))
service, plan = _plan(sf, config, archive)
_crash_during_apply(config, tmp_path, lib, plan, ArchiveState.REMOVING, force_copy=True)
(archive / "rome" / "a.jpg").unlink() # the medium lost it
recovery = ArchiveTransferService(_reopen(config), config=config)
assert recovery.journal.classify_all()[0]["classification"] == MANUAL
assert recovery.recover()["manual"] == 1
assert (folder / "a.jpg").exists()
assert recovery.journal.operations(plan["id"])[0]["journal_state"] != ArchiveState.COMPLETE

View File

@@ -0,0 +1,480 @@
"""Transferring, verifying, and removing active sources (US06-02).
Every case here asks the same question the service exists to answer: could an
original leave active storage without a durable, byte-identical archive copy? The
files are real, the hashes are real, and each failure path asserts that the source
is still exactly where it was.
Cross-filesystem behaviour is forced by patching the device comparison rather than
by requiring a second real filesystem in CI — the copy/verify/publish code that runs
is the production one.
"""
import json
import uuid
from datetime import datetime, timezone
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from photo_pipeline.api.app import create_app
from photo_pipeline.config import Config
from photo_pipeline.db import create_db_engine, create_session_factory, run_migrations
from photo_pipeline.models import Asset, AssetPath, UploadBatch, UploadItem
from photo_pipeline.services import archive_transfer as transfer_module
from photo_pipeline.services.archive_journal import ArchiveState
from photo_pipeline.services.archive_transfer import (
MANIFEST_NAME,
ArchiveTransferService,
read_manifest,
)
from photo_pipeline.services.archives import ArchiveError, ArchiveService
from photo_pipeline.services.hashing import sha256_file
pytestmark = pytest.mark.phase_f # part of the Phase F acceptance gate (US06-06)
NOW = datetime(2026, 1, 1, tzinfo=timezone.utc)
# ── environment ──────────────────────────────────────────────────────────────
def _env(tmp_path):
(tmp_path / "data").mkdir(exist_ok=True)
lib = tmp_path / "lib"
lib.mkdir(exist_ok=True)
archive = tmp_path / "archive"
archive.mkdir(exist_ok=True)
config = Config.from_env(
{
"PHOTO_PIPELINE_DATA_DIR": str(tmp_path / "data"),
"PHOTO_PIPELINE_LIBRARY_ROOTS": str(lib),
"PHOTO_PIPELINE_ARCHIVE_FREE_SPACE_RESERVE_BYTES": "0",
}
)
run_migrations(config.database_url)
return config, create_session_factory(create_db_engine(config.database_url)), lib, archive
def _album(sf, lib, album="rome", names=("a.jpg", "b.jpg")):
"""A real album whose assets carry the verified upload evidence archiving needs."""
folder = lib / album
folder.mkdir(parents=True, exist_ok=True)
ids = []
with sf() as session:
batch_id = str(uuid.uuid4())
session.add(
UploadBatch(
id=batch_id,
album=album,
folder=str(folder),
album_name=album,
state="succeeded",
preflight_token="v1:test",
outcome_state="verified",
created_at=NOW,
)
)
for name in names:
path = folder / name
path.write_bytes(f"{album}/{name} bytes".encode() * 8)
asset_id = str(uuid.uuid4())
ids.append(asset_id)
session.add(
Asset(
id=asset_id,
original_path=str(path),
current_path=str(path),
discovered_at=NOW,
hash_version=1,
byte_size=path.stat().st_size,
current_sha256=sha256_file(path),
)
)
session.add(AssetPath(asset_id=asset_id, path=str(path), valid_from=NOW))
session.add(
UploadItem(
batch_id=batch_id,
asset_id=asset_id,
path=str(path),
sha256=sha256_file(path),
sha1="0" * 40,
state="sent",
outcome="uploaded",
)
)
session.commit()
return folder, ids
def _location(sf, config, archive, name="external"):
return ArchiveService(sf, config=config).register(name, str(archive))
def _plan(sf, config, location_id, albums=None):
service = ArchiveTransferService(sf, config=config)
token = ArchiveService(sf, config=config).preflight(location_id, albums)["token"]
return service, service.create(location_id, albums, token=token)
def _contents(*roots):
"""Every file's bytes under the given roots, ignoring our own bookkeeping."""
return sorted(
path.read_bytes()
for root in roots
for path in root.rglob("*")
if path.is_file() and path.name != MANIFEST_NAME and not path.name.startswith(".")
)
def _states(service, plan_id):
return [row["journal_state"] for row in service.journal.operations(plan_id)]
def _asset(sf, asset_id):
with sf() as session:
return session.get(Asset, asset_id)
# ── planning ─────────────────────────────────────────────────────────────────
def test_a_plan_records_every_file_with_its_expected_hash_and_moves_nothing(tmp_path):
config, sf, lib, archive = _env(tmp_path)
folder, ids = _album(sf, lib)
location = _location(sf, config, archive)
before = _contents(lib)
service, plan = _plan(sf, config, location["id"])
assert plan["state"] == "planned" and plan["asset_count"] == 2
assert sorted(op["asset_id"] for op in plan["operations"]) == sorted(ids)
for operation in plan["operations"]:
source = folder / operation["archive_path"].split("/")[-1]
assert operation["expected_sha256"] == sha256_file(source)
assert operation["archive_path"].startswith("rome/")
assert operation["journal_state"] == ArchiveState.PLANNED
assert _contents(lib) == before
assert _contents(archive) == []
def test_a_stale_token_cannot_create_a_plan(tmp_path):
config, sf, lib, archive = _env(tmp_path)
folder, _ = _album(sf, lib)
location = _location(sf, config, archive)
token = ArchiveService(sf, config=config).preflight(location["id"])["token"]
(folder / "a.jpg").write_bytes(b"edited after approval")
with pytest.raises(ArchiveError) as error:
ArchiveTransferService(sf, config=config).create(location["id"], token=token)
assert error.value.code == "stale_token"
def test_a_blocked_scope_cannot_create_a_plan(tmp_path):
"""No verified upload means Immich may not hold these bytes; archiving would
remove the only copy."""
config, sf, lib, archive = _env(tmp_path)
folder = lib / "rome"
folder.mkdir()
path = folder / "a.jpg"
path.write_bytes(b"never uploaded")
with sf() as session:
session.add(
Asset(
id=str(uuid.uuid4()),
original_path=str(path),
current_path=str(path),
discovered_at=NOW,
hash_version=1,
byte_size=path.stat().st_size,
current_sha256=sha256_file(path),
)
)
session.commit()
location = _location(sf, config, archive)
token = ArchiveService(sf, config=config).preflight(location["id"])["token"]
with pytest.raises(ArchiveError) as error:
ArchiveTransferService(sf, config=config).create(location["id"], token=token)
assert error.value.code == "blocked"
assert path.exists()
# ── the transfer ─────────────────────────────────────────────────────────────
@pytest.mark.parametrize("same_filesystem", [True, False])
def test_archiving_verifies_the_copy_before_the_source_is_removed(
tmp_path, monkeypatch, same_filesystem
):
config, sf, lib, archive = _env(tmp_path)
folder, ids = _album(sf, lib)
location = _location(sf, config, archive)
service, plan = _plan(sf, config, location["id"])
expected = {op["asset_id"]: op["expected_sha256"] for op in plan["operations"]}
before = _contents(lib)
if not same_filesystem:
# Force the copy-verify-publish path without needing a second real volume.
monkeypatch.setattr(transfer_module, "_same_filesystem", lambda *_: False)
result = service.apply(plan["id"])
assert result == {
"plan_id": plan["id"],
"archived": 2,
"failed": 0,
"skipped": 0,
"state": "complete",
}
# The bytes moved: nothing is left in the library, everything is in the archive.
assert _contents(archive) == before
assert _contents(lib) == []
assert not folder.exists(), "an emptied album folder is not left behind"
for asset_id, digest in expected.items():
archived = archive / "rome" / _asset(sf, asset_id).archive_path.split("/")[-1]
assert sha256_file(archived) == digest
assert _states(service, plan["id"]) == [ArchiveState.COMPLETE] * 2
# No transfer temporaries survive either path.
assert not list((archive / "rome").glob(".archive-*"))
@pytest.mark.parametrize("same_filesystem", [True, False])
def test_the_manifest_on_the_medium_matches_the_archived_bytes(
tmp_path, monkeypatch, same_filesystem
):
"""The manifest is the medium's own record: it must be usable to verify the
archive with no database at all."""
config, sf, lib, archive = _env(tmp_path)
_album(sf, lib)
location = _location(sf, config, archive)
service, plan = _plan(sf, config, location["id"])
if not same_filesystem:
monkeypatch.setattr(transfer_module, "_same_filesystem", lambda *_: False)
service.apply(plan["id"])
entries = read_manifest(archive / "rome")
assert len(entries) == 2
for entry in entries:
archived = archive / entry["archive_path"]
assert sha256_file(archived) == entry["sha256"]
assert entry["byte_size"] == archived.stat().st_size
assert entry["media_id"] == location["media_id"]
assert entry["plan_id"] == plan["id"] and entry["album"] == "rome"
def test_archived_assets_keep_their_identity_and_gain_their_new_location(tmp_path):
config, sf, lib, archive = _env(tmp_path)
folder, ids = _album(sf, lib)
location = _location(sf, config, archive)
service, plan = _plan(sf, config, location["id"])
service.apply(plan["id"])
with sf() as session:
for asset_id in ids:
asset = session.get(Asset, asset_id)
assert asset is not None, "archiving never deletes the record"
assert asset.current_path is None
assert asset.availability_state == "archived_online"
assert asset.archive_location_id == location["id"]
assert (archive / asset.archive_path).exists()
occurrences = session.scalars(
select(AssetPath).where(AssetPath.asset_id == asset_id)
).all()
active = [row for row in occurrences if row.valid_until is None]
assert [row.path for row in active] == [str(archive / asset.archive_path)]
closed = [row for row in occurrences if row.valid_until is not None]
assert [row.path for row in closed] == [str(folder / asset.archive_path.split("/")[-1])]
def test_applying_the_same_plan_again_changes_nothing(tmp_path):
config, sf, lib, archive = _env(tmp_path)
_album(sf, lib)
location = _location(sf, config, archive)
service, plan = _plan(sf, config, location["id"])
service.apply(plan["id"])
archived = _contents(archive)
manifest = read_manifest(archive / "rome")
again = service.apply(plan["id"])
assert again["skipped"] == 2 and again["archived"] == 0 and again["failed"] == 0
assert _contents(archive) == archived
assert read_manifest(archive / "rome") == manifest
# ── unexpected changes stop the item ─────────────────────────────────────────
def test_an_occupied_destination_is_never_overwritten(tmp_path):
config, sf, lib, archive = _env(tmp_path)
folder, _ = _album(sf, lib, names=("a.jpg",))
location = _location(sf, config, archive)
service, plan = _plan(sf, config, location["id"])
(archive / "rome").mkdir()
(archive / "rome" / "a.jpg").write_bytes(b"someone else's file")
result = service.apply(plan["id"])
assert result["failed"] == 1 and result["state"] == "failed"
assert (archive / "rome" / "a.jpg").read_bytes() == b"someone else's file"
assert (folder / "a.jpg").exists(), "the source must survive a refused transfer"
assert service.journal.operations(plan["id"])[0]["error_code"] == "destination_exists"
def test_a_source_edited_after_planning_is_neither_archived_nor_removed(tmp_path):
config, sf, lib, archive = _env(tmp_path)
folder, _ = _album(sf, lib, names=("a.jpg",))
location = _location(sf, config, archive)
service, plan = _plan(sf, config, location["id"])
(folder / "a.jpg").write_bytes(b"edited between approval and apply")
result = service.apply(plan["id"])
assert result["failed"] == 1
assert (folder / "a.jpg").read_bytes() == b"edited between approval and apply"
assert not (archive / "rome" / "a.jpg").exists()
assert service.journal.operations(plan["id"])[0]["error_code"] == "source_changed"
def test_a_copy_that_lands_with_the_wrong_bytes_is_not_published(tmp_path, monkeypatch):
"""The read-back hash — not the fact that a write returned — is what proves the
archive copy."""
config, sf, lib, archive = _env(tmp_path)
folder, _ = _album(sf, lib, names=("a.jpg",))
location = _location(sf, config, archive)
service, plan = _plan(sf, config, location["id"])
monkeypatch.setattr(transfer_module, "_same_filesystem", lambda *_: False)
monkeypatch.setattr(
transfer_module.shutil,
"copyfileobj",
lambda src, dst, length=0: dst.write(b"corrupted in flight"),
)
result = service.apply(plan["id"])
assert result["failed"] == 1
assert service.journal.operations(plan["id"])[0]["error_code"] == "copy_mismatch"
assert (folder / "a.jpg").exists()
assert not (archive / "rome" / "a.jpg").exists()
assert not list((archive / "rome").glob(".archive-*")), "the failed copy is cleaned up"
def test_a_source_replaced_by_a_symlink_is_refused(tmp_path):
config, sf, lib, archive = _env(tmp_path)
folder, _ = _album(sf, lib, names=("a.jpg",))
location = _location(sf, config, archive)
service, plan = _plan(sf, config, location["id"])
elsewhere = tmp_path / "elsewhere.jpg"
elsewhere.write_bytes(b"not a library file")
(folder / "a.jpg").unlink()
(folder / "a.jpg").symlink_to(elsewhere)
result = service.apply(plan["id"])
assert result["failed"] == 1
assert elsewhere.exists() and (folder / "a.jpg").is_symlink()
assert not (archive / "rome" / "a.jpg").exists()
def test_one_failed_file_does_not_stop_the_rest_of_the_album(tmp_path):
config, sf, lib, archive = _env(tmp_path)
folder, _ = _album(sf, lib, names=("a.jpg", "b.jpg"))
location = _location(sf, config, archive)
service, plan = _plan(sf, config, location["id"])
(folder / "a.jpg").write_bytes(b"edited after approval")
result = service.apply(plan["id"])
assert (result["archived"], result["failed"]) == (1, 1)
assert (folder / "a.jpg").exists() and not (folder / "b.jpg").exists()
assert (archive / "rome" / "b.jpg").exists()
assert folder.exists(), "a folder that still holds an unarchived file stays"
def test_an_unresolved_transfer_blocks_the_next_preflight(tmp_path, monkeypatch):
config, sf, lib, archive = _env(tmp_path)
_album(sf, lib, "rome", names=("a.jpg",))
_album(sf, lib, "paris", names=("c.jpg",))
location = _location(sf, config, archive)
service, plan = _plan(sf, config, location["id"], ["rome"])
# Leave one item stuck mid-transfer, as a killed process would.
service.journal.begin(
service.journal.operations(plan["id"])[0]["id"], worker_id="w", fencing_token=1
)
report = ArchiveService(sf, config=config).preflight(location["id"], ["paris"])
assert report["state"] == "blocked"
assert "archive_pending" in {issue["code"] for issue in report["blockers"]}
# ── API surface ──────────────────────────────────────────────────────────────
def test_api_creates_a_plan_and_queues_it_on_the_archiver_lane(tmp_path):
config, sf, lib, archive = _env(tmp_path)
_album(sf, lib)
with TestClient(create_app(config)) as client:
location = client.post(
"/api/v1/archive-locations", json={"name": "external", "root": str(archive)}
).json()
token = client.post(
"/api/v1/archive-preflight", json={"location_id": location["id"]}
).json()["token"]
created = client.post(
"/api/v1/archive-plans", json={"location_id": location["id"], "token": token}
)
plan_id = created.json()["id"]
fetched = client.get(f"/api/v1/archive-plans/{plan_id}")
applied = client.post(f"/api/v1/archive-plans/{plan_id}/apply")
listed = client.get("/api/v1/archive-plans")
assert created.status_code == 201 and created.json()["asset_count"] == 2
assert fetched.status_code == 200 and len(fetched.json()["operations"]) == 2
assert applied.status_code == 200 and applied.json()["job"]["state"] == "queued"
assert applied.json()["job"]["lock_key"] == "archive"
assert [row["id"] for row in listed.json()["plans"]] == [plan_id]
# Queuing alone must not have touched a single file.
assert _contents(lib) and _contents(archive) == []
def test_api_refuses_a_stale_token_and_an_unknown_plan(tmp_path):
config, sf, lib, archive = _env(tmp_path)
_album(sf, lib)
location = _location(sf, config, archive)
with TestClient(create_app(config)) as client:
stale = client.post(
"/api/v1/archive-plans",
json={"location_id": location["id"], "token": "v1:not-the-real-token"},
)
unknown = client.get("/api/v1/archive-plans/nope")
unknown_apply = client.post("/api/v1/archive-plans/nope/apply")
assert stale.status_code == 409 and stale.json()["error"]["code"] == "stale_token"
assert unknown.status_code == 404 and unknown_apply.status_code == 404
def test_api_reports_recovery_state_for_an_interrupted_transfer(tmp_path):
config, sf, lib, archive = _env(tmp_path)
_album(sf, lib, names=("a.jpg",))
location = _location(sf, config, archive)
service, plan = _plan(sf, config, location["id"])
service.journal.begin(
service.journal.operations(plan["id"])[0]["id"], worker_id="w", fencing_token=1
)
with TestClient(create_app(config)) as client:
status = client.get("/api/v1/archive-recovery").json()
resolved = client.post("/api/v1/archive-recovery/resolve").json()
assert status["blocks_mutation"] is True
assert status["operations"][0]["classification"] == "resumable"
assert resolved == {"resumed": 1, "completed": 0, "manual": 0}
assert json.loads(json.dumps(resolved)) # plain JSON, nothing exotic

View File

@@ -167,7 +167,8 @@ def test_missing_file_is_flagged_not_deleted(
assets = assets_by_id(make_factory(db_url))
assert missing_id in assets # not pruned
assert assets[missing_id].missing_at is not None
assert assets[missing_id].availability_state == "active"
# Nothing explains the absence: this is not an offline archive medium (US06-03).
assert assets[missing_id].availability_state == "missing_unexpected"
def test_reappearing_file_clears_missing(
@@ -186,6 +187,7 @@ def test_reappearing_file_clears_missing(
inventory.scan(lib)
assets = assets_by_id(make_factory(db_url))
assert assets[asset_id].missing_at is None
assert assets[asset_id].availability_state == "active"
def test_identity_and_state_durable_across_restart(

View File

@@ -0,0 +1,377 @@
"""Offline identity and review evidence (US06-03).
An archived photo is not gone: it keeps its identity, its hashes, and enough
evidence to be recognised in a duplicate cluster while its medium sits in a
drawer. Every case here archives a *real* album through the real transfer, then
takes the medium away by removing its marker — the same thing the service sees
when an external disk is unplugged — and asks whether the application still tells
the truth about where the bytes are.
The distinction that matters throughout: an unmounted medium is
``archived_offline`` (expected, harmless), a mounted medium with a hole in it is
``missing_unexpected`` (needs a human). Confusing the two is how an archive
quietly loses a photo.
"""
import shutil
import uuid
from datetime import datetime, timezone
import numpy as np
import pytest
from fastapi.testclient import TestClient
from PIL import Image
from sqlalchemy import select
from photo_pipeline.api.app import create_app
from photo_pipeline.config import Config
from photo_pipeline.db import create_db_engine, create_session_factory, run_migrations
from photo_pipeline.models import Asset, Thumbnail, UploadBatch, UploadItem
from photo_pipeline.services.archive_transfer import ArchiveTransferService
from photo_pipeline.services.archives import MARKER_NAME, ArchiveService
from photo_pipeline.services.availability import (
ACTIVE,
ARCHIVED_OFFLINE,
ARCHIVED_ONLINE,
MISSING_UNEXPECTED,
)
from photo_pipeline.services.duplicates import ClusterState, DuplicateService, Method
from photo_pipeline.services.hashing import sha256_file
from photo_pipeline.services.inventory import InventoryService
from photo_pipeline.services.thumbnails import PROTECTED_SIZE, ThumbnailService, ThumbnailUnavailable
pytestmark = pytest.mark.phase_f # part of the Phase F acceptance gate (US06-06)
NOW = datetime(2026, 1, 1, tzinfo=timezone.utc)
# ── environment ──────────────────────────────────────────────────────────────
def _env(tmp_path):
(tmp_path / "data").mkdir(exist_ok=True)
lib = tmp_path / "lib"
lib.mkdir(exist_ok=True)
archive = tmp_path / "archive"
archive.mkdir(exist_ok=True)
config = Config.from_env(
{
"PHOTO_PIPELINE_DATA_DIR": str(tmp_path / "data"),
"PHOTO_PIPELINE_LIBRARY_ROOTS": str(lib),
"PHOTO_PIPELINE_ARCHIVE_FREE_SPACE_RESERVE_BYTES": "0",
}
)
run_migrations(config.database_url)
return config, create_session_factory(create_db_engine(config.database_url)), lib, archive
def structured(path, seed, size=(256, 192)):
"""A deterministic, decodable photo — previews and pHashes must be real."""
path.parent.mkdir(parents=True, exist_ok=True)
rng = np.random.default_rng(seed)
w, h = size
base = np.zeros((h, w, 3), dtype=np.uint8)
for _ in range(6):
x0 = int(rng.integers(0, w - 60))
y0 = int(rng.integers(0, h - 60))
base[y0 : y0 + 60, x0 : x0 + 60] = rng.integers(0, 256, 3)
grad = np.linspace(0, 120, w, dtype=np.uint8)
base[:, :, 0] = np.clip(base[:, :, 0].astype(int) + grad[None, :], 0, 255)
Image.fromarray(base).save(path, quality=95)
return path
def resized_copy(src, dst, scale=0.5):
with Image.open(src) as image:
image.resize(
(int(image.width * scale), int(image.height * scale)), Image.LANCZOS
).save(dst, quality=95)
return dst
def _uploaded(sf, lib, album="rome", seeds=(1, 2)):
"""A scanned album carrying the verified upload evidence archiving requires."""
folder = lib / album
for index, seed in enumerate(seeds):
structured(folder / f"{index}.jpg", seed)
result = InventoryService(sf).scan(lib)
with sf() as session:
batch_id = str(uuid.uuid4())
session.add(
UploadBatch(
id=batch_id,
album=album,
folder=str(folder),
album_name=album,
state="succeeded",
preflight_token="v1:test",
outcome_state="verified",
created_at=NOW,
)
)
for path, asset_id in result.asset_ids.items():
session.add(
UploadItem(
batch_id=batch_id,
asset_id=asset_id,
path=path,
sha256=sha256_file(path),
sha1="0" * 40,
state="sent",
outcome="uploaded",
)
)
session.commit()
return folder, result.asset_ids
def _archive(sf, config, archive, albums=None):
service = ArchiveService(sf, config=config)
location = service.register("external", str(archive))
token = service.preflight(location["id"], albums)["token"]
transfers = ArchiveTransferService(sf, config=config)
plan = transfers.create(location["id"], albums, token=token)
transfers.apply(plan["id"])
return location
def _unmount(archive):
"""Take the medium away the way a real one goes: its marker stops answering."""
(archive / MARKER_NAME).rename(archive / f"{MARKER_NAME}.away")
def _remount(archive):
(archive / f"{MARKER_NAME}.away").rename(archive / MARKER_NAME)
def _assets(sf):
with sf() as session:
return {asset.id: asset for asset in session.scalars(select(Asset))}
# ── availability ─────────────────────────────────────────────────────────────
def test_archived_assets_report_online_offline_and_missing(tmp_path):
config, sf, lib, archive = _env(tmp_path)
_, ids = _uploaded(sf, lib)
_archive(sf, config, archive)
inventory = InventoryService(sf)
states = {a.availability_state for a in _assets(sf).values()}
assert states == {ARCHIVED_ONLINE}
_unmount(archive)
inventory.scan(lib) # the album folder is gone from the active roots
assert {a.availability_state for a in _assets(sf).values()} == {ARCHIVED_OFFLINE}
assert all(a.missing_at is None for a in _assets(sf).values()) # not "missing"
_remount(archive)
inventory.scan(lib)
assert {a.availability_state for a in _assets(sf).values()} == {ARCHIVED_ONLINE}
# Mounted medium, absent file: that is not an offline archive, it needs a human.
victim = sorted(ids.values())[0]
with sf() as session:
asset = session.get(Asset, victim)
(archive / asset.archive_path).unlink()
inventory.scan(lib)
assert _assets(sf)[victim].availability_state == MISSING_UNEXPECTED
def test_scan_never_prunes_or_flags_offline_assets(tmp_path):
config, sf, lib, archive = _env(tmp_path)
_, ids = _uploaded(sf, lib)
_archive(sf, config, archive)
_unmount(archive)
before = _assets(sf)
result = InventoryService(sf).scan(lib)
assert result.counts.get("missing") is None
after = _assets(sf)
assert set(after) == set(before) == set(ids.values())
for asset in after.values():
assert asset.availability_state == ARCHIVED_OFFLINE
assert asset.current_sha256 and asset.pixel_sha256 # hashes retained
assert asset.archive_location_id and asset.archive_path
def test_active_missing_file_is_missing_unexpected_not_offline(tmp_path):
config, sf, lib, _archive_root = _env(tmp_path)
structured(lib / "loose" / "a.jpg", 7)
ids = InventoryService(sf).scan(lib).asset_ids
asset_id = next(iter(ids.values()))
(lib / "loose" / "a.jpg").unlink()
InventoryService(sf).scan(lib)
asset = _assets(sf)[asset_id]
assert asset.availability_state == MISSING_UNEXPECTED
assert asset.missing_at is not None
# ── deduplication against archived originals ─────────────────────────────────
def test_exact_copy_of_offline_asset_links_to_archived_canonical(tmp_path):
config, sf, lib, archive = _env(tmp_path)
folder, ids = _uploaded(sf, lib, seeds=(1,))
archived_id = next(iter(ids.values()))
original = next(iter(ids))
kept = tmp_path / "kept.jpg"
shutil.copy2(original, kept)
_archive(sf, config, archive)
_unmount(archive)
# The same photo turns up again in the active library while the disk is away.
(lib / "inbox").mkdir(parents=True, exist_ok=True)
shutil.copy2(kept, lib / "inbox" / "again.jpg")
scan = InventoryService(sf).scan(lib)
new_id = scan.asset_ids[str(lib / "inbox" / "again.jpg")]
assert scan.occurrences[str(lib / "inbox" / "again.jpg")] == "copied"
clusters = DuplicateService(sf).detect().clusters
exact = [c for c in clusters if c["method"] == Method.EXACT.value]
assert len(exact) == 1
cluster = exact[0]
# Byte-identical: linked directly, and the archived original stays canonical.
assert cluster["state"] == ClusterState.DECIDED.value
assert cluster["canonical_asset_id"] == archived_id
assert _assets(sf)[new_id].canonical_asset_id == archived_id
def test_fuzzy_copy_of_offline_asset_requires_review_and_names_the_medium(tmp_path):
config, sf, lib, archive = _env(tmp_path)
folder, ids = _uploaded(sf, lib, seeds=(1,))
archived_id = next(iter(ids.values()))
original = next(iter(ids))
variant_source = resized_copy(original, tmp_path / "small.jpg")
_archive(sf, config, archive)
_unmount(archive)
(lib / "inbox").mkdir(parents=True, exist_ok=True)
shutil.copy2(variant_source, lib / "inbox" / "small.jpg")
InventoryService(sf).scan(lib)
duplicates = DuplicateService(sf)
clusters = duplicates.detect().clusters
perceptual = [c for c in clusters if c["method"] == Method.PERCEPTUAL.value]
assert len(perceptual) == 1
detail = duplicates.get_cluster(perceptual[0]["id"])
assert detail["state"] == ClusterState.OPEN.value # never auto-decided
assert detail["requires_confirmation"] is True
assert detail["mount_required"] == ["external"] # full-resolution needs the disk
archived = next(m for m in detail["members"] if m["asset_id"] == archived_id)
assert archived["availability_state"] == ARCHIVED_OFFLINE
assert archived["current_path"] is None
assert archived["archive_path"] and archived["evidence"]["phash"]
# The retained preview is what makes the offline member reviewable at all.
assert archived["preview"] == {"state": "ready", "protected": True, "size": PROTECTED_SIZE}
# ── protected review evidence ────────────────────────────────────────────────
def test_protected_preview_survives_quota_and_serves_while_offline(tmp_path):
config, sf, lib, archive = _env(tmp_path)
_, ids = _uploaded(sf, lib, seeds=(1,))
asset_id = next(iter(ids.values()))
_archive(sf, config, archive)
_unmount(archive)
thumbnails = ThumbnailService(sf, config)
served = thumbnails.generate(asset_id, PROTECTED_SIZE)
assert served.exists() # rendered before the original left, not from the medium
# An aggressive quota may empty the cache, but not this evidence.
tight = ThumbnailService(sf, config.model_copy(update={"thumbnail_cache_quota_bytes": 1}))
tight._enforce_quota()
assert served.exists()
with sf() as session:
row = session.scalar(select(Thumbnail).where(Thumbnail.asset_id == asset_id))
assert row.protected is True
assert thumbnails.evidence(asset_id)["state"] == "ready"
def test_offline_asset_without_preview_reports_unavailable(tmp_path):
"""No preview and no medium is an honest 409, never a wrong picture."""
config, sf, lib, archive = _env(tmp_path)
_, ids = _uploaded(sf, lib, seeds=(1,))
asset_id = next(iter(ids.values()))
_archive(sf, config, archive)
with sf() as session:
for row in session.scalars(select(Thumbnail).where(Thumbnail.asset_id == asset_id)):
session.delete(row)
session.commit()
_unmount(archive)
with pytest.raises(ThumbnailUnavailable):
ThumbnailService(sf, config).generate(asset_id, 256)
_remount(archive) # mounted again: the archived original is readable
assert ThumbnailService(sf, config).generate(asset_id, 256).exists()
def test_offline_asset_is_browsable_through_the_api(tmp_path):
"""The browser sees an archived asset, its medium, and its preview — offline."""
config, sf, lib, archive = _env(tmp_path)
_, ids = _uploaded(sf, lib, seeds=(1,))
asset_id = next(iter(ids.values()))
_archive(sf, config, archive)
_unmount(archive)
with TestClient(create_app(config)) as client:
client.post("/api/v1/inventory/scan")
listed = client.get("/api/v1/inventory/assets", params={"availability": ARCHIVED_OFFLINE})
assert listed.status_code == 200
item = next(row for row in listed.json()["items"] if row["id"] == asset_id)
assert item["current_path"] is None
assert item["archive_path"] == "rome/0.jpg"
assert item["missing"] is False
# Searching by the archived path still finds it.
found = client.get("/api/v1/inventory/assets", params={"q": "rome"}).json()
assert [row["id"] for row in found["items"]] == [asset_id]
preview = client.get(f"/api/v1/assets/{asset_id}/thumbnail", params={"size": 1280})
assert preview.status_code == 200
assert preview.headers["content-type"] == "image/webp"
def test_offline_state_is_stable_across_restart(tmp_path):
config, sf, lib, archive = _env(tmp_path)
_, ids = _uploaded(sf, lib, seeds=(1, 2))
_archive(sf, config, archive)
_unmount(archive)
InventoryService(sf).scan(lib)
before = {
asset_id: (
asset.availability_state,
asset.archive_path,
asset.current_sha256,
asset.phash,
)
for asset_id, asset in _assets(sf).items()
}
# Restart: a fresh engine and session factory against the same database.
restarted = create_session_factory(create_db_engine(config.database_url))
after = {
asset_id: (
asset.availability_state,
asset.archive_path,
asset.current_sha256,
asset.phash,
)
for asset_id, asset in _assets(restarted).items()
}
assert after == before
assert set(after) == set(ids.values())
# And the medium coming back is picked up by the restarted process.
_remount(archive)
assert ArchiveService(restarted, config=config).locations()[0]["state"] == "online"
assert {a.availability_state for a in _assets(restarted).values()} == {ARCHIVED_ONLINE}
assert ACTIVE not in {a.availability_state for a in _assets(restarted).values()}

View File

@@ -0,0 +1,417 @@
"""Planning and executing safe restores (US06-04).
Restoring is the one archive operation that can *add* a file to the library, so
every case here asks two questions: did the right bytes come back under the right
identity, and did anything already in the library get touched? The media are real
directories, the hashes are real, and the failure paths assert that the archived
copy is still exactly where it was — a restore that fails must cost nothing.
"""
import shutil
import uuid
from datetime import datetime, timezone
import numpy as np
import pytest
from fastapi.testclient import TestClient
from PIL import Image
from sqlalchemy import select
from photo_pipeline.api.app import create_app
from photo_pipeline.config import Config
from photo_pipeline.db import create_db_engine, create_session_factory, run_migrations
from photo_pipeline.models import Asset, AssetPath, SafetyReview, UploadBatch, UploadItem
from photo_pipeline.services import availability
from photo_pipeline.services.archive_journal import ArchiveState
from photo_pipeline.services.archive_transfer import ArchiveTransferService
from photo_pipeline.services.archives import MARKER_NAME, ArchiveError, ArchiveService
from photo_pipeline.services.hashing import sha256_file
from photo_pipeline.services.inventory import InventoryService
from photo_pipeline.services.restores import RestoreService
pytestmark = pytest.mark.phase_f # part of the Phase F acceptance gate (US06-06)
NOW = datetime(2026, 1, 1, tzinfo=timezone.utc)
# ── environment ──────────────────────────────────────────────────────────────
def _env(tmp_path):
(tmp_path / "data").mkdir(exist_ok=True)
lib = tmp_path / "lib"
lib.mkdir(exist_ok=True)
archive = tmp_path / "archive"
archive.mkdir(exist_ok=True)
config = Config.from_env(
{
"PHOTO_PIPELINE_DATA_DIR": str(tmp_path / "data"),
"PHOTO_PIPELINE_LIBRARY_ROOTS": str(lib),
"PHOTO_PIPELINE_ARCHIVE_FREE_SPACE_RESERVE_BYTES": "0",
}
)
run_migrations(config.database_url)
return config, create_session_factory(create_db_engine(config.database_url)), lib, archive
def structured(path, seed, size=(192, 144)):
path.parent.mkdir(parents=True, exist_ok=True)
rng = np.random.default_rng(seed)
w, h = size
base = np.zeros((h, w, 3), dtype=np.uint8)
for _ in range(5):
x0 = int(rng.integers(0, w - 40))
y0 = int(rng.integers(0, h - 40))
base[y0 : y0 + 40, x0 : x0 + 40] = rng.integers(0, 256, 3)
Image.fromarray(base).save(path, quality=95)
return path
def _archived(sf, config, lib, archive, album="rome", seeds=(1, 2)):
"""A real album taken all the way through archiving, ready to be restored."""
folder = lib / album
for index, seed in enumerate(seeds):
structured(folder / f"{index}.jpg", seed)
scan = InventoryService(sf).scan(lib)
with sf() as session:
batch_id = str(uuid.uuid4())
session.add(
UploadBatch(
id=batch_id,
album=album,
folder=str(folder),
album_name=album,
state="succeeded",
preflight_token="v1:test",
outcome_state="verified",
created_at=NOW,
)
)
for path, asset_id in scan.asset_ids.items():
session.add(
UploadItem(
batch_id=batch_id,
asset_id=asset_id,
path=path,
sha256=sha256_file(path),
sha1="0" * 40,
state="sent",
outcome="uploaded",
)
)
# A decision that must survive the whole round trip.
session.add(
SafetyReview(
id=str(uuid.uuid4()),
asset_id=asset_id,
decision="sfw",
score=0.01,
reviewer="test",
)
)
session.commit()
service = ArchiveService(sf, config=config)
location = service.register("external", str(archive))
token = service.preflight(location["id"])["token"]
transfers = ArchiveTransferService(sf, config=config)
plan = transfers.create(location["id"], None, token=token)
transfers.apply(plan["id"])
return location, scan.asset_ids
def _restore(sf, config, location_id, asset_ids=None):
service = RestoreService(sf, config=config)
token = service.preflight(location_id, asset_ids)["token"]
plan = service.create(location_id, asset_ids, token=token)
return service, plan, service.apply(plan["id"])
def _unmount(archive):
(archive / MARKER_NAME).rename(archive / f"{MARKER_NAME}.away")
def _assets(sf):
with sf() as session:
return {asset.id: asset for asset in session.scalars(select(Asset))}
def _codes(report):
return {issue["code"] for issue in report["blockers"]} | {
issue["code"] for item in report["items"] for issue in item["blockers"]
}
# ── preflight ────────────────────────────────────────────────────────────────
def test_preflight_blocks_offline_medium(tmp_path):
config, sf, lib, archive = _env(tmp_path)
location, _ = _archived(sf, config, lib, archive)
_unmount(archive)
report = RestoreService(sf, config=config).preflight(location["id"])
assert report["state"] == "blocked"
assert "location_offline" in _codes(report)
def test_preflight_blocks_wrong_volume(tmp_path):
config, sf, lib, archive = _env(tmp_path)
location, _ = _archived(sf, config, lib, archive)
(archive / MARKER_NAME).write_text('{"media_id": "someone-elses-disk"}', encoding="utf-8")
report = RestoreService(sf, config=config).preflight(location["id"])
assert report["state"] == "blocked"
assert "wrong_volume" in _codes(report)
def test_preflight_blocks_changed_archive_bytes(tmp_path):
config, sf, lib, archive = _env(tmp_path)
location, ids = _archived(sf, config, lib, archive, seeds=(1,))
asset_id = next(iter(ids.values()))
with sf() as session:
archived_file = archive / session.get(Asset, asset_id).archive_path
archived_file.write_bytes(b"not the photo that was archived")
report = RestoreService(sf, config=config).preflight(location["id"])
assert report["state"] == "blocked"
assert "bytes_changed" in _codes(report)
with pytest.raises(ArchiveError) as error:
_restore(sf, config, location["id"])
assert error.value.code == "blocked"
def test_preflight_blocks_insufficient_capacity(tmp_path):
config, sf, lib, archive = _env(tmp_path)
location, _ = _archived(sf, config, lib, archive)
greedy = config.model_copy(
update={"archive_free_space_reserve_bytes": 1 << 62} # more than any disk has
)
report = RestoreService(sf, config=greedy).preflight(location["id"])
assert report["state"] == "blocked"
assert "insufficient_capacity" in _codes(report)
def test_token_changes_with_the_scope(tmp_path):
config, sf, lib, archive = _env(tmp_path)
location, ids = _archived(sf, config, lib, archive)
service = RestoreService(sf, config=config)
whole = service.preflight(location["id"])["token"]
partial = service.preflight(location["id"], [sorted(ids.values())[0]])["token"]
assert whole != partial
assert service.verify_token(whole, location["id"])
assert not service.verify_token(partial, location["id"])
# ── restore ──────────────────────────────────────────────────────────────────
def test_restore_returns_bytes_identity_and_decisions(tmp_path):
config, sf, lib, archive = _env(tmp_path)
location, ids = _archived(sf, config, lib, archive)
archived_hashes = {
asset_id: asset.current_sha256 for asset_id, asset in _assets(sf).items()
}
service, plan, result = _restore(sf, config, location["id"])
assert (result["restored"], result["failed"], result["state"]) == (2, 0, "complete")
for asset_id, asset in _assets(sf).items():
assert asset.availability_state == availability.ACTIVE
assert asset.current_path == str(lib / asset.archive_path)
assert sha256_file(asset.current_path) == archived_hashes[asset_id]
# The archived copy is a copy: restoring never empties the medium.
assert (archive / asset.archive_path).exists()
assert asset.archive_location_id == location["id"]
with sf() as session:
# Identity and decisions survived: same ids, same reviews, new occurrence.
assert set(ids.values()) == {a.id for a in session.scalars(select(Asset))}
assert {r.decision for r in session.scalars(select(SafetyReview))} == {"sfw"}
occurrences = [
row.reason
for row in session.scalars(
select(AssetPath).where(AssetPath.asset_id == sorted(ids.values())[0])
)
]
assert "restore" in occurrences
def test_restore_never_overwrites_a_collision(tmp_path):
config, sf, lib, archive = _env(tmp_path)
location, ids = _archived(sf, config, lib, archive, seeds=(1,))
asset_id = next(iter(ids.values()))
with sf() as session:
archive_path = session.get(Asset, asset_id).archive_path
occupied = lib / archive_path
occupied.parent.mkdir(parents=True, exist_ok=True)
occupied.write_bytes(b"a different photo already lives here")
before = occupied.read_bytes()
service, plan, result = _restore(sf, config, location["id"])
assert result["failed"] == 0
assert occupied.read_bytes() == before # untouched
restored = _assets(sf)[asset_id].current_path
assert restored != str(occupied)
assert "(restored)" in restored
assert sha256_file(restored) == sha256_file(archive / archive_path)
def test_apply_refuses_a_destination_taken_after_planning(tmp_path):
config, sf, lib, archive = _env(tmp_path)
location, ids = _archived(sf, config, lib, archive, seeds=(1,))
service = RestoreService(sf, config=config)
token = service.preflight(location["id"])["token"]
plan = service.create(location["id"], None, token=token)
# Someone drops a file exactly where the plan intends to publish.
destination = plan["operations"][0]["destination_path"]
from pathlib import Path
Path(destination).parent.mkdir(parents=True, exist_ok=True)
Path(destination).write_bytes(b"squatter")
result = service.apply(plan["id"])
assert result["failed"] == 1
operation = service.journal.operations(plan["id"])[0]
assert operation["journal_state"] == ArchiveState.FAILED
assert operation["error_code"] == "destination_exists"
assert Path(destination).read_bytes() == b"squatter"
assert _assets(sf)[next(iter(ids.values()))].availability_state == availability.ARCHIVED_ONLINE
def test_changed_archive_bytes_mark_the_asset_divergent(tmp_path):
config, sf, lib, archive = _env(tmp_path)
location, ids = _archived(sf, config, lib, archive, seeds=(1,))
asset_id = next(iter(ids.values()))
service = RestoreService(sf, config=config)
token = service.preflight(location["id"])["token"]
plan = service.create(location["id"], None, token=token)
# The medium's copy is edited after the plan was approved.
with sf() as session:
archived_file = archive / session.get(Asset, asset_id).archive_path
archived_file.write_bytes(b"edited on the shelf")
result = service.apply(plan["id"])
assert result["failed"] == 1
operation = service.journal.operations(plan["id"])[0]
assert operation["error_code"] == "bytes_changed"
asset = _assets(sf)[asset_id]
assert asset.archive_divergent_at is not None # durable divergence
assert asset.availability_state == availability.ARCHIVED_ONLINE
assert asset.current_path is None # nothing was published
# ── interruption and idempotency ─────────────────────────────────────────────
def test_interrupted_before_publishing_is_resumable(tmp_path):
config, sf, lib, archive = _env(tmp_path)
location, ids = _archived(sf, config, lib, archive, seeds=(1,))
service = RestoreService(sf, config=config)
token = service.preflight(location["id"])["token"]
plan = service.create(location["id"], None, token=token)
operation = service.journal.operations(plan["id"])[0]
# Model a kill right after the intent was written: nothing published yet.
service.journal.begin(operation["id"], worker_id="killed", fencing_token=1)
status = service.recovery_status()
assert status["operations"][0]["classification"] == "resumable"
assert service.recover() == {"resumed": 1, "completed": 0, "manual": 0}
assert service.journal.operations(plan["id"])[0]["journal_state"] == ArchiveState.PLANNED
result = service.apply(plan["id"])
assert result["failed"] == 0
assert _assets(sf)[next(iter(ids.values()))].availability_state == availability.ACTIVE
def test_interrupted_after_publishing_is_finished_by_recovery(tmp_path):
config, sf, lib, archive = _env(tmp_path)
location, ids = _archived(sf, config, lib, archive, seeds=(1,))
asset_id = next(iter(ids.values()))
service = RestoreService(sf, config=config)
token = service.preflight(location["id"])["token"]
plan = service.create(location["id"], None, token=token)
operation = service.journal.operations(plan["id"])[0]
# Model a kill between the published copy and the database update.
from pathlib import Path
destination = Path(operation["destination_path"])
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(operation["source_path"], destination)
service.journal.begin(operation["id"], worker_id="killed", fencing_token=1)
service.journal.transition(operation["id"], ArchiveState.VERIFIED, fencing_token=1)
assert service.recovery_status()["operations"][0]["classification"] == "forward"
assert service.recover()["completed"] == 1
asset = _assets(sf)[asset_id]
assert asset.availability_state == availability.ACTIVE
assert asset.current_path == str(destination)
# Repeated recovery and a repeated apply converge on the same state.
assert service.recover() == {"resumed": 0, "completed": 0, "manual": 0}
again = service.apply(plan["id"])
assert (again["skipped"], again["failed"]) == (1, 0)
assert _assets(sf)[asset_id].current_path == str(destination)
def test_restored_state_survives_restart_and_rescan(tmp_path):
config, sf, lib, archive = _env(tmp_path)
location, ids = _archived(sf, config, lib, archive)
_restore(sf, config, location["id"])
restarted = create_session_factory(create_db_engine(config.database_url))
InventoryService(restarted).scan(lib)
assets = _assets(restarted)
assert set(assets) == set(ids.values()) # no new identities from the rescan
for asset in assets.values():
assert asset.availability_state == availability.ACTIVE
assert asset.missing_at is None
# Nothing is archived at that location any more, so there is nothing to restore.
again = RestoreService(restarted, config=config).preflight(location["id"])
assert _codes(again) == {"empty_scope"}
def test_restore_api_round_trip(tmp_path):
config, sf, lib, archive = _env(tmp_path)
location, ids = _archived(sf, config, lib, archive, seeds=(1,))
with TestClient(create_app(config)) as client:
report = client.post(
"/api/v1/restore-preflight", json={"location_id": location["id"]}
).json()
assert report["state"] == "ready"
stale = client.post(
"/api/v1/restore-plans",
json={"location_id": location["id"], "token": "r1:not-the-token"},
)
assert stale.status_code == 409
created = client.post(
"/api/v1/restore-plans",
json={"location_id": location["id"], "token": report["token"]},
)
assert created.status_code == 201
plan_id = created.json()["id"]
assert created.json()["direction"] == "restore"
# The plan is visible and applying it queues work on the archiver lane.
assert client.get(f"/api/v1/restore-plans/{plan_id}").status_code == 200
queued = client.post(f"/api/v1/restore-plans/{plan_id}/apply")
assert queued.status_code == 200
assert queued.json()["job"]["job_type"] == "restore_plan"
assert queued.json()["job"]["lock_key"] == "archive"
assert client.get("/api/v1/restore-recovery").json()["manual"] == []
assert _assets(sf)[next(iter(ids.values()))].availability_state == (
availability.ARCHIVED_ONLINE # the worker, not the request, does the work
)

View File

@@ -0,0 +1,590 @@
"""Upload batch orchestration (US05-02).
The uploader is a real executable on disk driven through the real integration
adapter and ``subprocess`` — never a mock — so argument construction, output
bounding, credential privacy, and killing a running process are exercised the way
production does them. Each fake uploader records the argv it was given into a
side-channel file, which is what the argument/album-isolation assertions read.
"""
import json
import os
import signal
import stat
import threading
import time
import uuid
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from photo_pipeline.api.app import create_app
from photo_pipeline.config import Config
from photo_pipeline.db import create_db_engine, create_session_factory, run_migrations
from photo_pipeline.jobs.domain_handlers import UPLOAD_BATCH
from photo_pipeline.jobs.worker import Worker
from photo_pipeline.models import AnalysisResult, Asset, SafetyReview, UploadBatch
from photo_pipeline.services.hashing import sha1_file, sha256_file
from photo_pipeline.services.jobs import JobService, JobState
from photo_pipeline.services.upload_batches import (
BatchConflict,
BatchError,
BatchState,
ItemState,
UploadBatchService,
)
from photo_pipeline.services.uploads import UploadService
pytestmark = pytest.mark.phase_e # part of the Phase E acceptance gate (US05-06)
NOW = datetime(2026, 1, 1, tzinfo=timezone.utc)
SENTINEL_KEY = "immich-sentinel-9f3a2b"
UPLOADER_VERSION = "immich-go 0.21.0"
# ── fake external boundary ───────────────────────────────────────────────────
class _PingHandler(BaseHTTPRequestHandler):
def do_GET(self): # noqa: N802 (BaseHTTPRequestHandler API)
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b'{"res":"pong"}')
def log_message(self, *args):
pass
@pytest.fixture
def immich_server():
server = HTTPServer(("127.0.0.1", 0), _PingHandler)
threading.Thread(target=server.serve_forever, daemon=True).start()
yield f"http://127.0.0.1:{server.server_port}"
server.shutdown()
server.server_close()
def _uploader(tmp_path, body: str = "", *, name="immich-go"):
"""A real executable standing in for immich-go.
``--version`` answers like the real tool; any other invocation appends its
complete argv to ``<name>.argv`` and then runs ``body``.
"""
path = tmp_path / name
argv_log = tmp_path / f"{name}.argv"
path.write_text(
"#!/bin/sh\n"
f'if [ "$1" = "--version" ]; then echo "{UPLOADER_VERSION}"; exit 0; fi\n'
f'printf "%s\\n" "$*" >> "{argv_log}"\n'
f"{body}\n"
)
path.chmod(path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
return path
def _argv(tmp_path, name="immich-go") -> list[str]:
log = tmp_path / f"{name}.argv"
return log.read_text().splitlines() if log.exists() else []
# ── environment ──────────────────────────────────────────────────────────────
def _env(tmp_path, server_url, *, uploader=None):
(tmp_path / "data").mkdir(exist_ok=True)
lib = tmp_path / "lib"
lib.mkdir(exist_ok=True)
config = Config.from_env(
{
"PHOTO_PIPELINE_DATA_DIR": str(tmp_path / "data"),
"PHOTO_PIPELINE_LIBRARY_ROOTS": str(lib),
"PHOTO_PIPELINE_IMMICH_SERVER_URL": server_url,
"PHOTO_PIPELINE_IMMICH_API_KEY": SENTINEL_KEY,
"PHOTO_PIPELINE_IMMICH_GO_BINARY": str(
uploader if uploader is not None else _uploader(tmp_path, "exit 0")
),
}
)
run_migrations(config.database_url)
return config, create_session_factory(create_db_engine(config.database_url)), lib
def _album(sf, lib, album="rome", names=("a.jpg", "b.jpg")):
folder = lib / album
folder.mkdir(parents=True, exist_ok=True)
with sf() as session:
for name in names:
path = folder / name
path.write_bytes(f"{album}/{name}".encode() * 16)
asset_id = str(uuid.uuid4())
session.add(
Asset(
id=asset_id,
original_path=str(path),
current_path=str(path),
discovered_at=NOW,
hash_version=1,
byte_size=path.stat().st_size,
current_sha256=sha256_file(path),
)
)
session.add(
SafetyReview(
id=str(uuid.uuid4()),
asset_id=asset_id,
decision="sfw",
exif_verified_at=NOW,
)
)
session.add(
AnalysisResult(asset_id=asset_id, status="analyzed", exif_written_at=NOW)
)
session.commit()
return folder
def _service(sf, config):
return UploadBatchService(sf, config=config)
def _approved(sf, config, albums=None, **kwargs):
"""Create batches from a fresh, ready preflight."""
report = UploadService(sf, config=config).preflight(albums, **kwargs)
assert report["state"] == "ready", report["blockers"]
return _service(sf, config).create(albums, token=report["token"], **kwargs)
# ── creation ─────────────────────────────────────────────────────────────────
def test_batch_records_album_assets_hashes_and_command(tmp_path, immich_server):
config, sf, lib = _env(tmp_path, immich_server)
folder = _album(sf, lib)
(batch,) = _approved(sf, config)
assert batch["state"] == BatchState.PLANNED
assert batch["album"] == "rome" and batch["folder"] == str(folder)
assert batch["asset_count"] == 2 and len(batch["items"]) == 2
assert batch["uploader_version"] == UPLOADER_VERSION
assert "--album-name=rome" in batch["command"]
assert "--api-key=***" in batch["command"]
for item in batch["items"]:
assert item["sha256"] == sha256_file(item["path"])
assert item["sha1"] == sha1_file(item["path"])
assert item["state"] == ItemState.PENDING
def test_one_batch_per_album_and_scope_is_isolated(tmp_path, immich_server):
config, sf, lib = _env(tmp_path, immich_server)
_album(sf, lib, "rome")
_album(sf, lib, "paris", names=("c.jpg",))
batches = _approved(sf, config, ["paris"])
assert [b["album"] for b in batches] == ["paris"]
assert [b["asset_count"] for b in batches] == [1]
def test_creating_twice_reuses_the_open_batch(tmp_path, immich_server):
config, sf, lib = _env(tmp_path, immich_server)
_album(sf, lib)
first = _approved(sf, config)[0]
second = _approved(sf, config)[0]
assert first["id"] == second["id"]
def test_stale_preflight_token_cannot_create_a_batch(tmp_path, immich_server):
config, sf, lib = _env(tmp_path, immich_server)
folder = _album(sf, lib)
token = UploadService(sf, config=config).preflight()["token"]
(folder / "a.jpg").write_bytes(b"edited after approval")
with pytest.raises(BatchConflict) as error:
_service(sf, config).create(token=token)
assert error.value.code == "stale_preflight"
def test_blocked_scope_cannot_create_a_batch(tmp_path, immich_server):
config, sf, lib = _env(tmp_path, immich_server)
folder = _album(sf, lib)
(folder / "a.jpg").write_bytes(b"edited before approval")
report = UploadService(sf, config=config).preflight()
with pytest.raises(BatchError) as error:
_service(sf, config).create(token=report["token"])
assert error.value.code == "not_ready"
# ── running the uploader ─────────────────────────────────────────────────────
def test_run_invokes_the_uploader_with_the_batch_album_and_folder(tmp_path, immich_server):
config, sf, lib = _env(tmp_path, immich_server)
folder = _album(sf, lib)
(batch,) = _approved(sf, config)
result = _service(sf, config).run(batch["id"])
assert result["state"] == BatchState.SUCCEEDED and result["exit_code"] == 0
(invocation,) = _argv(tmp_path)
assert "upload from-folder" in invocation
assert "--album-name=rome" in invocation
assert str(folder) in invocation
assert all(item["state"] == ItemState.SENT for item in result["items"])
assert result["attempt_count"] == 1 and result["started_at"] and result["finished_at"]
def test_other_albums_are_never_passed_to_the_uploader(tmp_path, immich_server):
config, sf, lib = _env(tmp_path, immich_server)
_album(sf, lib, "rome")
paris = _album(sf, lib, "paris", names=("c.jpg",))
(batch,) = _approved(sf, config, ["rome"])
_service(sf, config).run(batch["id"])
(invocation,) = _argv(tmp_path)
assert str(paris) not in invocation and "paris" not in invocation
def test_album_names_are_arguments_not_shell_text(tmp_path, immich_server):
"""A folder whose name contains shell metacharacters must reach the uploader
verbatim; nothing may be interpreted (no shell is involved)."""
config, sf, lib = _env(tmp_path, immich_server)
hostile = "rome; touch pwned"
_album(sf, lib, hostile, names=("a.jpg",))
(batch,) = _approved(sf, config)
result = _service(sf, config).run(batch["id"])
assert result["state"] == BatchState.SUCCEEDED
assert f"--album-name={hostile}" in _argv(tmp_path)[0]
assert not (Path.cwd() / "pwned").exists() and not (lib / "pwned").exists()
def test_uploader_failure_is_recorded_and_items_are_not_sent(tmp_path, immich_server):
config, sf, lib = _env(
tmp_path, immich_server, uploader=_uploader(tmp_path, "echo 'boom' >&2; exit 3")
)
_album(sf, lib)
(batch,) = _approved(sf, config)
result = _service(sf, config).run(batch["id"])
assert result["state"] == BatchState.FAILED and result["exit_code"] == 3
assert result["error_code"] == "uploader_failed"
assert all(item["state"] == ItemState.FAILED for item in result["items"])
assert "boom" in _service(sf, config).report(batch["id"])
def test_a_failed_batch_can_be_retried_as_a_new_attempt(tmp_path, immich_server):
config, sf, lib = _env(tmp_path, immich_server, uploader=_uploader(tmp_path, "exit 3"))
_album(sf, lib)
(batch,) = _approved(sf, config)
service = _service(sf, config)
service.run(batch["id"])
retried = service.run(batch["id"])
assert retried["attempt_count"] == 2
assert len(_argv(tmp_path)) == 2
def test_bytes_edited_after_approval_block_the_upload(tmp_path, immich_server):
config, sf, lib = _env(tmp_path, immich_server)
folder = _album(sf, lib)
(batch,) = _approved(sf, config)
(folder / "a.jpg").write_bytes(b"edited after approval")
with pytest.raises(BatchConflict) as error:
_service(sf, config).run(batch["id"])
assert error.value.code == "stale_preflight"
assert _argv(tmp_path) == [], "the uploader must not run on changed bytes"
assert _service(sf, config).get(batch["id"])["state"] == BatchState.FAILED
# ── credential privacy ───────────────────────────────────────────────────────
def test_the_api_key_reaches_the_uploader_but_never_the_record(tmp_path, immich_server):
config, sf, lib = _env(tmp_path, immich_server)
_album(sf, lib)
(batch,) = _approved(sf, config)
result = _service(sf, config).run(batch["id"])
assert f"--api-key={SENTINEL_KEY}" in _argv(tmp_path)[0], "the real key must be passed"
assert SENTINEL_KEY not in json.dumps(result, default=str)
def test_a_key_echoed_by_the_uploader_is_scrubbed_from_the_report(tmp_path, immich_server):
config, sf, lib = _env(
tmp_path, immich_server, uploader=_uploader(tmp_path, 'echo "using key $4"; exit 0')
)
_album(sf, lib)
(batch,) = _approved(sf, config)
service = _service(sf, config)
service.run(batch["id"])
report = service.report(batch["id"])
assert SENTINEL_KEY not in report and "***" in report
# ── bounded output ───────────────────────────────────────────────────────────
def test_a_chatty_uploader_cannot_grow_the_report_without_bound(tmp_path, immich_server):
config, sf, lib = _env(
tmp_path,
immich_server,
uploader=_uploader(tmp_path, "i=0; while [ $i -lt 2000 ]; do echo line-$i; i=$((i+1)); done"),
)
_album(sf, lib)
(batch,) = _approved(sf, config)
from photo_pipeline.integrations import immich_go
service = _service(sf, config)
original = immich_go.MAX_REPORT_BYTES
immich_go.MAX_REPORT_BYTES = 500 # a cap small enough to hit in one test
try:
result = service.run(batch["id"])
finally:
immich_go.MAX_REPORT_BYTES = original
assert result["state"] == BatchState.SUCCEEDED, "the uploader still finished normally"
assert result["report_truncated"] is True
assert result["report_bytes"] <= 500
assert Path(result["report_path"]).stat().st_size <= 500
# ── one lane ─────────────────────────────────────────────────────────────────
def test_a_second_batch_cannot_run_while_one_is_running(tmp_path, immich_server):
config, sf, lib = _env(tmp_path, immich_server)
_album(sf, lib, "rome")
_album(sf, lib, "paris", names=("c.jpg",))
rome, paris = sorted(_approved(sf, config), key=lambda b: b["album"] != "rome")
with sf() as session: # a batch already occupying the lane
session.get(UploadBatch, rome["id"]).state = BatchState.RUNNING
session.commit()
with pytest.raises(BatchConflict) as error:
_service(sf, config).run(paris["id"])
assert error.value.code == "lane_busy"
assert _argv(tmp_path) == []
def test_the_job_lock_refuses_a_second_queued_upload(tmp_path, immich_server):
config, sf, lib = _env(tmp_path, immich_server)
_album(sf, lib, "rome")
_album(sf, lib, "paris", names=("c.jpg",))
batches = _approved(sf, config)
with TestClient(create_app(config)) as client:
first = client.post(f"/api/v1/upload-batches/{batches[0]['id']}/start")
second = client.post(f"/api/v1/upload-batches/{batches[1]['id']}/start")
assert first.status_code == 200
assert second.status_code == 409 and second.json()["error"]["code"] == "lock_held"
# ── cancellation ─────────────────────────────────────────────────────────────
def test_cancelling_a_running_batch_stops_the_uploader(tmp_path, immich_server):
"""The uploader sleeps; cancelling flips the batch to ``cancelling`` and the
running attempt must terminate the process and record ``cancelled``."""
config, sf, lib = _env(
tmp_path, immich_server, uploader=_uploader(tmp_path, 'echo started; sleep 30; exit 0')
)
_album(sf, lib)
(batch,) = _approved(sf, config)
service = _service(sf, config)
outcome = {}
def _run():
outcome["batch"] = service.run(batch["id"])
runner = threading.Thread(target=_run)
started = time.monotonic()
runner.start()
while service.get(batch["id"])["state"] != BatchState.RUNNING:
assert time.monotonic() - started < 30, "the attempt never started"
time.sleep(0.02)
service.cancel(batch["id"])
runner.join(timeout=30)
assert not runner.is_alive(), "cancellation must not wait for the uploader's own timeout"
assert outcome["batch"]["state"] == BatchState.CANCELLED
assert all(item["state"] == ItemState.PENDING for item in outcome["batch"]["items"])
def test_cancelling_a_planned_batch_never_starts_the_uploader(tmp_path, immich_server):
config, sf, lib = _env(tmp_path, immich_server)
_album(sf, lib)
(batch,) = _approved(sf, config)
service = _service(sf, config)
cancelled = service.cancel(batch["id"])
assert cancelled["state"] == BatchState.CANCELLED
assert _argv(tmp_path) == []
def test_a_cancelled_batch_can_be_run_again(tmp_path, immich_server):
config, sf, lib = _env(tmp_path, immich_server)
_album(sf, lib)
(batch,) = _approved(sf, config)
service = _service(sf, config)
service.cancel(batch["id"])
result = service.run(batch["id"])
assert result["state"] == BatchState.SUCCEEDED and result["attempt_count"] == 1
# ── restart / recovery ───────────────────────────────────────────────────────
def test_an_interrupted_attempt_becomes_uncertain_not_failed(tmp_path, immich_server):
"""The process died mid-upload: Immich may hold the files, so the batch requires
verification (US05-04) instead of a blind retry, and the lane is released."""
config, sf, lib = _env(tmp_path, immich_server)
_album(sf, lib)
(batch,) = _approved(sf, config)
with sf() as session: # what a killed worker leaves behind
session.get(UploadBatch, batch["id"]).state = BatchState.RUNNING
session.commit()
service = _service(sf, config)
assert service.recover() == {"interrupted": 1}
recovered = service.get(batch["id"])
assert recovered["state"] == BatchState.UNKNOWN
assert recovered["error_code"] == "interrupted"
with pytest.raises(BatchError) as error:
service.run(batch["id"])
assert error.value.code == "requires_verification"
assert _argv(tmp_path) == []
def test_recovery_leaves_a_planned_batch_runnable(tmp_path, immich_server):
config, sf, lib = _env(tmp_path, immich_server)
_album(sf, lib)
(batch,) = _approved(sf, config)
service = _service(sf, config)
assert service.recover() == {"interrupted": 0}
assert service.run(batch["id"])["state"] == BatchState.SUCCEEDED
def test_application_startup_recovers_an_interrupted_batch(tmp_path, immich_server):
config, sf, lib = _env(tmp_path, immich_server)
_album(sf, lib)
(batch,) = _approved(sf, config)
with sf() as session:
session.get(UploadBatch, batch["id"]).state = BatchState.RUNNING
session.commit()
with TestClient(create_app(config)) as client:
state = client.get(f"/api/v1/upload-batches/{batch['id']}").json()["state"]
assert state == BatchState.UNKNOWN
def test_a_killed_uploader_leaves_an_uncertain_batch(tmp_path, immich_server):
"""A real SIGKILL of the uploader process, not a simulated state write."""
config, sf, lib = _env(
tmp_path,
immich_server,
uploader=_uploader(tmp_path, 'echo "pid $$"; sleep 30; exit 0'),
)
_album(sf, lib)
(batch,) = _approved(sf, config)
service = _service(sf, config)
outcome = {}
runner = threading.Thread(target=lambda: outcome.update(batch=service.run(batch["id"])))
runner.start()
report = tmp_path / "data" / "uploads"
deadline = time.monotonic() + 30
pid = None
while pid is None:
assert time.monotonic() < deadline, "the uploader never announced itself"
for log in report.glob("*.log"):
text = log.read_text()
if text.startswith("pid "):
pid = int(text.split()[1])
time.sleep(0.02)
os.kill(pid, signal.SIGKILL)
runner.join(timeout=30)
# The process is gone with a non-zero status and no parsed report: the attempt
# failed locally, and a restart classifies it honestly.
assert outcome["batch"]["state"] in (BatchState.FAILED, BatchState.UNKNOWN)
assert outcome["batch"]["exit_code"] != 0
# ── worker integration ───────────────────────────────────────────────────────
def test_the_worker_runs_a_queued_batch_on_the_upload_lane(tmp_path, immich_server):
config, sf, lib = _env(tmp_path, immich_server)
_album(sf, lib)
(batch,) = _approved(sf, config)
with TestClient(create_app(config)) as client:
response = client.post(f"/api/v1/upload-batches/{batch['id']}/start")
job_id = response.json()["job"]["id"]
Worker(sf, worker_id="uploader-1", job_types=[UPLOAD_BATCH], config=config).run_once()
assert JobService(sf).get(job_id)["state"] == JobState.SUCCEEDED
assert _service(sf, config).get(batch["id"])["state"] == BatchState.SUCCEEDED
assert len(_argv(tmp_path)) == 1
# ── API surface ──────────────────────────────────────────────────────────────
def test_api_creates_lists_and_reads_batches_without_secrets(tmp_path, immich_server):
config, sf, lib = _env(tmp_path, immich_server)
_album(sf, lib)
with TestClient(create_app(config)) as client:
token = client.post("/api/v1/upload-preflight", json={}).json()["token"]
created = client.post("/api/v1/upload-batches", json={"token": token})
listed = client.get("/api/v1/upload-batches")
batch_id = created.json()["batches"][0]["id"]
fetched = client.get(f"/api/v1/upload-batches/{batch_id}")
assert created.status_code == 201
assert listed.json()["batches"][0]["id"] == batch_id
assert fetched.json()["state"] == BatchState.PLANNED
assert SENTINEL_KEY not in created.text + listed.text + fetched.text
def test_api_rejects_a_stale_token_and_an_unknown_batch(tmp_path, immich_server):
config, sf, lib = _env(tmp_path, immich_server)
_album(sf, lib)
with TestClient(create_app(config)) as client:
stale = client.post("/api/v1/upload-batches", json={"token": "v1:not-the-token"})
missing = client.get("/api/v1/upload-batches/does-not-exist")
assert stale.status_code == 409 and stale.json()["error"]["code"] == "stale_preflight"
assert missing.status_code == 404

View File

@@ -0,0 +1,513 @@
"""Upload preflight: credentials, scope, readiness, and tokens (US05-01).
Every external boundary is faked but never mocked away: the uploader is a real
executable on disk invoked through ``subprocess``, and the Immich server is a real
localhost HTTP server answering ``/api/server/ping``. Preflight itself must stay
read-only — the library snapshot is asserted unchanged.
"""
import json
import stat
import threading
import uuid
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, HTTPServer
import pytest
from fastapi.testclient import TestClient
from photo_pipeline.api.app import create_app
from photo_pipeline.config import Config
from photo_pipeline.db import create_db_engine, create_session_factory, run_migrations
from photo_pipeline.models import (
AnalysisResult,
Asset,
RenameOperation,
RenamePlan,
SafetyReview,
)
from photo_pipeline.services.hashing import sha256_file
from photo_pipeline.services.uploads import UploadError, UploadService
pytestmark = pytest.mark.phase_e # part of the Phase E acceptance gate (US05-06)
NOW = datetime(2026, 1, 1, tzinfo=timezone.utc)
# A sentinel credential: every assertion below proves it never leaves configuration.
SENTINEL_KEY = "immich-sentinel-9f3a2b"
UPLOADER_VERSION = "immich-go 0.21.0"
# ── fake external boundary ───────────────────────────────────────────────────
class _PingHandler(BaseHTTPRequestHandler):
payload = b'{"res":"pong"}'
status = 200
def do_GET(self): # noqa: N802 (BaseHTTPRequestHandler API)
self.send_response(type(self).status)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(type(self).payload)
def log_message(self, *args):
pass # keep the test output clean
@pytest.fixture
def immich_server():
"""A real HTTP server that answers like Immich. Yields its base URL."""
handler = type("Handler", (_PingHandler,), {})
server = HTTPServer(("127.0.0.1", 0), handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
yield f"http://127.0.0.1:{server.server_port}", handler
server.shutdown()
server.server_close()
def _fake_uploader(tmp_path):
"""A real executable standing in for immich-go."""
path = tmp_path / "immich-go"
path.write_text(f"#!/bin/sh\necho '{UPLOADER_VERSION}'\n")
path.chmod(path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
return path
# ── environment ──────────────────────────────────────────────────────────────
def _env(tmp_path, server_url, *, credential=SENTINEL_KEY, uploader=None):
(tmp_path / "data").mkdir(exist_ok=True)
lib = tmp_path / "lib"
lib.mkdir(exist_ok=True)
env = {
"PHOTO_PIPELINE_DATA_DIR": str(tmp_path / "data"),
"PHOTO_PIPELINE_LIBRARY_ROOTS": str(lib),
"PHOTO_PIPELINE_IMMICH_SERVER_URL": server_url,
"PHOTO_PIPELINE_IMMICH_GO_BINARY": str(
uploader if uploader is not None else _fake_uploader(tmp_path)
),
}
if credential:
env["PHOTO_PIPELINE_IMMICH_API_KEY"] = credential
config = Config.from_env(env)
run_migrations(config.database_url)
return config, create_session_factory(create_db_engine(config.database_url)), lib
def _album(
sf,
lib,
album="rome",
names=("a.jpg", "b.jpg"),
*,
decision="sfw",
exif_verified=True,
analyzed=True,
):
"""A real album folder with registered, stage-complete assets."""
folder = lib / album
folder.mkdir(parents=True, exist_ok=True)
ids = []
with sf() as session:
for name in names:
path = folder / name
path.write_bytes(name.encode() * 16)
asset_id = str(uuid.uuid4())
ids.append(asset_id)
session.add(
Asset(
id=asset_id,
original_path=str(path),
current_path=str(path),
discovered_at=NOW,
hash_version=1,
byte_size=path.stat().st_size,
current_sha256=sha256_file(path),
)
)
if decision is not None:
session.add(
SafetyReview(
id=str(uuid.uuid4()),
asset_id=asset_id,
decision=decision,
exif_verified_at=NOW if exif_verified else None,
)
)
if analyzed:
session.add(
AnalysisResult(
asset_id=asset_id,
status="analyzed",
exif_written_at=NOW,
)
)
session.commit()
return folder, ids
def _snapshot(lib):
return {
str(p.relative_to(lib)): (p.read_bytes() if p.is_file() else None)
for p in sorted(lib.rglob("*"))
}
def _service(sf, config):
return UploadService(sf, config=config)
def _codes(report):
return {issue["code"] for issue in report["blockers"]} | {
issue["code"] for album in report["albums"] for issue in album["blockers"]
} | {
issue["code"]
for album in report["albums"]
for asset in album["assets"]
for issue in asset["blockers"]
}
# ── happy path ───────────────────────────────────────────────────────────────
def test_ready_preflight_reports_scope_command_and_hashes(tmp_path, immich_server):
url, _ = immich_server
config, sf, lib = _env(tmp_path, url)
folder, ids = _album(sf, lib)
before = _snapshot(lib)
report = _service(sf, config).preflight()
assert report["state"] == "ready" and report["blockers"] == []
assert report["server"]["reachable"] is True
assert report["uploader"]["installed"] is True
assert report["uploader"]["version"] == UPLOADER_VERSION
assert report["credentials"]["api_key_configured"] is True
assert report["totals"] == {
"albums": 1,
"ready_albums": 1,
"assets": 2,
"eligible": 2,
"blocked": 0,
}
album = report["albums"][0]
assert album["album"] == "rome" and album["folder"] == str(folder)
assert album["album_name"] == "rome" # folder-as-album
assert album["partial"] is False and album["state"] == "ready"
assert sorted(a["asset_id"] for a in album["assets"]) == sorted(ids)
# Hashes are of the bytes on disk right now, not a remembered value.
for asset in album["assets"]:
assert asset["current_sha256"] == sha256_file(asset["current_path"])
assert report["token"].startswith("v1:")
assert _snapshot(lib) == before, "preflight must not touch the library"
def test_command_preview_shows_folder_and_album_without_the_key(tmp_path, immich_server):
url, _ = immich_server
config, sf, lib = _env(tmp_path, url)
folder, _ = _album(sf, lib)
preview = _service(sf, config).preflight()["albums"][0]["command_preview"]
assert f"--api-key={'***'}" in preview
assert "--album-name=rome" in preview
assert str(folder) in preview
assert SENTINEL_KEY not in " ".join(preview)
def test_scoping_to_one_album_excludes_the_others(tmp_path, immich_server):
url, _ = immich_server
config, sf, lib = _env(tmp_path, url)
_album(sf, lib, "rome")
_album(sf, lib, "paris", names=("c.jpg",))
report = _service(sf, config).preflight(["paris"])
assert [album["album"] for album in report["albums"]] == ["paris"]
assert report["totals"]["assets"] == 1
def test_unknown_album_in_scope_is_refused(tmp_path, immich_server):
url, _ = immich_server
config, sf, lib = _env(tmp_path, url)
_album(sf, lib, "rome")
with pytest.raises(UploadError):
_service(sf, config).preflight(["atlantis"])
def test_empty_scope_is_a_blocker(tmp_path, immich_server):
url, _ = immich_server
config, sf, _ = _env(tmp_path, url)
report = _service(sf, config).preflight()
assert report["state"] == "blocked" and "empty_scope" in _codes(report)
# ── credentials and environment ──────────────────────────────────────────────
def test_missing_api_key_blocks_without_touching_the_server(tmp_path, immich_server):
url, _ = immich_server
config, sf, lib = _env(tmp_path, url, credential=None)
_album(sf, lib)
report = _service(sf, config).preflight()
assert report["state"] == "blocked"
assert "credentials_missing" in _codes(report)
assert report["credentials"]["api_key_configured"] is False
def test_unreachable_server_blocks(tmp_path, immich_server):
url, _ = immich_server
config, sf, lib = _env(tmp_path, url.replace(url.rsplit(":", 1)[-1], "1"))
_album(sf, lib)
report = _service(sf, config).preflight()
assert "server_unreachable" in _codes(report)
assert report["server"]["reachable"] is False and report["server"]["detail"]
def test_server_that_is_not_immich_blocks(tmp_path, immich_server):
url, handler = immich_server
handler.payload = b'{"error":"unauthorized"}'
handler.status = 401
config, sf, lib = _env(tmp_path, url)
_album(sf, lib)
assert "server_unreachable" in _codes(_service(sf, config).preflight())
def test_missing_uploader_blocks(tmp_path, immich_server):
url, _ = immich_server
config, sf, lib = _env(tmp_path, url, uploader=tmp_path / "does-not-exist")
_album(sf, lib)
report = _service(sf, config).preflight()
assert "immich_go_missing" in _codes(report)
assert report["uploader"]["installed"] is False and report["uploader"]["version"] is None
def test_half_applied_rename_blocks_upload(tmp_path, immich_server):
"""Album paths must be final before upload: an operation that may have already
touched the disk blocks every other mutation until it is recovered (US04-04)."""
url, _ = immich_server
config, sf, lib = _env(tmp_path, url)
folder, _ = _album(sf, lib)
with sf() as session:
plan_id = str(uuid.uuid4())
session.add(RenamePlan(id=plan_id, state="applying", operation_count=1))
session.flush()
session.add(
RenameOperation(
id=str(uuid.uuid4()),
plan_id=plan_id,
sequence=0,
operation="move_folder",
source_path=str(folder),
destination_path=str(lib / "2019 Rome"),
journal_state="moving",
)
)
session.commit()
report = _service(sf, config).preflight()
assert report["state"] == "blocked" and "rename_pending" in _codes(report)
def test_no_secret_appears_anywhere_in_the_report(tmp_path, immich_server):
url, _ = immich_server
config, sf, lib = _env(tmp_path, url)
_album(sf, lib)
report = _service(sf, config).preflight()
assert SENTINEL_KEY not in json.dumps(report, default=str)
# ── stage readiness ──────────────────────────────────────────────────────────
@pytest.mark.parametrize(
"kwargs,code",
[
({"decision": None}, "safety_undecided"),
({"decision": "deferred"}, "safety_deferred"),
({"exif_verified": False}, "safety_exif_unverified"),
({"analyzed": False}, "analysis_incomplete"),
],
)
def test_blocked_stage_blocks_upload(tmp_path, immich_server, kwargs, code):
url, _ = immich_server
config, sf, lib = _env(tmp_path, url)
_album(sf, lib, **kwargs)
report = _service(sf, config).preflight()
assert report["state"] == "blocked"
assert code in _codes(report)
assert report["albums"][0]["eligible_count"] == 0
def test_reviewed_nsfw_asset_is_upload_eligible_without_analysis(tmp_path, immich_server):
"""NSFW never reaches the analyser, but a verified nsfw keyword makes it
uploadable (concept §8 eligibility table)."""
url, _ = immich_server
config, sf, lib = _env(tmp_path, url)
_album(sf, lib, decision="nsfw", analyzed=False)
report = _service(sf, config).preflight()
assert report["state"] == "ready"
assert report["albums"][0]["eligible_count"] == 2
def test_changed_bytes_block_the_album(tmp_path, immich_server):
url, _ = immich_server
config, sf, lib = _env(tmp_path, url)
folder, _ = _album(sf, lib)
(folder / "a.jpg").write_bytes(b"edited after the checkpoint")
report = _service(sf, config).preflight()
assert "bytes_changed" in _codes(report)
assert report["albums"][0]["blocked_count"] == 1
def test_missing_file_blocks_the_album(tmp_path, immich_server):
url, _ = immich_server
config, sf, lib = _env(tmp_path, url)
folder, _ = _album(sf, lib)
(folder / "a.jpg").unlink()
assert "file_missing" in _codes(_service(sf, config).preflight())
# ── partial scope ────────────────────────────────────────────────────────────
def test_partial_album_is_blocked_until_explicitly_approved(tmp_path, immich_server):
url, _ = immich_server
config, sf, lib = _env(tmp_path, url)
folder, _ = _album(sf, lib)
(folder / "a.jpg").write_bytes(b"changed")
blocked = _service(sf, config).preflight()
approved = _service(sf, config).preflight(allow_partial=True)
assert blocked["state"] == "blocked"
assert "partial_scope" in {issue["code"] for issue in blocked["albums"][0]["blockers"]}
assert approved["state"] == "ready"
assert approved["albums"][0]["partial"] is True
assert approved["albums"][0]["eligible_count"] == 1
# The approval is part of the token, so it can never be replayed as a full run.
assert approved["token"] != blocked["token"]
def test_partial_approval_cannot_rescue_an_album_with_nothing_ready(tmp_path, immich_server):
url, _ = immich_server
config, sf, lib = _env(tmp_path, url)
_album(sf, lib, decision=None)
report = _service(sf, config).preflight(allow_partial=True)
assert report["state"] == "blocked"
assert "empty_scope" in {issue["code"] for issue in report["albums"][0]["blockers"]}
# ── token ────────────────────────────────────────────────────────────────────
def test_token_is_stable_while_nothing_relevant_changes(tmp_path, immich_server):
url, _ = immich_server
config, sf, lib = _env(tmp_path, url)
_album(sf, lib)
service = _service(sf, config)
first = service.preflight()["token"]
assert service.preflight()["token"] == first
assert service.verify_token(first) is True
@pytest.mark.parametrize("scope", [None, ["rome"]])
def test_edited_bytes_make_the_token_stale(tmp_path, immich_server, scope):
url, _ = immich_server
config, sf, lib = _env(tmp_path, url)
folder, _ = _album(sf, lib)
service = _service(sf, config)
token = service.preflight(scope)["token"]
(folder / "b.jpg").write_bytes(b"edited outside the app")
assert service.verify_token(token, scope) is False
def test_changed_decision_makes_the_token_stale(tmp_path, immich_server):
url, _ = immich_server
config, sf, lib = _env(tmp_path, url)
_, ids = _album(sf, lib)
service = _service(sf, config)
token = service.preflight()["token"]
with sf() as session:
session.add(
SafetyReview(
id=str(uuid.uuid4()),
asset_id=ids[0],
decision="deferred",
created_at=datetime(2099, 1, 1, tzinfo=timezone.utc), # the latest review wins
)
)
session.commit()
assert service.verify_token(token) is False
def test_token_from_a_different_scope_is_rejected(tmp_path, immich_server):
url, _ = immich_server
config, sf, lib = _env(tmp_path, url)
_album(sf, lib, "rome")
_album(sf, lib, "paris", names=("c.jpg",))
service = _service(sf, config)
assert service.verify_token(service.preflight(["rome"])["token"], ["paris"]) is False
assert service.verify_token("v1:not-a-real-token") is False
assert service.verify_token("") is False
# ── API surface ──────────────────────────────────────────────────────────────
def test_api_preflight_returns_the_report_without_secrets(tmp_path, immich_server):
url, _ = immich_server
config, sf, lib = _env(tmp_path, url)
_album(sf, lib)
with TestClient(create_app(config)) as client:
response = client.post("/api/v1/upload-preflight", json={})
assert response.status_code == 200
body = response.json()
assert body["state"] == "ready" and body["token"].startswith("v1:")
assert SENTINEL_KEY not in response.text
def test_api_rejects_an_unknown_album(tmp_path, immich_server):
url, _ = immich_server
config, sf, lib = _env(tmp_path, url)
_album(sf, lib)
with TestClient(create_app(config)) as client:
response = client.post("/api/v1/upload-preflight", json={"albums": ["atlantis"]})
assert response.status_code == 422
assert response.json()["error"]["code"] == "unknown_album"

View File

@@ -0,0 +1,335 @@
"""Durable per-item upload outcomes (US05-03).
The uploader is a real executable that prints a real report, driven through the
real batch service, so the classification path exercised here is the one
production uses. What matters is that the stored outcome is never more optimistic
than the evidence: an unmentioned file, an unpinned uploader version, or counts
that disagree all leave the batch requiring verification.
"""
import json
import stat
import threading
import uuid
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from photo_pipeline.api.app import create_app
from photo_pipeline.config import Config
from photo_pipeline.db import create_db_engine, create_session_factory, run_migrations
from photo_pipeline.integrations import immich_go_report as report_parser
from photo_pipeline.models import AnalysisResult, Asset, SafetyReview
from photo_pipeline.services.hashing import sha256_file
from photo_pipeline.services.upload_batches import BatchState, UploadBatchService
from photo_pipeline.services.upload_reports import (
REQUIRES_VERIFICATION,
VERIFIED,
UploadReportService,
)
from photo_pipeline.services.uploads import UploadService
pytestmark = pytest.mark.phase_e # part of the Phase E acceptance gate (US05-06)
NOW = datetime(2026, 1, 1, tzinfo=timezone.utc)
SUPPORTED_VERSION = "immich-go 0.21.0"
# ── fake external boundary ───────────────────────────────────────────────────
class _PingHandler(BaseHTTPRequestHandler):
def do_GET(self): # noqa: N802 (BaseHTTPRequestHandler API)
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b'{"res":"pong"}')
def log_message(self, *args):
pass
@pytest.fixture
def immich_server():
server = HTTPServer(("127.0.0.1", 0), _PingHandler)
threading.Thread(target=server.serve_forever, daemon=True).start()
yield f"http://127.0.0.1:{server.server_port}"
server.shutdown()
server.server_close()
def _uploader(tmp_path, report_body: str, *, version=SUPPORTED_VERSION):
"""An uploader that answers ``--version`` and prints ``report_body``."""
path = tmp_path / "immich-go"
path.write_text(
"#!/bin/sh\n"
f'if [ "$1" = "--version" ]; then echo "{version}"; exit 0; fi\n'
f"cat <<'REPORT'\n{report_body}\nREPORT\n"
"exit 0\n"
)
path.chmod(path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
return path
# ── environment ──────────────────────────────────────────────────────────────
def _env(tmp_path, server_url, uploader):
(tmp_path / "data").mkdir(exist_ok=True)
lib = tmp_path / "lib"
lib.mkdir(exist_ok=True)
config = Config.from_env(
{
"PHOTO_PIPELINE_DATA_DIR": str(tmp_path / "data"),
"PHOTO_PIPELINE_LIBRARY_ROOTS": str(lib),
"PHOTO_PIPELINE_IMMICH_SERVER_URL": server_url,
"PHOTO_PIPELINE_IMMICH_API_KEY": "immich-sentinel-9f3a2b",
"PHOTO_PIPELINE_IMMICH_GO_BINARY": str(uploader),
}
)
run_migrations(config.database_url)
return config, create_session_factory(create_db_engine(config.database_url)), lib
def _album(sf, lib, album="rome", names=("new.jpg", "dup.jpg")):
folder = lib / album
folder.mkdir(parents=True, exist_ok=True)
with sf() as session:
for name in names:
path = folder / name
path.write_bytes(f"{album}/{name}".encode() * 16)
asset_id = str(uuid.uuid4())
session.add(
Asset(
id=asset_id,
original_path=str(path),
current_path=str(path),
discovered_at=NOW,
hash_version=1,
byte_size=path.stat().st_size,
current_sha256=sha256_file(path),
)
)
session.add(
SafetyReview(
id=str(uuid.uuid4()),
asset_id=asset_id,
decision="sfw",
exif_verified_at=NOW,
)
)
session.add(AnalysisResult(asset_id=asset_id, status="analyzed", exif_written_at=NOW))
session.commit()
return folder
def _run(sf, config, albums=None):
"""Create a batch from a fresh preflight and run it to completion."""
report = UploadService(sf, config=config).preflight(albums)
assert report["state"] == "ready", report["blockers"]
service = UploadBatchService(sf, config=config)
(batch,) = service.create(albums, token=report["token"])
return service.run(batch["id"])
def _outcomes(batch) -> dict:
return {Path(item["path"]).name: item["outcome"] for item in batch["items"]}
def _report_for(folder, **outcomes) -> str:
"""A text-v1 report plus a matching summary line."""
lines = {
"uploaded": "INFO uploaded {path}",
"duplicate": "INFO server has the same file {path}",
"upgraded": "INFO server has an older file, upgrading {path}",
"failed": "ERROR error uploading {path}: connection reset",
}
body = [lines[outcome].format(path=folder / name) for name, outcome in outcomes.items()]
totals: dict[str, int] = {}
for outcome in outcomes.values():
totals[outcome] = totals.get(outcome, 0) + 1
words = {
"uploaded": "Uploaded",
"duplicate": "duplicates",
"upgraded": "upgraded",
"failed": "errors",
}
body.append(", ".join(f"{words[k]} {v}" for k, v in totals.items()))
return "\n".join(body)
# ── classification ───────────────────────────────────────────────────────────
def test_every_file_gets_its_reported_outcome_with_evidence(tmp_path, immich_server):
lib = tmp_path / "lib"
report = _report_for(lib / "rome", **{"new.jpg": "uploaded", "dup.jpg": "duplicate"})
config, sf, lib = _env(tmp_path, immich_server, _uploader(tmp_path, report))
_album(sf, lib)
batch = _run(sf, config)
assert batch["state"] == BatchState.SUCCEEDED
assert batch["outcome_state"] == VERIFIED
assert _outcomes(batch) == {"new.jpg": "uploaded", "dup.jpg": "duplicate"}
assert batch["outcome_counts"]["uploaded"] == 1
assert batch["outcome_counts"]["duplicate"] == 1
assert batch["parser"] == "text-v1"
assert batch["parser_version"] == report_parser.PARSER_VERSION
assert batch["parsed_at"]
for item in batch["items"]:
assert item["sha1"] and item["sha256"], "the uploaded bytes stay identifiable"
assert Path(item["path"]).name in item["evidence"]
assert item["outcome_at"]
def test_a_file_the_report_never_mentions_is_unknown_not_sent(tmp_path, immich_server):
"""Exit code 0 says the process ended well, not that this photo reached Immich."""
lib = tmp_path / "lib"
report = _report_for(lib / "rome", **{"new.jpg": "uploaded"})
config, sf, lib = _env(tmp_path, immich_server, _uploader(tmp_path, report))
_album(sf, lib)
batch = _run(sf, config)
assert batch["state"] == BatchState.SUCCEEDED, "the process itself succeeded"
assert _outcomes(batch)["dup.jpg"] == report_parser.UNKNOWN
assert batch["outcome_state"] == REQUIRES_VERIFICATION
def test_an_unsupported_uploader_version_makes_every_item_unknown(tmp_path, immich_server):
lib = tmp_path / "lib"
report = _report_for(lib / "rome", **{"new.jpg": "uploaded", "dup.jpg": "uploaded"})
uploader = _uploader(tmp_path, report, version="immich-go 9.99.0")
config, sf, lib = _env(tmp_path, immich_server, uploader)
_album(sf, lib)
batch = _run(sf, config)
assert batch["parser"] is None
assert set(_outcomes(batch).values()) == {report_parser.UNKNOWN}
assert batch["outcome_state"] == REQUIRES_VERIFICATION
def test_an_empty_report_leaves_everything_unknown(tmp_path, immich_server):
config, sf, lib = _env(tmp_path, immich_server, _uploader(tmp_path, ""))
_album(sf, lib)
batch = _run(sf, config)
assert set(_outcomes(batch).values()) == {report_parser.UNKNOWN}
assert batch["outcome_state"] == REQUIRES_VERIFICATION
def test_a_failed_upload_is_classified_from_its_own_line(tmp_path, immich_server):
lib = tmp_path / "lib"
report = _report_for(lib / "rome", **{"new.jpg": "uploaded", "dup.jpg": "failed"})
config, sf, lib = _env(tmp_path, immich_server, _uploader(tmp_path, report))
_album(sf, lib)
batch = _run(sf, config)
assert _outcomes(batch) == {"new.jpg": "uploaded", "dup.jpg": "failed"}
# Every file is accounted for, so the evidence is complete even though one
# upload failed — resolving the failure is US05-04's job, not a re-parse.
assert batch["outcome_state"] == VERIFIED
# ── reconciliation ───────────────────────────────────────────────────────────
def test_a_summary_that_disagrees_with_the_lines_requires_verification(tmp_path, immich_server):
lib = tmp_path / "lib"
report = (
_report_for(lib / "rome", **{"new.jpg": "uploaded", "dup.jpg": "uploaded"}).rsplit("\n", 1)[
0
]
+ "\nUploaded 5"
)
config, sf, lib = _env(tmp_path, immich_server, _uploader(tmp_path, report))
_album(sf, lib)
batch = _run(sf, config)
assert batch["report_counts"] == {"uploaded": 5}
assert batch["outcome_counts"]["uploaded"] == 2
assert batch["outcome_state"] == REQUIRES_VERIFICATION
def test_a_reported_file_outside_the_batch_requires_verification(tmp_path, immich_server):
"""The uploader touched something the batch never approved."""
lib = tmp_path / "lib"
report = _report_for(
lib / "rome", **{"new.jpg": "uploaded", "dup.jpg": "uploaded", "stranger.jpg": "uploaded"}
)
config, sf, lib = _env(tmp_path, immich_server, _uploader(tmp_path, report))
_album(sf, lib)
batch = _run(sf, config)
assert set(_outcomes(batch).values()) == {"uploaded"}
assert batch["outcome_state"] == REQUIRES_VERIFICATION
# ── idempotency and restart ──────────────────────────────────────────────────
def test_reprocessing_the_same_report_is_idempotent(tmp_path, immich_server):
lib = tmp_path / "lib"
report = _report_for(lib / "rome", **{"new.jpg": "uploaded", "dup.jpg": "duplicate"})
config, sf, lib = _env(tmp_path, immich_server, _uploader(tmp_path, report))
_album(sf, lib)
batch = _run(sf, config)
first = UploadReportService(sf).ingest(batch["id"])
second = UploadReportService(sf).ingest(batch["id"])
assert first == second
reread = UploadBatchService(sf, config=config).get(batch["id"])
assert _outcomes(reread) == _outcomes(batch)
assert len(reread["items"]) == 2, "re-import must not duplicate item rows"
assert reread["outcome_counts"] == batch["outcome_counts"]
def test_outcomes_survive_a_restart_and_reach_the_api(tmp_path, immich_server):
lib = tmp_path / "lib"
report = _report_for(lib / "rome", **{"new.jpg": "uploaded", "dup.jpg": "duplicate"})
config, sf, lib = _env(tmp_path, immich_server, _uploader(tmp_path, report))
_album(sf, lib)
batch = _run(sf, config)
with TestClient(create_app(config)) as client: # a fresh application process
fetched = client.get(f"/api/v1/upload-batches/{batch['id']}").json()
assert fetched["outcome_state"] == VERIFIED
assert {Path(i["path"]).name: i["outcome"] for i in fetched["items"]} == {
"new.jpg": "uploaded",
"dup.jpg": "duplicate",
}
assert json.dumps(fetched) # the record stays JSON-serialisable for the UI
def test_a_missing_report_file_does_not_lose_the_batch(tmp_path, immich_server):
lib = tmp_path / "lib"
report = _report_for(lib / "rome", **{"new.jpg": "uploaded", "dup.jpg": "duplicate"})
config, sf, lib = _env(tmp_path, immich_server, _uploader(tmp_path, report))
_album(sf, lib)
batch = _run(sf, config)
Path(batch["report_path"]).unlink()
result = UploadReportService(sf).ingest(batch["id"])
assert result["outcome_state"] == REQUIRES_VERIFICATION
assert set(_outcomes(UploadBatchService(sf, config=config).get(batch["id"])).values()) == {
report_parser.UNKNOWN
}
def test_ingesting_an_unknown_batch_is_an_error(tmp_path, immich_server):
config, sf, lib = _env(tmp_path, immich_server, _uploader(tmp_path, ""))
with pytest.raises(KeyError):
UploadReportService(sf).ingest("does-not-exist")

View File

@@ -0,0 +1,537 @@
"""Verifying, retrying, and resolving uncertain uploads (US05-04).
The Immich boundary is a real HTTP server here: it answers ``/api/server/ping``
and ``/api/assets/bulk-upload-check`` exactly as the app's own adapter parses
them, and the set of checksums it "holds" is what each fault scenario controls.
The uploader stays a real executable driven through the real batch service, so
every state under test is reached the way production reaches it.
The invariant these tests defend is one-directional: uncertainty may only become
success when something authoritative said so — the server, or an operator who
recorded what they checked.
"""
import json
import stat
import threading
import uuid
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from photo_pipeline.api.app import create_app
from photo_pipeline.config import Config
from photo_pipeline.db import create_db_engine, create_session_factory, run_migrations
from photo_pipeline.models import AnalysisResult, Asset, SafetyReview, UploadBatch
from photo_pipeline.services.hashing import sha256_file
from photo_pipeline.services.upload_batches import BatchError, BatchState, UploadBatchService
from photo_pipeline.services.upload_reports import REQUIRES_VERIFICATION, VERIFIED
from photo_pipeline.services.upload_verification import (
ABSENT,
INCONCLUSIVE,
MANUAL,
PRESENT,
UploadVerificationService,
VerificationError,
retry_blockers,
)
from photo_pipeline.services.uploads import UploadService
pytestmark = pytest.mark.phase_e # part of the Phase E acceptance gate (US05-06)
NOW = datetime(2026, 1, 1, tzinfo=timezone.utc)
SENTINEL_KEY = "immich-sentinel-9f3a2b"
UPLOADER_VERSION = "immich-go 0.21.0"
# ── fake Immich ──────────────────────────────────────────────────────────────
class _FakeImmich:
"""The server's view of the world: which checksums it holds, and whether it
is willing to answer at all."""
def __init__(self) -> None:
self.held: set[str] = set()
self.available = True
self.checked: list[str] = []
def _handler(state: _FakeImmich):
class Handler(BaseHTTPRequestHandler):
def do_GET(self): # noqa: N802 (BaseHTTPRequestHandler API)
self._json(200, {"res": "pong"})
def do_POST(self): # noqa: N802
body = json.loads(self.rfile.read(int(self.headers["Content-Length"] or 0)) or "{}")
if not state.available:
self._json(503, {"error": "unavailable"})
return
if self.headers.get("x-api-key") != SENTINEL_KEY:
self._json(401, {"error": "unauthorized"})
return
results = []
for asset in body.get("assets", []):
state.checked.append(asset["checksum"])
results.append(
{"id": asset["id"], "action": "reject", "reason": "duplicate"}
if asset["checksum"] in state.held
else {"id": asset["id"], "action": "accept"}
)
self._json(200, {"results": results})
def _json(self, status: int, payload: dict) -> None:
body = json.dumps(payload).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
return Handler
@pytest.fixture
def immich():
state = _FakeImmich()
server = HTTPServer(("127.0.0.1", 0), _handler(state))
threading.Thread(target=server.serve_forever, daemon=True).start()
state.url = f"http://127.0.0.1:{server.server_port}"
yield state
server.shutdown()
server.server_close()
# ── environment ──────────────────────────────────────────────────────────────
def _uploader(tmp_path, report_body: str = "", *, exit_code: int = 0, version=UPLOADER_VERSION):
path = tmp_path / "immich-go"
path.write_text(
"#!/bin/sh\n"
f'if [ "$1" = "--version" ]; then echo "{version}"; exit 0; fi\n'
f"cat <<'REPORT'\n{report_body}\nREPORT\n"
f"exit {exit_code}\n"
)
path.chmod(path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
return path
def _env(tmp_path, immich, uploader=None):
(tmp_path / "data").mkdir(exist_ok=True)
lib = tmp_path / "lib"
lib.mkdir(exist_ok=True)
config = Config.from_env(
{
"PHOTO_PIPELINE_DATA_DIR": str(tmp_path / "data"),
"PHOTO_PIPELINE_LIBRARY_ROOTS": str(lib),
"PHOTO_PIPELINE_IMMICH_SERVER_URL": immich.url,
"PHOTO_PIPELINE_IMMICH_API_KEY": SENTINEL_KEY,
"PHOTO_PIPELINE_IMMICH_GO_BINARY": str(
uploader if uploader is not None else _uploader(tmp_path)
),
}
)
run_migrations(config.database_url)
return config, create_session_factory(create_db_engine(config.database_url)), lib
def _album(sf, lib, album="rome", names=("a.jpg", "b.jpg")):
folder = lib / album
folder.mkdir(parents=True, exist_ok=True)
with sf() as session:
for name in names:
path = folder / name
path.write_bytes(f"{album}/{name}".encode() * 16)
asset_id = str(uuid.uuid4())
session.add(
Asset(
id=asset_id,
original_path=str(path),
current_path=str(path),
discovered_at=NOW,
hash_version=1,
byte_size=path.stat().st_size,
current_sha256=sha256_file(path),
)
)
session.add(
SafetyReview(
id=str(uuid.uuid4()), asset_id=asset_id, decision="sfw", exif_verified_at=NOW
)
)
session.add(AnalysisResult(asset_id=asset_id, status="analyzed", exif_written_at=NOW))
session.commit()
return folder
def _batch(sf, config, albums=None):
report = UploadService(sf, config=config).preflight(albums)
assert report["state"] == "ready", report["blockers"]
(batch,) = UploadBatchService(sf, config=config).create(albums, token=report["token"])
return batch
def _interrupt(sf, config, batch_id):
"""Leave behind exactly what a killed worker leaves: a mid-flight attempt."""
with sf() as session:
session.get(UploadBatch, batch_id).state = BatchState.RUNNING
session.commit()
UploadBatchService(sf, config=config).recover()
def _server_holds(immich, batch):
immich.held.update(item["sha1"] for item in batch["items"])
def _by_name(batch) -> dict:
return {Path(item["path"]).name: item for item in batch["items"]}
def _uncertain_batch(tmp_path, immich, *, hold: bool):
"""A batch whose attempt died mid-upload, with the server holding the bytes or not."""
config, sf, lib = _env(tmp_path, immich)
_album(sf, lib)
batch = _batch(sf, config)
if hold:
_server_holds(immich, batch)
_interrupt(sf, config, batch["id"])
return config, sf, lib, batch
# ── accepted-but-unknown ─────────────────────────────────────────────────────
def test_acceptance_then_lost_response_is_confirmed_by_the_server(tmp_path, immich):
"""The classic lost response: Immich took the files, the app never saw a report."""
config, sf, lib, batch = _uncertain_batch(tmp_path, immich, hold=True)
result = UploadVerificationService(sf, config=config).verify(batch["id"])
assert result["state"] == BatchState.SUCCEEDED, "server evidence resolves the uncertainty"
assert result["outcome_state"] == VERIFIED
assert result["counts"] == {PRESENT: 2}
verified = UploadBatchService(sf, config=config).get(batch["id"])
for item in verified["items"]:
assert item["outcome"] == "uploaded"
assert item["verification"] == PRESENT
assert item["sha1"] in item["evidence"], "the exact bytes are named in the evidence"
assert immich.checked, "verification actually asked the server"
def test_timeout_before_acceptance_leaves_a_safe_retry(tmp_path, immich):
"""Nothing arrived, so the batch becomes a plain failure and may run again."""
config, sf, lib, batch = _uncertain_batch(tmp_path, immich, hold=False)
service = UploadBatchService(sf, config=config)
result = UploadVerificationService(sf, config=config).verify(batch["id"])
assert result["state"] == BatchState.FAILED
assert result["counts"] == {ABSENT: 2}
assert {item["outcome"] for item in service.get(batch["id"])["items"]} == {"failed"}
assert retry_blockers(service.get(batch["id"])) == []
assert service.run(batch["id"])["state"] == BatchState.SUCCEEDED
def test_an_uncertain_batch_cannot_be_retried_before_verification(tmp_path, immich):
config, sf, lib, batch = _uncertain_batch(tmp_path, immich, hold=True)
service = UploadBatchService(sf, config=config)
with pytest.raises(BatchError) as error:
service.run(batch["id"])
assert error.value.code == "requires_verification"
with TestClient(create_app(config)) as client:
response = client.post(f"/api/v1/upload-batches/{batch['id']}/start")
assert response.status_code == 409
assert response.json()["error"]["code"] == "requires_verification"
def test_a_partly_arrived_batch_is_a_failure_not_a_success(tmp_path, immich):
config, sf, lib = _env(tmp_path, immich)
_album(sf, lib)
batch = _batch(sf, config)
immich.held.add(batch["items"][0]["sha1"]) # only one file made it
_interrupt(sf, config, batch["id"])
result = UploadVerificationService(sf, config=config).verify(batch["id"])
assert result["state"] == BatchState.FAILED
assert result["counts"] == {PRESENT: 1, ABSENT: 1}
assert result["outcome_state"] == VERIFIED, "every file is accounted for"
# ── parser uncertainty ───────────────────────────────────────────────────────
def test_parser_uncertainty_is_settled_by_server_evidence(tmp_path, immich):
"""An unpinned uploader version leaves every item unknown; the server decides."""
config, sf, lib = _env(tmp_path, immich, _uploader(tmp_path, "done", version="immich-go 9.9.9"))
_album(sf, lib)
batch = _batch(sf, config)
service = UploadBatchService(sf, config=config)
ran = service.run(batch["id"])
assert ran["state"] == BatchState.SUCCEEDED and ran["outcome_state"] == REQUIRES_VERIFICATION
_server_holds(immich, ran)
result = UploadVerificationService(sf, config=config).verify(batch["id"])
assert result["outcome_state"] == VERIFIED
assert {item["outcome"] for item in service.get(batch["id"])["items"]} == {"uploaded"}
def test_an_unreachable_server_never_turns_uncertainty_into_success(tmp_path, immich):
config, sf, lib, batch = _uncertain_batch(tmp_path, immich, hold=True)
immich.available = False
service = UploadBatchService(sf, config=config)
result = UploadVerificationService(sf, config=config).verify(batch["id"])
assert result["server_reachable"] is False
assert result["detail"], "the reason the server could not answer is reported"
assert result["counts"] == {INCONCLUSIVE: 2}
assert result["state"] == BatchState.UNKNOWN, "still uncertain, not succeeded"
assert result["outcome_state"] == REQUIRES_VERIFICATION
assert [b["code"] for b in retry_blockers(service.get(batch["id"]))] == [
"requires_verification"
]
# ── safe failures ────────────────────────────────────────────────────────────
def test_a_plain_uploader_failure_is_retryable_without_verification(tmp_path, immich):
"""Nothing uncertain happened: the process failed before/while reporting an error."""
config, sf, lib = _env(tmp_path, immich, _uploader(tmp_path, "boom", exit_code=1))
_album(sf, lib)
batch = _batch(sf, config)
service = UploadBatchService(sf, config=config)
assert service.run(batch["id"])["state"] == BatchState.FAILED
assert retry_blockers(service.get(batch["id"])) == []
with TestClient(create_app(config)) as client:
assert client.post(f"/api/v1/upload-batches/{batch['id']}/start").status_code == 200
# ── changed bytes ────────────────────────────────────────────────────────────
def test_bytes_changed_after_upload_warn_and_block_a_rerun(tmp_path, immich):
config, sf, lib = _env(tmp_path, immich)
folder = _album(sf, lib)
batch = _batch(sf, config)
service = UploadBatchService(sf, config=config)
ran = service.run(batch["id"])
_server_holds(immich, ran)
(folder / "a.jpg").write_bytes(b"edited after the upload")
result = UploadVerificationService(sf, config=config).verify(batch["id"])
assert result["stale_bytes"] is True
changed = _by_name(result)["a.jpg"]
assert changed["changed_after_upload"] is True
assert _by_name(result)["b.jpg"]["changed_after_upload"] is False
stored = service.get(batch["id"])
assert stored["stale_bytes"] is True, "the warning is durable, not only in the response"
assert _by_name(stored)["a.jpg"]["observed_sha256"] != _by_name(stored)["a.jpg"]["sha256"]
with pytest.raises(BatchError) as error:
service.run(batch["id"])
assert error.value.code == "changed_after_upload"
with TestClient(create_app(config)) as client:
response = client.post(f"/api/v1/upload-batches/{batch['id']}/start")
assert response.status_code == 409
assert response.json()["error"]["code"] == "changed_after_upload"
def test_a_deleted_file_counts_as_changed_after_upload(tmp_path, immich):
config, sf, lib = _env(tmp_path, immich)
folder = _album(sf, lib)
batch = _batch(sf, config)
ran = UploadBatchService(sf, config=config).run(batch["id"])
_server_holds(immich, ran)
(folder / "a.jpg").unlink()
result = UploadVerificationService(sf, config=config).verify(batch["id"])
assert _by_name(result)["a.jpg"]["changed_after_upload"] is True
assert result["stale_bytes"] is True
# The bytes are still on the server: verification is about the upload, not the
# local file's continued existence.
assert _by_name(result)["a.jpg"]["verification"] == PRESENT
# ── repeated verification and history ────────────────────────────────────────
def test_repeated_verification_converges_and_keeps_every_answer(tmp_path, immich):
config, sf, lib, batch = _uncertain_batch(tmp_path, immich, hold=True)
service = UploadVerificationService(sf, config=config)
first = service.verify(batch["id"])
second = service.verify(batch["id"])
assert first["counts"] == second["counts"] == {PRESENT: 2}
assert first["state"] == second["state"] == BatchState.SUCCEEDED
history = service.history(batch["id"])
assert len(history) == 4, "the audit trail appends, it never overwrites"
assert {entry["source"] for entry in history} == {"immich_api"}
assert all(entry["action"] == "verify" for entry in history)
def test_verification_that_changes_its_mind_keeps_both_answers(tmp_path, immich):
"""A file that was absent and later present must show both, in order."""
config, sf, lib, batch = _uncertain_batch(tmp_path, immich, hold=False)
service = UploadVerificationService(sf, config=config)
service.verify(batch["id"])
_server_holds(immich, batch)
service.verify(batch["id"])
asset_id = batch["items"][0]["asset_id"]
results = [e["result"] for e in service.history(batch["id"]) if e["asset_id"] == asset_id]
assert results == [ABSENT, PRESENT]
# ── manual resolution ────────────────────────────────────────────────────────
def test_manual_resolution_requires_evidence_and_an_author(tmp_path, immich):
config, sf, lib, batch = _uncertain_batch(tmp_path, immich, hold=True)
service = UploadVerificationService(sf, config=config)
asset_id = batch["items"][0]["asset_id"]
for kwargs, code in (
({"evidence": " ", "actor": "dom"}, "evidence_required"),
({"evidence": "checked in Immich", "actor": ""}, "actor_required"),
({"evidence": "checked", "actor": "dom", "outcome": "definitely-fine"}, "invalid_outcome"),
):
with pytest.raises(VerificationError) as error:
service.resolve(batch["id"], asset_id, **{"outcome": "uploaded", **kwargs})
assert error.value.code == code
assert service.history(batch["id"]) == [], "a refused resolution records nothing"
assert UploadBatchService(sf, config=config).get(batch["id"])["state"] == BatchState.UNKNOWN
def test_manual_resolution_is_recorded_as_operator_evidence(tmp_path, immich):
"""An operator may settle what the server cannot — but never anonymously."""
config, sf, lib, batch = _uncertain_batch(tmp_path, immich, hold=True)
immich.available = False
service = UploadVerificationService(sf, config=config)
service.verify(batch["id"]) # inconclusive: the server is down
for item in batch["items"]:
result = service.resolve(
batch["id"],
item["asset_id"],
outcome="uploaded",
evidence="found in Immich by checksum in the web UI",
actor="dom",
)
assert result["state"] == BatchState.SUCCEEDED
assert result["outcome_state"] == VERIFIED
resolutions = [e for e in service.history(batch["id"]) if e["action"] == "resolve"]
assert len(resolutions) == 2
assert {e["actor"] for e in resolutions} == {"dom"}
assert {e["source"] for e in resolutions} == {"operator"}
assert all("web UI" in e["evidence"] for e in resolutions)
stored = UploadBatchService(sf, config=config).get(batch["id"])
assert {item["verification"] for item in stored["items"]} == {MANUAL}, (
"a manual answer stays distinguishable from server evidence"
)
def test_resolving_one_item_leaves_the_batch_uncertain(tmp_path, immich):
config, sf, lib, batch = _uncertain_batch(tmp_path, immich, hold=True)
immich.available = False
service = UploadVerificationService(sf, config=config)
service.verify(batch["id"])
result = service.resolve(
batch["id"],
batch["items"][0]["asset_id"],
outcome="uploaded",
evidence="visible in Immich",
actor="dom",
)
assert result["state"] == BatchState.UNKNOWN
assert result["outcome_state"] == REQUIRES_VERIFICATION
def test_resolving_an_unknown_item_or_batch_is_refused(tmp_path, immich):
config, sf, lib, batch = _uncertain_batch(tmp_path, immich, hold=True)
service = UploadVerificationService(sf, config=config)
for batch_id, asset_id in ((batch["id"], "not-in-this-batch"), ("no-such-batch", "x")):
with pytest.raises(VerificationError) as error:
service.resolve(batch_id, asset_id, outcome="uploaded", evidence="checked", actor="dom")
assert error.value.code == "not_found"
def test_verifying_an_unknown_batch_is_refused(tmp_path, immich):
config, sf, lib = _env(tmp_path, immich)
with pytest.raises(VerificationError) as error:
UploadVerificationService(sf, config=config).verify("does-not-exist")
assert error.value.code == "not_found"
# ── API surface and durability ───────────────────────────────────────────────
def test_the_api_verifies_resolves_and_lists_history_without_secrets(tmp_path, immich):
config, sf, lib, batch = _uncertain_batch(tmp_path, immich, hold=False)
with TestClient(create_app(config)) as client:
verified = client.post(f"/api/v1/upload-batches/{batch['id']}/verify")
resolved = client.post(
f"/api/v1/upload-batches/{batch['id']}/resolve",
json={
"asset_id": batch["items"][0]["asset_id"],
"outcome": "skipped",
"evidence": "the file was withdrawn from the album",
"actor": "dom",
},
)
refused = client.post(
f"/api/v1/upload-batches/{batch['id']}/resolve",
json={
"asset_id": batch["items"][0]["asset_id"],
"outcome": "uploaded",
"evidence": "",
"actor": "dom",
},
)
history = client.get(f"/api/v1/upload-batches/{batch['id']}/verifications")
missing = client.post("/api/v1/upload-batches/does-not-exist/verify")
assert verified.status_code == 200 and verified.json()["counts"] == {ABSENT: 2}
assert resolved.status_code == 200
assert refused.status_code == 422 and refused.json()["error"]["code"] == "evidence_required"
assert len(history.json()["verifications"]) == 3
assert missing.status_code == 404
assert SENTINEL_KEY not in verified.text + resolved.text + history.text
def test_verification_survives_a_restart(tmp_path, immich):
config, sf, lib, batch = _uncertain_batch(tmp_path, immich, hold=True)
UploadVerificationService(sf, config=config).verify(batch["id"])
with TestClient(create_app(config)) as client: # a fresh application process
fetched = client.get(f"/api/v1/upload-batches/{batch['id']}").json()
history = client.get(f"/api/v1/upload-batches/{batch['id']}/verifications").json()
assert fetched["state"] == BatchState.SUCCEEDED
assert fetched["verified_at"]
assert {item["verification"] for item in fetched["items"]} == {PRESENT}
assert len(history["verifications"]) == 2
assert json.dumps(fetched) # the record stays JSON-serialisable for the UI

View File

@@ -90,6 +90,26 @@ def test_cooperative_cancellation_leaves_items_resumable(sf, jobs):
assert by_state.get(ItemState.QUEUED) == 1 # "b" left resumable
def test_a_handler_that_stops_itself_releases_the_lock(sf, jobs):
"""A handler may stop without anyone cancelling the *job* — an upload batch
cancelled through its own API does exactly that. The job is still ``running``
when it raises, so it has to reach ``cancelled`` through ``cancelling``; if that
hop is skipped the transition is rejected and the lock is held forever."""
from photo_pipeline.jobs.handlers import Cancelled
def handler(item, ctx):
raise Cancelled("the work this job wraps was stopped elsewhere")
worker = Worker(sf, {"scan": handler}, "w1")
job = jobs.enqueue("scan", lock="library_write", items=["a"])
worker.run_once()
assert jobs.get(job["id"])["state"] == JobState.CANCELLED
assert jobs.progress(job["id"])["by_state"] == {ItemState.QUEUED: 1} # resumable
# The lane is free: the next job may be enqueued under the same lock.
assert jobs.enqueue("scan", lock="library_write", items=["b"])["state"] == JobState.QUEUED
def test_fencing_rejects_superseded_worker(sf, jobs):
job = jobs.enqueue("scan", items=["a"])
stale = jobs.claim(["scan"], "old")

View File

@@ -100,6 +100,43 @@
],
"US04-05": [
"tests/e2e/test_renames_ui.py"
],
"US04-06": [
"tests/e2e/test_phase_d_pipeline.py"
],
"US05-01": [
"tests/integration/test_upload_preflight.py"
],
"US05-02": [
"tests/integration/test_upload_batches.py"
],
"US05-03": [
"tests/unit/test_immich_go_report.py",
"tests/integration/test_upload_reports.py"
],
"US05-04": [
"tests/unit/test_immich_bulk_check.py",
"tests/integration/test_upload_verification.py"
],
"US05-05": [
"tests/e2e/test_uploads_ui.py"
],
"US05-06": [
"tests/e2e/test_phase_e_pipeline.py"
],
"US06-01": [
"tests/integration/test_archive_preflight.py"
],
"US06-02": [
"tests/unit/test_archive_journal_states.py",
"tests/integration/test_archive_transfer.py",
"tests/integration/test_archive_recovery.py"
],
"US06-03": [
"tests/integration/test_offline_assets.py"
],
"US06-04": [
"tests/integration/test_restore.py"
]
}
}

View File

@@ -0,0 +1,105 @@
"""Archive journal state machine and evidence table (US06-02).
The evidence table decides whether an original may be deleted, so every
combination of journal state and disk reality is asserted here as a pure function —
no database, no files. A wrong cell in this table is data loss.
"""
import pytest
from photo_pipeline.services.archive_journal import (
ALLOWED_TRANSITIONS,
FORWARD,
MANUAL,
RESUMABLE,
TERMINAL_STATES,
UNSAFE_STATES,
ArchiveState,
_classify,
can_transition,
)
pytestmark = pytest.mark.phase_f
# ── state machine ────────────────────────────────────────────────────────────
def test_the_happy_path_is_the_only_way_forward():
assert can_transition(ArchiveState.PLANNED, ArchiveState.TRANSFERRING)
assert can_transition(ArchiveState.TRANSFERRING, ArchiveState.VERIFIED)
assert can_transition(ArchiveState.VERIFIED, ArchiveState.REMOVING)
assert can_transition(ArchiveState.REMOVING, ArchiveState.COMPLETE)
# No shortcut may skip verification before a source is removed.
assert not can_transition(ArchiveState.TRANSFERRING, ArchiveState.REMOVING)
assert not can_transition(ArchiveState.PLANNED, ArchiveState.VERIFIED)
assert not can_transition(ArchiveState.VERIFIED, ArchiveState.COMPLETE)
def test_removal_never_goes_backwards():
"""Once the source may be gone, retrying the transfer would archive nothing and
could overwrite the copy that is now the only one."""
assert ALLOWED_TRANSITIONS[ArchiveState.REMOVING] == {
ArchiveState.COMPLETE,
ArchiveState.FAILED,
}
assert not can_transition(ArchiveState.REMOVING, ArchiveState.TRANSFERRING)
assert not can_transition(ArchiveState.REMOVING, ArchiveState.PLANNED)
def test_complete_is_terminal_and_failed_can_be_retried():
assert ALLOWED_TRANSITIONS[ArchiveState.COMPLETE] == set()
assert TERMINAL_STATES == {ArchiveState.COMPLETE}
assert can_transition(ArchiveState.FAILED, ArchiveState.TRANSFERRING)
assert can_transition(ArchiveState.FAILED, ArchiveState.PLANNED)
def test_every_state_that_can_touch_the_disk_is_marked_unsafe():
assert UNSAFE_STATES == {
ArchiveState.TRANSFERRING,
ArchiveState.VERIFIED,
ArchiveState.REMOVING,
}
assert ArchiveState.PLANNED not in UNSAFE_STATES
# ── evidence table ───────────────────────────────────────────────────────────
@pytest.mark.parametrize(
"state,source,destination,matches,expected",
[
# Nothing published yet: the source is still the only copy.
(ArchiveState.TRANSFERRING, True, False, False, RESUMABLE),
(ArchiveState.FAILED, True, False, False, RESUMABLE),
# The archive copy is durable and correct: finish the remaining steps.
(ArchiveState.TRANSFERRING, True, True, True, FORWARD),
(ArchiveState.TRANSFERRING, False, True, True, FORWARD),
(ArchiveState.VERIFIED, True, True, True, FORWARD),
(ArchiveState.REMOVING, False, True, True, FORWARD),
(ArchiveState.FAILED, True, True, True, FORWARD),
# Wrong bytes at the destination: never overwrite, never remove.
(ArchiveState.TRANSFERRING, True, True, False, MANUAL),
(ArchiveState.VERIFIED, True, True, False, MANUAL),
(ArchiveState.REMOVING, False, True, False, MANUAL),
# The journal claims an archived copy that is not there.
(ArchiveState.VERIFIED, True, False, False, MANUAL),
(ArchiveState.REMOVING, False, False, False, MANUAL),
# Neither copy exists — never silently accepted as success.
(ArchiveState.TRANSFERRING, False, False, False, MANUAL),
(ArchiveState.FAILED, False, False, False, MANUAL),
],
)
def test_classification_of_every_evidence_combination(
state, source, destination, matches, expected
):
classification, reason = _classify(state, source, destination, matches)
assert classification == expected, reason
def test_a_source_that_is_gone_without_an_archive_copy_is_never_called_recoverable():
"""The one combination that must always stop: the original left active storage
and nothing verifiable took its place."""
for state in (ArchiveState.TRANSFERRING, ArchiveState.VERIFIED, ArchiveState.REMOVING):
assert _classify(state, False, False, False)[0] == MANUAL

View File

@@ -0,0 +1,27 @@
"""How one bulk-upload-check result is read (US05-04).
Only a duplicate rejection proves Immich holds the bytes. Every other answer — a
rejection for another reason, an action this adapter does not know — must stay
uncertain, because "the server did not say yes" is not "the file is not there".
"""
from photo_pipeline.integrations.immich_go import _holds_bytes, bulk_upload_check
def test_a_duplicate_rejection_is_the_only_proof_of_possession():
assert _holds_bytes({"action": "reject", "reason": "duplicate"}) is True
assert _holds_bytes({"action": "accept"}) is False
def test_any_other_answer_is_uncertain_never_absent():
assert _holds_bytes({"action": "reject", "reason": "unsupported-format"}) is None
assert _holds_bytes({"action": "quarantine"}) is None
assert _holds_bytes({}) is None
def test_missing_credentials_are_reported_not_silently_treated_as_absence():
result = bulk_upload_check("", None, {"asset": "abc"})
assert result["reachable"] is False
assert result["present"] == {}
assert "credentials" in result["detail"]

View File

@@ -0,0 +1,139 @@
"""Golden parser fixtures for immich-go reports (US05-03).
The reports below are the pinned grammar for each supported uploader version. If a
future immich-go changes its wording, the fix is a new adapter and a new golden
report — never a looser pattern here, because a loose pattern is how an unread line
becomes a false success.
"""
import pytest
from photo_pipeline.integrations import immich_go_report as parser
TEXT_REPORT = """
Scanning /lib/rome
INFO uploaded /lib/rome/new.jpg
INFO server has the same file /lib/rome/dup.jpg
INFO server has an older file, upgrading /lib/rome/old.jpg
WARN discarded /lib/rome/notes.txt: unsupported file type
ERROR error uploading /lib/rome/broken.jpg: connection reset by peer
Uploaded 1, upgraded 1, duplicates 1, skipped 1, errors 1
"""
JSON_REPORT = """
{"time":"2026-01-01T10:00:00Z","level":"INFO","msg":"uploaded","file":"/lib/rome/new.jpg"}
{"level":"INFO","msg":"server has the same file","file":"/lib/rome/dup.jpg"}
{"level":"INFO","msg":"server has an older file, upgrading","file":"/lib/rome/old.jpg"}
{"level":"WARN","msg":"discarded: unsupported file type","file":"/lib/rome/notes.txt"}
{"level":"ERROR","msg":"error uploading","file":"/lib/rome/broken.jpg"}
{"level":"INFO","msg":"report","counts":{"uploaded":1,"upgraded":1,"duplicates":1,\
"skipped":1,"errors":1}}
"""
EXPECTED = {
"/lib/rome/new.jpg": parser.UPLOADED,
"/lib/rome/dup.jpg": parser.DUPLICATE,
"/lib/rome/old.jpg": parser.UPGRADED,
"/lib/rome/notes.txt": parser.SKIPPED,
"/lib/rome/broken.jpg": parser.FAILED,
}
EXPECTED_COUNTS = {
parser.UPLOADED: 1,
parser.UPGRADED: 1,
parser.DUPLICATE: 1,
parser.SKIPPED: 1,
parser.FAILED: 1,
}
def _outcomes(parsed) -> dict:
return {entry["path"]: entry["outcome"] for entry in parsed["entries"]}
@pytest.mark.parametrize(
("version", "report", "expected_parser"),
[
("immich-go 0.21.0", TEXT_REPORT, "text-v1"),
("immich-go 0.22.3", TEXT_REPORT, "text-v1"),
("immich-go 0.23.1", JSON_REPORT, "json-v1"),
("immich-go 0.24.0", JSON_REPORT, "json-v1"),
],
)
def test_every_supported_version_classifies_every_outcome(version, report, expected_parser):
parsed = parser.parse(report, version)
assert parsed["parser"] == expected_parser and parsed["supported"]
assert parsed["parser_version"] == parser.PARSER_VERSION
assert _outcomes(parsed) == EXPECTED
assert parsed["counts"] == EXPECTED_COUNTS
def test_an_unsupported_version_parses_nothing_at_all():
"""A build whose output was never pinned must not be read optimistically."""
parsed = parser.parse(TEXT_REPORT, "immich-go 9.99.0")
assert parsed["parser"] is None and parsed["supported"] is False
assert parsed["entries"] == [] and parsed["counts"] is None
@pytest.mark.parametrize("version", [None, "", "immich-go dev", "unknown"])
def test_a_missing_or_unreadable_version_is_unsupported(version):
assert parser.parser_for(version) is None
def test_malformed_lines_are_counted_never_classified():
report = (
"{not json at all\n"
'{"level":"INFO","msg":"uploaded"}\n' # no file
'["not","an","object"]\n'
'{"level":"INFO","msg":"uploaded","file":"/lib/rome/new.jpg"}\n'
)
parsed = parser.parse(report, "immich-go 0.23.1")
assert _outcomes(parsed) == {"/lib/rome/new.jpg": parser.UPLOADED}
assert parsed["unparsed"] == 3
def test_a_line_with_a_path_but_no_known_verb_is_not_an_outcome():
parsed = parser.parse("INFO considering /lib/rome/a.jpg\n", "immich-go 0.21.0")
assert parsed["entries"] == [] and parsed["unparsed"] == 1
def test_the_album_name_never_classifies_the_line():
"""A folder called "errors" must not turn a successful upload into a failure."""
parsed = parser.parse("INFO uploaded /lib/errors/a.jpg\n", "immich-go 0.21.0")
assert _outcomes(parsed) == {"/lib/errors/a.jpg": parser.UPLOADED}
def test_paths_with_spaces_and_reasons_are_read_whole():
report = "ERROR error uploading /lib/summer holiday/a b.jpg: connection reset\n"
parsed = parser.parse(report, "immich-go 0.21.0")
assert _outcomes(parsed) == {"/lib/summer holiday/a b.jpg": parser.FAILED}
def test_evidence_is_bounded_per_entry():
line = "INFO uploaded /lib/rome/a.jpg " + "x" * 5_000
(entry,) = parser.parse(line, "immich-go 0.21.0")["entries"]
assert len(entry["evidence"]) == parser.MAX_EVIDENCE_CHARS
def test_a_looping_uploader_cannot_produce_unbounded_entries(monkeypatch):
monkeypatch.setattr(parser, "MAX_ENTRIES", 10)
report = "".join(f"INFO uploaded /lib/rome/{n}.jpg\n" for n in range(50))
parsed = parser.parse(report, "immich-go 0.21.0")
assert len(parsed["entries"]) == 10 and parsed["entries_truncated"] is True
def test_a_report_without_a_summary_reports_no_counts():
parsed = parser.parse("INFO uploaded /lib/rome/a.jpg\n", "immich-go 0.21.0")
assert parsed["counts"] is None, "counts are read, never invented"