A live photo's default names fit, and no cut name starts with a dot
check / check (push) Canceled after 0s

`defaultFileName` in `src/filename.ts` now gives the name a file is saved
under when the caller gives no path, and `originalName`, `thumbnailName`,
`downloadFile` and `downloadThumbnail` all call it. For a live photo's image
and video it puts the part's own extension in before cutting the name short,
so those names fit too; a path from `--out` or `outPath` is kept as given.
`shortenKeepingExtension` keeps the first character before the extension,
cutting a long extension short to leave room for it, so a cut name no longer
starts with a dot. The backup's links for such a title change to match.
TODO.md names the open bug and describes what the code does.

Model: opus-5-5
This commit is contained in:
2026-10-18 17:05:49 +00:00
parent fd40714a6d
commit e03e99b486
11 changed files with 284 additions and 107 deletions
+27 -26
View File
@@ -16,13 +16,7 @@ import {
streamTagFinal,
} from "../crypto/index.js";
import { TruncatedStreamError } from "../errors.js";
import {
MAX_NAME_BYTES,
safeExtension,
sanitizeFileName,
shortenKeepingExtension,
withExtension,
} from "../filename.js";
import { defaultFileName, safeExtension, withExtension } from "../filename.js";
import { withRetry } from "../retry.js";
import type { ApiClient } from "../api/client.js";
import type { EnteFile } from "../model/types.js";
@@ -371,10 +365,14 @@ const openPart = async (
// name starts with `image` as the image and the one whose name starts with
// `video` as the video, and refuses a ZIP holding a second of either. It is
// written unpacked: each part is named `destination` with the extension
// replaced by its own entry's, and the two must differ ignoring case. When the
// file records a hash, `<imageHash>:<videoHash>` must match it, each over that
// part's own bytes. Only then are the image, then the video, renamed into
// place; on any failure neither is stored.
// replaced by its own entry's, and the two must differ ignoring case. When
// `defaultName` is set, `destination` is the name `defaultFileName` made from
// the title rather than a path the caller gave, and each part is named as
// `defaultFileName` names it with its own extension, so that the name is cut
// short to fit with that extension. When the file records a hash,
// `<imageHash>:<videoHash>` must match it, each over that part's own bytes.
// Only then are the image, then the video, renamed into place; on any failure
// neither is stored.
//
// The ZIP is chosen by its uploader and may expand enormously, so each part is
// written as it decompresses and never held, and the ZIP is refused once the
@@ -394,6 +392,7 @@ const decryptLivePhoto = async (
key: Uint8Array,
onProgress: ProgressCallback | undefined,
file: EnteFile,
defaultName: boolean,
): Promise<DownloadResult> => {
const sliceSize = 4096;
const dir = dirname(destination);
@@ -492,8 +491,12 @@ const decryptLivePhoto = async (
`live photo's image and video have the same extension, ${video.ext}`,
);
}
const path = withExtension(destination, image.ext);
const videoPath = withExtension(destination, video.ext);
const partPath = (ext: string): string =>
defaultName
? defaultFileName(file.metadata.title, file.id, { ext })
: withExtension(destination, ext);
const path = partPath(image.ext);
const videoPath = partPath(video.ext);
for (const part of parts) {
await part.handle.sync();
await part.handle.close();
@@ -537,6 +540,9 @@ const decryptLivePhoto = async (
// and then removes its own temp file, and only the attempt that reaches
// TAG_FINAL renames one into place, so a download that needed three tries still
// performs exactly one rename over the destination.
//
// `defaultName` says `destination` is the name `defaultFileName` made, not a
// path the caller gave (see `decryptLivePhoto`).
const fetchAndDecrypt = async (
api: ApiClient,
openStream: () => Promise<ReadableStream<Uint8Array>>,
@@ -545,6 +551,7 @@ const fetchAndDecrypt = async (
destination: string,
onProgress?: ProgressCallback,
original?: EnteFile,
defaultName = false,
): Promise<DownloadResult> =>
withRetry(async () => {
const stream = await openStream();
@@ -557,6 +564,7 @@ const fetchAndDecrypt = async (
key,
onProgress,
original,
defaultName,
);
}
const bytesWritten = await decryptToTemp(
@@ -588,15 +596,11 @@ export const downloadFile = async (
outPath?: string,
onProgress?: ProgressCallback,
): Promise<DownloadResult> => {
// `outPath` is the caller's and is used as is; the title is the server's
// and is sanitized so it can only name a file in the current directory,
// and cut short to fit in a file name, keeping its extension.
// `outPath` is the caller's and is used as is. Without it, the file is
// written in the current directory under the name `defaultFileName` makes
// from the title, which is the server's.
const resolvedPath =
outPath ??
shortenKeepingExtension(
sanitizeFileName(file.metadata.title, `file-${file.id}`),
MAX_NAME_BYTES,
);
outPath ?? defaultFileName(file.metadata.title, file.id);
const header = fromBase64(file.file.decryptionHeader);
return fetchAndDecrypt(
api,
@@ -606,6 +610,7 @@ export const downloadFile = async (
resolvedPath,
onProgress,
file,
outPath === undefined,
);
};
@@ -618,11 +623,7 @@ export const downloadThumbnail = async (
// As in `downloadFile`, with `thumb_` before the title.
const resolvedPath =
outPath ??
"thumb_" +
shortenKeepingExtension(
sanitizeFileName(file.metadata.title, `file-${file.id}`),
MAX_NAME_BYTES - "thumb_".length,
);
defaultFileName(file.metadata.title, file.id, { prefix: "thumb_" });
const header = fromBase64(file.thumbnail.decryptionHeader);
return fetchAndDecrypt(
api,