Compare commits
8 Commits
us/US05-01
...
us/US06-02
| Author | SHA1 | Date | |
|---|---|---|---|
| 9503fd1cfc | |||
| b4b316acf5 | |||
| 3ccef58796 | |||
| ac9943884d | |||
| 3c440f3d43 | |||
| 675876cad9 | |||
| e4ae5da440 | |||
| dcbd492a0d |
33
README.md
33
README.md
@@ -136,3 +136,36 @@ work_item/scripts/python -m pytest tests/e2e -m phase_d -q
|
||||
|
||||
The fault barrier is test-only configuration; without `PHOTO_PIPELINE_FAULT_AFTER`
|
||||
the apply path has no crash points. Phases A–C 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 A–D remain green in the full run above.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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
704
frontend/js/uploads.js
Normal 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."
|
||||
);
|
||||
}
|
||||
91
migrations/versions/0008_upload_batches.py
Normal file
91
migrations/versions/0008_upload_batches.py
Normal 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")
|
||||
52
migrations/versions/0009_upload_report_outcomes.py
Normal file
52
migrations/versions/0009_upload_report_outcomes.py
Normal 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)
|
||||
70
migrations/versions/0010_upload_verification.py
Normal file
70
migrations/versions/0010_upload_verification.py
Normal 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)
|
||||
40
migrations/versions/0011_archive_locations.py
Normal file
40
migrations/versions/0011_archive_locations.py
Normal 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")
|
||||
96
migrations/versions/0012_archive_plans.py
Normal file
96
migrations/versions/0012_archive_plans.py
Normal 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")
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
149
photo_pipeline/api/routes/archives.py
Normal file
149
photo_pipeline/api/routes/archives.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""Archive location, preflight, and plan API (US06-01, US06-02).
|
||||
|
||||
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
|
||||
from photo_pipeline.services.archives import ArchiveError, ArchiveService
|
||||
from photo_pipeline.services.archive_transfer import ArchiveTransferService
|
||||
from photo_pipeline.services.jobs import JobBlocked, JobService
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _service(request: Request) -> ArchiveService:
|
||||
return ArchiveService(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.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()
|
||||
176
photo_pipeline/api/routes/uploads.py
Normal file
176
photo_pipeline/api/routes/uploads.py
Normal 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))
|
||||
@@ -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:
|
||||
|
||||
295
photo_pipeline/integrations/immich_go.py
Normal file
295
photo_pipeline/integrations/immich_go.py
Normal 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,
|
||||
)
|
||||
)
|
||||
204
photo_pipeline/integrations/immich_go_report.py
Normal file
204
photo_pipeline/integrations/immich_go_report.py
Normal 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))
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Domain job handlers: safety scoring and content analysis (US02-06).
|
||||
"""Domain job handlers: safety scoring, content analysis, uploads, archive
|
||||
transfers (US02-06, US05-02, US06-02).
|
||||
|
||||
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``, and ``archive_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,18 @@ 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"
|
||||
# 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 +40,37 @@ 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")
|
||||
|
||||
|
||||
register(SAFETY_SCORE, _safety_score_item)
|
||||
register(ANALYSIS, _analysis_item)
|
||||
register(UPLOAD_BATCH, _upload_batch_item)
|
||||
register(ARCHIVE_PLAN, _archive_plan_item)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
122
photo_pipeline/models/archives.py
Normal file
122
photo_pipeline/models/archives.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""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
|
||||
|
||||
# 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)
|
||||
|
||||
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()
|
||||
)
|
||||
@@ -35,7 +35,14 @@ 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)
|
||||
# Duplicate canonical link: NULL when the asset is itself canonical or undecided.
|
||||
canonical_asset_id: Mapped[str | None] = mapped_column(ForeignKey("assets.id"))
|
||||
|
||||
|
||||
151
photo_pipeline/models/uploads.py
Normal file
151
photo_pipeline/models/uploads.py
Normal 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()
|
||||
)
|
||||
337
photo_pipeline/services/archive_journal.py
Normal file
337
photo_pipeline/services/archive_journal.py
Normal file
@@ -0,0 +1,337 @@
|
||||
"""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
|
||||
```
|
||||
|
||||
- ``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},
|
||||
}
|
||||
|
||||
TERMINAL_STATES = frozenset({ArchiveState.COMPLETE})
|
||||
# States where this item may already have touched the filesystem.
|
||||
UNSAFE_STATES = frozenset({ArchiveState.TRANSFERRING, ArchiveState.VERIFIED, ArchiveState.REMOVING})
|
||||
|
||||
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) -> bool:
|
||||
return target in ALLOWED_TRANSITIONS.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
|
||||
):
|
||||
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):
|
||||
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) -> list[dict]:
|
||||
"""Every operation left in a non-terminal, non-planned state — the work a
|
||||
restart has to reason about."""
|
||||
with self._session_factory() as session:
|
||||
rows = session.scalars(
|
||||
select(ArchiveOperation)
|
||||
.where(
|
||||
ArchiveOperation.journal_state.not_in([*TERMINAL_STATES, ArchiveState.PLANNED])
|
||||
)
|
||||
.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"],
|
||||
"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) -> list[dict]:
|
||||
return [self.classify(row["id"]) for row in self.incomplete()]
|
||||
|
||||
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,
|
||||
"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,
|
||||
}
|
||||
605
photo_pipeline/services/archive_transfer.py
Normal file
605
photo_pipeline/services/archive_transfer.py
Normal file
@@ -0,0 +1,605 @@
|
||||
"""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 (
|
||||
MANUAL,
|
||||
RESUMABLE,
|
||||
ArchiveJournal,
|
||||
ArchiveState,
|
||||
)
|
||||
from photo_pipeline.services.archives import MARKER_NAME, ArchiveError, ArchiveService
|
||||
from photo_pipeline.services.hashing import sha256_file
|
||||
from photo_pipeline.services.rename_apply import PreconditionFailed, maybe_fault
|
||||
|
||||
# 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,
|
||||
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,
|
||||
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).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:
|
||||
self._copy_and_publish(operation, source, destination)
|
||||
_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 _copy_and_publish(self, operation: dict, source: Path, destination: Path) -> None:
|
||||
"""Cross-filesystem: copy to a temporary file beside the destination, prove
|
||||
its bytes, then publish it atomically. The source is still untouched."""
|
||||
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) != operation["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 _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"
|
||||
)
|
||||
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}"
|
||||
)
|
||||
|
||||
# ── 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():
|
||||
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()
|
||||
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 _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,
|
||||
"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,
|
||||
}
|
||||
608
photo_pipeline/services/archives.py
Normal file
608
photo_pipeline/services/archives.py
Normal file
@@ -0,0 +1,608 @@
|
||||
"""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``.
|
||||
|
||||
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.hashing import sha256_file
|
||||
from photo_pipeline.services.jobs import JobService
|
||||
from photo_pipeline.services.rename_journal import RenameJournal
|
||||
from photo_pipeline.services.upload_reports import VERIFIED
|
||||
|
||||
PREFLIGHT_VERSION = 1
|
||||
TOKEN_PREFIX = f"v{PREFLIGHT_VERSION}"
|
||||
MARKER_NAME = ".photo-pipeline-archive.json"
|
||||
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()
|
||||
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((_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,
|
||||
}
|
||||
|
||||
|
||||
# ── 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 _read_marker(root: Path) -> dict | None:
|
||||
try:
|
||||
return json.loads((root / MARKER_NAME).read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
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}"
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
456
photo_pipeline/services/upload_batches.py
Normal file
456
photo_pipeline/services/upload_batches.py
Normal 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
|
||||
140
photo_pipeline/services/upload_reports.py
Normal file
140
photo_pipeline/services/upload_reports.py
Normal 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())
|
||||
349
photo_pipeline/services/upload_verification.py
Normal file
349
photo_pipeline/services/upload_verification.py
Normal 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
|
||||
],
|
||||
}
|
||||
306
photo_pipeline/services/uploads.py
Normal file
306
photo_pipeline/services/uploads.py
Normal 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}"
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -9,14 +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
|
||||
@@ -135,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,
|
||||
@@ -246,3 +262,182 @@ def approve_album(base: str, *, album: str = "rome", name: str) -> None:
|
||||
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()
|
||||
|
||||
568
tests/e2e/test_phase_e_pipeline.py
Normal file
568
tests/e2e/test_phase_e_pipeline.py
Normal 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"])
|
||||
@@ -13,6 +13,7 @@ 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():
|
||||
@@ -25,6 +26,12 @@ def test_all_phase_d_stories_are_mapped():
|
||||
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"
|
||||
|
||||
283
tests/e2e/test_uploads_ui.py
Normal file
283
tests/e2e/test_uploads_ui.py
Normal 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")
|
||||
592
tests/integration/test_archive_preflight.py
Normal file
592
tests/integration/test_archive_preflight.py
Normal 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"
|
||||
313
tests/integration/test_archive_recovery.py
Normal file
313
tests/integration/test_archive_recovery.py
Normal 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
|
||||
480
tests/integration/test_archive_transfer.py
Normal file
480
tests/integration/test_archive_transfer.py
Normal 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
|
||||
590
tests/integration/test_upload_batches.py
Normal file
590
tests/integration/test_upload_batches.py
Normal 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
|
||||
513
tests/integration/test_upload_preflight.py
Normal file
513
tests/integration/test_upload_preflight.py
Normal 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"
|
||||
335
tests/integration/test_upload_reports.py
Normal file
335
tests/integration/test_upload_reports.py
Normal 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")
|
||||
537
tests/integration/test_upload_verification.py
Normal file
537
tests/integration/test_upload_verification.py
Normal 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
|
||||
@@ -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")
|
||||
|
||||
@@ -103,6 +103,34 @@
|
||||
],
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
105
tests/unit/test_archive_journal_states.py
Normal file
105
tests/unit/test_archive_journal_states.py
Normal 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
|
||||
27
tests/unit/test_immich_bulk_check.py
Normal file
27
tests/unit/test_immich_bulk_check.py
Normal 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"]
|
||||
139
tests/unit/test_immich_go_report.py
Normal file
139
tests/unit/test_immich_go_report.py
Normal 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"
|
||||
Reference in New Issue
Block a user