immich-app/immich · warning · BadRequestException

Asset not in stack

Error message

Asset not in stack

What it means

StackService.removeAsset() looks up the stack containing the given assetId via stackRepository.getForAssetRemoval(assetId). If no stack is found, or the found stack's id differs from the requested stackId, it throws BadRequestException 'Asset not in stack' (stack.service.ts:70, HTTP 400).

Source

Thrown at server/src/services/stack.service.ts:70

    await this.requireAccess({ auth, permission: Permission.StackDelete, ids: [id] });
    await this.stackRepository.delete(id);
    await this.eventRepository.emit('StackDelete', { stackId: id, userId: auth.user.id });
  }

  async deleteAll(auth: AuthDto, dto: BulkIdsDto): Promise<void> {
    await this.requireAccess({ auth, permission: Permission.StackDelete, ids: dto.ids });
    await this.stackRepository.deleteAll(dto.ids);
    await this.eventRepository.emit('StackDeleteAll', { stackIds: dto.ids, userId: auth.user.id });
  }

  async removeAsset(auth: AuthDto, dto: UUIDAssetIDParamDto): Promise<void> {
    const { id: stackId, assetId } = dto;
    await this.requireAccess({ auth, permission: Permission.StackUpdate, ids: [stackId] });

    const stack = await this.stackRepository.getForAssetRemoval(assetId);

    if (!stack?.id || stack.id !== stackId) {
      throw new BadRequestException('Asset not in stack');
    }

    if (stack.primaryAssetId === assetId) {
      throw new BadRequestException("Cannot remove stack's primary asset");
    }

    await this.assetRepository.update({ id: assetId, stackId: null });
    await this.eventRepository.emit('StackUpdate', { stackId, userId: auth.user.id });
  }

  private findOrFail(id: string) {
    return findOrFail(() => this.stackRepository.getById(id), 'Asset stack');
  }
}

View on GitHub (pinned to 199723261c)

Solutions

  1. Verify the asset still belongs to the stack before issuing removeAsset (fetch the stack).
  2. Treat this error as a benign no-op on the client if the asset is already gone.
  3. Ensure the stackId in the path matches the asset's current stack.

Example fix

// before - stale ids
await removeAsset({ id: oldStackId, assetId });
// after - confirm current membership
const stack = await getStackForAsset(assetId);
if (stack?.id) await removeAsset({ id: stack.id, assetId });
Defensive patterns

Strategy: validation

Validate before calling

const stack = await findStackForAsset(assetId);
if (!stack || stack.id !== stackId) {
  // asset is not in this stack; treat as already-removed
  return;
}
await stackApi.removeAsset({ id: stackId, assetId });

Type guard

const assetIsInStack = (assetId: string, stackId: string, stack: { id: string; assets: { id: string }[] } | null): boolean =>
  !!stack && stack.id === stackId && stack.assets.some((a) => a.id === assetId);

Try / catch

try {
  await stackApi.removeAsset({ id: stackId, assetId });
} catch (e) {
  if (e instanceof BadRequestException && /not in stack/i.test(e.message)) {
    // benign: already removed or belongs elsewhere
    return;
  } else throw e;
}

Prevention

When it happens

Trigger: DELETE /stacks/:id/assets/:assetId where the asset is not a member of that stack (it belongs to another stack or to none). The permission check for StackUpdate on stackId passes first.

Common situations: Client showing stale membership after a concurrent change, removing an asset that was already removed, or a URL/route mismatch between stackId and the asset's actual stack.

Related errors


AI-assisted analysis of immich-app/immich@199723261c (2026-08-12). Data as JSON: /api/errors/de50fa3fb471cf54. Report an issue: GitHub.