TheRare Visuals

The shoot that came before the booking: moving media without moving bytes

Files used to live only inside a booking, so a shoot done before its booking existed meant downloading gigabytes and uploading them again. A media library and a move that changes one database row, and everything that must not follow a file to its new owner.

TheRare Engineering15 October 202610 min readIntermediate

The problem

Every photo and video on our platform lived inside a booking. That sounds reasonable until real life gets in the way.

Sometimes we shoot first and do the paperwork later: a friend of a client asks for a quick portrait session on the spot, a brand shoot is agreed over the phone, or a customer pays in person and their account only gets created days afterwards. The files are ready, the editor wants to start, and there's no booking to put them in.

So we did what you'd expect. The files went into a stand-in booking, and when the real booking existed, someone downloaded everything and uploaded it again. For a few photos that's annoying. For a shoot with raw video it means pulling several gigabytes down to a laptop and pushing them back up over the same connection, then waiting for every video to be encoded again (Part 10 covers what encoding costs us). And while a file sat in the wrong booking, it was one wrong click away from showing up in the wrong customer's gallery.

This post is about the fix: a studio media library for files that don't have a home yet, and a move that takes files from one booking to another. The interesting part is that neither of them copies a single byte.

The idea: the file doesn't move, the row does

In Part 5 we described how uploads work. The browser sends the file straight to a private storage bucket, and the database gets a row that points at it:

TEXT
media_assets
  id           4812
  booking_id   312
  customer_id  97
  object_key   bookings/312/7c1e…-IMG_0042.jpg
  file_name    IMG_0042.jpg

The object_key is the file's address in storage. Everything that decides who can see the file lives on the row: its booking, its customer, whether it's locked behind the paywall. The storage object itself knows nothing about any of that.

So moving a file between bookings doesn't need storage at all. Change booking_id and customer_id, and the file now belongs to the other booking. A 3 GB video moves as fast as a 3 KB thumbnail, because both are one UPDATE.

The obvious objection: the key still says bookings/312/… after the file has moved to booking 540. Doesn't that matter?

Only if something reads meaning into the path. We checked every place that might:

  • Access checks go through the row, never the path. A signed URL is only issued after the row says this viewer may see this file.
  • Deleting a booking removes its files row by row: it loads the booking's rows and deletes each row's object. It never does "delete everything under bookings/312/". If it did, deleting the old stand-in booking would wipe a file that now belongs to someone else.
  • Renaming a file already worked this way. The display name changes, the key doesn't.

That second point is the one to check in your own system before trying this. If any cleanup job deletes by prefix, moving rows without moving objects will eventually delete live files. Ours didn't, so the key is just an internal address, like an inode number, and a slightly misleading folder name in a bucket nobody browses is a price we're happy to pay.

The media library

The library is a holding area with folders. Each folder has a name and a free-text note for whatever will help match it to a booking later: a phone number, a shoot date, "the café portraits on Saturday".

SQL
CREATE TABLE library_folders (
    id         BIGSERIAL PRIMARY KEY,
    name       VARCHAR(150) NOT NULL,
    note       VARCHAR(1000),
    created_at TIMESTAMP NOT NULL
);
CREATE UNIQUE INDEX uq_library_folders_name ON library_folders(lower(name));

CREATE TABLE library_assets (
    id            BIGSERIAL PRIMARY KEY,
    folder_id     BIGINT NOT NULL REFERENCES library_folders(id) ON DELETE CASCADE,
    object_key    VARCHAR(1024) NOT NULL UNIQUE,
    file_name     VARCHAR(255),
    content_type  VARCHAR(255),
    file_size_bytes BIGINT,
    thumbnail_object_key VARCHAR(1024),
    uploaded_at   TIMESTAMP NOT NULL
);

Uploads into the library use the same resumable, direct-to-storage queue as deliverables, so a dropped connection halfway through a 4 GB video resumes rather than restarting. Library files go under their own library/{folderId}/… prefix.

