Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Mcommon/bin/migrate-media-tier.test.ts | 101++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcommon/bin/migrate-media-tier.ts | 164++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
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