* feat(api): Lightroom round-trip — read marks, put edits back (#745) Gets a client's proofing verdict into a desktop catalogue and a finished edit back over its proof, without anyone re-matching files by hand. Three parts: **Keep the camera filename.** photos.original_filename is the only carrier of `IMG_1234.JPG` — the stored filename is rewritten by generatePhotoFilename. But replacePhoto() overwrites original_filename with whatever name the new file arrives under, so the first re-upload of a renamed render destroys the key the NEXT round-trip needs. Migration 185 adds photos.source_filename, written once at ingest and never touched by a replace, backfilled from original_filename so existing galleries can still match on their first pass. The backfill sits outside the column guard and keys on whereNull, so a run that dies partway self-heals instead of leaving half the rows empty forever. **Read the marks.** GET /api/v1/events/:id/photos returns each photo with its client colour tallies, the caller's own marks, and a merged colour + rating. Guards copied from the sibling upload route (apiTokenAuth + read scope + photos.view + requireEventOwnership). Filters: marked_only, mark_source, color_labels, my_color_labels, min_rating, my_min_rating. The route filters to a page of ids with PhotoFilterBuilder, then enriches just those through photoExportService.getPhotosWithFeedback — the two halves already existed and neither does both, and going id-first keeps the per-colour tally query bounded by page size. services/markMerge.js decides how three possible opinions (guest colours, guest star average, the photographer's own row in photo_admin_marks) collapse into the one colour and one rating Lightroom has room for. Colour goes to the photographer on a tie — one deliberate choice beats an aggregate a tie-break already had to guess at. Rating takes the max, because a rating is a magnitude and losing the higher one quietly demotes a photo somebody rated highly. Its roundRating matches XmpGenerator.mapRating exactly so the API and an XMP sidecar can never disagree about how many stars a photo has. **Put the edit back.** POST /api/v1/events/:id/photos accepts an optional replaces_photo_id and routes to the existing replacePhoto(), preserving the photo's id, feedback, comments and position. The plugin stores the picpeak id on the catalogue photo, so the id survives the editor renaming the render — which makes it the reliable key, not the filename. Scoped to the event in the URL: a token inherits its owner's powers across every event they can see, so an unscoped id would let one gallery overwrite another's photo. For renders whose RAW never went through the plugin, findReplacementCandidate gains an opt-in number_token mode matching on the trailing digit run. Deliberately the LONGEST run and never a fixed last-N slice: multi-camera shoots disambiguate by prefixing the camera index into the number (cam11234.jpg / cam21234.jpg), and a last-4 slice reads 1234 from both bodies and reintroduces exactly the collision the prefix removes. Ambiguity is refused, never guessed. Also drops the multer temp file on the two new early returns — this route only unlinks in its catch block. * refactor(api): one rating-rounding rule, and apply match_mode where it counts Three things the pre-review pass turned up on the round-trip work: - `match_mode` reached the photo-cap pre-count but not the loop that actually picks the replacement target, so asking for `number_token` would have been counted and then quietly ignored. Both call sites now take it. - `number_token` matching read `select('*')` over every photo in the event to compare one digit run. It now reads the three columns the match needs and re-reads the single winner in full, so a 5000-photo event doesn't pull 5000 full rows through memory to answer one question. - `XmpGenerator.mapRating` and `markMerge.roundRating` were the same five thresholds written twice — the second way to do one thing that drifts the moment either is touched. The thresholds now live in markMerge and the generator delegates, which is what keeps a sidecar and the v1 API from ever disagreeing about a photo's star count. * fix(api): keep the new route in the generated OpenAPI spec The `color_labels` description carried an inline JSON example. In an unquoted YAML scalar `{ "green": 2 }` parses as a flow mapping, so swagger-jsdoc threw YAMLSemanticError and dropped the WHOLE route from the spec — visible only as a warning on boot, with the route still serving normally, which is exactly the kind of failure that survives to release. Found by booting a real instance rather than by reading the diff. * fix(api): close the four blockers from review on #1165 1. Replacing an external photo silently kept serving the old file. resolvePhotoStorageKey gives photo.source_origin precedence and returns null for 'reference'/'external', so the edit was uploaded, the row updated and 200 returned while every viewer kept getting the untouched NAS original and the upload sat orphaned. replacePhoto now repoints the row to managed and clears external_relpath. The file on the share is never touched — this moves the pointer, not the data. 2. Every replacement leaked its temp file. putFromFile COPIES on local and uploads on S3; neither consumes the source, and replacePhoto never unlinked it — while the v1 route had disabled its own cleanup on the belief that replacePhoto moved the file. Cleanup now lives in replacePhoto, which closes the admin path too (adminPhotos only unlinks in its new-files branch, so replaced files leaked there as well). The v1 route also unlinks on the FAILURE path, which returned before any cleanup ran. 3. The download-all ZIP is invalidated after a replacement, as adminPhotos.js already does. Without it guests kept downloading the pre-edit photo indefinitely, which defeats the point of the feature. 4. The round-trip could not see reference or watcher galleries at all. fileWatcher and adminExternalMedia never set original_filename — the camera name lives in `filename` for those rows — so the backfill and the GET fallback both produced NULL for exactly the galleries most likely to be driven from Lightroom. The backfill now COALESCEs, both ingest paths set source_filename, and the GET falls back to filename. Concerns: - number_token no longer reads every photo row in the event per file. A LIKE on the digit run narrows the candidate set in SQL first; the exact trailing-run check still decides, so semantics are unchanged. The token is a regex-extracted digit run, so it cannot carry a wildcard. - The replacement's activity entry is scoped to event.id instead of null. The dashboard feed excludes NULL-event rows for scoped callers (GHSA-jhcf), so it was vanishing from the audit trail of the photographer who owns the event. Nit: dropped the unused higherPriorityColor export from markMerge. Three regression tests cover the external repoint, the temp cleanup and the COALESCE backfill. 21/21 pass. * chore(migrations): renumber 185 -> 193 after gallery-folders landed 185_add_category_is_folder.js merged to main while this was in review, so the number the PR reserved is taken and main is now at 192. Knex keys on filename rather than the prefix, so both would have run — but picpeakImportService guards restores with migrationOrder(), which parses that prefix, and two files answering 185 make the forward-only check pass a backup onto a schema missing its columns. Renumbered with every reference: the header comment, the test that requires the path, and the four call-site comments that cite it. The 'migration 182' reference inside it is the colour-labels migration and is unrelated; gallery.js:1134 cites upstream's 185 and is untouched. * fix(api): keep external_relpath when a replacement converts the row The external-photo blocker fix cleared external_relpath along with flipping source_origin, which closed one hole and opened another. adminExternalMedia dedupes a re-scan on (event_id, external_relpath) — routes/adminExternalMedia.js:195 — and migration 186 puts a unique index on exactly that pair. With the column nulled, the next scan of the share would not recognise the NAS original as already imported and would insert it again, so the gallery would end up holding both the edit and a fresh copy of the file it replaced. Only source_origin needs to change: it is what resolvePhotoStorageKey keys on, and every other consumer of external_relpath reads the two together and lets source_origin decide. The stale relpath on a managed row is inert for resolution and still correct as a dedupe key. Test updated to assert the value is kept rather than cleared. * fix(uploads): say when exiftool is missing instead of blaming the RAW A server without exiftool reported `No usable embedded preview in RAW file X.CR3: spawn exiftool ENOENT` for every RAW upload. The headline describes a corrupt photo; the actual cause is a package that was never installed, demoted to a trailing detail. It sends people hunting through their camera files. Hit while testing the Lightroom round-trip (#745): an export of RAW originals failed 11 times with that message, and the file was fine. RAW upload is the only feature that needs exiftool, so an install can be missing it indefinitely and only find out when someone uploads a CR3 — which makes the wording the whole diagnosis. ENOENT now produces a message naming the dependency and the install command for Debian/Alpine/macOS, and breaks out of the tag loop instead of spawning the same missing binary twice more to report the last failure as if it described the photo. A genuinely preview-less RAW still gets the original message. Verified both paths by making exiftool unreachable via PATH rather than mocking: missing tool and unreadable file now report differently. * fix(external): a delivered edit must win a relpath-fold collision Follow-up to keeping external_relpath on a replaced photo. Keeping it is what lets adminExternalMedia still dedupe the folder re-scan, but it also leaves the row inside externalRelpathFold's sweep — and that sweep does not merely rewrite paths, it DELETES collision losers via externalPhotoDedupe. The survivor was whichever row happened to be claimed first, which is iteration order. So a replaced photo — source_origin 'managed', holding the edit the photographer just delivered — could be deleted in favour of the untouched camera original sitting next to it on the share. Managed rows now claim first and therefore survive. The external row that loses is the recoverable one: it is still on the share and a re-scan re-imports it. The edit is not recoverable. Note this is deliberately NOT the "skip managed rows in the fold" shape suggested in review. Skipping would leave those rows holding a base-relative path while every other row moved to root-relative, so the scanner — which computes root-relative — would stop matching them and import the camera original again as a duplicate. That is the exact bug keeping external_relpath exists to prevent, reintroduced through a different door. Rebasing them and protecting them from deletion keeps both properties.
This commit is contained in:
@@ -169,7 +169,7 @@ async function foldExternalRelpaths(knex, log = () => {}) {
|
||||
const rows = await knex('photos')
|
||||
.where('event_id', eventId)
|
||||
.whereNotNull('external_relpath')
|
||||
.select('id', 'external_relpath', 'size_bytes');
|
||||
.select('id', 'external_relpath', 'size_bytes', 'source_origin');
|
||||
|
||||
// Deciding health from a SAMPLE was the tempting shortcut and it is not
|
||||
// safe: a rebased event whose first few rows happen to come from the most
|
||||
@@ -227,7 +227,24 @@ async function foldExternalRelpaths(knex, log = () => {}) {
|
||||
const claimed = new Map();
|
||||
const resolved = [];
|
||||
const losers = new Map();
|
||||
for (const [row, chosen] of placements) {
|
||||
|
||||
// A replaced photo keeps its external_relpath so the folder re-scan can
|
||||
// still dedupe on it (#745), but its source_origin is now 'managed' and
|
||||
// its file is the edit the photographer delivered. Collisions here DELETE
|
||||
// the loser, and the survivor was whichever row happened to be claimed
|
||||
// first — so a delivered edit could be destroyed in favour of the
|
||||
// untouched camera original sitting next to it on the share.
|
||||
//
|
||||
// Managed rows claim first, which makes them the survivor in any
|
||||
// collision. The external row that loses is the recoverable one: it is
|
||||
// still on the share and a re-scan re-imports it. The edit is not.
|
||||
const claimOrder = placements.slice().sort((a, b) => {
|
||||
const aManaged = a[0].source_origin === 'managed' ? 0 : 1;
|
||||
const bManaged = b[0].source_origin === 'managed' ? 0 : 1;
|
||||
return aManaged - bManaged;
|
||||
});
|
||||
|
||||
for (const [row, chosen] of claimOrder) {
|
||||
const next = chosen ? `${chosen}/${row.external_relpath}` : row.external_relpath;
|
||||
const winner = claimed.get(next);
|
||||
if (winner != null) { losers.set(row.id, winner); collided++; continue; }
|
||||
|
||||
@@ -136,6 +136,10 @@ async function processNewPhoto(filePath) {
|
||||
const insertResult = await db('photos').insert({
|
||||
event_id: event.id,
|
||||
filename: path.basename(filePath),
|
||||
// The camera-original name. This path never sets original_filename, so
|
||||
// without this the Lightroom round-trip (#745) has nothing to match a
|
||||
// RAW against for auto-imported galleries.
|
||||
source_filename: path.basename(filePath),
|
||||
path: relativePath,
|
||||
thumbnail_path: relativeThumbPath,
|
||||
type: isVideo ? 'video' : photoType,
|
||||
|
||||
@@ -60,9 +60,31 @@ async function extractRawPreview(rawPath) {
|
||||
}
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
// exiftool missing is a DEPLOYMENT fault, not a bad file, and it fails
|
||||
// identically for every tag — so stop rather than retrying the same
|
||||
// spawn twice more and reporting the last one as if it described the
|
||||
// photo.
|
||||
if (err && err.code === 'ENOENT') break;
|
||||
}
|
||||
}
|
||||
await fsp.rm(outDir, { recursive: true, force: true }).catch(() => {});
|
||||
|
||||
// Distinguish "the tool isn't installed" from "this file has no preview".
|
||||
// Both used to surface as `No usable embedded preview in RAW file X:
|
||||
// spawn exiftool ENOENT`, which reads as a corrupt photo and sends people
|
||||
// hunting through their RAWs instead of installing a package. RAW upload is
|
||||
// the only feature that needs exiftool, so an install can be missing it and
|
||||
// not find out until someone uploads a CR3.
|
||||
if (lastErr && lastErr.code === 'ENOENT') {
|
||||
throw new Error(
|
||||
'exiftool is not installed on the server, and it is required to read '
|
||||
+ `RAW files (${path.basename(rawPath)}). Install it (Debian/Ubuntu: `
|
||||
+ 'apt-get install libimage-exiftool-perl, Alpine: apk add exiftool, '
|
||||
+ 'macOS: brew install exiftool) and retry. JPEG and other ordinary '
|
||||
+ 'images do not need it.'
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`No usable embedded preview in RAW file ${path.basename(rawPath)}: ${lastErr ? lastErr.message : 'no preview tag returned data'}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Merging several proofing verdicts into the single value a desktop
|
||||
* cataloguer can apply (Lightroom round-trip, #745).
|
||||
*
|
||||
* A photo can carry three different opinions at once: the guest colour
|
||||
* tallies in `photo_feedback`, the guest star average denormalized onto
|
||||
* `photos.average_rating`, and the photographer's own triage in
|
||||
* `photo_admin_marks`. Lightroom has room for exactly one colour label and
|
||||
* one star rating per photo, so something has to decide.
|
||||
*
|
||||
* This is that decision, in one place, so the API response, the XMP export
|
||||
* and the plugin can never drift apart on it.
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* Collapse a guest star average to Lightroom's 0-5 integer scale.
|
||||
*
|
||||
* Deliberately identical to XmpGenerator.mapRating so a photo exported as an
|
||||
* XMP sidecar and the same photo fetched through the v1 API never disagree
|
||||
* about how many stars it has. Note the last branch: any non-zero average
|
||||
* below 1.5 becomes 1 star, not 0 — "somebody rated this" and "nobody rated
|
||||
* this" must stay distinguishable.
|
||||
*/
|
||||
function roundRating(avgRating) {
|
||||
const value = parseFloat(avgRating);
|
||||
if (!value || Number.isNaN(value)) return 0;
|
||||
if (value >= 4.5) return 5;
|
||||
if (value >= 3.5) return 4;
|
||||
if (value >= 2.5) return 3;
|
||||
if (value >= 1.5) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one photo's marks down to a single colour and rating.
|
||||
*
|
||||
* @param {Object} photo - row enriched by photoExportService.getPhotosWithFeedback
|
||||
* @param {'client'|'mine'|'either'} markSource
|
||||
* @returns {{ color_label: string|null, rating: number|null }}
|
||||
*/
|
||||
function mergeMarks(photo, markSource = 'either') {
|
||||
if (!photo) return { color_label: null, rating: null };
|
||||
|
||||
const clientColor = photo.dominant_color_label || null;
|
||||
const clientRating = roundRating(photo.average_rating);
|
||||
const myColor = photo.my_color_label || null;
|
||||
const myRating = Number(photo.my_rating) || 0;
|
||||
|
||||
if (markSource === 'client') {
|
||||
return {
|
||||
color_label: clientColor,
|
||||
rating: clientRating || null,
|
||||
};
|
||||
}
|
||||
|
||||
if (markSource === 'mine') {
|
||||
return {
|
||||
color_label: myColor,
|
||||
rating: myRating || null,
|
||||
};
|
||||
}
|
||||
|
||||
// 'either' — the photographer's own mark wins the colour. It is one
|
||||
// person's deliberate triage rather than an aggregate that a tie-break
|
||||
// already had to guess at, so it is the stronger signal. Stars take the
|
||||
// max instead: a rating is a magnitude, and losing the higher of the two
|
||||
// would quietly demote a photo somebody rated highly.
|
||||
return {
|
||||
color_label: myColor || clientColor,
|
||||
rating: Math.max(myRating, clientRating) || null,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
mergeMarks,
|
||||
roundRating,
|
||||
};
|
||||
@@ -33,6 +33,9 @@ class PhotoExportService {
|
||||
'photos.id',
|
||||
'photos.filename',
|
||||
'photos.original_filename',
|
||||
// Camera-original name, preserved across replaces (migration 193) so
|
||||
// the Lightroom round-trip can still match after a re-upload (#745).
|
||||
'photos.source_filename',
|
||||
'photos.path',
|
||||
'photos.average_rating',
|
||||
'photos.feedback_count',
|
||||
|
||||
@@ -21,15 +21,102 @@ const { resolvePhotoStorageKey } = require('./photoResolver');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* Find a replacement candidate by matching original_filename (case-insensitive).
|
||||
* Returns the photo row if exactly one match, { ambiguous: true, count } if multiple, or null.
|
||||
* The trailing digit run of a filename stem, e.g. `Smith_Wedding_11234.jpg`
|
||||
* -> `11234`. Used by the `number_token` match mode below.
|
||||
*
|
||||
* Deliberately the LONGEST trailing run and never a fixed last-N slice.
|
||||
* Multi-camera shoots disambiguate by prefixing the camera index into the
|
||||
* number (`cam11234.jpg`, `cam21234.jpg`); a last-4 slice reads `1234` from
|
||||
* both bodies and reintroduces exactly the collision the prefix removes.
|
||||
*
|
||||
* @param {string} filename
|
||||
* @returns {string|null} the digit run, or null when there is none
|
||||
*/
|
||||
async function findReplacementCandidate(eventId, originalFilename) {
|
||||
function trailingDigitRun(filename) {
|
||||
if (!filename) return null;
|
||||
const stem = String(filename).replace(/\.[^.]+$/, '');
|
||||
const match = stem.match(/(\d+)$/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a replacement candidate.
|
||||
*
|
||||
* Two modes:
|
||||
*
|
||||
* - `exact` (default, unchanged behaviour): case-insensitive match on the
|
||||
* name the photo was uploaded under.
|
||||
* - `number_token` (#745): match on the trailing digit run instead, so an
|
||||
* editor who renamed `IMG_1234.JPG` to `Smith_Wedding_1234.jpg` in
|
||||
* Lightroom still lands on the right photo. Opt-in, because a digit run is
|
||||
* a much weaker key than a filename.
|
||||
*
|
||||
* Both modes prefer `source_filename` (the camera-original name, preserved
|
||||
* across replaces by migration 193) and fall back to `original_filename` for
|
||||
* rows that predate it.
|
||||
*
|
||||
* Returns the photo row on exactly one match, `{ ambiguous: true, count }`
|
||||
* when several match, or null. Ambiguity is never resolved by guessing — the
|
||||
* caller uploads the file as new rather than overwriting the wrong photo.
|
||||
*
|
||||
* @param {number} eventId
|
||||
* @param {string} originalFilename
|
||||
* @param {Object} [opts]
|
||||
* @param {'exact'|'number_token'} [opts.matchMode='exact']
|
||||
*/
|
||||
async function findReplacementCandidate(eventId, originalFilename, opts = {}) {
|
||||
if (!originalFilename) return null;
|
||||
const { matchMode = 'exact' } = opts;
|
||||
|
||||
if (matchMode === 'number_token') {
|
||||
const token = trailingDigitRun(originalFilename);
|
||||
if (!token) return null;
|
||||
|
||||
// The token has to be compared against the STEM of the stored name, not
|
||||
// the whole string, so `IMG_1234.JPG` yields `1234` on both sides. That
|
||||
// comparison stays in JS — expressing it in SQL across two engines is
|
||||
// more trouble than it is worth — but the CANDIDATE SET is narrowed in
|
||||
// SQL first.
|
||||
//
|
||||
// Without the LIKE this read every photo row in the event, once per
|
||||
// uploaded file, and twice per file when a photo cap is configured. At
|
||||
// the 2000-file upload limit against a 5000-photo event that is up to 20M
|
||||
// rows before any image processing starts. The token is a digit run
|
||||
// extracted by regex, so it is safe to interpolate and cannot carry a
|
||||
// LIKE wildcard.
|
||||
//
|
||||
// The LIKE over-matches on purpose (it ignores position and extension);
|
||||
// the exact trailing-run check below is still what decides, so semantics
|
||||
// are unchanged and only the row count drops.
|
||||
const pattern = '%' + token + '%';
|
||||
const rows = await db('photos')
|
||||
.where({ event_id: eventId })
|
||||
.where(function () {
|
||||
this.where('source_filename', 'like', pattern)
|
||||
.orWhere('original_filename', 'like', pattern);
|
||||
})
|
||||
.select('id', 'source_filename', 'original_filename');
|
||||
|
||||
const matches = rows.filter((row) => {
|
||||
const stored = row.source_filename || row.original_filename;
|
||||
return stored && trailingDigitRun(stored) === token;
|
||||
});
|
||||
|
||||
if (matches.length > 1) return { ambiguous: true, count: matches.length };
|
||||
if (matches.length === 1) {
|
||||
// Re-read the full row: replacePhoto() needs the columns the narrowed
|
||||
// select above deliberately skipped (type, filename, source_filename).
|
||||
return db('photos').where({ id: matches[0].id }).first();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const matches = await db('photos')
|
||||
.where({ event_id: eventId })
|
||||
.whereRaw('LOWER(original_filename) = LOWER(?)', [originalFilename]);
|
||||
.where(function () {
|
||||
this.whereRaw('LOWER(original_filename) = LOWER(?)', [originalFilename])
|
||||
.orWhereRaw('LOWER(source_filename) = LOWER(?)', [originalFilename]);
|
||||
});
|
||||
|
||||
if (matches.length === 1) return matches[0];
|
||||
if (matches.length > 1) return { ambiguous: true, count: matches.length };
|
||||
@@ -113,14 +200,29 @@ async function replacePhoto(existingPhoto, newFileTempPath, { originalFilename,
|
||||
// Ignore — watermark may not exist
|
||||
}
|
||||
|
||||
// Upload the new original.
|
||||
// Upload the new original. `putFromFile` COPIES (LocalFsStorage) or
|
||||
// uploads (S3) — neither consumes the source, and this function used to
|
||||
// leave it behind. The v1 route additionally stops its own cleanup on the
|
||||
// (wrong) assumption that this moved the file, so every replacement
|
||||
// stranded up to 100 MB in storage/temp. Cleaning up here closes the v1
|
||||
// and the admin path at once: adminPhotos only unlinks in its
|
||||
// new-files branch, so replaced files leaked there too.
|
||||
await storage.putFromFile(finalKey, newFileTempPath, { contentType: mimeType });
|
||||
await fsp.unlink(newFileTempPath).catch(() => {});
|
||||
|
||||
// Update DB record — preserve id, event_id, category_id, type, visibility,
|
||||
// uploaded_at, sort_order, feedback counts, view/download counts
|
||||
const updates = {
|
||||
filename: newFilename,
|
||||
original_filename: originalFilename,
|
||||
// source_filename is deliberately NOT in this list. It holds the
|
||||
// camera-original name and must survive a replace, otherwise the
|
||||
// Lightroom round-trip (#745) loses its match key the first time an
|
||||
// editor uploads a renamed render over the proof. Backfilled here only
|
||||
// when the row predates migration 193 and has nothing stored yet.
|
||||
...(existingPhoto.source_filename
|
||||
? {}
|
||||
: { source_filename: existingPhoto.original_filename || originalFilename }),
|
||||
path: relativePath,
|
||||
thumbnail_path: thumbnailPath,
|
||||
size_bytes: stats.size,
|
||||
@@ -129,6 +231,25 @@ async function replacePhoto(existingPhoto, newFileTempPath, { originalFilename,
|
||||
captured_at: capturedAt,
|
||||
mime_type: mimeType,
|
||||
media_type: mimeType?.startsWith('video/') ? 'video' : 'image',
|
||||
// The replacement lives in the managed backend, so the row has to say
|
||||
// so. resolvePhotoStorageKey gives photo.source_origin precedence over
|
||||
// everything and returns null for 'reference'/'external' — so leaving
|
||||
// this set meant the new file was stored and recorded while every
|
||||
// viewer kept being served the untouched NAS original, with the upload
|
||||
// orphaned and the API reporting success.
|
||||
//
|
||||
// external_relpath is deliberately KEPT. It is no longer used to
|
||||
// resolve the photo — source_origin decides that, and every other
|
||||
// consumer reads the two together — but it is still the key
|
||||
// adminExternalMedia dedupes on when the folder is re-scanned
|
||||
// (`where({ event_id, external_relpath })`) and the column the unique
|
||||
// index from migration 186 covers. Clearing it would make the next scan
|
||||
// treat the NAS original as a new file and import a duplicate
|
||||
// alongside the photo that just replaced it.
|
||||
//
|
||||
// The external file itself is never touched: this repoints the row, it
|
||||
// does not delete or move anything on the share.
|
||||
source_origin: 'managed',
|
||||
};
|
||||
|
||||
// Face data (#1074): the row keeps its id but now points at a DIFFERENT
|
||||
@@ -173,4 +294,4 @@ async function replacePhoto(existingPhoto, newFileTempPath, { originalFilename,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { findReplacementCandidate, replacePhoto };
|
||||
module.exports = { trailingDigitRun, findReplacementCandidate, replacePhoto };
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
const { COLOR_LABEL_TO_XMP, dominantColorLabel } = require('../constants/colorLabels');
|
||||
const { roundRating } = require('./markMerge');
|
||||
|
||||
class XmpGenerator {
|
||||
/**
|
||||
@@ -50,12 +51,10 @@ class XmpGenerator {
|
||||
* @returns {number} XMP rating (0-5, integer)
|
||||
*/
|
||||
mapRating(avgRating) {
|
||||
if (!avgRating || avgRating === 0) return 0;
|
||||
if (avgRating >= 4.5) return 5;
|
||||
if (avgRating >= 3.5) return 4;
|
||||
if (avgRating >= 2.5) return 3;
|
||||
if (avgRating >= 1.5) return 2;
|
||||
return 1;
|
||||
// Delegates to markMerge.roundRating (#745) so the sidecar and the v1 API
|
||||
// can never disagree about how many stars a photo has. The thresholds
|
||||
// used to live here; they moved rather than being copied.
|
||||
return roundRating(avgRating);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user