When the booking exists, an admin selects files and picks Move to booking, with a destination: Deliverables (what the customer receives), Raw footage (what the customer can browse and pick from), or Editor source (raw files only the editing team sees).

The move creates the booking's row from the library row and deletes the library row. The object key is handed over untouched:

Java
MediaAsset media = mediaAssetRepository.save(MediaAsset.builder()
        .booking(booking)
        .customer(booking.getCustomer())
        .objectKey(asset.getObjectKey())          // same bytes, new owner
        .thumbnailObjectKey(asset.getThumbnailObjectKey())
        .fileName(name)
        .locked(locked)
        .build());
libraryAssetRepository.delete(asset);

All of this runs in one transaction. Either the file now belongs to the booking, or it's still in the library. There's no moment where it belongs to both, or to neither.

The cleanup job had to learn about the library

Part 5 described our orphan sweeper. When the browser asks for an upload URL, we write a small "pending upload" record. Once a day, any pending upload older than 24 hours whose key isn't referenced by a real row is assumed abandoned, and its object is deleted.

A new table that holds object keys is a new place a file can be "referenced". If the sweeper doesn't know about it, every library upload looks abandoned after a day and gets deleted. So the check grew one line:

Java
private boolean isReferenced(String objectKey) {
    return mediaAssetRepository.existsByObjectKey(objectKey)
            || rawFootageAssetRepository.existsByObjectKey(objectKey)
            // … proofs, attachments, shoot-day photos …
            || libraryAssetRepository.existsByObjectKey(objectKey);
}

It's the easiest line in the feature to forget and the most expensive one to miss, which is why the sweeper's class comment now says, in so many words, that any new table holding object keys must be added here.

Lesson. When a background job decides what's garbage by looking for references, every new kind of reference has to be taught to it. Write that rule down next to the job, not in someone's memory.

Moving between bookings: what changes with the owner

Library files belong to nobody yet, so moving them is simple. Moving a file from one booking to another is where the real thinking went, because a file picks up history while it sits in a booking. Some of that history belongs to the file. Some of it belongs to the old customer, and must not follow the file into a stranger's gallery.

We went through every table that points at a media file and sorted it:

Tied to the fileWhat happens on a moveWhy
The stored originalKeptIt's the same photo or video
ThumbnailKeptMade from the pixels, no identity in it
Clean HLS streamKeptSame pixels, no identity, and it's the expensive part to rebuild
Watermarked preview stream and imageDeleted and rebuiltThe customer's id is burned into the frames (Part 6)
Feedback comments and read markersRemovedA conversation with the old customer
Gallery foldersRemovedPart of the old booking's organisation
Share-link entriesRemovedThe old booking's guests would keep seeing it
Raw footage edit selections and trimmed clipsRemoved (clip files deleted too)Choices the old customer made
Paywall lockRecomputedDepends on the new booking's payment

The watermark row is the interesting one. Our previews for unpaid customers aren't watermarked with just a logo: they carry the customer's own id, drifting across the frame, so a leaked preview can be traced back to the account it came from. Move that file to another customer without rebuilding the preview and you'd be showing customer B a preview stamped with customer A's id. Worse, if B leaked it, it would point at A.

So the move checks whether the customer changes, and only then throws the previews away:

Java
boolean customerChanged = media.getCustomer() == null
        || !media.getCustomer().getId().equals(newCustomer.getId());
if (customerChanged) {
    hlsStreamService.deleteStream(media.getObjectKey(), Flavour.PREVIEW);
    previewImageService.deletePreview(media.getObjectKey());
    media.setHlsPreviewMasterKey(null);
    media.setPreviewImageKey(null);
    // … and the matching error / queued-at columns
}

If the file is still locked in its new booking, the new customer's preview is queued right away, after the transaction commits, so it's ready before they open their gallery. The clean stream, which has no identity in it, is kept. For a video that's most of the encoding cost saved.

