commit ae2dd984dc407e3ddb26c511e167b81ff83eb433
parent 9f1d64a58a777d59cabc23ffb39f879f8877a862
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 2 Oct 2026 02:26:14 -0400
common: review L1, L2, L4 — reclaim only what this tool migrated, only with a twin on the corpus disk; dead temps stay on the platter
L1: the swap leaves .tier-migration.reclaim beside the config; --all --reclaim
takes only channels carrying it, a named --reclaim without it refuses and
says why, and a reclaim removes it. L2: a platter entry is deleted only when
one lstat finds its twin at channels/<slug>/data/<id>/<name> (a directory for
a directory, else the same size); anything else is kept and reported. L4: a
postprocessor's dead *.temp.* is not copied or linked — it stays on the
platter and --reclaim deletes it. The --all stop reports the big three's copy
bytes from the ordering walk. Tests: the twin rule, the note's scope, a
killed reclaim resuming, the temp left and reclaimed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
2 files changed, 222 insertions(+), 43 deletions(-)
diff --git a/common/bin/migrate-media-tier.test.ts b/common/bin/migrate-media-tier.test.ts
@@ -55,6 +55,7 @@ const {
migrateMediaTier,
INCOMING_NAME,
LIST_NAME,
+ RECLAIM_NOTE,
} = await import("./migrate-media-tier");
const { inspectChannelMedia, readRelocationMarker } = await import("../lib/channelMedia");
const { classifyEntry } = await import("../lib/mediaTier");
@@ -123,10 +124,14 @@ const VIDEOS: Record<string, Record<string, string>> = {
"audio.m4a.part": "P".repeat(512),
"audio.tmp-1234.mp3": "T".repeat(256),
"clips/w-0001.mp4": "C".repeat(1024),
+ // A postprocessor's dead temp: left on the platter, never copied.
+ "source-media.temp.mp4": "D".repeat(1536),
},
v2: {
"transcript.json": JSON.stringify({ segments: [{ start: 0, end: 1, text: "hi there" }] }),
"audio.opus": "O".repeat(3000),
+ // Syncthing's unfinished transfer: scratch, carried.
+ ".syncthing.audio.mp3.tmp": "Y".repeat(128),
},
v3: {},
};
@@ -245,7 +250,13 @@ function run(over: Partial<MigrateOpts> = {}, lines: string[] = []) {
}
// THE MIGRATED STATE, every rule the slice names.
-async function assertMigrated(slug = SLUG, scale = 1): Promise<void> {
+const DEAD_TEMP = "source-media.temp.mp4";
+
+async function assertMigrated(
+ slug = SLUG,
+ scale = 1,
+ opts: { reclaimed?: boolean } = {},
+): Promise<void> {
const ch = channelDir(slug);
const config = await readChannelConfig(paths, slug);
assert.equal(config?.mediaDir, mediaTarget(slug));
@@ -265,6 +276,13 @@ async function assertMigrated(slug = SLUG, scale = 1): Promise<void> {
for (const name of Object.keys(files)) {
const top = name.split("/")[0];
const p = path.join(dir, name);
+ if (name === DEAD_TEMP) {
+ assert.throws(() => lstatSync(p), "a dead temp is not carried to the corpus disk");
+ const onPlatter = path.join(mediaTarget(slug), id, name);
+ if (opts.reclaimed) assert.throws(() => lstatSync(onPlatter), "reclaimed");
+ else assert.ok(lstatSync(onPlatter).isFile(), "left on the platter, unlinked");
+ continue;
+ }
const st = lstatSync(p);
if (name === "audio.mp3" || name === "audio.opus" || name === "transcript.live_chat.json") {
assert.ok(st.isSymbolicLink(), `${id}/${name} is a link`);
@@ -300,7 +318,8 @@ test("a dry run writes nothing, and says what it would do", async () => {
assert.equal(r.tierableFiles, 3);
assert.equal(r.copy?.clips.files, 1);
assert.equal(r.copy?.source.files, 1, "source-media is carried, not tiered");
- assert.equal(r.copy?.scratch.files, 2);
+ assert.equal(r.copy?.scratch.files, 3, "the part, the tmp and the Syncthing temp");
+ assert.equal(r.leftOnPlatter?.files, 1, "the dead *.temp.* stays on the platter");
assert.deepEqual(snapshot(), before);
assert.ok(lines.some((l) => l.includes("DRY RUN")));
});
@@ -319,8 +338,10 @@ test("a run migrates the channel; a rerun is a no-op", async () => {
// The live chat's cues (none here) aside, the raw replay's link answers the
// freshness check from the corpus disk with the file's time.
assert.equal(lstatSync(path.join(channelDir(), "data", "v1", "transcript.live_chat.json")).mtimeMs, OLD.getTime());
- // Without --reclaim the platter keeps its text copies.
+ // Without --reclaim the platter keeps its text copies, and the reclaim note
+ // says so.
assert.ok(lstatSync(path.join(mediaTarget(), "v1", "transcript.en.vtt")).isFile());
+ assert.ok(lstatSync(path.join(channelDir(), RECLAIM_NOTE)).isFile());
const before = snapshot();
const again = await run();
@@ -337,7 +358,9 @@ test("--reclaim deletes the platter's text copies and keeps the media", async ()
const r = res.channels[0];
assert.equal(r.outcome, "migrated");
assert.ok((r.reclaimedFiles ?? 0) > 0);
- await assertMigrated();
+ assert.deepEqual(r.reclaimKept, []);
+ await assertMigrated(SLUG, 1, { reclaimed: true });
+ assert.throws(() => lstatSync(path.join(channelDir(), RECLAIM_NOTE)), "the note goes");
assert.deepEqual(readdirSync(path.join(mediaTarget(), "v1")).sort(), [
"audio.mp3",
"transcript.live_chat.json",
@@ -345,11 +368,12 @@ test("--reclaim deletes the platter's text copies and keeps the media", async ()
assert.deepEqual(readdirSync(path.join(mediaTarget(), "v2")), ["audio.opus"]);
assert.throws(() => lstatSync(path.join(mediaTarget(), "v3")), "an emptied dir goes");
- // Reclaim on its own, later: nothing left to take, nothing touched.
+ // Reclaim again, later: the note is gone, so it refuses and touches nothing.
const before = snapshot();
const again = await run({ reclaim: true });
- assert.equal(again.channels[0].outcome, "already");
- assert.equal(again.channels[0].reclaimedFiles, 0);
+ assert.equal(again.exitCode, 1);
+ assert.equal(again.channels[0].outcome, "refused");
+ assert.match(again.channels[0].detail ?? "", /no \.tier-migration\.reclaim beside its config/);
assert.deepEqual(snapshot(), before);
});
@@ -365,7 +389,68 @@ test("--reclaim after a plain run takes the copies a first run left", async () =
const real = await run({ reclaim: true });
assert.equal(real.channels[0].reclaimedFiles, wouldTake);
assert.throws(() => lstatSync(path.join(mediaTarget(), "v1", "transcript.en.vtt")));
- await assertMigrated();
+ await assertMigrated(SLUG, 1, { reclaimed: true });
+});
+
+test("--reclaim keeps a platter copy whose twin on the corpus disk is gone or differs", async () => {
+ resetCorpus();
+ seedLegacy();
+ await run();
+ // Since the migration: one text file removed, one rewritten to another size.
+ rmSync(path.join(channelDir(), "data", "v1", "transcript.en.vtt"));
+ writeFileSync(path.join(channelDir(), "data", "v2", "transcript.json"), "{}");
+ const lines: string[] = [];
+ const res = await run({ reclaim: true }, lines);
+ assert.equal(res.exitCode, 0, res.channels[0].detail);
+ assert.deepEqual(res.channels[0].reclaimKept?.sort(), ["v1/transcript.en.vtt", "v2/transcript.json"]);
+ assert.ok(lstatSync(path.join(mediaTarget(), "v1", "transcript.en.vtt")).isFile(), "kept");
+ assert.ok(lstatSync(path.join(mediaTarget(), "v2", "transcript.json")).isFile(), "kept");
+ assert.throws(() => lstatSync(path.join(mediaTarget(), "v1", "metadata.info.json")), "its twin is there: taken");
+ assert.throws(() => lstatSync(path.join(mediaTarget(), "v1", DEAD_TEMP)), "the dead temp is taken");
+ assert.ok(lines.some((l) => /kept 2 entries on the drive with no same-size copy/.test(l)));
+});
+
+test("a reclaim killed after its deletes resumes from its marker and ends clean", async () => {
+ resetCorpus();
+ seedLegacy();
+ const killed = await run({
+ reclaim: true,
+ deps: {
+ checkpoint: (_slug, s) => {
+ if (s === "reclaimed") throw new Error("killed");
+ },
+ },
+ });
+ assert.equal(killed.channels[0].outcome, "failed");
+ assert.equal((await readRelocationMarker(paths, SLUG))?.phase, "reclaim");
+ const resumed = await run();
+ assert.equal(resumed.channels[0].outcome, "migrated", resumed.channels[0].detail);
+ assert.equal(resumed.channels[0].resumedFrom, "reclaim");
+ await assertMigrated(SLUG, 1, { reclaimed: true });
+ assert.throws(() => lstatSync(path.join(channelDir(), RECLAIM_NOTE)));
+});
+
+test("--all --reclaim takes only channels this tool migrated", async () => {
+ resetCorpus(["moved", SLUG]);
+ seedLegacy();
+ // A channel the Storage panel moved: on the media tier, no reclaim note, a
+ // non-tierable file in its media tree that a reclaim would take.
+ writeConfig("moved", { mediaDir: mediaTarget("moved") });
+ mkdirSync(path.join(mediaTarget("moved"), "x1"), { recursive: true });
+ writeFileSync(path.join(mediaTarget("moved"), "x1", "stray.txt"), "s");
+ mkdirSync(path.join(channelDir("moved"), "data", "x1"), { recursive: true });
+ writeFileSync(path.join(channelDir("moved"), "data", "x1", "stray.txt"), "s");
+ symlinkSync(mediaTarget("moved"), path.join(channelDir("moved"), "media"));
+
+ const res = await run({ slugs: "all", reclaim: true });
+ assert.equal(res.exitCode, 0, res.channels.map((c) => c.detail).join("; "));
+ assert.deepEqual(res.channels.map((c) => c.slug), [SLUG]);
+ await assertMigrated(SLUG, 1, { reclaimed: true });
+ assert.ok(lstatSync(path.join(mediaTarget("moved"), "x1", "stray.txt")).isFile(), "not walked");
+
+ const named = await run({ slugs: ["moved"], reclaim: true });
+ assert.equal(named.channels[0].outcome, "refused");
+ assert.match(named.channels[0].detail ?? "", /migrate-tier did not migrate it/);
});
test("a media move's marker is refused, with or without a scope; nothing is touched", async () => {
diff --git a/common/bin/migrate-media-tier.ts b/common/bin/migrate-media-tier.ts
@@ -25,7 +25,8 @@
// `copy bytes + resume margin <= free(channelsDir) - floor`.
// copy marker `{target: <root>/<slug>/media, direction: "out",
// phase: "copy", scope: "tier-migration"}`; every entry that is
-// NOT tierable (the text, `clips/`, scratch, `source-media.*`)
+// NOT tierable (the text, `clips/`, scratch, `source-media.*`) —
+// except a postprocessor's dead `*.temp.*`, left on the platter —
// is copied to `channels/<slug>/data.incoming/` by rsync from a
// NUL-separated list the classifier wrote (never a glob); the
// copy is verified by an itemized dry run AND per-kind counts and
@@ -37,10 +38,14 @@
// `unlink(channels/<slug>/data)`; `rename(data.incoming -> data)`;
// `patchChannelConfig(slug, { mediaDir }, { unset: ["dataDir"] })`.
// Each step looks at the disk first, so a rerun after a crash
-// between any two of them finishes the rest.
-// reclaim only with --reclaim: the platter's copies of the text (every
-// entry of `<root>/<slug>/media/<id>/` that is not tierable) are
-// deleted, empty video dirs dropped.
+// between any two of them finishes the rest. The reclaim note
+// (`.tier-migration.reclaim`) is left beside the config.
+// reclaim only with --reclaim, and only on a channel carrying the note:
+// the platter's copies of the text (an entry of
+// `<root>/<slug>/media/<id>/` that is not tierable, and whose twin
+// on the corpus disk `lstat`s the same) and the dead temps are
+// deleted, empty video dirs dropped; anything else is kept and
+// reported.
// done the marker is cleared; bytes by kind and links made are printed.
//
// THE EDITOR MUST BE STOPPED. It holds its job registry in memory, so nothing
@@ -124,6 +129,13 @@ export const LARGE_TEXT_CHANNELS: readonly string[] = [
export const INCOMING_NAME = "data.incoming";
// The NUL-separated rsync list, beside the marker; removed when the channel is done.
export const LIST_NAME = ".tier-migration.files";
+// THE RECLAIM NOTE, beside the config from the swap until a reclaim: "this
+// tool migrated this channel, and the drive still holds its text copies".
+// `--all --reclaim` takes only channels carrying it, and a named `--reclaim`
+// refuses one without it — a channel the Storage panel moved has nothing of
+// the retired layout to take, and walking its whole tree to find that out
+// costs minutes on a platter.
+export const RECLAIM_NOTE = ".tier-migration.reclaim";
// ---------------------------------------------------------------------------
// The inventory: the old tree, by kind
@@ -165,6 +177,9 @@ export type Inventory = {
copyBytes: number;
tierable: TierableFile[];
tierableBytes: number;
+ // Dead postprocessor temps (`*.temp.*`) left on the platter, neither copied
+ // nor linked; `--reclaim` deletes them.
+ left: Tally;
};
// Which copy kind a video-dir entry is. `stats` decides only one thing: a
@@ -197,6 +212,16 @@ function add(t: KindTallies, kind: CopyKind, by: Tally): void {
t[kind].bytes += by.bytes;
}
+// A POSTPROCESSOR'S DEAD TEMP — `source-media.temp.mp4`, `audio.temp.mp3`:
+// yt-dlp's postprocessor writes it and renames it over the final name, so one
+// still there after the run is a leftover (15 GB of them on one channel,
+// measured 2026-10-02). It is scratch by the classifier; the migration leaves
+// it on the platter, unlinked, rather than carry it to the corpus disk, and
+// `--reclaim` deletes it with the text copies.
+export function isLeftOnPlatter(name: string): boolean {
+ return classifyEntry(name) === "scratch" && /\.temp\./.test(name);
+}
+
// ONE WALK OF THE OLD TREE: each video dir's entries classified by name. A
// tierable regular file is left where it is (a link stands in for it); every
// other entry is listed for the copy and measured.
@@ -208,6 +233,7 @@ export async function inventoryTree(dir: string): Promise<Inventory> {
copyBytes: 0,
tierable: [],
tierableBytes: 0,
+ left: { files: 0, bytes: 0 },
};
const top = (await readdir(dir, { withFileTypes: true })).sort((a, b) =>
a.name.localeCompare(b.name),
@@ -236,6 +262,12 @@ export async function inventoryTree(dir: string): Promise<Inventory> {
inv.tierableBytes += st.size;
continue;
}
+ if (isLeftOnPlatter(name)) {
+ const t = await measureEntry(fp, st);
+ inv.left.files += t.files;
+ inv.left.bytes += t.bytes;
+ continue;
+ }
inv.copyList.push(`${e.name}/${name}`);
add(inv.copy, copyKindOf(name), await measureEntry(fp, st));
}
@@ -432,10 +464,14 @@ export type ChannelReport = {
copyBytes?: number;
tierableFiles?: number;
tierableBytes?: number;
+ // Dead `*.temp.*` left on the platter (not copied, not linked).
+ leftOnPlatter?: Tally;
linksMade?: number;
linksAlready?: number;
reclaimedFiles?: number;
reclaimedBytes?: number;
+ // Platter entries `--reclaim` kept: no same-size copy on the corpus disk.
+ reclaimKept?: string[];
// The phase a marker was resumed from.
resumedFrom?: string;
};
@@ -636,10 +672,14 @@ async function migrateOne(ctx: Ctx, slug: string): Promise<ChannelReport> {
report.copyBytes = inv.copyBytes;
report.tierableFiles = inv.tierable.length;
report.tierableBytes = inv.tierableBytes;
+ report.leftOnPlatter = inv.left;
log(
`${slug}: ${inv.videoIds.length} video dir(s); to the corpus disk: ` +
`${formatBytes(inv.copyBytes)} (${kindsLine(inv.copy)}); staying on the ` +
- `media drive: ${inv.tierable.length} file(s), ${formatBytes(inv.tierableBytes)}`,
+ `media drive: ${inv.tierable.length} file(s), ${formatBytes(inv.tierableBytes)}` +
+ (inv.left.files > 0
+ ? `; dead postprocessor temps left there unlinked (--reclaim deletes them): ${tallyText(inv.left)}`
+ : ""),
);
// THE SPACE RULE: the copy plus the resume margin must fit above the disk
@@ -777,6 +817,10 @@ async function migrateOne(ctx: Ctx, slug: string): Promise<ChannelReport> {
if (phase === "swap") {
await swap(ctx, slug, { D, target, dataLink, incoming, mediaLink });
+ await writeFile(
+ path.join(channelDir, RECLAIM_NOTE),
+ JSON.stringify({ target, migratedAt: new Date().toISOString() }) + "\n",
+ );
await rm(listFile, { force: true });
phase = "reclaim";
}
@@ -791,6 +835,7 @@ async function migrateOne(ctx: Ctx, slug: string): Promise<ChannelReport> {
});
Object.assign(report, await reclaimTree(ctx, slug, target));
await ctx.checkpoint(slug, "reclaimed");
+ await rm(path.join(channelDir, RECLAIM_NOTE), { force: true });
}
await clearDirMarker(relocationMarkerPath(paths, slug));
@@ -867,53 +912,89 @@ async function swap(
await ctx.checkpoint(slug, "config-written");
}
-// RECLAIM: the platter's copies of the text. Everything in
-// `<root>/<slug>/media/<id>/` that is not a tierable regular file was copied
-// to the corpus disk and verified; it goes, and a video dir left empty goes
-// with it. A top-level non-directory entry was a stray text file, copied too.
+// RECLAIM: the platter's copies of the text. In `<root>/<slug>/media/<id>/`
+// a tierable regular file is the media and stays; a dead `*.temp.*` (left
+// there by the migration, never copied) goes; every other entry goes ONLY when
+// one `lstat` of its twin on the corpus disk — `channels/<slug>/data/<id>/<name>`
+// — finds it there, a directory for a directory or a file of the same size.
+// Anything else is KEPT and reported: the platter copy may be the only one (a
+// reclaim long after the migration, a file changed or removed since). A video
+// dir left empty goes (non-recursive `rmdir`). A top-level non-directory entry
+// is a stray file copied from the top of the old tree, checked the same way.
async function reclaimTree(
ctx: Ctx,
slug: string,
mediaDir: string,
-): Promise<Pick<ChannelReport, "reclaimedFiles" | "reclaimedBytes">> {
+): Promise<Pick<ChannelReport, "reclaimedFiles" | "reclaimedBytes" | "reclaimKept">> {
+ const ssd = path.join(ctx.paths.channelsDir, slug, "data");
let files = 0;
let bytes = 0;
+ const kept: string[] = [];
+ const hasTwin = async (rel: string, st: Stats): Promise<boolean> => {
+ let twin: Stats;
+ try {
+ twin = await lstat(path.join(ssd, rel));
+ } catch {
+ return false;
+ }
+ if (st.isDirectory()) return twin.isDirectory();
+ return !twin.isDirectory() && twin.size === st.size;
+ };
+ const take = async (p: string, st: Stats): Promise<void> => {
+ const t = await measureEntry(p, st);
+ files += t.files;
+ bytes += t.bytes;
+ if (!ctx.dryRun) await rm(p, { recursive: true, force: true });
+ };
const top = await readdir(mediaDir, { withFileTypes: true });
for (const e of top) {
const p = path.join(mediaDir, e.name);
if (!e.isDirectory()) {
- const t = await measureEntry(p);
- files += t.files;
- bytes += t.bytes;
- if (!ctx.dryRun) await rm(p, { force: true });
+ const st = await lstat(p);
+ if (await hasTwin(e.name, st)) await take(p, st);
+ else kept.push(e.name);
continue;
}
for (const name of await readdir(p)) {
const fp = path.join(p, name);
const st = await lstat(fp);
if (isTierable(name) && st.isFile()) continue;
- const t = await measureEntry(fp, st);
- files += t.files;
- bytes += t.bytes;
- if (!ctx.dryRun) await rm(fp, { recursive: true, force: true });
+ const rel = `${e.name}/${name}`;
+ if (isLeftOnPlatter(name) || (await hasTwin(rel, st))) await take(fp, st);
+ else kept.push(rel);
}
if (!ctx.dryRun) await rmdir(p).catch(() => {});
}
ctx.log(
`${slug}: ${ctx.dryRun ? "would reclaim" : "reclaimed"} ${files} file(s), ` +
- `${formatBytes(bytes)} of text copies from ${mediaDir}`,
+ `${formatBytes(bytes)} of text copies and dead temps from ${mediaDir}`,
);
- return { reclaimedFiles: files, reclaimedBytes: bytes };
+ if (kept.length > 0) {
+ ctx.log(
+ `${slug}: kept ${kept.length} entr${kept.length === 1 ? "y" : "ies"} on the drive with no ` +
+ `same-size copy on the corpus disk (${kept.slice(0, 5).join(", ")}` +
+ `${kept.length > 5 ? `, … ${kept.length - 5} more` : ""})`,
+ );
+ }
+ return { reclaimedFiles: files, reclaimedBytes: bytes, reclaimKept: kept };
}
-// --reclaim on a channel already on the media tier (this run's earlier
-// migration, or a previous one): its media drive must answer, and nothing may
-// be moving it.
+// --reclaim on a channel already on the media tier: only one this tool
+// migrated (its reclaim note stands), whose media drive answers and whose
+// `data/` is a real directory.
async function reclaimChannel(
ctx: Ctx,
slug: string,
mediaDir: string,
-): Promise<Pick<ChannelReport, "reclaimedFiles" | "reclaimedBytes">> {
+): Promise<Pick<ChannelReport, "reclaimedFiles" | "reclaimedBytes" | "reclaimKept">> {
+ const note = path.join(ctx.paths.channelsDir, slug, RECLAIM_NOTE);
+ if ((await linkOrDirState(note)).kind === "missing") {
+ throw new Refusal(
+ `cannot reclaim: no ${RECLAIM_NOTE} beside its config — migrate-tier did not ` +
+ `migrate it, or its reclaim is done; its drive holds no text copies of the ` +
+ `retired layout to take`,
+ );
+ }
const loc = await inspectChannelMedia(ctx.paths, slug, undefined, { fresh: true });
if (loc.status !== "ok") {
throw new Refusal(
@@ -933,6 +1014,7 @@ async function reclaimChannel(
});
const out = await reclaimTree(ctx, slug, mediaDir);
await ctx.checkpoint(slug, "reclaimed");
+ await rm(note, { force: true });
await clearDirMarker(relocationMarkerPath(ctx.paths, slug));
forgetChannelMedia(slug);
return out;
@@ -963,14 +1045,15 @@ export async function legacyChannels(paths: Paths): Promise<string[]> {
return out.sort();
}
-// Channels already on the media tier (for `--all --reclaim`).
-async function migratedChannels(paths: Paths): Promise<string[]> {
+// Channels this tool migrated whose drive still holds the text copies (their
+// reclaim note stands), for `--all --reclaim`.
+async function reclaimableChannels(paths: Paths): Promise<string[]> {
const entries = await readdir(paths.channelsDir, { withFileTypes: true }).catch(() => []);
const out: string[] = [];
for (const e of entries) {
if (!e.isDirectory()) continue;
- const config = await readChannelConfig(paths, e.name).catch(() => null);
- if (config?.mediaDir?.trim() && !config.dataDir?.trim()) out.push(e.name);
+ const note = await linkOrDirState(path.join(paths.channelsDir, e.name, RECLAIM_NOTE));
+ if (note.kind !== "missing") out.push(e.name);
}
return out.sort();
}
@@ -1035,23 +1118,31 @@ export async function migrateMediaTier(opts: MigrateOptions): Promise<MigrateRes
let order: string[];
let deferred: string[] = [];
+ // Every legacy channel's copy bytes, from ONE walk each: the order, and the
+ // stop's report on the big three.
+ const sizes = new Map<string, number>();
if (opts.slugs === "all") {
const legacy = await legacyChannels(opts.paths);
const large = legacy.filter((s) => LARGE_TEXT_CHANNELS.includes(s));
const rest = legacy.filter((s) => !LARGE_TEXT_CHANNELS.includes(s));
const measure = async (slugs: string[]) => {
const sized: { slug: string; bytes: number }[] = [];
- for (const slug of slugs) sized.push({ slug, bytes: await copyBytesOf(opts.paths, slug) });
+ for (const slug of slugs) {
+ const bytes = await copyBytesOf(opts.paths, slug);
+ sizes.set(slug, bytes);
+ sized.push({ slug, bytes });
+ }
return sized
.sort((a, b) => a.bytes - b.bytes || a.slug.localeCompare(b.slug))
.map((s) => s.slug);
};
log(`${legacy.length} channel(s) on the retired layout: ${legacy.join(", ") || "none"}`);
order = await measure(rest);
- if (opts.includeLarge) order.push(...(await measure(large)));
- else deferred = large;
+ const largeOrder = await measure(large);
+ if (opts.includeLarge) order.push(...largeOrder);
+ else deferred = largeOrder;
if (ctx.reclaim) {
- for (const slug of await migratedChannels(opts.paths)) {
+ for (const slug of await reclaimableChannels(opts.paths)) {
if (!order.includes(slug)) order.push(slug);
}
}
@@ -1078,7 +1169,10 @@ export async function migrateMediaTier(opts: MigrateOptions): Promise<MigrateRes
log(
` ${slug}: copied ${formatBytes(report.copyBytes ?? 0)} (${kindsLine(report.copy)}); ` +
`${report.linksMade ?? 0} link(s) made; ${report.tierableFiles ?? 0} media file(s), ` +
- `${formatBytes(report.tierableBytes ?? 0)} stay on the media drive`,
+ `${formatBytes(report.tierableBytes ?? 0)} stay on the media drive` +
+ (report.leftOnPlatter?.files
+ ? `; ${tallyText(report.leftOnPlatter)} of dead temps left there for --reclaim`
+ : ""),
);
}
}
@@ -1111,7 +1205,7 @@ export async function migrateMediaTier(opts: MigrateOptions): Promise<MigrateRes
`the gate floor is ${opts.settings.minFreeDiskGB} GB.`,
);
for (const slug of deferred) {
- const bytes = await copyBytesOf(opts.paths, slug);
+ const bytes = sizes.get(slug) ?? (await copyBytesOf(opts.paths, slug));
log(
` ${slug}: ${
bytes < 0