The old conversation goes too. It's tempting to keep comments "for history", but a comment like "can you brighten my sister's face" makes no sense, and leaks something, in another family's gallery. The audit log keeps the history instead: every move records who moved which file, from which booking, to which.

Lesson. "Move" sounds like one operation, but it's really a question asked of every table that points at the thing: does this belong to the object, or to its old owner? Make the list explicitly. The rows you forget are the ones that leak.

The name clash that would have overwritten a file

The bug we almost shipped was about file names.

On our platform, uploading a file whose name already exists in a booking replaces the old file. That's deliberate: an editor re-exports IMG_0042.jpg with a colour fix, uploads it, and the customer sees the new version in the same spot.

Now move IMG_0042.jpg into a booking that already has an IMG_0042.jpg. Cameras reuse names all the time, so this isn't rare. The move itself works: two rows, two different objects. But the booking now has two files with the same name, and the next time the editor uploads that name, the "replace" lookup picks whichever row comes back first. One of two different photos gets overwritten, and nobody can say in advance which.

The fix is to never let a move create a clash. A moved file that would collide gets a suffix, the way a desktop does when you copy a file into a folder:

Java
static String unique(String name, Predicate<String> taken) {
    if (!taken.test(name)) return name;
    int dot = name.lastIndexOf('.');
    String stem = dot > 0 ? name.substring(0, dot) : name;
    String ext  = dot > 0 ? name.substring(dot) : "";
    for (int i = 2; i < 10_000; i++) {
        String candidate = stem + " (" + i + ")" + ext;
        if (!taken.test(candidate)) return candidate;
    }
    return stem + " (" + System.currentTimeMillis() + ")" + ext;
}

The lookup is case-insensitive, because the replace lookup is too: img_0042.JPG and IMG_0042.jpg are the same name as far as overwriting goes. The response lists every rename (IMG_0042.jpg -> IMG_0042 (2).jpg), so the admin isn't surprised later.

Lesson. When a new feature can put data into a state the old code never had to handle, look at what the old code does in that state. Here, "two files with one name" was impossible before moves existed, and the upload code quietly relied on that.

The rules we kept small

A few decisions kept the feature from growing:

  • Deliverables need a customer. A booking created from a phone inquiry may not be linked to an account yet. Deliverables are tied to a customer, so moving them there is refused with a clear message. Raw footage belongs to the booking alone, so it can go in straight away.
  • A file keeps its section. Deliverables move as deliverables, raw footage as raw footage, and raw footage keeps its audience (client or editor-only). Turning a raw clip into a deliverable is a different decision, with different rules about who sees it, so it isn't a side effect of a move.
  • Only files from the source booking. The request names a source booking and file ids. Any id that isn't actually in that booking is skipped and reported, so a stale browser tab can't move a file it shouldn't be able to see.
  • One transaction per move. If anything fails, nothing moved.

What we'd do differently

Two gaps are still open:

  • No way back to the library. If a file was moved to the wrong booking, you move it again to the right one, which works. But you can't park it back in the library while you work out where it belongs.
  • A move can't change a file's section. That was deliberate (see above), but it means "these raw clips are actually the final edits" is still a download and an upload.

Takeaways

  • If access is decided by a database row, moving data can be a row update. Don't copy bytes you don't have to.
  • Before doing that, check that nothing cleans up storage by path or prefix. Delete row by row, and an object key becomes just an address.
  • A reference-counting cleanup job must know about every table that holds references. Write the rule beside the job.
  • Treat "move" as a question for every table that points at the thing: does this belong to the object, or to its owner?
  • Anything that encodes identity, like a traceable watermark, has to be rebuilt when ownership changes.
  • New features can create states old code assumed were impossible. Look for them before users do.
  • Do the move in one transaction, and record it in the audit log. History belongs there, not in the new owner's view.

Found this useful?

Built something with what you learned here, or spotted a better way? Tell us. We work with freelance developers on Shootlo.